Read the event details
Read X-Payment-Platform-Event-Id, X-Payment-Platform-Event-Type, and X-Payment-Platform-Signature.
Webhooks
Add an HTTPS webhook endpoint in Merchant Dashboard. Quid Payments sends your server signed events when a checkout completes, fails, or expires.
Verify each event
Verify the original raw body before you parse the JSON.
X-Payment-Platform-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
v1 = HMAC_SHA256(signing_secret, "<t>.<raw_request_body>")Webhook checklist
1. Read the raw request body.
2. Parse t and v1 from X-Payment-Platform-Signature.
3. Reject timestamps more than 5 minutes from your server clock.
4. Recompute HMAC_SHA256(signing_secret, "<t>.<raw_request_body>").
5. Compare signatures with a constant-time comparison.
6. Store X-Payment-Platform-Event-Id and return 200 for already-processed events.
7. Treat delivery as at-least-once; your handler must be idempotent.
8. For checkout.session.completed, confirm amount_minor, currency,
invoice_ref, and successful checkout status before fulfilment.Verification examples
Use a five-minute timestamp tolerance. Compare signatures in constant time. Store each event ID before you update an order.
import crypto from "node:crypto";
function verifyWebhook({ rawBody, signature, signingSecret }) {
const parts = Object.fromEntries(signature.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
const received = parts.v1 || "";
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (!timestamp || ageSeconds > 300) throw new Error("stale webhook");
const expected = crypto.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`).digest("hex");
if (received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
throw new Error("invalid webhook signature");
}
}
export async function handleWebhook(request, response) {
const rawBody = await getRawBody(request);
verifyWebhook({ rawBody,
signature: request.headers["x-payment-platform-signature"],
signingSecret: process.env.PAYMENT_PLATFORM_WEBHOOK_SECRET });
const eventId = request.headers["x-payment-platform-event-id"];
if (await alreadyProcessed(eventId)) return response.status(200).end();
const event = JSON.parse(rawBody);
if (event.type === "checkout.session.completed") {
await fulfilIfAmountCurrencyAndInvoiceMatch(event.data.session);
}
await markProcessed(eventId);
response.status(200).end();
}import hmac
import json
import time
from hashlib import sha256
def verify_payment_platform_webhook(raw_body: bytes, signature: str, signing_secret: str) -> None:
parts = dict(item.split("=", 1) for item in signature.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > 300:
raise ValueError("stale webhook")
expected = hmac.new(signing_secret.encode(), f"{timestamp}.".encode() + raw_body, sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
raise ValueError("invalid webhook signature")
def handle_payment_platform_webhook(request):
raw_body = request.body
verify_payment_platform_webhook(raw_body, request.headers["X-Payment-Platform-Signature"], PAYMENT_PLATFORM_WEBHOOK_SECRET)
event_id = request.headers["X-Payment-Platform-Event-Id"]
if already_processed(event_id):
return "", 200
event = json.loads(raw_body)
if event["type"] == "checkout.session.completed":
fulfil_if_amount_currency_and_invoice_match(event["data"]["session"])
mark_processed(event_id)
return "", 200Read X-Payment-Platform-Event-Id, X-Payment-Platform-Event-Type, and X-Payment-Platform-Signature.
Handle checkout.session.completed, checkout.session.failed, and checkout.session.expired.
Confirm amount_minor, currency, invoice_ref, and successful checkout status before you fulfil.
{
"id": "evt_example",
"type": "checkout.session.completed",
"data": { "session": {
"id": "cs_example", "status": "success", "amount_minor": 307038,
"currency": "GHS", "invoice_ref": "INV-2026-001", "metadata": {}
}}
}