Every request to /api/v1/* is authenticated against a tenant’s signing_secret. Two modes are supported: HMAC signatures (recommended) and bearer tokens (simpler, equivalent strength when transport is TLS).
| Header | Required | Description |
|---|
x-chert-tenant | Conditional | Your tenant_slug from registration. Required for tenants registered with multi_tenant: true (strict routing) and required whenever x-chert-signature is sent. Optional for default single-tenant accounts using bearer auth — the bearer token alone identifies the workspace. |
x-chert-signature | One of | v1,<unix_seconds>,<hex> — preferred. Always send x-chert-tenant alongside it. |
authorization | One of | Bearer <signing_secret> — single-tenant default. The bearer token resolves the tenant when x-chert-tenant is omitted. |
content-type | On POST | application/json |
If both x-chert-signature and authorization are present, the signature is checked first.
x-chert-signature: v1,<unix_seconds>,<hmac_hex>
| Field | Description |
|---|
v1 | Format version. |
unix_seconds | Request timestamp. Must be within 5 minutes of server time. |
hmac_hex | Lowercase hex of HMAC-SHA256(signing_secret, "<unix_seconds>.<raw_body>"). |
What gets signed
The HMAC input is the literal string <unix_seconds> + . + <raw_body>.
- For
POST requests, raw_body is the exact bytes you transmit. Do not re-serialize, do not strip whitespace, do not reorder keys after signing.
- For
GET requests, raw_body is the empty string. The HMAC input ends in a trailing dot.
The server reads the request body byte-for-byte before parsing, so any reformatting on the client invalidates the signature.
Replay window
Timestamps more than 5 minutes off in either direction are rejected with 401 and the distinct code 2013 AUTH_TIMESTAMP_SKEW — separate from a genuinely bad signature (2004). Sync your clock and resign on retry — never reuse a signature.
Reference implementations
TS=$(date +%s)
BODY='{"phone":"+14155551234","body":"Hi"}'
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl https://console.trychert.com/api/v1/send \
-H "x-chert-tenant: $SLUG" \
-H "x-chert-signature: v1,$TS,$SIG" \
-H "content-type: application/json" \
-d "$BODY"
Bearer token fallback
For scripts, prototypes, and trusted server-to-server calls, send the secret directly:
authorization: Bearer <signing_secret>
The server compares with a constant-time check. Bearer is fine when the request never leaves a controlled server environment — TLS 1.2+ is the only protection between the secret and the network. Use HMAC wherever the signing process could be exposed (e.g. edge functions with third-party dependencies, client-side code, shared build pipelines).
Errors
Auth and signature failures return distinct numeric codes — they are no longer collapsed into one opaque “unauthorized”. Branch on the numeric code:
| Status | Code | Name | Cause | Action |
|---|
| 401 | 2012 | AUTH_MISSING | No credentials supplied at all — both x-chert-signature and authorization are absent. | Add a signature or bearer header. |
| 401 | 2004 | AUTH_INVALID | Credentials supplied but rejected — malformed or mismatched signature, or bad bearer token. | Re-sign with the current timestamp / check the token. |
| 401 | 2013 | AUTH_TIMESTAMP_SKEW | Signature timestamp outside the 5-minute replay window. | Sync your clock and re-sign with a fresh timestamp. |
| 403 | 2007 | EMAIL_NOT_VERIFIED | Tenant email not yet confirmed. | Click the link in the verification email. |
| 404 | 2001 | TENANT_NOT_FOUND | tenant_slug not recognized or tenant suspended. | Verify x-chert-tenant matches your registration response. |
Error responses carry the standard envelope — { success, error: { status, code, message, retryable }, trace_id } — with retryable: false on every auth failure. The message is generic; the crypto-step detail (which header was missing, which verification step failed) is logged server-side under trace_id and is no longer leaked in the response. See Errors for the full table.
POST /api/v1/send is a legacy-shaped endpoint and still emits a flat { error, code: "auth_failed" } body for auth failures rather than the numeric envelope above. All other /api/v1/* routes use the distinct numeric codes.
Rotating the signing secret
The signing_secret is shown exactly once at registration. To rotate it, contact your Chert administrator. Re-registering the same email returns the existing tenant and does not regenerate the secret.
Webhook signatures
Inbound webhook events are signed independently with the webhook subscription secret (returned once when the subscription is created). Two signature header formats are emitted simultaneously:
| Header | Format | Notes |
|---|
x-chert-signature | v1,<ts>,<hex> | Legacy format. Supported for backward compatibility. |
X-Webhook-Signature | t=<ts>,v1=<hex> | New format. Recommended for new integrations. |
Both compute the same HMAC: HMAC-SHA256(subscription_secret, "<ts>.<raw_body>"). The raw body is the exact bytes received — do not parse and re-serialize before verifying. See Receiving replies for the consumer-side verification snippet that handles both formats.
Additional headers on every webhook delivery:
| Header | Description |
|---|
X-Webhook-Event | Event type, e.g. message.received |
X-Webhook-Event-Id | Stable dedup key |
X-Webhook-Timestamp | Unix seconds |
X-Webhook-Subscription-Id | Id of the subscription that triggered this delivery |
See also