QuataPay sends a signed HTTP POST to your endpoint each time a payment changes state. Verify every request before you act on it.
Configure an endpoint
In your merchant dashboard go to Developer → Webhooks and add your endpoint URL. It must be an https:// URL on the public internet — http://, private, loopback and link-local addresses are refused.
The endpoint's signing secret is shown once, when you create it. Store it like a password; every request to that endpoint is signed with it.
An endpoint receives every event unless you pick a list of event types for it.
What a request looks like
POST /quatapay/webhook HTTP/1.1
Content-Type: application/json
User-Agent: QuataPay-Webhooks/1.0
X-QuataPay-Timestamp: 1791370800
X-QuataPay-Signature: sha256=1c3a8d850ee6a783931573bb1066171269e25dcf1a8d9709ecb6bac778e3b121
{"data":{"amount":5000,"id":"pay-1"},"event":"gateway.payment.succeeded"}
The body is compact JSON with sorted keys: {"data": {…}, "event": "<event type>"}.
Verify the signature
The signature is an HMAC-SHA256, keyed with the endpoint's signing secret, over the timestamp header, a full stop, and the raw request body:
signed_payload = X-QuataPay-Timestamp + "." + raw_body
signature = "sha256=" + hex(HMAC_SHA256(signing_secret, signed_payload))
Accept a request only when all three of these hold:
- The timestamp is fresh.
X-QuataPay-Timestampis no more than 300 seconds from your server's clock. Reject anything older — it is a replay of a captured request. - The signature matches, compared in constant time. Use
crypto.timingSafeEqual,hmac.compare_digestorhash_equals— never==. - You signed the raw bytes. Compute the HMAC over the request body exactly as it arrived, before any JSON parsing. Parsing and re-serialising changes spacing and key order, and the signature will never match.
Answer a request that fails any check with 400 and do nothing else with it.
Test vector. With signing secret test_secret_do_not_use, the request shown above — timestamp 1791370800, body {"data":{"amount":5000,"id":"pay-1"},"event":"gateway.payment.succeeded"} — must produce exactly the X-QuataPay-Signature value shown. (Its timestamp is long past, so a correct verifier rejects that request as stale unless you skip the freshness check while testing.)
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const TOLERANCE_SECONDS = 300;
function verifyQuataPay(secret, rawBody, timestamp, signature) {
if (!/^\d+$/.test(timestamp || "")) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // a Buffer: the bytes exactly as received
.digest("hex"),
);
const provided = Buffer.from(signature || "");
return (
expected.length === provided.length &&
crypto.timingSafeEqual(expected, provided)
);
}
const app = express();
// express.raw keeps the body as a Buffer — do not put express.json() in front of this route.
app.post("/quatapay/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyQuataPay(
process.env.QPAY_WEBHOOK_SECRET,
req.body,
req.get("X-QuataPay-Timestamp"),
req.get("X-QuataPay-Signature"),
);
if (!ok) return res.status(400).send("invalid signature");
const { event, data } = JSON.parse(req.body.toString("utf8"));
// Record the event, answer 200, then do the work.
res.sendStatus(200);
});
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_quatapay(secret: str, raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature or "")
Pass the raw body: request.get_data() in Flask, request.body in Django, await request.body() in FastAPI. The headers are X-QuataPay-Timestamp and X-QuataPay-Signature.
PHP
function verifyQuataPay(string $secret, string $rawBody, ?string $timestamp, ?string $signature): bool {
if ($timestamp === null || !ctype_digit($timestamp)) return false;
if (abs(time() - (int) $timestamp) > 300) return false;
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature ?? '');
}
$rawBody = file_get_contents('php://input');
$ok = verifyQuataPay(
getenv('QPAY_WEBHOOK_SECRET'),
$rawBody,
$_SERVER['HTTP_X_QUATAPAY_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_QUATAPAY_SIGNATURE'] ?? null,
);
With the SDKs
Both official SDKs do all three checks. Pass the timestamp header — verify refuses a request without one.
import { WebhookVerifier } from "quatapay";
const verifier = new WebhookVerifier(process.env.QPAY_WEBHOOK_SECRET);
verifier.verify(req.body, req.get("X-QuataPay-Signature"), req.get("X-QuataPay-Timestamp")); // throws QuataPayError
from quatapay import WebhookVerifier
verifier = WebhookVerifier(secret=os.environ["QPAY_WEBHOOK_SECRET"])
verifier.verify(request.get_data(), request.headers.get("X-QuataPay-Signature"), request.headers.get("X-QuataPay-Timestamp")) # raises QuataPayError
Events
Every gateway event carries the same data object, the payment as it is after the change:
{
"data": {
"amount": 5000,
"completed_at": "2026-10-07T12:03:41.512345+00:00",
"created_at": "2026-10-07T11:58:02.104112+00:00",
"currency": "XAF",
"customer_reference": "order_12345",
"expected_release_at": null,
"fx_rate_bps": null,
"id": "3f2b8c1e-5d7a-4e0b-9a61-2c4d8e7f9a10",
"merchant_id": "9c1d…",
"metadata": { "cart_id": "c_981" },
"mode": "live",
"payment_id": "3f2b8c1e-5d7a-4e0b-9a61-2c4d8e7f9a10",
"payment_method": "momo",
"released_at": null,
"settlement_amount": null,
"settlement_currency": "XAF",
"settlement_held": null,
"settlement_mode": null,
"slug": "Xb3kP0qLm7Rt2VwY9cZa4Hn1",
"status": "succeeded",
"transaction_id": "b81e…"
},
"event": "gateway.payment.succeeded"
}
payment_method is how the customer paid — wallet (their QuataPay wallet) or momo (Mobile Money) — once the payment has succeeded. It is null on events before that, on sandbox simulations, and on payments completed before this field was added (October 2026).
Some events add fields:
failure_reasonongateway.payment.failed.cancel_reasonongateway.payment.cancelled, when one was given.paid_by_roleandpaid_by_idongateway.payment.succeededwhen a QuataPay user paid from their wallet.simulated: trueon events from the sandbox simulate endpoint.
Expired, then succeeded
A customer can approve a Mobile Money prompt shortly before the checkout's expiry time. While that prompt is pending the checkout is not expired — it stays processing until MTN answers, then becomes succeeded or failed. If MTN confirms a payment for a checkout that had already expired, QuataPay still credits you and sends gateway.payment.succeeded after the gateway.payment.expired you received earlier. The money is in your wallet: fulfil the order, or refund the customer.
Delivery and retries
A delivery succeeds when your endpoint answers with any 2xx status within 5 seconds. Anything else — another status, no answer within 5 seconds, a connection or TLS error — fails it, and QuataPay sends it again:
After the fifth failed attempt there are no more automatic retries. Retries go out from a job that runs once a minute, so each can arrive up to a minute after the time shown. Every attempt is signed again with a new timestamp, so a retry passes the 300-second check.
Developer → Webhooks → Show deliveries lists every attempt for an endpoint, and Replay sends any of them again.
Per-payment webhook_url. If you set webhook_url when you create a payment, that payment's events are also POSTed there, signed the same way with the secret of your oldest active endpoint. That copy is recorded as a delivery of that endpoint: it is retried on the same schedule, listed under its deliveries, and can be replayed. It is not sent twice when the URL is one of your endpoints. With no active endpoint nothing is sent to it (QuataPay never sends an unsigned webhook): read the payment back with GET /gateway/payments/{id}.
When events are sent. An event goes out once the change is saved, never for a change that was rolled back. A refusal the customer can get past (not enough money in their wallet, a declined phone request) sends no event: the checkout stays open (requested) until it is paid or expires.
Best practices
- Answer
2xxquickly and do the work afterwards — anything slower than 5 seconds counts as a failure and is sent again. - Make your handler idempotent. The same event can arrive more than once (a retry after a slow answer, a replay, the
webhook_urlcopy), so key your processing ondata.idplusevent. - Events can arrive out of order —
gateway.payment.succeededcan followgateway.payment.expired. Act on the latest state, or read it withGET /gateway/payments/{id}. - Use
data.id, notdata.slug, as the payment's stable identifier.