SendSetsDocs

Error codes

Reference for all API error codes and their meanings.

The SendSets 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",
  "fix": "The one-line remediation, when the API knows one."
}

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.

fix is optional and written for an agent or a script: the command or change that clears the error, in one line. It is omitted when the API has nothing specific to suggest. The sendsets CLI prints it under the error.

Agent-facing codes

The codes an agent branches on while setting a workspace up. Each carries a fix. Endpoints that answer with a checklist (POST /app-connections/:id/test, POST /emails/:id/test, POST /campaigns/:id/validate) put the same code, message and fix inside the 200 body under error or problems[], because a failing app or mailbox is a result, not a transport error.

CodeStatusRaised by
app_connection_failed200 (checklist)POST /app-connections/:id/test when the app cannot be reached or refuses the signed app.ping
app_action_failedstep runan app_action step whose endpoint answered a non-2xx, a non-object, or nothing in time
event_contact_not_found404POST /events for an email or contact id that matches no contact
event_not_found404GET /events/:id
event_run_not_found404GET /campaigns/:id/test-runs/:runId
mailbox_auth_failed400, or 200 (checklist)POST /emails when the credential fails validation; POST /emails/:id/test when the stored credential no longer works
mailbox_unhealthy200 (checklist)POST /emails/:id/test when the mailbox is inactive or no worker can check it
mailbox_provider_requires_oauth400POST /emails with a Google or Microsoft provider; use POST /emails/onboarding/oauth/cli-start
mailbox_consent_expired404GET /emails/onboarding/oauth/session/:session after the consent link lapsed
warmup_enable_failed400POST /emails/:id/warmup/start or /resume refused
campaign_invalid400, or problems[]POST /campaigns/:id/start on a campaign with no steps; the summary of a failed validate
campaign_no_mailbox400, or problems[]a campaign with no active mailbox to send from
campaign_no_contactsproblems[]a campaign with no leads
campaign_variable_missingproblems[]a template references a field no contact provides or an .App key no earlier step produces
step_no_connection, step_unreachable, wait_unbounded, wait_timeout_unroutedproblems[]structural problems in the step graph
run_failedtest runa single-prospect campaign test that ended in failure
app_connection_not_found400a run file's app_action.connection names no connection of the workspace
mailbox_provisioning_unavailable503managed mailboxes on an instance that does not offer them (every self-hosted instance)
mailbox_provisioning_failed503, or order errorthe mailbox provider could not be reached, or could not fulfil the order
provision_provider_invalid, provision_count_invalid, provision_domain_invalid, provision_domains_invalid400a quote request outside what can be ordered, including more domains than mailboxes
domain_unavailable400the requested domain cannot be registered
quote_not_found, quote_expired, quote_consumed404, 409a provision request naming a quote that is missing, older than fifteen minutes, or already ordered
payment_expired, payment_failed, subscription_endedorder errorthe checkout lapsed, a renewal failed, or the subscription behind the mailboxes ended
renewal_not_eligible, renewal_payment_required, renewal_request_failed, renewal_refused, renewal_failedrenewal errora domain renewal that needs a person: the provider will not renew it, the card on file failed, or the provider refused after payment
redirect_not_allowed400a checkout or billing-portal request whose return URL is not on an origin this deployment owns

HTTP status codes

Client errors (4xx)

CodeErrorDescription
400Bad RequestInvalid request syntax or parameters, or a quota that would be passed (storage_limit_reached)
401UnauthorizedMissing or invalid authentication
402Payment RequiredOut of AI credits (insufficient_credits)
403ForbiddenAuthenticated but lacks permission, or the workspace's mailbox allowance is full (mailbox_allowance_reached)
404Not FoundResource doesn't exist
409ConflictResource already exists
422UnprocessableValidation failed
429Too Many RequestsRate limit or AI usage cap exceeded (rate_limit_exceeded, usage_cap_exceeded)

Server errors (5xx)

CodeErrorDescription
500Internal Server ErrorUnexpected server error
501Not ImplementedFeature not available
503Service UnavailableService temporarily down

Error details

400 Bad Request

Returned when the request cannot be processed due to invalid syntax.

Common causes:

  • Invalid JSON in request body
  • Missing required fields
  • Invalid field types
  • Values outside allowed ranges

Example:

