Authentication & HMAC
Every ingest request after pairing is authenticated with HMAC-SHA256 over a canonical string. This page is the precise spec: how you get the secret, exactly what you sign, which headers carry it, and how replay is prevented.
1. Pairing — getting the shared secret
Before its first request a producer has no secret. It performs a one-time handshake:
- The owner generates a 12-character pair code (
VB-XXXX-XXXX, TTL ~1 hour, single-use) in the VoiceBot dashboard. - The producer
POSTs the code to/api/v1/ingest/pair— no HMAC headers (it has no secret yet), only TLS. - The backend consumes the code atomically, generates a 32-byte shared secret, stores it
encrypted (AES-GCM), and returns it base64-encoded along with the
tenant_id. - The producer stores the secret encrypted and uses it to sign every subsequent request.
{
"tenant_id": "03d7d7e6-0f98-44be-8376-95313602d253",
"shared_secret_b64": "v3RY+sec...base64-32-bytes...==",
"ingest_url": "https://api.monoverse.tech",
"protocol_version": 1,
"account": { "organization_name": "Acme Electronics LLC", "plan_tier": "growth" }
}
Decode shared_secret_b64 to raw bytes — those bytes are the HMAC key. Store ingest_url and use
it as the base for every endpoint; don't hardcode a host.
protocol_version foot-gunThe /pair response body reports "protocol_version": 1. That is a server-side field value — it
is not the version you send. The wire header X-VoiceBot-Protocol-Version must always be the
integer 2, on /pair and on every signed request. Sending 1 on the wire is rejected with
426 Upgrade Required.
2. The string-to-sign
The signature is computed over five fields joined by newlines. This is the exact byte layout the backend reconstructs and compares:
canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + SHA256_HEX(BODY)
signature = HEX( HMAC_SHA256(shared_secret_raw, canonical) )
| Field | Value |
|---|---|
METHOD | Uppercase HTTP verb — POST, PUT, GET. |
PATH | request.url.path only — no host, no query string. |
TIMESTAMP | The exact string sent in X-VoiceBot-Timestamp (Unix epoch seconds, UTC). Not reformatted. |
NONCE | The exact string sent in X-VoiceBot-Nonce (base64url, 16 bytes). Not decoded. |
SHA256_HEX(BODY) | Lowercase hex SHA-256 of the raw body bytes you transmit. Empty body → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. |
POST
/api/v1/ingest/init
1747396800
aGVsbG8td29ybGQtYTFiMmMz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The body hash must cover the exact bytes on the wire. If you re-serialize the JSON after computing the
hash (key reordering, whitespace), the hash no longer matches and you get 401 bad_signature. A
lowercased method, an extra trailing newline, or a decoded nonce break it the same way.
3. Required headers
| Header | Value | Notes |
|---|---|---|
X-VoiceBot-Tenant-Id | UUID | From /pair. The tenant whose secret signs the request. |
X-VoiceBot-Timestamp | integer epoch seconds (UTC) | Replay window ±300s. |
X-VoiceBot-Nonce | base64url of 16 random bytes | Single-use for 600s per tenant. Fresh each request. |
X-VoiceBot-Signature | 64-char lowercase hex | The HMAC above. |
X-VoiceBot-Protocol-Version | 2 | Integer major. Required everywhere, including /pair. |
X-VoiceBot-Plugin-Version | semver | Producer version (telemetry). |
X-VoiceBot-Site-Url | URL | Sending site origin; heartbeat upserts the site row from it. |
Content-Type | application/json / application/x-ndjson | Per endpoint; upload may be gzipped. |
Idempotency-Key | UUID | Optional on /events; required on full-snapshot /finalize. Cached 24h. |
4. Replay protection
Two independent checks make a captured request un-replayable:
- Timestamp window.
abs(server_now − timestamp) ≤ 300s, else401 stale_timestamp. Keep your clock synced. - Nonce uniqueness. Each nonce is accepted once per 600s per tenant (atomic set-if-absent),
else
401 nonce_replay. Generate 16 fresh random bytes per request.
Because the nonce is recorded before the handler runs, a transient 429/5xx has already burned
that nonce — replaying the identical signed request is rejected as nonce_replay. Do not blindly
retry signed requests. Build a fresh request (new timestamp + nonce + signature), and use
Idempotency-Key on /events and /finalize for exactly-once. See Errors.
Next
- Endpoints — what to sign and call, in order.