CentraPoint

Guides

Webhooks

Receive signed HTTPS notifications in your own systems when a payment's status changes.

On this page

Overview#

Webhooks let CentraPoint push payment updates to your systems: when a payment completes, fails, is cancelled or is refunded, CentraPoint sends a signed JSON POST to the HTTPS endpoints you configure. This is the recommended way to learn about payment outcomes. Keep the Transactions API as a fallback for reconciliation and for payments you have not heard about.

Setting up an endpoint#

  1. In the dashboard, go to Settings → Webhooks (administrators only; requires a plan that includes the REST API).
  2. Under Add endpoint, enter your HTTPS URL, an optional description, and the events to receive. Selecting no events subscribes to all of them.
  3. Copy the signing secret (whsec_…). It is shown only once; store it like a password.
  4. Click Send test event to check your receiver.

You can have up to 5 endpoints per organisation. Each can be disabled, deleted, or have its secret rotated, and you can change which events it receives at any time without re-creating it, so its URL and signing secret stay the same. Selecting no events, or all of them, subscribes the endpoint to every event, including ones added in future. The page also shows a delivery log with each attempt's status and a Resend button.

Endpoint requirements#

  • HTTPS only, on port 443 or 8443.
  • No credentials in the URL (https://user:pass@… is rejected).
  • The host must resolve to a public internet address. Private, loopback, link-local, carrier-grade NAT and similar ranges are rejected. The check runs when you save the endpoint and again before every delivery.
  • Redirects are not followed. A 3xx response counts as a failed delivery, so configure the final URL.

Events#

Webhook events
EventSent when
payment.completecomplete: money received and verified. Fulfil the order.
payment.failedfailed: declined, rejected, or a debit order returned unpaid.
payment.cancelledcancelled: the payer cancelled or abandoned checkout.
payment.refundedrefunded: fully refunded.
payment.partially_refundedpartially_refunded: part of the amount refunded.
invoice.paidAn invoice is fully paid (through a gateway or a recorded offline payment). data is the invoice object.
invoice.partially_paidAn invoice received a payment but still has a balance. data is the invoice.
invoice.cancelledAn invoice was cancelled. data is the invoice.
subscription.activatedThe first payment of a subscription completed. See Subscription events.
subscription.chargedA renewal collection completed.
subscription.payment_failedA renewal collection failed.
subscription.cancelledThe subscription was cancelled (API, dashboard or gateway).
subscription.pausedThe subscription was paused (API or dashboard).
subscription.resumedA paused subscription was resumed (API or dashboard).
eft.proof_receivedProof of payment was uploaded for an EFT order. data is the EFT order.
eft.rejectedStaff rejected the proof. data is the EFT order (with rejectionReason).
eft.expiredAn EFT order expired unpaid. data is the EFT order.
eft.cancelledAn EFT order was cancelled through the API or the dashboard. data is the EFT order.
reconciliation.completedA reconciliation run finished. data: runId, status, exceptions, lineCount, statementDate, totals.
webhook.testNot a payment event. Sent only by Send test event; its data is { "message": "CentraPoint webhook test" }.

Events are emitted from every path that changes a status: gateway notifications, on-demand verification, debit order settlement and unpaids, reconciliation, refunds, and the payer cancelling. pending produces no event. Remember that a later event can supersede an earlier one; for example payment.cancelled followed by payment.complete when a gateway confirms late (see the status lifecycle).

Payload#

POST body
{
  "id": "evt_3f9a1c7b2e4d6f8091a2b3c4",
  "type": "payment.complete",
  "created": "2026-09-25T08:14:04.201Z",
  "data": {
    "reference": "CP-20260925-9F3A1C7B",
    "status": "complete",
    "amount": 499,
    "currency": "ZAR",
    "type": "once",
    "gateway": "payfast",
    "providerRef": "2458761",
    "paymentLinkId": "cmg1k2x3y0001abcd1234efgh",
    "externalReference": "ORDER-1001",
    "eftOrderId": null,
    "invoice": {
      "id": "cmg2i9n0v0005inv0001abcd",
      "number": "INV-000042",
      "status": "paid",
      "externalReference": "ORDER-1001"
    },
    "customer": {
      "id": "cmg2c0s7t0003cust0001abcd",
      "externalReference": "CRM-1001"
    },
    "metadata": {
      "cartId": "c_8841"
    },
    "renewal": false,
    "parentReference": null,
    "subscriptionId": null,
    "refundedAmount": 0,
    "paidAt": "2026-09-25T08:14:03.512Z"
  }
}
Event fields
FieldTypeDescription
idrequiredstringUnique event ID (evt_…). The same event sent to several endpoints, retried or resent keeps the same ID. Use it to de-duplicate.
typerequiredstringEvent type, see Events.
createdrequiredstring (date-time)ISO 8601 UTC time the event was created.
data.referencerequiredstringPayment reference. Look it up with GET /api/v1/transactions/{reference}.
data.statusrequiredstringTransaction status when the event was created.
data.amountrequirednumberGross amount in major units.
data.currencyrequiredstringISO 4217 code.
data.typerequiredstringonce, recurring, invoice, debit_order or eft.
data.gatewayrequiredstringGateway type, e.g. payfast.
data.providerRefrequiredstring | nullThe gateway's own payment ID.
data.paymentLinkIdrequiredstring | nullPayment link the payment was made through.
data.externalReferencerequiredstring | nullThe payment link's (or EFT order's) externalReference, i.e. your order ID.
data.eftOrderIdrequiredstring | nullEFT order the payment belongs to (Manual EFT).
data.invoicerequiredobject | null{ id, number, status, externalReference } when the payment is for an invoice.
data.customerrequiredobject | null{ id, externalReference } of the paying customer.
data.metadatarequiredobjectThe payment link's (or EFT order's) metadata; {} when none.
data.renewalrequiredbooleanTrue for later collections of a subscription.
data.parentReferencerequiredstring | nullFor renewals: reference of the first payment.
data.subscriptionIdrequiredstring | nullSubscription started by the payment link, if any.
data.refundedAmountrequirednumberTotal refunded so far (excluding failed refunds); 0 if none.
data.paidAtrequiredstring | nullWhen the payment completed.

