Skip to content

Webhooks

Download .md

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.

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.

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) — call POST /gateway/sessions/ again with the same merchant_reference, amount and currency. You get the same session back with a fresh checkout_url.
  • After session.expired or session.failed (the payment is final) — replaying the same merchant_reference returns the session without a checkout_url. Create a new payment with a new merchant_reference instead.

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.

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.*).
{
"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.

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.

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:

  1. any v1 value equals your computed signature, compared in constant time — after you rotate your secret, the header carries two v1 values for 24 hours (see Rotating your secret); and
  2. t is 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 hashlib
import hmac
import 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)

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.

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.

  1. Return 2xx quickly. Acknowledge within 10 seconds, then do the work asynchronously. A non-2xx status, a timeout, a connection error or a redirect (3xx) is a failed delivery. Redirects are not followed: configure the final URL.

  2. 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.

  3. Make your handler idempotent. Delivery is at least once: the same event can arrive more than once, even after you answered 2xx. Record each processed X-Paymee-Webhook-Id (the same value as the body’s id) and skip repeats.

  4. 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 status and the event’s created_at; when in doubt, fetch the session with GET /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/.

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.

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:

  1. Pick the event — any of the seven above.

  2. 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.

  3. 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: false and goes to your test-mode endpoint.
  • Fake identifiers. Ids start with test_ (the event id with evt_test_) and match no real session, so looking one up through the API returns 404.
  • 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.