WarmblyDocs

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)

CodeErrorDescription
400Bad RequestInvalid request syntax or parameters, or a quota that would be passed (storage_limit_reached)
401UnauthorizedMissing or invalid authentication
402Payment RequiredOut of credits (insufficient_credits), or no free placement tests left this month (placement_quota_exceeded, placement_not_entitled)
403ForbiddenAuthenticated but lacks permission, or the workspace's mailbox allowance is full (mailbox_allowance_reached)
404Not FoundResource doesn't exist
409ConflictResource already exists
422UnprocessableValidation failed
429Too Many RequestsRate 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)

CodeErrorDescription
500Internal Server ErrorUnexpected server error
501Not ImplementedFeature not available
503Service UnavailableService 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 bodymessage
is emptyThe request body is empty. ...
is not valid JSONThe request body is not valid JSON: <reason> (at byte N).
is the wrong JSON type at the top levelThe request body must be a JSON array, not a JSON object.
has a field of the wrong JSON typeField "to" must be a JSON array, not a JSON string.
has a time that is not RFC 3339The 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 rangeThe 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:

codeMeaning
invalid_lead_statusPOST /contacts/search or POST /contacts/export was given a lead_status that is not one of the documented values
invalid_sort_byPOST /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_sortPUT /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_layoutPUT /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_hostPOST /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_engagementPOST /contacts/search or POST /contacts/export was given an engagement that is not one of the documented values
lead_filter_requires_campaignlead_status or engagement was set without exactly one campaign_ids entry; both filters describe a contact inside one campaign
unknown_verification_statusA contact's verification_status is not a value any known verification service writes
unknown_verification_providerA contact's verification_provider names a vocabulary the platform cannot read
invalid_actionPOST /contacts/verification was given an action other than verify, mark_deliverable or mark_undeliverable
no_contactsPOST /contacts/verification selected no contacts: neither contacts nor a campaign_id with refused leads
list_bounce_riskPOST /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_undeliverablePOST /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_bodyPOST /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_leadsPOST /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_leadsA 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_tasksPATCH /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_contactsA 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_largeA "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_filterA 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_settingPATCH /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_slugPATCH /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_folderPUT /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_organizationThe 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_limitSet a lead's CC was given more than 2 contacts
lead_cc_selfSet a lead's CC named the lead itself as a copy
invalid_recipientPOST /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_idPOST /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

codeStatusMeaning
password_breached400The password appears in a public list of breached passwords and was refused. Choose one that does not
sso_link_expired400The 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

codeStatusMeaning
invalid_name400A 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 as https:, no hostname such as example.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).

codeStatusMeaning
mailbox_import_empty400No file and no pasted text, or an input with no rows, or a header row and nothing under it
mailbox_import_too_large400More 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_column400POST /emails/imports with a mapping in which no column is email
mailbox_import_row_incomplete400PATCH /emails/imports/:id/rows/:line left the row without an SMTP and IMAP server or without a password. The message says which
invalid_cursor400GET /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_failed409Only 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_expired409The row's credentials were deleted 7 days after the import finished, so it cannot be retried. Import it again
mailbox_import_nothing_to_retry409POST /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).

