# 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`).

<Aside type="note" title="The store in these examples is a placeholder">
`orders`, `processedEvents` and `jobs` stand for your own database and job queue. What
matters is how two of the order methods behave:

- `markPaid(reference, sessionId)` moves an order from `pending` to `paid` and returns
  whether it did. On an order that is already `paid` or `released`, it does nothing.
- `release(reference)` moves an order from `pending` to `released` (cancel it, put the
  stock back). On an order that is already `paid`, it does nothing.

In SQL, each is a single conditional update. Only one call can win, even when two copies
of the same event arrive at once:

```sql
UPDATE orders SET status = 'paid', session_id = $2
 WHERE id = $1 AND status = 'pending';   -- markPaid: "changed" = 1 row updated
```
</Aside>

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

<Tabs syncKey="lang">
<TabItem label="Node.js">

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

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

```php
<?php
// POST /orders/{id}/pay (your router sets $_GET["id"])
require __DIR__ . "/store.php"; // placeholder: your database ($orders)

$order = $orders->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);
```

</TabItem>
<TabItem label="Python">

```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/<order_id>/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)
```

</TabItem>
</Tabs>

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/).

<Aside type="caution">
A `merchant_reference` belongs to one amount and currency. If the order's total can
change after you first created its session, reusing the reference with the new amount is
refused with `409` `conflict`: give the changed order a new reference.
</Aside>

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

<Aside type="caution" title="Don't fulfil on the redirect">
The buyer can close the tab before reaching `return_url`, and anyone can open that URL.
Only the webhook changes the order.
</Aside>

## Handle the webhook

Paymee sends a signed `POST` to your `webhook_url` when the session ends. Your handler:

<Steps>

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.

</Steps>

`data` is the session, and `data.merchant_reference` is your order id.

<Tabs syncKey="lang">
<TabItem label="Node.js">

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

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

```php
<?php
// POST /webhooks/paymee
require __DIR__ . "/store.php"; // placeholders: your database and job queue ($orders, $processedEvents, $jobs)

const TOLERANCE_SECONDS = 300; // reject events signed more than 5 minutes ago

function verify(string $rawBody, string $header, string $secret, int $tolerance = TOLERANCE_SECONDS): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(",", $header) as $part) {
        $pair = explode("=", trim($part), 2);
        if (count($pair) !== 2) continue;
        [$key, $value] = $pair;
        if ($key === "t") $timestamp = $value;
        elseif ($key === "v1") $signatures[] = $value; // several during a secret rotation
    }
    if ($timestamp === null || !ctype_digit($timestamp) || !$signatures) return false;
    if (abs(time() - (int) $timestamp) > $tolerance) return false;
    $expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
    foreach ($signatures as $sig) {
        if (hash_equals($expected, $sig)) return true;
    }
    return false;
}

$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);
```

</TabItem>
<TabItem label="Python">

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

</TabItem>
</Tabs>

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.

<Tabs syncKey="lang">
<TabItem label="Node.js">

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

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

```php
<?php
// GET /orders/{id}/confirmation (your router sets $_GET["id"])
require __DIR__ . "/store.php"; // placeholder: your database ($orders)

$order = $orders->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.";
```

</TabItem>
<TabItem label="Python">

```python
# Same app, orders, PAYMEE_API and PAYMEE_SECRET_KEY as in "Create the session".
@app.get("/orders/<order_id>/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."
```

</TabItem>
</Tabs>

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