MolnPayDocs
Guides

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

EventMeaning
customer.created
wallet.createdan address was derived
deposit.detectedseen on chain — not yet money
deposit.confirmedcredited to the customer's ledger balance
deposit.faileda 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.orphaneda confirmed deposit was reorged out and reversed — you must reverse your own credit
sweep.started / .confirmed / .failedconsolidation into the master wallet. Does not change customer balances
payout.created / .approved / .rejected / .submitted / .confirmed / .failed / .canceleddata is the payout object; tx_hash appears from .submitted on
conversion.completed / .failedauto conversion of one credited payment (Settings → Auto conversion). failed means kept as received, never money lost
balance.transferred / .transfer_reversedstaff moved a settled balance between merchants with no chain transaction; data.direction says which side you are on
invoice.createdpriced, addressed and quoted
invoice.pendinga transfer was seen on chain, below finality — not yet money
invoice.paidfinal. Fulfil here, and only here
invoice.underpaidshort by more than ±0.5%. The address stays live for a top-up; do not fulfil
invoice.overpaidover by more than ±0.5%; excess_amount says by how much
invoice.expiredthe window closed without enough arriving
invoice.canceledwithdrawn by you while unpaid
invoice.refundedthe excess went back, to an address the payer confirmed
endpoint.testfired 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:

  1. the value of t (Unix timestamp, seconds, ASCII decimal),
  2. the single character . (U+002E),
  3. 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.parseJSON.stringify reorders 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 GET disagree, trust the GET.

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

  • https only, 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.

On this page