Skip to main content
A payment completes asynchronously — the payer signs, a bridge moves value, and funds land some seconds or minutes later. The webhook is how you learn about it without polling.

The nine events

Three lifecycle outcomes for each of the three products: Registering an endpoint without naming events subscribes it to all nine. That is the safe default: an unwanted delivery is visible and ignorable, a missing one is neither.
Subscribe to the .expired and .failed events, not only .completed. Without them a session that dies is silent, and an integration that treats “no webhook yet” as “still pending” waits forever for a delivery that is never coming.
Full payload schemas are in the API reference.

The payload

data.destAmount is absent on an open-sized deposit — there was no target amount to compare against. Credit the account from paidAmount, and treat a missing destAmount as “any amount was acceptable” rather than as an error.The session read spells the same thing differently, which has caught people: it always carries destAmount, using "0" for an open session, and reports openSized: true alongside. Branch on openSized rather than comparing against "0" — the sentinel is an artifact of a NOT NULL column, not a contract.

Verifying the signature

Every delivery carries two signature headers. Use x-hypermid-signature-v2:
Compute HMAC_SHA256(secret, "<t>.<raw body>") and compare in constant time.
The two timestamps intentionally use different units. The t= value in x-hypermid-signature-v2 is Unix seconds and is the value included in the signed string. The webhook body’s timestamp field is Unix milliseconds (Date.now() style) and is informational; do not use it as t or divide it into the signed body before verifying. For example, a header t=1755561600 corresponds to a body timestamp near 1755561600000.
The SDK ships this — one import instead of twenty-five lines you have to get right:
It handles the replay window and the rotation overlap. If you would rather implement it yourself:
Sign the raw request body bytes, exactly as received. Parsing to JSON and re-serializing reorders keys and changes whitespace, which changes the hash. This is the single most common cause of “signature does not match” — in Express, reach for express.raw() on the webhook route, not express.json().

The legacy header

x-hypermid-signature is a bare hex HMAC over the body alone, with no timestamp binding and therefore no replay protection. It is still sent so existing integrations keep working. Do not build new verifiers on it.

Rotation

Rotating mints a new secret and demotes the current one to a 24-hour overlap window. During that window deliveries carry a second v1= computed with the old secret, so a verifier holding either value still succeeds. Update your stored secret at your leisure inside the window.

Delivery guarantees

Retries mean the same data.id can arrive more than once. Key on data.id and ignore an id you have already settled. Doing the work twice is your bug to prevent, not something the network can guarantee away.
We time out at 10 seconds and count anything that is not a 2xx as a failure worth retrying. Acknowledge first, do slow work afterwards.
Deliveries are independent. Do not infer sequence from arrival order — use timestamp, or re-read the session.
Re-checked at delivery time, not only at registration, so repointing a registered hostname at an internal address will not reach it.

Environments

An endpoint belongs to the environment of the key that registered it. Register with sk_test_… and it receives sandbox completions only; register with sk_live_… and it receives production ones. Neither key can list, delete, rotate or read the secret of the other’s endpoints. So a sandbox integration is exercised end to end without any risk of a test delivery reaching the handler that credits real accounts — and without a sandbox key being able to reach a production signing secret.
Each environment has its own signing secret, because each endpoint does. Store them separately; verifying a sandbox delivery against your production secret fails, correctly.

Testing

POST /v1/payments/webhooks/{webhookId}/test sends a realistically-shaped delivery, as does the Test button in the dashboard.
A test delivery carries test: true at the top level. A real payment never does. Make your production handler reject or sandbox those — otherwise somebody pressing Test credits a live account.
You can also replay a body at your own receiver from the Postman collection, which ships the three events as example requests.