Error codes
Reference for all API error codes and their meanings.
The Zexr API uses standard HTTP status codes and returns structured error responses in JSON format.
Error response format
All errors follow this structure:
{
"error": "Error Type",
"message": "Human-readable description of what went wrong.",
"code": "machine_readable_code",
"request_id": "req_or_uuid_for_support"
}error and message are for people. Client logic should use code, HTTP status, and endpoint-specific fields such as retry_after. Include request_id when contacting support.
HTTP status codes
Send-admission diagnostics
These codes appear in campaign activity and task failure metadata, not as HTTP response codes:
| Code | Meaning |
|---|---|
CAMPAIGN_DAILY_LIMIT_REACHED | This campaign has reached its configured UTC-day maximum for this sending mailbox. Other mailboxes have separate campaign allowances. |
MAILBOX_SENDING_PLAN_DENIED | The mailbox's enabled local-day sending plan is unavailable, outside working hours or on a break, or its daily/hourly quota is exhausted. The scheduler retries when capacity opens. |
MANUAL_SEND_LIMIT_REACHED | A unibox send (reply, forward or compose, including POST /unibox/* and POST /emails/:id/send) would exceed the mailbox's fair-use allowance: recipients on one email, in an hour, in 24 hours, in new conversations over 24 hours, or, across all the workspace's mailboxes, the 24-hour allowance its plan gives. The failure message names which. Unibox sends never count toward campaign or warmup limits. See sending controls. |
RECIPIENT_SUPPRESSED | A recipient is on the workspace suppression list, directly or through its domain. The failure message names the address. A unibox forward is never refused for this. |
SEND_ADMISSION_DENIED | Another current send-authority, recipient or mailbox capacity check denied admission. |
Admission denial before dispatch releases the send reservation without spending a failed-send attempt. A refused unibox send ends with status failed, is not retried, and is reported to the mailbox owner. See campaigns and sending behavior for the separate configurable ceilings and daily targets.
Client errors (4xx)
| Code | Error | Description |
|---|---|---|
| 400 | Bad Request | Invalid request syntax or parameters, or a quota that would be passed (storage_limit_reached) |
| 401 | Unauthorized | Missing or invalid authentication |
| 402 | Payment Required | Out of credits (insufficient_credits), or no free placement tests left this month (placement_quota_exceeded, placement_not_entitled) |
| 403 | Forbidden | Authenticated but lacks permission, or the workspace's mailbox allowance is full (mailbox_allowance_reached) |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists |
| 422 | Unprocessable | Validation failed |
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (rate_limit_exceeded, usage_cap_exceeded), or too many placement tests or batches running at once (placement_too_many_running, placement_too_many_batches) |
Server errors (5xx)
| Code | Error | Description |
|---|---|---|
| 500 | Internal Server Error | Unexpected server error |
| 501 | Not Implemented | Feature not available |
| 503 | Service Unavailable | Service temporarily down |
Error details
400 Bad Request
Returned when the request cannot be processed due to invalid syntax.
Common causes:
- Invalid JSON in request body
- Missing required fields
- Invalid field types
- Values outside allowed ranges
Example:
{
"error": "Bad Request",
"message": "The request body is missing or has invalid fields: \"to\" is required; \"subject\" is required.",
"code": "bad_request",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}When the body cannot be read, the message says why:
| The body | message |
|---|---|
| is empty | The request body is empty. ... |
| is not valid JSON | The request body is not valid JSON: <reason> (at byte N). |
| is the wrong JSON type at the top level | The request body must be a JSON array, not a JSON object. |
| has a field of the wrong JSON type | Field "to" must be a JSON array, not a JSON string. |
| has a time that is not RFC 3339 | The request body has a time that is not RFC 3339 (such as 2026-10-01T09:00:00Z): "tomorrow". |
| has a text field containing a NUL character | Field "<name>" contains a NUL character, which is not supported. |
| is missing required fields or has values out of range | The request body is missing or has invalid fields: "to" is required; ... (up to five fields, then a count of the rest) |
Fields are named by their JSON key, with nested fields as a dotted path (inner.name). The code is bad_request in every case, so branch on code and show or log message.
Versioned API routes check every query parameter name and value before the endpoint runs, including endpoints that read filters directly. The legacy /invitations/lookup alias applies the same validation. NUL characters, invalid UTF-8 and malformed query encoding return 400 rather than sending unsupported text to the database. For example, ?q=%00 returns Query parameter "q[0]" contains a NUL character, which is not supported. Valid Unicode text and properly encoded punctuation remain unchanged.
Resource IDs on email, campaign and contact routes must be UUIDs. Email tags and campaign email_tags and folders arrays also require UUID values. Malformed values return 400 with code: "bad_request" and a request_id before a database write. Valid UUIDs are normalized and repeated relation IDs are deduplicated; workspace ownership checks still apply.
How to fix:
- Check that your JSON is valid
- Verify all required fields are present
- Ensure field values match expected types
- Compare the body's shape with the endpoint's reference page: some endpoints take an array, most take an object
Specific 400 codes:
code | Meaning |
|---|---|
invalid_lead_status | POST /contacts/search or POST /contacts/export was given a lead_status that is not one of the documented values |
invalid_sort_by | POST /contacts/search, POST /contacts/export or a bulk action's all selection was given a sort_by of the form custom:<key> whose key could never be a custom-field name (letters, numbers, underscores, spaces or dashes) |
invalid_column, duplicate_column, too_many_columns, invalid_sort | PUT /me/views/:view was given a column id that view cannot render, the same column twice, more than 64 columns, or a sort that names neither a sortable contact column nor a well-formed custom:<key> (unibox_rail has no columns or sort at all) |
invalid_layout | PUT /me/views/:view was given a layout on a view that has none, or a unibox_rail layout with an unknown field, an empty or over-long key (200 bytes), more than 500 keys in a list or 32 sections, or a favorite name over 40 characters |
invalid_mail_host | POST /contacts/search, POST /contacts/export or a bulk action's all selection was given a mail_hosts entry that is not a documented provider value |
invalid_engagement | POST /contacts/search or POST /contacts/export was given an engagement that is not one of the documented values |
lead_filter_requires_campaign | lead_status or engagement was set without exactly one campaign_ids entry; both filters describe a contact inside one campaign |
unknown_verification_status | A contact's verification_status is not a value any known verification service writes |
unknown_verification_provider | A contact's verification_provider names a vocabulary the platform cannot read |
invalid_action | POST /contacts/verification was given an action other than verify, mark_deliverable or mark_undeliverable |
no_contacts | POST /contacts/verification selected no contacts: neither contacts nor a campaign_id with refused leads |
list_bounce_risk | POST /campaigns/:id/start refused the launch on the list's projected bounce rate. Clean or verify the list, or repeat the request with acknowledge_list_risk: true |
leads_undeliverable | POST /campaigns/:id/start found nothing to send because address verification refused every remaining lead; the campaign is parked at paused_undeliverable until they are re-verified or marked deliverable |
empty_step_body | POST /campaigns/:id/start found an email step with nothing in either body, so it would send a blank message to every lead it reached. Write the step's body and start again |
no_leads | POST /campaigns/:id/start on a campaign that has never had a lead, with continuous off. Add contacts, or set continuous so it starts empty and waits for them. A campaign whose leads have all finished is a different case: it starts and waits |
no_remaining_leads | A platform-initiated restart of a campaign with nothing left to send and continuous off found nothing to do; the campaign is completed again. A start you request never answers this: it turns continuous on and waits |
too_many_tasks | PATCH /crm/tasks or DELETE /crm/tasks was given more than 1000 ids in one request, or more than 50,000 exclusions. Split it into batches |
too_many_contacts | A contact bulk action was given more than 10,000 contact ids in one request, or more than 250,000 exclusions. Split it into batches, or send a filter selection instead of ids |
selection_too_large | A "all": true bulk selection resolved to more than its limit: 250,000 contacts, or 50,000 CRM tasks. Narrow the filter and run it in parts; nothing was changed |
invalid_filter | A task filter carried an id that is not one: assigned_to, contact_id and deal_id name records, and are matched against id columns. Sent by POST /crm/tasks/search, POST /crm/tasks/summary, and the filters of a "all": true bulk selection |
invalid_setting | PATCH /outreach/settings (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a reply_intent.crm_task_intents entry that is not a reply intent, or an inbox_tagging.questions entry with no question text, a label that is missing, repeated or built in, or a choice question with fewer than two options, or an inbox_tagging.languages entry that is not a supported language code |
invalid_recipient_rules | PUT /outreach/recipient-rules carried a role_mode other than all or founder_only, a name that is not letters, digits, ., _ or - (at most 64 characters, starting and ending on a letter or digit), more than 100 names in a list, or a rule switched on with an empty list. The message names the field |
removal_note_not_editable | POST /unibox/agent-drafts/:id/approve sent a body that differs from an inbox agent removal note. The note goes to an address on the suppression list, so it is sent as written; omit body or send it unchanged |
invalid_inbox_agent_settings | PUT /ai/inbox-agent/settings carried a mode other than off, draft or auto, an allowed link that is not a bare domain name or more than 50 of them, a template over 4,000 characters or one that fails the reply checks (it must end with {{sender_name}} on its own line, use no other merge field than {{first_name}}, carry no em or en dash, no placeholder, no follow-up promise and no link off the allowlist), a limit out of range, a playbook skill that is not in the workspace, or auto without an enabled playbook. The message names the field |
invalid_slug | PATCH /organization/current was given a slug that is not 2 to 80 lowercase letters, numbers or dashes starting and ending with a letter or number |
invalid_sync_folder | PUT /emails/:id/sync was given a folder the sync always follows (INBOX, or a sent, drafts, spam, trash or archive folder by attribute or name), a name that is empty after trimming, longer than 255 characters or carrying a control character, more than 50 names, or a mailbox that is not IMAP. The message names the entry refused |
no_organization | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
lead_cc_limit | Set a lead's CC was given more than 2 contacts |
lead_cc_self | Set a lead's CC named the lead itself as a copy |
invalid_recipient | POST /emails/:id/send, POST /unibox/reply or POST /unibox/compose was given a to, cc or bcc entry that is not exactly one email address (ana@example.com or Ana <ana@example.com>), or no to address at all. Put each address in its own entry |
recipient_suppressed | POST /emails/:id/send, POST /unibox/reply or POST /unibox/compose named a to, cc or bcc address on the workspace suppression list, directly or through its domain. The message names the address and the entry. Nothing was queued. A forward (POST /unibox/reply with forward_message_id) is not checked against the list |
invalid_message_id | POST /emails/:id/send or POST /unibox/reply was given an in_reply_to entry that is not one Message-ID: printable characters with no spaces or line breaks, with or without the surrounding < and > |
Password refusals
code | Status | Meaning |
|---|---|---|
password_breached | 400 | The password appears in a public list of breached passwords and was refused. Choose one that does not |
sso_link_expired | 400 | The pending token from a link_required sign-in is unknown, expired (ten minutes), already used, or has had three wrong passwords. Start the provider sign-in again |
A password must be 8 to 128 characters. There is no composition rule, but it is checked against the 100,000 most commonly breached passwords published by the UK National Cyber Security Centre, case-insensitively.
{
"error": "Bad Request",
"message": "This password appears in a public list of breached passwords. Choose one that does not.",
"code": "password_breached",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}App Attest refusals
The native app proves itself with App Attest in place of the captcha on sign-in, registration and password reset.
code | Status | Meaning |
|---|---|---|
app_attest_failed | 400 | The proof did not verify: the challenge expired or was already used, the proof came from another app or from a development build the server does not accept, the assertion's counter did not advance, the key id was already registered, or the object was malformed. Fetch a fresh challenge and make a new proof; if it keeps failing, update the app |
app_attest_key_unknown | 400 | The key_id was never registered on this server. Register the key again with POST /auth/app-attest/keys, then retry the call |
app_attest_unavailable | 404 | POST /auth/app-attest/challenge or /keys on an instance with App Attest off. When GET /auth/config reports a captcha without app_attest, email and password sign-in is unavailable in the app; Sign in with Apple, Google and passkeys still work |
Each registered key may make 30 protected calls per hour. Past that the call answers 400 with code bad_request and the message Too many attempts, please try again later., the same answer as the other sign-in attempt budgets.
{
"error": "Bad Request",
"message": "We couldn’t verify this copy of the app. Update it from the App Store and try again.",
"code": "app_attest_failed",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Name refusals
code | Status | Meaning |
|---|---|---|
invalid_name | 400 | A first name, last name, workspace name, company name, OAuth app name or mailbox display name broke the naming rules. The message names the field and the rule |
Names are shown to other people, including in invitation and notification emails, so they carry plain text only. The same rules apply to the dashboard, the API, the setup page and zexrctl:
- a first or last name is at most 50 characters and contains a letter; a workspace or company name is at most 64 characters and contains a letter or a number; a mailbox display name keeps its own 2 to 100 character bound
- no links, web addresses or email addresses: nothing with
@,//,www.or a scheme such ashttps:, no hostname such asexample.com, and no IP address. Full-width and ideographic dots count as dots - no control characters, invisible formatting characters (zero-width and bidirectional overrides),
<,>,`or\, and no more than three stacked combining marks - leading and trailing spaces are trimmed and runs of spaces become one, and that is the form stored
- an OAuth app name cannot include Zexr, spaced out, accented or spelled with look-alike characters included. An app that already had such a name keeps it until the name is changed
{
"error": "Bad Request",
"message": "First name cannot contain a link, web address or email address.",
"code": "invalid_name",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}A name from Google, Apple or a single sign-on provider that breaks these rules is dropped rather than refusing the sign-in, and you are asked for it during onboarding.
Mailbox import refusals
The mailbox import routes (/emails/imports) answer these. A row that fails to connect is not an HTTP error: the import carries it, with its reason in the row's cause (listed in the guide).
code | Status | Meaning |
|---|---|---|
mailbox_import_empty | 400 | No file and no pasted text, or an input with no rows, or a header row and nothing under it |
mailbox_import_too_large | 400 | More than 5,000 mailboxes, a file over 10 MB, a pasted list over 2 MB, or a body that is not multipart form data. Split the file and import the rest separately |
mailbox_import_no_email_column | 400 | POST /emails/imports with a mapping in which no column is email |
mailbox_import_row_incomplete | 400 | PATCH /emails/imports/:id/rows/:line left the row without an SMTP and IMAP server or without a password. The message says which |
invalid_cursor | 400 | GET /emails/imports or GET /emails/imports/:id/rows was given a cursor this API did not issue. Start again without one |
mailbox_import_row_not_failed | 409 | Only a row that did not connect can be fixed; this one is queued, running, connected, updated, skipped, cancelled or waiting for sign-in |
mailbox_import_credentials_expired | 409 | The row's credentials were deleted 7 days after the import finished, so it cannot be retried. Import it again |
mailbox_import_nothing_to_retry | 409 | POST /emails/imports/:id/retry matched no failed row that still holds its credentials |
{
"error": "Conflict",
"message": "This row's credentials were removed after the retry window. Import it again.",
"code": "mailbox_import_credentials_expired",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Mailbox source refusals
The inbox vendor routes (/emails/vendors), the admin grant routes (/emails/grants) and the sending domain routes (/emails/domains) answer these. Inside an import, the vendor and grant failures land on the row instead, as its cause (see the guide).
code | Status | Meaning |
|---|---|---|
mailbox_vendor_unauthorized | 400 | The vendor did not accept the API key. A saved connection is marked invalid until the key is updated |
mailbox_vendor_no_workspace | 400 | The key is accepted but the vendor lists no workspace or organization for it, so there is nothing to read. Create one at the vendor, then connect again |
mailbox_vendor_invalid_fields | 400 | A field the vendor requires is empty, or a field holds control characters. GET /emails/vendors/catalog lists each vendor's fields |
mailbox_vendor_unknown | 400 | vendor is not one of the supported vendors |
mailbox_vendor_rate_limited | 429 | The vendor is rate limiting Zexr's requests. Try again in a minute |
mailbox_vendor_unavailable | 400 | The vendor answered with something unexpected, did not answer, or no longer has the mailbox. Try again shortly |
mailbox_grant_not_configured | 400 | Admin grants are not available for this provider. GET /emails/grants/config names the settings still unset in google_missing and microsoft_missing |
google_delegation_unauthorized | 400 | Google refused the domain-wide delegation, or accepted it and refused the call: the client ID or a scope is missing from the Admin console entry, an OAuth client ID was entered instead of the service account's numeric one, or the admin address is not a super administrator. Admin console changes can take a few minutes to apply. |
microsoft_consent_missing | 400 | The Microsoft 365 organization has not granted admin consent, does not exist, or the consent lacks Mail.ReadWrite, Mail.Send or User.Read.All; or the consent sign-in did not complete or name the organization. A Global Administrator connects it again, and again after the app's permissions change. |
mailbox_grant_state_invalid | 400 | The Google sign-in or Microsoft consent state expired (15 minutes), was already used, or was started by another member or workspace, or Google did not accept the sign-in. Start again |
mailbox_grant_proof_missing | 400 | The workspace has not proved it controls the Google Workspace domain: the _zexr.<domain> TXT record does not hold this workspace's zexr-verify= value yet, or the Google sign-in was not the admin address entered on that domain. Also returned when the directory does not list the admin address as a super administrator, and for a Microsoft 365 consent signed in by someone without the Global Administrator or Privileged Role Administrator role. Publish the record (DNS can take a while to appear) or sign in as that administrator, then finish again |
mailbox_grant_unavailable | 503 | Google, Microsoft or the DNS lookup for the proof did not answer. Nothing was recorded or changed, and a grant's status is left as it was. Try again in a minute |
mailbox_grant_domain_mismatch | 400 | The admin address is not on the domain entered, or the mailbox is on a domain the grant does not cover |
mailbox_grant_mailbox_unreachable | 400 | Google or Microsoft has no mailbox for the account: it does not exist, is suspended or disabled, or has no Exchange Online license |
mailbox_grant_inactive | 400 | The grant failed its last check, so it connects nothing. Check the grant (POST /emails/grants/:id/check), then retry |
mailbox_vendor_domain_not_found | 404 | PUT /emails/domains/:domain/vendor-forwarding or POST /emails/domains/:domain/vendor-tracking on a domain none of the workspace's active vendor accounts holds |
mailbox_vendor_domain_unsupported | 400 | The vendor holding the domain cannot do this through its API: it has no forwarding removal, writes no DNS records for Zexr, or the existing record is one the vendor manages itself. See domains held by an inbox vendor |
mailbox_reauth_delegated | 409 | POST /emails/onboarding/oauth/reauth/:id on a mailbox connected through an admin grant. It has no sign-in of its own to renew; check its grant instead, from Add account > Google or Microsoft > Whole domain or with POST /emails/grants/:id/check |
sending_domain_not_in_workspace | 404 | The workspace has no mailbox on this domain. Also 400 when no domain was given |
sending_domain_shared_provider | 400 | The domain belongs to a shared email provider, such as gmail.com, and cannot be redirected |
domain_redirect_invalid_target | 400 | target_url (or a vendor forwarding url) is not an http or https address on a host, carries credentials, is longer than 2,048 characters, or points back at the same domain or its www. An import's redirects option answers the same. Also returned by vendor-tracking when host is not a subdomain of the domain |
domain_redirect_taken | 409 | Another workspace already serves a verified redirect for this domain. The redirect is saved but does not verify |
domain_redirect_linked | 409 | This domain's redirect is managed from another Zexr installation, and only that installation changes it |
cli_auth_scopes_role | 403 | Approving a device sign-in: your role in the chosen workspace allows none of the scopes the tool asked for. Pick another workspace, or ask for narrower scopes |
sending_domain_bulk_invalid | 400 | A bulk setup named no domain, more than 100, neither a tracking_label nor a redirect_url, a tracking_label that is not one DNS label, or a served_by other than instance or cloud |
tracking_domain_taken | 409 | Another workspace on this instance has already verified this tracking domain, on a mailbox or as a campaign's override. Returned when saving or verifying a mailbox's tracking domain, applying one to a sending domain (including bulk setup), and verifying a campaign's override. Nothing changes |
tracking_host_not_configured | 400 | This instance has no tracking host (TRACKING_DOMAIN), so it cannot serve a tracking domain or a redirect |
{
"error": "Bad Request",
"message": "This sign-in expired or belongs to someone else. Start again.",
"code": "mailbox_grant_state_invalid",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}App password switch refusals
POST /emails/onboarding/app-password/:id moves a mailbox off per-mailbox Google sign-in onto an app password. Besides these, a password Gmail refuses answers with the same codes as an SMTP/IMAP connect, such as mailbox_auth_refused. Nothing is changed on any refusal.
code | Status | Meaning |
|---|---|---|
app_password_invalid | 400 | app_password is not 16 letters once spaces are removed. Create one at myaccount.google.com/apppasswords and send it as Google shows it |
mailbox_not_google_signin | 409 | The mailbox is not connected with per-mailbox Google sign-in: it is already on an app password or an admin grant, is an Outlook or SMTP/IMAP mailbox. A repeat of a switch that succeeded answers this |
Placement test refusals
The inbox placement test routes (/placement/* and /campaigns/:id/placement-monitor) answer these. A refused test sends nothing: every check runs before the first copy is scheduled.
code | Status | Meaning |
|---|---|---|
placement_not_entitled | 402 | The workspace has no active trial or subscription, so it cannot start a test. Only the hosted product answers this |
placement_quota_exceeded | 402 | The workspace has used its free tests for the month on the shared (instance) panel. A tracking comparison needs two. On the shared panel of a paid workspace the message names the price in credits: send max_credits of at least that much to pay it. Monitors and trials cannot pay, and wait for the next month. Tests on the workspace's own seed inboxes are never counted |
placement_too_many_running | 429 | The workspace already has 3 tests running, counting each half of a tracking comparison. Wait for one to finish |
placement_sender_busy | 409 | The sending mailbox still has copies of another test waiting to be sent. One test sends from a mailbox at a time |
placement_sender_unavailable | 409 | The sending mailbox is not connected and active, or it is itself a seed inbox, which receives tests and cannot send one |
placement_daily_budget | 409 | The mailbox has too little of its daily limit left today for a useful test (at least five seeds per test, ten for a comparison), or for every seed chosen with seed_ids. A test that fits otherwise is sized down to what is left rather than refused. The message gives both numbers |
placement_no_seeds | 409 | The panel has no seed inbox the test could use: none were added, none is connected, or every one is on the sender's own domain, which is always skipped |
placement_panel_unavailable | 409 | panel names a panel this deployment does not offer |
placement_invalid_seeds | 400 | seed_ids names a mailbox that is not a seed inbox of the workspace, or one that is not connected and running right now; the message names it |
placement_invalid_tracking | 400 | tracking is on or compare for a campaign that sends plain text, which carries no tracking |
placement_not_running | 409 | POST /placement/tests/:id/cancel on a test that has already finished or been cancelled |
placement_seed_unavailable | 409 | The mailbox cannot be marked as a seed inbox: it is on the instance panel, or a placement test is still sending from it |
placement_seed_limit | 409 | The workspace already has 50 seed inboxes. Unmark one first |
mailbox_is_seed | 409 | Warmup was started or resumed on a seed inbox. A seed never warms up; unmark it first with PUT /placement/seeds/:email_account_id |
placement_batch_empty | 400 | POST /placement/batches resolved to no sending mailbox: the scope, its filters and the sample left nothing |
placement_batch_too_large | 400 | The batch would hold more mailboxes than the instance allows in one batch (10,000 by default, an operator setting). The message gives both numbers; narrow the selection or take a sample |
placement_too_many_batches | 429 | The workspace already has 5 batches running. Wait for one to finish, or cancel one |
placement_batch_not_running | 409 | POST /placement/batches/:id/cancel on a batch that has already finished or been cancelled |
A batch runs every check that applies to the whole batch (the copy, the panel, the tracking, the allowance and the credits agreed) before it is created, and refuses with the codes above. The checks for each mailbox run when its turn comes: a mailbox refused then is deferred or skipped, and its sender row in GET /placement/batches/:id/senders carries the code as reason with its sentence as detail. Besides the single-test codes, a sender row can carry placement_sender_deleted (the mailbox was removed from the workspace), placement_batch_retry_expired (still unavailable when the seven-day retry window closed), placement_batch_start_failed (the test could not be started after several tries) and placement_batch_copy_unavailable (the campaign or step being tested was deleted, which skips every mailbox not started yet).
{
"error": "Conflict",
"message": "This test sends 40 emails from sales@yourdomain.com, which has 12 left of its daily limit today.",
"code": "placement_daily_budget",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}A 400 without one of these codes is a malformed request: an unknown panel or tracking, a sequence_id without its campaign_id, a missing subject or body on an ad-hoc test, a monitor interval_days outside 1 to 30 or an alert_below outside 0 to 100, or on a batch pace quick, both or neither of sender_account_ids and sender_scope, an unknown sender_scope.type, sample.mode or on_unavailable, or sample.stratify on a sample that is not random or percent.
OAuth app and community directory refusals
POST /oauth/applications registers an app and PUT /oauth/applications/:id/listing publishes or edits its community directory listing. Nothing is saved on any refusal.
code | Status | Meaning |
|---|---|---|
invalid_listing | 400 | A listing field breaks a rule: the slug is not 3 to 48 lowercase letters, numbers or single dashes, or is reserved for a built-in integration; the tagline is empty or over 120 characters; the description is over 2,000; either holds control, invisible or bidirectional-override characters; the category is unknown; or install_url, support_url or privacy_url is not an https address on a host without credentials. The message names the field |
app_not_listable | 400 | The app cannot be listed: it is disabled or suspended, or it is a client created by dynamic registration, which has no publishing workspace |
developer_access_blocked | 403 | The instance's operators have blocked this workspace or person from registering and publishing apps. GET /oauth/applications reports the reason in developer_access. Also returned by POST /oauth/applications |
invalid_website | 400 | website on POST or PATCH /oauth/applications is not an http or https address on a host without credentials |
invalid_logo | 400 | logo_url on POST or PATCH /oauth/applications is not an address this instance issued for the workspace. Upload the logo with POST /oauth/applications/{id}/logo instead |
listing_slug_taken | 409 | Another app on this instance already uses the slug. Choose another |
listing_hidden | 409 | The instance's operators hid this listing, so DELETE /oauth/applications/{id}/listing cannot remove it. The hide note is on the listing |
app_suspended | 409 | The instance's operators suspended this app. It cannot be edited or given a new logo until it is unsuspended |
Credential management refusals
code | Status | Meaning |
|---|---|---|
oauth_token_not_allowed | 403 | An OAuth app token called a route that manages credentials: creating, editing, revoking or deleting API keys, or anything under /oauth/applications and /oauth/application-logo. Use the dashboard or an API key |
api_key_permissions_exceed_caller | 403 | POST /api-keys or PATCH /api-keys/{id} would give a key more than its creator has: a permission the member's role does not cover, or, for an API key caller, a permission, mailbox or IP address the calling key does not have (a mailbox- or IP-limited key can only manage keys limited to a subset of its own). See what a role can delegate |
api_key_holder_left | 401 | The member who created this API key is no longer in the workspace, so the key no longer works. Create a new key from a current member |
api_key_mailbox_limited | 403 | An API key limited to some mailboxes called a surface that acts across the workspace: /ai/tools or /mcp, a campaign sender pool resolved from mailbox tags, or starting a campaign whose pool is not an explicit list of the key's mailboxes. See email account restrictions |
The OAuth consent and token endpoints answer in the RFC 6749 shape (error, error_description) instead. access_denied from GET /oauth/authorize/details or POST /oauth/authorize means the approving member's role covers none of the requested scopes. invalid_grant on a refresh means the token is unknown, expired, revoked, was already used (which also revokes the grant), or its member no longer holds any of its scopes. A failure inside the server is 500 server_error with a fixed description and a request_id.
CRM provider refusals
Returned by the CRM endpoints while a workspace runs its CRM on HubSpot or Pipedrive. A change is written to the provider before it is saved in Zexr, so each of these means nothing was changed on either side.
code | Status | Meaning |
|---|---|---|
crm_not_connected | 409 | GET /crm/metadata, POST /crm/sync, POST /crm/backfill, a /crm/contacts/:id or a /crm/lists call on a workspace that is not in HubSpot or Pipedrive mode. Connect the provider and choose it as the CRM with PUT /crm/settings first |
crm_reauth_required | 409 | The provider revoked Zexr's access, the connection was removed, or the connection lacks a permission provider mode needs (in HubSpot deals, companies, owners, the contact schema, or the optional list permission for a list import; in Pipedrive deals, people, activities and users, or the connected user may not make the change). Reconnect and approve every permission |
crm_managed_externally | 409 | A create, update or delete of a pipeline, stage or task type. They are managed in HubSpot or Pipedrive while it is the CRM; change them there and they update in Zexr within minutes |
crm_stage_unknown | 400 | The pipeline_id or stage_id given is not a mirrored pipeline or stage of the provider, or (in HubSpot) a deal was marked won or lost in a pipeline with no closed stage of that kind. Also 409 from a copy into the provider when it has no deal pipeline to copy deals into |
crm_owner_unmapped | 400 | A deal or task was assigned to a member who is not matched to a HubSpot owner or Pipedrive user. Match them with PUT /crm/owners/:externalId |
crm_contact_missing | 404 | The contact is not in the provider, and creating contacts there is turned off in the workspace's CRM settings |
crm_record_missing | 404 | The record no longer exists in the provider; it was deleted there |
crm_provider_rejected | 422 | The provider refused the change. The message carries its reason, such as a required property HubSpot enforces or a Pipedrive plan without the feature |
crm_sync_running | 429 | POST /crm/sync within 30 seconds (HubSpot) or a minute (Pipedrive) of the last one. The running sync finishes in a minute or two |
crm_unavailable | 503 | The provider did not answer or failed, including a Pipedrive company out of its daily API budget. Nothing was changed; retry shortly |
{
"error": "Conflict",
"message": "Pipelines are managed in HubSpot. Edit them there and they update here within minutes.",
"code": "crm_managed_externally",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Salesforce sync refusals
The Salesforce endpoints answer with their own codes. When Salesforce itself refused, message carries its errorCode and text, such as Salesforce: INVALID_FIELD: No such column 'Tier__c' on entity 'Lead'.
code | Status | Meaning |
|---|---|---|
invalid_salesforce_domain | 400 | The custom domain given to POST /integrations/oauth/start is not a Salesforce My Domain (*.my.salesforce.com) |
invalid_salesforce_settings | 400 | PUT /integrations/salesforce/:id/settings named something the sync cannot run: an unknown field, a related field to write, an engagement field to read, a field mapped twice, or a fixed owner with no user |
salesforce_error | 400 | Salesforce refused the request. The message is Salesforce's own |
salesforce_unreachable | 400 | Salesforce did not answer. Retry later |
salesforce_sync_off | 400 | POST /integrations/salesforce/:id/sync-now on a connection with sync turned off |
salesforce_reconnect_required | 409 | The Salesforce session ended (a revoked app, a password reset, a deactivated user). Reconnect the integration |
import_running | 409 | POST /integrations/salesforce/:id/import-sources/:sourceId/run or POST /integrations/apollo/:id/import-sources/:sourceId/run while that import is already running |
salesforce_rate_limited | 429 | The Salesforce org has used its API requests for today |
Apollo and Clay refusals
The Apollo and Clay endpoints answer with their own codes.
code | Status | Meaning |
|---|---|---|
apollo_enrich_too_many | 400 | POST /integrations/apollo/:id/enrichments selected more than 5,000 contacts |
clay_webhook_url | 400 | PATCH /integrations/clay/:id set a webhook_url that is not a Clay table webhook |
clay_webhook_missing | 400 | PATCH /integrations/clay/:id chose events to send with no table webhook saved |
apollo_key_rejected | 422 | Apollo refused the saved API key. Replace it with PUT /integrations/apollo/:id/key |
apollo_key_forbidden | 422 | The API key works but cannot reach an endpoint the integration uses. Use a master key, or one with lists, contact search and people enrichment |
apollo_key_missing | 422 | The Apollo connection has no API key |
apollo_rate_limited | 429 | Apollo's rate limit for the account was reached. Retry later |
Automation refusals
code | Status | Meaning |
|---|---|---|
action_provider_mismatch | 400 | POST /automations, PATCH /automations/:id or POST /integrations/connections/:id/events paired an action with a connection whose provider does not run it, such as a Slack message on a HubSpot connection. Each action runs only on the integration it belongs to; the provider's actions are listed under capability.actions in GET /integrations/catalog |
mailbox_oauth_return_origin
A 400 with code: "mailbox_oauth_return_origin" means a Google or Microsoft mailbox connect or reauthorization came from a dashboard origin the backend does not allow. Start the connect again from the Zexr dashboard.
401 Unauthorized
Returned when authentication fails.
Common causes:
- Missing
Authorizationheader - Invalid API key format
- Expired API key
- Revoked API key
Example:
{
"error": "Unauthorized",
"message": "Token not found.",
"code": "unauthorized",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Include the
Authorization: Bearer zexr_...header - Verify your API key is correct
- Check if your key has expired or been revoked
- Generate a new key if necessary
One 401 variant is not about API keys at all. sso_wrong_browser is returned by POST /auth/sso/exchange when the handoff code is collected without the binding secret that POST /auth/<provider>/begin handed the browser that started the sign-in. The handoff is deliberately non-transferable: a sign-in link that was forwarded, or opened in another browser, cannot sign the recipient in. Start the sign-in again in the browser you want to use.
402 Payment Required
Returned when an AI action, or a placement test paid in credits, is requested but the organization does not have enough credits. The response carries the stable code insufficient_credits.
{
"error": "Payment Required",
"message": "You're out of AI credits. Add more to keep using the assistant.",
"code": "insufficient_credits",
"request_id": "req_..."
}How to fix:
- Wait for the monthly allowance to reset, or buy a top-up pack (see AI credits)
- Related: a
429with codeusage_cap_exceededmeans a short-term AI usage cap was hit; retry later
Placement tests answer 402 too, with placement_not_entitled or placement_quota_exceeded; see placement test refusals. A paid placement test past the credit balance answers insufficient_credits, and one past a spend limit answers 429 with usage_cap_exceeded; neither charges anything.
403 Forbidden
Returned when authenticated but lacking necessary permissions.
Common causes:
- API key lacks required permission
- Request IP not in allowlist
- Email account not in allowlist
- Organization access restricted
- The member is restricted to selected resources (
member_access_restricted)
Example:
{
"error": "Forbidden",
"message": "You don't have access to this feature.",
"code": "forbidden",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Check your API key's permissions
- Verify IP restrictions if configured
- Request additional permissions if needed
inbox_agent_requires_paid_plan
A 403 whose code is inbox_agent_requires_paid_plan comes from PUT /ai/inbox-agent/settings with a mode other than off on a workspace whose plan does not include the inbox agent. Setting off is always allowed.
mailbox_gmail_oauth_disabled
A 403 whose code is mailbox_gmail_oauth_disabled comes from POST /emails/onboarding/oauth/start with provider: "gmail". The backend has no complete Google mailbox client credentials, or explicitly disables new Google OAuth connects through BOX_GOOGLE_OAUTH_CONNECT; GET /auth/config announces it as gmail_oauth_connect: false. Nothing about the caller's permissions is wrong. Re-authorizing an existing Gmail mailbox (POST /emails/onboarding/oauth/reauth/:id) is never refused this way.
{
"error": "Forbidden",
"message": "New Gmail mailboxes connect with an app password over IMAP and SMTP on this deployment, not with Google sign-in.",
"code": "mailbox_gmail_oauth_disabled",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Connect the mailbox through
POST /emails/onboarding/smtp-imapwithsmtp.gmail.com:465andimap.gmail.com:993, both TLS, and a Google app password. See Gmail and Google Workspace
Registration and invitation refusals
Signup and invitation refusals carry their own code, so a client can branch on the specific condition instead of matching on text. They describe the deployment's policy and never say anything about whether a given address exists.
code | Status | Meaning |
|---|---|---|
registration_invite_only | 403 | The server runs DISABLE_REGISTRATION=invite_only. Creating an account requires an invitation link, which carries the token that permits the signup |
registration_closed | 403 | The server runs DISABLE_REGISTRATION=true. Signups are off and invitations do not override it |
invitation_invalid | 403 | The invitation is expired, cancelled, already used, or was issued for a different email address |
{
"error": "Forbidden",
"message": "This server is invite only. Ask an administrator to invite you, then open the link in the invitation to create your account.",
"code": "registration_invite_only",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix: these describe the deployment's signup policy, not a fault. Ask a workspace owner for a new invitation when one has expired or was sent to another address.
Confirmation required
code | Status | Meaning |
|---|---|---|
reauth_required | 403 | The action needs a proof of identity newer than the session. Confirm with POST /v1/auth/reauth, then retry |
reauth_limited | 400 | Too many failed confirmations for this account within the hour. Wait, or sign out and sign in again: a sign-in counts as a confirmation for five minutes |
reauth_no_factor | 400 | POST /auth/reauth was sent a password or code for an account that has neither. Confirm with a passkey or the linked sign-in provider listed by GET /auth/reauth/methods; an account with none of those signs out and in again (a sign-in counts as a confirmation for five minutes) |
reauth_identity_mismatch | 400 | The Google, Apple or single sign-on account verified during a confirmation is not the one linked to this Zexr account. Continue with the account you sign in with |
reauth_expired | 400 | The confirmation's code or nonce expired or was already used. Start the confirmation again |
admin_mfa_required | 403 | An admin route was reached by a session that did not present a second factor. Turn on 2FA or add a passkey, then sign in again |
passkey_user_verification_required | 400 | A passkey sign-in came from an authenticator that did not verify the user with a PIN or biometric. Set a PIN on the security key, or sign in another way |
two_fa_invalid_code | 400 | The authenticator or recovery code did not match. Wait for a fresh code and try again; the login challenge allows five attempts per pending session |
password_changed_sign_in_again | 409 | POST /auth/me/password stored the new password but could not issue the calling device a new session. Every earlier token is invalid; discard them and sign in again with the new password |
reauth_required guards the changes that hand out a durable credential, reveal a signing secret, change who can get in, cannot be undone, or reach a whole domain's mail. The full list is under actions that need a recent confirmation. List the ways the account can confirm with GET /v1/auth/reauth/methods (a passkey and a linked Google, Apple or single sign-on identity are among them; see endpoints). With a password or a current two-factor code:
curl -X POST "https://api.zexr.app/v1/auth/reauth" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"password": "..."}'{ "valid_for_seconds": 300 }The confirmation is recorded on the session and lasts for the window returned, so a run of related changes only asks once.
Turning on two-factor authentication (POST /auth/2fa/enroll/start and /confirm) is guarded too, since a confirmed authenticator is itself a way to confirm. An account with a password confirms as above. An account with neither a password nor 2FA (created through Google, Apple or single sign-on) confirms with its passkey or linked identity. One with none of those is allowed for five minutes after a sign-in like every confirmed action; an older session gets reauth_no_factor and the fix is to sign out and sign in again. API keys and OAuth tokens have no session to confirm, so these routes are reachable only with a signed-in session; that is deliberate for key creation, which otherwise lets one leaked key mint more.
{
"error": "Forbidden",
"message": "Confirm it is you before making this change.",
"code": "reauth_required",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Member access scope
A member restricted to selected resources, and any API key or OAuth app acting for one, reaches only the read routes that filter to their grants. The scope's own refusals:
code | Status | Meaning |
|---|---|---|
member_access_restricted | 403 | The caller's selected-resource scope does not cover this route. Resource writes and workspace-wide surfaces such as contacts and settings are refused. Workspace creation is refused to accounts with only restricted memberships, except instance administrators; limit-request operations require an unrestricted membership in the target workspace |
invalid_access_resource | 400 | PUT /organization/members/:id/access or an invitation named a campaign folder, campaign or mailbox that is not in this workspace |
invalid_access_scope | 400 | The scope is neither workspace nor restricted |
too_many_access_grants | 400 | More than 500 folders, campaigns or mailboxes in one list |
owner_access_unrestricted | 403 | The workspace owner always reaches the entire workspace and cannot be restricted |
A campaign or mailbox outside a restricted caller's grants answers 404, the same as one that does not exist, so a scope does not reveal what else the workspace holds. GET /unibox/thread/labels likewise returns the same 404 for an unknown thread, a thread outside the selected workspace, or a thread with no message in an accessible mailbox. An accessible thread with no labels returns an empty list.
Failure to resolve a restricted scope or its scoped counts fails the request rather than returning workspace-wide data. Realtime authorization lookup errors stop campaign, mailbox and organization channels; user-channel events whose authorization cannot be verified are withheld. See bounded realtime authorization.
{
"error": "Forbidden",
"message": "Your access to this workspace is limited to selected campaigns and mailboxes, and this is outside it.",
"code": "member_access_restricted",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix: ask a member with Manage team to widen the access under Settings > Members.
slack_link_email_mismatch
A 403 whose code is slack_link_email_mismatch comes from POST /integrations/slack/link sent without a Sign in with Slack result. Confirming a Slack link that way needs the email on the Slack account's profile to be the signed-in user's Zexr email, compared without regard to case. The code stays usable: confirm it again with slack_code and state from POST /integrations/slack/link/verify. GET /integrations/slack/link/:code reports the check ahead of time as email_matches, and whether Sign in with Slack is available as verify_available.
{
"error": "Forbidden",
"message": "This Slack account's email is not the one you sign in to Zexr with. Continue with Slack to confirm the account is yours.",
"code": "slack_link_email_mismatch",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Sign in to Zexr with the address on your Slack profile, then confirm again
- If the addresses already match, a member with
manage_settingsreconnects Slack under Integrations > Slack so the connection grantsusers:readandusers:read.email
404 Not Found
Returned when the requested resource doesn't exist.
Common causes:
- Invalid resource ID
- Resource was deleted
- Resource belongs to different organization
- Typo in endpoint URL
Example:
{
"error": "Not Found",
"message": "Resource not found.",
"code": "not_found",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Verify the resource ID is correct
- Check that the resource hasn't been deleted
- Ensure you're using the correct endpoint
Specific 404 codes:
code | Meaning |
|---|---|
unknown_view | /me/views/:view was given a view name other than contacts, campaign_leads or unibox_rail |
app_attest_unavailable | POST /auth/app-attest/challenge or /keys on an instance with App Attest off. See App Attest refusals |
lead_cc_contact_not_found | Set a lead's CC named a contact that is not in the workspace |
slack_not_connected | A Slack route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under Integrations > Slack |
slack_link_invalid | The Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button |
slack_verify_failed | POST /integrations/slack/link with a Sign in with Slack result that is unknown, expired, already used, started by someone else or for another code. Start again from POST /integrations/slack/link/verify |
slack_verify_wrong_account | POST /integrations/slack/link after Sign in with Slack signed in a different Slack account than the one the link code names |
slack_verify_unavailable | POST /integrations/slack/link/verify on an instance whose Slack app has no client credentials |
slack_not_linked | PATCH /integrations/slack/link from a member who has not linked a Slack account in this workspace |
409 Conflict
Returned when the request conflicts with existing data.
Common causes:
- Trying to create a resource that already exists
- Duplicate unique values
- Deleting something whose state does not allow it yet, such as an API key that can still authenticate
Example:
{
"error": "Conflict",
"message": "resource already exists",
"code": "conflict",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Creating an account for an address that already has one answers 409 with the generic conflict code and a message pointing to sign-in and password reset. With email verification on it comes from POST /auth/register/confirm, after the emailed code has shown the address is the caller's; without it, POST /auth/register answers it directly.
A few conflicts carry their own code. The mailbox import's are under mailbox import refusals. A contact's email address has to be free, so changing one to an address another contact already holds is refused rather than merging the two:
code | Status | Meaning |
|---|---|---|
agent_draft_send_unknown | 409 | POST /unibox/agent-drafts/:id/approve on an inbox agent draft whose automatic send was stored but not confirmed, so it may already have gone out. Check the thread and discard the draft if it was sent |
agent_draft_not_queued | 409 | POST /unibox/agent-drafts/:id/cancel on an inbox agent reply that is no longer queued: the sender already claimed it, it was sent, or a person already handled it |
contact_email_taken | 409 | The address given to update a contact already belongs to another contact |
lead_cc_lead_is_copied | 409 | Set a lead's CC on a lead that is itself copied on another lead in the campaign, so it sends nothing of its own to copy anyone on |
lead_cc_has_copies | 409 | Set a lead's CC names a contact that has copies of their own in the campaign |
mailbox_is_seed | 409 | Warmup was started or resumed on a placement seed inbox. See placement test refusals |
mailbox_not_google_signin | 409 | POST /emails/onboarding/app-password/:id on a mailbox that is not connected with per-mailbox Google sign-in. See app password switch refusals |
approval_not_pending | 409 | POST /ai/sessions/:id/approve named a tool call the conversation is not waiting on: it was already decided, in another tab or in Slack, or a newer message replaced it |
422 Unprocessable
Returned when validation fails on the request data.
Common causes:
- Invalid email format
- String exceeds maximum length
- Number outside valid range
- Invalid enum value
Example:
{
"error": "Unprocessable",
"message": "validation failed",
"code": "unprocessable",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}500 Internal Server Error
Returned when an unexpected error occurs on the server.
Example:
{
"error": "Internal Server Error",
"message": "Something went wrong.",
"code": "internal_error",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Retry the request after a short delay
- If persistent, contact support with request details
A 500 always carries this same message. The underlying detail is not returned, because it is usually database or provider output naming tables, columns and hosts, none of which helps a caller. It is logged against the request_id in the response, so quoting that id in a support request is what connects the two.
One internal_error variant is worth distinguishing. When an authentication endpoint cannot send its email, the message names that specifically rather than reporting a generic fault:
{
"error": "Internal Server Error",
"message": "We couldn't send the email. If you administer this server, check the mail transport configuration.",
"code": "internal_error",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Retry in a few minutes, and contact support with the request_id if it persists.
503 Service Unavailable
Returned when the service is temporarily unavailable.
Example:
{
"error": "Service Unavailable",
"message": "service unavailable",
"code": "service_unavailable",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Wait and retry with exponential backoff
- Check status page for incidents
ai_not_configured
A 503 whose code is ai_not_configured is not transient and retrying will not help. It comes from POST /templates/analyze and means the deployment has no AI provider set up at all. It is how a client tells "there is no AI here" apart from "the provider is having a bad minute", which returns the generic service_unavailable and is worth retrying.
{
"error": "Service Unavailable",
"message": "AI analysis is not configured on this deployment.",
"code": "ai_not_configured",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Hide the AI affordance in your client rather than retrying: nothing about the request will make it succeed
slack_not_configured
A 503 whose code is slack_not_configured is not transient and retrying will not help. The deployment is not set up for Slack. The three Slack request URLs (/api/v1/integrations/slack/events, /interactivity and /commands) answer it whenever SLACK_SIGNING_SECRET is unset.
{
"error": "Service Unavailable",
"message": "Slack is not set up on this Zexr instance.",
"code": "slack_not_configured",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- In a client, read
app_configuredandinteractive_configuredfromGET /integrations/slack/statusand hide the Slack affordances rather than retrying
Those request URLs answer 401 unauthorized to a request whose Slack signature does not verify. Linking a Slack account to a workspace you are not a member of is 403 forbidden, and linking one whose email is not yours without signing in to it with Slack is 403 slack_link_email_mismatch.
mailbox_allowance_reached
A 403 whose code is mailbox_allowance_reached comes from every path that connects a mailbox: POST /emails/onboarding/oauth/start, POST /emails/onboarding/oauth/finish, POST /emails/onboarding/smtp-imap, and per row inside POST /emails/onboarding/smtp-imap/bulk. A mailbox import does not answer it over HTTP: the rows past the allowance fail with the cause allowance_reached and can be retried once it is raised. It is not a permission problem: the workspace holds its whole mailbox allowance, which on a paid plan is one mailbox for every send a day the plan includes, and 10 on a free workspace. Nothing was connected.
{
"error": "Forbidden",
"message": "This workspace holds 15000 of its 15000 mailboxes. Request an increase, or move to a plan with more daily sends.",
"code": "mailbox_allowance_reached",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Read
GET /emails/allowancefirst:remainingsays how many connects will succeed, andpending_requestwhether an increase is already asked for - Submit a limit-increase request for
max_email_accountsviaPOST /organization/:orgId/limit-requests, or move to a plan with more daily sends. An approved request raises the allowance immediately; retry the connect then - Reconnecting an existing mailbox never returns this code
storage_limit_reached
A 400 whose code is storage_limit_reached comes from POST /campaigns/:id/attachments, from POST /email-images, and from a campaign duplicate that would copy attachments. The workspace's stored bytes, its attachments across every campaign plus its email image library, would pass the quota. The check and the write happen together under one per-workspace lock shared by both, so two uploads racing for the last of the quota cannot both get in. Nothing was stored.
{
"error": "Bad Request",
"message": "Storage limit reached: 51190 MB of 51200 MB used, 12 MB to add. Remove attachments or images, or upgrade your plan.",
"code": "storage_limit_reached",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
GET /organization/current/limitsreportsstorage.used_bytesandstorage.limit_bytes, andstorage.over_quotawhen a plan change left the workspace above the quota. Existing attachments keep sending either way- Delete attachments you no longer need (
DELETE /campaigns/:id/attachments/:attachmentId) or images (DELETE /email-images/:id), or move to a paid plan for the larger quota
mailbox_validation_timeout
A 400 whose code is mailbox_validation_timeout comes from the SMTP and IMAP connect and re-authorize endpoints. Zexr proves credentials by opening a real connection to the mail server before it stores anything, and a connection stayed silent inside that window. The message names the leg that did not answer and says whether the other one signed in. Nothing was saved and no mailbox was created.
{
"error": "Bad Request",
"message": "SMTP (smtp.gmail.com:465) did not answer in time. IMAP (imap.gmail.com:993) signed in. Nothing was saved. Check the host and port, then try again. Port 465 did not answer and the worker could not use 587 with STARTTLS on the same server either. Some networks block outbound mail ports; check that the server is reachable from outside and which port it listens on.",
"code": "mailbox_validation_timeout",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}The same code with a message that begins Zexr's worker did not report back means no worker answered the check at all, so the mail server was never tested. The check is offered to each healthy worker in turn and waits on at most two that take it without answering, so this means the fleet as a whole is not answering. Retry, and contact support if it persists.
How to fix:
- Read which leg stayed silent. One leg passing and the other hanging is the network between the worker and that port, not the password: a port that nothing listens on, or that a firewall drops, takes the full timeout rather than refusing straight away
- When 465 never answers, the worker also tries 587 with STARTTLS on the same server and uses it if it connects; a passing check is then stored with port 587. This error means neither port answered, so check that the server is reachable from outside your network and which port it listens on
- Retry. A mail server under load can be slow once and answer immediately on the next attempt
- Confirm the server accepts connections from outside your network, and that the security setting matches the port:
tlsfor 465 and 993,starttlsfor 587 and 143
mailbox_auth_refused
A 400 whose code is mailbox_auth_refused comes from the SMTP and IMAP connect and re-authorize endpoints. The mail server answered and refused the sign-in. The message names the leg and the server, quotes the server's own reply, and for Google's servers says what an app password is. Nothing was saved.
{
"error": "Bad Request",
"message": "SMTP (smtp.gmail.com:587) refused the sign-in: 535 5.7.8 Username and Password not accepted. IMAP (imap.gmail.com:993) refused the sign-in: imap: NO [AUTHENTICATIONFAILED] Invalid credentials (Failure). Nothing was saved. Google itself refused this address and password. Use a 16-letter app password from myaccount.google.com/apppasswords, created while signed in to the Google account that owns this address, and enter the account's own sign-in address rather than an alias or a group. A deleted app password stops working at once, and on Google Workspace the administrator can block app passwords or IMAP.",
"code": "mailbox_auth_refused",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}How to fix:
- Read the server's reply in the message: it says which leg refused and usually why (a wrong password, an app password required, IMAP switched off for the domain)
- The username is normally the full address. The password is the provider's app password when the account has two-step verification on, never the account password
- Whitespace is not the problem: Zexr trims the password on every path, and removes every space from one bound for a Google server
- On Google, a refusal with a real app password nearly always means the address is not the account that issued it: enter the account's own sign-in address rather than an alias or a group, create the app password while signed in to that account, and check it has not been deleted since. On Google Workspace the administrator can also block app passwords or IMAP for the organization
mailbox_unreachable
A 400 whose code is mailbox_unreachable comes from the same endpoints. The worker could not open a connection to the server at all: the name did not resolve, the port refused, or a firewall dropped the packets. The message says which server and which of those it was. Nothing was saved.
{
"error": "Bad Request",
"message": "SMTP (smtp.example.com:465) could not be reached from the worker: the port refused the connection. Nothing was saved.",
"code": "mailbox_unreachable",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}How to fix:
- Check the host and port for typos
- The connection leaves from the worker, not from your browser or from the API host: the worker's network has to reach the server on that port. Some hosting providers block outbound mail ports until asked
mailbox_tls_failed
A 400 whose code is mailbox_tls_failed comes from the same endpoints. The server was reached but no secure connection could be made: the certificate did not verify for that host name, the handshake failed, or the port expects a different mode (tls on a STARTTLS port, or the reverse) and no STARTTLS was offered. Nothing was saved.
{
"error": "Bad Request",
"message": "IMAP (mail.example.com:143) did not complete a secure connection: the server offers no STARTTLS on this port. Nothing was saved.",
"code": "mailbox_tls_failed",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}How to fix:
- Match the security setting to the port:
tlsfor 465 and 993,starttlsfor 587 and 143, unless the provider documents otherwise - Use the host name the certificate is issued for, which is the one in the provider's documentation rather than an alias or an IP address
mailbox_server_declined
A 400 whose code is mailbox_server_declined comes from the same endpoints. The sign-in could not be completed for a reason other than the credentials: the server asked for a retry later (a 4xx reply, often a login rate limit), it offers no sign-in method Zexr implements, it answered with a 5xx that is not about the password (a mechanism it does not support, a STARTTLS it insists on), the conversation broke, or the probe itself failed before a reply arrived. The message carries the server's reply when there is one. Nothing was saved.
{
"error": "Bad Request",
"message": "SMTP (smtp.example.com:587) declined the sign-in for now and asks for a retry: 454 4.7.0 Too many login attempts, please try again later. Nothing was saved.",
"code": "mailbox_server_declined",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}How to fix:
- For a
4xxreply, wait a few minutes and retry - For a server that offers only mechanisms Zexr does not implement (NTLM, GSSAPI), use the provider's documented SMTP submission host, which normally offers
LOGINorPLAIN; a different password does not add a mechanism
mailbox_worker_unreachable
A 503 whose code is mailbox_worker_unreachable comes from DELETE /emails/{id}. Disconnecting a mailbox has to reach the machine that syncs it before the record goes, because once the record is gone nothing can tell that machine to stop. When the instruction cannot be delivered, nothing is removed and the mailbox is left exactly as it was.
{
"error": "Service Unavailable",
"message": "This mailbox could not be disconnected right now because the machine syncing it could not be reached. Nothing was removed, so try again in a moment.",
"code": "mailbox_worker_unreachable",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Retry the delete. It is safe to repeat: a mailbox that is already gone returns
404, and one that is still there is untouched
mailbox_send_as_unsupported
A 400 whose code is mailbox_send_as_unsupported comes from POST /emails/{id}/identity/refresh and from a PATCH /emails/{id} that sets send_as_email. Only Gmail and Google Workspace mailboxes publish the addresses they are allowed to send as; an Outlook or SMTP/IMAP mailbox has no such list, so there is nothing to refresh and nothing to choose from.
{
"error": "Bad Request",
"message": "This mailbox's provider does not expose send-as addresses. Only Gmail and Google Workspace mailboxes do.",
"code": "mailbox_send_as_unsupported",
"request_id": "9f0a6f21-2c7e-4f2d-9d0e-1f7b9a2c4e55"
}How to fix:
- Read
GET /emails/{id}/identityfirst. Itssupportedfield isfalsefor these mailboxes, andidentitiesis empty - Send from the mailbox's own address, which is what an empty
send_as_emailmeans
mailbox_send_as_unknown
A 400 whose code is mailbox_send_as_unknown comes from a PATCH /emails/{id} that sets send_as_email to an address the provider has not verified for that mailbox. The choice is refused here rather than at send time, where the provider's own refusal arrives days later against a campaign step and names nothing you could act on.
{
"error": "Bad Request",
"message": "That address is not one your provider has verified this mailbox to send as. Refresh the list, or add and verify the address in your provider first.",
"code": "mailbox_send_as_unknown",
"request_id": "b71c3f88-0b3e-4a41-9a52-2f4f1fd0c0aa"
}How to fix:
- Add the alias in Gmail (Settings, Accounts, "Send mail as") and finish its verification
- Call
POST /emails/{id}/identity/refreshso Zexr re-reads the list, then setsend_as_emailto an address whoseverifiedistrue
mailbox_signature_too_large
A 400 whose code is mailbox_signature_too_large comes from POST /emails/{id}/identity/refresh with import_signature set. The provider's signature is larger than Zexr stores (20000 characters of HTML), and it is refused rather than truncated: half a signature is worse than none. The send-as list is not stored either, so the call changes nothing.
{
"error": "Bad Request",
"message": "The signature on this mailbox is larger than Zexr stores (20000 characters). Shorten it in your provider and import it again.",
"code": "mailbox_signature_too_large",
"request_id": "2d5e9a13-7c41-4f9b-bb17-a0f1d9c6e332"
}How to fix:
- Shorten the signature in Gmail, usually by linking an image rather than embedding it, and import again
- Or write the signature in Zexr directly with
PATCH /emails/{id}
mailbox_identity_unavailable
A 503 whose code is mailbox_identity_unavailable comes from POST /emails/{id}/identity/refresh. Reading a mailbox's sending addresses is an account operation, so it runs on the worker holding that mailbox, never from the API itself. It is unavailable exactly when that machine is: while the mailbox is being moved between workers, just after a worker restart, or before a newly connected mailbox has been placed. Nothing was changed.
{
"error": "Service Unavailable",
"message": "Zexr could not reach the machine running this mailbox, so its sending addresses were not refreshed. Nothing was changed; try again in a moment.",
"code": "mailbox_identity_unavailable",
"request_id": "6e2f1a90-5d13-4a77-9c0b-73f0b5a2e118"
}How to fix:
- Retry. Placement happens within moments, so a second attempt usually succeeds
GET /emails/{id}reports the mailbox'sstatus; aninactivemailbox is not placed on a worker at all and will keep refusing until it is reactivated
Error handling best practices
Implement retry logic
For transient errors (5xx, 429), implement exponential backoff:
async function requestWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok) {
return response.json();
}
// Don't retry client errors (4xx) except rate limits
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
throw new Error(`Client error: ${response.status}`);
}
// Retry server errors and rate limits
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
} catch (error) {
if (attempt === maxRetries - 1) throw error;
}
}
}Parse error responses
Always parse and handle error responses:
async function apiRequest(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json();
throw new ApiError(response.status, error.error, error.message);
}
return response.json();
}
class ApiError extends Error {
constructor(status, type, message) {
super(message);
this.status = status;
this.type = type;
}
}Rate limiting
When you exceed rate limits, you'll receive:
HTTP/1.1 429 Too Many Requests
Retry-After: 60{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"code": "rate_limit_exceeded",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Use the Retry-After header to determine when to retry.
Admin device request errors
The admin device flow
adds admin_device_expired (404, indistinguishable invalid/unknown/expired/
consumed request), admin_device_resolved (409, already decided or mismatched/
replayed consent), and admin_device_slow_down (429, poll faster than five
seconds). Start again on expiry and never auto-approve on conflict.
Attempt budgets use existing rate_limit_exceeded. Auth/MFA/reauth and
dependency failures keep existing codes. Redis failures never authorize a
single-factor or anonymous admin. Node diagnostics
use generic errors and explicit unavailable/stale coverage, not outage inference.