Webhooks
BitMomo sends an HTTPS POST to your endpoint when one of your payments changes status. Webhooks tell you that something happened; always confirm the payment with the API before acting on it.
Set up an endpoint
Section titled “Set up an endpoint”In the dashboard, open Settings → Webhooks:
- Enter your endpoint. It must be a public HTTPS URL.
- Choose the events to receive. Most integrations need
payment.completedandpayment.needs_review. - Save, then copy the signing secret. It is shown only once; if you lose it, rotate it to get a new one.
Using the API instead, a key with webhooks:write can call PUT /api/v1/gateway/webhooks/config with url, events and is_active. The first configuration returns secret once. GET /gateway/webhooks/config never returns the secret.
One configuration applies to your whole merchant account. Saving again replaces the URL and events.
Events
Section titled “Events”Events are named payment.<status>, after the status the payment just entered, for example:
| Event | Sent when |
|---|---|
payment.confirming |
The customer’s transaction is on chain and waiting for confirmations. |
payment.completed |
The payment is confirmed and credited. |
payment.needs_review |
The payment is held for a manual check. |
payment.expired |
The quote expired without an accepted payment. |
payment.cancelled |
The payment was cancelled. |
payment.failed |
The payment failed. |
You only receive the events your configuration subscribes to.
Which endpoint receives events
Section titled “Which endpoint receives events”- If you create a payment without
callback_url, its events go to your active webhook configuration (if it subscribes to that event). - If you pass
callback_url, it must exactly match your registered, active endpoint, otherwise payment creation fails with400. Events for that payment then go only to that endpoint. - With no webhook configuration, no events are sent. Your integration can still poll the API.
The request
Section titled “The request”POST /webhooks/bitmomo HTTP/1.1Content-Type: application/jsonIdempotency-Key: 6b0d4a3e-2f5a-4c4e-9a43-9f7c1e2d8b10X-Webhook-ID: 6b0d4a3e-2f5a-4c4e-9a43-9f7c1e2d8b10X-Webhook-Event: payment.completedX-Webhook-Attempt: 1X-Webhook-Signature: 3f2a…(64 hex characters)X-Webhook-Timestamp: 2026-11-15T10:04:05.123456+00:00X-Webhook-Key-Version: 1X-Webhook-Sequence: 42X-BitMomo-Environment: test{ "event": "payment.completed", "payment_id": "1b113181-14c5-4cf7-97fc-64a180f2eda3", "status": "completed", "environment": "test", "order_id": "order_1001", "tx_hash": "0x…", "confirmations": 19, "occurred_at": "2026-11-15T10:04:01.000000+00:00", "_meta": { "delivery_id": "6b0d4a3e-2f5a-4c4e-9a43-9f7c1e2d8b10", "event": "payment.completed", "attempt": 1, "sequence": 42, "key_version": 1, "occurred_at": "2026-11-15T10:04:01.000000+00:00", "sent_at": "2026-11-15T10:04:05.123456+00:00" }}_meta field |
Meaning |
|---|---|
delivery_id |
Stable ID for this event delivery. Retries reuse it. Also in Idempotency-Key and X-Webhook-ID. |
attempt |
1 for the first send, then 2, 3… on retries. |
occurred_at |
When the event happened. Never changes between retries. |
sent_at |
When this attempt was signed. Fresh on every attempt; equals X-Webhook-Timestamp. |
sequence |
Increasing delivery number for your endpoint. Deliveries can still arrive out of order. |
key_version |
Version of the secret that signed this request; equals X-Webhook-Key-Version. |
Verify every request
Section titled “Verify every request”X-Webhook-Signature is the lowercase hex HMAC-SHA256 of the exact raw request body, keyed with your signing secret. Verify before parsing or trusting anything. Reject the request if:
- the signature does not match (compare in constant time);
_meta.sent_atis more than 5 minutes from your clock (replay protection);X-Webhook-TimestamporX-Webhook-Key-Versiondisagree with the signed_metavalues.
The SDK does all three:
import { CryptoPOS } from '@bitmomo/merchant-sdk';
const verified = CryptoPOS.verifyWebhookEvent(rawBody, headers['x-webhook-signature'], { secrets: [{ version: 1, secret: process.env.BITMOMO_WEBHOOK_SECRET! }], timestamp: headers['x-webhook-timestamp'], keyVersion: headers['x-webhook-key-version'],});
if (!verified.valid) { // verified.reason is e.g. 'bad-signature' or 'stale-timestamp' return respond(400);}const { deliveryId, event, occurredAt } = verified.envelope;Most frameworks parse JSON before your handler runs. Read the raw bytes instead: express.raw({ type: 'application/json' }) in Express, or Buffer.from(await request.arrayBuffer()) in a Next.js route handler.
Without the SDK, compute HMAC-SHA256(secret, rawBody) in hex and compare it with the header using a constant-time comparison, then apply the checks above.
Respond, then process
Section titled “Respond, then process”- Reply with any
2xxstatus within 10 seconds. Any other status, a timeout or a connection error counts as a failure. - Store the event durably before replying, keyed by
delivery_id, and do the real work (fulfilment, emails) afterwards from your own queue. - If storing fails, reply with an error so BitMomo retries.
Retries and duplicates
Section titled “Retries and duplicates”Failed deliveries are retried with exponential backoff, about 2, 4, 8 and 16 seconds apart, for up to 5 attempts in total. Retries reuse the same delivery_id and carry a fresh signature and sent_at.
The retry window is short, roughly half a minute. If your endpoint is down for longer, catch up by listing your payments when it comes back. Do not rely on webhooks alone.
Delivery is at least once. You will sometimes receive the same event twice, so deduplicate on delivery_id with a unique constraint in your database. The SDK’s createDeliveryDedupeCache() is an in-memory helper for simple cases; it does not survive restarts.
Rotating the signing secret
Section titled “Rotating the signing secret”Rotate in Settings → Webhooks, or with POST /api/v1/gateway/webhooks/config/rotate (scope webhooks:write). Rotation returns a new secret with the next version number, shown once. The previous secret keeps working for 24 hours, so in-flight retries still verify.
During the changeover, give the verifier both secrets; the key version on each request picks the right one:
secrets: [ { version: 1, secret: process.env.BITMOMO_WEBHOOK_SECRET_V1! }, { version: 2, secret: process.env.BITMOMO_WEBHOOK_SECRET_V2! },],Rotate immediately if a secret may have leaked. Never log secrets or full signatures.