Webhooks
Mercuryo sends a POST with a JSON body to the webhook URL of your shop; Mercuryo sets the URL for
each shop. The delivery is declared in the
OpenAPI document under webhooks.
Events
type |
Sent when |
|---|---|
payin.requires_action |
The payer has a step to take |
payin.authorized |
A card payin is on hold |
payin.capture_failed, payin.void_failed |
The provider rejected a capture or a void; the payin is authorized again |
payin.completed, payin.failed |
The payin reaches a final status |
payin.refunded |
A refund of the payin completes |
refund.completed, refund.failed |
The refund reaches a final status |
payout.completed, payout.failed |
The payout reaches a final status |
Body
| Field | Type | Meaning |
|---|---|---|
id |
string | Event id, a UUID. The same on every redelivery — deduplicate on it |
type |
string | The event — see Events |
shop_id |
integer | The shop the event belongs to |
created_at |
date-time | When the event happened |
data.object |
object | The full transaction, as GET /v2/transactions/{transaction_id} returns it |
{
"id": "8d4b2a0c-5e9f-4a6d-9c1b-3f0e6a9d2cf8",
"type": "payin.completed",
"shop_id": 1024,
"created_at": "2026-09-29T09:00:20Z",
"data": { "object": { "id": "0b6f4e2a-7c1d-4e8b-9a3f-5d2c8e1b4a70", "object": "payin", "status": "completed" } }
}
data.object is shortened above.
Signature
One header:
Webhook-Signature: t=1790672400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | Meaning |
|---|---|
t |
Unix time of this delivery attempt, in seconds |
v1 |
Lowercase hex HMAC-SHA256 of <t>.<raw body> with the shop's signing secret. Two v1 during a secret rotation, one per secret |
Verify before you process:
-
Read
shop_idfrom the body only to pick the shop's signing secret. Trust nothing else in the body yet. - Sign
<t>.<raw body>— the raw bytes you received, not a re-serialized object. - Compare with each
v1in constant time. No match — do not process the body. - Reject a
tmore than 5 minutes from your clock — it protects against replays.
import hmac, hashlib, json, time
def verify(raw_body: bytes, signature_header: str, secrets_by_shop: dict) -> bool:
parts = [p.split("=", 1) for p in signature_header.split(",")]
t = next(v for k, v in parts if k == "t")
signatures = [v for k, v in parts if k == "v1"]
if abs(time.time() - int(t)) > 300:
return False
secret = secrets_by_shop[json.loads(raw_body)["shop_id"]]
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
Several shops
Each shop has its own webhook URL, signing secret and key; a payin shop and a payout shop are two
shops. Keep a map from shop_id to the shop's secret and key:
shop_id |
Signing secret | Key for API calls |
|---|---|---|
1024 payin EUR |
PAYIN_EUR_WEBHOOK_SECRET |
PAYIN_EUR_KEY |
1025 payout EUR |
PAYOUT_EUR_WEBHOOK_SECRET |
PAYOUT_EUR_KEY |
Delivery
- Answer
2xxwithin 30 seconds. Acknowledge first, process after. - Any other answer or a timeout: up to 15 redeliveries over about two days — 16 attempts in total. The pause grows with each attempt: N⁴ + 3 seconds before redelivery N — 4 s, 19 s, 84 s and so on.
- An event can arrive more than once. Deduplicate on
id. - A verified webhook is the result:
data.objectis the full transaction. -
Reading the status is the fallback channel. If no webhook has come for a minute, you can start polling the status with
GET /v2/transactions/{transaction_id}until it is final (R20). -
Events can arrive out of order. A final status never changes, except a payin
completed→refunded: ignore a non-final event for a transaction you already have as final.
Next: Migrating from v1