Error codes
Reference for all API error codes and their meanings.
The SenditCold 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. A few codes also carry an optional reason, a finer key for when the same code has more than one next step; each is listed with its code, and a client should treat an unknown or missing reason as the plain code.
HTTP status codes
Send-admission diagnostics
These codes appear in campaign activity and task failure metadata, not as HTTP response codes:
| Code | Meaning |
|---|---|
CAMPAIGN_DAILY_LIMIT_REACHED | This campaign has reached its configured UTC-day maximum for this sending mailbox. Other mailboxes have separate campaign allowances. |
MAILBOX_SENDING_PLAN_DENIED | The mailbox's enabled local-day sending plan is unavailable, outside working hours or on a break, or its daily/hourly quota is exhausted. The scheduler retries when capacity opens. |
MANUAL_SEND_LIMIT_REACHED | A unibox send (reply, forward or compose, including POST /unibox/* and POST /emails/:id/send) would exceed the mailbox's fair-use allowance: recipients on one email, in an hour, in 24 hours, in new conversations over 24 hours, or, across all the workspace's mailboxes, the 24-hour allowance its plan gives. The failure message names which. Unibox sends never count toward campaign or warmup limits. See sending controls. |
RECIPIENT_SUPPRESSED | A recipient is on the workspace suppression list, directly or through its domain. The failure message names the address. A unibox forward is never refused for this. |
SEND_ADMISSION_DENIED | Another current send-authority, recipient or mailbox capacity check denied admission. |
Admission denial before dispatch releases the send reservation without spending a failed-send attempt. A refused unibox send ends with status failed, is not retried, and is reported to the mailbox owner. See campaigns and sending behavior for the separate configurable ceilings and daily targets.
Client errors (4xx)
| Code | Error | Description |
|---|---|---|
| 400 | Bad Request | Invalid request syntax or parameters, or a quota that would be passed (storage_limit_reached) |
| 401 | Unauthorized | Missing or invalid authentication |
| 402 | Payment Required | The workspace balance is too low (insufficient_credits), a card was not charged when adding money (card_declined, authentication_required, payment_failed), or no free placement tests left this month (placement_quota_exceeded, placement_not_entitled) |
| 403 | Forbidden | Authenticated but lacks permission, or the workspace's mailbox allowance is full (mailbox_allowance_reached) |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists |
| 422 | Unprocessable | Validation failed |
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (rate_limit_exceeded, usage_cap_exceeded), or too many placement tests or batches running at once (placement_too_many_running, placement_too_many_batches) |
Server errors (5xx)
| Code | Error | Description |
|---|---|---|
| 500 | Internal Server Error | Unexpected server error |
| 501 | Not Implemented | Feature not available |
| 503 | Service Unavailable | Service temporarily down |
Error details
400 Bad Request
Returned when the request cannot be processed due to invalid syntax.
Common causes:
- Invalid JSON in request body
- Missing required fields
- Invalid field types
- Values outside allowed ranges
Example:
{
"error": "Bad Request",
"message": "The request body is missing or has invalid fields: \"to\" is required; \"subject\" is required.",
"code": "bad_request",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}When the body cannot be read, the message says why:
| The body | message |
|---|---|
| is empty | The request body is empty. ... |
| is not valid JSON | The request body is not valid JSON: <reason> (at byte N). |
| is the wrong JSON type at the top level | The request body must be a JSON array, not a JSON object. |
| has a field of the wrong JSON type | Field "to" must be a JSON array, not a JSON string. |
| has a time that is not RFC 3339 | The request body has a time that is not RFC 3339 (such as 2026-10-01T09:00:00Z): "tomorrow". |
| has a text field containing a NUL character | Field "<name>" contains a NUL character, which is not supported. |
| is missing required fields or has values out of range | The request body is missing or has invalid fields: "to" is required; ... (up to five fields, then a count of the rest) |
Fields are named by their JSON key, with nested fields as a dotted path (inner.name). The code is bad_request in every case, so branch on code and show or log message.
Versioned API routes check every query parameter name and value before the endpoint runs, including endpoints that read filters directly. The legacy /invitations/lookup alias applies the same validation. NUL characters, invalid UTF-8 and malformed query encoding return 400 rather than sending unsupported text to the database. For example, ?q=%00 returns Query parameter "q[0]" contains a NUL character, which is not supported. Valid Unicode text and properly encoded punctuation remain unchanged.
Every route takes each query parameter at most once. A parameter repeated in the query string returns 400 with code: "duplicate_query_parameter" and a request_id, whatever the values, and the message names it: The query parameter "status" appears more than once. Send it once per request. The parameters documented as lists accept repeats: status on GET /v1/advisor/recommendations, and campaign_ids and folder_ids on GET /v1/analytics/dashboard. Send any other list as one comma-separated value where the endpoint documents one.
A request carrying more than one Authorization, X-Warmbly-Token, Idempotency-Key or Origin header line returns 400 with code: "duplicate_header", and the message names the header. Send each of them once. Repeated X-Forwarded-For lines are read as one list in arrival order, and for X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and X-Real-IP the last line is used, the one the nearest proxy wrote. Forwarded headers are believed only from TRUSTED_PROXIES.
The public forms service answers its /api/forms/ routes with error and code holding the same slug, and a message to show:
code | Status | Meaning |
|---|---|---|
duplicate_query_parameter, duplicate_header | 400 | A query parameter or the X-Warmbly-Render header was sent more than once. The message names it |
stale_page | 403 | The page was open long enough for its submission token to lapse. Reload it and submit again |
not_found | 404 | The form does not exist or is not published, or no forms API route matches the path |
unavailable | 503 | The forms service could not reach the backend. Try again in a few minutes |
form_submit_failed | 400, 413, 502 | The submission was refused (the message names the field or rule), was too large, or could not be passed on. Fix and submit again, or retry a 502 |
{
"error": "stale_page",
"message": "This page has been open too long. Reload it and try again.",
"code": "stale_page"
}Resource IDs on email, campaign and contact routes must be UUIDs. Email tags and campaign email_tags and folders arrays also require UUID values. Malformed values return 400 with code: "bad_request" and a request_id before a database write. Valid UUIDs are normalized and repeated relation IDs are deduplicated; workspace ownership checks still apply.
How to fix:
- Check that your JSON is valid
- Verify all required fields are present
- Ensure field values match expected types
- Compare the body's shape with the endpoint's reference page: some endpoints take an array, most take an object
Specific 400 codes:
code | Meaning |
|---|---|
duplicate_query_parameter | A query parameter appeared more than once on a route that reads it as a single value |
duplicate_header | Authorization, X-Warmbly-Token, Idempotency-Key or Origin was sent on more than one header line |
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 |
invalid_template | A step, A/B variant or new campaign was written (or a campaign started) with a subject, HTML body or plain-text body that would not render as written: an {{if}} with no {{end}}, an unknown function, an unknown .Sender field, or a bare name such as {{frist_name}} that is not a built-in field. The message names the field, the line and the problem. Fix the copy and send it again; see personalization |
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 list filter could not be read. 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. GET /unibox sends it for a campaign_id or contact_id that is not a UUID, a since or until that is neither a YYYY-MM-DD date nor an RFC 3339 timestamp; the message names the parameter |
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_filter_tag | PUT /emails/:id/warmup-filter-tag was given a tag shorter than 6 or longer than 40 characters, one using anything but letters, digits, spaces, hyphens and underscores, or one that calls the mail warmup, automated, generated or a test. Leave tag out to get a random 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 |
invalid_tls_pin | An SMTP/IMAP connect or reconnect carried a tls_pin that is not a SHA-256 fingerprint (64 hexadecimal characters; colons, spaces and a sha256: prefix are ignored), or POST /emails/onboarding/smtp-imap/:id/certificates/confirm was missing the fingerprint of a server the mailbox trusts on its own certificate |
mailbox_certificate_not_pinned | POST /emails/onboarding/smtp-imap/:id/certificates/confirm on a mailbox whose servers are both verified by a certificate authority, so there is no certificate to confirm |
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 |
recipient_suppressed | POST /emails/:id/send, POST /unibox/reply or POST /unibox/compose named a to, cc or bcc address on the workspace suppression list, directly or through its domain. The message names the address and the entry. Nothing was queued. A forward (POST /unibox/reply with forward_message_id) is not checked against the list |
invalid_message_id | POST /emails/:id/send or POST /unibox/reply was given an in_reply_to entry that is not one Message-ID: printable characters with no spaces or line breaks, with or without the surrounding < and > |
invalid_draft | PUT /unibox/ai-drafts/by-thread was given a missing or oversized thread_id, a body that is empty or longer than 8000 characters, a source that is empty, longer than 40 characters or not letters, digits, dots, dashes and underscores, an intent_class longer than 40 characters, or a confidence outside 0 to 1 |
thread_not_found | PUT /unibox/ai-drafts/by-thread named a thread with no inbound message in this workspace |
already_sent | PUT /unibox/ai-drafts/by-thread found an approved draft that already answers the newest inbound message on the thread. Nothing was changed |
draft_discarded | PUT /unibox/ai-drafts/by-thread found a discarded draft that already covers the newest inbound message on the thread. Nothing was changed |
Image upload refusals
Avatars, OAuth app logos, form logos, covers and backgrounds, and images placed in an email are checked by what the file contains, not by its name or the type the browser sends.
code | Status | Meaning |
|---|---|---|
image_unreadable | 400 | The file is empty or does not decode as an image: it is damaged, or it is a different format than its name suggests |
image_format_not_allowed | 400 | The file is an image, but not a format this upload takes |
image_too_large | 400 | The file is over the size limit |
image_dimensions_too_large | 400 | The image is wider or taller than the limit |
image_too_small | 400 | An OAuth app logo is under 32px on a side |
| Upload | Formats | Size | Each side |
|---|---|---|---|
| Avatar, OAuth app logo | PNG or JPG | 2 MB | 1024px |
| Form logo | PNG or JPG | 1 MB | 1024px |
| Form cover or background | PNG or JPG | 4 MB | 2560px |
| Email image | PNG, JPG, GIF or WebP | 5 MB | 4000px |
{
"error": "Bad Request",
"message": "The avatar could not be read as an image. The file looks damaged, or it is a different format than its name suggests. Upload a PNG or JPG file up to 2 MB and 1024px on each side.",
"code": "image_unreadable",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}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 |
password_reused | 400 | POST /auth/login/initial-password was given the issued initial password itself. Choose a password of your own |
password_change_expired | 400 | The pending token from a password_change_required sign-in is unknown, expired (ten minutes) or already used, or the issued password it was for has run out. Sign in again; an issued password past its 48 hours is refused like a wrong password, so ask whoever issued it for a new one |
initial_password_web_only | 403 | POST /auth/login was given a valid issued initial password by a SenditCold iOS app build that cannot choose a new password in the app. Nothing was opened. Finish the first sign-in on the web, where you choose your own password, then sign in to the app with it, or update the app. Clients that send supports_password_change: true, browsers and API clients get the password_change_required step instead |
password_change_required | 403 | POST /auth/sso/link was given an issued initial password. Sign in with email and password first, which asks you to choose your own, then link the provider |
reset_link_invalid | 401 | POST /auth/reset-password/session or /confirm was given a reset link or reset session that expired, was already used, or was issued before the password last changed. Request a new link |
api_credentials_revoke_unsupported | 400 | POST /auth/reset-password/confirm or POST /auth/me/password asked to end connected apps and API keys (revoke_api_credentials) on a server that cannot. The password was not changed. Send it again without that option, then remove apps and keys from Settings |
sessions_not_ended | 503 | The same two routes could not sign out every device on the account, so the password was not changed. From DELETE /auth/me/identities/:provider it means the provider was removed but not every device that signed in with it was signed out; repeating the request finishes it. The response carries Retry-After; retry after it |
identity_not_found | 404 | DELETE /auth/me/identities/:provider named something other than google, apple or oidc |
last_sign_in_method | 409 | DELETE /auth/me/identities/:provider or DELETE /auth/passkey/credentials/:id would leave the account with no password, no passkey and no other provider. Nothing was removed. Set a password (POST /auth/me/password/link) or add a passkey first |
password_login_disabled | 403 | POST /auth/me/password/link was called on a server that turned password sign-in off (DISABLE_PASSWORD_LOGIN), so a password could never be used. No link was sent |
A password must be 8 to 128 characters. There is no composition rule, but it is checked against the 100,000 most commonly breached passwords published by the UK National Cyber Security Centre, case-insensitively.
{
"error": "Bad Request",
"message": "This password appears in a public list of breached passwords. Choose one that does not.",
"code": "password_breached",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}App Attest refusals
The native app proves itself with App Attest in place of the captcha on sign-in, registration and password reset.
code | Status | Meaning |
|---|---|---|
app_attest_failed | 400 | The proof did not verify: the challenge expired or was already used, the proof came from another app or from a development build the server does not accept, the assertion's counter did not advance, the key id was already registered, or the object was malformed. Fetch a fresh challenge and make a new proof; if it keeps failing, update the app |
app_attest_key_unknown | 400 | The key_id was never registered on this server. Register the key again with POST /auth/app-attest/keys, then retry the call |
app_attest_unavailable | 404 | POST /auth/app-attest/challenge or /keys on an instance with App Attest off. When GET /auth/config reports a captcha without app_attest, email and password sign-in is unavailable in the app; Sign in with Apple, Google and passkeys still work |
Each registered key may make 30 protected calls per hour. Past that the call answers 400 with code bad_request and the message Too many attempts, please try again later., the same answer as the other sign-in attempt budgets.
{
"error": "Bad Request",
"message": "We couldn’t verify this copy of the app. Update it from the App Store and try again.",
"code": "app_attest_failed",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Account deletion refusals
Scheduling an account deletion is refused with one of these codes, or with organization_cloud_connected (see Cloud connection refusals) when a workspace it owns is connected to SenditCold Cloud. The same checks run again when the 30-day grace period ends: if any still applies, the deletion stays pending, is checked again a day later, and the account is kept until it completes.
code | Status | Meaning |
|---|---|---|
account_has_shared_work | 409 | The account still holds mailboxes, contacts, campaigns, campaign attachments, reply templates, API keys, apps or shared assistant conversations in a workspace it does not own. Remove them before scheduling deletion of the account |
workspace_subscription_active | 409 | A workspace the account owns has a subscription that will renew. Cancel it before scheduling deletion of the account |
agency_subscription_active | 409 | An agency the account owns has a plan that will renew. Cancel it under Agency > Billing before scheduling deletion of the account |
Partnership refusals
The Partnership plan routes under /emails/connect and /emails/oauth-apps, its sign-in links, and Google and Microsoft sign-in on that plan.
code | Status | Meaning |
|---|---|---|
partnership_plan_required | 403 | The workspace is not on the Partnership plan, so the mailbox connect API, sign-in links and its own OAuth apps are not available |
partnership_oauth_app_required | 422 | On the Partnership plan, Google and Microsoft mailboxes sign in only through the workspace's own OAuth app, and it has none for this provider. Also returned for inbox-vendor sign-in and domain-wide grants, which only SenditCold's own apps can serve |
connect_grant_app_required | 422 | A handed-over sign-in (POST /emails/connect/oauth) needs the workspace's own OAuth app for that provider saved first |
connect_grant_input | 400 | Send either refresh_token, or code with the redirect_uri it was issued for |
connect_grant_redirect_uri | 400 | redirect_uri must be the https callback the code was issued for |
connect_grant_refused | 422 | The provider refused the token or code: revoked, expired, already used, issued for another redirect_uri, or from a different app than the one saved |
connect_grant_offline | 422 | The sign-in carries no refresh token. Ask for offline access (access_type=offline and prompt=consent on Google, offline_access on Microsoft) |
connect_grant_wrong_mailbox | 403 | The sign-in belongs to a different address than the email sent with it |
connect_grant_mailbox_not_allowed | 403 | The sign-in would renew a mailbox outside the mailboxes this API key may act on |
connect_link_provider | 400 | provider must be gmail or outlook |
connect_link_email | 400 | email must be a single bare address |
connect_link_return_url | 400 | return_url must be an https address with no fragment |
connect_link_expiry | 400 | expires_in_hours must be between 1 and 168 |
connect_warmup | 400 | A warmup value is not positive, or reply_rate is outside 1 to 100 |
connect_link_not_found | 404 | The workspace has no sign-in link with that id |
connect_link_unavailable | 404 | The sign-in link expired, was revoked or already connected its mailbox |
connect_link_browser | 403 | The sign-in was finished in a different browser from the one that opened the link. Open the link again |
connect_link_wrong_mailbox | 403 | The owner signed in with a different address than the one the link names |
connect_link_declined | 400 | The owner cancelled the provider's consent screen. The link stays usable |
connect_link_creator_left | 403 | The member whose key created the link can no longer manage mailboxes in the workspace |
connect_warmup_failed | none | Set as error_code on a connected link: the mailbox connected, but its warmup did not start. Start it with POST /emails/:id/warmup/start |
connect_warmup_settings_failed | none | Set as error_code on a connected link: warmup started on the default ramp because the link's ramp settings were refused. Apply them with PATCH /emails/:id |
oauth_app_provider | 400 | The provider in the path must be gmail or outlook |
oauth_app_client_id | 400 | The client ID does not fit the provider: Google client IDs end in .apps.googleusercontent.com, Microsoft ones are a GUID |
oauth_app_client_secret | 400 | A new app, or a new client ID, needs its client secret |
oauth_app_redirect_url | 400 | redirect_url must be an https address with a path and no fragment |
oauth_app_rejected | 422 | The provider did not accept the client ID and secret |
oauth_app_in_use | 409 | Mailboxes are signed in through the app, so its client ID cannot change and it cannot be removed. A new secret for the same client ID can be saved at any time |
oauth_app_not_found | 404 | The workspace has no OAuth app for that provider |
oauth_app_secret_missing | 422 | The app has no client secret, which happens after a workspace import without credentials. Save it again |
Cloud connection refusals
code | Status | Meaning |
|---|---|---|
cloud_link_workspace_required | 409 | Disconnecting a shared server connection requires explicit confirmation of the server-wide scope; it affects all local workspaces using that connection |
cloud_link_upgrade_required | 409 | This Cloud deployment does not support workspace-scoped connections. Upgrade Cloud first |
organization_cloud_connected | 409 | Disconnect the workspace's Cloud connection before deleting it or scheduling deletion of it or its owner account |
cloud_link_connected | 409 | The workspace is already connected to SenditCold Cloud. SenditCold Cloud links no new instances, so a disconnected workspace cannot link another Cloud workspace |
cloud_link_not_connected | 409 | The workspace is not connected to SenditCold Cloud, so there is nothing to change or disconnect |
cloud_link_no_key | 409 | The instance has no CREDENTIALS_ENCRYPTION_KEY, so it cannot store the SenditCold Cloud token. Set one and restart the backend before connecting |
cloud_link_url | 400 | The Cloud URL does not start with https:// |
cloud_link_no_pending | 404 | No connection is in progress for this workspace. Start the connection again |
cloud_link_code_expired | 404 | The connection code expired before it was approved in SenditCold Cloud. Start again |
cloud_link_mailbox_inactive | 422 | Only active mailboxes can be enrolled in SenditCold Cloud warmup |
cloud_link_oauth_mailbox | 422 | A Google or Microsoft mailbox connected with this instance's own OAuth app cannot be warmed by SenditCold Cloud, because its refresh grant is bound to that app. The message says how to connect it instead |
cloud_link_lifecycle | 400 | A pause or resume request named an action other than pause or resume |
cloud_link_executor | 400 | An enrollment named an executor other than cloud. SenditCold Cloud warms only mailboxes whose credential it holds |
cloud_link_credential_required | 409 | The mailbox's credential stayed on this server, so SenditCold Cloud keeps it out of the pool. Enroll it with executor cloud to hand the credential over; a Google or Microsoft mailbox is added again through SenditCold Cloud sign-in |
SenditCold Cloud pool link
These come from SenditCold Cloud's side of the connection (/v1/pool-link/*), which a self-hosted instance calls on your behalf. The instance passes the code and message through, so they can reach you from the instance's own settings and mailbox screens too.
code | Status | Meaning |
|---|---|---|
pool_link_request | 400 | The connection request is missing the instance name |
pool_link_credential | 400 | Enrolling a mailbox needs a credential that matches the mailbox's provider |
pool_link_code_not_found | 404 | The connection code is unknown or has expired. Start the connection again from the instance |
pool_link_code_used | 409 | The connection code has already been used |
pool_link_denied | 403 | The connection was declined in SenditCold Cloud |
pool_link_revoked | 401 | The instance's link has been revoked. SenditCold Cloud links no new instances, so it cannot be linked again |
pool_link_instance_not_found | 404 | The linked instance no longer exists in SenditCold Cloud |
pool_link_mailbox_not_found | 404 | The mailbox is not enrolled in SenditCold Cloud |
pool_link_closed | 403 | SenditCold Cloud no longer links new self-hosted instances, on any plan. Returned when an instance asks for a connection code and when a code is approved on app.senditcold.com/connect, for every workspace. Existing connections keep working. Use a hosted workspace on app.senditcold.com for the hosted warmup pool; see SenditCold Cloud |
pool_link_subscription_required | 402 | The SenditCold Cloud workspace an instance is already linked to is free. A free workspace linked earlier keeps its enrolled mailboxes warming, but adding one (an SMTP/IMAP enrollment, a Google or Microsoft sign-in through SenditCold Cloud, or connecting an existing Cloud mailbox) is refused until that linked workspace moves to a paid plan, which lifts the restriction without reconnecting. Keep warming the mailbox on the instance, or choose a plan for the linked workspace |
warmup_plan_retired | 409 | POST /pool-link/checkout was called, but the Warmup plan is no longer sold. Choose a plan on the billing page. Workspaces already on it keep it while they stay subscribed |
pool_link_already_adopted | 409 | The mailbox is already linked to an instance |
pool_link_not_adoptable | 422 | Only active Google and Microsoft mailboxes in the Cloud workspace can be linked to an instance |
pool_link_not_managed | 403 | The mailbox's credential is held by the instance, not by SenditCold Cloud, so Cloud cannot act on it |
pool_link_mailbox_blocked | 403 | SenditCold Cloud blocked the mailbox for hurting the pool. Sending from it is suspended until it is reviewed |
pool_link_mailbox_inactive | 403 | The mailbox is not active in SenditCold Cloud. Reconnect it to keep sending |
pool_link_warmup_unavailable | 403 | Warmup is not available on the Cloud workspace |
pool_link_oauth_pending | 409 | A mailbox sign-in started through SenditCold Cloud has not completed yet. Poll again |
pool_link_oauth_session | 404 | The mailbox sign-in session is unknown or has expired. Start the sign-in again |
pool_link_executor | 400 | On SenditCold Cloud's linked-instance API: an enrollment named an executor other than cloud. Cloud runs every mailbox it warms |
pool_link_executor_conflict | 409 | On SenditCold Cloud's linked-instance API: a credential update was sent for a mailbox whose credential stayed on the instance. Enroll the mailbox again with its credential to hand it to Cloud |
CLI sign-in
warmbly auth login signs the CLI in with a one-time code (POST /auth/cli/code, POST /auth/cli/poll, and the dashboard's approval page). See the CLI.
code | Status | Meaning |
|---|---|---|
cli_auth_request | 400 | POST /auth/cli/poll was sent without a device_code |
cli_auth_scopes | 400 | The CLI asked for scopes that include permission bits this instance does not grant |
cli_auth_code_not_found | 404 | The code is unknown or has expired. Run warmbly auth login again for a fresh one |
cli_auth_code_used | 409 | The code has already been used |
cli_auth_forbidden | 403 | Approving a CLI sign-in needs the manage_api_keys permission in the workspace, because the approval mints an API key |
SenditCold Cloud warmup refusals
A mailbox warmed by SenditCold Cloud has its warmup settings changed through the cloud (see warmup settings). PATCH /emails/:id and the warmup start, pause and resume routes answer with these when that cannot happen. In every case the rest of the patch is not applied on the instance. After cloud_warmup_not_applied, the mailbox's copy of its SenditCold Cloud settings is still refreshed to what SenditCold Cloud reported. A replay with the same Idempotency-Key returns the stored answer, so retry with a new key.
code | Status | Meaning |
|---|---|---|
cloud_warmup_value_unsupported | 422 | SenditCold Cloud does not take 0 from an instance for the starting volume, maximum, daily increase or reply rate. Use 1 or more, or change it in SenditCold Cloud |
cloud_warmup_not_applied | 409 | SenditCold Cloud answered but did not apply the named settings. The mailbox now shows the values SenditCold Cloud reported |
cloud_warmup_managed | 409 | Participation, the shared daily and recipient limits, and stopping warmup are changed with the mailbox's SenditCold Cloud controls, not on the mailbox |
cloud_link_disconnecting | 409 | The workspace is disconnecting from SenditCold Cloud. Retry once it has finished |
cloud_link_unreachable | 503 | SenditCold Cloud could not be reached |
cloud_link_bad_response | 503 | SenditCold Cloud answered without the mailbox's warmup settings |
Name refusals
code | Status | Meaning |
|---|---|---|
invalid_name | 400 | A first name, last name, workspace name, company name, client report name, brand 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, company or client report name is at most 64 characters and contains a letter or a number; a mailbox display name keeps its own 2 to 100 character bound
- no links, web addresses or email addresses: nothing with
@,//,www.or a scheme such ashttps:, no hostname such asexample.com, and no IP address. Full-width and ideographic dots count as dots - no control characters, invisible formatting characters (zero-width and bidirectional overrides),
<,>,`or\, and no more than three stacked combining marks - leading and trailing spaces are trimmed and runs of spaces become one, and that is the form stored
- an OAuth app name cannot include SenditCold, spaced out or spelled with look-alike characters included. An app that already had such a name keeps it until the name is changed
{
"error": "Bad Request",
"message": "First name cannot contain a link, web address or email address.",
"code": "invalid_name",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}A name from Google, Apple or a single sign-on provider that breaks these rules is dropped rather than refusing the sign-in, and you are asked for it during onboarding.
Redirect destination refusals
code | Status | Meaning |
|---|---|---|
redirect_target_not_own_domain | 400 | A form's redirect_url, a sending domain's redirect target_url, or a vendor forwarding url is not on one of the workspace's own domains. Bulk setup answers it on the refused domain's row |
A redirect may only send visitors to a page on one of the workspace's own domains, or a subdomain of one:
- a domain the workspace has a mailbox on, other than a shared email provider such as
gmail.com - a verified tracking host of one of its mailboxes
- the workspace's custom forms domain, once it is verified
- a website the workspace confirmed by publishing its
_warmblyTXTrecord on the website's registrable domain. Saving looks for the record before refusing, andPOST /emails/domains/:domain/redirect/websitereturns the record to publish. See confirm your website
Hosts are compared in lowercase, without a trailing dot and in their ASCII form, so bücher.de and xn--bcher-kva.de are the same domain. A subdomain matches its parent (shop.acme.com is on acme.com), but a name that only starts or ends the same way does not (acme.com.example.net and notacme.com are not on acme.com). An empty value still means no redirect.
{
"error": "Bad Request",
"message": "The redirect has to go to a page on one of this workspace's own domains: a domain it has a mailbox on (not a shared provider such as gmail.com), its verified tracking domain, its verified forms domain, a website it verified with its TXT record, or a subdomain of one of them. Verify the website under Sending domains > Redirect, or use an address on one of those.",
"code": "redirect_target_not_own_domain",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}A destination saved earlier that no longer passes, because the workspace removed its last mailbox on that domain for example, is kept but not used: see the forms guide and root redirects.
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 |
google_app_password_required | 400 | PATCH /emails/imports/:id/rows/:line or POST /emails/imports/:id/retry gave a Google row a password that is not a 16-letter app password. Google refuses the account password over IMAP and SMTP. A retry refuses the whole batch and queues none of it |
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 or is waiting for sign-in can be fixed; this one is queued, running, connected, updated, skipped, cancelled, or still being authorized by its inbox vendor |
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 SenditCold'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_vendor_reconnect_unavailable | 400 | POST /emails/vendors/:id/reconnect on an instance without Google sign-in for new mailboxes (gmail_oauth_connect), so no vendor can sign in for SenditCold |
mailbox_vendor_reconnect_unsupported | 400 | POST /emails/vendors/:id/reconnect for a vendor that cannot sign in to mailboxes through its API. InboxKit and Zapmail can |
mailbox_vendor_reconnect_none | 404 | POST /emails/vendors/:id/reconnect found no Google mailbox imported from this vendor account, or email_account_id names a mailbox that is not one. Also answers 400 when one request names more than 1,000 mailboxes |
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 SenditCold, 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 SenditCold Cloud serve it. The redirect is saved but does not verify. On SenditCold Cloud's linked-instance API, the linked workspace already has its own redirect for the domain |
domain_redirect_linked | 409 | On SenditCold 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 SenditCold Cloud, or Cloud does not serve redirects for it |
domain_redirect_cloud_unreachable | 503 | SenditCold Cloud could not be reached while saving, moving or removing a redirect it serves, so nothing changed. Retry |
domain_redirect_limit | 409 | SenditCold Cloud already serves 200 redirects for this linked instance |
pool_link_redirect_not_found | 404 | On SenditCold 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 SenditCold 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_mailbox_conflict | 409 | Enrollment found the address already connected in the linked Cloud workspace and could not attach it. Automatic SMTP/IMAP association requires an active SMTP/IMAP Cloud mailbox, exactly matching credentials, no active Cloud campaign using it and no other instance enrollment. For an existing Google or Microsoft OAuth mailbox, or a delegated Google Workspace mailbox, an instance administrator uses Add account > In your SenditCold Cloud workspace > Connect instead. This explicit adoption keeps Cloud history and can switch an existing local app-password mailbox in place. Nothing is attached by address alone. Contact support for other associations; do not delete or recreate the Cloud mailbox |
pool_link_not_adoptable | 422 | The selected Cloud mailbox is not active in the linked workspace, is not Google or Microsoft, or uses a Microsoft whole-organization grant. Google Workspace delegated mailboxes and per-mailbox Google or Microsoft OAuth mailboxes can be adopted |
pool_link_instance_url | 422 | Starting a Google or Microsoft sign-in through SenditCold 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. Linking again is no longer possible, so connect the mailbox with the instance's own OAuth app or an app password |
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 SenditCold Cloud page that started it, or returned to a different browser. Nothing was connected. Start again from the instance |
cloud_link_managed_protocol | 409 | Cloud does not advertise durable managed-consent protocol 1. Upgrade all Cloud control-plane replicas before opening a new managed connection or adoption. Existing mailboxes are unchanged |
cloud_link_smtp_association_protocol | 409 | Cloud does not advertise smtp_association_protocol: 1. Update all Cloud replicas before connecting existing app-password copies. No local association intent or credentials have changed |
cloud_link_smtp_association | 409 | The explicitly selected existing Cloud SMTP/IMAP mailbox could not be associated. Check the linked workspace and whether an active Cloud campaign or another instance uses that mailbox. Do not delete it or replace passwords to repair the association |
pool_link_oauth_unknown | 409 | The provider exchange has no confirmed committed mailbox. Do not retry the authorization code or infer success; remove the pending connection before starting new explicit consent |
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 |
forms_domain_taken | 409 | Another workspace on this instance has already verified this domain, as its forms domain, a tracking domain or a redirect, or an agency holds it as its portal domain. Returned when saving or verifying the workspace's custom forms domain. Nothing changes |
lead_sync_target_not_found | 400 | Creating or editing a lead sync source named a target campaign or a category that does not exist in this workspace. Nothing is saved |
tracking_domain_taken | 409 | Another workspace on this instance has already verified this tracking domain, on a mailbox, as a campaign's override, or as its forms domain. 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 a Google mailbox on its own Google sign-in or an admin grant: it is already on an app password, is an Outlook or SMTP/IMAP mailbox, or its sign-in is held by SenditCold 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 SenditCold Cloud workspace has used its free tests on SenditCold Cloud's panel. A tracking comparison needs two. On the instance panel of a paid workspace the message names the price in dollars: send max_usd of at least that much to pay it from the balance (max_credits, the legacy form, is read only when max_usd is absent). 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 SenditCold 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 spend 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 with no mailbox tags. 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. 400 invalid_request from POST /oauth/token or POST /oauth/revoke includes a form that sends any parameter more than once (RFC 6749 section 3.2). 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 SenditCold, 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 SenditCold'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 SenditCold within minutes |
crm_stage_unknown | 400 | The pipeline_id or stage_id given is not a mirrored pipeline or stage of the provider, or (in HubSpot) a deal was marked won or lost in a pipeline with no closed stage of that kind. Also 409 from a copy into the provider when it has no deal pipeline to copy deals into |
crm_owner_unmapped | 400 | A deal or task was assigned to a member who is not matched to a HubSpot owner or Pipedrive user. Match them with PUT /crm/owners/:externalId |
crm_contact_missing | 404 | The contact is not in the provider, and creating contacts there is turned off in the workspace's CRM settings |
crm_record_missing | 404 | The record no longer exists in the provider; it was deleted there |
crm_provider_rejected | 422 | The provider refused the change. The message carries its reason, such as a required property HubSpot enforces or a Pipedrive plan without the feature |
crm_sync_running | 429 | POST /crm/sync within 30 seconds (HubSpot) or a minute (Pipedrive) of the last one. The running sync finishes in a minute or two |
crm_unavailable | 503 | The provider did not answer or failed, including a Pipedrive company out of its daily API budget. Nothing was changed; retry shortly |
{
"error": "Conflict",
"message": "Pipelines are managed in HubSpot. Edit them there and they update here within minutes.",
"code": "crm_managed_externally",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Salesforce sync refusals
The Salesforce endpoints answer with their own codes. When Salesforce itself refused, message carries its errorCode and text, such as Salesforce: INVALID_FIELD: No such column 'Tier__c' on entity 'Lead'.
code | Status | Meaning |
|---|---|---|
invalid_salesforce_domain | 400 | The custom domain given to POST /integrations/oauth/start is not a Salesforce My Domain (*.my.salesforce.com) |
invalid_salesforce_settings | 400 | PUT /integrations/salesforce/:id/settings named something the sync cannot run: an unknown field, a related field to write, an engagement field to read, a field mapped twice, or a fixed owner with no user |
salesforce_error | 400 | Salesforce refused the request. The message is Salesforce's own |
salesforce_unreachable | 400 | Salesforce did not answer. Retry later |
salesforce_sync_off | 400 | POST /integrations/salesforce/:id/sync-now on a connection with sync turned off |
salesforce_reconnect_required | 409 | The Salesforce session ended (a revoked app, a password reset, a deactivated user). Reconnect the integration |
import_running | 409 | POST /integrations/salesforce/:id/import-sources/:sourceId/run or POST /integrations/apollo/:id/import-sources/:sourceId/run while that import is already running |
salesforce_rate_limited | 429 | The Salesforce org has used its API requests for today |
Apollo and Clay refusals
The Apollo and Clay endpoints answer with their own codes.
code | Status | Meaning |
|---|---|---|
apollo_enrich_too_many | 400 | POST /integrations/apollo/:id/enrichments selected more than 5,000 contacts |
clay_webhook_url | 400 | PATCH /integrations/clay/:id set a webhook_url that is not a Clay table webhook |
clay_webhook_missing | 400 | PATCH /integrations/clay/:id chose events to send with no table webhook saved |
apollo_key_rejected | 422 | Apollo refused the saved API key. Replace it with PUT /integrations/apollo/:id/key |
apollo_key_forbidden | 422 | The API key works but cannot reach an endpoint the integration uses. Use a master key, or one with lists, contact search and people enrichment |
apollo_key_missing | 422 | The Apollo connection has no API key |
apollo_rate_limited | 429 | Apollo's rate limit for the account was reached. Retry later |
Lead finder refusals
The lead finder endpoints answer with their own codes.
code | Status | Meaning |
|---|---|---|
lead_search_empty | 400 | POST /leads/search named neither a query nor a category |
lead_search_area | 400 | The search area is not a usable rectangle, circle (50 m to 50 km) or shape (3 to 200 points), or it is larger than the source searches at once |
lead_search_exhausted | 400 | POST /leads/search asked for the next page of a search that has no more results |
lead_provider_not_connected | 400 | The chosen source needs an API key and neither the workspace nor the instance has one. Connect it under Integrations |
lead_provider_key | 400 | The source refused the API key. Replace the key on its integration |
lead_selection_too_large | 400 | A bulk call named more than 5,000 lead_ids |
lead_person_no_domain | 400 | A person was added or searched for without an email on a lead with no website, so there is no domain to work an address out at |
lead_person_exists | 409 | Renaming a person would give them the name of someone already on the lead |
lead_provider_quota | 429 | The source is rate limiting the key or its quota is used up. Retry later |
lead_provider_unavailable | 503 | The source did not answer, took too long, or (OpenStreetMap) every server is busy. Retry, or search a smaller area |
A search through a source the instance pays for, or a person's email found on request, answers 402 with insufficient_credits when the balance is too low and 429 with usage_cap_exceeded past a spend limit, and charges nothing. An enrichment run charges as it reads each website; one that reaches either limit stops with status: "error" and the reason in last_error, and leaves the remaining leads untouched.
Copy to workspace refusals
POST /campaigns/:id/copy-to and POST /templates/copy-to refuse the whole request with these codes and copy nothing. A workspace the caller cannot write is reported inside the 200 answer instead, as that entry's error.
code | Status | Meaning |
|---|---|---|
copy_destinations_limit | 400 | organization_ids named more than 10 workspaces |
copy_templates_limit | 400 | template_ids named more than 50 templates |
cross_workspace_copy_requires_session | 403 | An API key or OAuth token asked to copy into a workspace other than its own. Copy from the dashboard, signed in as a member of both |
member_access_restricted | 403 | A member restricted to selected campaigns asked to copy templates |
not_found | 404 | The campaign, or one of the template_ids, is not in this workspace or not shared with the caller |
copy_destination_forbidden | per workspace | In a result entry: the caller is not a member of that workspace with the Manage campaigns permission and access to the whole workspace |
storage_limit_reached | per workspace | In a result entry: the campaign's attachments would pass that workspace's storage quota |
Client report refusals
The client report routes answer these codes. The public report page answers not_found for a link that is unknown, expired, revoked or opened on another workspace's custom domain, and invalid_range for a range it does not offer.
code | Status | Meaning |
|---|---|---|
invalid_report_scope | 400 | access named a campaign, campaign folder or mailbox outside the workspace, more than 500 of one kind, a scope other than workspace or restricted, or a restricted scope with nothing in it |
invalid_report_range | 400 | date_range or range is not last_7_days, last_30_days, this_month or last_month |
invalid_report_sections | 400 | Every section is off. Turn on at least one |
invalid_report_link | 400 | expires_in_days is outside 1 to 365 |
invalid_report_schedule | 400 | The schedule cannot run: no recipients or more than 10, an address with a display name or not an address, an unknown cadence, weekday, month_day or hour out of range, or a timezone that is not an IANA zone |
invalid_name | 400 | The report name is empty, too long, or contains a link or address. See name refusals |
report_not_found | 404 | The report does not exist in the selected workspace |
report_limit_reached | 409 | The workspace already has 200 reports |
report_links_unavailable | 422 | The instance has no portal service (PORTAL_DOMAIN), so a link or a scheduled email would point nowhere |
report_emails_unavailable | 422 | The instance has no outgoing mail configured, so a schedule could not send |
Agency refusals
The agency routes answer these codes.
code | Status | Meaning |
|---|---|---|
invalid_name | 400 | The agency or client name is empty, too long, or contains a link or address. See name refusals |
invalid_email | 400 | The staff email is not a plain address |
invalid_agency_role | 400 | role is not admin or member |
invalid_monthly_send_limit | 400 | monthly_send_limit is below 0 or above 100,000,000, or is not a whole number |
invalid_monthly_ai_limit | 400 | monthly_ai_limit_usd is below $0 or above $50,000, or is not a number |
invalid_ai_amount | 400 | amount_usd is outside $1 to $5,000 for a move into a client, or $5 to $5,000 for an AI balance purchase |
invalid_ai_auto_topup | 400 | amount_usd is outside $5 to $5,000, threshold_usd is outside $0 to $5,000, or limit_usd is below amount_usd or above $50,000 on the AI balance's automatic top-up |
invalid_auto_topup | 400 | threshold_sends is outside 0 to 10,000,000 or max_packs is outside 1 to 20 on the automatic top-up |
agency_plan_unavailable | 400 | The plan is not an agency plan, or has no price for that interval yet |
agency_no_subscription | 400 | A top-up, an AI balance purchase, a plan switch or the billing portal before the agency has a plan, or a plan switch while its subscription is not active |
agency_pool_unlimited | 400 | A top-up on a plan with no send pool cap |
invalid_cursor | 400 | GET /agency/:agencyId/inbox was given a cursor this API did not issue. Start again without one |
invalid_limit | 400 | GET /agency/:agencyId/inbox was given a limit outside 1 to 100 |
agency_ai_balance_insufficient | 402 | POST /agency/:agencyId/clients/:orgId/ai-balance asked to move more than the agency's AI balance holds. Nothing moves |
agency_forbidden | 403 | The caller's agency role cannot make this change: members change nothing, admins cannot make or change admins |
agency_owner_fixed | 403 | The owner cannot be removed or given another role |
agency_client_not_owner | 403 | Only a workspace's owner can bring it into an agency |
agency_limit_reached | 403 | The account already owns five agencies |
agency_leave_not_owner | 403 | POST /organization/agency/leave from someone other than the workspace's owner |
agency_client_inbox_forbidden | 403 | GET /agency/:agencyId/inbox/thread or /inbox/messages/:messageId for a client where the caller is not a member, lacks Use unified inbox, or is restricted to no mailboxes |
agency_client_mailboxes_forbidden | 403 | PUT /agency/:agencyId/vendors/:id/clients/:orgId for a client where the caller cannot manage mailboxes |
agency_vendor_staff_only | 403 | A workspace member who is not on the agency's staff listing, importing from, reconnecting through or removing a connection made from the agency's vendor account |
agency_not_found | 404 | The agency does not exist, or the caller is not on its staff |
agency_staff_not_found | 404 | The person is not on the agency's staff |
agency_client_not_found | 404 | The workspace is not one of this agency's clients, including an organization_id on the agency inbox routes |
agency_client_taken | 409 | The workspace already belongs to an agency |
agency_vendor_managed | 409 | PATCH /emails/vendors/:id on a connection made from an agency's vendor account. Its key is changed at the agency, with PATCH /agency/:agencyId/vendors/:id |
agency_client_has_subscription | 409 | The workspace pays for its own plan. Cancel it in the workspace's billing first |
agency_already_subscribed | 409 | The agency already has a plan; switch it with POST /agency/:agencyId/billing/change |
agency_no_payment_method | 409 | An automatic top-up, of sends or of the AI balance, turned on for an agency with no saved card |
agency_plan_unchanged | 409 | A plan switch to the plan and billing period the agency is already on |
agency_plan_preview_expired | 409 | The preview a plan switch was confirmed from no longer holds: proration_date is more than 30 minutes old, in the future or before the current billing period, or the subscription is no longer on from_price_id. Preview the switch again |
agency_pool_exceeds_plan | 409 | A switch to a plan whose send pool, with this period's top-ups, is smaller than what the period has already used. Switch after the period renews |
agency_billed | 409 | POST /subscription/checkout on a client workspace, whose plan comes from its agency |
agency_leave_agency_owner | 409 | POST /organization/agency/leave on a workspace whose owner also owns the agency. Detach it with DELETE /agency/:agencyId/clients/:orgId instead |
agency_billing_disabled | 503 | The instance does not bill agencies (BILLING_PROVIDER=none); every agency feature is already available. Also the answer to moving or buying AI balance where AI is not charged |
Agency API key refusals
An agency API key is refused with these codes, on top of the ordinary key codes (unauthorized for an unknown, revoked or expired key, rate_limit_exceeded, and forbidden for a missing permission bit).
code | Status | Meaning |
|---|---|---|
agency_key_organization_required | 400 | A workspace endpoint was called with an agency key and no valid client workspace id in the Warmbly-Organization header |
invalid_agency_key_permissions | 400 | POST /agency/:agencyId/api-keys with no permission, an unknown bit, API_KEYS, REALTIME_SUBSCRIBE or CONNECT_EMAILS |
agency_key_holder_left | 401 | The owner or admin who created the key is no longer an owner or admin of the agency, or no longer has a membership in the client workspace named. Create a new key |
agency_key_not_client | 403 | The workspace in Warmbly-Organization is not a client of the key's agency, including one that was detached |
agency_key_not_allowed | 403 | A route an agency key cannot call: session-only routes, agency billing and settings writes, workspace API key and OAuth app management, and the MCP server |
agency_key_ip_not_allowed | 403 | The request came from an address outside the key's allowed_ips |
agency_credential_required | 403 | An agency route was called with a workspace API key or an OAuth token. Use a session or an agency key |
agency_api_key_not_found | 404 | PATCH or DELETE /agency/:agencyId/api-keys/:keyId for a key this agency does not have (or, for PATCH, one already revoked) |
White-label refusals
/organization/brand and /agency/:agencyId/brand answer these codes. See white-label.
code | Status | Meaning |
|---|---|---|
invalid_name | 400 | The brand display_name contains a link or address, or is too long. See name refusals |
invalid_accent_color | 400 | accent_color is not a six-digit hex colour like #0284c7 |
invalid_website_url | 400 | website_url is not an absolute https:// address with a domain, or carries a user name or password |
invalid_portal_domain | 400 | The agency portal domain is not a host name, or is one of this instance's own hosts |
invalid_portal_sign_in | 400 | portal_sign_in on PUT /agency/:agencyId/brand is not portal or warmbly |
invalid_theme | 400 | A theme field on PUT /agency/:agencyId/brand holds a value outside its list: font, corners, appearance, sign_in_layout or sign_in_background. The message names the field and the values it takes. See portal theme |
invalid_brand_text | 400 | theme.sign_in_heading is over 60 characters or theme.sign_in_message is over 200, or one of them contains a link, a web or email address, markup or control characters, or no letter or number. The content rules are the name rules. The message names the field and the rule |
invalid_brand_link | 400 | theme.support_url, theme.terms_url or theme.privacy_url is not an absolute https:// address with a domain, or theme.support_email is not a plain email address like [email protected]. The message names the field |
image_unreadable, image_format_not_allowed, image_too_large, image_dimensions_too_large | 400 | The logo is not a PNG or JPG up to 2 MB and 1024px on each side, the agency's tab icon is not a PNG up to 1 MB and 512px on each side, or its sign-in image is not a PNG or JPG up to 4 MB and 2560px on each side. The message says which image and what is accepted |
white_label_not_entitled | 403 | Turning hide_powered_by on without Agency Pro, Agency Scale or Enterprise. A value already stored saves unchanged |
agency_forbidden | 403 | Agency staff other than the owner and admins changing the agency's brand |
agency_not_found | 404 | The agency does not exist, or the caller is not on its staff |
portal_domain_taken | 409 | Another agency or workspace on this instance holds the portal domain. Nothing changes |
Portal session refusals
A session signed in on an agency's portal domain reaches only that agency's client workspaces. These codes answer what it cannot reach, and the sign-in that hands such a portal its session.
code | Status | Meaning |
|---|---|---|
portal_handoff_invalid | 400 | POST /auth/portal/exchange with a code that expired, was already spent, or came from an origin other than the portal it was issued for. Every attempt spends the code. Sign in again from the portal |
portal_session_restricted | 403 | A portal session called an account-level route (password, two-factor, passkeys, sign-in methods, sessions, account deletion, creating a workspace or an agency, accepting an invitation to a workspace outside the agency, OAuth consent, CLI and pool link approvals, workspace and agency API key creation, the SenditCold Cloud link or the admin routes), switched to or named a workspace that is not a current client of the agency, or named another agency. The message names the main address, where signing in reaches everything the account can |
portal_registration_closed | 403 | POST /auth/register or /auth/register/confirm sent from a portal domain without an invitation. Accounts there are set up from the link in an invitation; an invitation for a different address answers invitation_invalid |
portal_sign_in_elsewhere | 403 | A sign-in sent from a portal whose agency signs its clients in on the main address. Sign in there; it returns you to the portal |
portal_handoff_origin | 403 | POST /auth/portal/handoff from anywhere but the main dashboard address, or from a session signed in on a portal |
portal_not_found | 404 | POST /auth/portal/handoff for a host that is not a verified portal whose agency signs its clients in on the main address |
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 |
google_personal_app_password
A 400 with code: "google_personal_app_password" comes from POST /emails/onboarding/oauth/start with provider: "gmail" and a login_hint on gmail.com or googlemail.com. Personal Gmail connects with an app password, not with Google sign-in. Nothing was started and nothing was stored.
How to fix:
- Turn on 2-Step Verification for the Google account, create an app password, and connect the mailbox through
POST /emails/onboarding/smtp-imapwithsmtp.gmail.com:587(STARTTLS) andimap.gmail.com:993(TLS). See personal Gmail - A Google Workspace mailbox signs in with Google once its administrator trusts SenditCold; see Google Workspace with Google sign-in
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
session_ended means the dashboard session behind the token was signed out, revoked, or ended by a password change. Refreshing will not help; sign in again.
{
"error": "Unauthorized",
"message": "Your session has ended. Sign in again to continue.",
"code": "session_ended",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Two more 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 from the balance, is requested but the workspace balance is too low to cover it. The check runs before any work starts, so nothing is charged. The response carries the stable code insufficient_credits, kept from before the balance was in dollars.
{
"error": "Payment Required",
"message": "The workspace balance is too low. Add funds to keep using AI features.",
"code": "insufficient_credits",
"request_id": "req_..."
}How to fix:
- Add money to the balance, turn on automatic reload, or wait for the monthly allowance to reset (see Balance and usage)
- Related: a
429with codeusage_cap_exceededmeans a spend limit or a short-term AI usage cap was hit; retry when the window rolls over
POST /subscription/balance/top-up answers 402 when the saved card was not charged: card_declined (the card was refused), authentication_required (the bank asked to confirm the payment) or payment_failed. Nothing was added. Offer Stripe Checkout instead with POST /subscription/balance/checkout. See balance and usage.
Placement tests answer 402 too, with placement_not_entitled or placement_quota_exceeded; see placement test refusals. A paid placement test past the balance answers insufficient_credits, and one past a spend limit answers 429 with usage_cap_exceeded; neither charges anything.
403 Forbidden
Returned when authenticated but lacking necessary permissions.
Common causes:
- API key lacks required permission
- Request IP not in allowlist
- Email account not in allowlist
- Organization access restricted
- The member is restricted to selected resources (
member_access_restricted)
Example:
{
"error": "Forbidden",
"message": "You don't have access to this feature.",
"code": "forbidden",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Check your API key's permissions
- Verify IP restrictions if configured
- Request additional permissions if needed
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; the dashboard then offers Google sign-in
google_delegation_retired
A 403 with code: "google_delegation_retired" comes from POST /emails/grants/google/start or POST /emails/grants/google/finish for a domain the workspace holds no Google grant for. New Google Workspace domain-wide delegation grants are no longer set up. A grant the workspace already holds keeps working: it can be proved again, checked, used to connect more mailboxes and removed. Microsoft whole-organization consent is unaffected.
How to fix:
- Have a Workspace administrator mark SenditCold as a trusted app, then sign in each mailbox with Google. See Google Workspace with Google sign-in
- Or connect the mailbox with an app password over IMAP and SMTP
plan_offer_required
A 403 whose code is plan_offer_required comes from POST /subscription/checkout, POST /subscription/change-plan or POST /subscription/offers/:planId/checkout. Those routes sell public plans, and a private plan only to a workspace SenditCold has offered it to. The workspace holds no open offer for the chosen plan: it was never offered, or the offer has ended or been withdrawn. The Warmup plan is no longer sold anywhere: it is refused here too, and POST /pool-link/checkout answers 409 with warmup_plan_retired.
{
"error": "Forbidden",
"message": "This plan is available by invitation only. Ask SenditCold to offer it to this workspace.",
"code": "plan_offer_required",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Pick a plan from
GET /plans, or one listed byGET /subscription/offers. See plans offered to a workspace
Registration and invitation refusals
Signup and invitation refusals carry their own code, so a client can branch on the specific condition instead of matching on text. They describe the deployment's policy and never say anything about whether a given address exists.
code | Status | Meaning |
|---|---|---|
registration_invite_only | 403 | The server runs DISABLE_REGISTRATION=invite_only. Creating an account requires an invitation link, which carries the token that permits the signup |
registration_closed | 403 | The server runs DISABLE_REGISTRATION=true. Signups are off and invitations do not override it |
invitation_invalid | 403 | The invitation is expired, cancelled, already used, or was issued for a different email address |
invitation_expired | 400 | POST /invitations/accept on an invitation older than 48 hours. It stays on the team page, where it can be sent again |
invitation_used_or_expired | 400 | POST /invitations/accept on an invitation that was accepted or expired while the request was in flight. Ask for it to be sent again |
invitation_roles_removed | 400 | POST /invitations/accept on an invitation whose roles were all deleted since it was sent. Ask for a new invitation |
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.
Account and workspace standing
An account or workspace that support or the instance's administrators have restricted answers with its own code. These are not permission problems, and a role change does not lift them.
code | Status | Meaning |
|---|---|---|
account_suspended | 403 | The account is suspended and cannot sign in, by password, passkey or two-factor confirmation |
account_sending_suspended | 403 | The account may not send mail. A manual send (POST /emails/:id/send, POST /unibox/*) is refused; nothing else is affected |
account_workspace_create_suspended | 403 | The account may not create workspaces. It can still join one it is invited to |
workspace_sending_suspended | 403 | Sending is paused for the whole workspace while it is under review. Contact support to resolve the review |
{
"error": "Forbidden",
"message": "Sending is paused for this workspace while it is under review. Contact support to resolve the review; nothing else in the workspace is affected.",
"code": "workspace_sending_suspended",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Confirmation required
code | Status | Meaning |
|---|---|---|
reauth_required | 403 | The action needs a proof of identity newer than the session. Confirm with POST /v1/auth/reauth, then retry |
reauth_limited | 400 | Too many failed confirmations for this account within the hour. Wait, or sign out and sign in again: a sign-in counts as a confirmation for five minutes |
reauth_no_factor | 400 | POST /auth/reauth was sent a password or code for an account that has neither. Confirm with a passkey or the linked sign-in provider listed by GET /auth/reauth/methods; an account with none of those signs out and in again (a sign-in counts as a confirmation for five minutes) |
reauth_identity_mismatch | 400 | The Google, Apple or single sign-on account verified during a confirmation is not the one linked to this SenditCold account. Continue with the account you sign in with |
reauth_expired | 400 | The confirmation's code or nonce expired or was already used. Start the confirmation again |
admin_mfa_required | 403 | An admin route was reached by a session that did not present a second factor. Turn on 2FA or add a passkey, then sign in again |
passkey_user_verification_required | 400 | A passkey sign-in came from an authenticator that did not verify the user with a PIN or biometric. Set a PIN on the security key, or sign in another way |
two_fa_invalid_code | 400 | The authenticator or recovery code did not match. Wait for a fresh code and try again; the login challenge allows five attempts per pending session |
two_fa_code_used | 400 | This instance already accepted that authenticator step or a later one. Wait for a new code before signing in or confirming another action. A website and admin panel backed by the same instance share this single-use rule |
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, move money, cannot be undone, or reach a whole domain's mail. The full list is under actions that need a recent confirmation. List the ways the account can confirm with GET /v1/auth/reauth/methods (a passkey and a linked Google, Apple or single sign-on identity are among them; see endpoints). With a password or a current two-factor code:
curl -X POST "https://api.senditcold.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) confirms with its passkey or linked identity. One with none of those is allowed for five minutes after a sign-in like every confirmed action; an older session gets reauth_no_factor and the fix is to sign out and sign in again. API keys and OAuth tokens have no session to confirm, so these routes are reachable only with a signed-in session; that is deliberate for key creation, which otherwise lets one leaked key mint more.
{
"error": "Forbidden",
"message": "Confirm it is you before making this change.",
"code": "reauth_required",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Member access scope
A member restricted to selected resources, and any API key or OAuth app acting for one, reaches only the routes that filter to their grants, and writes only through a client role that acts in scope. The scope's own refusals:
code | Status | Meaning |
|---|---|---|
member_access_restricted | 403 | The caller's selected-resource scope does not cover this route. Workspace-wide surfaces such as contacts and settings are refused, and so is a write without a role that acts in scope, a reply outside a conversation held in granted mailboxes, a campaign edit to its senders, folders, tracking domain, copied addresses or status, a step that acts on contacts, the CRM or automations, and a campaign edit, step change or start on a campaign that does not send only from granted mailboxes in an explicit list with no mailbox tags. Workspace creation is refused to accounts with only restricted memberships, except instance administrators; limit-request operations require an unrestricted membership in the target workspace |
invalid_access_resource | 400 | PUT /organization/members/:id/access or an invitation named a campaign folder, campaign or mailbox that is not in this workspace |
invalid_access_scope | 400 | The scope is neither workspace nor restricted |
too_many_access_grants | 400 | More than 500 folders, campaigns or mailboxes in one list |
owner_access_unrestricted | 403 | The workspace owner always reaches the entire workspace and cannot be restricted |
A campaign or mailbox outside a restricted caller's grants answers 404, the same as one that does not exist, so a scope does not reveal what else the workspace holds. GET /unibox/thread/labels likewise returns the same 404 for an unknown thread, a thread outside the selected workspace, or a thread with no message in an accessible mailbox. An accessible thread with no labels returns an empty list.
Failure to resolve a restricted scope or its scoped counts fails the request rather than returning workspace-wide data. Realtime authorization lookup errors stop campaign, mailbox and organization channels; user-channel events whose authorization cannot be verified are withheld. See bounded realtime authorization.
{
"error": "Forbidden",
"message": "Your access to this workspace is limited to selected campaigns and mailboxes, and this is outside it.",
"code": "member_access_restricted",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix: ask a member with Manage team to widen the access under Settings > Members.
slack_link_email_mismatch
A 403 whose code is slack_link_email_mismatch comes from POST /integrations/slack/link sent without a Sign in with Slack result. Confirming a Slack link that way needs the email on the Slack account's profile to be the signed-in user's SenditCold 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 SenditCold 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 SenditCold with the address on your Slack profile, then confirm again
- If the addresses already match, a member with
manage_settingsreconnects Slack under Integrations > Slack so the connection grantsusers:readandusers:read.email
404 Not Found
Returned when the requested resource doesn't exist.
Common causes:
- Invalid resource ID
- Resource was deleted
- Resource belongs to different organization
- Typo in endpoint URL
Example:
{
"error": "Not Found",
"message": "Resource not found.",
"code": "not_found",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}How to fix:
- Verify the resource ID is correct
- Check that the resource hasn't been deleted
- Ensure you're using the correct endpoint
Specific 404 codes:
code | Meaning |
|---|---|
unknown_view | /me/views/:view was given a view name other than contacts, campaign_leads or unibox_rail |
app_attest_unavailable | POST /auth/app-attest/challenge or /keys on an instance with App Attest off. See App Attest refusals |
lead_cc_contact_not_found | Set a lead's CC named a contact that is not in the workspace |
slack_not_connected | A Slack route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under Integrations > Slack |
slack_link_invalid | The Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button |
slack_verify_failed | POST /integrations/slack/link with a Sign in with Slack result that is unknown, expired, already used, started by someone else or for another code. Start again from POST /integrations/slack/link/verify |
slack_verify_wrong_account | POST /integrations/slack/link after Sign in with Slack signed in a different Slack account than the one the link code names |
slack_verify_unavailable | POST /integrations/slack/link/verify on an instance whose Slack app has no client credentials |
slack_not_linked | PATCH /integrations/slack/link from a member who has not linked a Slack account in this workspace |
409 Conflict
Returned when the request conflicts with existing data.
Common causes:
- Trying to create a resource that already exists
- Duplicate unique values
- Deleting something whose state does not allow it yet, such as an API key that can still authenticate
Example:
{
"error": "Conflict",
"message": "resource already exists",
"code": "conflict",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Creating an account for an address that already has one answers 409 with the generic conflict code and a message pointing to sign-in and password reset. With email verification on it comes from POST /auth/register/confirm, after the emailed code has shown the address is the caller's; without it, POST /auth/register answers it directly.
A few conflicts carry their own code. The mailbox import's are under mailbox import refusals. A contact's email address has to be free, so changing one to an address another contact already holds is refused rather than merging the two:
code | Status | Meaning |
|---|---|---|
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 a Google mailbox on its own sign-in or an admin grant. See app password switch refusals |
scheduled_send_not_pending | 409 | Cancel a scheduled send named a send that is no longer pending: it has started sending, was sent, failed, or was already cancelled. Nothing changed |
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 |
payment_method_required | 409 | POST /subscription/balance/top-up, or turning on automatic reload with PATCH /subscription/balance/auto-reload, with no saved card. Save one with POST /subscription/balance/payment-method, or pay through POST /subscription/balance/checkout |
charge_in_progress | 409 | POST /subscription/balance/top-up while another payment for the workspace is still going through. Try again in a minute |
idempotency_outcome_unknown | 409 | The request that first used this Idempotency-Key ended in a server error or never finished, so whether it took effect is unknown and it is not run again while the key is kept (24 hours). Retry with a new key only once you have established that the change did not happen. See retry safety |
mailbox_cloud_unenroll_failed | 409 | The mailbox is linked to SenditCold Cloud and its link could not be released, so DELETE /emails/{id} would leave SenditCold 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 SenditCold 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. SenditCold 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 > SenditCold 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"
}PATCH /subscription/balance/auto-reload has two codes of its own:
code | Status | Meaning |
|---|---|---|
recharge_too_small | 422 | amount_usd is not more than threshold_usd, so a reload would not lift the balance back above the point that triggers it |
monthly_limit_too_small | 422 | monthly_limit_usd is below one reload (amount_usd) or above $50,000 |
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.
Every endpoint that checks a password, an emailed or two-factor code, or a sign-in link charges an attempt to its budget before it compares anything. When that budget cannot be read, the request is refused with code: "attempt_check_unavailable" and a Retry-After header, nothing is compared and the attempt is not counted, so a sign-in can briefly fail during a cache outage. Retry after the header. Looking up a CLI or SenditCold Cloud pairing code (/auth/cli/codes/:code, /pool-link/codes/:code) answers the same code when its lookup budget cannot be read.
{
"error": "Service Unavailable",
"message": "We couldn't check this attempt right now, so it was not counted. Wait a few seconds and try again.",
"code": "attempt_check_unavailable",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}Other temporary failures answer the generic form:
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 SenditCold 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, 2 during a workspace's free trial, 0 on a workspace without a paid plan after it, and 10 on a workspace that kept the earlier free plan. On a workspace without a plan the message says a plan is needed to connect mailboxes. 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 - Without a paid plan, choose one: every paid plan connects mailboxes
- 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
invalid_attachment
A 400 from POST /campaigns/:id/attachments means the new file is unsupported, malformed, outside the documented format limits, or does not match its final filename extension. Supported extensions are PDF, DOCX, XLSX, PPTX, CSV, TSV, TXT, PNG, JPG/JPEG, GIF, WebP and ZIP. New CSV/TSV attachments allow at most 4096 fields per logical record, including a trailing empty field; quoted separators and newlines stay within their field. Export the file in a supported profile with its matching extension and try again. Changing the client MIME header does not change the detected type. This refusal stores no object and reserves no storage quota. Existing stored files and portable archive restoration keep their compatibility policy.
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. A copy to another workspace reports it per workspace, inside its 200 answer. 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. SenditCold 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 SenditCold'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: SenditCold 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, a firewall dropped the packets, or the host name points to a private network address. 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
- A host name that points to a private network address (such as
10.x,192.168.xor a loopback address) is refused, because SenditCold only connects mailboxes to public addresses. Use the host name your provider gives for connecting from outside your network. On a self-hosted instance, the message also namesMAIL_ALLOW_PRIVATE_HOSTS=true, which the operator can set to connect mail servers on a private network (see configuration)
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, and SenditCold only signs in over an encrypted connection. Use the server's SSL/TLS port (usually 993) or a port that offers STARTTLS (usually 143). Nothing was saved.",
"code": "mailbox_tls_failed",
"request_id": "6f3c0a1e-2d47-4c6b-9a1f-2b7c5f0e8d31"
}When the certificate is what failed, the message says why (issued for a different host name, expired, or not vouched for by a recognised certificate authority) and names the certificate the server presented: who it is for, who issued it (or that it is self-signed) and the SHA-256 fingerprint of its public key. When the server can be trusted on its own certificate instead, the message ends by saying so and the response carries "reason": "certificate_untrusted"; the dashboard's connect and reconnect forms then offer Trust this server's own certificate.
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
- For a server with a self-signed or company certificate, compare the fingerprint with the one the server's owner gives you, then connect again with
tls_unverified: trueon that server (in the dashboard, Trust this server's own certificate). The mailbox is then trusted on that certificate only. See servers with their own certificate
Mailbox errors TLS_CERTIFICATE_CHANGED and TLS_CERTIFICATE_UNCONFIRMED
These are not responses to a request. They are the error a connected SMTP/IMAP mailbox reports (in the mailbox drawer, GET /analytics/accounts/:id, and the EMAIL_SERVER_ERROR notification) when it trusts a server on its own certificate and SenditCold did not connect:
| Code | Meaning |
|---|---|
TLS_CERTIFICATE_CHANGED | The server presented a public key other than the one confirmed for it, for example after a certificate was replaced with a new key |
TLS_CERTIFICATE_UNCONFIRMED | The mailbox trusts the server on its own certificate but no fingerprint has been confirmed for it yet |
Both are warnings: the mailbox is not deactivated, its credentials are not marked invalid, and nothing it was due to send or sync is dropped. It retries on its usual interval.
How to fix:
- Check with the server's owner that the change is expected
- Open Settings > Server certificates on the mailbox and choose Re-confirm, or call
POST /emails/onboarding/smtp-imap/:id/certificates/checkand then.../certificates/confirmwith the fingerprints it reported. The error clears once the confirmed certificates connect - If the confirm call answers
mailbox_tls_failedsaying the server did not present the certificate with the fingerprint you confirmed, a fingerprint was mistyped or the certificate changed after the check. Nothing was changed; run the check again and confirm what it reports
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 SenditCold implements, it answered with a 5xx that is not about the password (a mechanism it does not support, a STARTTLS it insists on), it only offers sign-in without encryption, 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 SenditCold does not implement (NTLM, GSSAPI, XOAUTH2), use the provider's documented SMTP submission host, which normally offers
LOGINorPLAIN; a different password does not add a mechanism. The message lists what the server offered - A server that only offers sign-in over an unencrypted connection is refused before any password is sent. Use its SSL/TLS port (usually 465) or a port that offers STARTTLS (usually 587)
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 SenditCold 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 SenditCold 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 SenditCold 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 SenditCold 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": "SenditCold 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) {
const backoff = (attempt) =>
new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
for (let attempt = 0; ; attempt++) {
let response;
try {
response = await fetch(url, options);
} catch (networkError) {
if (attempt === maxRetries - 1) throw networkError;
await backoff(attempt);
continue;
}
if (response.ok) {
return response.json();
}
// Retry server errors and rate limits; every other 4xx is final
const retryable = response.status === 429 || response.status >= 500;
if (retryable && attempt < maxRetries - 1) {
await backoff(attempt);
continue;
}
// Carry the machine-readable code, e.g. idempotency_outcome_unknown
const body = await response.json().catch(() => ({}));
const error = new Error(body.message || `HTTP ${response.status}`);
error.status = response.status;
error.code = body.code;
throw error;
}
}A retry of a write that carries an Idempotency-Key answers with whatever the first attempt recorded. A 429 leaves the key as it was, so the same key runs the request unless an earlier attempt already recorded an outcome. A 5xx from a request that reached the operation makes the same key answer 409 idempotency_outcome_unknown instead of running it again; a 5xx from a gateway the request never got past leaves the key unused, so the retry runs it for the first time. On idempotency_outcome_unknown, stop retrying and use a new key only once you have established that the change did not happen. See retry safety.
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.
Looking up a CLI or SenditCold Cloud pairing code is budgeted per signed-in person, and past it answers 429 with code: "code_lookup_limited" and Retry-After: 60. Check the code shown on the other device before trying again.
Admin device request errors
The admin device flow
adds admin_device_expired (404, indistinguishable invalid/unknown/expired/
consumed request), admin_device_resolved (409, already decided or mismatched/
replayed consent), and admin_device_slow_down (429, poll faster than five
seconds). Start again on expiry and never auto-approve on conflict.
Attempt budgets use existing rate_limit_exceeded. Auth/MFA/reauth and
dependency failures keep existing codes. Redis failures never authorize a
single-factor or anonymous admin. Node diagnostics
use generic errors and explicit unavailable/stale coverage, not outage inference.
Client connect link errors
POST /emails/client-links refuses its input with:
code | Status | Meaning |
|---|---|---|
client_link_invalid_providers | 400 | providers is empty or names something other than gmail, outlook, smtp_imap and vendor |
client_link_invalid_max_mailboxes | 400 | max_mailboxes is outside 1 to 500 |
client_link_invalid_expiry | 400 | expires_in_days is outside 1 to 90 |
client_link_invalid_tags | 400 | More than 20 tag_ids |
invalid_name | 400 | The label contains a link, an address or characters that are not allowed |
client_links_unavailable | 503 | The instance has no portal service (PORTAL_DOMAIN), so there is nowhere to serve the page |
The client's connect page shows these, each in its own words:
code | Status | Meaning |
|---|---|---|
client_link_not_found | 404 | No link has this address, or it is opened on another workspace's custom domain |
client_link_expired | 403 | The link is past its expiry |
client_link_revoked | 403 | The link was revoked, or the member who made it can no longer manage mailboxes |
client_link_exhausted | 409 | The link has connected every mailbox it allows |
client_link_provider_not_allowed | 400 | The link does not accept that kind of mailbox |
client_link_provider_unavailable | 400 | That sign-in is not configured on this instance |
client_link_signin_expired | 400 | The provider sign-in took longer than 10 minutes or was already used |
client_link_signin_declined | 400 | The client cancelled the sign-in at the provider |
client_link_captcha_failed | 400 | The security check on the server-details or vendor account form did not pass |
client_link_no_mailboxes | 400 | A client started a vendor import through a connect link without picking a mailbox, or none of the ones picked is in the account any more |
client_link_too_many_mailboxes | 409 | A client picked more vendor mailboxes than the connect link still allows |
mailbox_vendor_hold_expired | 400 | The ticket a connect page holds for a client's vendor key was already used or is older than 15 minutes. The client enters the key again |
client_link_rate_limited | 429 | Too many attempts through this link in the last hour |
A workspace at its mailbox allowance refuses the connect with the usual mailbox_allowance_reached, which the page words for the client.
Admin send hold errors
POST /admin/sends/holds/:id/resolve adds three codes:
code | Status | Meaning |
|---|---|---|
send_hold_not_found | 404 | The mailbox is not held from sending, or does not exist. Refresh the list |
send_hold_changed | 409 | The held send or reason differs from the one submitted, because the hold moved or lifted since the list loaded. Refresh and check the new held send |
send_hold_not_resolvable | 409 | The hold cannot take a verdict right now (the reason is in the message and on the row), or the evidence stopped applying while it was submitted. Nothing was recorded |