Webhooks
When a session is claimed, ToruPay POSTs a signed JSON body to the webhook_url you set on that session. Delivery is at-least-once and keeps retrying until your endpoint answers with a 2xx.
The request
POST https://yourshop.example/webhooks/kronx
content-type: application/json
x-kronx-signature: dcfe85e57d5758fd9f70cc22ecc537556015821fdaa26a253532aa9d3415daf6
x-kronx-event-id: 8412
x-kronx-attempt: 1
{"order_id":"ORD-1042","payment_status":"COMPLETED","trid":"kpay_5f1c8a2d4b3e4f7a9c1d2e3f4a5b6c7d","amount":1200}Headers
| Header | Value |
|---|---|
x-kronx-signature | HMAC-SHA256, hex encoded, over the exact JSON bytes of the body, keyed with your callback secret. |
x-kronx-event-id | The outbox row id. Stable across every retry of the same event. Use it as your idempotency key. |
x-kronx-attempt | Which attempt this is, starting at 1. |
Body
| Field | Type | Meaning |
|---|---|---|
order_id | string | Your own order reference, exactly as you sent it to initialize. |
payment_status | string | One of COMPLETED, FAILED, CANCELLED, EXPIRED. |
trid | string | The session’s payment_id. Not the provider TrxID. |
amount | number | The session amount in taka. |
Verifying the signature
Compute the HMAC over the raw request body and compare it with a constant-time function. Never compare signatures with ==.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the exact bytes. Re-serialising the parsed object changes the
// whitespace and the signature will never match again.
app.post(
'/webhooks/kronx',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body; // Buffer
const expected = crypto
.createHmac('sha256', process.env.KRONX_CALLBACK_SECRET)
.update(raw)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(req.get('x-kronx-signature') ?? '', 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(raw.toString('utf8'));
applyOnce(req.get('x-kronx-event-id'), event);
res.status(200).end();
}
);<?php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('KRONX_CALLBACK_SECRET'));
$given = $_SERVER['HTTP_X_KRONX_SIGNATURE'] ?? '';
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
apply_once($_SERVER['HTTP_X_KRONX_EVENT_ID'] ?? '', $event);
http_response_code(200);import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/kronx")
def kronx_webhook():
raw = request.get_data() # bytes, before any parsing
expected = hmac.new(
os.environ["KRONX_CALLBACK_SECRET"].encode(),
raw,
hashlib.sha256,
).hexdigest()
given = request.headers.get("x-kronx-signature", "")
if not hmac.compare_digest(expected, given):
return "", 401
event = json.loads(raw)
apply_once(request.headers.get("x-kronx-event-id"), event)
return "", 200Idempotency
Because delivery is at-least-once, the same event can arrive more than once: a timeout on your side that still completed, a slow 200, a retry that crossed with your response. Record x-kronx-event-id and make the second arrival a no-op. Do not key on order_id alone, because one order can legitimately produce more than one event.
What counts as delivered
Only a response with status 200 to 299. Redirects are not followed, and 3xx, 4xx and 5xx are all treated as failures, as is a timeout. The request times out after 10 seconds, so answer quickly and do the slow work afterwards.
Retry schedule
After a failed attempt the next one is scheduled at 1 minute × 2^attempts, capped at six hours.
| After attempt | Next attempt in |
|---|---|
| 1 | 2 minutes |
| 2 | 4 minutes |
| 3 | 8 minutes |
| 4 | 16 minutes |
| 5 | 32 minutes |
| 6 | 1 hour 4 minutes |
| 7 | 2 hours 8 minutes |
| 8 | 4 hours 16 minutes |
| 9 and beyond | 6 hours, the ceiling |
| 15 | No further attempt. The event is marked dead. |
An event also stops if the webhook_url fails its address check at send time, or if no callback secret is set on your account. Setting the secret later lets the queued events go out.
Every rejection code your endpoint might want to mirror is on the errors page.