ZexrDocs

Authentication

Learn how to authenticate with the Zexr API using API keys.

The Zexr API uses API keys for authentication. Each API key has specific permissions that control what operations it can perform.

API responses use Cache-Control: no-store by default to instruct browsers and intermediaries not to retain private data. Public uploaded assets keep their explicit caching policy.

API key format

API keys follow this format:

zexr_<43-character-random-string>

The zexr_ prefix identifies the key as a Zexr key. The remaining 43 characters are 32 random bytes encoded as base64url (no padding): 256 bits of entropy, so brute-forcing a valid key is computationally infeasible.

Keys are stored as a SHA-256 hash; the plaintext is shown exactly once on creation. To help you spot a key in the dashboard without exposing the secret, we store the first 8 characters (key_prefix, e.g. zexr_ab) and the last 4 characters (key_suffix, e.g. wxyz). Render them as zexr_ab…wxyz.

Three ways to get a key

  1. The dashboard. Settings > API keys, pick the scopes, copy the secret. This is the right path for a key a server will use.
  2. A device sign-in. A command-line tool runs the device flow: it opens a browser approval and receives a key named for the machine that asked. This is the right path for a key you will use yourself, and it is the only one that does not involve pasting a secret into a shell.
  3. The API. POST /v1/api-keys with a key that carries API_KEYS. The secret is in that response and nowhere else.

All three produce the same thing: a zexr_ key with a scope bitmask, listed under Settings > API keys, revocable there.

The CLI device flow

A command-line tool gets a key through a device-code handshake, so the terminal never handles your password and the browser never handles the key:

  1. The tool calls POST /v1/auth/cli/code with the scopes it wants and the machine's hostname. It gets back a device_code it keeps, a user_code it prints, and a verification_uri_complete it opens.
  2. You approve at app.zexr.app/cli, choosing which workspace the key belongs to. The approval is what mints the key, so it requires the MANAGE_API_KEYS organization permission and a recent sign-in, and the key is capped to the scopes your role in that workspace allows.
  3. The tool polls POST /v1/auth/cli/poll and receives the key exactly once, on the first poll after approval.

Codes expire after ten minutes, both halves are per-IP rate limited, and the device_code is stored hashed. Anything else can drive the same flow: it is two public endpoints and a browser.

Ending a key

DELETE /v1/api-keys/:id revokes any key in the workspace and needs the API_KEYS scope. DELETE /v1/api-keys/self revokes the key the call was made with and needs no scope at all, so a narrowly scoped credential can always end itself.

Revoking leaves the key listed, with the time and reason it ended, and its request history intact. DELETE /v1/api-keys/:id/permanent removes the row for good, along with its usage logs, and is how the Delete key button under a revoked key in Settings > API keys works. It refuses a key that could still authenticate with a 409: revoke it first, so what ended the credential is on the record. A key past its expires_at can be deleted directly, since it already authenticates nothing.

Using your API key

Include your API key in the Authorization header of every request:

curl -X GET "https://api.zexr.app/v1/api-keys" \
  -H "Authorization: Bearer zexr_abc123..." \
  -H "Content-Type: application/json"

For mutation retries, include an Idempotency-Key header with a unique value per logical operation. Zexr stores completed mutation responses for 24 hours per organization and key, then replays matching retries instead of performing the operation again.

Verifying a credential

To check that a credential is valid and see who it belongs to, call GET /v1/me. It works with an API key, an OAuth access token, or a dashboard session, requires no specific permission, and returns the caller's identity:

curl -X GET "https://api.zexr.app/v1/me" \
  -H "Authorization: Bearer zexr_abc123..."
{
  "user_id": "0b1f...",
  "email": "jane@acme.com",
  "name": "Jane Doe",
  "organization_id": "9a2c...",
  "organization_name": "Acme Inc",
  "auth_type": "api_key",
  "scopes": ["read_contacts", "write_contacts"]
}

auth_type is api_key, oauth, or jwt, and scopes lists the granted API scopes for key and OAuth callers (empty for dashboard sessions, which use organization roles instead). This is the right endpoint for an integration to validate a connection and render a label. The separate GET /v1/auth/me is session-only and is not reachable with an API key or OAuth token.

For a restricted session, /auth/me returns only conversation categories applied to messages in its granted mailboxes. Campaign grants do not grant inbox access. Workspace-wide category registries remain available to unrestricted members. See member access scope for the summary, notification and workspace-creation boundaries that also apply to existing members and credentials.

OAuth access tokens

API keys authenticate your own scripts. If you are building an app that other people connect their Zexr workspace to, use OAuth instead: the user grants your app scoped access and you receive a bearer access token (prefix zxat_). It goes in the same Authorization: Bearer header and is checked against the same permissions, so every endpoint below behaves identically whether you present an API key or an OAuth token, with two differences: an OAuth token also acts within the approving member's current role, and it can never manage API keys or OAuth apps. See OAuth for the full flow.

Key security best practices

Keep Your Keys Secret

Never expose API keys in client-side code, public repositories, or logs. Treat them like passwords.

