Error Handling

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
JSON
{
  "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).
Deprecated alias. During the current migration window some responses also include a 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
JSON
{
  "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

CodeMeaningDescription
200OKRequest succeeded.
201CreatedResource was created successfully.
202AcceptedRequest accepted for async processing (e.g., bill sync).
400Bad RequestInvalid request body or parameters (see errors[]).
401UnauthorizedMissing or invalid authentication credentials.
403ForbiddenValid credentials but insufficient permissions.
404Not FoundThe requested resource does not exist.
429Too Many RequestsRate limit exceeded. See retry_after / Retry-After.
500Internal Server ErrorSomething 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 CodeHTTPTypeSummary
INTERNAL_ERROR500api_errorAn unexpected error occurred on our end.
VALIDATION_ERROR400invalid_requestOne or more request fields are invalid.
NOT_FOUND404invalid_requestThe requested resource does not exist.
CONFLICT409invalid_requestThe request conflicts with the current resource state.
INVALID_CURSOR400invalid_requestThe pagination cursor is malformed or stale.
IDEMPOTENCY_KEY_MISMATCH409invalid_requestThe Idempotency-Key was reused with a different body.
RATE_LIMITED429rate_limitYou have exceeded the allowed request rate.
SERVICE_UNAVAILABLE503upstreamAn upstream dependency is temporarily unavailable.

Authentication

Error CodeHTTPTypeSummary
UNAUTHORIZED401authAuthentication failed or is missing.
FORBIDDEN403authAuthenticated, but not authorized for this resource.
CLIENT_ID_MISMATCH403authThe supplied client_id does not match your session.
NEEDS_RESIGNIN401authNo authenticated client context was resolved.

Bills & statements

Error CodeHTTPTypeSummary
BILL_NOT_FOUND404invalid_requestNo bill exists with that id.
ACCOUNT_LINK_NOT_FOUND404invalid_requestThe account link does not exist.
STATEMENT_NOT_EXTRACTED425invalid_requestThe statement has not been extracted yet.
EXTRACTION_FAILED503upstreamStatement extraction failed upstream.
EXTRACTION_UNSUPPORTED_FOR_BILLER422invalid_requestThis biller does not support statement extraction.
STATEMENT_TOO_LARGE413upstreamThe statement exceeds the size limit.

Links & Connect

Error CodeHTTPTypeSummary
LINK_NOT_FOUND404invalid_requestNo link exists with that id.
LINK_TOKEN_NOT_FOUND404invalid_requestThe link token is unknown or expired.
BACKGROUND_NOT_ELIGIBLE409invalid_requestThe link token cannot be backgrounded now.
UPDATE_BILLER_MISMATCH409invalid_requestThe update token targets a different biller.
INVALID_UPDATE_REASON400invalid_requestThe update reason is not a recognized value.
LINK_UPDATE_FORBIDDEN403authThe update token belongs to a different client.
REDIRECT_URI_NOT_REGISTERED400invalid_requestThe redirect_uri is not in your registered allowlist.

Billers

Error CodeHTTPTypeSummary
BILLER_NOT_FOUND404invalid_requestNo biller exists with that id.
BILLER_UNSUPPORTED422invalid_requestThis biller cannot be automated.

Payments

Error CodeHTTPTypeSummary
PAYMENT_EXECUTION_NOT_AVAILABLE501upstreamPayment execution is not yet available.

Feedback

Error CodeHTTPTypeSummary
FEEDBACK_RUN_NOT_FOUND404invalid_requestThe referenced resource does not exist.
FEEDBACK_RUN_NOT_OWNED403authThe referenced resource belongs to another client.
FEEDBACK_RUN_EXPIRED410invalid_requestThe feedback window has closed.
FEEDBACK_INVALID_CATEGORY422invalid_requestThe category is not valid for this resource type.
FEEDBACK_INVALID_SIGNAL400invalid_requestThe signal is not valid for the resource_type.
FEEDBACK_ALREADY_SUBMITTED409invalid_requestFeedback was already submitted for this resource.
FEEDBACK_RATE_LIMITED429rate_limitToo many feedback submissions.

Messaging

Error CodeHTTPTypeSummary
MESSAGING_CONSENT_NOT_GRANTED403invalid_requestThe customer has not granted messaging consent.
MESSAGING_LIVE_ACCESS_REQUIRED403authMessaging requires production (live) access.
MESSAGING_CONTENT_FLAGGED422invalid_requestThe message content was flagged.
MESSAGING_RATE_LIMITED429rate_limitMessaging rate limit exceeded.
MESSAGING_SUPPRESSED200invalid_requestThe recipient is suppressed; message not sent.
MESSAGING_AUP_NOT_ACCEPTED428invalid_requestThe messaging Acceptable Use Policy is not accepted.
MESSAGING_AUP_REACCEPT_REQUIRED412invalid_requestThe messaging AUP must be re-accepted.
MESSAGING_INVALID_PAYLOAD400invalid_requestThe message payload is malformed.
MESSAGING_INVALID_CATEGORY400invalid_requestThe message category is not recognized.
MESSAGING_PERSIST_FAILED503upstreamA transient error prevented recording the message.
MESSAGING_INTERNAL_ERROR500upstreamAn unexpected messaging error occurred.
MESSAGING_NOT_FOUND404invalid_requestThe 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

Was this page helpful?