# 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 **sandbox 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/payment/success", "cancel_url": "https://your-site.tn/payment/cancel", "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/payment/success", cancel_url: "https://your-site.tn/payment/cancel", 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/payment/success", "cancel_url" => "https://your-site.tn/payment/cancel", "webhook_url" => "https://your-site.tn/webhooks/paymee", ]), ]); $session = json_decode(curl_exec($ch), true); echo $session["checkout_url"]; ``` 2. ### Read the response A `201 Created` returns the session, including a `checkout_url`: ```json {5} { "id": "ps_3Nk8x2c9aQ...", "merchant_reference": "order-0001", "amount": "10.500", "checkout_url": "https://pay.paymee.online/checkout/tok_abc123...", "status": "CREATED" } ``` 3. ### Redirect the buyer Send the buyer to the `checkout_url`. They complete payment on the Paymee-hosted page (with 3-D Secure). When done, Paymee redirects them back to your `return_url` (or `cancel_url` if they abandon). ```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 OTP:** `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 **webhook** Paymee sends to your `webhook_url`: - `session.succeeded` → fulfil the order - `session.failed` → show a failure and let them retry 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. - [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_…` (production), `sk_test_…` (sandbox) | 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, buyer, and your URLs. The response includes a `checkout_url`. 2. **Redirect the buyer** to `checkout_url`. 3. **Buyer pays** on the Paymee-hosted page (with 3-D Secure). 4. **Paymee redirects** the buyer to your `return_url` (success/processing) or `cancel_url` (abandoned). 5. **Paymee sends a webhook** to your `webhook_url` with the authoritative result. Fulfil the order here, not on the browser redirect. ```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->>B: redirect to return_url / cancel_url P->>S: webhook (authoritative result) → fulfil ``` ## Create a session `POST https://api.paymee.online/api/v1/gateway/sessions/` ### Request fields | Field | Type | Required | Notes | | --- | --- | --- | --- | | `amount` | string | ✅ | Decimal string with currency-correct precision. TND has **3 decimals** — see [Amounts & currency](/guides/amounts-and-currency/). | | `currency` | string | – | ISO 4217. Defaults to your account's currency (`TND` for Tunisian merchants). | | `merchant_reference` | string | ✅ | Your unique reference for this payment. Reusing it returns the existing session — see [Idempotency](/guides/idempotency/). | | `first_name` | string | ✅ | Buyer's given name. | | `last_name` | string | ✅ | Buyer's surname. | | `email` | string | ✅ | Buyer's email. | | `phone` | string | – | Buyer's phone. | | `description` | string | – | Shown on the checkout page. | | `order_id` | string | – | Your display-friendly order id. | | `items` | array | – | Line items: `[{ name, quantity, unit_price, description }]`. | | `metadata` | object | – | Arbitrary key/values echoed back to you (e.g. on webhooks). | | `return_url` | string | – | Where the buyer is sent after paying. | | `cancel_url` | string | – | Where the buyer is sent if they cancel. | | `webhook_url` | string | – | Where Paymee posts the result. Falls back to your account default if omitted. | | `max_attempts` | integer | – | Max payment attempts on the session (1–10, default 5). | See the full schema, including responses, in the [API Reference → Create a payment session](/reference/operations/createpaymentsession/). ### Response `201 Created` (or `200 OK` if the `merchant_reference` already existed): ```json { "id": "ps_3Nk8x2c9aQ...", "merchant_reference": "order-0001", "order_number": "PM-014A2C", "amount": "10.500", "currency": "TND", "status": "CREATED", "checkout_url": "https://pay.paymee.online/checkout/tok_abc123...", "buyer_first_name": "Aymen", "buyer_last_name": "Ben Salah", "buyer_email": "aymen@example.com" } ``` Redirect the buyer to `checkout_url`. ## Session status A session moves through these statuses (`status` field): | Status | Meaning | | --- | --- | | `CREATED` | Session created, awaiting the first payment attempt. | | `PROCESSING` | A payment attempt is in progress with the acquirer (buyer on the hosted page). | | `PENDING` | A prior attempt was declined and the session can be retried (still cancellable). | | `SUCCEEDED` | Payment succeeded. **Terminal.** | | `FAILED` | Payment declined with no attempts left. **Terminal.** | | `EXPIRED` | Session expired before completion (default TTL 24 h). **Terminal.** | | `CANCELED` | Canceled by you or the buyer. **Terminal.** | ```mermaid stateDiagram-v2 direction LR [*] --> CREATED CREATED --> PROCESSING: payment attempt PROCESSING --> SUCCEEDED: approved PROCESSING --> PENDING: declined, retries left PENDING --> PROCESSING: buyer retries PROCESSING --> FAILED: attempts exhausted CREATED --> CANCELED: cancel PENDING --> CANCELED: cancel CREATED --> EXPIRED: TTL PENDING --> EXPIRED: TTL SUCCEEDED --> [*] FAILED --> [*] CANCELED --> [*] EXPIRED --> [*] ``` Only `CREATED` and `PENDING` sessions can be canceled — once an attempt is `PROCESSING`, you must wait for it to resolve. :::note[Session TTL vs. checkout-link TTL] The 24 h above is the **payment session's** lifetime. The hosted-page link you redirect the buyer to (`checkout_url`) is a separate, shorter-lived object (**~30 min**) — one session can issue several. 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 h elapses — only then does the session become `EXPIRED` and emit [`session.expired`](/guides/webhooks/#events). So an abandoned checkout can emit `checkout.session.expired` first and `session.expired` hours later. Fulfilment should follow the terminal `session.*` events, not the link. ::: You can fetch the current status at any time with [`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/) — useful as a fallback or reconciliation check. **Webhooks are the primary mechanism**; polling is the backup. :::tip[Don't fulfil on redirect] The buyer returning to `return_url` is **not** a reliable signal — they might close the tab after paying, or land on `return_url` while the payment is still `PROCESSING`. Fulfil orders from the [webhook](/guides/webhooks/), and treat `return_url` purely as UX. ::: ## Canceling To cancel a session that hasn't reached a terminal state, call [`POST /gateway/sessions/{id}/cancel/`](/reference/operations/cancelpaymentsession/). Source: https://docs.paymee.online/guides/hosted-checkout/ --- # Webhooks Webhooks are how Paymee tells your server the **authoritative** outcome of a payment. Always fulfil orders from a webhook, not from the browser redirect. Paymee `POST`s a JSON body to the `webhook_url` you set on the session (or your account default), signed with the `X-Paymee-Signature` header. ## Events | Event | When | Act on it? | | --- | --- | --- | | `session.succeeded` | The payment completed. Session is now `SUCCEEDED` (terminal). | **Yes** — fulfil the order. | | `session.failed` | A payment attempt was declined. Carries `can_retry`; the session is only dead once it reaches `FAILED` (terminal). | When `can_retry` is `false`. | | `session.expired` | The **payment session** hit its ~24 h TTL without completing → now `EXPIRED` (terminal). The order will never be paid. | **Yes** — release the order. | | `checkout.session.expired` | One **hosted-page link** hit its short TTL (~30 min). The payment session itself is unaffected and may still be paid. | No — informational. | ### Recovering an abandoned payment Both expiry events are a cue to **re-engage the buyer** — usually by emailing or SMSing them a fresh link. You don't need a separate "create link" endpoint: recovery is just another call to [`POST /sessions/`](/guides/hosted-checkout/#create-a-session), which is **idempotent on `merchant_reference`**. - **After `checkout.session.expired`** (the link lapsed, the payment is still alive) — call `POST /sessions/` again with the **same `merchant_reference`**. You get the **same payment session back with a fresh `checkout_url`**: the link is re-minted automatically when the previous one has expired. Send that URL to the buyer. - **After `session.expired`** (the whole payment is terminal) — replaying the same `merchant_reference` returns the session **without a `checkout_url`** (there's nothing left to pay). To recover, create a **new** payment with a **new `merchant_reference`** — that mints a brand-new session and link. Send that. A `checkout_url` carries the short (~30 min) link TTL, so send it promptly; if the buyer clicks after it lapses, just call `POST /sessions/` again to mint another. ## Payload Every delivery has the same envelope; `data` carries the session, and some events add fields. ```json { "id": "evt_9f2c…", "event": "session.succeeded", "created_at": "2026-06-27T10:24:00Z", "data": { "id": "ps_3Nk8x2c9aQ...", "order_number": "PM-014A2C", "merchant_reference": "order-0001", "order_id": "", "amount": "10.500", "currency": "TND", "description": "", "status": "SUCCEEDED", "buyer": { "first_name": "Aymen", "last_name": "Ben Salah", "email": "aymen@example.com", "phone": "" }, "items": [], "metadata": {}, "paid_at": "2026-06-27T10:24:00Z", "payment": { "method": "card", "card_brand": "VISA", "card_last_four": "1119", "card_country": "TN", "fee_amount": "0.300", "net_amount": "10.200" } } } ``` - **`session.succeeded`** adds `paid_at` and a `payment` block. - **`session.failed`** adds `last_failure_code`, `last_failure_message`, `attempts_count`, and `can_retry`. ## Verify the signature Every request includes these headers: | Header | Purpose | | --- | --- | | `X-Paymee-Signature` | `t=,v1=` — the HMAC to verify. | | `X-Paymee-Webhook-Id` | Stable event id, identical across retries — use it to **deduplicate**. | | `X-Paymee-Event` | The event type (e.g. `session.succeeded`). | The signature is computed as: ``` signed_payload = "." expected = HMAC_SHA256(signed_payload, your_webhook_secret) // whsec_… ``` Compare `expected` to the `v1=` value using a **constant-time** comparison. ```python import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: # header looks like: "t=1719481440,v1=abcdef..." parts = dict(p.split("=", 1) for p in header.split(",")) timestamp, sig = parts["t"], parts["v1"] signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig) ``` ```js import crypto from "node:crypto"; function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const signed = Buffer.concat([ Buffer.from(`${parts.t}.`), Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody), ]); const expected = crypto .createHmac("sha256", secret) .update(signed) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(parts.v1), ); } ``` ```php ## Responding & retries 1. **Return `2xx` quickly.** Acknowledge within a few seconds, then do heavy work asynchronously. Deliveries time out after **10 seconds**; a non-`2xx`, a timeout, or a connection error is treated as a failed delivery. 2. **Make your handler idempotent.** Deliveries are **at-least-once** — a retry (or an occasional duplicate) can arrive even after you already processed the event. Deduplicate on `X-Paymee-Webhook-Id` (it is stable across retries): record processed ids and make fulfilment safe to run twice, so a repeat delivery never double-ships an order or double-credits a balance. 3. **Retries.** Failed deliveries are retried up to three times, after approximately **1 minute**, **2 hours**, and **24 hours**. You can inspect delivery history for a session via [`GET /gateway/sessions/{id}/webhooks/`](/reference/operations/listsessionwebhookdeliveries/), or across the account via [`GET /gateway/webhooks/`](/reference/operations/listwebhookdeliveries/). ### Manually resending If all automatic retries are exhausted (or your endpoint was misconfigured at the time), you can **resend a delivery from your Paymee dashboard** — open **Settings → Developers**, find the event in the webhook logs, and choose **Resend**. A resend re-signs the payload with your current webhook secret and reuses the same `X-Paymee-Webhook-Id`, so an idempotent handler treats it as the same event. :::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/ --- # Idempotency Network calls fail. When you retry `POST /gateway/sessions/`, you must not create a second payment for the same order. Paymee makes session creation **idempotent through the `merchant_reference` field**. ## How it works `merchant_reference` is your unique identifier for a payment (an order id, a cart id, an invoice number — whatever is stable on your side). - The **first** request with a given `merchant_reference` creates the session and returns `201 Created`. - A **subsequent** request with the **same** `merchant_reference` returns the **existing** session with `200 OK` — no duplicate is created. ```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 → 200 OK, the same session is returned ``` ## 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 (e.g. the buyer is paying again after a failure that closed the session). - Keep it unique per merchant account. :::tip Because retries are safe, you can confidently retry on timeouts or `5xx` responses without risking a double charge. ::: ## Conflicts If a request conflicts with the current state of an existing session, you receive `409 Conflict` as an [RFC 9457 problem document](/guides/errors/), which may include an `existing_session_id`. Source: https://docs.paymee.online/guides/idempotency/ --- # Refunds Refund a succeeded payment with [`POST /gateway/sessions/{id}/refund/`](/reference/operations/refundpaymentsession/). Refunds can be **full** or **partial**. ## Request ```bash curl https://api.paymee.online/api/v1/gateway/sessions/ps_3Nk8x2c9aQ.../refund/ \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "reason": "Customer requested a refund", "refund_type": "FULL" }' ``` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `reason` | string | ✅ | Why the refund is issued. | | `refund_type` | string | – | `FULL` (default) or `PARTIAL`. | | `amount` | string | conditional | **Required for `PARTIAL`.** Decimal string with currency-correct precision. | | `metadata` | object | – | Arbitrary key/values. | For a partial refund: ```json { "reason": "Damaged item", "refund_type": "PARTIAL", "amount": "3.000" } ``` ## Response ```json { "refund_id": "rf_8Qa…", "refunded_amount": "10.500", "automated_refund": true } ``` - `automated_refund: true` — the acquirer refund/reversal was confirmed automatically. - `automated_refund: false` — the refund was accepted but the acquirer step could not be confirmed automatically; our operations team processes it manually. In that case the response also carries an `epg_error_message`. :::note Refunds only apply to sessions in the `SUCCEEDED` state. Refunding a session in any other state returns a `422` [problem document](/guides/errors/) (for example `invalid-state` or `no-payment`). ::: Source: https://docs.paymee.online/guides/refunds/ --- # Testing & sandbox Build and test your integration against the **sandbox**, where no real money moves. The environment is selected by your **key prefix** — a `sk_test_…` key operates in the sandbox. | Environment | Base URL | Key | | --- | --- | --- | | Sandbox | `https://api.paymee.online/api/v1` | `sk_test_…` | | Production | `https://api.paymee.online/api/v1` | `sk_live_…` | ## Test cards Use these cards on the hosted checkout page in the sandbox. Enter them on the acquirer's 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 OTP** for every test card: `12345`. The success or decline outcome is determined by the sandbox acquirer. :::caution These are **sandbox** test cards only. Never 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 OTP `12345`. 4. Confirm your endpoint receives a `session.succeeded` [webhook](/guides/webhooks/) and that you verify its signature. 5. Try a [refund](/guides/refunds/). 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 float. Floating-point math loses precision on money; 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 webhook payload. 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 captured `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 programmatically. ## Shape ```json { "type": "https://api.paymee.online/errors/validation-error", "title": "Validation Error", "status": 422, "detail": "One or more fields failed validation.", "errors": [ { "field": "amount", "code": "invalid", "message": "Amount is required." } ] } ``` | Field | Meaning | | --- | --- | | `type` | Stable URI identifying the error category — branch on this. | | `title` | Short, human-readable summary. | | `status` | HTTP status code. | | `detail` | Explanation specific to this occurrence. | | `errors` | Present on validation failures: a per-field array of `{ field, code, message }`. | Always key your handling on `type` (and the HTTP status), never on the human-readable `detail` string, which may change. ## Error type catalog All `type` URIs are under `https://api.paymee.online/errors/`. | `type` suffix | HTTP | Meaning | Retry? | | --- | --- | --- | --- | | `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 | Conflicts with the resource's current state. May include `existing_session_id`. | No | | `session-terminal` | 409 | The session is already in a terminal state. | No | | `validation-error` | 422 | One or more fields failed validation (see `errors`). | No | | `checkout-limit-exceeded` | 422 | Too many active checkouts on the session. | No | | `invalid-state` | 422 | Operation not allowed in the session's current state (e.g. refunding a non-succeeded session). | No | | `no-payment` | 422 | No successful payment found to act on. | No | | `refund-failed` | 422 | The refund could not be completed. | No | | `no-terminal-configured` | 422 | The merchant account has no acquirer terminal configured. | No (merchant config) | | `terminal-inactive` | 422 | The account's acquirer terminal is inactive. | No (merchant config) | | `terminal-rejected` | 422 | The acquirer rejected the terminal credentials. | No (merchant config) | | `rate-limit-exceeded` | 429 | Too many requests. Back off and retry. | Yes | | `processor-unavailable` | 502 | The acquirer is unreachable or timed out. | Yes | | `internal-error` | 500 | Unexpected server error. | Yes (with backoff) | :::tip The `4xx` types are caused by your request or merchant configuration and should not be blindly retried. Only `429`, `500`, and `502` are safe to retry, with exponential backoff. ::: ## Validation error codes A `validation-error` carries a per-field `errors` array. The `code` values you should branch on: | `errors[].code` | Field | Meaning | | --- | --- | --- | | `required` / `invalid` | any | The field is missing or malformed. | | `invalid_type` | `amount` | `amount` was sent as a number, not a string. | | `invalid_format` | `amount` | Thousands separators, leading zeros, or not a decimal. | | `invalid_precision` | `amount` | Decimal places don't match the currency (3 for `TND`, 2 for `EUR`). | | `invalid_value` | `amount` | Zero or negative, or a foreign amount that rounds to nothing in TND. | | `amount_too_large` | `amount` | Over `999999.999` TND, before or after conversion. | | `currency_unsupported` | `currency` | No exchange rate is configured for this currency. Configure one in Settings → Currencies, or request in `TND`. See [Amounts & currency](/guides/amounts-and-currency/#unsupported-currency). | ## Rate limits Limits apply **per merchant account**, separately for live (`sk_live_`) and test (`sk_test_`) keys: sandbox traffic never uses your live budget, and every key on the same account shares that account's budget. Every request counts against the account-wide limit. Creating sessions and checkouts, 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 /sessions/`, `POST /sessions/{id}/checkout/` | 10 requests/second | 5 requests/second | | Refund | `POST /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. Wait at least that long before retrying. When you retry a session creation, reuse the same `merchant_reference` so a request that did go through is not repeated (see [Idempotency](/guides/idempotency/)). 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 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 the [sandbox](/guides/testing/), follow this checklist to go live. ## Checklist - **Swap your keys.** Replace `sk_test_…` with your `sk_live_…` key and point at the production base URL `https://api.paymee.online/api/v1`. - **Use the live webhook secret.** Configure your endpoint with the production `whsec_…` secret and confirm signature verification passes on a real event. - **Verify every webhook.** Reject any webhook whose `X-Paymee-Signature` does not validate. Never trust an unverified payload. - **Fulfil from webhooks, not redirects.** Confirm orders are marked paid by the `session.succeeded` webhook, and that `return_url` is treated as UX only. - **Deduplicate** on `X-Paymee-Webhook-Id` and make your handler idempotent. - **Reuse `merchant_reference`** per order so retries don't double-charge. See [Idempotency](/guides/idempotency/). - **Format amounts per currency.** TND uses three decimals — see [Amounts & currency](/guides/amounts-and-currency/). - **Handle errors by `type`.** Branch on the RFC 9457 `type`, retry only `429`, `500`, and `502` with backoff. See [Errors](/guides/errors/). - **Return `2xx` fast** from your webhook endpoint; do heavy work async. - **Secure your secrets.** Keep `sk_…` and `whsec_…` server-side, out of source control and logs. Rotate 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, monitor [webhook delivery history](/reference/operations/listwebhookdeliveries/) and reconcile against [session status](/guides/hosted-checkout/#session-status) for any payments where a webhook was missed. Source: https://docs.paymee.online/guides/going-live/