Errors
Errors are typed and predictable. Branch on error.code (a stable string), not on the
human-readable message.
Error shape
Section titled “Error shape”{ "success": false, "error": { "code": "insufficient_funds", "message": "Wallet balance is below the first cycle amount.", "details": { } }, "meta": { "requestId": "req_aBcDeFgHiJkL" }}error.code— stable, machine-readable. The thing to switch on.error.message— human-readable; may change. Don’t parse it.error.details— present for validation errors (field-level info).meta.requestId— include this when contacting support; it ties to your request in the logs.
HTTP status mapping
Section titled “HTTP status mapping”| Status | Meaning |
|---|---|
400 | Bad request — missing idempotency key, business-rule violation (e.g. insufficient_funds). |
401 | Authentication failed — missing/invalid/expired/revoked key. |
403 | Authenticated but not allowed (e.g. onboarding_incomplete). |
404 | Resource not found. |
409 | Conflict — duplicate, or a request already in progress. |
422 | Validation failed, or idempotency-key reused with a different body. |
429 | Rate limited. |
500 | Internal error — safe to retry with the same idempotency key. |
Error-code reference
Section titled “Error-code reference”Cross-cutting
Section titled “Cross-cutting”| Code | HTTP | When |
|---|---|---|
idempotency_required | 400 | Idempotency-Key header missing on a write. |
idempotency_error | 422 | Same idempotency key reused with a different body. |
request_in_progress | 409 | A request with that idempotency key is still running. |
validation_error | 400 | Request body/query failed validation. |
authentication_error | 401 | Auth failed. |
permission_error | 403 | Authenticated but not authorized. |
not_found | 404 | Generic not found. |
rate_limit_error | 429 | Too many requests. |
internal_error | 500 | Server error. |
Wallet & billing
Section titled “Wallet & billing”| Code | HTTP | When |
|---|---|---|
insufficient_funds | 400 | Wallet can’t cover the charge. |
wallet_cap_exceeded | 400 | Top-up would exceed the KYC-tier cap. |
Merchant & onboarding
Section titled “Merchant & onboarding”| Code | HTTP | When |
|---|---|---|
business_not_found | 404 | No business for this actor. |
business_already_exists | 409 | One business per user (Phase 1). |
onboarding_incomplete | 403 | Finish onboarding/KYC before this call. |
api_key_not_found | 404 | Unknown API key. |
api_key_expired | 401 | API key past expiry. |
api_key_revoked | 401 | API key revoked. |
Catalog
Section titled “Catalog”| Code | HTTP | When |
|---|---|---|
product_not_found | 404 | Unknown product. |
product_inactive | 400 | Can’t add a price to an archived product. |
price_not_found | 404 | Unknown price. |
price_not_active | 400 | Can’t subscribe to an inactive price. |
Subscriptions & invoices
Section titled “Subscriptions & invoices”| Code | HTTP | When |
|---|---|---|
subscription_not_found | 404 | Unknown subscription. |
subscription_inactive | 400 | Subscription not in an active state. |
subscription_cannot_cancel | 400 | Can’t cancel from the current state. |
subscription_already_exists | 409 | Customer already subscribed to this price. |
trial_not_available | 400 | No trial available for this subscription. |
invoice_not_found | 404 | Unknown invoice. |
invoice_not_editable | 400 | Invoice not in an editable state. |
Customers, events, webhooks, team
Section titled “Customers, events, webhooks, team”| Code | HTTP | When |
|---|---|---|
customer_not_found | 404 | Unknown customer. |
customer_already_exists | 409 | Email already used for this business. |
event_not_found | 404 | Unknown event. |
webhook_endpoint_not_found | 404 | Unknown webhook endpoint. |
webhook_endpoint_disabled | 400 | Endpoint is disabled. |
webhook_signature_invalid | 401 | Signature verification failed. |
webhook_no_endpoints | 400 | No endpoints registered for the event. |
team_invite_not_found | 404 | Unknown team invite. |
team_member_conflict | 409 | Already a member or invited. |
Handling pattern
Section titled “Handling pattern”const res = await fetch(/* ... */);const body = await res.json();
if (!body.success) { switch (body.error.code) { case "insufficient_funds": return promptTopUp(); case "rate_limit_error": return backoffAndRetry(); case "validation_error": return showFieldErrors(body.error.details); default: logWithRequestId(body.meta.requestId, body.error); }}