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
Section titled “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. - A public
https://address for your server. To receive webhooks on your own machine, expose it through an HTTPS tunnel. - An
orderstable with at least an id, an amount, a currency and a status (pending,paidorreleased).
Create the session
Section titled “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. - Compute
amounton 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.
import express from "express";import { orders } from "./store.js"; // placeholder: your database
const app = express();const PAYMEE_API = "https://api.paymee.online/api/v1";const PAYMEE_SECRET_KEY = process.env.PAYMEE_SECRET_KEY; // sk_test_… or sk_live_…
app.post("/orders/:id/pay", async (req, res) => { const order = await orders.find(req.params.id); // your code: check it belongs to this buyer if (!order) return res.sendStatus(404); if (order.status !== "pending") return res.redirect(303, `/orders/${order.id}/confirmation`);
const response = await fetch(`${PAYMEE_API}/gateway/sessions/`, { method: "POST", headers: { Authorization: `Bearer ${PAYMEE_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: order.amount, // "10.500" currency: order.currency, // "TND" merchant_reference: order.id, first_name: order.firstName, last_name: order.lastName, email: order.email, return_url: `https://your-site.tn/orders/${order.id}/confirmation`, cancel_url: "https://your-site.tn/cart", webhook_url: "https://your-site.tn/webhooks/paymee", }), }); if (!response.ok) return res.status(502).send("Payment is unavailable, please try again."); const session = await response.json(); // 201: new session, 200: the existing one
await orders.saveSessionId(order.id, session.id); // checkout_url is null when the session is already final: show the order instead. res.redirect(303, session.checkout_url ?? `/orders/${order.id}/confirmation`);});<?php// 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 buyerif (!$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);import os
import requestsfrom 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)Saving the session’s id (ps_…) on the order lets you look the session up later.
Every field and error is in Hosted checkout
and Errors.
Redirect the buyer
Section titled “Redirect the buyer”The code above answers with a 303 redirect to checkout_url. The buyer pays on the
Paymee-hosted page, with 3-D Secure, and can retry on the same page after a decline.
Then:
- After a successful payment, Paymee redirects the buyer to your
return_url. - The page’s Return to store button takes the buyer to your
cancel_url, without paying.
A checkout_url lasts about 30 minutes, the session about 24 hours. A buyer who comes back
later and clicks Pay again gets a fresh link: the same request returns the open session
with a new checkout_url.
Handle the webhook
Section titled “Handle the webhook”Paymee sends a signed POST to your webhook_url when the session ends. Your handler:
-
Verifies the signature against the raw request body, and answers
400if it doesn’t match. -
Skips an event it already processed. Delivery is at least once, and a retry keeps the same event
id(also sent as theX-Paymee-Webhook-Idheader). -
Acts on the event:
session.succeeded: checks thatdata.amountanddata.currencymatch the order, then marks it paid and queues the fulfilment (emails, shipping).session.failed(every attempt declined) orsession.expired(unpaid after about 24 hours): releases the order. Both are final.
-
Records the event and answers
200within 10 seconds. If something fails, it answers500(or times out), and Paymee retries for about 3 days.
data is the session, and data.merchant_reference is your order id.
import crypto from "node:crypto";import express from "express";import { orders, processedEvents, jobs } from "./store.js"; // placeholders: your database and job queue
const app = express();const WEBHOOK_SECRET = process.env.PAYMEE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300; // reject events signed more than 5 minutes ago
export function verify(rawBody, header, secret, tolerance = TOLERANCE_SECONDS) { let timestamp; const signatures = []; for (const part of String(header ?? "").split(",")) { const i = part.indexOf("="); if (i === -1) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t") timestamp = value; else if (key === "v1") signatures.push(value); // several during a secret rotation } if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8"); const expected = Buffer.from( crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex"), ); return signatures.some((sig) => { const candidate = Buffer.from(sig); return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected); });}
// express.raw keeps the body as bytes. Register this route before any app.use(express.json()).app.post("/webhooks/paymee", express.raw({ type: "application/json" }), async (req, res) => { if (!verify(req.body, req.get("X-Paymee-Signature"), WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body);
try { if (await processedEvents.has(event.id)) return res.sendStatus(200); // a repeat
const session = event.data; const reference = session.merchant_reference; switch (event.event) { case "session.succeeded": { const order = await orders.find(reference); if (!order || session.amount !== order.amount || session.currency !== order.currency) { console.error(`Session ${session.id} doesn't match order ${reference}: not fulfilled`); break; } if (await orders.markPaid(reference, session.id)) { await jobs.enqueue("fulfil-order", reference); // slow work runs after you answer } break; } case "session.failed": case "session.expired": await orders.release(reference); break; // Any other event: nothing to do here. }
await processedEvents.add(event.id); res.sendStatus(200); } catch (err) { console.error(err); res.sendStatus(500); // Paymee retries }});<?php// POST /webhooks/paymeerequire __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);import hashlibimport hmacimport jsonimport osimport 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 "", 200A few details:
- Compare amounts as strings.
data.amounthas the same three-decimal format you sent ("10.500"). If you price orders in another currency, comparedata.original.amountanddata.original.currencyinstead:data.amountis then the converted TND charge. See Amounts & currency. - Events can arrive out of order, and that’s fine here:
markPaidandreleaseonly move apendingorder, 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 with200. See 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.
Show the return page
Section titled “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}/ to show the
right message. This page only displays the result: it never marks the order paid.
// Same app, orders, PAYMEE_API and PAYMEE_SECRET_KEY as in "Create the session".app.get("/orders/:id/confirmation", async (req, res) => { const order = await orders.find(req.params.id); // your code: check it belongs to this buyer if (!order) return res.sendStatus(404);
let paid = order.status === "paid"; if (order.status === "pending" && order.sessionId) { // The webhook hasn't arrived yet: ask Paymee. const response = await fetch(`${PAYMEE_API}/gateway/sessions/${order.sessionId}/`, { headers: { Authorization: `Bearer ${PAYMEE_SECRET_KEY}` }, }); paid = response.ok && (await response.json()).status === "SUCCEEDED"; } res.send(paid ? "Payment received, thank you!" : "We're confirming your payment. Refresh this page in a moment.");});<?php// 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 buyerif (!$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.";# 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."Reconcile
Section titled “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}/ 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 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/: newest first,
limit up to 100, and the last session’s id as starting_after while has_more is
true.
Checklist
Section titled “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.
markPaidandreleaseonly movependingorders, so repeats and out-of-order events are harmless.- The handler answers
2xxwithin 10 seconds, queues slow work, and answers5xxon errors so Paymee retries. - A reconciliation job catches anything the webhooks missed.
When this works in test mode, follow Going live.

