Authentication and request signing
Every request carries your API key. Every POST also carries a signature over the exact bytes sent, made with your request-signing secret. GETs are never signed.
| Concern | Rule |
|---|---|
| Authentication | Authorization: Bearer <API_KEY> on every call |
| Signing | X-TrueRoute-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> on every POST |
| Clock | the signature's t must be within 300 seconds of TrueRoute's clock, in both directions: keep NTP |
| Idempotency | a fresh UUID per logical operation (at most 255 characters), reused with the SAME bytes on every retry |
API keys
A key is issued once, with its request-signing secret, and shown once. A sandbox key begins tr_sandbox_ and a production key tr_live_. The prefix is a human affordance so a key found in a log names its own blast radius: never validate keys against it, and treat the whole value as opaque.
Example (fake): tr_sandbox_EXAMPLE_KEY_DO_NOT_USE
The two signed forms
The form is fixed per endpoint; the reference marks each one.
| Form | Used on | Signed material |
|---|---|---|
| unkeyed | POSTs that do not read an Idempotency-Key | {t}.{rawBody} |
| keyed | POSTs that require an Idempotency-Key | TRv1.{t}.{keyLength}.{key}.{rawBody} |
keyLength is the key's length in characters, so a key and a body can never be re-split into a different pair. The MAC is HMAC-SHA256 with your request-signing secret, written as lower-case hex.
Test vector
Check your signer against these values before you send anything. The secret is a fake published for this purpose; TrueRoute's test suite verifies both headers with the server's own verifier.
| Input | Value |
|---|---|
| secret | trsec_EXAMPLE_SECRET_DO_NOT_USE |
| t | 1767225600 |
| rawBody | {"example":true} |
| Idempotency-Key (keyed form) | 00000000-0000-4000-8000-000000000000 |
unkeyed: X-TrueRoute-Signature: t=1767225600,v1=1e79a362dd96b749a0143d7ba5fec629aefef816292e9e5f7d422f68bf1013d4
keyed: X-TrueRoute-Signature: t=1767225600,v1=2c02b72be6f939630509257a26163eaf567815095d882eb71bf7762597b06141
A signer in TypeScript
Lifted at build time from TrueRoute's partner sample, which the test suite executes against the server's verifier (Node 18+, no dependencies).
import { createHmac } from 'node:crypto'
/**
* The `X-TrueRoute-Signature` header for one request.
*
* `rawBody` is the exact string you send. `idempotencyKey` is the `Idempotency-Key` header value on
* an endpoint that requires one (the "keyed" form), or null on an endpoint that does not read it
* (the "unkeyed" form). Which form an endpoint uses is fixed: see REFERENCE.md section 4.
*/
export function trueRouteSignature(input: {
signingSecret: string
rawBody: string
idempotencyKey: string | null
timestampSeconds: number
}): string {
const t = String(Math.floor(input.timestampSeconds))
const material =
input.idempotencyKey === null
? `${t}.${input.rawBody}`
: `TRv1.${t}.${input.idempotencyKey.length}.${input.idempotencyKey}.${input.rawBody}`
const v1 = createHmac('sha256', input.signingSecret).update(material, 'utf8').digest('hex')
return `t=${t},v1=${v1}`
}
Idempotency
- Generate the key before the first send and persist it with your operation.
- Retry a timeout, a 409
request_in_flight, a 429 or a 5xx with the SAME key and the SAME body (a freshtand signature). A replay answers the stored response withIdempotent-Replay: true. - A different body under a used key is refused (
idempotency_key_reuse). A new operation takes a new key. - Never put patient data in a key.
Why a failure says so little
authentication_failed deliberately does not say whether the key or the signature was wrong: that answer would confirm a stolen key. Check the key, the signing secret, the form for that endpoint, and that you signed the bytes you sent.