Skip to main content

8. Webhooks

8.1 Providers​

Only the following provider names are accepted in the path (case-insensitive):

Path segmentKindAcceptedSignature headerAlgorithm and encoding
flutterwavePaymentsYesflutterwave-signatureHMAC-SHA256 over the raw body, base64
paystackPaymentsYesx-paystack-signatureHMAC-SHA512 over the raw body, hex
fincraPaymentsYessignatureHMAC-SHA512, hex
squadPaymentsYesx-squad-signatureHMAC-SHA512, hex
termiiSMS statusVerified, then always 503x-termii-signatureHMAC-SHA512, hex
twilioSMS statusVerified, then always 503x-twilio-signatureHMAC-SHA1 of URL, parameters and content type, base64 (constant-time compare)

Any other provider name returns 404 NOT_FOUND.

Signature comparison is constant-time (constantTimeEqual). A missing signature fails verification.

SMS status callbacks (termii, twilio) are verified and then rejected with 503 FEATURE_UNAVAILABLE because a durable tenant-bound dispatch record is not implemented. Invalid signatures on those providers still return 401.

8.2 Processing flow for payment webhooks​

  1. Route reads the raw body (max 1 MiB) and sets x-dffms-webhook-url to the request URL (used by Twilio verification).
  2. Provider adapter verifies the signature. Failure: 401 INVALID_WEBHOOK.
  3. The adapter extracts the provider eventId and reference. Missing reference: 401.
  4. The transaction is looked up by reference. Zero or several matches: 401 INVALID_WEBHOOK ("unknown or ambiguous").
  5. An inbox row is inserted with unique key (tenant, provider, providerEventId); a conflict means duplicate: true and no further work.
  6. An outbox job payment.webhook.received is written.
  7. The response returns 200 immediately. after() then processes up to 5 outbox jobs for that tenant.
  8. The worker re-verifies the payment with the provider API (verifyPayment, verifyPayout or verifyRefund), checks amount and currency against the stored transaction, and only then posts ledger entries. Provider redirects or webhook bodies alone never move money.

8.3 Webhook responses​

{
"data": { "accepted": true, "duplicate": false, "eventId": "<provider-event-id>" },
"error": null,
"requestId": "<request-id>"
}