Skip to content

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.

SurfaceAuthentication
*/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

Every signed request carries:

HeaderValue
X-Bloonio-Tenant-IdYour tenant_id.
X-Bloonio-TimestampThe current Unix timestamp in milliseconds.
X-Bloonio-Signaturehex( hmac_sha256( tenant_secret, "{timestamp_ms}.{sha256_hex(body)}" ) )
  1. Serialize the request body exactly as it will be sent (same bytes). For a body-less request (GET), use an empty string.
  2. Compute body_hash = sha256_hex(body).
  3. Read timestamp_ms, the current Unix time in milliseconds.
  4. Build the signing string: "{timestamp_ms}.{body_hash}".
  5. Compute signature = hmac_sha256(tenant_secret, signing_string) in hex.
  6. Send the three headers above with the request.
import hashlib
import hmac
import json
import time
import httpx
TENANT_ID = "tnt_..." # from provisioning
TENANT_SECRET = "sk_..." # secret, server-side only
BASE_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()

On receipt, the relay:

  1. requires all three headers to be present;
  2. checks that X-Bloonio-Timestamp is within a roughly 30-second window (temporal replay protection);
  3. checks that the tenant_id exists and the tenant is active (else 403);
  4. rejects any signature it has already seen (replay protection, over a short configurable window);
  5. recomputes the signature and compares it in constant time.
SymptomLikely cause
401 invalid signatureThe signed body differs from the sent body, or wrong tenant_secret.
401 timestamp out of windowClock skew, or a request replayed too late. Sync via NTP.
403 inactive tenantTenant suspended — see Tenant lifecycle.
401 replay detectedThe same signature was already used; generate a fresh timestamp.