Skip to content

Fulfil an order

Download .md

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

  • 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 orders table with at least an id, an amount, a currency and a status (pending, paid or released).

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

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.

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.

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

  1. Verifies the signature against the raw request body, and answers 400 if it doesn’t match.

  2. Skips an event it already processed. Delivery is at least once, and a retry keeps the same event id (also sent as the X-Paymee-Webhook-Id header).

  3. Acts on the event:

    • session.succeeded: checks that data.amount and data.currency match the order, then marks it paid and queues the fulfilment (emails, shipping).
    • session.failed (every attempt declined) or session.expired (unpaid after about 24 hours): releases the order. Both are final.
  4. Records the event and answers 200 within 10 seconds. If something fails, it answers 500 (or times out), and Paymee retries for about 3 days.

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

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

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

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

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.

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