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

FieldNotes
urlHTTPS URL. Must be publicly resolvable; private/loopback ranges are rejected.
subscribed_events1+ 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 /webhooks lists 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.

DomainEvents
Transactionstransaction.pending, transaction.success, transaction.failed, transaction.reversed
Payoutspayout.queued, payout.processing, payout.success, payout.failed
Escrowescrow.held, escrow.released, escrow.returned
Disputesdispute.opened, dispute.resolved
KYCkyc.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, 8h after 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:

  1. Timestamp window. Reject any delivery whose X-WasaaPay-Timestamp is more than 300 seconds from your clock (the snippet above does this). This bounds how long a captured delivery can be replayed.
  2. 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 processed event.ids and ignore repeats, so your handler is effectively exactly-once.