Error codes
Every non-2xx response from the ReachBell API uses the same JSON envelope. Build your error handling against that shape once and every endpoint behaves the same.
Error response shape
{
"statusCode": 404,
"message": "Project not found",
"requestId": "req_01HX4..."
}
| Field | Meaning |
|---|---|
statusCode | HTTP status. Always matches the response's HTTP status line. |
message | Human-readable explanation. Safe to log; not necessarily safe to surface to end users. |
requestId | Unique ID for the request. Searchable in your API logs and in Sentry. Quote it when you contact support. |
Always log the
requestId. It's the single most useful piece of information when something goes wrong — it lets us trace your exact request through our infrastructure in seconds.
HTTP status codes
| Status | What it means at ReachBell |
|---|---|
| 400 Bad Request | The request was malformed — invalid JSON, missing required field at the protocol level. |
| 401 Unauthorized | Your JWT is missing, expired, or your x-api-key is unknown. Re-authenticate. |
| 403 Forbidden | You are authenticated, but you don't have permission. Usually one of: VIEWER role attempting a write, or you tried to read a resource that belongs to a different organization or project. |
| 404 Not Found | The resource doesn't exist, or it does exist but it belongs to a different org/project than the one your credentials are scoped to. We don't leak existence across tenants. |
| 409 Conflict | A duplicate or contradictory operation. The most common case is registering a subscriber whose push token already exists, or editing a campaign that's already sending. |
| 422 Unprocessable Entity | DTO validation failed — a field is the wrong type, missing, or out of range. The message will list the failing field. |
| 429 Too Many Requests | You're past the rate limit. Read the Retry-After header (seconds) and back off. |
| 500 Internal Server Error | A bug on our side. Send us the requestId. |
| 502/503/504 | Transient infrastructure issue. Retry with exponential backoff. |
422 validation details
422 responses include a details array with one entry per failing field:
{
"statusCode": 422,
"message": "Validation failed",
"requestId": "req_01HX4...",
"details": [
{ "field": "content.title", "error": "must be a string" },
{ "field": "schedule.sendAt", "error": "must be a valid ISO 8601 date" }
]
}
429 backoff
curl -i https://api.reachbell.com/transactional/send -H "x-api-key: ..."
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{
"statusCode": 429,
"message": "Rate limit exceeded",
"requestId": "req_01HX4..."
}
Sleep Retry-After seconds, then retry once. If you hit 429 again, double the wait and retry up to three times before surfacing the error.
Route-specific reasons
Some endpoints return 200 OK with a reason field rather than a non-2xx — most notably the transactional endpoints, where "not delivered" is a normal business outcome.
POST /transactional/send
reason | Meaning |
|---|---|
subscriber_not_found | No subscriber matched the externalId or token. |
subscriber_unsubscribed | The user has revoked browser permission. The SDK will not re-prompt unless they reset it manually. |
subscriber_inactive | Subscriber is in a sandbox project but credentials are live, or vice versa. |
frequency_capped | The project's delivery policy blocks another push within the cap window. |
quiet_hours | Quiet hours are active in the subscriber's local timezone. |
provider_error | FCM / APNs / VAPID rejected. Check the dashboard for provider-level diagnostics. |
payload_too_large | Push payload exceeded the 4 KB FCM limit — usually a too-large image URL or too many actions. |
POST /transactional/email
reason | Meaning |
|---|---|
sender_domain_not_verified | senderEmail is not on a verified domain for the project. |
recipient_bounced | The address previously bounced and is suppressed. Remove from your list. |
recipient_complained | The address marked previous mail as spam. Permanently suppressed. |
template_not_found | The templateId doesn't exist on this project. |
template_variables_missing | A {{variable}} in the template was not supplied in variables. |
POST /transactional/whatsapp
reason | Meaning |
|---|---|
template_not_approved | The named template isn't approved by Meta yet. |
outside_service_window | More than 24 hours since the user last messaged you and no template provided. |
recipient_opted_out | The user blocked your business number. |
POST /transactional/sms
reason | Meaning |
|---|---|
invalid_phone | The number failed E.164 validation. |
carrier_rejected | The carrier refused delivery — usually content filtering or destination-country rules. |
unverified_sender_id | The senderId is not provisioned for the destination country. |
A worked example
A typical client-side handler looks like this:
const res = await fetch('https://api.reachbell.com/campaigns', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 5);
await new Promise(r => setTimeout(r, wait * 1000));
return retry();
}
if (!res.ok) {
const err = await res.json();
console.error('[reachbell]', err.requestId, err.message);
throw new Error(err.message);
}
return res.json();
The requestId log line is what saves you when you reach out to support.
What's next?
- Tune your rate limit handling.
- Re-read the Transactional API for the
reasoncodes in context. - Set up structured logging so
requestIdis always captured alongside your business request ID.