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);
} else if (event.type.startsWith("payout.")) {
await refreshStoredPayout(event.data.payout.id, event.environment);
}
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"])
elif event["type"].startswith("payout."):
refresh_stored_payout(event["data"]["payout"]["id"], event["environment"])
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": {}
}}
}Payout events
Handle payout.requested, payout.processing, payout.paid, payout.failed, payout.cancelled and payout.returned. These events use data.payout, with environment and livemode at the top level.
The payout contains its ID, Quid reference, status, amount, fee, currency, bank reference, return reference and reconciliation status. It has no checkout session or recipient account details. See the complete payout event example.
Events go only to the active webhook paired with the creating key. Verify the raw body and timestamp using the same signature contract. Deduplicate by event id; retries reuse it. Do not assume arrival order. A paid payout can later be returned.
The example handlers call your own refreshStoredPayout function. It should match the payout ID and environment to your stored payout, fetch its current state with the creating key, and update your record before marking the event processed. Keep processing and event deduplication atomic or use a durable queue.