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
{
"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"
}
}| Field | Always there | What it is |
|---|---|---|
code | Yes | What went wrong, in snake_case. It never changes for a given kind of refusal, so it's what your code should read. |
message | Yes | A 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. |
hint | Yes | One sentence saying what to do next. |
doc_url | Yes | The page for this code: https://usebluebot.com/docs/errors/<code>. |
request_id | Yes | This request's id, the same as the Request-Id header. Quote it when you write to support. |
retry_after | When waiting will help | Seconds to wait before sending the request again. The Retry-After header says the same. |
details | For invalid_request | One 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 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? | Codes | What to do |
|---|---|---|
| Yes, after waiting | rate_limited, internal, upstream_failed, unavailable | Wait 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 it | invalid_request, unauthorized, insufficient_scope, plan_lapsed, account_held, conflict, idempotency_conflict, limit_reached, payload_too_large, unsupported_media_type | The 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. |
| No | forbidden, not_found | Repeating 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.
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
201with the messagequeuedand ahold. It goes on its own. - A text to someone on your stop list. It's answered
201with the message alreadycanceledandcancel_reason: "opted_out", so your code learns the person stopped without a special case. - A text that didn't go. Once BlueBot has accepted a text, what happens to it is its state, not an HTTP error:
failedwith afailure.reason,expired, orunconfirmed. You hear about each with a webhook. - Canceling a text that's already canceled. It answers
200with the message as it is.
When something keeps failing
- Check 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 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
| Code | Status | What it means | Retry? |
|---|---|---|---|
| 400 | Invalid request | After fixing it | |
| 401 | Unauthorized | After fixing it | |
| 402 | Plan lapsed | After fixing it | |
| 403 | Forbidden | No | |
| 403 | Insufficient scope | After fixing it | |
| 403 | Account held | After fixing it | |
| 404 | Not found | No | |
| 409 | Conflict | After fixing it | |
| 409 | Idempotency key reused | After fixing it | |
| 409 | Limit reached | After fixing it | |
| 413 | Payload too large | After fixing it | |
| 415 | Unsupported media type | After fixing it | |
| 429 | Rate limited | Yes, after waiting | |
| 500 | Internal error | Yes, after waiting | |
| 502 | Upstream failed | Yes, after waiting | |
| 503 | Unavailable | Yes, after waiting |