Skip to content

Webhooks

Download .md

Webhooks are how Paymee tells your server the authoritative outcome of a payment. Always fulfil orders from a webhook, not from the browser redirect.

Paymee POSTs a JSON body to the webhook_url you set on the session (or your account default), signed with the X-Paymee-Signature header.

Event When Act on it?
session.succeeded The payment completed. Session is now SUCCEEDED (terminal). Yes — fulfil the order.
session.failed A payment attempt was declined. Carries can_retry; the session is only dead once it reaches FAILED (terminal). When can_retry is false.
session.expired The payment session hit its ~24 h TTL without completing → now EXPIRED (terminal). The order will never be paid. Yes — release the order.
checkout.session.expired One hosted-page link hit its short TTL (~30 min). The payment session itself is unaffected and may still be paid. No — informational.

Both expiry events are a cue to re-engage the buyer — usually by emailing or SMSing them a fresh link. You don’t need a separate “create link” endpoint: recovery is just another call to POST /sessions/, which is idempotent on merchant_reference.

  • After checkout.session.expired (the link lapsed, the payment is still alive) — call POST /sessions/ again with the same merchant_reference. You get the same payment session back with a fresh checkout_url: the link is re-minted automatically when the previous one has expired. Send that URL to the buyer.
  • After session.expired (the whole payment is terminal) — replaying the same merchant_reference returns the session without a checkout_url (there’s nothing left to pay). To recover, create a new payment with a new merchant_reference — that mints a brand-new session and link. Send that.

A checkout_url carries the short (~30 min) link TTL, so send it promptly; if the buyer clicks after it lapses, just call POST /sessions/ again to mint another.

Every delivery has the same envelope; data carries the session, and some events add fields.

{
"id": "evt_9f2c…",
"event": "session.succeeded",
"created_at": "2026-06-27T10:24:00Z",
"data": {
"id": "ps_3Nk8x2c9aQ...",
"order_number": "PM-014A2C",
"merchant_reference": "order-0001",
"order_id": "",
"amount": "10.500",
"currency": "TND",
"description": "",
"status": "SUCCEEDED",
"buyer": {
"first_name": "Aymen",
"last_name": "Ben Salah",
"email": "aymen@example.com",
"phone": ""
},
"items": [],
"metadata": {},
"paid_at": "2026-06-27T10:24:00Z",
"payment": {
"method": "card",
"card_brand": "VISA",
"card_last_four": "1119",
"card_country": "TN",
"fee_amount": "0.300",
"net_amount": "10.200"
}
}
}
  • session.succeeded adds paid_at and a payment block.
  • session.failed adds last_failure_code, last_failure_message, attempts_count, and can_retry.

Every request includes these headers:

Header Purpose
X-Paymee-Signature t=<timestamp>,v1=<hex> — the HMAC to verify.
X-Paymee-Webhook-Id Stable event id, identical across retries — use it to deduplicate.
X-Paymee-Event The event type (e.g. session.succeeded).

The signature is computed as:

signed_payload = "<timestamp>.<raw_request_body>"
expected = HMAC_SHA256(signed_payload, your_webhook_secret) // whsec_…

Compare expected to the v1= value using a constant-time comparison.

import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
# header looks like: "t=1719481440,v1=abcdef..."
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, sig = parts["t"], parts["v1"]
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
  1. Return 2xx quickly. Acknowledge within a few seconds, then do heavy work asynchronously. Deliveries time out after 10 seconds; a non-2xx, a timeout, or a connection error is treated as a failed delivery.

  2. Make your handler idempotent. Deliveries are at-least-once — a retry (or an occasional duplicate) can arrive even after you already processed the event. Deduplicate on X-Paymee-Webhook-Id (it is stable across retries): record processed ids and make fulfilment safe to run twice, so a repeat delivery never double-ships an order or double-credits a balance.

  3. Retries. Failed deliveries are retried up to three times, after approximately 1 minute, 2 hours, and 24 hours.

You can inspect delivery history for a session via GET /gateway/sessions/{id}/webhooks/, or across the account via GET /gateway/webhooks/.

If all automatic retries are exhausted (or your endpoint was misconfigured at the time), you can resend a delivery from your Paymee dashboard — open Settings → Developers, find the event in the webhook logs, and choose Resend. A resend re-signs the payload with your current webhook secret and reuses the same X-Paymee-Webhook-Id, so an idempotent handler treats it as the same event.