# Paymee Developers — full documentation
> Integrate the Paymee payment gateway: hosted checkout, signed webhooks, refunds, and idempotent REST writes.
> Source: https://docs.paymee.online/
---
# Quickstart
This guide takes you from zero to a working payment in **under 10 minutes**. You will
create a payment session and redirect a test buyer to the hosted checkout.
## Prerequisites
- A Paymee merchant account.
- Your **test secret key** (`sk_test_…`) from the dashboard. See
[Authentication](/guides/authentication/).
1. ### Create a payment session
Send your first request. The only required fields are the amount, a unique
`merchant_reference`, and the buyer's name and email.
```bash
curl https://api.paymee.online/api/v1/gateway/sessions/ \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"amount": "10.500",
"currency": "TND",
"merchant_reference": "order-0001",
"first_name": "Aymen",
"last_name": "Ben Salah",
"email": "aymen@example.com",
"return_url": "https://your-site.tn/orders/order-0001/paid",
"cancel_url": "https://your-site.tn/cart",
"webhook_url": "https://your-site.tn/webhooks/paymee"
}'
```
```js
const res = await fetch(
"https://api.paymee.online/api/v1/gateway/sessions/",
{
method: "POST",
headers: {
Authorization: "Bearer sk_test_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: "10.500",
currency: "TND",
merchant_reference: "order-0001",
first_name: "Aymen",
last_name: "Ben Salah",
email: "aymen@example.com",
return_url: "https://your-site.tn/orders/order-0001/paid",
cancel_url: "https://your-site.tn/cart",
webhook_url: "https://your-site.tn/webhooks/paymee",
}),
},
);
const session = await res.json();
console.log(session.checkout_url);
```
```php
true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_your_key_here",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"amount" => "10.500",
"currency" => "TND",
"merchant_reference" => "order-0001",
"first_name" => "Aymen",
"last_name" => "Ben Salah",
"email" => "aymen@example.com",
"return_url" => "https://your-site.tn/orders/order-0001/paid",
"cancel_url" => "https://your-site.tn/cart",
"webhook_url" => "https://your-site.tn/webhooks/paymee",
]),
]);
$session = json_decode(curl_exec($ch), true);
echo $session["checkout_url"];
```
Send `amount` as a string with exactly three decimals for TND (`"10.500"`). All URLs
must use `https://` on a public domain name; to receive webhooks on your own machine,
expose it through an HTTPS tunnel.
2. ### Read the response
A `201 Created` returns the session, including its `checkout_url`:
```json
{
"id": "ps_0b7c3a1e-5d2f-4e8a-9c6b-1f2e3d4c5b6a",
"merchant_reference": "order-0001",
"amount": "10.500",
"currency": "TND",
"status": "CREATED",
"livemode": false,
"checkout_url": "https://pay.paymee.online/checkout/kq2V8Zr3xN0bT6mW1yPdF4sHjL9cA7uE",
"expires_at": "2026-11-21T10:24:00.000000Z"
}
```
The full response has more fields — see
[Hosted checkout](/guides/hosted-checkout/#response).
3. ### Redirect the buyer
Send the buyer to the `checkout_url`. They pay on the Paymee-hosted page (with 3-D
Secure). After a successful payment, Paymee redirects them to your `return_url`; the
page's **Return to store** button takes them to your `cancel_url`.
```js
res.redirect(session.checkout_url);
```
4. ### Pay with a test card
On the checkout page, use a [test card](/guides/testing/):
- **Visa** `4509 2111 1111 1119`, expiry `12/26`, CVV `748`
- **3-D Secure code:** `12345`
5. ### Receive the result
Do **not** rely on the buyer returning to `return_url` to mark an order paid (they may
close the tab). Instead, handle the **webhooks** Paymee sends to your `webhook_url`:
- `session.succeeded` → fulfil the order
- `session.failed` → every attempt was declined: the session is closed; offer a new
payment (with a new `merchant_reference`)
- `session.expired` → the buyer never paid: release the order
Set up and verify webhooks next: [Webhooks](/guides/webhooks/).
## What's next
- [Hosted checkout flow](/guides/hosted-checkout/) — the full lifecycle and every field.
- [Webhooks](/guides/webhooks/) — verify signatures and handle events reliably.
- [Fulfil an order](/guides/fulfil-an-order/) — the whole flow as code you can copy.
- [Going live](/guides/going-live/) — the production checklist.
Source: https://docs.paymee.online/guides/quickstart/
---
# Authentication
The Paymee gateway uses **two distinct credentials**. Keep both server-side and
never expose them in browser or mobile clients.
| Credential | Format | Where it's used |
| --- | --- | --- |
| **Secret API key** | `sk_live_…` (live mode), `sk_test_…` (test mode) | The `Authorization` header on every API request |
| **Webhook secret** | `whsec_…` | Verifying the signature on webhooks you receive — see [Webhooks](/guides/webhooks/) |
Both are issued from your **Paymee merchant dashboard**:
- **Test keys** (`sk_test_…`) are available in the dashboard and can be retrieved
while you build.
- **Live keys** (`sk_live_…`) require an approved business verification (KYB) and
are shown **once** at creation — store them securely (e.g. a secrets manager)
and rotate from the dashboard if lost.
## Authorize a request
Pass your secret key as a Bearer token:
```http
POST /api/v1/gateway/sessions/ HTTP/1.1
Host: api.paymee.online
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
```
## Test vs. live is decided by the key
There is **one base URL** for everything:
```
https://api.paymee.online/api/v1
```
You do **not** use a separate host for testing — the **key prefix determines the
mode** on the same host:
- `sk_test_…` → **test mode** (no real money; use [test cards](/guides/testing/)).
- `sk_live_…` → **live mode** (real payments).
:::caution
Treat secret keys like passwords. Don't commit them to source control, embed
them in front-end code, or log them. Rotate immediately if exposed.
:::
## Errors
A missing, malformed, expired, or revoked key returns `401 Unauthorized` as an
[RFC 9457 problem document](/guides/errors/):
```json
{
"type": "https://api.paymee.online/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid or expired API key."
}
```
Source: https://docs.paymee.online/guides/authentication/
---
# Hosted checkout flow
The hosted checkout is the standard way to accept a card payment. Paymee hosts the
payment page and handles 3-D Secure, so card data never reaches your servers.
## Lifecycle
1. **Create a session** — `POST /gateway/sessions/` with the amount, the buyer, and
your URLs. The response includes a `checkout_url`.
2. **Redirect the buyer** to `checkout_url`.
3. **The buyer pays** on the Paymee-hosted page (with 3-D Secure). After a decline they
can try again on the same page, up to `max_attempts` times. See
[The payment page](#the-payment-page).
4. **Paymee sends a webhook** to your `webhook_url` with the authoritative result.
Fulfil the order here, not on the browser redirect.
5. **After a successful payment, the buyer is redirected** to your `return_url`.
```mermaid
sequenceDiagram
participant S as Your server
participant P as Paymee
participant B as Buyer
S->>P: POST /gateway/sessions/
P-->>S: { checkout_url }
S->>B: redirect to checkout_url
B->>P: pays on hosted page (3-D Secure)
P->>S: webhook session.succeeded (authoritative) → fulfil
P->>B: redirect to return_url
```
## Create a session
`POST https://api.paymee.online/api/v1/gateway/sessions/`
### Request fields
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `amount` | string | ✅ | Decimal string with the currency's exact number of decimals: **3 for TND**, e.g. `"10.500"`. See [Amounts & currency](/guides/amounts-and-currency/). |
| `currency` | string | – | ISO 4217 code. Defaults to your account's currency (`TND`). |
| `merchant_reference` | string (≤ 200) | ✅ | Your unique reference for this payment, e.g. your order id. See [Idempotency](/guides/idempotency/). |
| `first_name` | string (≤ 100) | ✅ | Buyer's given name. |
| `last_name` | string (≤ 100) | ✅ | Buyer's surname. |
| `email` | string (≤ 255) | ✅ | Buyer's email. |
| `phone` | string (≤ 20) | – | Buyer's phone. |
| `description` | string (≤ 500) | – | What the buyer is paying for. |
| `order_id` | string (≤ 100) | – | Your display-friendly order id. |
| `items` | array of objects | – | Line items, e.g. `[{ "name": "T-shirt", "quantity": 2, "unit_price": "25.000" }]`. Returned as you sent them. |
| `metadata` | object | – | Your own key/values, returned on the session and every webhook. Up to 50 keys; keys up to 40 characters, without `[` or `]`; values up to 500 characters. Numbers and booleans are stored as text (`42` → `"42"`); objects and lists are refused. |
| `return_url` | string (≤ 2048) | – | Where the buyer is sent after a successful payment. See [Returning the buyer](#returning-the-buyer). |
| `cancel_url` | string (≤ 2048) | – | Where the buyer goes back to your store without paying. |
| `webhook_url` | string (≤ 2048) | – | Where Paymee sends this session's events. Defaults to your account's endpoint. See [Webhooks](/guides/webhooks/). |
| `max_attempts` | integer | – | Payment attempts allowed on the session, 1–10 (default 5). |
| `locale` | string | – | Language of the payment page: `auto` (default), `fr` or `en`. See [Language](#language). |
All three URLs must use `https://` on a domain name; `webhook_url` must not be an IP
address. An optional text field sent as `""` counts as not given.
See the full schema in the
[API Reference → Create a payment session](/reference/operations/createpaymentsession/).
### Response
`201 Created`, with the session:
```json
{
"id": "ps_0b7c3a1e-5d2f-4e8a-9c6b-1f2e3d4c5b6a",
"merchant_reference": "order-0001",
"order_number": "PM-014A2C",
"order_id": "",
"amount": "10.500",
"currency": "TND",
"original": { "amount": "10.500", "currency": "TND", "rate": "1.000000" },
"description": "",
"items": [],
"status": "CREATED",
"livemode": true,
"buyer": {
"first_name": "Aymen",
"last_name": "Ben Salah",
"email": "aymen@example.com",
"phone": ""
},
"metadata": { "cart_id": "8812" },
"return_url": "https://your-site.tn/orders/order-0001/paid",
"cancel_url": "https://your-site.tn/cart",
"webhook_url": "https://your-site.tn/webhooks/paymee",
"max_attempts": 5,
"checkout_url": "https://pay.paymee.online/checkout/kq2V8Zr3xN0bT6mW1yPdF4sHjL9cA7uE",
"locale": "auto",
"expires_at": "2026-11-21T10:24:00.000000Z",
"completed_at": null,
"created_at": "2026-11-20T10:24:00.000000Z",
"attempts": [],
"refunds": []
}
```
`buyer` is the same object as in webhooks. Each entry of `attempts` has
`failure_code` and `failure_message` — the acquirer's decline code and reason, as in
webhooks — when that attempt was declined.
Redirect the buyer to `checkout_url` exactly as returned. Sending the same
`merchant_reference` again returns this session with `200 OK` instead of creating a
second one — see [Idempotency](/guides/idempotency/).
## The payment page
`checkout_url` opens Paymee's payment page, where the buyer pays by card (Visa or
Mastercard).
- **Two pages, one flow.** Paymee's page shows the amount, a language switcher, the result
and the buttons back to your store. The card is entered on the page of ClicToPay, the
acquiring bank, so card details never reach your servers.
- **3-D Secure on every payment.** The buyer's bank checks that the card is theirs: it may
ask for a code by SMS or an approval in its app, or approve without asking anything.
- **A decline is not the end.** The buyer stays on the page and can try again, with the
same card or another, up to `max_attempts` times (default 5). See
[Session status](#session-status).
### Language
The payment page is in French or English. Choose its language with `locale`:
| `locale` | The page opens in |
| --- | --- |
| `auto` (default) | The buyer's browser language: French for a French browser, English for any other. |
| `fr` | French. |
| `en` | English. |
The buyer can switch language on the page; their choice wins over `locale` and is kept on
that browser for later payments. The card-entry page opens in the language the buyer is
reading when they click **Pay**.
### Websites and mobile apps
On a website, send the buyer to `checkout_url` in the same tab or a new one. The payment
page can't be shown in an iframe: browsers refuse to frame it.
In a mobile app, open `checkout_url` in an **in-app browser tab**:
`SFSafariViewController` on iOS, Custom Tabs on Android. The buyer stays in your app, the
page shows its real address, and banks' 3-D Secure steps work as in a browser, including
a hand-off to the bank's own app.
To bring the buyer back to your app, make `return_url` and `cancel_url` links your app
opens: a universal link on iOS or an App Link on Android, on your own domain (for example
`https://your-site.tn/app/orders/order-0001/paid`). When the app opens, read the order's
state from your server, which the [webhook](/guides/webhooks/) keeps up to date.
:::caution[Avoid embedded WebViews]
An embedded WebView (`WKWebView`, Android `WebView`) lets your app read what the buyer
types on the page, card number included, and some banks' 3-D Secure steps fail inside
one. If you have to use one:
- enable JavaScript and DOM storage, and accept cookies (on Android, third-party cookies
too, with `CookieManager.setAcceptThirdPartyCookies`);
- open links the WebView can't load itself (a bank's app, `intent://` or other custom
schemes) outside it;
- watch navigation for your `return_url` and `cancel_url`, then close the WebView.
:::
## Returning the buyer
Paymee doesn't add anything to your URLs: the buyer lands on exactly the `return_url` or
`cancel_url` you gave. Put what you need to show the right page in the URL itself, for
example `https://your-site.tn/orders/order-0001/paid`.
- **`return_url`** — after a **successful** payment, the hosted page shows a short
confirmation, then redirects the buyer here. Without one, the buyer stays on the
confirmation.
- **`cancel_url`** — the hosted page shows a **Return to store** button linking here:
while paying, after a decline, and when the link or the session has expired. Without
one, the page tells the buyer they can close the window.
A declined buyer is not redirected: they stay on the hosted page and can try again while
attempts remain.
:::tip[Don't fulfil on redirect]
The buyer landing on `return_url` is **not** a reliable signal — they can close the tab
before the redirect. Fulfil orders from the [webhook](/guides/webhooks/), and treat
`return_url` purely as UX. On that page, read the order's state from your own database
(updated by the webhook), or fetch the session.
:::
## Session status
A session moves through these statuses (`status` field):
| Status | Meaning |
| --- | --- |
| `CREATED` | Created; no payment attempt yet. |
| `PROCESSING` | A payment attempt is in progress (the buyer is on the card or 3-D Secure page). |
| `PENDING` | An attempt was declined; the buyer can try again. |
| `SUCCEEDED` | Paid. **Final.** |
| `FAILED` | Every allowed attempt was declined. **Final.** |
| `EXPIRED` | Not paid within the session's lifetime (about 24 hours). **Final.** |
| `CANCELED` | Canceled by your `cancel` call. **Final.** |
```mermaid
stateDiagram-v2
direction LR
[*] --> CREATED
CREATED --> PROCESSING: payment attempt
PROCESSING --> SUCCEEDED: approved
PROCESSING --> PENDING: declined, attempts left
PENDING --> PROCESSING: buyer tries again
PROCESSING --> FAILED: declined, no attempts left
PENDING --> FAILED: no attempts left
CREATED --> CANCELED: your cancel call
PENDING --> CANCELED: your cancel call
CREATED --> EXPIRED: lifetime over
PENDING --> EXPIRED: lifetime over
PROCESSING --> EXPIRED: lifetime over
SUCCEEDED --> [*]
FAILED --> [*]
CANCELED --> [*]
EXPIRED --> [*]
```
The buyer can't cancel a session: one they abandon stays open until it expires. Each
final status has its webhook (`session.succeeded`, `session.failed`, `session.expired`)
except `CANCELED`, which only your own call can cause.
:::note[Session lifetime vs. checkout-link lifetime]
The 24 hours above are the **payment session's** lifetime. The `checkout_url` you
redirect the buyer to is a separate, shorter-lived link (**about 30 minutes**). If a
buyer lets a link go stale it expires on its own, emitting
[`checkout.session.expired`](/guides/webhooks/#events), **while the session stays
open** and payable until its 24 hours elapse. To give the buyer a fresh link, call
`POST /gateway/sessions/` again with the same `merchant_reference`.
:::
## Retrieving sessions
Fetch a session at any time with
[`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/). It includes its
payment `attempts` and its `refunds`. **Webhooks are the primary mechanism**; fetching
is the backup and the source of truth for reconciliation.
List your sessions, newest first, with
[`GET /gateway/sessions/list/`](/reference/operations/listpaymentsessions/):
```bash
curl "https://api.paymee.online/api/v1/gateway/sessions/list/?limit=50" \
-H "Authorization: Bearer sk_live_…"
```
The response is `{ "data": [ …sessions… ], "has_more": true }`. `limit` is 1–100
(default 20). To get the next page, pass the last session's `id` as `starting_after`;
`ending_before` pages backwards.
## Canceling
Cancel a session that hasn't been paid with
[`POST /gateway/sessions/{id}/cancel/`](/reference/operations/cancelpaymentsession/). The
response is the session, now `CANCELED`, and its checkout link stops working.
- Only `CREATED` and `PENDING` sessions can be canceled. While a payment attempt is
`PROCESSING`, the call is refused with `422` `invalid-state`: wait for the attempt to
finish.
- A session that is already final answers `409` `session-terminal`.
Source: https://docs.paymee.online/guides/hosted-checkout/
---
# 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.
### 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=,v1=[,v1=…]` — 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 = "."
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.
```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)
```
```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);
});
}
```
```php
$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
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
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/).
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:
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.
:::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/
---
# Fulfil an order
This recipe chains the pieces of a hosted-checkout integration into code you can copy:
your server creates a payment session for an order and redirects the buyer, then a
**webhook** marks the order paid, or releases it if the buyer never pays. The browser
redirect only shows the buyer a page; it never changes an order.
The examples use Node.js (Express), PHP and Python (Flask).
## Before you start
- Your **secret key** (`sk_test_…` while you build) and your **webhook signing secret**
(`whsec_…`), both from **Settings → Developers** in your dashboard. See
[Authentication](/guides/authentication/).
- A public `https://` address for your server. To receive webhooks on your own machine,
expose it through an HTTPS tunnel.
- An `orders` table with at least an id, an amount, a currency and a status (`pending`,
`paid` or `released`).
## Create the session
When the buyer clicks **Pay** on an order you have saved as `pending`, create a payment
session for it and redirect the buyer to its `checkout_url`.
- Use the **order id as `merchant_reference`**. If the call times out, or the buyer
clicks twice, the same request returns the existing session (`200`) instead of a second
one. See [Idempotency](/guides/idempotency/).
- Compute `amount` on your server, as a string with exactly three decimals for TND
(`"10.500"`).
- Put the **order id in `return_url`**. Paymee redirects the buyer to it exactly as you
gave it and adds nothing.
```js
import express from "express";
import { orders } from "./store.js"; // placeholder: your database
const app = express();
const PAYMEE_API = "https://api.paymee.online/api/v1";
const PAYMEE_SECRET_KEY = process.env.PAYMEE_SECRET_KEY; // sk_test_… or sk_live_…
app.post("/orders/:id/pay", async (req, res) => {
const order = await orders.find(req.params.id); // your code: check it belongs to this buyer
if (!order) return res.sendStatus(404);
if (order.status !== "pending") return res.redirect(303, `/orders/${order.id}/confirmation`);
const response = await fetch(`${PAYMEE_API}/gateway/sessions/`, {
method: "POST",
headers: {
Authorization: `Bearer ${PAYMEE_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.amount, // "10.500"
currency: order.currency, // "TND"
merchant_reference: order.id,
first_name: order.firstName,
last_name: order.lastName,
email: order.email,
return_url: `https://your-site.tn/orders/${order.id}/confirmation`,
cancel_url: "https://your-site.tn/cart",
webhook_url: "https://your-site.tn/webhooks/paymee",
}),
});
if (!response.ok) return res.status(502).send("Payment is unavailable, please try again.");
const session = await response.json(); // 201: new session, 200: the existing one
await orders.saveSessionId(order.id, session.id);
// checkout_url is null when the session is already final: show the order instead.
res.redirect(303, session.checkout_url ?? `/orders/${order.id}/confirmation`);
});
```
```php
find($_GET["id"] ?? ""); // your code: check it belongs to this buyer
if (!$order) {
http_response_code(404);
exit;
}
$confirmation = "/orders/" . rawurlencode($order["id"]) . "/confirmation";
if ($order["status"] !== "pending") {
header("Location: $confirmation", true, 303);
exit;
}
$ch = curl_init("https://api.paymee.online/api/v1/gateway/sessions/");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("PAYMEE_SECRET_KEY"), // sk_test_… or sk_live_…
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"amount" => $order["amount"], // "10.500"
"currency" => $order["currency"], // "TND"
"merchant_reference" => $order["id"],
"first_name" => $order["first_name"],
"last_name" => $order["last_name"],
"email" => $order["email"],
"return_url" => "https://your-site.tn" . $confirmation,
"cancel_url" => "https://your-site.tn/cart",
"webhook_url" => "https://your-site.tn/webhooks/paymee",
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || ($status !== 200 && $status !== 201)) { // 201: new, 200: existing
http_response_code(502);
exit("Payment is unavailable, please try again.");
}
$session = json_decode($body, true);
$orders->saveSessionId($order["id"], $session["id"]);
// checkout_url is null when the session is already final: show the order instead.
header("Location: " . ($session["checkout_url"] ?? $confirmation), true, 303);
```
```python
import os
import requests
from flask import Flask, abort, redirect
from store import orders # placeholder: your database
app = Flask(__name__)
PAYMEE_API = "https://api.paymee.online/api/v1"
PAYMEE_SECRET_KEY = os.environ["PAYMEE_SECRET_KEY"] # sk_test_… or sk_live_…
@app.post("/orders//pay")
def pay(order_id):
order = orders.find(order_id) # your code: check it belongs to this buyer
if order is None:
abort(404)
confirmation = f"/orders/{order.id}/confirmation"
if order.status != "pending":
return redirect(confirmation, 303)
response = requests.post(
f"{PAYMEE_API}/gateway/sessions/",
headers={"Authorization": f"Bearer {PAYMEE_SECRET_KEY}"},
json={
"amount": order.amount, # "10.500"
"currency": order.currency, # "TND"
"merchant_reference": order.id,
"first_name": order.first_name,
"last_name": order.last_name,
"email": order.email,
"return_url": f"https://your-site.tn{confirmation}",
"cancel_url": "https://your-site.tn/cart",
"webhook_url": "https://your-site.tn/webhooks/paymee",
},
timeout=15,
)
if not response.ok:
return "Payment is unavailable, please try again.", 502
session = response.json() # 201: new session, 200: the existing one
orders.save_session_id(order.id, session["id"])
# checkout_url is null when the session is already final: show the order instead.
return redirect(session["checkout_url"] or confirmation, 303)
```
Saving the session's `id` (`ps_…`) on the order lets you look the session up later.
Every field and error is in [Hosted checkout](/guides/hosted-checkout/#create-a-session)
and [Errors](/guides/errors/).
## Redirect the buyer
The code above answers with a `303` redirect to `checkout_url`. The buyer pays on the
Paymee-hosted page, with 3-D Secure, and can retry on the same page after a decline.
Then:
- **After a successful payment**, Paymee redirects the buyer to your `return_url`.
- The page's **Return to store** button takes the buyer to your `cancel_url`, without
paying.
A `checkout_url` lasts about 30 minutes, the session about 24 hours. A buyer who comes back
later and clicks **Pay** again gets a fresh link: the same request returns the open session
with a new `checkout_url`.
## Handle the webhook
Paymee sends a signed `POST` to your `webhook_url` when the session ends. Your handler:
1. **Verifies the signature** against the raw request body, and answers `400` if it
doesn't match.
2. **Skips an event it already processed.** Delivery is at least once, and a retry keeps
the same event `id` (also sent as the `X-Paymee-Webhook-Id` header).
3. **Acts on the event:**
- `session.succeeded`: checks that `data.amount` and `data.currency` match the order,
then marks it paid and queues the fulfilment (emails, shipping).
- `session.failed` (every attempt declined) or `session.expired` (unpaid after about
24 hours): releases the order. Both are final.
4. **Records the event and answers `200`** within 10 seconds. If something fails, it
answers `500` (or times out), and Paymee retries for about 3 days.
`data` is the session, and `data.merchant_reference` is your order id.
```js
import crypto from "node:crypto";
import express from "express";
import { orders, processedEvents, jobs } from "./store.js"; // placeholders: your database and job queue
const app = express();
const WEBHOOK_SECRET = process.env.PAYMEE_WEBHOOK_SECRET; // whsec_…
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);
});
}
// express.raw keeps the body as bytes. Register this route before any app.use(express.json()).
app.post("/webhooks/paymee", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-Paymee-Signature"), WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
try {
if (await processedEvents.has(event.id)) return res.sendStatus(200); // a repeat
const session = event.data;
const reference = session.merchant_reference;
switch (event.event) {
case "session.succeeded": {
const order = await orders.find(reference);
if (!order || session.amount !== order.amount || session.currency !== order.currency) {
console.error(`Session ${session.id} doesn't match order ${reference}: not fulfilled`);
break;
}
if (await orders.markPaid(reference, session.id)) {
await jobs.enqueue("fulfil-order", reference); // slow work runs after you answer
}
break;
}
case "session.failed":
case "session.expired":
await orders.release(reference);
break;
// Any other event: nothing to do here.
}
await processedEvents.add(event.id);
res.sendStatus(200);
} catch (err) {
console.error(err);
res.sendStatus(500); // Paymee retries
}
});
```
```php
$tolerance) return false;
$expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
$rawBody = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_PAYMEE_SIGNATURE"] ?? "";
if (!verify($rawBody, $header, (string) getenv("PAYMEE_WEBHOOK_SECRET"))) { // whsec_…
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
if ($processedEvents->has($event["id"])) { // a repeat
http_response_code(200);
exit;
}
$session = $event["data"];
$reference = $session["merchant_reference"];
switch ($event["event"]) {
case "session.succeeded":
$order = $orders->find($reference);
if (!$order || $session["amount"] !== $order["amount"] || $session["currency"] !== $order["currency"]) {
error_log("Session {$session["id"]} doesn't match order $reference: not fulfilled");
break;
}
if ($orders->markPaid($reference, $session["id"])) {
$jobs->enqueue("fulfil-order", $reference); // slow work runs after you answer
}
break;
case "session.failed":
case "session.expired":
$orders->release($reference);
break;
// Any other event: nothing to do here.
}
// An uncaught exception above answers 500, and Paymee retries.
$processedEvents->add($event["id"]);
http_response_code(200);
```
```python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
from store import jobs, orders, processed_events # placeholders: your database and job queue
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["PAYMEE_WEBHOOK_SECRET"] # whsec_…
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)
@app.post("/webhooks/paymee")
def paymee_webhook():
raw_body = request.get_data() # the raw bytes, before any JSON parsing
if not verify(raw_body, request.headers.get("X-Paymee-Signature", ""), WEBHOOK_SECRET):
return "", 400
event = json.loads(raw_body)
if processed_events.has(event["id"]): # a repeat
return "", 200
session = event["data"]
reference = session["merchant_reference"]
if event["event"] == "session.succeeded":
order = orders.find(reference)
if (
order is None
or session["amount"] != order.amount
or session["currency"] != order.currency
):
app.logger.error("Session %s doesn't match order %s: not fulfilled", session["id"], reference)
elif orders.mark_paid(reference, session["id"]):
jobs.enqueue("fulfil-order", reference) # slow work runs after you answer
elif event["event"] in ("session.failed", "session.expired"):
orders.release(reference)
# Any other event: nothing to do here.
# An exception above answers 500, and Paymee retries.
processed_events.add(event["id"])
return "", 200
```
A few details:
- **Compare amounts as strings.** `data.amount` has the same three-decimal format you sent
(`"10.500"`). If you price orders in another currency, compare `data.original.amount` and
`data.original.currency` instead: `data.amount` is then the converted TND charge. See
[Amounts & currency](/guides/amounts-and-currency/#foreign-currency-requests).
- **Events can arrive out of order**, and that's fine here: `markPaid` and `release` only
move a `pending` order, so a late event can't undo a final one.
- **A released order stays released.** A failed or expired session can't be paid again.
If the buyer wants to try again, create a new order, with a new `merchant_reference`.
- **Other events** (`session.attempt_failed`, `checkout.session.expired`, `refund.*`) need
no action to fulfil an order. The handler acknowledges them with `200`. See
[Events](/guides/webhooks/#events).
- **Test without paying.** In test mode, **Send a test event** from **Settings →
Developers** with a pending order's id as the reference and its amount, to check both
branches. See [Testing your endpoint](/guides/webhooks/#testing-your-endpoint).
## Show the return page
When the buyer lands on `return_url`, read the order from **your own database**. The
webhook usually arrives first. If the order is still `pending`, fetch the session with
[`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/) to show the
right message. This page only displays the result: it never marks the order paid.
```js
// Same app, orders, PAYMEE_API and PAYMEE_SECRET_KEY as in "Create the session".
app.get("/orders/:id/confirmation", async (req, res) => {
const order = await orders.find(req.params.id); // your code: check it belongs to this buyer
if (!order) return res.sendStatus(404);
let paid = order.status === "paid";
if (order.status === "pending" && order.sessionId) {
// The webhook hasn't arrived yet: ask Paymee.
const response = await fetch(`${PAYMEE_API}/gateway/sessions/${order.sessionId}/`, {
headers: { Authorization: `Bearer ${PAYMEE_SECRET_KEY}` },
});
paid = response.ok && (await response.json()).status === "SUCCEEDED";
}
res.send(paid ? "Payment received, thank you!" : "We're confirming your payment. Refresh this page in a moment.");
});
```
```php
find($_GET["id"] ?? ""); // your code: check it belongs to this buyer
if (!$order) {
http_response_code(404);
exit;
}
$paid = $order["status"] === "paid";
if ($order["status"] === "pending" && $order["session_id"]) {
// The webhook hasn't arrived yet: ask Paymee.
$ch = curl_init("https://api.paymee.online/api/v1/gateway/sessions/" . rawurlencode($order["session_id"]) . "/");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYMEE_SECRET_KEY")],
]);
$session = json_decode((string) curl_exec($ch), true);
$paid = ($session["status"] ?? null) === "SUCCEEDED";
}
echo $paid ? "Payment received, thank you!" : "We're confirming your payment. Refresh this page in a moment.";
```
```python
# Same app, orders, PAYMEE_API and PAYMEE_SECRET_KEY as in "Create the session".
@app.get("/orders//confirmation")
def confirmation(order_id):
order = orders.find(order_id) # your code: check it belongs to this buyer
if order is None:
abort(404)
paid = order.status == "paid"
if order.status == "pending" and order.session_id:
# The webhook hasn't arrived yet: ask Paymee.
response = requests.get(
f"{PAYMEE_API}/gateway/sessions/{order.session_id}/",
headers={"Authorization": f"Bearer {PAYMEE_SECRET_KEY}"},
timeout=10,
)
paid = response.ok and response.json()["status"] == "SUCCEEDED"
if paid:
return "Payment received, thank you!"
return "We're confirming your payment. Refresh this page in a moment."
```
## Reconcile
Webhooks are retried for about 3 days. As a safety net, for example after your endpoint
was down for longer, run a periodic job over orders still `pending` more than a day after
their session was created. Fetch each session with
[`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/) and apply the
same rules as your handler:
| Session `status` | Action |
| --- | --- |
| `SUCCEEDED` | Check the amount and currency, then `markPaid`. |
| `FAILED`, `EXPIRED`, `CANCELED` | `release`. `CANCELED` comes only from your own [cancel call](/guides/hosted-checkout/#canceling) and has no webhook. |
| `CREATED`, `PROCESSING`, `PENDING` | Still open: check again later. |
Unlike the redirect, this answer comes from the API with your secret key, so it is
authoritative. To go through all your sessions, page through
[`GET /gateway/sessions/list/`](/reference/operations/listpaymentsessions/): newest first,
`limit` up to 100, and the last session's `id` as `starting_after` while `has_more` is
`true`.
## Checklist
- **One order, one `merchant_reference`.** Retries reuse it; a changed total gets a new
one.
- **Amounts come from your server**, as strings with three decimals for TND.
- **The order id is in `return_url`**, and that page only reads.
- **The webhook verifies the signature** on the raw body, with the 5-minute tolerance.
- **The webhook deduplicates** on the event id (`X-Paymee-Webhook-Id`).
- **Amount and currency are checked** before an order is marked paid.
- **`markPaid` and `release` only move `pending` orders**, so repeats and out-of-order
events are harmless.
- **The handler answers `2xx` within 10 seconds**, queues slow work, and answers `5xx`
on errors so Paymee retries.
- **A reconciliation job** catches anything the webhooks missed.
When this works in test mode, follow [Going live](/guides/going-live/).
Source: https://docs.paymee.online/guides/fulfil-an-order/
---
# Idempotency
Network calls fail. When you retry a request, you must not create a second payment or a
second refund. Paymee makes both safe to retry:
- **Session creation** is idempotent through your **`merchant_reference`**.
- **Refunds** are idempotent through the **`Idempotency-Key`** header — see
[Refunds](/guides/refunds/#retrying-safely-idempotency-key).
## Session creation
`merchant_reference` is your unique identifier for a payment (an order id, a cart id, an
invoice number — whatever is stable on your side). It is unique per merchant account,
separately in test and live mode.
- The **first** request with a given `merchant_reference` creates the session and
returns `201 Created`.
- A **later** request with the **same** `merchant_reference`, **`amount`** and
**`currency`** returns the **existing** session with `200 OK` — no duplicate is
created, even when the two requests arrive at the same moment.
- The same `merchant_reference` with a **different `amount` or `currency`** is refused
with `409 Conflict` (`type` `conflict`); the problem document's
`existing_session_id` names the session that already uses it.
```bash
# First call → 201 Created, new session
curl https://api.paymee.online/api/v1/gateway/sessions/ \
-H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
-d '{ "amount": "10.500", "merchant_reference": "order-0001", "first_name": "Aymen", "last_name": "Ben Salah", "email": "aymen@example.com" }'
# Same merchant_reference, amount and currency → 200 OK, the same session
```
Only `amount` and `currency` are compared. Any other field of a repeated request (buyer,
URLs, `description`, `items`, `metadata`, `max_attempts`) is **ignored**: you get the
original session, as it was created. To change those, create a new session with a new
`merchant_reference`.
### What a repeated request returns
| The existing session is… | You get |
| --- | --- |
| Still payable (`CREATED`, `PROCESSING`, `PENDING`) | The session with a working `checkout_url`: the current link, or a fresh one if it had expired. |
| Final (`SUCCEEDED`, `FAILED`, `EXPIRED`, `CANCELED`) | The session with `checkout_url: null`: there is nothing left to pay. |
So repeating the request is also how you get a fresh link for a buyer who let theirs
expire.
## Guidelines
- **Generate `merchant_reference` once per order**, before the first attempt, and reuse
it on every retry of that order.
- Use a **new** `merchant_reference` only when you genuinely want a new payment — for
example after `session.failed` or `session.expired`, when the buyer pays again.
- For refunds, generate one `Idempotency-Key` per refund and reuse it on every retry of
that refund.
:::tip
With these, you can retry on timeouts, `429` and `500` responses without risking a double
charge or a double refund.
:::
Source: https://docs.paymee.online/guides/idempotency/
---
# Refunds
Refund a succeeded payment session with
[`POST /gateway/sessions/{id}/refund/`](/reference/operations/refundpaymentsession/).
Refunds can be **full** or **partial**, and a session can be refunded several times
until its whole amount has been returned.
## Request
```bash
curl https://api.paymee.online/api/v1/gateway/sessions/ps_0b7c3a1e-…/refund/ \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c0e9a-2d4b-4b8e-9a51-0c3f7d2e8b14" \
-d '{
"reason": "Damaged item",
"refund_type": "PARTIAL",
"amount": "3.000"
}'
```
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `reason` | string (≤ 500) | ✅ | Why the refund is issued. |
| `refund_type` | string | – | `FULL` (default): the whole amount of the payment. `PARTIAL`: the `amount` you give. |
| `amount` | string | for `PARTIAL` | A positive decimal string in TND, e.g. `"3.000"`. Extra decimals are rounded to the millime. |
| `metadata` | object | – | Your own key/values, stored with the refund. |
A `FULL` refund always means the full amount of the payment. After a partial refund,
return the rest with a `PARTIAL` refund of the remaining amount.
### Retrying safely: `Idempotency-Key`
A refund request that times out may or may not have been processed. To retry without
refunding twice, send an `Idempotency-Key` header: a unique value you generate per
refund (a UUID v4 is ideal), up to 255 characters.
- Repeating a request with the **same key and the same body** returns the refund that
key created — with the `Idempotent-Replayed: true` header — instead of refunding again.
- If that refund had been refused (for example `insufficient-funds`), repeating it with
the same key tries again.
- Using a key that was already used for a **different** refund request (another body or
another session) returns `409` `idempotency-key-reuse`.
Keys do not expire. Without the header, every request is a new refund: a retried
`PARTIAL` request would refund twice. (Retrying a `FULL` refund without a key is refused
as `over-refund`, so it is safe either way.)
## The refund object
A refund request answers `200 OK` with the refund:
```json
{
"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"
}
```
| `status` | Meaning |
| --- | --- |
| `SUCCEEDED` | The money is on its way back to the buyer's card. `completed_at` is set. |
| `PENDING` | The refund is accepted but could not be confirmed automatically; it is completed manually and can take a few days. `processor_message` says why. We contact you if anything is needed on your side. |
| `FAILED` | The money was not returned. `completed_at` is set and `processor_message` says why. |
Most card refunds are `SUCCEEDED` straight away. A `PENDING` refund ends as `SUCCEEDED`
or `FAILED`, and you are told by webhook: **`refund.succeeded`** or **`refund.failed`**
(see [Webhooks](/guides/webhooks/#refund-events)). These events are sent for every refund,
including those issued from your dashboard.
Every refund of a session is also listed, oldest first, in the `refunds` array of
[`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/) — use it for
reconciliation. A refund does not change the session's own `status`, which stays
`SUCCEEDED`.
`processor_message` is informational, for your logs and support; don't branch on its
text.
## Rules
- Only a `SUCCEEDED` session can be refunded. Refunds are always in TND.
- The total of a session's refunds can never exceed what was paid. A refund over the
remaining amount is refused (`over-refund`).
- A partial refund can be refused until the payment has settled, normally the day after
the payment (`partial-requires-settled`). A full refund is accepted right away.
- A refund can be refused when it can't be funded from your balance
(`insufficient-funds`).
## Errors
Errors are [problem documents](/guides/errors/). A refusal changes nothing: you can fix
the cause and send the request again.
| `type` suffix | HTTP | When |
| --- | --- | --- |
| `bad-request` | 400 | The body isn't valid JSON, or the `Idempotency-Key` is empty or over 255 characters. |
| `not-found` | 404 | No such session on this account. |
| `idempotency-key-reuse` | 409 | The `Idempotency-Key` was already used for a different refund request. |
| `validation-error` | 422 | `reason` missing, unknown `refund_type`, or a `PARTIAL` refund without a positive `amount`. |
| `invalid-state` | 422 | The session or its payment can't be refunded (e.g. the session isn't `SUCCEEDED`). |
| `no-payment` | 422 | The session has no successful payment. |
| `partial-requires-settled` | 422 | Partial refund before the payment has settled. |
| `over-refund` | 422 | More than the amount left to refund. |
| `insufficient-funds` | 422 | The refund can't be funded from your balance. |
| `refund-failed` | 422 | The refund could not be recorded; nothing was returned to the buyer. |
Source: https://docs.paymee.online/guides/refunds/
---
# Testing
Build and test your integration in **test mode**, where no real money moves. There is
one API host: your **key prefix** selects the mode.
| Mode | Base URL | Key |
| --- | --- | --- |
| Test | `https://api.paymee.online/api/v1` | `sk_test_…` |
| Live | `https://api.paymee.online/api/v1` | `sk_live_…` |
Test mode is fully separate from live mode: its own sessions and refunds, its own webhook
endpoint and signing secret, and its own exchange rates. Switch your dashboard to **test
mode** to configure them. Every test-mode object and event carries `livemode: false`.
## Test cards
Use these cards on the hosted checkout page in test mode. You enter them on the payment
page — Paymee never asks you to send card numbers to the API.
| Brand | Card number | Expiry | CVV |
| --- | --- | --- | --- |
| Visa | `4509 2111 1111 1119` | `12/26` | `748` |
| Mastercard | `5440 2127 1111 1110` | `12/26` | `665` |
**3-D Secure code** for every test card: `12345`.
Whether a test payment succeeds or is declined is decided by the acquirer's test system;
there are no cards or amounts that force a decline. To check your failure handling
without a declined payment, send a `session.attempt_failed` or `session.failed`
[test event](/guides/webhooks/#testing-your-endpoint) to your endpoint.
:::caution
These are **test-mode** cards only. Don't put them in your application code or your test
suite — they belong on the hosted checkout page during manual or automated checkout
testing.
:::
## End-to-end test flow
1. Create a session with your `sk_test_…` key (see [Quickstart](/guides/quickstart/)).
2. Open the returned `checkout_url`.
3. Pay with a test card above; complete 3-D Secure with `12345`.
4. Confirm your endpoint receives a `session.succeeded` [webhook](/guides/webhooks/) and
that you verify its signature.
5. [Refund](/guides/refunds/) the session through the API and confirm you receive
`refund.succeeded`.
When everything works end to end, follow the [Going live](/guides/going-live/) checklist.
Source: https://docs.paymee.online/guides/testing/
---
# Amounts & currency
## You are always paid in TND
Paymee charges the buyer's card and settles you in **Tunisian Dinar (TND)**. Every
`amount` the API returns — on session detail, session list, the hosted checkout
and every webhook — is the amount in your account's currency, TND: **what the
card is charged and what your balance is credited with.**
You may *request* a payment in another currency (for example a store priced in
EUR). The gateway converts that request into TND at the exchange rate you
configured, and keeps what you requested alongside the result. See
[Foreign-currency requests](#foreign-currency-requests) below.
## Amounts are strings
Send `amount` as a **decimal string**, not a number. Floating-point math loses
precision on money, and a JSON number drops trailing zeros (`10.500` arrives as `10.5`
and is then refused for its precision); a string preserves exactly what you intend.
```json
{ "amount": "10.500" }
```
`amount` is the value in the currency's **main unit** — for `TND`, dinars, not
millimes. Send `"10.500"` for ten dinars and five hundred millimes; the gateway
converts to millimes internally before talking to the acquirer. Never send a
millimes integer like `10500`.
## Precision follows the requested currency
Format `amount` with exactly the ISO 4217 number of decimals of the currency you
send it in. TND is subdivided into **1000 millimes**, so a TND amount has
**three** decimals — unlike most currencies, which have two.
| Currency | Decimals | Example |
| --- | --- | --- |
| `TND` | 3 | `"10.500"` |
| `EUR`, `USD`, `GBP` | 2 | `"10.50"` |
| `JPY` | 0 | `"1000"` |
:::caution
The API **rejects** an amount whose decimal places don't match its currency: for
`TND`, `"10.50"` or `"10"` are refused with a `validation-error` whose
`errors[].code` is `invalid_precision` — they are not silently reinterpreted.
:::
## Currency field
`currency` is an ISO 4217 code, case-insensitive. If omitted, the request is in
your account's currency (`TND`). The maximum amount is `999999.999` TND, applied
both to what you request and to the converted TND amount.
## Foreign-currency requests
A request in a currency other than TND is accepted only when you have configured
an **exchange rate** for it in the merchant portal (Settings → Currencies). A
rate is the TND value of one unit of the currency — `1 EUR = 3.350000 TND` —
with up to six decimals. Live and test mode keep separate rate tables.
When a session is created in such a currency, the gateway:
1. validates the amount at the **requested** currency's precision (2 decimals for EUR);
2. multiplies it by your rate and rounds **half-up to millimes**;
3. stores the result as the session's `amount` in `TND`, and freezes what you
requested and the rate applied in an `original` object.
```json
// POST /gateway/sessions/ { "amount": "100.00", "currency": "EUR", ... }
{
"amount": "335.000",
"currency": "TND",
"original": { "amount": "100.00", "currency": "EUR", "rate": "3.350000" },
...
}
```
`original` is present on **every** session response and session event. For a TND
request it simply mirrors the charge, with `rate` = `"1.000000"`:
```json
{ "amount": "10.500", "currency": "TND", "original": { "amount": "10.500", "currency": "TND", "rate": "1.000000" } }
```
Three guarantees follow from the snapshot:
- **The rate is frozen at creation.** Changing or removing a rate in the portal
never alters an existing session, and an [idempotent replay](/guides/idempotency/)
of the same `merchant_reference` returns the original snapshot, never a
re-conversion at today's rate.
- **The buyer sees both amounts.** The hosted checkout shows the quote in your
currency, the TND amount the card will be charged, and the rate.
- **Refunds are in TND**, capped at the charged `amount`.
:::note
If you integrate a store priced in a foreign currency, read `amount`/`currency`
as *what you will receive* and `original` as *what you asked for*. They are only
equal for TND requests.
:::
### Unsupported currency
A request in a currency you have **not** configured a rate for is rejected with
`422` and a `validation-error` on the `currency` field:
```json
{
"type": "https://api.paymee.online/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation.",
"errors": [
{ "field": "currency", "code": "currency_unsupported", "message": "No exchange rate is configured for GBP. ..." }
]
}
```
Configure a rate for the currency, or send the request in TND.
Source: https://docs.paymee.online/guides/amounts-and-currency/
---
# Errors
The gateway returns errors as **[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
problem documents** with the `application/problem+json` content type. The `type` field
is a stable URI you can branch on.
## Shape
```json
{
"type": "https://api.paymee.online/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation.",
"errors": [
{ "field": "email", "code": "required", "message": "This field is required." }
]
}
```
| Field | Meaning |
| --- | --- |
| `type` | Stable URI identifying the error — branch on this. |
| `title` | Short, human-readable summary. |
| `status` | HTTP status code. |
| `detail` | Explanation specific to this occurrence. |
| `errors` | On `validation-error` only: one `{ field, code, message }` per invalid field. |
| `existing_session_id` | On `conflict` from session creation: the session that already uses this `merchant_reference`. |
Key your handling on `type` (and `errors[].code`), never on `title`, `detail` or
`message`, which may change.
## HTTP statuses
| Status | Means |
| --- | --- |
| `400` | The request is malformed (e.g. the body isn't valid JSON). |
| `401` | Missing, malformed, expired or revoked API key. |
| `404` | The object doesn't exist on this account. |
| `409` | The request conflicts with an earlier one. |
| `422` | The request is well-formed but was refused: a field failed validation, or a business rule refused it. |
| `429` | Rate limit exceeded. |
| `500` | Unexpected server error. Nothing was changed. |
## Error type catalog
All `type` URIs are under `https://api.paymee.online/errors/`.
| `type` suffix | HTTP | Meaning | Retry? |
| --- | --- | --- | --- |
| `bad-request` | 400 | Malformed request: the body isn't valid JSON, or a header is invalid. | No |
| `unauthorized` | 401 | Missing, malformed, expired, or revoked API key. | No |
| `forbidden` | 403 | Authenticated, but not allowed. | No |
| `not-found` | 404 | The session does not exist for this account. | No |
| `conflict` | 409 | Same `merchant_reference` as an existing session, with a different `amount` or `currency`. Includes `existing_session_id`. | No |
| `session-terminal` | 409 | The session is already final (cancel). | No |
| `idempotency-key-reuse` | 409 | The `Idempotency-Key` was already used for a different refund request. | No |
| `validation-error` | 422 | One or more fields failed validation (see `errors`). | No |
| `invalid-state` | 422 | Not allowed in the session's current state (e.g. canceling a `PROCESSING` session, refunding one that didn't succeed). | No |
| `no-payment` | 422 | The session has no successful payment to refund. | No |
| `partial-requires-settled` | 422 | Partial refund before the payment has settled. See [Refunds](/guides/refunds/#rules). | Later |
| `over-refund` | 422 | The refund exceeds the amount left to refund. | No |
| `insufficient-funds` | 422 | The refund can't be funded from your balance. | Later |
| `refund-failed` | 422 | The refund could not be recorded. | No |
| `rate-limit-exceeded` | 429 | Too many requests. Wait `Retry-After` seconds. | Yes |
| `error` | 405 / 415 | Wrong HTTP method or content type. | No |
| `internal-error` | 500 | Unexpected server error; nothing was changed. | Yes, with backoff |
:::tip
Only `429` and `500` are worth retrying automatically, with exponential backoff. When
you retry a session creation, reuse the same `merchant_reference`; when you retry a
refund, reuse the same `Idempotency-Key`. A request that did go through is then not
repeated.
:::
New `type`s may be added (see [Versioning](/guides/versioning/)): give your code a
default branch.
## Validation error codes
A `validation-error` lists each invalid field in `errors`. Nested fields are named with
dots, e.g. `items.1` or `metadata.cart_id`.
**Any field**
| `errors[].code` | Meaning |
| --- | --- |
| `required` | The field is missing. |
| `blank` | A required text field is empty. |
| `null` | The field is `null`. |
| `invalid` | The value is malformed (e.g. not an email address, wrong JSON type). |
| `max_length` | The text is too long. |
| `min_value` / `max_value` | The number is out of range (e.g. `max_attempts` outside 1–10). |
| `invalid_choice` | Not one of the allowed values (e.g. `refund_type`). |
| `not_a_list` / `not_a_dict` | `items` isn't a list, or one of its entries isn't an object. |
**`amount` and `currency`**
| `errors[].code` | Meaning |
| --- | --- |
| `invalid_format` | Thousands separators, leading zeros, or not a decimal number. |
| `invalid_precision` | The decimals don't match the currency: exactly 3 for `TND`, 2 for `EUR`. See [Amounts & currency](/guides/amounts-and-currency/). |
| `invalid_value` | Zero or negative, or a foreign amount that rounds to nothing in TND. |
| `amount_too_large` | Over `999999.999` TND, before or after conversion. |
| `currency_unsupported` | No exchange rate is configured for this currency. See [Amounts & currency](/guides/amounts-and-currency/#unsupported-currency). |
**`metadata`**
| `errors[].code` | Meaning |
| --- | --- |
| `too_many_keys` | More than 50 keys. |
| `key_too_long` | A key is longer than 40 characters. |
| `invalid_key_chars` | A key contains `[` or `]`. |
| `invalid_value_type` | A value is an object, a list or `null` (text, numbers and booleans are accepted). |
| `value_too_long` | A value is longer than 500 characters. |
**`return_url`, `cancel_url`, `webhook_url`**
| `errors[].code` | Meaning |
| --- | --- |
| `https_required` | The URL doesn't use `https://`. |
| `invalid_domain` | The host isn't a domain name with at least one dot (e.g. `localhost`). |
| `ip_not_allowed` | `webhook_url` uses an IP address instead of a domain name. |
| `invalid_scheme` | `javascript:` URLs are refused. |
| `url_too_long` | Over 2048 characters. |
## Rate limits
Limits apply **per merchant account**, separately for live (`sk_live_`) and test
(`sk_test_`) keys: test traffic never uses your live budget, and every key of the same
account shares that account's budget.
Every request counts against the account-wide limit. Creating sessions and refunds also
count against their own, lower limit.
| Budget | Endpoints | Live | Test |
| --- | --- | --- | --- |
| Account-wide | All gateway endpoints | 25 requests/second | 10 requests/second |
| Create | `POST /gateway/sessions/` | 10 requests/second | 5 requests/second |
| Refund | `POST /gateway/sessions/{id}/refund/` | 5 requests/second | 2 requests/second |
Over a limit, the API answers **`429 Too Many Requests`** with a **`Retry-After`**
header giving the number of seconds to wait. Spread bulk work (backfills,
reconciliation) over time rather than sending it in bursts.
Source: https://docs.paymee.online/guides/errors/
---
# Versioning & deprecation
## Version in the path
The gateway API is versioned in the URL path. The current version is **`v1`**:
```
https://api.paymee.online/api/v1/gateway/...
```
A given version's request and response contracts are stable. We will not make
breaking changes to `v1`.
## What counts as breaking vs. non-breaking
We may add the following to `v1` **without** a new version — your integration
should tolerate them:
- New endpoints.
- New **optional** request fields.
- New fields in responses and webhook payloads.
- New `enum` values (e.g. a new session status or webhook event).
- New error `type`s.
Write your integration defensively: ignore unknown fields, and have a default
branch for unrecognized `status`, `event`, and error `type` values.
**Breaking changes** — removing or renaming a field, changing a type, or
changing required fields — are introduced only under a **new path version**
(e.g. a future `v2`).
## Deprecation policy
When a version or field is deprecated, we will:
- Announce it in the [changelog](/guides/changelog/) with a migration path.
- Continue to support the deprecated surface during a published sunset window.
- Keep both versions running in parallel so you can migrate on your schedule.
:::note
Pin to `v1` explicitly in your base URL. Don't build URLs that assume a "latest"
alias.
:::
Source: https://docs.paymee.online/guides/versioning/
---
# Going live
When your integration works end to end in [test mode](/guides/testing/), follow this
checklist to go live.
## Checklist
- **Swap your key.** Replace `sk_test_…` with your `sk_live_…` key. The base URL doesn't
change: the key selects the mode. Live keys are available once your business
verification is approved, and are shown only once — store yours in a secrets manager.
- **Configure your live webhook endpoint and secret.** Live mode has its own endpoint and
its own `whsec_…` secret: set them in your dashboard in live mode, and confirm
signature verification passes on a real event.
- **Verify every webhook** — accept any matching `v1` signature, within 5 minutes of the
timestamp. See [Verify the signature](/guides/webhooks/#verify-the-signature).
- **Fulfil from webhooks, not redirects.** Orders are marked paid by `session.succeeded`;
`return_url` is UX only.
- **Deduplicate** on `X-Paymee-Webhook-Id`, make your handler idempotent, and don't depend
on the order events arrive in.
- **Return `2xx` within 10 seconds** from your webhook endpoint; do heavy work
asynchronously.
- **Retry safely.** Reuse the same `merchant_reference` when retrying a session creation,
and send an `Idempotency-Key` with every refund. See [Idempotency](/guides/idempotency/).
- **Send amounts as strings** with exactly three decimals for TND. See
[Amounts & currency](/guides/amounts-and-currency/).
- **Handle errors by `type`.** Retry only `429` and `500`, with backoff. See
[Errors](/guides/errors/).
- **Secure your secrets.** Keep `sk_…` and `whsec_…` server-side, out of source control
and logs. Rotate them from the dashboard if exposed.
## Production endpoints
| Purpose | URL |
| --- | --- |
| API base | `https://api.paymee.online/api/v1` |
| Create session | `POST /gateway/sessions/` |
| Webhooks (you receive) | your configured `webhook_url` |
Once live, reconcile regularly: fetch [sessions](/guides/hosted-checkout/#retrieving-sessions)
(each with its `refunds`) and check your
[webhook delivery history](/reference/operations/listwebhookdeliveries/) for events your
endpoint missed.
Source: https://docs.paymee.online/guides/going-live/
---
# Changelog
Changes are announced here. Within `v1` they never require you to change working code —
see [Versioning & deprecation](/guides/versioning/).
## November 2026
**New**
- **[Fulfil an order](/guides/fulfil-an-order/)**, a guide that takes an order from
session creation to a verified webhook, in Node.js, PHP and Python.
- **Refund object.** `POST /gateway/sessions/{id}/refund/` now returns the
[refund](/guides/refunds/#the-refund-object): a `re_…` id, a `status` of `PENDING`,
`SUCCEEDED` or `FAILED`, and a `processor_message`. It replaces the earlier response
fields (`refund_id`, `refunded_amount`, `automated_refund`, `epg_error`).
- **`Idempotency-Key` on refunds**, so a timed-out refund can be retried without
refunding twice. See [Retrying safely](/guides/refunds/#retrying-safely-idempotency-key).
- **Refund events** `refund.succeeded` and `refund.failed`, for refunds issued through
any channel, and a `refunds` list on every session.
- **`session.attempt_failed`**, sent for every declined payment attempt.
- **`locale` on sessions** chooses the payment page's language: `fr`, `en`, or `auto`
(the default) to follow the buyer's browser. The card-entry page now opens in the same
language as the payment page. See
[Language](/guides/hosted-checkout/#language).
- **The API reference** now documents every webhook event and every error response.
The OpenAPI specification can be downloaded at
[`/openapi/merchant-api.yaml`](/openapi/merchant-api.yaml).
- **Markdown version of every guide**, at the page's address with `.md` in place of the
trailing slash (for example [`/guides/webhooks.md`](/guides/webhooks.md)), so a single
page can be handed to an AI assistant. [`/llms.txt`](/llms.txt) now links to these.
- **Send a test event** in your dashboard now covers every event, including
`session.attempt_failed` and the refund events.
**Changed**
- The payment page can no longer be shown in an iframe. See
[Websites and mobile apps](/guides/hosted-checkout/#websites-and-mobile-apps).
- A session created without `locale` follows the buyer's browser; the payment page used
to open in French.
- A webhook's body `id` is now the same value as its `X-Paymee-Webhook-Id` header.
- Webhook retries now span about 3 days (up to 10 deliveries) instead of about a day.
- Webhooks are sent within seconds after the change they describe, independently of the
buyer's checkout; events can arrive out of order.
- Requests refused by a business rule now always answer `422` (some refund refusals
answered `400`).
- Validation errors carry specific codes such as `required`, `blank` and `max_length`
instead of `invalid` for every case.
- Optional text fields sent as `""` count as not given, and `metadata` accepts numbers and
booleans (stored as text).
- `webhook_url` is no longer looked up in DNS when you create a session; the address is
checked when each event is delivered.
- An unexpected server error now answers an `internal-error` problem document.
- Every amount — in responses and in webhooks — now has exactly the currency's decimals
(`"10.500"`). Some response fields (a fetched session's `amount`, an attempt's
`fee_amount` and `net_amount`, and the webhook `payment` amounts) used to show five
(`"10.50000"`); a missing `net_amount` is `null`.
- Every timestamp now ends in `Z`; some webhook timestamps used `+00:00`.
- The same data has the same name in API responses and webhooks: the buyer is a nested
`buyer` object (replacing the `buyer_first_name`, `buyer_last_name`, `buyer_email` and
`buyer_phone` fields of API responses); the time a session ended is `completed_at`
(replacing `paid_at` in webhooks); an attempt's number is `attempt_number`; and an
attempt's `failure_code` is the acquirer's decline code everywhere (it was always
`declined` in API responses), with its reason in `failure_message`.
- `checkout.session.expired` no longer repeats the session id as `payment_session_id`:
use `data.id`.
Source: https://docs.paymee.online/guides/changelog/