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