# 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 `POST`s 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

| 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/`](/guides/hosted-checkout/#canceling) 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.

<Aside type="note" title="“session” vs “checkout session” — they’re different objects">
`session.expired` and `checkout.session.expired` look alike but fire on
**different objects**, with **different lifetimes** and **different consequences**:

- **`session.expired` → the payment.** This is the payment session you created
  with `POST /gateway/sessions/`. It lives about **24 hours** and has the full
  [status lifecycle](/guides/hosted-checkout/#session-status). On expiry it
  becomes `EXPIRED`, a **final** state — the order can never be paid on it.
  **Act on it:** release stock, mark the order abandoned.
- **`checkout.session.expired` → one hosted-page link.** The `checkout_url` you
  redirect the buyer to is a separate, short-lived object (about **30 minutes**). When
  it lapses, the **payment session stays open** and keeps its own 24-hour clock.
  **Don’t end the order on it** — it only means the buyer walked away from that link.

A buyer who opens checkout and disappears typically triggers
**`checkout.session.expired` first, then `session.expired`** hours later. Drive
fulfilment off the `session.*` events; most integrations ignore
`checkout.session.expired`.
</Aside>

### 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/`](/guides/hosted-checkout/#create-a-session), which is
[idempotent on `merchant_reference`](/guides/idempotency/):

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

## 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

```json
{
  "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](/guides/amounts-and-currency/).
`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

`data` is the [refund](/guides/refunds/#the-refund-object), exactly as the API returns it:

```json
{
  "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](/reference/), under
**Webhooks**. New fields may be added at any time; ignore the ones you don't use.

## 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:

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](#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.

<Aside type="caution">
Verify against the **raw request body bytes**, before any JSON parsing. Re-encoding the
parsed JSON changes the bytes and breaks the signature.
</Aside>

<Tabs syncKey="lang">
<TabItem label="Python">

```python
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)
```

</TabItem>
<TabItem label="Node.js">

```js
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);
  });
}
```

</TabItem>
<TabItem label="PHP">

```php
<?php
const 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;
}
```

</TabItem>
</Tabs>

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

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

<Steps>

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}/`](/reference/operations/getpaymentsession/).

</Steps>

<Aside type="caution" title="Idempotency is required, not optional">
Treat `X-Paymee-Webhook-Id` as the idempotency key and guard every side effect behind
it, so a repeated delivery never ships an order twice or credits a balance twice.
</Aside>

You can inspect delivery history for a session with
[`GET /gateway/sessions/{id}/webhooks/`](/reference/operations/listsessionwebhookdeliveries/),
or across your account with
[`GET /gateway/webhooks/`](/reference/operations/listwebhookdeliveries/).

### 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

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:

<Steps>

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.

</Steps>

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.

:::note[Coming from Paymee v1/v2?]
The legacy `check_sum = md5(token + payment_status + API_Token)` is **replaced** by the
HMAC-SHA256 `X-Paymee-Signature` above, using a dedicated `whsec_…` secret (separate
from your API key).
:::

Source: https://docs.paymee.online/guides/webhooks/
