Skip to the page

Errors

Errors

The one shape every refusal takes, how to handle an error by its code, which errors to retry, and every code the API answers with.

When the API refuses a request, it answers with an HTTP status of 400 or above and a body in one shape, whatever went wrong. The code in it is stable: write your program against the code, and show message and hint to people. Every code has a page of its own under /docs/errors, and every refusal links to that page in doc_url.

The error body

Response400A send with a field missing
{
  "error": {
    "code": "invalid_request",
    "message": "Some fields are missing or wrong.",
    "hint": "Check the fields named in the message and send it again.",
    "doc_url": "https://usebluebot.com/docs/errors/invalid_request",
    "details": [
      {
        "field": "to",
        "message": "Send `to` or `chat_id`."
      }
    ],
    "request_id": "req_1Hq2LxW9cVbN3mKpRtYs0a"
  }
}
FieldAlways thereWhat it is
codeYesWhat went wrong, in snake_case. It never changes for a given kind of refusal, so it's what your code should read.
messageYesA sentence for a person, such as "There’s no message with that id." The words may change; don't match on them. A message never contains the words of a text.
hintYesOne sentence saying what to do next.
doc_urlYesThe page for this code: https://usebluebot.com/docs/errors/<code>.
request_idYesThis request's id, the same as the Request-Id header. Quote it when you write to support.
retry_afterWhen waiting will helpSeconds to wait before sending the request again. The Retry-After header says the same.
detailsFor invalid_requestOne entry per field that's wrong: field is a dotted path (files.2, quiet_hours.start) and message says what's wrong with it.

The status always matches the code: invalid_request is always 400, rate_limited always 429. Every answer is application/json, refusals included.

Handle errors by code

Branch on error.code, not on the status alone, since several codes share a status (forbidden, insufficient_scope and account_held are all 403, and conflict, idempotency_conflict and limit_reached are all 409).

const response = await fetch('https://usebluebot.com/api/v1/messages', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.BLUEBOT_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `order-${order.id}`,
  },
  body: JSON.stringify({ to: order.phone, text: `Hi ${order.firstName}, your order is ready.` }),
});

if (!response.ok) {
  const { error } = await response.json();
  switch (error.code) {
    case 'invalid_request':
      // A field is wrong. Fix the data; sending it again won't help.
      console.warn('Not sent', error.details, error.request_id);
      break;
    case 'rate_limited':
    case 'internal':
    case 'upstream_failed':
    case 'unavailable':
      // Wait and send the same request with the same Idempotency-Key.
      await retryLater(error.retry_after ?? 2);
      break;
    default:
      throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
  }
}

The SDKs turn a refusal into an error you can catch, with the same code, message, hint and request_id, and they retry the codes worth retrying for you.

Which errors to retry

Every code falls in one of three groups, and each code's page says which.

Retry?CodesWhat to do
Yes, after waitingrate_limited, internal, upstream_failed, unavailableWait retry_after seconds when it's there; otherwise wait a second or two and double the wait on each try, up to a minute or so. Send the same request with the same Idempotency-Key, so a send that did go through isn't sent twice.
After fixing itinvalid_request, unauthorized, insufficient_scope, plan_lapsed, account_held, conflict, idempotency_conflict, limit_reached, payload_too_large, unsupported_media_typeThe same request gets the same answer until something changes: the data, the key, the plan, or the state of the thing you're acting on. Fix that first.
Noforbidden, not_foundRepeating it gets the same answer. Change the request.

A 5xx answer to a POST with an Idempotency-Key lets the key go, so the retry runs as a fresh request. Any other answer is kept for 24 hours and returned to a retry with the same key, with Idempotent-Replayed: true. One conflict is worth a quick retry: "A request with this Idempotency-Key is still running" comes with Retry-After: 1, because the first request is still being answered. Idempotency

What isn't an error

Some things that sound like failures come back as ordinary answers, because nothing about the request was wrong:

  • A held text. A send that can't go yet (quiet hours, the Mac away, a daily limit, its turn in the pace) is answered 201 with the message queued and a hold. It goes on its own. Holds
  • A text to someone on your stop list. It's answered 201 with the message already canceled and cancel_reason: "opted_out", so your code learns the person stopped without a special case. Stop words
  • A text that didn't go. Once BlueBot has accepted a text, what happens to it is its state, not an HTTP error: failed with a failure.reason, expired, or unconfirmed. You hear about each with a webhook. Message states
  • Canceling a text that's already canceled. It answers 200 with the message as it is.

When something keeps failing

  • Check BlueBot's status with GET /v1/status, which needs no key. "sending": "stopped" means BlueBot paused sending for everyone during an incident; texts are held, not lost. "database": "down" with "sending": "unknown" means BlueBot can’t reach its database, so it can’t say whether sending is on, and Macs can’t pick up texts until it’s back.
  • Check your Mac with GET /v1/device. Most "my texts aren't going" questions are a Mac that went to sleep, and that's a hold, not an error.
  • Write to support@usebluebot.com with the request_id. BlueBot keeps the route, status and time of every request for 30 days, never the body, so the id is how a person finds yours.

Every code

CodeStatusWhat it meansRetry?
invalid_request400Invalid requestAfter fixing it
unauthorized401UnauthorizedAfter fixing it
plan_lapsed402Plan lapsedAfter fixing it
forbidden403ForbiddenNo
insufficient_scope403Insufficient scopeAfter fixing it
account_held403Account heldAfter fixing it
not_found404Not foundNo
conflict409ConflictAfter fixing it
idempotency_conflict409Idempotency key reusedAfter fixing it
limit_reached409Limit reachedAfter fixing it
payload_too_large413Payload too largeAfter fixing it
unsupported_media_type415Unsupported media typeAfter fixing it
rate_limited429Rate limitedYes, after waiting
internal500Internal errorYes, after waiting
upstream_failed502Upstream failedYes, after waiting
unavailable503UnavailableYes, after waiting