Webhook Integration
When a transaction changes state, PhkaPay sends an HTTP POST to the webhook URL you configured. Listen for these to be notified the moment a payment completes instead of polling POST /khqr/check.
Webhook endpoints (URL, secret, subscribed events, enable/disable) are created and managed in the admin dashboard. There is no public API for managing them — this guide covers how to receive and verify deliveries on your own server.
How delivery works
- You register a webhook (URL + secret) and pick which events to receive.
- On each event PhkaPay
POSTs the JSON payload to your URL, signed with your secret. - Your server verifies the signature and returns
2xxquickly to acknowledge. - Non-
2xxresponses or timeouts are retried (up to 3 attempts with backoff).
Events
| Event | Fires when |
|---|---|
payment.created | A KHQR was generated and is awaiting payment |
payment.completed | Payment succeeded (status: SUCCESS) |
payment.failed | Payment failed or the QR expired |
A webhook with no events selected receives all events.
The request we send
Headers
content-type: application/json
x-timestamp: 2025-08-03T15:11:13.483Z # ISO-8601; also part of the signature
x-signature: sha256=<hex hmac> # see "Signature" belowBody
{
"event": "payment.completed",
"transactionId": "abcf4dd4-b7b1-460d-a309-250981498458",
"md5": "0335ae49...",
"status": "SUCCESS",
"amount": "1",
"currency": "840",
"requestLog": {
"metaData": {
"meta": { "orderId": "ORD-123" },
"amount": 1,
"currency": 840,
"expiredAfter": 30000
}
},
"createdAt": "2025-08-03T15:11:13.483Z"
}status: SUCCESS paid · FAILED declined/error · EXPIRED QR timed out · PENDING awaiting payment. The meta you passed to generate is echoed back at requestLog.metaData.meta — use it to match your order.
Signature
Every delivery is signed so you can confirm it came from PhkaPay and was not tampered with.
signature = HMAC_SHA256( secret, `${x-timestamp}.${rawBody}` ) // lowercase hex
x-signature: sha256=<signature>secret— the webhook secret from the dashboard.rawBody— the exact bytes of the request body. Do not re-serialize the parsed JSON before hashing:JSON.stringify(parsedBody)can differ from what we sent (key order, spacing, unicode) and the signature will not match. Always hash the raw body.
Listen & verify (Node / Express)
import crypto from "crypto";
import express from "express";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
// Capture the RAW body so the signature can be checked byte-for-byte.
app.use("/webhook", express.raw({ type: "application/json" }));
function isValid(req) {
const ts = req.header("x-timestamp");
const sig = req.header("x-signature") || "";
const raw = req.body; // Buffer (from express.raw)
// Replay protection: reject deliveries older than 5 minutes.
if (!ts || Math.abs(Date.now() - Date.parse(ts)) > 5 * 60_000) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(`${ts}.${raw}`).digest("hex");
// Constant-time compare to avoid timing attacks.
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", (req, res) => {
if (!isValid(req)) return res.status(401).send("invalid signature");
const payload = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // acknowledge fast, then process asynchronously
queueMicrotask(() => handleEvent(payload));
});
function handleEvent(p) {
if (p.event === "payment.completed" && p.status === "SUCCESS") {
// fulfil order p.requestLog.metaData.meta.orderId
}
}
app.listen(3000);Verify in any language
The rule is identical everywhere: hex HMAC-SHA256 of `${x-timestamp}.${rawBody}` keyed with your secret, compared (constant-time) against x-signature without its sha256= prefix.
# Flask
import hmac, hashlib
def is_valid(raw: bytes, ts: str, sig: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)Best practices
- HTTPS only, and verify the signature before trusting any payload.
- Acknowledge fast — return
2xxwithin a few seconds, then process async. Slow handlers trigger retries. - Idempotency — store handled
transactionIds and skip duplicates; retries can re-deliver the same event. - Replay protection — reject deliveries whose
x-timestampis too old.
Testing
Send a test delivery from the webhook's page in the dashboard, or expose your local server with a tunnel (e.g. ngrok http 3000) and trigger a real payment.
Related
Last updated: June 5, 2026