Do

  • Store API keys in environment variables or secure secret managers
  • Use different keys for development and production
  • Restrict keys to only the permissions they need
  • Set expiration dates for keys when possible
  • Use IP allowlists to restrict key usage

Don't

  • Commit API keys to version control
  • Share API keys via email or chat
  • Use production keys in development
  • Give keys more permissions than necessary

Permissions

Each API key has a permissions bitmask that controls its capabilities. See the Permissions Reference for a complete list.

A key acts for the member who created it. Every route checks the key's bits and that member's current role in the workspace, so a key never does more than its creator could do in the dashboard today: if the creator's role loses a permission, the key loses it too. When the creator leaves the workspace, the key stops working and answers 401 with code api_key_holder_left; create a new key from a current member.

Permission categories

CategoryDescription
ReadView resources (emails, campaigns, contacts, etc.)
WriteCreate and modify resources
BulkPerform bulk operations
SpecialAdvanced features (realtime, webhooks, API key management)

Example: read-only key

A read-only API key might have these permissions:

{
  "permissions": 31
}

This combines:

  • READ_EMAILS (1)
  • READ_CAMPAIGNS (2)
  • READ_CONTACTS (4)
  • READ_UNIBOX (8)
  • READ_ANALYTICS (16)

Total: 1 + 2 + 4 + 8 + 16 = 31

IP restrictions

You can restrict API keys to specific IPs or CIDR ranges. Entries can be bare IPs (v4 or v6) or CIDR blocks; an empty list means "any IP".

{
  "name": "Production Server",
  "permissions": 688159,
  "allowed_ips": [
    "203.0.113.10",
    "203.0.113.11",
    "10.0.0.0/8",
    "2001:db8::/32"
  ]
}

Requests from outside every listed range are rejected with 403 Forbidden. There's a soft cap of 64 entries per key.

Email account restrictions

Limit API keys to specific email accounts:

{
  "name": "Marketing Team",
  "permissions": 127,
  "allowed_email_accounts": [
    "550e8400-e29b-41d4-a716-446655440000",
    "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
  ]
}

A restricted key only reaches those mailboxes and their mail, and naming any other mailbox answers 403:

  • Unibox reads. GET /unibox lists only their conversations, GET /unibox/thread returns only their messages, GET /unibox/count and GET /unibox/overview count only them, and GET /unibox/:id, thread labels and agent drafts are refused or left out for mail in any other mailbox.
  • Unibox writes. Reply, compose (an automatic pick chooses among the listed mailboxes), drafts, agent-draft approval and discard, PATCH /unibox/seen, PATCH /unibox/folder and snoozes refuse a message or conversation with mail in another mailbox. Marking a whole folder read (folder on PATCH /unibox/seen) is refused, since it reaches every mailbox.
  • Campaigns and analytics. A sender pool (senders on POST /campaigns, PUT /campaigns/:id/senders) may only name listed mailboxes, GET /analytics/accounts lists only them, and GET /analytics/accounts/:id refuses any other. The key's campaigns use an explicit sender list (sender_strategy explicit): a pool resolved from mailbox tags, setting email_tag_ids or email_tags, or starting a campaign whose pool is not an explicit list of the key's mailboxes answers 403 with code api_key_mailbox_limited.
  • Advisor. Applying or undoing a fix is refused when the fix changes another mailbox.
  • AI tools. /ai/tools and /mcp act across the whole workspace, so a restricted key is refused there with code api_key_mailbox_limited. Use the REST endpoints, which apply the limit.

Key expiration

Set an expiration date for temporary access:

{
  "name": "Contractor Access",
  "permissions": 31,
  "expires_at": "2027-12-31T23:59:59Z"
}

After expiration, the key returns 401 Unauthorized.

Using a credential with MCP

An MCP client (Claude Code, Claude Desktop, Cursor) authenticates to Zexr with either an OAuth sign-in (the one-command path, no key to paste) or a static API key. For a key, send it as a bearer token to https://api.zexr.app/v1/mcp; the client then sees exactly the tools the credential's scopes allow. See the MCP server page for both paths.

Error responses

401 Unauthorized

Returned when:

  • API key is missing
  • API key is invalid
  • API key has expired
  • API key has been revoked
{
  "error": "Unauthorized",
  "message": "Token not found."
}

403 Forbidden

Returned when:

  • API key lacks required permissions
  • Request IP is not in allowlist
  • Email account is not in allowlist
{
  "error": "Forbidden",
  "message": "You don't have access to this feature."
}

Platform admin device sessions

This backend flow is separate from workspace API-key authorization. Platform admin access uses a normal bearer session, never an organization API key. Use the operator CLI's remote login and the administrator website's /device consent page. The CLI never listens on an HTTP port or receives a browser redirect.

Endpoint contract

All bodies/responses are JSON; bodies are capped at 4 KiB. Successful responses are Cache-Control: no-store. Keep device and consent secrets in memory, not URLs, stdout, command arguments, diagnostics or browser localStorage.

