Authentication (HMAC)
Every backend → relay call is signed with the same HMAC scheme (sometimes called “HMAC#1”), identical for the Auth Relay and the Chat Relay. Learn it here; the product pages just link back.
Which scheme for which call?
Section titled “Which scheme for which call?”| Surface | Authentication |
|---|---|
*/relay/* endpoints (backend → relay) | HMAC signature (this page) |
Admin endpoints (/provision/*, /rotate/*, /fetch/*…) | X-Admin-Key header |
Auth Relay device endpoints (/device/*) | Authorization: Bearer <token> |
Chat Relay console (/console/*) | Authorization: Bearer <console_token> |
| Callbacks (relay → your backend) | Same HMAC signature, which you verify — see Webhooks & callbacks |
The three headers
Section titled “The three headers”Every signed request carries:
| Header | Value |
|---|---|
X-Bloonio-Tenant-Id | Your tenant_id. |
X-Bloonio-Timestamp | The current Unix timestamp in milliseconds. |
X-Bloonio-Signature | hex( hmac_sha256( tenant_secret, "{timestamp_ms}.{sha256_hex(body)}" ) ) |
The signing algorithm
Section titled “The signing algorithm”- Serialize the request body exactly as it will be sent (same bytes).
For a body-less request (
GET), use an empty string. - Compute
body_hash = sha256_hex(body). - Read
timestamp_ms, the current Unix time in milliseconds. - Build the signing string:
"{timestamp_ms}.{body_hash}". - Compute
signature = hmac_sha256(tenant_secret, signing_string)in hex. - Send the three headers above with the request.
Example implementation
Section titled “Example implementation”import hashlibimport hmacimport jsonimport time
import httpx
TENANT_ID = "tnt_..." # from provisioningTENANT_SECRET = "sk_..." # secret, server-side onlyBASE_URL = "$BASE_URL" # e.g. https://auth-relay.bloonio.com
def signed_headers(tenant_id: str, tenant_secret: str, body: bytes) -> dict: ts_ms = str(int(time.time() * 1000)) body_hash = hashlib.sha256(body).hexdigest() signing_string = f"{ts_ms}.{body_hash}" signature = hmac.new( tenant_secret.encode(), signing_string.encode(), hashlib.sha256, ).hexdigest() return { "X-Bloonio-Tenant-Id": tenant_id, "X-Bloonio-Timestamp": ts_ms, "X-Bloonio-Signature": signature, "Content-Type": "application/json", }
payload = {"example": "value"}body = json.dumps(payload).encode() # these exact bytes are signed AND sent
resp = httpx.post( f"{BASE_URL}/api/v1/relay/...", headers=signed_headers(TENANT_ID, TENANT_SECRET, body), content=body, # send `content`, not `json=`, to freeze the bytes timeout=10,)resp.raise_for_status()import crypto from "node:crypto";
const TENANT_ID = "tnt_...";const TENANT_SECRET = "sk_...";const BASE_URL = "$BASE_URL"; // e.g. https://chat-relay.bloonio.com
function signedHeaders(tenantId, tenantSecret, body) { const tsMs = String(Date.now()); const bodyHash = crypto.createHash("sha256").update(body).digest("hex"); const signingString = `${tsMs}.${bodyHash}`; const signature = crypto .createHmac("sha256", tenantSecret) .update(signingString) .digest("hex"); return { "X-Bloonio-Tenant-Id": tenantId, "X-Bloonio-Timestamp": tsMs, "X-Bloonio-Signature": signature, "Content-Type": "application/json", };}
const body = JSON.stringify({ example: "value" }); // signed AND sent unchanged
const res = await fetch(`${BASE_URL}/api/v1/relay/...`, { method: "POST", headers: signedHeaders(TENANT_ID, TENANT_SECRET, body), body,});if (!res.ok) throw new Error(`Relay: ${res.status}`);What the relay verifies
Section titled “What the relay verifies”On receipt, the relay:
- requires all three headers to be present;
- checks that
X-Bloonio-Timestampis within a roughly 30-second window (temporal replay protection); - checks that the
tenant_idexists and the tenant is active (else403); - rejects any signature it has already seen (replay protection, over a short configurable window);
- recomputes the signature and compares it in constant time.
Common errors
Section titled “Common errors”| Symptom | Likely cause |
|---|---|
401 invalid signature | The signed body differs from the sent body, or wrong tenant_secret. |
401 timestamp out of window | Clock skew, or a request replayed too late. Sync via NTP. |
403 inactive tenant | Tenant suspended — see Tenant lifecycle. |
401 replay detected | The same signature was already used; generate a fresh timestamp. |
Next steps
Section titled “Next steps”- Tenant lifecycle — where
tenant_idandtenant_secretcome from. - Webhooks & callbacks — verify the same signature in the other direction.