Error codes
Reference for all API error codes and their meanings.
The Warmbly 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
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". |
| 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.
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_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 ([email protected] or Ana <[email protected]>), or no to address at all. Put each address in its own entry |
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"
}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 warmblyctl:
- 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
{
"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 Warmbly'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 | This instance has no Google service account or no Microsoft app for admin grants. An operator sets them up; see configuration. 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. See when Google refuses |
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. See when Microsoft refuses |
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 _warmbly.<domain> TXT record does not hold this workspace's warmbly-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 Warmbly, 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 on this instance already serves a verified redirect for this domain, or already has Warmbly Cloud serve it. The redirect is saved but does not verify. On Warmbly Cloud's linked-instance API, the linked workspace already has its own redirect for the domain |
domain_redirect_linked | 409 | On Warmbly Cloud: this domain's redirect is served for a linked self-hosted instance, and only that instance changes it |
domain_redirect_cloud_unavailable | 409 | served_by is cloud, but this instance is not linked to Warmbly Cloud, or Cloud does not serve redirects for it |
domain_redirect_cloud_unreachable | 503 | Warmbly Cloud could not be reached while saving, moving or removing a redirect it serves, so nothing changed. Retry |
domain_redirect_limit | 409 | Warmbly Cloud already serves 200 redirects for this linked instance |
pool_link_redirect_not_found | 404 | On Warmbly Cloud's linked-instance API: Cloud serves no redirect for this domain for the calling instance |
pool_link_cleartext_mailbox | 422 | Enrolling a mailbox in Warmbly Cloud, or updating its credential there: SMTP or IMAP uses security none. Cloud reaches the mail server over the internet and needs TLS or STARTTLS on both legs. Switch the mailbox to TLS, or keep warming it on the instance |
pool_link_instance_url | 422 | Starting a Google or Microsoft sign-in through Warmbly Cloud from an instance that registered no address of its own when it was linked, so Cloud has nowhere to return the sign-in to. Set APP_URL on the instance, disconnect it and link it again |
pool_link_return_url | 400 | The same sign-in named a return address that is not on the instance's registered address (same scheme and host) |
pool_link_oauth_browser | 400 | The brokered sign-in was not continued from the Warmbly Cloud page that started it, or returned to a different browser. Nothing was connected. Start again from the instance |
cli_auth_scopes_role | 403 | Approving a CLI sign-in: your role in the chosen workspace allows none of the scopes the CLI asked for. Pick another workspace, or ask for narrower scopes with --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, or its sign-in is held by Warmbly Cloud. 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 instance panel, or the linked Warmbly Cloud workspace has used its free tests on Warmbly Cloud's panel. A tracking comparison needs two. On the instance 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 is cloud on an instance that is not linked to Warmbly Cloud |
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 [email protected], 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 Warmbly, 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 Warmbly'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 Warmbly 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 while that import is already running |
salesforce_rate_limited | 429 | The Salesforce org has used its API requests for today |
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. Add its exact HTTP(S) origin to CORS_ALLOW_ORIGINS, as in shared-backend dashboard configuration. A wildcard is not an OAuth return allowlist.
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 wmbly_...header - Verify your API key is correct
- Check if your key has expired or been revoked
- Generate a new key if necessary
Two 401 variants are not about API keys at all. setup_token_invalid is returned by the first-run claim endpoint when the setup link is invalid, already used or expired. Print a new one with warmblyctl setup-link, described in first run.
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
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
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 - An instance with its own Google app must set both
BOX_GOOGLE_CLIENT_IDandBOX_GOOGLE_CLIENT_SECRET. LeaveBOX_GOOGLE_OAUTH_CONNECTunset or set it totrue, then enable the dashboard visibility flag on the deployments that should offer Google sign-in
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 |
setup_already_complete | 403 | The first-run claim was attempted on an instance that already has an account |
{
"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: on a self-hosted deployment these are configuration, not faults. See accounts and access.
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 | The account has neither a password nor two-factor authentication, so there is nothing to confirm with. A sign-in counts as a confirmation for five minutes, so sign out and sign in again, then repeat the action; turning on two-factor authentication gives the account a way to confirm without signing in 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. Confirm with a password or a current two-factor code:
curl -X POST "https://api.warmbly.com/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) has nothing to confirm with, so like every confirmed action it is allowed for five minutes after a sign-in; 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"
}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 Warmbly 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 Warmbly 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 Warmbly 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 |
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 |
|---|---|---|
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 |
mailbox_cloud_unenroll_failed | 409 | The mailbox is linked to Warmbly Cloud and its link could not be released, so DELETE /emails/{id} would leave Warmbly Cloud holding its credential or its claim on it. The mailbox record remains, and restoration onto its worker is attempted |
mailbox_cloud_unenroll_failed is a self-hosted instance losing contact with Warmbly Cloud mid-delete. Deleting an enrolled mailbox has to revoke its enrollment before the record goes, because the pool holds the mailbox's own SMTP/IMAP credentials and that record is the only thing that knows the enrollment exists. The same call releases a cloud-managed mirror before the record goes, so the mailbox returns to the cloud workspace and can be adopted again instead of staying claimed by an instance that no longer keeps it. The mailbox record remains. Warmbly attempts to restore it onto its worker immediately, and the worker reconciler may restore it later if that attempt fails. Retry the delete once the instance can reach the cloud again. Unenrolling under Settings > Warmbly Cloud first does not help: it makes the same call.
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, because on a self-hosted instance the person reading it is often the one who can fix it:
{
"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"
}On a self-hosted deployment that means MAIL_TRANSPORT and the SMTP_ variables. See self-hosting.
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
mailbox_provider_not_configured
A 503 whose code is mailbox_provider_not_configured is not transient and retrying will not help. It means the deployment has no OAuth client for the mailbox provider the request asked for, which only happens on a self-hosted install.
{
"error": "Service Unavailable",
"message": "Gmail is not configured on this deployment. Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET in your .env, then restart.",
"code": "mailbox_provider_not_configured",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Set
BOX_GOOGLE_CLIENT_IDandBOX_GOOGLE_CLIENT_SECRET, orBOX_OUTLOOK_CLIENT_IDandBOX_OUTLOOK_CLIENT_SECRET, in the.envat your install root, then restart - Or connect the mailbox over SMTP and IMAP instead, which needs no configuration
- Full walkthrough: connect mailboxes
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:
- Set
AI_PROVIDERandAI_API_KEYin the.envat your install root, then restart. See the configuration reference - 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 instance is not set up for Slack, which only happens on a self-hosted install. 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 Warmbly instance.",
"code": "slack_not_configured",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Create the instance's Slack app and set
SLACK_OAUTH_CLIENT_ID,SLACK_OAUTH_CLIENT_SECRETandSLACK_SIGNING_SECRETon the backend and the consumer, then restart. See Slack app - 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. Warmbly 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 Warmbly'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. On the hosted product, retry and contact support if it persists. On a self-hosted instance, check that a worker is running, heartbeating, and reaching the same Redis as the backend: the check reaches the worker over Redis and its answer comes back the same way.
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: Warmbly 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 Warmbly 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 Warmbly 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 Warmbly 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 Warmbly 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 Warmbly 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 Warmbly 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": "Warmbly 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.