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#
- In the dashboard, go to Settings → Webhooks (administrators only; requires a plan that includes the REST API).
- Under Add endpoint, enter your HTTPS URL, an optional description, and the events to receive. Selecting no events subscribes to all of them.
- Copy the signing secret (
whsec_…). It is shown only once; store it like a password. - 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
3xxresponse counts as a failed delivery, so configure the final URL.
Events#
| Event | Sent when |
|---|---|
payment.complete | complete: money received and verified. Fulfil the order. |
payment.failed | failed: declined, rejected, or a debit order returned unpaid. |
payment.cancelled | cancelled: the payer cancelled or abandoned checkout. |
payment.refunded | refunded: fully refunded. |
payment.partially_refunded | partially_refunded: part of the amount refunded. |
invoice.paid | An invoice is fully paid (through a gateway or a recorded offline payment). data is the invoice object. |
invoice.partially_paid | An invoice received a payment but still has a balance. data is the invoice. |
invoice.cancelled | An invoice was cancelled. data is the invoice. |
subscription.activated | The first payment of a subscription completed. See Subscription events. |
subscription.charged | A renewal collection completed. |
subscription.payment_failed | A renewal collection failed. |
subscription.cancelled | The subscription was cancelled (API, dashboard or gateway). |
subscription.paused | The subscription was paused (API or dashboard). |
subscription.resumed | A paused subscription was resumed (API or dashboard). |
eft.proof_received | Proof of payment was uploaded for an EFT order. data is the EFT order. |
eft.rejected | Staff rejected the proof. data is the EFT order (with rejectionReason). |
eft.expired | An EFT order expired unpaid. data is the EFT order. |
eft.cancelled | An EFT order was cancelled through the API or the dashboard. data is the EFT order. |
reconciliation.completed | A reconciliation run finished. data: runId, status, exceptions, lineCount, statementDate, totals. |
webhook.test | Not 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#
{
"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"
}
}| Field | Type | Description |
|---|---|---|
idrequired | string | Unique event ID (evt_…). The same event sent to several endpoints, retried or resent keeps the same ID. Use it to de-duplicate. |
typerequired | string | Event type, see Events. |
createdrequired | string (date-time) | ISO 8601 UTC time the event was created. |
data.referencerequired | string | Payment reference. Look it up with GET /api/v1/transactions/{reference}. |
data.statusrequired | string | Transaction status when the event was created. |
data.amountrequired | number | Gross amount in major units. |
data.currencyrequired | string | ISO 4217 code. |
data.typerequired | string | once, recurring, invoice, debit_order or eft. |
data.gatewayrequired | string | Gateway type, e.g. payfast. |
data.providerRefrequired | string | null | The gateway's own payment ID. |
data.paymentLinkIdrequired | string | null | Payment link the payment was made through. |
data.externalReferencerequired | string | null | The payment link's (or EFT order's) externalReference, i.e. your order ID. |
data.eftOrderIdrequired | string | null | EFT order the payment belongs to (Manual EFT). |
data.invoicerequired | object | null | { id, number, status, externalReference } when the payment is for an invoice. |
data.customerrequired | object | null | { id, externalReference } of the paying customer. |
data.metadatarequired | object | The payment link's (or EFT order's) metadata; {} when none. |
data.renewalrequired | boolean | True for later collections of a subscription. |
data.parentReferencerequired | string | null | For renewals: reference of the first payment. |
data.subscriptionIdrequired | string | null | Subscription started by the payment link, if any. |
data.refundedAmountrequired | number | Total refunded so far (excluding failed refunds); 0 if none. |
data.paidAtrequired | string | null | When 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:
{
"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"
}
}periodStartis when the payment was made;periodEndadds one billing interval. Forpayment_failedboth arenull, because no period was paid.subscription.cancelled,subscription.pausedandsubscription.resumedcarry the subscription only, with nopaymentor 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#
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CentraPoint-Webhooks/1.0 |
CentraPoint-Event | The event type, e.g. payment.complete. |
CentraPoint-Delivery | ID of this delivery (constant across its retries; a manual resend gets a new delivery ID but the same event id). |
CentraPoint-Signature | t=<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:
v1 = hex( HMAC-SHA256( key = signing secret, message = "<t>." + <raw request body> ) )- Read the raw request body exactly as received. Do not parse and re-serialise the JSON first.
- Split
CentraPoint-Signatureon,and readtandv1. - Reject the request if
tis more than 5 minutes away from your current time (protects against replay). Keep your server clock in sync. - Compute the HMAC with your signing secret, used as-is including the
whsec_prefix (it is not base64-decoded), overt, a dot, and the raw body. - Compare with
v1using 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 perdata.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.amountanddata.currencyagainst 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.testevent 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
idand 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.