TrueRoute Developers

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

HeaderMeaning
x-trueroute-signaturet=<unix seconds>,v1=<hex>; two v1= values during a secret rotation
x-trueroute-timestampthe same t, which must match the signature
x-trueroute-event-iddedupe on it: delivery is at least once
x-trueroute-event-typeone of the event types below
x-trueroute-sequenceper subject; never let a lower sequence overwrite a higher one
x-trueroute-delivery-idthis delivery
x-trueroute-attemptthe 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

Event types

Prescriptions

Modules