Skip to main content
Version: next

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:

  1. The owner generates a 12-character pair code (VB-XXXX-XXXX, TTL ~1 hour, single-use) in the VoiceBot dashboard.
  2. The producer POSTs the code to /api/v1/ingest/pairno HMAC headers (it has no secret yet), only TLS.
  3. 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.
  4. The producer stores the secret encrypted and uses it to sign every subsequent request.
POST /api/v1/ingest/pair → 200
{
"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.

The protocol_version foot-gun

The /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) )
FieldValue
METHODUppercase HTTP verb — POST, PUT, GET.
PATHrequest.url.path only — no host, no query string.
TIMESTAMPThe exact string sent in X-VoiceBot-Timestamp (Unix epoch seconds, UTC). Not reformatted.
NONCEThe 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.
Canonical string — POST /api/v1/ingest/init, empty body
POST
/api/v1/ingest/init
1747396800
aGVsbG8td29ybGQtYTFiMmMz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Sign the bytes you send

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

HeaderValueNotes
X-VoiceBot-Tenant-IdUUIDFrom /pair. The tenant whose secret signs the request.
X-VoiceBot-Timestampinteger epoch seconds (UTC)Replay window ±300s.
X-VoiceBot-Noncebase64url of 16 random bytesSingle-use for 600s per tenant. Fresh each request.
X-VoiceBot-Signature64-char lowercase hexThe HMAC above.
X-VoiceBot-Protocol-Version2Integer major. Required everywhere, including /pair.
X-VoiceBot-Plugin-VersionsemverProducer version (telemetry).
X-VoiceBot-Site-UrlURLSending site origin; heartbeat upserts the site row from it.
Content-Typeapplication/json / application/x-ndjsonPer endpoint; upload may be gzipped.
Idempotency-KeyUUIDOptional 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, else 401 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.
The nonce is consumed even on failure

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.