Webhook Testing
Developer documentation

Webhook Testing

Test your webhook endpoint before processing real transactions.

1 · Tunnel to localhost

While developing, expose your local server with a tunnel and set your webhook URL:

ngrok http 8080
# → https://xxxx.ngrok-free.app  → set as your webhook URL

2 · Sign a real transaction

Trigger a VA credit through the sandbox. When Cyrus processes it and reconciliation confirms the transaction, a signed payment.succeeded webhook is delivered to your URL.

3 · Mock Nomba inbound webhooks

To simulate Nomba-to-Cyrus webhooks during development, use the helper script:

./scripts/mock-nomba-webhook.sh \
  --account 1230751405 \
  --amount 2500 \
  --secret $NOMBA_WEBHOOK_SECRET

This tests the full ingestion pipeline without sending real money.

What to expect

Cyrus sends signed POST requests to your webhook URL.

Request headers

  • X-Cyrus-Event — event type, one of payment.succeeded, payment.reversed, payment.flagged, payout.completed, payout.failed
  • X-Cyrus-Delivery — unique UUID for this delivery attempt
  • X-Cyrus-Timestamp — epoch milliseconds of the delivery
  • X-Cyrus-Signaturesha256= followed by the HMAC-SHA256 hex digest of {timestamp}.{payload}, where {timestamp} is the value of X-Cyrus-Timestamp and {payload} is the raw JSON body

Payload

{
  "event": "payment.succeeded",
  "createdAt": "2026-07-09T12:00:00Z",
  "data": {
    "transactionId": "uuid",
    "amountKobo": 250000,
    "feeKobo": 3750,
    "currency": "NGN",
    "status": "SUCCESSFUL",
    "matchStatus": "MATCHED",
    "sessionId": "1000042602061021531516xxxx",
    "providerTransactionId": "API-VACT_TRA-613BB-...",
    "customerReference": "cust_abc123",
    "virtualAccountNumber": "1230751405",
    "paidAt": "2026-07-09T11:55:00Z"
  }
}

Response & retries

  • Return 2xx to acknowledge — Cyrus stops retrying.
  • 5xx and 429 trigger retries with exponential backoff (up to the configured max attempts).
  • Other 4xx (400, 401, 403, 404, 410) are treated as permanent failures — the webhook is marked failed immediately.
  • Connection timeouts, DNS failures, and network errors are also retried.

Deduplication

Cyrus sends each event type exactly once per transaction. If a delivery fails and is retried, it carries the same transactionId and event type. Dedupe on those two fields rather than the delivery ID.

Verifying signatures

Construct the signing string as {X-Cyrus-Timestamp}.{raw body}, compute HMAC-SHA256 with your webhook secret, and compare as hex:

const crypto = require("crypto");
const expected = "sha256=" + crypto
  .createHmac("sha256", secret)
  .update(timestamp + "." + body)
  .digest("hex");
if (expected !== signature) throw new Error("Invalid signature");

To avoid timing attacks, use a constant-time comparison. The timestamp also lets you enforce a replay window (e.g. reject signatures older than 5 minutes).