Skip to content

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

  1. You register a webhook (URL + secret) and pick which events to receive.
  2. On each event PhkaPay POSTs the JSON payload to your URL, signed with your secret.
  3. Your server verifies the signature and returns 2xx quickly to acknowledge.
  4. Non-2xx responses or timeouts are retried (up to 3 attempts with backoff).

Events

EventFires when
payment.createdA KHQR was generated and is awaiting payment
payment.completedPayment succeeded (status: SUCCESS)
payment.failedPayment 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" below

Body

json
{
  "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)

js
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.

python
# 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 2xx within 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-timestamp is 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.

Last updated: June 5, 2026