Getting started

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.

JSON — request body we POST
{
  "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.

Node.js — verify and handle
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);
});
Python — verify
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.