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