Authentication
Every request carries a bearer credential in the Authorization header. There is no second scheme and no alternative header — this page says exactly which credential to use where, and what happens when the wrong one arrives.
Bearer, and only bearer
Authentication is a single header. The server reads Authorization, matches the scheme case-insensitively, and takes the rest as the credential.
Authorization: Bearer wa_live_<secret>There is no x-api-key header
The string x-api-key appears in this platform's backend in exactly one place — the list of header names redacted from logs. No code path reads it as a credential. A request that sends only that header is a 401, and the 401 will not tell you why.
Two kinds of credential, one header
The same header carries either a workspace API key or a user access token. Which one you want depends on whether a machine or a person is acting.
| Credential | Shape | Used for |
|---|---|---|
| API key | wa_test_… / wa_live_… | Server-to-server integrations. Names its own workspace and carries a fixed set of scopes. |
| User access token | eyJhbGciOi… | The console, and anything acting on behalf of a signed-in person. Short-lived, obtained from the login endpoint. |
The server tells them apart by the credential's prefix, so you never declare which kind you are sending. A few endpoints accept only one of them: creating an API key and the QR pairing routes require a user token, because a machine credential has no person and no browser behind it.
Test and live keys
A key is created in one mode and stays in it. The mode is part of the credential, visible in its prefix, and it changes what the key is allowed to do.
| Mode | Prefix | Description |
|---|---|---|
| test | wa_test_ | Sandbox work. Required for every sandbox operation, including sending through a sandbox channel. |
| live | wa_live_ | Production traffic. Refused by every sandbox operation. |
A live key cannot touch the sandbox
Allocating, verifying, revoking a sandbox session and sending through a sandbox channel all refuse a live key with SANDBOX_TEST_KEY_REQUIRED. This is deliberate: it makes it impossible to send test traffic on a production credential by accident.
The X-Workspace-Id header
Every workspace-scoped endpoint needs to know which workspace it is acting in. Where that comes from depends on the credential.
- With an
API key: the workspace comes from the key, and the header is optional. - With a
user access token: the header is required, because a person can belong to several workspaces. Omitting it is a 400 withWORKSPACE_CONTEXT_REQUIRED. - Sending a header that disagrees with an API key's own workspace is a 403, not a silent override.
# An API key already names its workspace, so the header is optional...
curl https://whats.azzamkh.sa/api/v1/channels \
-H "Authorization: Bearer $WA_API_KEY"
# ...but a USER access token belongs to a person, who may be in several.
curl https://whats.azzamkh.sa/api/v1/channels \
-H "Authorization: Bearer <access token>" \
-H "X-Workspace-Id: ws_..."
# Omitting it with a user token:
# 400 { "error": { "code": "WORKSPACE_CONTEXT_REQUIRED", ... } }Scopes
An API key carries a fixed set of scopes chosen at creation. A request outside them is a 403 with the required permissions in details.
| Scope | Description |
|---|---|
| workspace.read | docs.pages.authentication.scopes.list.workspace.read |
| channels.read | docs.pages.authentication.scopes.list.channels.read |
| channels.manage | docs.pages.authentication.scopes.list.channels.manage |
| messages.read | docs.pages.authentication.scopes.list.messages.read |
| messages.send | docs.pages.authentication.scopes.list.messages.send |
| contacts.read | docs.pages.authentication.scopes.list.contacts.read |
| contacts.manage | docs.pages.authentication.scopes.list.contacts.manage |
| templates.manage | docs.pages.authentication.scopes.list.templates.manage |
| campaigns.read | docs.pages.authentication.scopes.list.campaigns.read |
| campaigns.manage | docs.pages.authentication.scopes.list.campaigns.manage |
| automations.manage | docs.pages.authentication.scopes.list.automations.manage |
| webhooks.manage | docs.pages.authentication.scopes.list.webhooks.manage |
The default set has one hole in it
Omitting scopes, or sending an empty array, grants every scope except webhooks.manage. A key that needs to manage webhook endpoints must ask for that scope explicitly.
Creating a key
POST/v1/api-keysKeys are created by a signed-in user, never by another key: a compromised key cannot mint itself a wider one.
# A key can only be minted by a signed-in user, never by another key.
curl -X POST https://whats.azzamkh.sa/api/v1/api-keys \
-H "Authorization: Bearer <access token>" \
-H "X-Workspace-Id: ws_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Order service (test)",
"mode": "test",
"scopes": ["messages.send", "messages.read"]
}'
# 201 Created — "secret" appears in THIS response and nowhere else.
# {
# "id": "key_...",
# "name": "Order service (test)",
# "mode": "test",
# "prefix": "wa_test",
# "last4": "9f3a",
# "scopes": ["messages.send", "messages.read"],
# "expires_at": null,
# "secret": "wa_test_..."
# }The secret is shown once
The full credential appears in the creation response and nowhere else. Only a hash is stored, so it cannot be recovered — if it is lost, revoke the key and create another.
Rotating and revoking
There is no rotation endpoint. Rotation is create-then-revoke, which is the safer shape anyway: the old key keeps working while the new one is deployed.
DELETE/v1/api-keys/{keyId}curl -X DELETE https://whats.azzamkh.sa/api/v1/api-keys/key_... \
-H "Authorization: Bearer <access token>" \
-H "X-Workspace-Id: ws_..."
# 200 OK — repeating this returns the ORIGINAL revoked_at, not an error.
# { "id": "key_...", "revoked_at": "2026-08-13T10:00:00.000Z" }In order:
- Create a new key with the same scopes and mode.
- Deploy it, and confirm traffic is flowing on it.
- Revoke the old key. Revocation takes effect immediately, and repeating it is safe: it returns the original revocation time rather than an error.
Authentication failures
Every failure follows the standard envelope described in errors and limits.
| HTTP | Code | When it happens |
|---|---|---|
| 401 | UNAUTHORIZED | No credential, an unknown one, a revoked one, an expired one, or a key whose workspace is not active. These are deliberately indistinguishable. |
| 403 | FORBIDDEN | A valid credential that is not allowed here — a missing scope, or an API key on a route that requires a user session. |
| 400 | WORKSPACE_CONTEXT_REQUIRED | A user access token with no workspace named in the path or the header. |
| 403 | ACCOUNT_SUSPENDED | The user account is suspended. |
| 403 | WORKSPACE_SUSPENDED | The workspace is suspended. |
Handling keys
- Read keys from the environment. Never commit one, and never put one in client-side code.
- The API is a server-to-server surface. A key in a browser is a key you have published.
- Ask for the scopes you use and no more. A send-only integration does not need
contacts.manage. - Build against a
testkey. Switching to live should be a configuration change, not a code change.