Test your webhook endpoint before processing real transactions.
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
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.
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.
Cyrus sends signed POST requests to your webhook URL.
X-Cyrus-Event — event type, one of payment.succeeded, payment.reversed, payment.flagged, payout.completed, payout.failedX-Cyrus-Delivery — unique UUID for this delivery attemptX-Cyrus-Timestamp — epoch milliseconds of the deliveryX-Cyrus-Signature — sha256= 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{
"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"
}
}2xx to acknowledge — Cyrus stops retrying.5xx and 429 trigger retries with exponential backoff (up to the configured max attempts).4xx (400, 401, 403, 404, 410) are treated as permanent failures — the webhook is marked failed immediately.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.
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).