Guide · 07 / 10
Webhooks
Register endpoints, verify signatures, and handle retries and replays.
Webhooks push event notifications to your server whenever something changes — a
collection succeeds, a payout fails, an escrow hold releases. A webhook fires on
every status change (including intermediate ones like pending), so your
handler must be idempotent.
Registering an endpoint
POST /webhooks with the URL to deliver to and the events you want.
Request (WebhookEndpointCreateRequest):
| Field | Notes |
|---|---|
url | HTTPS URL. Must be publicly resolvable; private/loopback ranges are rejected. |
subscribed_events | 1+ event types from the registry. |
curl -sS https://sandbox.api.wasaapay.com/v1/webhooks \
-H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
-H "Content-Type: application/json" \
--data '{
"url": "https://merchant.example.com/webhooks/wasaapay",
"subscribed_events": ["transaction.success","transaction.failed","payout.success","payout.failed"]
}'
Response (WebhookEndpointCreated) returns the signing_secret — shown
once and never retrievable again. Store it securely; you need it to verify
deliveries.
{
"data": {
"id": "d1e2f3a4-...",
"url": "https://merchant.example.com/webhooks/wasaapay",
"subscribed_events": ["transaction.success","transaction.failed","payout.success","payout.failed"],
"active": true,
"signing_secret": "whsec_5f0e...shown-once...",
"created_at": "2026-08-15T09:15:00.000Z"
},
"error": null,
"meta": { "request_id": "req_9f2c1a" }
}
GET /webhookslists your active endpoints (never the secret).DELETE /webhooks/{id}removes an endpoint (soft delete).
The endpoint URL is checked for SSRF safety both at registration and again at each delivery (DNS can change in between); in production it must be HTTPS.
Event types
There are 15 merchant-facing event types (the canonical registry from
libs/events/src/registry.ts, mirrored by the OpenAPI WebhookEventType enum).
Names are dot.case and past-state.
| Domain | Events |
|---|---|
| Transactions | transaction.pending, transaction.success, transaction.failed, transaction.reversed |
| Payouts | payout.queued, payout.processing, payout.success, payout.failed |
| Escrow | escrow.held, escrow.released, escrow.returned |
| Disputes | dispute.opened, dispute.resolved |
| KYC | kyc.verified, kyc.rejected |
Delivery format
Each delivery is an HTTP POST with a JSON body and two signature headers:
POST /webhooks/wasaapay HTTP/1.1
Content-Type: application/json
User-Agent: wasaapay-webhook-service/0.1
X-WasaaPay-Timestamp: 1734600123
X-WasaaPay-Signature: d3b56f2f33181cb68deb1b72ddd88b3e81ccded122daa9d433bc480b86b9da70
{"id":"evt_1a2b3c","type":"transaction.success","created_at":"2026-08-15T09:15:00.000Z","data":{"transaction_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","merchant_id":"8f3a2b10-1c9e-4c2a-9b2f-3e6d2a1e9c11","amount":150000,"currency":"KES","status":"success","occurred_at":"2026-08-15T09:15:00.000Z"}}
The body always has the same envelope: id (unique event id), type (one of the
15), created_at, and data (the event payload, whose fields depend on type).
Verifying the signature
The signature is HMAC-SHA256 over `${timestamp}.${rawBody}` keyed by your
endpoint's signing_secret — the same scheme as request signing. Verify it on
the raw request body (before any JSON parsing/reserialization), and reject
timestamps outside a ±300s window.
This matches verifySignature in libs/common/src/crypto/hmac.ts. A minimal,
dependency-free Node/Express receiver:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SIGNING_SECRET = process.env.WASAAPAY_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_SECONDS = 300;
function constantTimeEqual(a, b) {
const ba = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
if (ba.length !== bb.length) { timingSafeEqual(ba, ba); return false; }
return timingSafeEqual(ba, bb);
}
function verify(secret, rawBody, timestamp, signature) {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) return false; // replay guard
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
return constantTimeEqual(expected, signature);
}
const app = express();
// Capture the RAW bytes — do not let a JSON parser reserialize the body first.
app.post('/webhooks/wasaapay', express.raw({ type: '*/*' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const timestamp = req.get('X-WasaaPay-Timestamp');
const signature = req.get('X-WasaaPay-Signature');
if (!verify(SIGNING_SECRET, rawBody, timestamp, signature)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(rawBody);
// TODO: dedupe on event.id, then handle event.type / event.data.
// Return 2xx FAST; do heavy work asynchronously.
res.status(200).send('ok');
});
You can reproduce the example above: HMAC-SHA256 of
`1734600123.{"id":"evt_1a2b3c",...}` (the exact body shown earlier) with
secret whsec_ZmFrZS13ZWJob29rLXNlY3JldA yields
d3b56f2f33181cb68deb1b72ddd88b3e81ccded122daa9d433bc480b86b9da70.
Respond 2xx quickly. Any 2xx marks the delivery successful; anything else
(or a timeout) counts as a failure and is retried. Do slow work off the request
path.
Retries & backoff
If a delivery does not get a 2xx (non-2xx status, timeout, or connection error),
it is retried on an exponential backoff. Defaults (configurable per environment):
- Attempts: up to 6 total — the initial attempt plus 5 retries
(
WEBHOOK_MAX_ATTEMPTS). - Backoff schedule:
1m, 5m, 30m, 2h, 8hafter successive failures (WEBHOOK_BACKOFF_SCHEDULE_SECONDS = 60,300,1800,7200,28800). - Per-attempt timeout: 10s. HTTP redirects on the delivery are refused (a redirect could bounce the POST somewhere private).
- A retry sweep runs about every minute.
When all attempts are exhausted, the event moves to a terminal exhausted
state: it is written to a dead-letter store and an internal alert fires. Exhausted
events are not retried further.
Delivery history
Each delivery attempt records its outcome — attempt_count, last_response_code,
and a truncated last_response_snippet of your response body — so failures can be
inspected and dead-lettered events replayed operationally. Response bodies are
never trusted or parsed; only a short snippet is stored for debugging.
Replay protection & idempotency
Two things protect you against replays and duplicates:
- Timestamp window. Reject any delivery whose
X-WasaaPay-Timestampis more than 300 seconds from your clock (the snippet above does this). This bounds how long a captured delivery can be replayed. - Deduplicate on
event.id. Because webhooks fire on every status change and are retried, you may see the same event id more than once. Record processedevent.ids and ignore repeats, so your handler is effectively exactly-once.