Skip to main content

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"
}
}
}
  • template is journey.booked, journey.started, journey.delayed (with data.delayReason in customer terms, and etaAt the new time) or journey.cancelled.
  • channel is email when the customer has an email address and sms when they only have a phone number. It's a suggestion; deliver however suits the customer.
  • message.id is 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).

Spike

Deliveries aren't retried yet. A production version would queue messages and retry failures with backoff, which is why idempotency matters.