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.
This guide covers all three: reading the envelope, branching on types and codes, retrying without double-charging, and hearing about failures that never throw.
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"
}
}
typestringrequiredCoarse category that maps to 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.
detailsarrayField-level entries, each with its own code, param, and message. Populated when the API reports more than one issue in a single response. A single issue is reported through the top-level code and param instead, so handle both shapes.
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.
remediationobjectMachine-actionable recovery hints: whether the request is retryable and what to do next. See Remediation Hints.
conflict_detailsarrayPresent only on checkout session revision conflicts (CHECKOUT_SESSION_REVISION_CONFLICT). Carries the latest revision and repriced amounts so a checkout client can re-render and retry. See Checkout Sessions.
Branch on code, never on message. Messages are for humans and their wording changes without notice. Codes are part of the contract.
Error Types#
| Type | Status | What happened | What to do |
|---|---|---|---|
validation_error | 400 | A field failed validation | Fix the field named in param, or each entry in details |
invalid_request_error | 400, 405, 409, 415 | The request itself is not acceptable: wrong method, wrong content type, or a stale checkout revision | Fix the request shape, or re-read state on a revision conflict |
authentication_error | 401 | Key missing, invalid, revoked, expired, or not usable in this mode | Fix credentials. Do not retry |
authorization_error | 403 | Key is valid but lacks a required scope | Use a key with the right scopes. Keys cannot upgrade their own scopes |
not_found_error | 404 | No such resource for this merchant in this mode | Check the ID, and check you are not mixing test and live keys |
conflict_error | 409 | The operation conflicts with current state: uniqueness, lifecycle, or idempotency key reuse | Re-read the resource and decide, do not resend blindly |
rate_limit_error | 429 | Too many requests | Wait Retry-After seconds, then retry |
internal_error | 500 | Something failed on Flint's side, including response expansion assembly | Retry with backoff. If it persists, contact support with the request_id |
unavailable_error | 503 | Service temporarily unavailable | Retry with backoff and jitter |
external_service_error | 503 | An upstream dependency failed | Retry with backoff and jitter |
timeout_error | 504 | 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:
| Code | Type | What it means and what to do |
|---|---|---|
INVALID_JSON | validation_error | The body is not valid JSON. Usually a serialization bug on your side |
UNKNOWN_FIELD | validation_error | The body contains a field the endpoint does not accept. Check spelling against the API reference |
INVALID_FIELD_TYPE | validation_error | A field has the wrong JSON type, such as a string where a number belongs |
RESOURCE_LIMIT_EXCEEDED | validation_error | The request body is too large |
INVALID_API_KEY, API_KEY_REVOKED, API_KEY_EXPIRED | authentication_error | The key cannot authenticate. Rotate or reissue the key, then redeploy the secret |
SANDBOX_SELECTION_REQUIRED | authentication_error | A test-mode key must be sandbox-bound. See test-mode errors |
INSUFFICIENT_SCOPE | authorization_error | The key lacks a scope this endpoint requires. Create a key with the right scopes |
RESOURCE_NOT_FOUND | not_found_error | The ID does not exist for this merchant in this mode |
IDEMPOTENCY_KEY_REUSED | conflict_error | The Idempotency-Key was already used with a different body. Generate a fresh key for new work; reuse a key only for exact retries |
IDEMPOTENCY_KEY_IN_PROGRESS | conflict_error | The original request with this key is still executing. This one is safe to retry after a short delay |
MERCHANT_ONBOARDING_REQUIRED | conflict_error | The account is not ready to charge yet. Finish onboarding; see Going Live |
RATE_LIMIT_EXCEEDED | rate_limit_error | Slow down and honor Retry-After. See Rate Limits |
REQUEST_FAILED, INTERNAL_ERROR, SERVICE_UNAVAILABLE | varies | Generic fallbacks when nothing more specific applies. Handle by status class |
New codes ship as the API grows, so always keep a default branch keyed on type and status.
Remediation Hints#
Errors that have a well-defined recovery path include a remediation object. 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",
"remediation": {
"retryable": false,
"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.
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 LLM Integration.
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.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),
}),
);
Replay semantics in brief: the same key with the same body returns the original response with Idempotency-Replayed: true; the same key with a different body fails with IDEMPOTENCY_KEY_REUSED. 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.payment_succeeded,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.payment_succeededevent, 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.
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.
- 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.
