Webhooks
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.
Events
Section titled “Events”| 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. |
Recovering an abandoned payment
Section titled “Recovering an abandoned payment”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) — callPOST /sessions/again with the samemerchant_reference. You get the same payment session back with a freshcheckout_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 samemerchant_referencereturns the session without acheckout_url(there’s nothing left to pay). To recover, create a new payment with a newmerchant_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.
Payload
Section titled “Payload”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.succeededaddspaid_atand apaymentblock.session.failedaddslast_failure_code,last_failure_message,attempts_count, andcan_retry.
Verify the signature
Section titled “Verify the signature”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)import crypto from "node:crypto";
function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const signed = Buffer.concat([ Buffer.from(`${parts.t}.`), Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody), ]); const expected = crypto .createHmac("sha256", secret) .update(signed) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(parts.v1), );}<?phpfunction verify(string $rawBody, string $header, string $secret): bool { parse_str(str_replace(",", "&", $header), $parts); // t=..., v1=... $signed = $parts["t"] . "." . $rawBody; $expected = hash_hmac("sha256", $signed, $secret); return hash_equals($expected, $parts["v1"]);}Responding & retries
Section titled “Responding & retries”-
Return
2xxquickly. 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. -
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. -
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/.
Manually resending
Section titled “Manually resending”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.

