Webhooks
Webhooks are how Paymee tells your server the authoritative outcome of a payment or a refund. Always fulfil orders from a webhook, not from the browser redirect.
Paymee POSTs a signed JSON event to the webhook_url you set on the session, or to
your account’s default endpoint (Settings → Developers in your dashboard). Test mode
(sk_test_…) and live mode (sk_live_…) each have their own endpoint and their own
signing secret.
The URL must use https:// on a public domain name (not an IP address). Paymee refuses
to deliver to private or internal addresses.
Events
Section titled “Events”| Event | When | Act on it? |
|---|---|---|
session.succeeded |
The payment succeeded. The session is SUCCEEDED (final). |
Yes — fulfil the order. |
session.attempt_failed |
One payment attempt was declined. While can_retry is true, the buyer can try again on the same page. |
Optional — e.g. log it, or follow up with the buyer. |
session.failed |
Every allowed attempt was declined (max_attempts, default 5). The session is FAILED (final). |
Yes — the order can’t be paid on this session. |
session.expired |
The session reached the end of its lifetime (about 24 hours) unpaid. It is EXPIRED (final). |
Yes — release the order. |
checkout.session.expired |
One hosted-page link expired (about 30 minutes). The payment session itself is still payable. | No — informational. |
refund.succeeded |
A refund returned the money to the buyer. | Yes — mark the order refunded. |
refund.failed |
An accepted refund ended without the money being returned. | Yes — the buyer was not refunded. |
Every declined attempt sends session.attempt_failed. The last allowed decline sends
session.attempt_failed and then session.failed. There is no event for a canceled
session: a session is only canceled by your own
POST /gateway/sessions/{id}/cancel/ call, whose
response is the canceled session.
Refund events are sent for every refund, whichever way it was issued: through the API, from your dashboard, or from an e-commerce plugin.
Recovering an abandoned payment
Section titled “Recovering an abandoned payment”Both expiry events are a cue to re-engage the buyer, for example by emailing them a
fresh link. Recovery is just another call to
POST /gateway/sessions/, which is
idempotent on merchant_reference:
- After
checkout.session.expired(the link lapsed, the payment is still open) — callPOST /gateway/sessions/again with the samemerchant_reference, amount and currency. You get the same session back with a freshcheckout_url. - After
session.expiredorsession.failed(the payment is final) — replaying the samemerchant_referencereturns the session without acheckout_url. Create a new payment with a newmerchant_referenceinstead.
A checkout_url is valid for about 30 minutes, so send it promptly. If the buyer clicks
after it lapses, call POST /gateway/sessions/ again to get another one.
Payload
Section titled “Payload”Every event has the same envelope:
| Field | Meaning |
|---|---|
id |
The event id (evt_…). Identical across retries, and also sent as the X-Paymee-Webhook-Id header. |
event |
The event type, e.g. session.succeeded. |
created_at |
When the event happened (ISO 8601, UTC, ending in Z). |
livemode |
true for live mode, false for test mode. |
data |
The payment session (session.*, checkout.session.expired) or the refund (refund.*). |
Session events
Section titled “Session events”{ "id": "evt_5f1c2a9d8e7b6a5f4e3d2c1b", "event": "session.succeeded", "created_at": "2026-11-20T10:24:00.512000Z", "livemode": true, "data": { "id": "ps_0b7c3a1e-5d2f-4e8a-9c6b-1f2e3d4c5b6a", "order_number": "PM-014A2C", "merchant_reference": "order-0001", "order_id": "", "amount": "10.500", "currency": "TND", "original": { "amount": "10.500", "currency": "TND", "rate": "1.000000" }, "description": "", "status": "SUCCEEDED", "buyer": { "first_name": "Aymen", "last_name": "Ben Salah", "email": "aymen@example.com", "phone": "" }, "items": [], "metadata": { "cart_id": "8812" }, "completed_at": "2026-11-20T10:24:00.497000Z", "payment": { "method": "CARD", "card_brand": "VISA", "card_last_four": "1119", "card_country": "Tunisia", "fee_amount": "0.300", "net_amount": "10.200" } }}data.amount is what the buyer was charged (TND) and data.original is what you
requested — they differ only for foreign-currency requests.
data.completed_at is when the session reached a final status (null while it is open).
Some events add fields to data:
| Event | Extra fields in data |
|---|---|
session.succeeded |
payment (method, card_brand, card_last_four, card_country — the issuing bank’s country name — fee_amount, net_amount). |
session.attempt_failed |
attempt (id, attempt_number, failure_code, failure_message), attempts_count, attempts_remaining, can_retry. |
session.failed |
last_failure_code, last_failure_message, attempts_count, and can_retry (always false). |
checkout.session.expired |
checkout_session_token (a short prefix of the expired link’s token). |
failure_code and failure_message are the card acquirer’s decline code and reason. Use
them for logs and support, not for program logic: their values can change.
Refund events
Section titled “Refund events”data is the refund, exactly as the API returns it:
{ "id": "evt_8a2b4c6d8e0f1a3b5c7d9e1f", "event": "refund.succeeded", "created_at": "2026-11-21T09:12:03.104000Z", "livemode": true, "data": { "id": "re_4f1d0c2e-9b7a-4c51-8f0e-2a6b1c3d4e5f", "session_id": "ps_0b7c3a1e-5d2f-4e8a-9c6b-1f2e3d4c5b6a", "merchant_reference": "order-0001", "amount": "3.000", "currency": "TND", "refund_type": "PARTIAL", "status": "SUCCEEDED", "reason": "Damaged item", "processor_message": null, "created_at": "2026-11-21T09:12:01.870000Z", "completed_at": "2026-11-21T09:12:03.095000Z" }}The full schema of every event is in the API reference, under Webhooks. New fields may be added at any time; ignore the ones you don’t use.
Verify the signature
Section titled “Verify the signature”Every request carries these headers:
| Header | Purpose |
|---|---|
X-Paymee-Signature |
t=<unix timestamp>,v1=<hex>[,v1=<hex>…] — one or more HMAC signatures. |
X-Paymee-Webhook-Id |
The event id, identical across retries — use it to deduplicate. |
X-Paymee-Event |
The event type (e.g. session.succeeded). |
signed_payload = "<t>.<raw_request_body>"signature = hex( HMAC_SHA256(key = your whsec_… secret, message = signed_payload) )Accept the request only when:
- any
v1value equals your computed signature, compared in constant time — after you rotate your secret, the header carries twov1values for 24 hours (see Rotating your secret); and tis within 5 minutes of your server’s clock. Every delivery, retries included, is signed when it is sent, so a genuine request is never older than that.
Otherwise respond 400 and do nothing. A malformed header must not crash your handler.
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300 # reject events signed more than 5 minutes ago
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = TOLERANCE_SECONDS) -> bool: """True only if a v1 signature matches and the timestamp is recent.""" timestamp, signatures = None, [] for part in (header or "").split(","): key, sep, value = part.strip().partition("=") if not sep: continue if key == "t": timestamp = value elif key == "v1": signatures.append(value) # several during a secret rotation if not timestamp or not timestamp.isdigit() or not signatures: return False if abs(time.time() - int(timestamp)) > tolerance: return False signed = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject events signed more than 5 minutes ago
export function verify(rawBody, header, secret, tolerance = TOLERANCE_SECONDS) { let timestamp; const signatures = []; for (const part of String(header ?? "").split(",")) { const i = part.indexOf("="); if (i === -1) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t") timestamp = value; else if (key === "v1") signatures.push(value); // several during a secret rotation } if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8"); const expected = Buffer.from( crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex"), ); return signatures.some((sig) => { const candidate = Buffer.from(sig); return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected); });}<?phpconst TOLERANCE_SECONDS = 300; // reject events signed more than 5 minutes ago
function verify(string $rawBody, string $header, string $secret, int $tolerance = TOLERANCE_SECONDS): bool { $timestamp = null; $signatures = []; foreach (explode(",", $header) as $part) { $pair = explode("=", trim($part), 2); if (count($pair) !== 2) continue; [$key, $value] = $pair; if ($key === "t") $timestamp = $value; elseif ($key === "v1") $signatures[] = $value; // several during a secret rotation } if ($timestamp === null || !ctype_digit($timestamp) || !$signatures) return false; if (abs(time() - (int) $timestamp) > $tolerance) return false; $expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret); foreach ($signatures as $sig) { if (hash_equals($expected, $sig)) return true; } return false;}Verifying the signature is the supported way to authenticate webhooks. Paymee does not publish a list of source IP addresses: they can change without notice.
Rotating your secret
Section titled “Rotating your secret”Rotate your whsec_… secret from Settings → Developers in your dashboard. The new
secret is shown once. For the next 24 hours, every delivery is signed with both
the new and the previous secret (two v1 values), so you can deploy the new secret at
any time in that window without losing events. After 24 hours only the new secret
signs. If a secret leaks, rotate it and deploy the new one straight away.
Responding & retries
Section titled “Responding & retries”-
Return
2xxquickly. Acknowledge within 10 seconds, then do the work asynchronously. A non-2xxstatus, a timeout, a connection error or a redirect (3xx) is a failed delivery. Redirects are not followed: configure the final URL. -
Expect retries. A failed delivery is retried 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours after each failed attempt — up to 10 deliveries over about 3 days. Retries go to the URL the event was first sent to. If you remove your endpoint, pending retries stop.
-
Make your handler idempotent. Delivery is at least once: the same event can arrive more than once, even after you answered
2xx. Record each processedX-Paymee-Webhook-Id(the same value as the body’sid) and skip repeats. -
Don’t rely on order. Events are sent within seconds of the change they describe, but two events can arrive in a different order than they happened (a retry, for example, arrives late). Each payload carries the object’s current
statusand the event’screated_at; when in doubt, fetch the session withGET /gateway/sessions/{id}/.
You can inspect delivery history for a session with
GET /gateway/sessions/{id}/webhooks/,
or across your account with
GET /gateway/webhooks/.
Manually resending
Section titled “Manually resending”If all retries are exhausted (or your endpoint was down for longer), resend a delivery
from your dashboard: open Settings → Developers, find the event in the webhook logs,
and choose Resend. A resend is signed with your current secret and keeps the same
X-Paymee-Webhook-Id, so an idempotent handler treats it as the same event.
Testing your endpoint
Section titled “Testing your endpoint”You don’t need a payment to check your handler. In your dashboard, switch to test mode, open Settings → Developers, and use Send a test event under your webhook URL:
-
Pick the event — any of the seven above.
-
Optionally enter your own reference (and an amount). It is sent as
data.merchant_reference(and, for session events,data.order_id), so you can point the event at an order in your staging system. -
Send. Paymee POSTs one signed event to your test-mode webhook URL and shows your endpoint’s status code, response time and the start of its response.
A test event is shaped like a real one and signed with your test-mode secret, so it exercises your signature check and your parsing. The differences:
- Test mode only. It carries
livemode: falseand goes to your test-mode endpoint. - Fake identifiers. Ids start with
test_(the event id withevt_test_) and match no real session, so looking one up through the API returns404. - No retries, no history. It is sent once and does not appear in your webhook logs.
- Limited to 5 test events per minute.
A test session.attempt_failed is a first decline (can_retry: true); a test refund
event is a full refund of the test session.

