Webhooks
How Scan to Pay notifies your backend when a transaction reaches a terminal state — payload, acknowledgement, retries, and recovery.
A webhook is the server-to-server notification that Scan to Pay sends to your backend when a transaction reaches a terminal state — SUCCESS, CLIENT_CANCEL, BANK_REJECTED, REVERSED, and so on. Webhooks are the primary way you learn about payments. If you don't configure one, you'll have to poll instead — and the two methods are mutually exclusive.
This page covers everything you need to receive, acknowledge, and recover webhooks. For signature decryption code samples, see Signing and verifying webhooks.
How it works
- You register a notification URL on the Scan to Pay Portal against your merchant profile, and generate a notification key.
- A customer completes a payment.
- Scan to Pay schedules an automatic reversal job for the transaction before notifying you. This is the safety net — if you don't acknowledge, the transaction is reversed.
- Scan to Pay HTTP-POSTs the encrypted payload to your notification URL.
- Your handler decrypts the payload, processes it, and returns
HTTP 200within the acknowledgement window. - The reversal job is cancelled. The transaction settles normally.
- If your handler returns any non-200 status, times out, or never receives the request, the reversal job fires and the transaction is reversed.
The model is fail-safe: the platform reverses unless you explicitly confirm receipt.
You cannot use webhooks AND polling.Setting a notification URL disables
queryReffor the merchant — it returnsHTTP 406 Not Acceptable. Pick one. For production, webhooks are recommended; polling is a fallback for integrators that can't expose a public HTTPS endpoint.
Register your endpoint
In the Scan to Pay Portal — sandbox or production:
- Log in as the merchant administrator.
- Open the email dropdown (top right) → Notifications.
- Add your HTTP notification URL. It must be HTTPS-capable and reachable from the public internet.
- Generate a notification decryption key on the same page. Store this key securely — you'll use it on every incoming webhook.
- Press Check to send a test unencrypted JSON payload (
{ "result": "TEST" }) so you can verify your endpoint is reachable before going live.
Allowed URL ports
Your notification URL must use one of these TCP ports:
| Port | Use |
|---|---|
| 443 | Standard HTTPS (recommended) |
| 8443 | HTTPS on alternate port |
| 80 | Plain HTTP — sandbox only, never use in production |
| 8080 | Plain HTTP on alternate port — sandbox only |
Network whitelisting
Webhook deliveries originate from a fixed set of outbound IP addresses. Whitelist these on your firewall and any upstream proxies so notifications aren't blocked.
Sandbox outbound IPs:
INSERT HERE
Production outbound IPs:
INSERT HERE
If these ranges change, we'll notify integrators in advance. If you suspect a delivery was blocked — no notification arrived for a transaction you expected, no entry in your handler logs — contact support with the transaction ID.
Payload structure
The webhook body is base64-encoded ciphertext. Decode it, then decrypt with your notification key — see Signing and verifying webhooks for the exact decryption code. The decrypted payload is a JSON object with this shape:
{
"transactionId": 81234,
"merchantId": 25,
"merchantName": "Acme Coffee",
"acquirer": "ABSA",
"reference": "demo-order-001",
"amount": 10.00,
"currencyCode": "ZAR",
"code": "0123456789",
"status": "SUCCESS",
"clientMsisdn": "27831234567",
"destination": "27821111111",
"extraReferenceName": null,
"extraReferenceValue": null,
"tipAmount": null,
"referenceTransactionId": 0,
"bankResponse": {
"retrievalReferenceNumber": 6321400012,
"authCode": "123456",
"bankResponse": "00",
"bankResponseMessage": "Approved"
},
"cardInfo": {
"cardType": "VISA",
"accountType": "CREDIT",
"cardHolderName": "J SMITH",
"bin": "411111",
"last4": "1234",
"binLast4": "411111-1234",
"cardIssuer": "ABSA"
},
"clientContact": {
"firstName": "James",
"lastName": "Smith",
"emailAddress": "[email protected]",
"phoneNumber": "27831234567",
"country": "ZA"
},
"clientBilling": null,
"clientShipping": null,
"versionData": {
"version": "1"
}
}Field reference
| Field | Type | Always present | Description |
|---|---|---|---|
transactionId | long | ✓ | Scan to Pay's internal transaction ID. Use this when calling getNotification to re-fetch. |
merchantId | long | ✓ | Your merchant ID — useful for multi-merchant integrators. |
merchantName | string | ✓ | Your registered merchant name. |
acquirer | string | ✓ | The acquiring bank that processed this transaction. |
reference | string | ✓ | Your merchantReference from the original createCode call. Use this to join the notification to your order record. |
amount | decimal | ✓ | Transaction amount in currencyCode. |
currencyCode | string | ✓ | ISO 4217 currency code (e.g. ZAR). |
code | string | ✓ for code purchases | The 10-digit QR code value, if this was a code-based purchase. |
status | enum | ✓ | The notification status — see the table below. Different from TxState. |
clientMsisdn | string | ✓ | Customer's mobile number in international format. |
destination | string | optional | Destination MSISDN for airtime / VAS purchases. |
extraReferenceName / extraReferenceValue | string | optional | Extra reference fields supplied at create-code time. |
tipAmount | decimal | optional | Tip portion, if requestTip was set on the code. |
referenceTransactionId | long | ✓ for refunds/reversals | Links a refund or reversal notification back to the original transaction. |
bankResponse | object | ✓ | Acquiring-bank response details (see below). |
cardInfo | object | ✓ | Tokenised card information (BIN + last 4 only — never the full PAN). |
clientContact | object | optional | Customer contact info, if the code requested it. |
clientBilling / clientShipping | object | optional | Customer addresses, if requested. |
versionData | object | ✓ | Versioning envelope. Defaults to {"version": "1"}. |
bankResponse object
bankResponse object| Field | Description |
|---|---|
retrievalReferenceNumber | The bank's RRN. Useful for reconciliation against your acquirer statement. |
authCode | Authorisation code from the issuer. |
bankResponse | ISO response code (e.g. 00, 51, 91). See ISO response codes. |
bankResponseMessage | Human-readable explanation of the bank response. |
cardInfo object
cardInfo object| Field | Description |
|---|---|
cardType | VISA, MASTERCARD, AMEX, DINERS, etc. |
accountType | DEBIT, CREDIT, or CHEQUE. |
cardHolderName | As registered with the issuer. |
bin | First 6 digits of the card. |
last4 | Last 4 digits. |
binLast4 | Convenience field: {bin}-{last4}. |
cardIssuer | The issuing bank. |
Notification status values
The status field uses the NotificationStatus enum. This is deliberately simpler than the full TxState enum used in queryRef responses — it's the merchant-facing rollup of the internal state machine.
status | Meaning |
|---|---|
SUCCESS | Payment completed successfully. Settle the order. |
CLIENT_CANCEL | Customer cancelled before authorising. Treat as abandoned. |
CLIENT_TIMEOUT | Customer didn't complete in time. Treat as abandoned. |
BANK_OFFLINE | Couldn't reach the bank. Transient — customer can retry. |
BANK_REJECTED | Bank declined. Customer can retry with a different card. |
TRANSACTION_REJECTED | Platform rejected before reaching the bank (validation, limit, fraud). |
SYSTEM_ERROR | Internal Scan to Pay error. Investigate via support. |
REVERSED | Original transaction was reversed (look at referenceTransactionId). |
REFUNDED | Full or partial refund completed. |
LIMIT_FAILED | Failed a TPE or merchant limit check. |
HASH_CHECK_FAILED / HASH_CHECK_MISSING | Card-link hash validation failed. |
BANK_PAYMENT_CANCELLATION | Bank cancelled the payment after authorisation. |
DO_NOT_TRY_AGAIN | Issuer declined permanently — do not retry. |
TRY_AGAIN_AFTER_X_HRS | Issuer rate-limited — retry later. |
CARD_EXPRIY_NOT_ALLOWED | Card expiry rejected by merchant or scheme. |
INTERNATIONAL_BIN_NOT_ALLOWED | International card rejected by merchant configuration. |
BIN_CLASSIFICATION_NOT_ALLOWED | Card BIN classification not permitted. |
TIMEOUT_* (15 variants) | WIG / USSD flow-step timeouts. See Transaction states for the equivalent TxState mapping. |
FAILED | Generic external-notification failure (reserved). |
Status mapping — TxState vs NotificationStatusIf you also use
queryRefor examine transactions in the Admin Portal, you'll seeTxStatevalues likeEND_BANK_NON_00. The webhook rolls these up to a smaller set ofNotificationStatusvalues designed for merchant consumption. For example,END_BANK_NON_00,END_BANK_DECLINE, andEND_INSUFFICIENT_FUNDS_OR_OVER_CREDIT_LIMITall surface asBANK_REJECTEDin the notification. Use the Transaction states page when you need the underlying TxState.
Acknowledging the webhook
Your handler must return HTTP 200 within the acknowledgement window. The body of your response is ignored — what matters is the status code.
| Your response | What Scan to Pay does |
|---|---|
HTTP 200 (any body) | Cancels the scheduled reversal. Transaction settles normally. |
| Any non-200 | Lets the scheduled reversal job fire. Transaction is reversed. |
| No response (connection error, timeout) | Same as non-200 — reversal fires. |
The acknowledgement window is 45 seconds. If your handler takes longer than that, the reversal runs.
Acknowledge first, process asynchronously. The reversal safety net is aggressive. Have your handler:
- Verify the source and decrypt the payload.
- Persist the raw notification to a queue or table.
- Return
HTTP 200immediately.- Process the notification (update order, send email, fulfil) on a background worker.
If your synchronous business logic ever takes longer than 45 seconds, the transaction will reverse out from under you.
Recovery — re-fetching missed notifications
If your handler missed a delivery — outage, deploy gone wrong, network blip — you can pull the notification back manually using the getNotification endpoint.
curl -u "$USERNAME:$PASSWORD" \
https://qa.scantopay.io/pluto/purchase/notification/81234Response is the same TransactionNotificationV3 object the webhook would have delivered, but unencrypted. The endpoint is locked per-transaction (Hazelcast distributed lock), so it's safe to call concurrently.
Calling getNotification for a successful transaction that hasn't yet been acknowledged also cancels the reversal job — it's effectively the same as a webhook ack. Use this as your recovery path: scan your order DB for "payment expected, no notification received" cases and pull them.
Other notification channels
The platform supports three notification methods:
| Method | What it sends | When to use |
|---|---|---|
| HTTP (webhook) | The full TransactionNotificationV3 payload, encrypted | All production integrations |
| Human-readable summary of the transaction | Operational notifications, not for automated processing | |
| SMS | Brief SMS summary | Low-stakes confirmations |
You can configure any combination of the three in the portal. HTTP is the only channel that triggers the reversal-on-failure safety net — email and SMS deliveries are best-effort and don't affect transaction state.
Test your endpoint
In the sandbox, use the Check button on the Portal notifications page to send a probe:
{ "result": "TEST" }This is sent unencrypted to your URL. Verify your handler:
- Receives the POST
- Returns HTTP 200
- Logs the payload
Once that works, run a full sandbox transaction (see Quickstart) and verify your handler:
- Receives the encrypted production-shape payload
- Decrypts it correctly with your sandbox notification key
- Returns 200 within 45 seconds
- Records the order in your system
Best practices
- Verify before you trust. Decrypt with your notification key. If decryption fails, your payload is fake (or you have the wrong key) — never trust the body.
- Be idempotent. Scan to Pay won't deliberately send the same webhook twice, but operational reality (replays after a failed ack, intermediary retries) can produce duplicates. Treat
transactionIdas the dedup key. - Don't display raw
statusvalues to end customers. MapBANK_REJECTEDto "Sorry, your card was declined" before showing it to humans. - Watch for
REVERSEDnotifications on transactions you thought succeeded. This is how you learn your handler ack'd too late. Tune your handler latency until you stop seeing them. - Whitelist Scan to Pay's outbound IPs in your network — see Network whitelisting above.
- Never log the decrypted payload to plaintext logs. It contains MSISDN, card BIN/last4, and cardholder name. Treat as PII.
What's next
- Decryption code samples → Signing and verifying webhooks
- Full status reference and
TxStatemapping → Transaction states - Map bank decline codes to customer-facing copy → ISO response codes
- First-time setup walkthrough → Quickstart
- Going live checklist → Going live
Updated 1 day ago