codeStatusMeaning
mailbox_vendor_unauthorized400The vendor did not accept the API key. A saved connection is marked invalid until the key is updated
mailbox_vendor_no_workspace400The 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_fields400A field the vendor requires is empty, or a field holds control characters. GET /emails/vendors/catalog lists each vendor's fields
mailbox_vendor_unknown400vendor is not one of the supported vendors
mailbox_vendor_rate_limited429The vendor is rate limiting Warmbly's requests. Try again in a minute
mailbox_vendor_unavailable400The vendor answered with something unexpected, did not answer, or no longer has the mailbox. Try again shortly
mailbox_grant_not_configured400This 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_unauthorized400Google 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_missing400The 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_invalid400The 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_missing400The 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_unavailable503Google, 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_mismatch400The admin address is not on the domain entered, or the mailbox is on a domain the grant does not cover
mailbox_grant_mailbox_unreachable400Google or Microsoft has no mailbox for the account: it does not exist, is suspended or disabled, or has no Exchange Online license
mailbox_grant_inactive400The grant failed its last check, so it connects nothing. Check the grant (POST /emails/grants/:id/check), then retry
mailbox_vendor_domain_not_found404PUT /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_unsupported400The 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_delegated409POST /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_workspace404The workspace has no mailbox on this domain. Also 400 when no domain was given
sending_domain_shared_provider400The domain belongs to a shared email provider, such as gmail.com, and cannot be redirected
domain_redirect_invalid_target400target_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_taken409Another 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_linked409On Warmbly Cloud: this domain's redirect is served for a linked self-hosted instance, and only that instance changes it
domain_redirect_cloud_unavailable409served_by is cloud, but this instance is not linked to Warmbly Cloud, or Cloud does not serve redirects for it
domain_redirect_cloud_unreachable503Warmbly Cloud could not be reached while saving, moving or removing a redirect it serves, so nothing changed. Retry
domain_redirect_limit409Warmbly Cloud already serves 200 redirects for this linked instance
pool_link_redirect_not_found404On Warmbly Cloud's linked-instance API: Cloud serves no redirect for this domain for the calling instance
pool_link_cleartext_mailbox422Enrolling 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_url422Starting 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_url400The same sign-in named a return address that is not on the instance's registered address (same scheme and host)
pool_link_oauth_browser400The 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_role403Approving 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_invalid400A 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_taken409Another 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_configured400This 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.

codeStatusMeaning
app_password_invalid400app_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_signin409The 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.

codeStatusMeaning
placement_not_entitled402The workspace has no active trial or subscription, so it cannot start a test. Only the hosted product answers this
placement_quota_exceeded402The 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_running429The workspace already has 3 tests running, counting each half of a tracking comparison. Wait for one to finish
placement_sender_busy409The sending mailbox still has copies of another test waiting to be sent. One test sends from a mailbox at a time
placement_sender_unavailable409The sending mailbox is not connected and active, or it is itself a seed inbox, which receives tests and cannot send one
placement_daily_budget409The 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_seeds409The 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_unavailable409panel is cloud on an instance that is not linked to Warmbly Cloud
placement_invalid_seeds400seed_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_tracking400tracking is on or compare for a campaign that sends plain text, which carries no tracking
placement_not_running409POST /placement/tests/:id/cancel on a test that has already finished or been cancelled
placement_seed_unavailable409The 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_limit409The workspace already has 50 seed inboxes. Unmark one first
mailbox_is_seed409Warmup 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_empty400POST /placement/batches resolved to no sending mailbox: the scope, its filters and the sample left nothing
placement_batch_too_large400The 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_batches429The workspace already has 5 batches running. Wait for one to finish, or cancel one
placement_batch_not_running409POST /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.

codeStatusMeaning
invalid_listing400A 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_listable400The 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_blocked403The 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_website400website on POST or PATCH /oauth/applications is not an http or https address on a host without credentials
invalid_logo400logo_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_taken409Another app on this instance already uses the slug. Choose another
listing_hidden409The instance's operators hid this listing, so DELETE /oauth/applications/{id}/listing cannot remove it. The hide note is on the listing
app_suspended409The instance's operators suspended this app. It cannot be edited or given a new logo until it is unsuspended

Credential management refusals

codeStatusMeaning
oauth_token_not_allowed403An 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_caller403POST /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_left401The 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_limited403An 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.

codeStatusMeaning
crm_not_connected409GET /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_required409The 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_externally409A 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_unknown400The 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_unmapped400A 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_missing404The contact is not in the provider, and creating contacts there is turned off in the workspace's CRM settings
crm_record_missing404The record no longer exists in the provider; it was deleted there
crm_provider_rejected422The 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_running429POST /crm/sync within 30 seconds (HubSpot) or a minute (Pipedrive) of the last one. The running sync finishes in a minute or two
crm_unavailable503The 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'.

