Errors

Every failure from the roi API is the same JSON envelope with the same status semantics. There are 33 error types, 8 of which are safe to retry unchanged.

The envelope

Errors never come back as HTML, a bare string, or a different shape under load: the gateway mirrors the engine's envelope so a client that parses this can parse every failure it will ever see.

{
  "error": {
    "type": "invalid_request",
    "code": "invalid_type",
    "message": "\"lines.amount_minor\" must be a number, received a string",
    "param": "lines.amount_minor",
    "doc_url": "https://billroi.com/docs/errors#invalid_request",
    "request_id": "req_9f2c1a7b40e8d3f5"
  }
}

Branch on type, never on message. Messages are written for a human reading a log and are changed whenever a clearer one is found; type and code are contracts.

request_id

Every response carries a ROI-Request-Id header, success or failure, and every error repeats it in the body as request_id. It is in the body because most error handling logs the parsed error and nothing else, so a header-only id is one a customer can never quote back.

One request has one id across both roi processes. Quote it and we can find the exact request.

param

Validation failures name the offending field in param, as a JSON path into the payload you sent: external_id, or lines.amount_minor for something nested. Unrecognised fields are rejected rather than silently ignored, so a typo surfaces on the first call instead of as a value that quietly never arrived.

Retrying

Retriable below means the identical request may be sent again unchanged. Everything else needs the request, the object, or the account to change first, and retrying it will fail the same way. Rate limits publish a Retry-After header; honour it.

Send an Idempotency-Key on anything that moves money and retries stay safe even when a response is lost rather than refused.

Every error type

The request body or query string did not parse, or a value failed validation. When roi can identify the field, param names it as a dotted path into the payload you sent (lines.amount_minor), and code distinguishes unknown_field (a name roi does not recognise) from invalid_type (a recognised name carrying the wrong JSON type) and empty_body (nothing was sent at all).

Read param. Unrecognised fields are rejected rather than ignored, so an unknown_field is a typo, a camelCase name that should be snake_case, or a field belonging to a different endpoint. Paths name fields, not positions: a fault in one element of an array reads components.aggregation whichever element carries it, so search the array for the name rather than counting to an index.

No object with that identifier exists in the environment the key belongs to. Customers and subscriptions accept either a roi UUID or your own external_id, and both are looked up.

Confirm the identifier, then confirm the environment: test and live are entirely separate datasets, and an object created with a test key is invisible to a live key by design. code is no_transcript when the object exists but has no transcript: only invoices issued by a subscription period close record one.

The credential was missing, malformed, or not accepted. code says which: missing_authorization, malformed_authorization, malformed_key, or invalid_key.

Send Authorization: Bearer roi_sk_test_…. An invalid_key means the key is unknown, revoked, or from the other environment; roi does not say which, because that would confirm details of an account the caller has not proved they hold.

The credential is valid but not permitted to reach this endpoint. Some routes are reachable only from the roi cockpit's own signed calls, never from a merchant API key.

Use a documented /v1 endpoint. If you believe the route should be public, say so: this is a product gap, not a configuration problem you can solve from your side.

An object with that external_id already exists. roi returns a conflict rather than creating a duplicate, which is what makes a blind retry of a create call safe.

Treat it as success if you were retrying. Fetch the existing object by the same external_id if you need its roi id.

A conditional write was refused because the object moved after you read it. PUT /v1/invoice-template takes if_revision; when the stored revision has advanced past it, the write is rejected rather than allowed to overwrite an edit you never saw.

Re-read the object, re-apply your change on top of the revision you just read, and send again with the new if_revision. Resending the same body unchanged is refused identically. Omitting if_revision makes the write unconditional and last-writer-wins.

A request that moves money was sent without an Idempotency-Key. roi requires one rather than inventing it, because a key roi chose could not survive your process restarting mid-request.

Send Idempotency-Key with a value derived from your own work unit (the job id, the event id) so a retry after a crash reuses it.

This Idempotency-Key was already used for a different endpoint or a different request body. roi fingerprints both, so a reused key with changed contents is a bug being caught rather than a duplicate being prevented.

Use a fresh key for a genuinely different request. If the body changed between attempts of what you consider the same operation, that difference is the bug.

A request with this Idempotency-Key is still running. The second caller is refused rather than allowed to race the first.

Retry with the same key after a short backoff; you will receive the first request's recorded response.

The operation is only valid in the other environment. Promoting a configuration to live starts from test, by design: certification happens where nothing can charge a real card.

Run the operation with a test key, then promote.

This account has no isolated live environment yet.

Complete the launch checklist in the cockpit. Live is provisioned when the account is ready for it, not at signup.

