Webhooks
The envelope, the event catalog, verifying MolnPay-Signature, the delivery contract, and managing endpoints from the API.
9.1 Envelope
{
"id": "evt_01K3QW9Z8Y4M7F2N6X0P", // ULID — STABLE across retries AND replays
"object": "event",
"type": "deposit.confirmed",
"api_version": "2026-08-01",
"created": "2026-08-01T12:34:56.789Z",
"livemode": true,
"tenant": "8f1c…",
"sequence": 918273, // monotonic per tenant (gaps are normal)
"data": { /* the resource, identical to the REST GET response */ },
"attempt": 3,
"delivery_id": "whd_01K3QWA2…"
}data is the same shape the REST API returns, so you write one deserializer.
9.2 Event catalog
| Event | Meaning |
|---|---|
customer.created | |
wallet.created | an address was derived |
deposit.detected | seen on chain — not yet money |
deposit.confirmed | credited to the customer's ledger balance |
deposit.failed | a detected transfer turned out not to be money: reverted, or the chain disagrees with the claim. data.failure_reason says why. Clear your "pending" |
deposit.orphaned | a confirmed deposit was reorged out and reversed — you must reverse your own credit |
sweep.started / .confirmed / .failed | consolidation into the master wallet. Does not change customer balances |
payout.created / .approved / .rejected / .submitted / .confirmed / .failed / .canceled | data is the payout object; tx_hash appears from .submitted on |
conversion.completed / .failed | auto conversion of one credited payment (Settings → Auto conversion). failed means kept as received, never money lost |
balance.transferred / .transfer_reversed | staff moved a settled balance between merchants with no chain transaction; data.direction says which side you are on |
invoice.created | priced, addressed and quoted |
invoice.pending | a transfer was seen on chain, below finality — not yet money |
invoice.paid | final. Fulfil here, and only here |
invoice.underpaid | short by more than ±0.5%. The address stays live for a top-up; do not fulfil |
invoice.overpaid | over by more than ±0.5%; excess_amount says by how much |
invoice.expired | the window closed without enough arriving |
invoice.canceled | withdrawn by you while unpaid |
invoice.refunded | the excess went back, to an address the payer confirmed |
endpoint.test | fired by the "Send test event" button |
invoice.underpaid can arrive more than once on one invoice: each partial
payment is a new fact about how much is still owed, not a repeat of the last.
Every other invoice.* event is emitted at most once per state change.
9.3 Verifying the signature
MolnPay-Signature: t=1785312000,v1=5257a869e7ec…,v1=9f1c…Build the signed payload by concatenating:
- the value of
t(Unix timestamp, seconds, ASCII decimal), - the single character
.(U+002E), - the raw HTTP request body bytes, exactly as received.
Compute HMAC-SHA256(signed_payload, your_endpoint_secret), hex-encode
lowercase, and compare — in constant time — against every v1 value.
One match passes. Reject if |now − t| > 300 seconds.
Do not re-serialize the body before hashing.
JSON.parse→JSON.stringifyreorders keys and changes whitespace, and the HMAC will not match. This is the number-one cause of signature failures.
Two v1 values appear during a secret rotation: we sign with the old and the
new secret simultaneously (24h by default) so you can switch without a
coordinated cutover.
Copy a working implementation from the TypeScript verifier or the Python verifier. Both are the same logic the service uses and both include recipes for getting raw bytes out of Express, Fastify, Next.js, FastAPI, Flask and Django.
Every delivery also carries: MolnPay-Event-Id, MolnPay-Event-Type,
MolnPay-Delivery-Id, MolnPay-Attempt, MolnPay-Api-Version, and
Idempotency-Key (equal to the event id).
9.4 Delivery contract
- At least once. Dedupe on
id. - Unordered. Order on
sequence; do not assume arrival order. Sequence is monotonic per tenant but has gaps — compare, never count. - Ack fast. Return 2xx within 10 seconds, then process asynchronously. A slow 200 is recorded as a failed delivery and will be retried.
- The REST API is the source of truth. If an event and a
GETdisagree, trust theGET.
Retries: 10 attempts over roughly 46 hours, front-loaded (30s, 2m, 5m, 15m, 1h, 3h, 6h, 12h, 24h) with jitter.
Auto-disable: 5 consecutive exhausted deliveries disables your endpoint. An HTTP 410 Gone disables it immediately — that is the documented way to say "stop permanently".
Nothing is lost while disabled. Events still get a skipped delivery record,
so after fixing your endpoint you re-enable it and bulk-replay the gap.
9.5 Your endpoint URL must satisfy
httpsonly, port 443 or 8443- resolves to a public IP — private, loopback, link-local, CGNAT and cloud-metadata addresses are rejected
- no credentials in the URL
- no redirects — a 3xx is treated as a failure, not followed
These are re-checked on every send, not just at registration, because DNS is mutable.
9.6 Managing endpoints from the API
Everything below needs the webhooks:write scope (replay needs
webhooks:replay; reads need webhooks:read). Any key kind may hold them.
Register. POST /v1/webhooks with url, an optional description, and
event_types (an empty list, the default, means every event — including
types added later, so always keep a default branch). The 201 is the endpoint
plus secret, shown only here. Up to 16 endpoints per project.
Prove it. POST /v1/webhooks/{id}/test queues one endpoint.test through
the real pipeline — signed, SSRF-checked, retried — and answers 202 with a
delivery_id. Read GET /v1/webhook_deliveries/{delivery_id} until status
is succeeded; if it is not, response_status, response_body and
error_detail say why in that order of usefulness.
Rotate. POST /v1/webhooks/{id}/rotate_secret (overlap_hours, default
24, max 168). Until overlap_until, every delivery carries two v1 values and
either secret verifies, so switch your receiver at your own pace. The new secret
is shown only in that response.
Pause and resume. PATCH /v1/webhooks/{id} with status: "paused" stops
sends while still recording every event as a skipped delivery. status: "active" resumes. The same PATCH re-enables an endpoint we disabled (five
consecutive exhausted deliveries, or a 410 from you) and resets its strike
count.
Recover a gap. After an outage: fix the receiver, re-enable, then
POST /v1/webhook_deliveries/replay with endpoint_id (and optionally
status / since). It re-sends up to 100 failed, exhausted or skipped
deliveries, oldest first, each as a new delivery (replay_of) of the
same event — so a handler that dedupes on event.id is unaffected. Call it
again for a larger gap. One delivery at a time:
POST /v1/webhook_deliveries/{id}/replay.
Audit. GET /v1/events is every event your project emitted, ordered by
sequence — the number the envelope carries. Reconciling "did we miss one?" is
a comparison against this list, never a guess. GET /v1/events/{id}/deliveries
shows where each send of one event stood.
Delete. DELETE /v1/webhooks/{id} removes the endpoint and its delivery
history. The events themselves stay.