Webhooks
Webhooks push every state change to your system, so you never poll for tracking. Each delivery is signed with HMAC-SHA256 over the timestamp and body.
Envelope
Every event carries an event id, type, created time, tenant, job id and a data object. Respond with any 2xx within 10 seconds; failures retry with exponential back-off and appear in the delivery log for manual replay. Events can arrive more than once: de-duplicate on id.
POST /hazchem/events HTTP/1.1
content-type: application/json
hazchem-timestamp: 1791795600
hazchem-signature: v1=5f0c2a...e91b
hazchem-event-id: evt_2b7d9c41
{
"id": "evt_2b7d9c41",
"type": "delivery.completed",
"created_at": "2026-10-12T10:41:00.000Z",
"tenant_id": "t-lab",
"job_id": "job-sbx1",
"data": { "recipient": "Receiving desk", "otp_verified": true }
}Events
| Event | Meaning | data example |
|---|---|---|
| job.created | Job booked through any channel | {"job_id":"job-sbx1","ref":"HZ-SBX1001","sku":"HZ-LAB-05","sla_due":"2026-10-12T11:00:00.000Z"} |
| job.assigned | Driver and vehicle assigned | {"job_id":"job-sbx1","driver":{"first_name":"Sipho"},"vehicle":{"registration":"THE GO 40 GP"},"eta_pickup_minutes":18} |
| pickup.completed | Collected; scan count and box temperature | {"job_id":"job-sbx1","parcels_scanned":2,"seal":"HZ-S445330","box_temperature_c":4.6} |
| custody.scanned | Scan at any hand-off with seal check | {"job_id":"job-sbx1","location":"Meridian Central Lab","seal_match":true,"by":"Receiving desk"} |
| temperature.excursion | Out of range with duration and action | {"job_id":"job-23","range_c":[2,8],"max_c":9.4,"minutes_out_of_range":14,"action":"Receiver notified, re-dispatch offered"} |
| delivery.completed | Delivered with recipient | {"job_id":"job-sbx1","recipient":"Receiving desk","otp_verified":true,"delivered_at":"2026-10-12T10:41:00.000Z"} |
| delivery.failed | Handover failed; return started | {"job_id":"job-30","reason":"No person over 14 with the OTP","return_job_id":"ret_job-30"} |
| pod.available | POD artefacts ready | {"job_id":"job-sbx1","pod_url":"https://api.haz-chem.co.za/v1/jobs/job-sbx1/pod"} |
| invoice.issued | Invoice issued | {"invoice_id":"INV-2618","period":"2026-09","amount_cents":41280000,"currency":"ZAR","vat_inclusive":false} |
Also available: quote.created, job.updated, job.cancelled, driver.en_route, pickup.arriving, pickup.failed, seal.broken, temperature.reading, eta.changed, sla.at_risk, sla.breached, delivery.arriving, return.created, return.completed, certificate.issued, statement.issued, payout.sent, incident.reported, compliance.expiring.
Verify the signature
Compute HMAC-SHA256(secret, timestamp + "." + raw_body) and compare it in constant time with the Hazchem-Signature header. Reject timestamps older than five minutes to stop replays. Rotate secrets from the developer console; the old secret stays valid for 24 hours.
import crypto from "node:crypto";
// Express-style handler. Use the RAW body: re-serialised JSON will not match the signature.
export function verifyHazchem(rawBody, headers, secret) {
const ts = headers["hazchem-timestamp"];
const sig = headers["hazchem-signature"]; // "v1=<hex>"
if (!ts || !sig) return false;
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = sig.replace(/^v1=/, "");
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}import hmac, hashlib, time
def verify_hazchem(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("Hazchem-Timestamp")
sig = headers.get("Hazchem-Signature", "").removeprefix("v1=")
if not ts or abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)