The table above describes payment.* events. Unlike the Transactions API, their payload does not include fee, net, payer email or refund detail; call the Transactions API if you need those. invoice.* events carry the full invoice object as data, and eft.* events the EFT order object.

Subscription events#

subscription.* events carry the subscription object plus subscriptionId (same as id). activated, charged and payment_failed also carry the payment that triggered them and the period it pays for:

subscription.charged
{
  "id": "evt_2a4c6e8f0b1d3f5a7c9e1b3d",
  "type": "subscription.charged",
  "created": "2026-10-25T08:14:05.000Z",
  "data": {
    "id": "cmg2s5b8k0007sub0001abcd",
    "status": "active",
    "frequency": "monthly",
    "amount": 499,
    "currency": "ZAR",
    "gateway": "payfast",
    "paymentLinkId": "cmg1k2x3y0001abcd1234efgh",
    "externalReference": "SUB-ACME-PRO",
    "metadata": {
      "plan": "pro"
    },
    "createdAt": "2026-09-25T08:14:04.000Z",
    "updatedAt": "2026-09-25T08:14:04.000Z",
    "subscriptionId": "cmg2s5b8k0007sub0001abcd",
    "payment": {
      "reference": "CP-20260925-9F3A1C7B-2458799",
      "amount": 499,
      "currency": "ZAR",
      "status": "complete",
      "paidAt": "2026-10-25T08:14:03.000Z"
    },
    "periodStart": "2026-10-25T08:14:03.000Z",
    "periodEnd": "2026-11-25T08:14:03.000Z"
  }
}
  • periodStart is when the payment was made; periodEnd adds one billing interval. For payment_failed both are null, because no period was paid.
  • subscription.cancelled, subscription.paused and subscription.resumed carry the subscription only, with no payment or period fields.
  • You also receive the matching payment.* event for each collection; handle whichever suits your integration, and de-duplicate on the subscription and payment reference rather than assuming both arrive in order.

