Error Handling
BillerAPI uses conventional HTTP status codes and one structured error envelope on every non-2xx response, so you can branch on a stable code and show an actionable message.
The error envelope
Every error response returns this JSON body. It is the exact shape on the wire — no other error dialects.
Error response
{
"error_code": "BILL_NOT_FOUND",
"error_type": "invalid_request",
"error_message": "No bill exists with id bill_abc123.",
"hint": "Verify the bill_id from a recent list call.",
"docs_url": "https://docs.billerapi.com/errors/BILL_NOT_FOUND",
"request_id": "d94f5e2a-8c3b-4f1e-9a7d-6b2c1e0f8a34"
}error_codeA stable, machine-readable code in UPPER_SNAKE_CASE (see the reference below). Branch on this — it is an additive-only contract.error_typeCoarse category for retry/backoff decisions: invalid_request, rate_limit, auth, upstream, or api_error.error_messageA human-readable description of the problem.hintAn actionable next step for resolving the error.docs_urlA link to this exact error code’s docs page (https://docs.billerapi.com/errors/<code>).request_idCorrelation id for the failing request — also returned as the X-Request-Id response header on every response. Quote it in support tickets.retry_afterOptional. On 429, the number of seconds to wait before retrying (also on the Retry-After header).errorsOptional. On 400 validation failures, an array of per-field problems (see below).documentation_url field. It duplicates docs_url and will be removed — read docs_url.Validation errors
A 400 VALIDATION_ERROR carries an errors array — one entry per offending field, so you can map problems straight onto a form. Each item names the field in the same snake_case you sent it.
400 VALIDATION_ERROR
{
"error_code": "VALIDATION_ERROR",
"error_type": "invalid_request",
"error_message": "One or more request fields are invalid. See errors[] for details.",
"hint": "Inspect errors[] for the offending fields and retry with a corrected request.",
"docs_url": "https://docs.billerapi.com/errors/VALIDATION_ERROR",
"request_id": "d94f5e2a-8c3b-4f1e-9a7d-6b2c1e0f8a34",
"errors": [
{ "param": "given_name", "code": "required", "message": "given_name should not be empty" },
{ "param": "email", "code": "invalid_format", "message": "email must be an email" }
]
}paramThe offending field, in the snake_case the API accepts. A stray camelCase field is reported with code unknown_parameter and a hint about the expected name.codeA stable reason: required, invalid_type, invalid_format, too_short, too_long, unknown_parameter.messageA human-readable, single-field explanation.HTTP Status Codes
| Code | Meaning | Description |
|---|---|---|
| 200 | OK | Request succeeded. |
| 201 | Created | Resource was created successfully. |
| 202 | Accepted | Request accepted for async processing (e.g., bill sync). |
| 400 | Bad Request | Invalid request body or parameters (see errors[]). |
| 401 | Unauthorized | Missing or invalid authentication credentials. |
| 403 | Forbidden | Valid credentials but insufficient permissions. |
| 404 | Not Found | The requested resource does not exist. |
| 429 | Too Many Requests | Rate limit exceeded. See retry_after / Retry-After. |
| 500 | Internal Server Error | Something went wrong on our end. Contact support if persistent. |
Error code reference
The error_code field is one of these stable codes. Each links to a dedicated page at https://docs.billerapi.com/errors/<code> with cause, fix, and an example. The list is additive-only.
General
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| INTERNAL_ERROR | 500 | api_error | An unexpected error occurred on our end. |
| VALIDATION_ERROR | 400 | invalid_request | One or more request fields are invalid. |
| NOT_FOUND | 404 | invalid_request | The requested resource does not exist. |
| CONFLICT | 409 | invalid_request | The request conflicts with the current resource state. |
| INVALID_CURSOR | 400 | invalid_request | The pagination cursor is malformed or stale. |
| IDEMPOTENCY_KEY_MISMATCH | 409 | invalid_request | The Idempotency-Key was reused with a different body. |
| RATE_LIMITED | 429 | rate_limit | You have exceeded the allowed request rate. |
| SERVICE_UNAVAILABLE | 503 | upstream | An upstream dependency is temporarily unavailable. |
Authentication
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| UNAUTHORIZED | 401 | auth | Authentication failed or is missing. |
| FORBIDDEN | 403 | auth | Authenticated, but not authorized for this resource. |
| CLIENT_ID_MISMATCH | 403 | auth | The supplied client_id does not match your session. |
| NEEDS_RESIGNIN | 401 | auth | No authenticated client context was resolved. |
Bills & statements
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| BILL_NOT_FOUND | 404 | invalid_request | No bill exists with that id. |
| ACCOUNT_LINK_NOT_FOUND | 404 | invalid_request | The account link does not exist. |
| STATEMENT_NOT_EXTRACTED | 425 | invalid_request | The statement has not been extracted yet. |
| EXTRACTION_FAILED | 503 | upstream | Statement extraction failed upstream. |
| EXTRACTION_UNSUPPORTED_FOR_BILLER | 422 | invalid_request | This biller does not support statement extraction. |
| STATEMENT_TOO_LARGE | 413 | upstream | The statement exceeds the size limit. |
Links & Connect
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| LINK_NOT_FOUND | 404 | invalid_request | No link exists with that id. |
| LINK_TOKEN_NOT_FOUND | 404 | invalid_request | The link token is unknown or expired. |
| BACKGROUND_NOT_ELIGIBLE | 409 | invalid_request | The link token cannot be backgrounded now. |
| UPDATE_BILLER_MISMATCH | 409 | invalid_request | The update token targets a different biller. |
| INVALID_UPDATE_REASON | 400 | invalid_request | The update reason is not a recognized value. |
| LINK_UPDATE_FORBIDDEN | 403 | auth | The update token belongs to a different client. |
| REDIRECT_URI_NOT_REGISTERED | 400 | invalid_request | The redirect_uri is not in your registered allowlist. |
Billers
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| BILLER_NOT_FOUND | 404 | invalid_request | No biller exists with that id. |
| BILLER_UNSUPPORTED | 422 | invalid_request | This biller cannot be automated. |
Payments
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| PAYMENT_EXECUTION_NOT_AVAILABLE | 501 | upstream | Payment execution is not yet available. |
Feedback
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| FEEDBACK_RUN_NOT_FOUND | 404 | invalid_request | The referenced resource does not exist. |
| FEEDBACK_RUN_NOT_OWNED | 403 | auth | The referenced resource belongs to another client. |
| FEEDBACK_RUN_EXPIRED | 410 | invalid_request | The feedback window has closed. |
| FEEDBACK_INVALID_CATEGORY | 422 | invalid_request | The category is not valid for this resource type. |
| FEEDBACK_INVALID_SIGNAL | 400 | invalid_request | The signal is not valid for the resource_type. |
| FEEDBACK_ALREADY_SUBMITTED | 409 | invalid_request | Feedback was already submitted for this resource. |
| FEEDBACK_RATE_LIMITED | 429 | rate_limit | Too many feedback submissions. |
Messaging
| Error Code | HTTP | Type | Summary |
|---|---|---|---|
| MESSAGING_CONSENT_NOT_GRANTED | 403 | invalid_request | The customer has not granted messaging consent. |
| MESSAGING_LIVE_ACCESS_REQUIRED | 403 | auth | Messaging requires production (live) access. |
| MESSAGING_CONTENT_FLAGGED | 422 | invalid_request | The message content was flagged. |
| MESSAGING_RATE_LIMITED | 429 | rate_limit | Messaging rate limit exceeded. |
| MESSAGING_SUPPRESSED | 200 | invalid_request | The recipient is suppressed; message not sent. |
| MESSAGING_AUP_NOT_ACCEPTED | 428 | invalid_request | The messaging Acceptable Use Policy is not accepted. |
| MESSAGING_AUP_REACCEPT_REQUIRED | 412 | invalid_request | The messaging AUP must be re-accepted. |
| MESSAGING_INVALID_PAYLOAD | 400 | invalid_request | The message payload is malformed. |
| MESSAGING_INVALID_CATEGORY | 400 | invalid_request | The message category is not recognized. |
| MESSAGING_PERSIST_FAILED | 503 | upstream | A transient error prevented recording the message. |
| MESSAGING_INTERNAL_ERROR | 500 | upstream | An unexpected messaging error occurred. |
| MESSAGING_NOT_FOUND | 404 | invalid_request | The referenced message or thread does not exist. |
Handling Errors
Always check the HTTP status code and parse the error envelope for details.
Error handling
try {
const response = await fetch('https://sandbox.api.billerapi.com/v1/billers', {
headers: {
Authorization: `Bearer ${process.env.BILLERAPI_SECRET_KEY}`,
},
});
if (!response.ok) {
const error = await response.json();
console.error(`[${response.status}] ${error.error_code}: ${error.error_message}`);
console.error('Fix:', error.hint, '·', error.docs_url);
if (error.error_code === 'VALIDATION_ERROR') {
for (const field of error.errors ?? []) {
console.error(` ${field.param} (${field.code}): ${field.message}`);
}
}
if (error.error_type === 'rate_limit') {
// Prefer the body field; fall back to the Retry-After header.
const retryAfter = error.retry_after ?? response.headers.get('Retry-After');
console.log(`Retry after ${retryAfter} seconds`);
}
return;
}
const data = await response.json();
console.log(data);
} catch (err) {
console.error('Network error:', err.message);
}404 after a webhook is normal
BillerAPI’s webhook bodies are intentionally minimal — they carry the envelope plus a small data.object with the resource ID and a few routing keys. To get the full resource you call GET /v1/<resource>/<id> after handling the webhook.
That follow-up GET can return 404 Not Found even though you just received an event for the resource. This is a normal consequence of two facts:
- Webhook delivery is asynchronous; minutes can pass between the event being emitted and your handler running.
- Some resources are short-lived (a link can be disconnected, a request-to-link can be cancelled, a bill can be re-extracted with a new id).
Treat 404 as “gone is gone”: log the event id and return 200 OK from your webhook endpoint. Do not return non-2xx — that triggers BillerAPI to retry the same webhook, which will hit the same 404 on the next attempt and burn your retry budget. The state the event reflected (e.g. link.disconnected) is still actionable from the envelope alone.
Tolerating 404 on the follow-up GET
async function handleBillCreated(envelope) {
const billId = envelope.data.object.id;
const res = await fetch(`https://api.billerapi.com/v1/bills/${billId}`, {
headers: { Authorization: `Bearer ${linkScopedToken}` },
});
if (res.status === 404) {
// Resource gone between webhook fan-out and our follow-up GET.
// Acknowledge so we are not retried; we already know the bill id.
logger.info('bill_gone_at_consume_time', { event_id: envelope.id, bill_id: billId });
return; // caller returns 200 OK
}
if (!res.ok) throw new Error(`unexpected ${res.status}`);
await persistBill(await res.json());
}Related
- Error code reference — a dedicated page per error code
- Feedback errors — the
FEEDBACK_*codes - Rate Limits — rate limit policy and retry strategies
- Authentication — API key and bearer token auth
- Idempotency — webhook dedupe by
event.id