Two amounts in different currencies were compared or combined. roi never converts: an implicit FX rate inside a billing system is an unauditable number nobody chose.

Make the currencies match, or run the comparison once per currency.

The plan is archived. Archived plans keep billing their existing subscribers and refuse new ones, which is what makes archiving safe to do on a live plan.

Subscribe to the current revision of the plan, or unarchive it.

The plan has subscriptions on it and cannot be deleted. Deleting it would leave live subscriptions pointing at pricing that no longer exists.

Archive it instead: existing subscribers keep billing, new ones are refused.

The object is real, and the operation does not apply in its current state: paying an already-paid invoice, voiding a paid one, sending an invoice that was never finalized.

Read the object's status and branch on it. The message names the state roi found.

A metric definition did not compile, or referenced an event property that its aggregation cannot use.

Evaluate the metric against real events with POST /v1/metrics/{id}/evaluate before attaching it to a plan.

The metric compiled but could not be evaluated over the window you asked for. message carries the evaluator's own reason.

Narrow the window and re-evaluate to find the boundary where it starts failing. A metric that groups on a dimension your events do not carry evaluates over nothing, so compare the definition against a real event body from POST /v1/usage.

A pricing simulation could not rate the CURRENT plan over the window you asked for. message carries the rating engine's own reason. The proposed side is not attempted once this fails, so the response carries no deltas.

Correct whichever component message names on the current plan. Rating the plan alone over a single period isolates it faster than the comparison does.

A pricing simulation rated the current plan but could not rate the PROPOSED one. message carries the rating engine's own reason.

The fault is in the proposal, not in the usage or the window: the same events rated cleanly against the current plan moments earlier. Correct the proposed plan definition message names.

The refund ran and came back a failure. roi reached the processor; the refund did not complete. message carries the refund's own reason.

Read message. A processor refusal needs the amount or the invoice corrected rather than a retry. Check what the invoice has already had refunded before resubmitting, so a partly applied refund is not doubled.

The endpoint URL passed validation but roi could not store it and mint a signing secret. Nothing was written, so any endpoint you had configured before is still in effect and still receiving deliveries.

Retry once. If it persists, quote request_id — a URL that validates but will not store is ours, not yours.

A metered plan was validated with no usage in the window. Rating nothing produces a zero that proves nothing.

Send representative usage for the subscription, then validate again.

The window contains product events that no plan component prices. Left alone this is silent revenue leakage, so validation refuses rather than reporting a total that quietly excludes them.

Add a component covering those events, or stop sending them.

The processor declined the transaction. roi is out of the money path: this is the merchant's own PSP answering, relayed verbatim.

Read the processor's own code in the message. A soft decline is what the dunning workflow exists to retry; roi is already handling it for subscription collections.

connector_error502retriable

The merchant's processor was reachable but the call failed or the credential was rejected.

Test the connection from the cockpit's Connections page, which reports the processor's own answer. A rotated or scope-reduced key is the usual cause.

upstream_error502retriable

A third-party system roi called on your behalf (an accounting sink, a processor) failed.

Retry with backoff. Persistent failures are visible on the relevant connection's page with the upstream's own error.

mail_error502retriable

roi reached the mail provider and the provider refused the message. message carries the provider's own text. The attempt is recorded against the invoice either way, so "never sent" and "sent and refused" stay distinguishable.

Retry with backoff for a transient provider fault. A refused recipient — a malformed address, or one the provider has suppressed — needs the customer's email corrected first, because every retry will be refused identically.

signal_failed502retriable

roi could not deliver the stop signal to the workflow that owns this invoice's collection. The invoice was not changed and collection continues.

Retry. The workflow transitions the invoice itself rather than the request doing it, so a retry cannot race a live charge or stop collection twice.

The feature is not switched on for this deployment or this merchant: outbound mail with no provider, capture with no connector registry.

Configure it in the cockpit. This will not resolve on retry.

This deployment has no outbound mail provider, so the invoice email was not sent. The attempt is still recorded against the invoice.

Configure a mail provider for the deployment; this will not resolve on retry. The invoice is unaffected in the meantime — hosted_invoice_url is the payable page, and you can deliver that link by your own channel.

unavailable503retriable

A dependency roi needs, chiefly its own database, did not answer. Your request was not processed.

Retry with backoff. Nothing was created, so a retry cannot duplicate anything. code is service_unavailable.

rate_limited429retriable

Too many requests on this credential. The gateway answers this before the request reaches the engine, so nothing was processed.

Honour the Retry-After header. The official SDKs already do.

api_error500retriable

An unhandled failure inside roi. This one is ours.

Retry idempotently. If it persists, quote request_id. It is the whole reason that field is in the body.

API referenceQuickstart