Webhooks (your own messaging)
With Our own messaging system in Settings (or PUT /v1/org/settings with { "messagingMode": "webhook", "webhookUrl": "https://…" }), Tracker doesn't email customers. It posts each message to your URL, and you send it however you like: SMS, WhatsApp, your own email templates.
The request
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Tracker-Event: message.requested
X-Tracker-Signature: t=1793197591,v1=5c1f…
Idempotency-Key: msg_…
{
"type": "message.requested",
"createdAt": "2026-11-02T09:41:00.000Z",
"message": {
"id": "msg_…",
"template": "journey.started",
"channel": "sms",
"organisation": { "id": "org_…", "name": "Northbrook Homes" },
"journey": { "id": "jrn_…", "externalRef": "JOB-1001" },
"recipient": { "name": "Sam Tenant", "email": null, "phone": "+447700900123" },
"data": {
"trackingUrl": "https://<your Tracker host>/t/…",
"operativeName": "Alex",
"destinationLabel": "14 Woodhouse Lane, Leeds",
"etaAt": "2026-11-02T09:58:00.000Z"
}
}
}
templateisjourney.booked,journey.started,journey.delayed(withdata.delayReasonin customer terms, andetaAtthe new time) orjourney.cancelled.channelisemailwhen the customer has an email address andsmswhen they only have a phone number. It's a suggestion; deliver however suits the customer.message.idis stable: treat it as an idempotency key, so a repeated delivery isn't sent to the customer twice.
Reply 2xx once you've accepted the message. You can return an X-Message-Id header with your own reference, and it shows in the message log. Any other status is logged as failed.
Checking the signature
The signature is an HMAC-SHA256 of "<t>.<raw body>" with your webhook secret, in hex, where t is the Unix time it was signed. Check it before trusting the body, and reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyTracker(rawBody, header, secret, toleranceS = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
Use the raw request body, before any JSON parsing. The secret is created when you first switch to webhook messaging, and is shown under Customer messages in Settings (and as webhookSecret in GET /v1/org).
Deliveries aren't retried yet. A production version would queue messages and retry failures with backoff, which is why idempotency matters.