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:

  1. Read shop_id from the body only to pick the shop's signing secret. Trust nothing else in the body yet.
  2. Sign <t>.<raw body> — the raw bytes you received, not a re-serialized object.
  3. Compare with each v1 in constant time. No match — do not process the body.
  4. Reject a t more 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 2xx within 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.object is 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