Skip to content

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

Callback 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

HeaderValue
x-kronx-signatureHMAC-SHA256, hex encoded, over the exact JSON bytes of the body, keyed with your callback secret.
x-kronx-event-idThe outbox row id. Stable across every retry of the same event. Use it as your idempotency key.
x-kronx-attemptWhich attempt this is, starting at 1.

Body

FieldTypeMeaning
order_idstringYour own order reference, exactly as you sent it to initialize.
payment_statusstringOne of COMPLETED, FAILED, CANCELLED, EXPIRED.
tridstringThe session’s payment_id. Not the provider TrxID.
amountnumberThe 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 ==.

Node.js
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
<?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);
Python
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 "", 200
Read the body before your framework parses it.The signature covers the exact bytes that were sent. A JSON body that has been parsed and re-serialised is a different byte string, and the check will fail for every event.

Idempotency

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 attemptNext attempt in
12 minutes
24 minutes
38 minutes
416 minutes
532 minutes
61 hour 4 minutes
72 hours 8 minutes
84 hours 16 minutes
9 and beyond6 hours, the ceiling
15No 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.