Webhooks
Webhooks push events to your server the instant something happens — most importantly, when a payment completes. Register endpoints in the dashboard under Webhooks, choose which events to receive, and view/replay deliveries.
Events
| Event | Fires when |
|---|---|
payment.created | A payment is created. |
payment.scanned | The customer opens/scans the QR. |
payment.completed | Payment is paid. The one most integrations act on. |
payment.failed | Payment failed. |
payment.expired | Payment expired before payment. |
payment.cancelled | Payment was cancelled. |
payment.refunded | A full or partial refund was issued. |
Delivery headers
Request headers
X-Payment-Event: payment.completed
X-Payment-Id: evt_9f8c... (event id — use for idempotency)
X-Payment-Signature: t=1783683255,v1=6f1c...e2Verifying signatures
Every delivery is signed. v1 is HMAC-SHA256(signing_secret, "{timestamp}.{rawBody}") in hex, where rawBody is the exact bytes you received. Reject requests whose timestamp is more than 5 minutes old (replay protection) or whose signature doesn’t match.
⚠️ Verify against the raw body
Compute the HMAC over the raw request bytes, before any JSON parsing/re-serialization — re-encoding changes the bytes and breaks the signature.
Node.js (Express)
import crypto from 'node:crypto';
// Mount with the raw body: app.use('/webhooks', express.raw({ type: '*/*' }))
app.post('/webhooks/paykh', (req, res) => {
const raw = req.body.toString('utf8');
const header = req.header('X-Payment-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto
.createHmac('sha256', process.env.PAYKH_WEBHOOK_SECRET)
.update(parts.t + '.' + raw)
.digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
if (!ok || !fresh) return res.status(400).end();
const event = JSON.parse(raw);
// Idempotent on the event id (X-Payment-Id header):
if (event.type === 'payment.completed') fulfillOrder(event.data.reference_id);
res.status(200).end();
});Python (Flask)
import hmac, hashlib, time, os
from flask import request, abort
@app.post("/webhooks/paykh")
def paykh_webhook():
raw = request.get_data() # exact bytes
header = request.headers.get("X-Payment-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(os.environ["PAYKH_WEBHOOK_SECRET"].encode(),
(parts["t"] + ".").encode() + raw,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
if abs(time.time() - int(parts["t"])) > 300:
abort(400)
# process request.json ...
return "", 200The official SDKs ship a verifySignature() helper so you don’t implement this by hand.
Reliability
- At-least-once delivery with automatic retries and backoff — make your handler idempotent on the event id.
- Failing endpoints are retried; persistent failures are logged and can auto-disable.
- Rotate the signing secret from the dashboard — the old secret stays valid for 24h.
- Return
2xxquickly; do slow work asynchronously.