Webhooks
Transaction webhook
Section titled “Transaction webhook”Request (sent by Spay to your webhook URL)
POST https://<your-webhook-host>/<webhook-path>Content-Type: application/jsonX-Spay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>{ "eventId": "44444444-4444-4444-8444-444444444444", "event": "TRANSACTION", "reference": "pay-pos-0001", "originalReference": null, "ownerAccountNumber": "8880000001", "type": "INTERBANK_TRANSFER", "mode": "DEBIT", "status": "SUCCESSFUL", "reviewRequired": false, "amount": 1000.0, "feeAmount": 1.25, "currency": "NGN", "source": { "accountNumber": "8880000001", "accountName": "Adunni Foods" }, "beneficiary": { "accountNumber": "0123456789", "accountName": "Example Beneficiary", "institutionCode": "000013", "institutionName": "Example Bank" }, "narration": "POS payment", "failureReason": null, "transactionDate": "2026-10-25T09:15:00+01:00", "updatedAt": "2026-10-25T09:15:30+01:00"}Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| eventId | string, UUID | Yes | The webhook’s id, the same on every retry. Deduplicate by it. |
| event | string, TRANSACTION | Yes | Marks a transaction webhook. |
| reference | string | Yes | The transaction’s reference. |
| originalReference | string or null | Yes | Set on a reversal: the reference of the transaction it reverses. |
| ownerAccountNumber | string, 10 digits | Yes | The account the transaction belongs to. |
| type | enum: INTERBANK_TRANSFER, INTRABANK_TRANSFER, BILL_PAYMENT_DEBIT, BILL_PAYMENT_REVERSAL | Yes | What kind of transaction it is. |
| mode | enum: DEBIT, CREDIT | Yes | Whether money left or reached the account. |
| status | enum: SUCCESSFUL, FAILED, REVERSED | Yes | The transaction’s final status. |
| reviewRequired | boolean | Yes | Whether the transaction is under review. |
| amount | number, naira, at most 2 decimal places | Yes | The amount. |
| feeAmount | number, naira, at most 2 decimal places | Yes | The fee charged on the transaction, net of any refund. 0 when no fee was charged. |
| currency | string, NGN | Yes | The currency. |
| source.accountNumber | string or null | Yes | The account the money came from. |
| source.accountName | string or null | Yes | That account’s name. |
| source.institutionCode | string | No, omitted for the owner account | The sending institution’s code. |
| source.institutionName | string | No, omitted for the owner account | The sending institution’s name. |
| beneficiary.accountNumber | string or null | Yes | The account the money went to. |
| beneficiary.accountName | string or null | Yes | That account’s name. |
| beneficiary.institutionCode | string | No, omitted for the owner account | The receiving institution’s code. |
| beneficiary.institutionName | string | No, omitted for the owner account | The receiving institution’s name. |
| narration | string, may be empty | Yes | The transaction’s narration. |
| failureReason | enum: INSUFFICIENT_FUNDS, SOURCE_ACCOUNT_UNAVAILABLE, BENEFICIARY_UNAVAILABLE, CANCELLED, PROCESSING_ERROR, or null | Yes | Why a FAILED transaction failed; null otherwise. |
| transactionDate | timestamp | Yes | When the transaction happened. |
| updatedAt | timestamp | Yes | When the transaction last changed. |
- Each API client has its own webhook URL. A transaction’s webhooks go to the client that initiated it. Webhooks for a credit nobody initiated go to every enabled client with a webhook URL.
- A webhook is sent when a transaction reaches a final status, and again with a new
eventIdwhenever itsstatusorfeeAmountchanges afterwards (e.g.SUCCESSFULthenREVERSED, or a fee refunded). - Fees are included in
feeAmount, never sent separately. v1is the hex HMAC-SHA256 oft,., and the raw body, keyed with your webhook signing secret.- Retries resend the same
eventIdand body with a newt. Deduplicate byeventId. - Your endpoint must answer within 30 seconds (connect 10 seconds). Any non-2xx is retried.
- Retries start after 1 minute, doubling to at most 30 minutes, for 24 hours from when the webhook was queued. After that, Spay holds it and can resend it.
- An account’s webhooks are delivered one at a time in order, so a webhook still being retried holds back that account’s later webhooks.
Expected response: 200 OK (any 2xx acknowledges the webhook; anything else is retried)
KYC decision webhook
Section titled “KYC decision webhook”Request (sent by Spay to your webhook URL)
POST https://<your-webhook-host>/<webhook-path>Content-Type: application/jsonX-Spay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>{ "eventId": "88888888-8888-4888-8888-888888888888", "event": "KYC_DECISION", "customerId": "22222222-2222-4222-8222-222222222222", "status": "REJECTED", "tier": 1, "items": [ { "path": "business.proofOfAddress", "code": "EXPIRED", "note": "Bill is older than 3 months" }, { "path": "people[0].nin", "code": "MISMATCH", "note": "NIN belongs to another person" } ], "decidedAt": "2026-10-29T14:00:00+01:00"}Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| eventId | string, UUID | Yes | The webhook’s id, the same on every retry. Deduplicate by it. |
| event | string, KYC_DECISION | Yes | Marks a KYC decision webhook; a transaction webhook has TRANSACTION. |
| customerId | string, UUID | Yes | The customer. |
| status | enum: APPROVED, REJECTED | Yes | The decision. |
| tier | integer, 1 to 3 | Yes | The customer’s KYC tier after the decision. |
| items[].path | string | Yes | A rejected path. |
| items[].code | enum: ILLEGIBLE, EXPIRED, MISMATCH, MISSING, NOT_ACCEPTED, OTHER | Yes | Why the item was rejected. |
| items[].note | string | No, present when the reviewer gave one | The reviewer’s text. |
| decidedAt | timestamp | Yes | When Spay decided. |
- On
APPROVED,tieris 3 and every account of the customer moves to it. - Sent to the client that submitted the pack. Signing, retries and deduplication are as for the transaction webhook.
Expected response: 200 OK (any 2xx acknowledges the webhook; anything else is retried)