Webhooks
Qora posts a signed JSON payload to your endpoint when a payment changes state. This is the reliable way to know you have been paid.
Events
| Type | Fires when |
|---|---|
payment.created | A payment was created and its QR minted. |
payment.succeeded | The payment is confirmed and final. This is the one that matters. |
payment.expired | The QR expired unpaid. |
payment.canceled | The payment was canceled. |
The request
POST /hooks/qora HTTP/1.1
Content-Type: application/json
X-Qora-Signature: t=1757212500,v1=8f3a…c91b
X-Qora-Event-Id: evt_9KpQ2mXnRt4vLb7sDcFg
X-Qora-Event-Type: payment.succeeded
X-Qora-Delivery-Id: whd_3Fk8QmZxVn2RtYuIoP
X-Qora-Attempt: 1
{
"id": "evt_9KpQ2mXnRt4vLb7sDcFg",
"object": "event",
"type": "payment.succeeded",
"mode": "live",
"created": 1757212500,
"data": {
"payment": {
"id": "pay_7Fh2kQ9mXwRt3vNbLp6sDc",
"amount": 25000,
"currency": "NPR",
"status": "succeeded",
"reference": "Order #1042",
"fonepay": { "trace_id": "884213907", "status": "success" },
"succeeded_at": "2026-09-07T09:17:12+05:45"
}
}
}
The signature scheme
The X-Qora-Signature header has two parts:
t=<unix timestamp>,v1=<hex hmac-sha256>
To verify:
- Parse
tandv1out of the header. - Build the signed message: the timestamp, a literal
., then the raw request body. - Compute
HMAC-SHA256of that message using your endpoint's signing secret. - Compare with
v1using a constant-time comparison. - Reject anything where
tis more than five minutes from now.
Sign the raw body, not re-encoded JSON. Parsing and re-serialising can reorder keys or change spacing, and the signature will never match. Capture the body as a string before your framework parses it.
Verifying — PHP
class="tok-key">function qora_verify(string $payload, string $header, string $secret, int $tolerance = class="tok-num">300): bool
{
$parts = [];
class="tok-key">foreach (explode(',', $header) class="tok-key">as $piece) {
[$k, $v] = array_pad(explode('=', trim($piece), class="tok-num">2), class="tok-num">2, '');
$parts[$k] = $v;
}
$timestamp = (int) ($parts['t'] ?? class="tok-num">0);
$given = (string) ($parts['v1'] ?? '');
class="tok-key">if ($timestamp <= class="tok-num">0 || $given === '') {
class="tok-key">return class="tok-key">false;
}
// Reject replays of an old-but-valid payload.
class="tok-key">if (abs(time() - $timestamp) > $tolerance) {
class="tok-key">return class="tok-key">false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// Constant-time: a plain === leaks the signature one byte at a time.
class="tok-key">return hash_equals($expected, $given);
}
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_QORA_SIGNATURE'] ?? '';
class="tok-key">if (! qora_verify($payload, $header, getenv('QORA_WEBHOOK_SECRET'))) {
http_response_code(class="tok-num">400);
exit;
}
$event = json_decode($payload, class="tok-key">true);
class="tok-key">if ($event['type'] === 'payment.succeeded') {
mark_paid($event['data']['payment']['reference']);
}
http_response_code(class="tok-num">200);
Verifying — Node
const crypto = require('crypto');
const express = require('express');
const app = express();
function qoraVerify(payload, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('='))
);
const timestamp = parseInt(parts.t, 10);
const given = parts.v1;
if (!timestamp || !given) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(given, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as bytes. express.json would reformat it and
// every signature check would fail.
app.post('/hooks/qora', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString('utf8');
if (!qoraVerify(payload, req.get('X-Qora-Signature') || '', process.env.QORA_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(payload);
if (event.type === 'payment.succeeded') {
markPaid(event.data.payment.reference);
}
res.sendStatus(200);
});
Verifying — Python
import hashlib
import hmac
import time
from flask import Flask, request
app = Flask(__name__)
def qora_verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(
piece.strip().split("=", 1)
for piece in header.split(",")
if "=" in piece
)
try:
timestamp = int(parts.get("t", 0))
except ValueError:
return False
given = parts.get("v1", "")
if not timestamp or not given:
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, given)
@app.post("/hooks/qora")
def hook():
# request.data is the raw body, before any JSON parsing.
if not qora_verify(request.data, request.headers.get("X-Qora-Signature", ""), SECRET):
return "", 400
event = request.get_json()
if event["type"] == "payment.succeeded":
mark_paid(event["data"]["payment"]["reference"])
return "", 200
Responding
Return any 2xx as soon as you have stored the event. Anything else — or no answer
within 15 seconds — counts as a failure and is retried.
Do the slow work afterwards. Printing a receipt or calling another service inside the webhook handler is how a timeout turns one payment into five duplicate deliveries.
Retries
Up to five attempts, backing off: immediately, then 30s, 5m, 30m, 2h.
Every attempt carries the same X-Qora-Event-Id. Deduplicate on it.
A successful delivery whose response we never received will be retried, so your handler must
tolerate seeing the same event twice.
$eventId = $_SERVER['HTTP_X_QORA_EVENT_ID'];
// A unique index on this column turns a duplicate delivery into a no-op.
class="tok-key">if (already_processed($eventId)) {
http_response_code(class="tok-num">200);
exit;
}
record_processed($eventId);
Failures and recovery
Every attempt, its response code, and its body are visible under
Developers → Webhooks, and any delivery can be
retried by hand. If your receiver was down for longer than the retry window, reconcile from
GET /v1/events, which holds the events regardless of delivery.
Security notes
- https only. http endpoints are rejected when you add them.
- The webhook secret is not your API key. Two separate secrets, so leaking one does not compromise the other.
- Verify before you trust. Anyone can POST to a public URL; only Qora can sign for it.
- Treat the payload as a notification, not as truth. For anything irreversible, re-read
GET /v1/payments/{id}server-side.