Get told when a payment moves
Instead of polling, hand Orange a URL and we'll POST a JSON event to it every time
a payment changes status — created → deposited → success.
Confirm it's really us, update your records, respond 200.
Register a webhook
There's no separate webhook resource — you attach webhookUrl and
webhookSecret when you create the payment. Each payment can have its own endpoint.
{
"merchantId": "9f430126-99da-42e8-afb1-4cab619cda5a",
"amount": 100,
"reference": "INV12345",
"webhookUrl": "https://your-server.com/webhooks/orange",
"webhookSecret": "a-random-secret-you-choose"
}The event payload
Each event is a flat JSON object — no envelope. It carries the current status, your
echoed webhookSecret, an integrity hash, and the full
payment object. It's sent with a single header, Content-Type: application/json
— there are no signature headers.
{
"paymentId": "eafdea43-63a7-4f61-b8ae-823c1b3aa236",
"status": "success",
"log": "payment has been completed successfully",
"webhookSecret": "a-random-secret-you-choose",
"receivedAmount": 100,
"sentAmount": 102.28,
"date": "2024-09-27 21:01:50",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"payment": {
"id": "eafdea43-63a7-4f61-b8ae-823c1b3aa236",
"merchantId": "9f430126-99da-42e8-afb1-4cab619cda5a",
"amount": 100,
"amountSettled": 102.28,
"status": "success",
"destinationCurrency": "ZAR",
"reference": "INV12345",
"type": "PURCHASE"
}
}Verify events
There's no HMAC signature header. The reliable check is the echoed webhookSecret:
compare the webhookSecret in the body to the one you set on that payment, and reject
anything that doesn't match.
A hash field is also included — a plain SHA256 (hex) of
paymentId + status + log + webhookSecret + receivedAmount + sentAmount + date + payment.
Because the trailing payment segment is a fixed server-side marker (not the payment
JSON) and date is a server-local timestamp string, the secret comparison above is the
dependable test — treat the hash as a best-effort tamper check.
webhookSecret private and use a unique, hard-to-guess value. Anyone who knows
it could forge an event, since it travels in the payload.// 1) Primary check — the secret you set is echoed back in the body.
function isAuthentic(body, expectedSecret) {
return body.webhookSecret === expectedSecret;
}
// 2) Optional integrity check — recompute the SHA256 hash.
// NOTE: the trailing "payment" segment is a fixed server-side marker
// (not the payment JSON), and "date" is the server's local timestamp
// string, so treat this as best-effort and rely on the secret above.
const crypto = require('crypto');
function hashMatches(body) {
const PAYMENT_MARKER = 'OrangeHelpers.Models.Orange.Payment';
const data =
body.paymentId + body.status + body.log + body.webhookSecret +
body.receivedAmount + body.sentAmount + body.date + PAYMENT_MARKER;
const expected = crypto.createHash('sha256').update(data, 'utf8').digest('hex');
return expected === body.hash;
}Retries & best practice
We retry on failure
Anything other than a 2xx (or a timeout — we wait ~30s per attempt) is retried up
to 3 times with exponential backoff (~1s, 2s, 4s). Return 200 fast,
do work async.
Be idempotent
The same event may arrive more than once. Key on paymentId + status so
duplicates are harmless.
Always check the secret
Compare the body's webhookSecret to the one you set before touching your database.
Drop anything that doesn't match.
Reconcile as backup
Webhooks are best-effort. Periodically poll payments to catch anything your endpoint missed during downtime.