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

EventFires when
payment.createdA payment is created.
payment.scannedThe customer opens/scans the QR.
payment.completedPayment is paid. The one most integrations act on.
payment.failedPayment failed.
payment.expiredPayment expired before payment.
payment.cancelledPayment was cancelled.
payment.refundedA 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...e2

Verifying 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 "", 200

The 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 2xx quickly; do slow work asynchronously.