Qora by Laayo
Get API keys

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

TypeFires when
payment.createdA payment was created and its QR minted.
payment.succeededThe payment is confirmed and final. This is the one that matters.
payment.expiredThe QR expired unpaid.
payment.canceledThe payment was canceled.

The request

POST to your endpoint
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
Body
{
  "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:

  1. Parse t and v1 out of the header.
  2. Build the signed message: the timestamp, a literal ., then the raw request body.
  3. Compute HMAC-SHA256 of that message using your endpoint's signing secret.
  4. Compare with v1 using a constant-time comparison.
  5. Reject anything where t is 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

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

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

webhook.py
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.

Deduplicating
$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.