# Webhooks (/guides/webhooks)



<!-- generated from docs/wallet-api/QUICKSTART.md §9 — edit THAT file -->

## 9.1 Envelope [#91-envelope]

```jsonc
{
  "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 [#92-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**                                                                                                             |
| `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.refund_requested`                                                                           | you asked for a refund of a paid invoice. Nothing has left yet                                                                                                                                      |
| `invoice.refunded`                                                                                   | a refund's payout **confirmed on chain** — yours, or the overpayment excess the payer directed. `data.refund` says which and how much; `data.status` is `refunded` once the whole receipt went back |
| `invoice.refund_failed`                                                                              | the refund's payout failed, was rejected or was cancelled; the amount is back on your balance                                                                                                       |
| `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 [#93-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.parse` → `JSON.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](/guides/verify-signature) or
[the Python verifier](/guides/verify-signature). 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 [#94-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 [#95-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 [#96-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.