Request headers#

Webhook request headers
HeaderValue
Content-Typeapplication/json
User-AgentCentraPoint-Webhooks/1.0
CentraPoint-EventThe event type, e.g. payment.complete.
CentraPoint-DeliveryID of this delivery (constant across its retries; a manual resend gets a new delivery ID but the same event id).
CentraPoint-Signaturet=<unix seconds>,v1=<hex signature>

Verifying signatures#

Always verify the signature before trusting a webhook: anyone can send a request to your URL. The signature is:

Signature
v1 = hex( HMAC-SHA256( key = signing secret, message = "<t>." + <raw request body> ) )
  1. Read the raw request body exactly as received. Do not parse and re-serialise the JSON first.
  2. Split CentraPoint-Signature on , and read t and v1.
  3. Reject the request if t is more than 5 minutes away from your current time (protects against replay). Keep your server clock in sync.
  4. Compute the HMAC with your signing secret, used as-is including the whsec_ prefix (it is not base64-decoded), over t, a dot, and the raw body.
  5. Compare with v1 using a constant-time comparison. Reject on mismatch.

Verification code#

# Send a correctly signed test event to your own receiver (bash + openssl).
SECRET="whsec_your_signing_secret"
BODY='{"id":"evt_local_test","type":"webhook.test","created":"2026-09-25T08:00:00.000Z","data":{"message":"CentraPoint webhook test"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -X POST "https://example.com/webhooks/centrapoint" \
  -H "Content-Type: application/json" \
  -H "CentraPoint-Event: webhook.test" \
  -H "CentraPoint-Delivery: local-test" \
  -H "CentraPoint-Signature: t=$T,v1=$SIG" \
  --data-raw "$BODY"

Responding and retries#

  • Respond with any 2xx status within 10 seconds. Do slow work (emails, fulfilment) after responding, for example via a queue.
  • Anything else (non-2xx, redirect, timeout, connection or TLS error) is a failed attempt.
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts in total over about 2 days. After the last attempt the delivery is marked failed.
  • Failed deliveries can be resent from the delivery log in Settings → Webhooks. Disabled endpoints receive nothing.

Duplicates and ordering#

  • Deliveries can repeat (retries after a timeout, manual resends). Store processed event ids and ignore ones you have seen, and make fulfilment idempotent per data.reference.
  • Ordering is not guaranteed. A retried older event can arrive after a newer one. Use the event as a trigger: when in doubt, fetch the current state with GET /api/v1/transactions/{reference} and act on that.
  • Check data.externalReference, data.amount and data.currency against your order before fulfilling.

Rotating the signing secret#

Click Rotate secret on the endpoint to generate a new secret (shown once). The old secret stops being used immediately, so update your receiver straight away. Deliveries that fail during the switch are retried and will then be signed with the new secret.

Testing#

  • Send test event in the dashboard sends a signed webhook.test event and shows the result immediately.
  • Make a sandbox payment through a payment link to receive real payment.* events.
  • Use the cURL tab above to exercise your receiver locally with a correctly signed request.

Receiver checklist#

  • HTTPS endpoint on port 443 or 8443 with a public address, no redirects.
  • Verifies the signature over the raw body, with a timing-safe compare and a 5-minute timestamp tolerance.
  • Responds 2xx within 10 seconds and processes asynchronously.
  • De-duplicates on event id and tolerates out-of-order events.
  • Handles unknown event types gracefully (new ones may be added): respond 2xx and ignore.
  • Keeps a polling fallback with the Transactions API for payments it has not heard about.