Error handling
Every failure your integration will meet falls into one of three groups, and each group needs a different response:
- Request errors (
4xx): the request is wrong, or your key cannot do what it is asking. Fix the request. Retrying it unchanged returns the same error. - Transient errors (
429and5xx): the request was fine, the moment was not. Retry with backoff and an idempotency key. - Payment outcomes: declined cards, failed refunds, failed payouts. These are not HTTP errors at all. They arrive as resource state and webhook events, and your product handles them as normal business outcomes.
Read the error envelope, branch on types and codes, retry without double-charging, and listen for asynchronous payment failures.
The error envelope#
Every failing request returns the same JSON shape. Trigger one right now: this request omits the line item's required name.
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"line_items": [
{ "quantity": 1, "unit_price_money": { "amount": 400, "currency": "USD" } }
]
}'
{
"error": {
"type": "validation_error",
"code": "LINE_ITEM_NAME_REQUIRED",
"message": "Line item 0: name is required",
"param": "line_items[0].name",
"request_id": "97879286-b522-4c73-9021-31c18e8d77f3",
"doc_url": "https://developers.withflintpay.com/docs/errors",
"request_log_url": "/v1/developer/request-logs?request_id=97879286-b522-4c73-9021-31c18e8d77f3",
"error_source": "merchant",
"remediation": {
"retryable": false,
"next_steps": "Provide the field identified by param and retry."
}
}
}
typestringRequiredCoarse category determined by the HTTP status. Use it to pick a handling strategy: fix, re-authenticate, retry, or re-read state. All values are listed in the table below.
codestringRequiredStable, machine-readable identifier for the specific failure, such as LINE_ITEM_NAME_REQUIRED or IDEMPOTENCY_KEY_REUSED. This is the field to branch on in code.
messagestringRequiredHuman-readable explanation, written for your logs. Wording can change without notice, so never build logic on it and never show it to buyers verbatim.
paramstringDotted path of the request field that caused the failure, such as line_items[0].name. Present on most validation errors; use it to highlight the offending form field.
detailsarrayEach item is one individual failure belonging to the top-level error. An item may repeat the top-level code to carry item context, including for a single failure. Every details[].code is registered and listed in the operation's x-flint-error-codes. Facts appear in typed fields; param contains only request paths.
request_idstringUnique identifier for this request, also returned in the X-Request-Id response header on every response. Log it on every failure and quote it when contacting support.
doc_urlstringRequiredLink to the generated error catalog entry point. Use code to locate the specific recovery guidance.
request_log_urlstringAuthenticated API path for request logs filtered to this request_id. Present when the error has a request ID.
error_sourcestringRequiredThe system responsible for the failed input or operation: merchant for the request or merchant configuration, integration for a merchant-configured callback, or flint for Flint's API and infrastructure.
remediationobjectMachine-actionable recovery hints: whether the request is retryable and what to do next. See Remediation Hints.
blocking_resourcesarrayResources that block an operation. Each reference has resource_type, resource_id, and an optional current status. The top-level fields repeat the first details[] entry. Use the IDs to retrieve or resolve the blockers; do not parse them from message.
When blockers are known, invoice and order dependency conflicts list up to 25 resources and include blocking_resource_count for the total. This applies to INVOICE_HAS_ISSUED_CREDIT_NOTE, CREDIT_NOTE_HAS_ALLOCATIONS, ORDER_ALREADY_HAS_ACTIVE_INVOICE, ORDER_HAS_OPEN_CHECKOUT, ORDER_HAS_ACTIVE_PAYMENT_INTENT, ORDER_ALREADY_HAS_PAYMENTS, ORDER_ALREADY_HAS_REFUNDS, and ORDER_HAS_MANUAL_PAYMENTS. Active credit-note allocations use credit_note_allocation; refund blockers use the public refund ID. Manual payment blockers identify the owning invoice, where you can reverse the payment. Posted value payments identify their gift_card_redemption or return_resolution; invoice write-offs identify the owning invoice. Resolve the listed resources and retry to discover any remaining blockers.
ORDER_PAYMENT_LEG_CHECKOUT_ACTIVE and CHECKOUT_SPLIT_PAYMENT_UNSUPPORTED also include blocking_resources alongside their typed session and payment intent IDs.
On CHECKOUT_SESSION_CURRENT_CHANGED, blocking_resources and blocking_resource_count are present only when the current checkout session is known. A checkout creation race returns retryable ORDER_CHECKOUT_SESSION_CHANGED without these fields.
Collection ownership conflicts also identify the invoice, checkout session, or payment attempt holding collection. Buyer credentials do not receive merchant-only blocking resources.
blocking_resource_countintegerTotal blocking resources, including those omitted from the bounded blocking_resources list.
conflict_detailsarrayPresent only on checkout session revision conflicts (CHECKOUT_SESSION_REVISION_CONFLICT, a conflict_error with status 409), raised when updating an Order line item's modifiers through a checkout session. Carries the latest revision and repriced amounts so the client can re-render and retry against the latest revision. Every conflict_details[].code is registered and listed in the operation's x-flint-error-codes.
selectable_merchantsarrayOn MERCHANT_SELECTION_REQUIRED or INVALID_MERCHANT_SELECTION, the merchants available to your developer identity. Each item contains merchant_id and business_name. Retry with one of these merchant IDs.
reasonstringOn CHECKOUT_SESSION_MODIFIERS_READ_ONLY, existing_order_checkout means the checkout uses an existing Order, invoice_finalized means it uses a finalized Invoice, and subscription_terms_locked means its subscription terms are locked. On PROMOTION_DECLINED, this field identifies the promotion decline reason.
existing_checkout_session_idstringOn CHECKOUT_SESSION_ALREADY_EXISTS and related conflicts, the ID of the open session that already owns the order. Recover the session from this field, never by parsing message. See Checkout sessions.
current_checkout_session_idstringOn CHECKOUT_SESSION_CURRENT_CHANGED, when known, the ID of the session that is current when a replacement request named a stale one.
Branch on code, never on message. Messages are for humans and their wording changes without notice. Codes are part of the contract.
Error types#
The HTTP status determines type. Every Flint error envelope with the same status has the same type.
| Status | Type | What happened | What to do |
|---|---|---|---|
| 400, 422 | validation_error | The request failed validation | Fix the field named in param, or each entry in details |
| 401 | authentication_error | Key missing, invalid, revoked, expired, or not usable in this mode | Fix credentials. Do not retry |
| 402 | payment_error | The payment attempt was declined or blocked | Surface a buyer-facing decline and collect another payment method; branch on the specific code |
| 403 | authorization_error | Key is valid but lacks a required scope | Use a key with the right scopes. Keys cannot upgrade their own scopes |
| 404 | not_found_error | No such resource for this merchant in this mode | Check the ID, and check you are not mixing test and live keys |
| 405, 410, 413, 415 | invalid_request_error | Wrong method, a retired API version, a body over 1 MiB, or wrong content type | Fix the request method, API version, body size, or content type |
| 409 | conflict_error | The operation conflicts with current state: uniqueness, lifecycle, stale checkout revision, or idempotency key reuse | Re-read the resource and decide, do not resend blindly |
| 429 | rate_limit_error | Too many requests | Wait Retry-After seconds, then retry |
| 500 and all other error statuses | internal_error | Something failed on Flint's side, including response expansion assembly | Retry with backoff. If it persists, ask for help with the request_id |
| 502 | external_service_error | An upstream dependency failed | Retry with backoff and jitter |
| 503 | unavailable_error | Service temporarily unavailable | Retry with backoff and jitter |
| 504 | timeout_error | The request ran out of time. The operation may still have completed | Retry with the same Idempotency-Key so a completed write is replayed, not repeated |
The type field is a closed set. If you ever see a value outside this table, treat it as a contract violation: log the request_id, fall back to the HTTP status class, and contact support.
Error codes to handle by name#
The full catalog, with a retryable flag and a recommended fix for every code, lives at Error Codes. Endpoint-specific validation codes such as LINE_ITEM_NAME_REQUIRED are self-describing and rarely need their own branch; the codes below are the ones worth explicit handling in most integrations:
New codes ship as the API grows, so always keep a default branch keyed on type and status.
Remediation hints#
Every error includes a remediation object derived from the same catalog as the error reference. It tells your code, not just your logs, what to do next:
{
"error": {
"type": "authentication_error",
"code": "SANDBOX_SELECTION_REQUIRED",
"message": "Test mode API keys must be sandbox-bound before they can authenticate public API requests.",
"request_id": "f81d4fae-7dec-4b1d-a765-00a0c91e6bf6",
"doc_url": "https://developers.withflintpay.com/docs/errors",
"request_log_url": "/v1/developer/request-logs?request_id=f81d4fae-7dec-4b1d-a765-00a0c91e6bf6",
"error_source": "merchant",
"remediation": {
"retryable": false,
"next_steps": "Create or use a sandbox-bound test key for the sandbox this request should target.",
"next_actions": [
{
"action_type": "issue_developer_sandbox_test_key",
"reason_code": "SANDBOX_BOUND_TEST_KEY_REQUIRED",
"reason_message": "Create or use a sandbox-bound test key for the sandbox this request should target.",
"required_fields": ["sandbox_id"]
}
]
}
}
}
retryablebooleanWhether retrying the same request can succeed. When present, trust it over your own status-based heuristic.
next_stepsstringImmediate human-readable recovery instruction for this error code.
missing_or_invalid_fieldsarrayField paths to fix before resubmitting.
next_actionsarrayConcrete recovery steps. Each entry has an action_type, a reason_code and reason_message, and may include required_fields, a url to visit, an expires_at, requires_human_confirmation, and a suggested_delay_milliseconds before acting.
Remediation is designed to be machine-actionable, which makes it especially useful for agent-driven integrations: an agent can read next_actions and recover without a human interpreting log output. See AI agents.
Response headers#
| Header | When | Meaning |
|---|---|---|
X-Request-Id | Every response | Correlation ID, identical to error.request_id. Log it even on success |
Retry-After | 429 only | Whole seconds to wait before retrying |
Flint-Rate-Limited-Reason | 429 only | Which limit was hit, such as api-key, merchant, or global-ip |
Idempotency-Key, Idempotency-Replayed | Idempotent writes | Idempotency-Replayed: true means this is the cached original response, not a new execution |
Flint-Mode | Every response | test or live, useful when diagnosing key mix-ups |
Flint does not send X-RateLimit-* quota headers. Size your steady-state throughput against the published limits in Rate limits instead of probing for remaining quota.
Write one error handler#
Centralize parsing so the rest of your code works with typed errors instead of raw responses:
class FlintApiError extends Error {
constructor(status, headers, body) {
const e = body?.error ?? {};
super(e.message ?? `Flint API error (HTTP ${status})`);
this.name = "FlintApiError";
this.status = status;
this.type = e.type ?? "internal_error";
this.code = e.code ?? "UNKNOWN";
this.param = e.param;
this.details = e.details ?? [];
this.remediation = e.remediation;
this.requestId = e.request_id ?? headers.get("X-Request-Id");
this.docUrl = e.doc_url;
this.requestLogUrl = e.request_log_url;
this.errorSource = e.error_source;
this.retryAfterMs = headers.has("Retry-After")
? Number(headers.get("Retry-After")) * 1000
: undefined;
// Trust the server's hint when present; fall back to status class.
this.retryable =
e.remediation?.retryable ?? (status === 429 || status >= 500);
}
}
async function flintFetch(path, options = {}) {
const res = await fetch(`https://api.withflintpay.com${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.FLINT_API_KEY}`,
...options.headers,
},
});
const body = res.status === 204 ? null : await res.json();
if (!res.ok) throw new FlintApiError(res.status, res.headers, body);
return body;
}
Validation errors deserve their own branch, because they map to user-visible form fields. Remember the two shapes: a single issue arrives in the top-level code and param, multiple issues arrive in details:
try {
await flintFetch("/v1/orders", {
method: "POST",
body: JSON.stringify(payload),
});
} catch (err) {
if (err instanceof FlintApiError && err.type === "validation_error") {
const issues = err.details.length
? err.details
: [{ code: err.code, param: err.param, message: err.message }];
for (const issue of issues) {
showFieldError(issue.param, issue.message);
}
return;
}
throw err;
}
Retry without Double-Charging#
Four rules make retries safe:
- Retry only
429,5xx, and network failures where no response arrived. Never retry a4xxunchanged; the one exception isIDEMPOTENCY_KEY_IN_PROGRESS, which succeeds once the original request finishes. - Honor
Retry-Afterwhen present. Otherwise use exponential backoff with jitter, and cap total attempts. - Send an
Idempotency-Keyon every write you might retry. A timeout does not mean the write failed; the key guarantees a retry replays the original result instead of executing twice. - Treat
conflict_erroras a signal to re-read state, not to resend. The resource moved; find out where it is now.
async function withRetries(request, { attempts = 4 } = {}) {
let delayMs = 500;
for (let attempt = 1; ; attempt++) {
try {
return await request();
} catch (err) {
const retryable =
err instanceof FlintApiError
? err.retryable || err.code === "IDEMPOTENCY_KEY_IN_PROGRESS"
: true; // network failure: no response, safe with an idempotency key
if (!retryable || attempt === attempts) throw err;
const waitMs = err.retryAfterMs ?? delayMs + Math.random() * delayMs;
await new Promise((resolve) => setTimeout(resolve, waitMs));
delayMs = Math.min(delayMs * 2, 30_000);
}
}
}
await withRetries(() =>
flintFetch("/v1/orders", {
method: "POST",
headers: { "Idempotency-Key": "order-create-2098" },
body: JSON.stringify(payload),
}),
);
For endpoints that replay results, an identical retry with a key Flint has in progress or on record either replays the original response or receives IDEMPOTENCY_KEY_IN_PROGRESS. A changed body fails with IDEMPOTENCY_KEY_REUSED. Use a new key for a corrected request. Key generation, per-endpoint support, and the replay window are covered in Idempotency.
A declined card is not an error#
A declined card produces no error on your server, no exception, and no failing HTTP status. The buyer sees the decline in the checkout UI and can try another card; on your side, the order simply stays open with paid_money at 0 until a payment succeeds. If you only handle thrown errors, declines are invisible to you.
Payment outcomes arrive through two channels:
- Webhook events, the source of truth for money movement. Subscribe to the failure events for every flow you run, not just the success events:
payment_intent.payment_failed,order.paid,refund.failed,payout.failed, andsubscription.payment_failed. See Webhooks for reliable processing. - Resource state, when you re-fetch. An order reports
paid_moneyandbalance_money; a failed refund reportsstatus: "failed"with afailure_reason.
Three rules follow:
- Never treat a
2xxfrom a create call as payment success. Creating a checkout session, an invoice, or a payment intent starts a payment; only events and resource state finish one. - Mark orders paid from the
order.paidevent, not from a redirect or a create response. - Show buyers your own retry-friendly message on failure. Decline reasons exist for your logs and dashboards, not for buyer-facing copy.
If you drive the order payment flow directly (rather than through Flint-hosted checkout), the decline detail also comes back inline on the POST /v1/orders/{order_id}/pay response: the payment attempt's per-leg last_payment_error names the normalized reason, and is_resumable tells you whether the same attempt can continue. Do not read is_resumable: false as "start over": an attempt that is finalizing has already taken the buyer's money and is waiting on Flint, not on you. See Declines and payment attempts for the full recovery model, including finalizing and recovering a lost response.
To exercise these paths before launch, force declines and authentication challenges with test cards: see Testing.
Production checklist#
- Log
request_id,type,code, andparamon every non-2xx response. - Alert on your
5xxand429rates, and on401/403spikes, which usually mean a rotated or misconfigured key rather than a Flint incident. - Cap retry attempts and use jitter, so an outage does not turn your workers into a thundering herd.
- Keep a default branch for unknown
typeandcodevalues, keyed on HTTP status class. - Subscribe to failure webhook events for every payment flow you run, and test each handler.
- Quote the
request_idwhen contacting support, and use Debugging to trace it yourself first.
Next steps#
- Error Codes: the full catalog with a recommended fix for every code.
- Card decline codes: what each issuer decline means, whether to retry, and what to tell the customer.
- Idempotency: key mechanics, replay window, and which endpoints accept keys.
- Rate limits: route classes, current limits, and planning throughput.
- Webhooks: signature verification and processing events reliably.
- Testing: trigger declines and test-mode errors on purpose.
- Debugging: turn a
request_idinto a diagnosis. - CLI: the same failure classes as stable exit codes, so a script can branch on them without parsing stderr.
