Webhooks
Instead of polling for changes, register an HTTPS endpoint and we will POST to it when something happens. Create endpoints in Settings → Webhooks, pick the events you care about, and copy the signing secret.
Events
sale.createdA sale is completed, at the till or through the API.sale.voidedA sale is voided and its entries reversed.sale.refundedA return or refund completes against a sale.payment.receivedA payment is recorded, in or out.purchase.receivedA goods-received note is booked into stock.stock.lowAn item crosses at or below its reorder point.The delivery
Every event arrives in the same envelope. The event-specific fields are under data.
{
"event": "sale.created",
"shop_id": "8f1c2d3e-4b5a-6c7d-8e9f-0a1b2c3d4e5f",
"delivery_id": "b7a1f0c9-2e4d-4a91-9f3b-6c5d4e3f2a1b",
"created_at": "2026-07-11T09:14:22.104Z",
"data": {
"sale_id": "3d9e7c21-…",
"invoice_no": "INV-00042",
"total_amount": "2450.00",
"payment_status": "paid",
"customer_id": 118,
"sale_date": "2026-07-11T09:14:21Z"
}
}X-Tajir-EventThe event name, e.g. sale.created.X-Tajir-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body, keyed by this endpoint's signing secret.X-Tajir-DeliveryUnique id for this delivery. Dedupe on it — a retry reuses the same id.Verify the signature
Anyone on the internet can POST to your URL. Check the signature before you trust the body — and compute it over the raw bytes you received, not over a re-serialised object.
import crypto from "node:crypto";
// Verify against the RAW body. Parsing to JSON and re-serialising changes the
// bytes — key order, whitespace — and the signature will not match.
app.post("/tajir-webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.TAJIR_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const received = req.headers["x-tajir-signature"] ?? "";
// Constant-time compare — a plain === leaks the secret one byte at a time.
const ok =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.status(401).end();
// Acknowledge first — we give up after 10 seconds — then do the work.
res.status(200).end();
const { event, data, delivery_id } = JSON.parse(req.body.toString());
void handle(event, data, delivery_id);
});import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header or "")Retries
Reply 2xx to acknowledge. We wait 10 seconds — do the real work after you respond, not before.
Timeouts, network errors and 5xx are retried with exponential backoff, up to 5 attempts. A 4xx — other than 408 and 429 — is a permanent rejection and is not retried.
Because a delivery can be retried, your handler must be idempotent — dedupe on X-Tajir-Delivery. Deliveries are queued only after the originating transaction commits, so you will never be told about a sale that later rolled back.
Your endpoint must be HTTPS and publicly reachable. Every attempt — the status, the response body, the error — is recorded against the endpoint in your dashboard, so when an integration goes quiet you can see exactly what we sent and what came back.