Webhooks
TrueRoute registers an https endpoint per integration and environment and issues a webhook secret for it, a different secret from your request-signing secret. Every delivery is signed; verify it before you trust it. A webhook is a convenience: missing one loses nothing a read of the order or journey cannot recover.
Signature
Every delivery is signed t=<unix seconds>,v1=<hex HMAC-SHA256> over {t}.{rawBody} with the endpoint's webhook secret. Reject a delivery whose t is more than 300 seconds from your clock. Compare in constant time.
Secret rotation. On an endpoint that receives module events, a rotation keeps the old secret valid for an overlap: the header then carries two v1= values over the same t, and a delivery is genuine when either matches the secret you hold, so you can switch on your own schedule. The event platform.webhook_secret.rotation_started announces it.
Headers
| Header | Meaning |
|---|---|
x-trueroute-signature | t=<unix seconds>,v1=<hex>; two v1= values during a secret rotation |
x-trueroute-timestamp | the same t, which must match the signature |
x-trueroute-event-id | dedupe on it: delivery is at least once |
x-trueroute-event-type | one of the event types below |
x-trueroute-sequence | per subject; never let a lower sequence overwrite a higher one |
x-trueroute-delivery-id | this delivery |
x-trueroute-attempt | the attempt number, starting at 1 |
Verification code (TypeScript)
Lifted at build time from TrueRoute's partner sample. The test suite runs this exact code against the headers TrueRoute's delivery worker produces, with one secret and with two during a rotation.
import { createHmac, timingSafeEqual } from 'node:crypto'
export const TRUEROUTE_SIGNATURE_WINDOW_SECONDS = 300
/**
* Verify one webhook delivery from TrueRoute.
*
* `rawBody` is the body EXACTLY as received (read it as text before parsing). `headers` uses
* lower-case names. While TrueRoute rotates an endpoint's secret the signature header carries two
* `v1=` values over the same `t=`; a delivery is genuine when ANY of them matches the secret you
* hold, so you can switch secrets on your own schedule inside the overlap.
*/
export function verifyTrueRouteWebhook(input: {
rawBody: string
headers: Record<string, string | undefined>
webhookSecret: string
nowSeconds: number
}): boolean {
const signature = input.headers['x-trueroute-signature']
const timestampHeader = input.headers['x-trueroute-timestamp']
if (typeof signature !== 'string' || typeof timestampHeader !== 'string') return false
let t: string | null = null
const candidates: string[] = []
for (const part of signature.split(',')) {
const eq = part.indexOf('=')
if (eq < 0) continue
const k = part.slice(0, eq).trim()
const v = part.slice(eq + 1).trim()
if (k === 't') t = v
else if (k === 'v1') candidates.push(v)
}
if (t === null || candidates.length === 0 || !/^[0-9]+$/.test(t)) return false
if (t !== timestampHeader.trim()) return false
if (Math.abs(input.nowSeconds - Number(t)) > TRUEROUTE_SIGNATURE_WINDOW_SECONDS) return false
const expected = Buffer.from(
createHmac('sha256', input.webhookSecret).update(`${t}.${input.rawBody}`, 'utf8').digest('hex'),
'utf8',
)
return candidates.some((v1) => {
const got = Buffer.from(v1, 'utf8')
return got.length === expected.length && timingSafeEqual(got, expected)
})
}
/**
* After verifying: dedupe on `x-trueroute-event-id`, apply only a `sequence` higher than the one you
* hold for that subject, and answer 2xx within 10 seconds. A GET of the order is always authoritative.
*/
export function shouldApplyWebhook(
seenEventIds: ReadonlySet<string>,
heldSequence: number | null,
headers: Record<string, string | undefined>,
): boolean {
const eventId = headers['x-trueroute-event-id']
const sequence = Number(headers['x-trueroute-sequence'])
if (typeof eventId !== 'string' || seenEventIds.has(eventId)) return false
if (!Number.isInteger(sequence)) return false
return heldSequence === null || sequence > heldSequence
}
// In your HTTP handler: read the body as TEXT first, verify, then parse.
const rawBody = await request.text()
const headers = Object.fromEntries(
[...request.headers].map(([k, v]) => [k.toLowerCase(), v]),
)
const genuine = verifyTrueRouteWebhook({
rawBody,
headers,
webhookSecret: process.env.TR_WEBHOOK_SECRET ?? '', // e.g. EXAMPLE_WEBHOOK_SECRET_DO_NOT_USE
nowSeconds: Date.now() / 1000,
})
if (!genuine) return new Response(null, { status: 401 })
if (shouldApplyWebhook(seenEventIds, heldSequenceFor(subject), headers)) {
apply(JSON.parse(rawBody))
}
return new Response(null, { status: 204 }) // answer 2xx quickly
Delivery and retries
- At least once. Answer 2xx within 10 seconds; anything else, or no answer, is retried.
- Prescription events retry after 1 min, 5 min, 30 min, 2 h, 6 h, 1 d.
- Module events retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 1 d, 1 d, 1 d, and are ordered per subject: a later event waits while an earlier one is still being retried.
- Events for a clinic whose grant has ended are withheld, never sent.
- Payloads carry ids and states only. Read the resource for detail; a read is always authoritative.
Event types
Prescriptions
order.acceptedorder.chargedorder.submitted_to_pharmacyorder.shippedorder.deliveredorder.exceptionorder.voided
Modules
diagnostics.lab_order.pendingdiagnostics.lab_order.accepteddiagnostics.lab_order.ambiguousdiagnostics.lab_order.refuseddiagnostics.lab_order.cancelleddiagnostics.lab_order.partially_resulteddiagnostics.lab_order.resulteddiagnostics.specimen.receiveddiagnostics.specimen.rejecteddiagnostics.specimen.recollection_requireddiagnostics.payment.chargeddiagnostics.payment.ambiguousdiagnostics.payment.refuseddiagnostics.payment.refundeddiagnostics.result.criticaldiagnostics.result.version_availablediagnostics.result.unmatcheddiagnostics.result.bounddiagnostics.critical.acknowledgeddiagnostics.critical.re_escalatedcollection.requisition.readycollection.appointment.bookedcollection.appointment.ambiguouscollection.appointment.refusedcollection.appointment.rescheduledcollection.appointment.cancelledcollection.appointment.checked_incollection.appointment.collectedcollection.appointment.no_showcollection.kit.shippedcollection.kit.deliveredcollection.kit.return_in_transitcollection.kit.received_by_labcollection.kit.exceptiondevices.connection.activedevices.connection.errordevices.connection.disconnecteddevices.observations.availableplatform.webhook_secret.rotation_started