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

  1. You register a notification URL on the Scan to Pay Portal against your merchant profile, and generate a notification key.
  2. A customer completes a payment.
  3. 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.
  4. Scan to Pay HTTP-POSTs the encrypted payload to your notification URL.
  5. Your handler decrypts the payload, processes it, and returns HTTP 200 within the acknowledgement window.
  6. The reversal job is cancelled. The transaction settles normally.
  7. 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 queryRef for the merchant — it returns HTTP 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:

  1. Log in as the merchant administrator.
  2. Open the email dropdown (top right) → Notifications.
  3. Add your HTTP notification URL. It must be HTTPS-capable and reachable from the public internet.
  4. Generate a notification decryption key on the same page. Store this key securely — you'll use it on every incoming webhook.
  5. 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:

PortUse
443Standard HTTPS (recommended)
8443HTTPS on alternate port
80Plain HTTP — sandbox only, never use in production
8080Plain 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

FieldTypeAlways presentDescription
transactionIdlongScan to Pay's internal transaction ID. Use this when calling getNotification to re-fetch.
merchantIdlongYour merchant ID — useful for multi-merchant integrators.
merchantNamestringYour registered merchant name.
acquirerstringThe acquiring bank that processed this transaction.
referencestringYour merchantReference from the original createCode call. Use this to join the notification to your order record.
amountdecimalTransaction amount in currencyCode.
currencyCodestringISO 4217 currency code (e.g. ZAR).
codestring✓ for code purchasesThe 10-digit QR code value, if this was a code-based purchase.
statusenumThe notification status — see the table below. Different from TxState.
clientMsisdnstringCustomer's mobile number in international format.
destinationstringoptionalDestination MSISDN for airtime / VAS purchases.
extraReferenceName / extraReferenceValuestringoptionalExtra reference fields supplied at create-code time.
tipAmountdecimaloptionalTip portion, if requestTip was set on the code.
referenceTransactionIdlong✓ for refunds/reversalsLinks a refund or reversal notification back to the original transaction.
bankResponseobjectAcquiring-bank response details (see below).
cardInfoobjectTokenised card information (BIN + last 4 only — never the full PAN).
clientContactobjectoptionalCustomer contact info, if the code requested it.
clientBilling / clientShippingobjectoptionalCustomer addresses, if requested.
versionDataobjectVersioning envelope. Defaults to {"version": "1"}.

bankResponse object

FieldDescription
retrievalReferenceNumberThe bank's RRN. Useful for reconciliation against your acquirer statement.
authCodeAuthorisation code from the issuer.
bankResponseISO response code (e.g. 00, 51, 91). See ISO response codes.
bankResponseMessageHuman-readable explanation of the bank response.

cardInfo object

FieldDescription
cardTypeVISA, MASTERCARD, AMEX, DINERS, etc.
accountTypeDEBIT, CREDIT, or CHEQUE.
cardHolderNameAs registered with the issuer.
binFirst 6 digits of the card.
last4Last 4 digits.
binLast4Convenience field: {bin}-{last4}.
cardIssuerThe 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.

statusMeaning
SUCCESSPayment completed successfully. Settle the order.
CLIENT_CANCELCustomer cancelled before authorising. Treat as abandoned.
CLIENT_TIMEOUTCustomer didn't complete in time. Treat as abandoned.
BANK_OFFLINECouldn't reach the bank. Transient — customer can retry.
BANK_REJECTEDBank declined. Customer can retry with a different card.
TRANSACTION_REJECTEDPlatform rejected before reaching the bank (validation, limit, fraud).
SYSTEM_ERRORInternal Scan to Pay error. Investigate via support.
REVERSEDOriginal transaction was reversed (look at referenceTransactionId).
REFUNDEDFull or partial refund completed.
LIMIT_FAILEDFailed a TPE or merchant limit check.
HASH_CHECK_FAILED / HASH_CHECK_MISSINGCard-link hash validation failed.
BANK_PAYMENT_CANCELLATIONBank cancelled the payment after authorisation.
DO_NOT_TRY_AGAINIssuer declined permanently — do not retry.
TRY_AGAIN_AFTER_X_HRSIssuer rate-limited — retry later.
CARD_EXPRIY_NOT_ALLOWEDCard expiry rejected by merchant or scheme.
INTERNATIONAL_BIN_NOT_ALLOWEDInternational card rejected by merchant configuration.
BIN_CLASSIFICATION_NOT_ALLOWEDCard BIN classification not permitted.
TIMEOUT_* (15 variants)WIG / USSD flow-step timeouts. See Transaction states for the equivalent TxState mapping.
FAILEDGeneric external-notification failure (reserved).
📘

Status mapping — TxState vs NotificationStatus

If you also use queryRef or examine transactions in the Admin Portal, you'll see TxState values like END_BANK_NON_00. The webhook rolls these up to a smaller set of NotificationStatus values designed for merchant consumption. For example, END_BANK_NON_00, END_BANK_DECLINE, and END_INSUFFICIENT_FUNDS_OR_OVER_CREDIT_LIMIT all surface as BANK_REJECTED in 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 responseWhat Scan to Pay does
HTTP 200 (any body)Cancels the scheduled reversal. Transaction settles normally.
Any non-200Lets 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:

  1. Verify the source and decrypt the payload.
  2. Persist the raw notification to a queue or table.
  3. Return HTTP 200 immediately.
  4. 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/81234

Response 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:

MethodWhat it sendsWhen to use
HTTP (webhook)The full TransactionNotificationV3 payload, encryptedAll production integrations
EmailHuman-readable summary of the transactionOperational notifications, not for automated processing
SMSBrief SMS summaryLow-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:

  1. Receives the POST
  2. Returns HTTP 200
  3. Logs the payload

Once that works, run a full sandbox transaction (see Quickstart) and verify your handler:

  1. Receives the encrypted production-shape payload
  2. Decrypts it correctly with your sandbox notification key
  3. Returns 200 within 45 seconds
  4. 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 transactionId as the dedup key.
  • Don't display raw status values to end customers. Map BANK_REJECTED to "Sorry, your card was declined" before showing it to humans.
  • Watch for REVERSED notifications 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