{
  "error": "Bad Request",
  "message": "invalid request body",
  "code": "bad_request",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Check that your JSON is valid
  • Verify all required fields are present
  • Ensure field values match expected types

Specific 400 codes:

codeMeaning
invalid_lead_statusPOST /contacts/search or POST /contacts/export was given a lead_status that is not one of the documented values
invalid_engagementPOST /contacts/search or POST /contacts/export was given an engagement that is not one of the documented values
lead_filter_requires_campaignlead_status or engagement was set without exactly one campaign_ids entry; both filters describe a contact inside one campaign
unknown_verification_statusA contact's verification_status is not a value any known verification service writes
unknown_verification_providerA contact's verification_provider names a vocabulary the platform cannot read
invalid_actionPOST /contacts/verification was given an action other than verify, mark_deliverable or mark_undeliverable
no_contactsPOST /contacts/verification selected no contacts: neither contacts nor a campaign_id with refused leads
list_bounce_riskPOST /campaigns/:id/start refused the launch on the list's projected bounce rate. Clean or verify the list, or repeat the request with acknowledge_list_risk: true
leads_undeliverablePOST /campaigns/:id/start found nothing to send because address verification refused every remaining lead; the campaign is parked at paused_undeliverable until they are re-verified or marked deliverable
empty_step_bodyPOST /campaigns/:id/start found an email step with nothing in either body, so it would send a blank message to every lead it reached. Write the step's body and start again
no_leadsPOST /campaigns/:id/start on a campaign that has never had a lead, with continuous off. Add contacts, or set continuous so it starts empty and waits for them. A campaign whose leads have all finished is a different case: it starts and waits
no_remaining_leadsA platform-initiated restart of a campaign with nothing left to send and continuous off found nothing to do; the campaign is completed again. A start you request never answers this: it turns continuous on and waits
too_many_tasksPATCH /crm/tasks or DELETE /crm/tasks was given more than 1000 ids in one request, or more than 50,000 exclusions. Split it into batches
selection_too_largeA "all": true bulk selection resolved to more than 50,000 rows. Narrow the filter and run it in parts; nothing was changed
invalid_filterA task filter carried an id that is not one: assigned_to, contact_id and deal_id name records, and are matched against id columns. Sent by POST /crm/tasks/search, POST /crm/tasks/summary, and the filters of a "all": true bulk selection
invalid_settingPATCH /outreach/settings (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a reply_intent.crm_task_intents entry that is not a reply intent
no_organizationThe request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry

401 Unauthorized

Returned when authentication fails.

Common causes:

  • Missing Authorization header
  • Invalid API key format
  • Expired API key
  • Revoked API key

Example:

{
  "error": "Unauthorized",
  "message": "Token not found.",
  "code": "unauthorized",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Include the Authorization: Bearer ssk_... header
  • Verify your API key is correct
  • Check if your key has expired or been revoked
  • Generate a new key if necessary

Two 401 variants are not about API keys at all. setup_token_invalid is returned by the first-run claim endpoint when the setup link is invalid, already used or expired. Print a new one with sendsetsctl 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 is requested but the organization is out of credits. The response carries the stable code insufficient_credits.

{
  "error": "Payment Required",
  "message": "You're out of AI credits. Add more to keep using the assistant.",
  "code": "insufficient_credits",
  "request_id": "req_..."
}

How to fix:

  • Wait for the monthly allowance to reset, or buy a top-up pack (see AI credits)
  • Related: a 429 with code usage_cap_exceeded means a short-term AI usage cap was hit; retry later

403 Forbidden

Returned when authenticated but lacking necessary permissions.

Common causes:

  • API key lacks required permission
  • Request IP not in allowlist
  • Email account not in allowlist
  • Organization access restricted

Example:

{
  "error": "Forbidden",
  "message": "You don't have access to this feature.",
  "code": "forbidden",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Check your API key's permissions
  • Verify IP restrictions if configured
  • Request additional permissions if needed

Registration and invitation refusals

Signup and invitation refusals carry their own code, so a client can branch on the specific condition instead of matching on text. They describe the deployment's policy and never say anything about whether a given address exists.

codeStatusMeaning
registration_invite_only403The server runs DISABLE_REGISTRATION=invite_only. Creating an account requires an invitation link, which carries the token that permits the signup
registration_closed403The server runs DISABLE_REGISTRATION=true. Signups are off and invitations do not override it
invitation_invalid403The invitation is expired, cancelled, already used, or was issued for a different email address
setup_already_complete403The first-run claim was attempted on an instance that already has an account
{
  "error": "Forbidden",
  "message": "This server is invite only. Ask an administrator to invite you, then open the link in the invitation to create your account.",
  "code": "registration_invite_only",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix: on a self-hosted deployment these are configuration, not faults. See accounts and access.

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

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"
}

One conflict carries its own code. A contact's email address has to be free, so changing one to an address another contact already holds is refused rather than merging the two:

codeStatusMeaning
contact_email_taken409The address given to update a contact already belongs to another contact

422 Unprocessable

Returned when validation fails on the request data.

Common causes:

  • Invalid email format
  • String exceeds maximum length
  • Number outside valid range
  • Invalid enum value

Example:

{
  "error": "Unprocessable",
  "message": "validation failed",
  "code": "unprocessable",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

500 Internal Server Error

Returned when an unexpected error occurs on the server.

Example:

{
  "error": "Internal Server Error",
  "message": "Something went wrong.",
  "code": "internal_error",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Retry the request after a short delay
  • If persistent, contact support with request details

One internal_error variant is worth distinguishing. When an authentication endpoint cannot send its email, the message names that specifically rather than reporting a generic fault:

{
  "error": "Internal Server Error",
  "message": "We couldn't send the email. If you administer this server, check the mail transport configuration.",
  "code": "internal_error",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

On a self-hosted deployment that means MAIL_TRANSPORT and the SMTP_ variables. See self-hosting.

503 Service Unavailable

Returned when the service is temporarily unavailable.

Example:

{
  "error": "Service Unavailable",
  "message": "service unavailable",
  "code": "service_unavailable",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Wait and retry with exponential backoff
  • Check status page for incidents

mailbox_provider_not_configured

A 503 whose code is mailbox_provider_not_configured is not transient and retrying will not help. It means the deployment has no OAuth client for the mailbox provider the request asked for, which only happens on a self-hosted install.

{
  "error": "Service Unavailable",
  "message": "Gmail is not configured on this deployment. Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET in your .env, then restart.",
  "code": "mailbox_provider_not_configured",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET, or BOX_OUTLOOK_CLIENT_ID and BOX_OUTLOOK_CLIENT_SECRET, in the .env at your install root, then restart
  • Or connect the mailbox over SMTP and IMAP instead, which needs no configuration
  • Full walkthrough: connect mailboxes

ai_not_configured

A 503 whose code is ai_not_configured is not transient and retrying will not help. It comes from POST /templates/analyze and means the deployment has no AI provider set up at all. It is how a client tells "there is no AI here" apart from "the provider is having a bad minute", which returns the generic service_unavailable and is worth retrying.

{
  "error": "Service Unavailable",
  "message": "AI analysis is not configured on this deployment.",
  "code": "ai_not_configured",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Set AI_PROVIDER and AI_API_KEY in the .env at your install root, then restart. See the configuration reference
  • Hide the AI affordance in your client rather than retrying: nothing about the request will make it succeed

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. It is not a permission problem: the workspace holds its whole mailbox allowance, which on a paid plan is one mailbox for every send a day the plan includes, and 10 on a free workspace. Nothing was connected.

{
  "error": "Forbidden",
  "message": "This workspace holds 15000 of its 15000 mailboxes. Request an increase, or move to a plan with more daily sends.",
  "code": "mailbox_allowance_reached",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Read GET /emails/allowance first: remaining says how many connects will succeed, and pending_request whether an increase is already asked for
  • Submit a limit-increase request for max_email_accounts via POST /organization/:orgId/limit-requests, or move to a plan with more daily sends. An approved request raises the allowance immediately; retry the connect then
  • Reconnecting an existing mailbox never returns this code

storage_limit_reached

A 400 whose code is storage_limit_reached comes from POST /campaigns/:id/attachments, from POST /email-images, and from a campaign duplicate that would copy attachments. The workspace's stored bytes, its attachments across every campaign plus its email image library, would pass the quota. The check and the write happen together under one per-workspace lock shared by both, so two uploads racing for the last of the quota cannot both get in. Nothing was stored.

{
  "error": "Bad Request",
  "message": "Storage limit reached: 51190 MB of 51200 MB used, 12 MB to add. Remove attachments or images, or upgrade your plan.",
  "code": "storage_limit_reached",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • GET /organization/current/limits reports storage.used_bytes and storage.limit_bytes, and storage.over_quota when a plan change left the workspace above the quota. Existing attachments keep sending either way
  • Delete attachments you no longer need (DELETE /campaigns/:id/attachments/:attachmentId) or images (DELETE /email-images/:id), or move to a paid plan for the larger quota

mailbox_worker_unreachable

A 503 whose code is mailbox_worker_unreachable comes from DELETE /emails/{id}. Disconnecting a mailbox has to reach the machine that syncs it before the record goes, because once the record is gone nothing can tell that machine to stop. When the instruction cannot be delivered, nothing is removed and the mailbox is left exactly as it was.

{
  "error": "Service Unavailable",
  "message": "This mailbox could not be disconnected right now because the machine syncing it could not be reached. Nothing was removed, so try again in a moment.",
  "code": "mailbox_worker_unreachable",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

How to fix:

  • Retry the delete. It is safe to repeat: a mailbox that is already gone returns 404, and one that is still there is untouched

mailbox_send_as_unsupported

A 400 whose code is mailbox_send_as_unsupported comes from POST /emails/{id}/identity/refresh and from a PATCH /emails/{id} that sets send_as_email. Only Gmail and Google Workspace mailboxes publish the addresses they are allowed to send as; an Outlook or SMTP/IMAP mailbox has no such list, so there is nothing to refresh and nothing to choose from.

{
  "error": "Bad Request",
  "message": "This mailbox's provider does not expose send-as addresses. Only Gmail and Google Workspace mailboxes do.",
  "code": "mailbox_send_as_unsupported",
  "request_id": "9f0a6f21-2c7e-4f2d-9d0e-1f7b9a2c4e55"
}

How to fix:

  • Read GET /emails/{id}/identity first. Its supported field is false for these mailboxes, and identities is empty
  • Send from the mailbox's own address, which is what an empty send_as_email means

mailbox_send_as_unknown

A 400 whose code is mailbox_send_as_unknown comes from a PATCH /emails/{id} that sets send_as_email to an address the provider has not verified for that mailbox. The choice is refused here rather than at send time, where the provider's own refusal arrives days later against a campaign step and names nothing you could act on.

{
  "error": "Bad Request",
  "message": "That address is not one your provider has verified this mailbox to send as. Refresh the list, or add and verify the address in your provider first.",
  "code": "mailbox_send_as_unknown",
  "request_id": "b71c3f88-0b3e-4a41-9a52-2f4f1fd0c0aa"
}

How to fix:

  • Add the alias in Gmail (Settings, Accounts, "Send mail as") and finish its verification
  • Call POST /emails/{id}/identity/refresh so SendSets re-reads the list, then set send_as_email to an address whose verified is true

mailbox_signature_too_large

A 400 whose code is mailbox_signature_too_large comes from POST /emails/{id}/identity/refresh with import_signature set. The provider's signature is larger than SendSets 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 SendSets 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 SendSets 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": "SendSets could not reach the machine running this mailbox, so its sending addresses were not refreshed. Nothing was changed; try again in a moment.",
  "code": "mailbox_identity_unavailable",
  "request_id": "6e2f1a90-5d13-4a77-9c0b-73f0b5a2e118"
}

How to fix:

  • Retry. Placement happens within moments, so a second attempt usually succeeds
  • GET /emails/{id} reports the mailbox's status; an inactive mailbox is not placed on a worker at all and will keep refusing until it is reactivated

Error handling best practices

Implement retry logic

For transient errors (5xx, 429), implement exponential backoff:

async function requestWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.ok) {
        return response.json();
      }

      // Don't retry client errors (4xx) except rate limits
      if (response.status >= 400 && response.status < 500 && response.status !== 429) {
        throw new Error(`Client error: ${response.status}`);
      }

      // Retry server errors and rate limits
      if (attempt < maxRetries - 1) {
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
    }
  }
}

Parse error responses

Always parse and handle error responses:

async function apiRequest(url, options) {
  const response = await fetch(url, options);

  if (!response.ok) {
    const error = await response.json();
    throw new ApiError(response.status, error.error, error.message);
  }

  return response.json();
}

class ApiError extends Error {
  constructor(status, type, message) {
    super(message);
    this.status = status;
    this.type = type;
  }
}

Rate limiting

When you exceed rate limits, you'll receive:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Please retry after 60 seconds.",
  "code": "rate_limit_exceeded",
  "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}

Use the Retry-After header to determine when to retry.

See also

On this page