Skip to content

Webhooks

Request (sent by Spay to your webhook URL)

POST https://<your-webhook-host>/<webhook-path>
Content-Type: application/json
X-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

FieldTypeRequiredDescription
eventIdstring, UUIDYesThe webhook’s id, the same on every retry. Deduplicate by it.
eventstring, TRANSACTIONYesMarks a transaction webhook.
referencestringYesThe transaction’s reference.
originalReferencestring or nullYesSet on a reversal: the reference of the transaction it reverses.
ownerAccountNumberstring, 10 digitsYesThe account the transaction belongs to.
typeenum: INTERBANK_TRANSFER, INTRABANK_TRANSFER, BILL_PAYMENT_DEBIT, BILL_PAYMENT_REVERSALYesWhat kind of transaction it is.
modeenum: DEBIT, CREDITYesWhether money left or reached the account.
statusenum: SUCCESSFUL, FAILED, REVERSEDYesThe transaction’s final status.
reviewRequiredbooleanYesWhether the transaction is under review.
amountnumber, naira, at most 2 decimal placesYesThe amount.
feeAmountnumber, naira, at most 2 decimal placesYesThe fee charged on the transaction, net of any refund. 0 when no fee was charged.
currencystring, NGNYesThe currency.
source.accountNumberstring or nullYesThe account the money came from.
source.accountNamestring or nullYesThat account’s name.
source.institutionCodestringNo, omitted for the owner accountThe sending institution’s code.
source.institutionNamestringNo, omitted for the owner accountThe sending institution’s name.
beneficiary.accountNumberstring or nullYesThe account the money went to.
beneficiary.accountNamestring or nullYesThat account’s name.
beneficiary.institutionCodestringNo, omitted for the owner accountThe receiving institution’s code.
beneficiary.institutionNamestringNo, omitted for the owner accountThe receiving institution’s name.
narrationstring, may be emptyYesThe transaction’s narration.
failureReasonenum: INSUFFICIENT_FUNDS, SOURCE_ACCOUNT_UNAVAILABLE, BENEFICIARY_UNAVAILABLE, CANCELLED, PROCESSING_ERROR, or nullYesWhy a FAILED transaction failed; null otherwise.
transactionDatetimestampYesWhen the transaction happened.
updatedAttimestampYesWhen 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 eventId whenever its status or feeAmount changes afterwards (e.g. SUCCESSFUL then REVERSED, or a fee refunded).
  • Fees are included in feeAmount, never sent separately.
  • v1 is the hex HMAC-SHA256 of t, ., and the raw body, keyed with your webhook signing secret.
  • Retries resend the same eventId and body with a new t. Deduplicate by eventId.
  • 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)

Request (sent by Spay to your webhook URL)

POST https://<your-webhook-host>/<webhook-path>
Content-Type: application/json
X-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

FieldTypeRequiredDescription
eventIdstring, UUIDYesThe webhook’s id, the same on every retry. Deduplicate by it.
eventstring, KYC_DECISIONYesMarks a KYC decision webhook; a transaction webhook has TRANSACTION.
customerIdstring, UUIDYesThe customer.
statusenum: APPROVED, REJECTEDYesThe decision.
tierinteger, 1 to 3YesThe customer’s KYC tier after the decision.
items[].pathstringYesA rejected path.
items[].codeenum: ILLEGIBLE, EXPIRED, MISMATCH, MISSING, NOT_ACCEPTED, OTHERYesWhy the item was rejected.
items[].notestringNo, present when the reviewer gave oneThe reviewer’s text.
decidedAttimestampYesWhen Spay decided.
  • On APPROVED, tier is 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)