codeStatusMeaning
invalid_salesforce_domain400The custom domain given to POST /integrations/oauth/start is not a Salesforce My Domain (*.my.salesforce.com)
invalid_salesforce_settings400PUT /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_error400Salesforce refused the request. The message is Salesforce's own
salesforce_unreachable400Salesforce did not answer. Retry later
salesforce_sync_off400POST /integrations/salesforce/:id/sync-now on a connection with sync turned off
salesforce_reconnect_required409The Salesforce session ended (a revoked app, a password reset, a deactivated user). Reconnect the integration
import_running409POST /integrations/salesforce/:id/import-sources/:sourceId/run while that import is already running
salesforce_rate_limited429The Salesforce org has used its API requests for today

Automation refusals

codeStatusMeaning
action_provider_mismatch400POST /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 Authorization header
  • 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 429 with code usage_cap_exceeded means 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-imap with smtp.gmail.com:465 and imap.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_ID and BOX_GOOGLE_CLIENT_SECRET. Leave BOX_GOOGLE_OAUTH_CONNECT unset or set it to true, 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.

codeStatusMeaning
registration_invite_only403The server runs DISABLE_REGISTRATION=invite_only. Creating an account requires an invitation link, which carries the token that permits the signup
registration_closed403The server runs DISABLE_REGISTRATION=true. Signups are off and invitations do not override it
invitation_invalid403The invitation is expired, cancelled, already used, or was issued for a different email address
setup_already_complete403The 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

codeStatusMeaning
reauth_required403The action needs a proof of identity newer than the session. Confirm with POST /v1/auth/reauth, then retry
reauth_limited400Too 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_factor400The 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_required403An 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_required400A 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_code400The 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_again409POST /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"
}

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_settings reconnects Slack under Integrations > Slack so the connection grants users:read and users: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:

codeMeaning
unknown_view/me/views/:view was given a view name other than contacts, campaign_leads or unibox_rail
lead_cc_contact_not_foundSet a lead's CC named a contact that is not in the workspace
slack_not_connectedA Slack route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under Integrations > Slack
slack_link_invalidThe Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button
slack_verify_failedPOST /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_accountPOST /integrations/slack/link after Sign in with Slack signed in a different Slack account than the one the link code names
slack_verify_unavailablePOST /integrations/slack/link/verify on an instance whose Slack app has no client credentials
slack_not_linkedPATCH /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:

codeStatusMeaning
contact_email_taken409The address given to update a contact already belongs to another contact
lead_cc_lead_is_copied409Set 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_copies409Set a lead's CC names a contact that has copies of their own in the campaign
mailbox_is_seed409Warmup was started or resumed on a placement seed inbox. See placement test refusals
mailbox_not_google_signin409POST /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_pending409POST /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_failed409The 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_ID and BOX_GOOGLE_CLIENT_SECRET, or BOX_OUTLOOK_CLIENT_ID and BOX_OUTLOOK_CLIENT_SECRET, in the .env at 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_PROVIDER and AI_API_KEY in the .env at 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_SECRET and SLACK_SIGNING_SECRET on the backend and the consumer, then restart. See Slack app
  • In a client, read app_configured and interactive_configured from GET /integrations/slack/status and 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/allowance first: remaining says how many connects will succeed, and pending_request whether an increase is already asked for
  • Submit a limit-increase request for max_email_accounts via POST /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/limits reports storage.used_bytes and storage.limit_bytes, and storage.over_quota when 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: tls for 465 and 993, starttls for 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: tls for 465 and 993, starttls for 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 4xx reply, 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 LOGIN or PLAIN; 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}/identity first. Its supported field is false for these mailboxes, and identities is empty
  • Send from the mailbox's own address, which is what an empty send_as_email means

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/refresh so Warmbly re-reads the list, then set send_as_email to an address whose verified is true

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's status; an inactive mailbox 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.

See also

On this page