EndpointRequestSuccessAuthentication
POST /v1/auth/cli/admin/code{"client_name":"My diagnostic laptop"}201, start schema belowPublic, IP limited
POST /v1/auth/cli/admin/poll{"device_secret":"<CLI memory secret>"}200, poll schema belowDevice secret in body, IP limited
POST /admin/auth/device/describe{"user_code":"ABCD-EFGH"}200, describe schema belowNormal session, live nonzero admin permissions, session MFA
POST /admin/auth/device/decide{"user_code":"ABCD-EFGH","consent_token":"<page memory secret>","decision":"approved"}200 {"status":"approved"}; denied is the other valid decision/statusSame admin gates, plus existing fresh-auth gate

Start response:

{
  "device_secret": "<43-character base64url secret, never display>",
  "user_code": "ABCD-EFGH",
  "instance_url": "https://backend.example.test",
  "verification_path": "/device",
  "expires_in": 300,
  "interval": 5
}

client_name uses existing plain workspace-display-name rules (required, max 64 characters). It is requester-supplied, not verified device identity. The CLI must compare instance_url to its configured server. It is the existing API_PUBLIC_URL, or the local listener fallback; remote deployments must configure that public URL correctly. Show only the user code and the configured admin origin's /device page, never an untrusted origin from a requester. Instance URLs with credentials, query strings, fragments or non-HTTP schemes disable the device service rather than echoing sensitive/ambiguous identity.

Describe response:

{
  "request": {
    "client_name": "My diagnostic laptop",
    "instance_url": "https://backend.example.test",
    "expires_at": "2026-10-10T06:05:00Z",
    "status": "pending",
    "consent_token": "<43-character page-memory secret>"
  },
  "requested_access": "Your live platform administrator permissions through a separate revocable session",
  "admin_permissions": 16
}

Show server, client name, expiry and full requested access before explicit consent. Warn against approving an unsolicited code. admin_permissions is the existing numeric platform bitmask, not workspace API scopes. Opening a link or describing a code never authorizes it. Sign in normally first: OAuth, passkeys and password+MFA retain all existing challenges, Turnstile and rate limits. A consent token is hashed and bound to the describing browser session and request. A different session cannot spend it. If decide returns reauth_required, finish existing POST /v1/auth/reauth, then explicitly decide again; never auto-approve when login/reauth returns.

Poll no faster than every five seconds. Pending/denied responses contain status: "pending" or "denied", with neither token nor user_id. An approving poll returns:

{
  "status": "approved",
  "user_id": "<approving admin UUID>",
  "token": {
    "access_token": "<normal purpose-bound access token>",
    "refresh_token": "<normal purpose-bound refresh token>",
    "access_token_expires_at": "2026-10-10T18:01:00Z",
    "refresh_token_expires_at": "2027-04-08T06:01:00Z"
  }
}

Security and lifecycle

  • Five-minute Redis grants contain only the hashed 256-bit device secret and approval metadata; the short user code has 40 bits of randomness and is only a browser lookup. State is namespaced to instance identity/signing secret.
  • Redis decisions and poll claims are atomic and one-use. Approval/denial and successful issuance use the existing admin audit log without secret material. Issuance consumes the grant before creating the session. A lost response or issuance error cannot replay it; start again and inspect/revoke any session whose response was lost. Redis loss cancels handshakes, not issued sessions.
  • Approval requires session MFA and fresh auth. Issuance locks/rechecks the source session and user in Postgres: active/unexpired, not login-banned, live admin access, original proof within five minutes. Later browser reauth cannot rejuvenate an already-approved old proof.
  • The separate CLI session gets new ID/nonces but preserves source auth_provider, mfa_verified and the original approval-time reauth_at. New session creation time is not fresh MFA. The existing model has no separate factor-time column to invent. It starts without a selected workspace; any workspace use requires existing selection and permissions.
  • Access/refresh lifetimes remain 12 hours/180 days, with existing purpose checks, rotation, reuse detection, revocation and MFA lifecycle. Refresh never stamps reauth. Sensitive actions still use existing fresh-auth gates. Revoke the CLI through normal sessions management. Revoking the browser after issuance does not revoke the independent CLI session; revoke-all and security changes keep existing behavior. Store tokens only in a protected CLI store.
  • Additional fail-closed budgets: starts 20/IP/10 minutes; polls 360/IP/10 minutes; browser lookups/decisions 12/user/minute; poll interval five seconds. The existing CLI IP limiter also applies (default 500/15 minutes). Redis failure never bypasses these budgets or allows anonymous/single-factor admin.

Errors use the existing {error,message,code,request_id} envelope:

StatusCodeClient action
400bad_request, invalid_nameCorrect JSON/name/decision
401unauthorizedSign in normally or restart if source invalid at issuance
403admin_mfa_required, forbiddenRequire MFA/live platform admin access
403reauth_requiredReauthenticate browser, then explicitly decide
404admin_device_expiredInvalid/unknown/expired/consumed requests are indistinguishable; restart
409admin_device_resolvedAlready decided or mismatched/replayed consent; do not auto-retry approval
429admin_device_slow_down, rate_limit_exceededWait five seconds or the attempt window
503service_unavailableDependency unavailable; no fail-open issuance
500internal_errorStart again; approving poll cannot be replayed

Next steps

On this page