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 (429 and 5xx): 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
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" } }
    ]
  }'
Response
{
  "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."
    }
  }
}
typestringRequired

Coarse 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.

codestringRequired

Stable, 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.

messagestringRequired

Human-readable explanation, written for your logs. Wording can change without notice, so never build logic on it and never show it to buyers verbatim.

paramstring

Dotted 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.

detailsarray

Each 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_idstring

Unique 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_urlstringRequired

Link to the generated error catalog entry point. Use code to locate the specific recovery guidance.

request_log_urlstring

Authenticated API path for request logs filtered to this request_id. Present when the error has a request ID.

error_sourcestringRequired

The 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.

remediationobject

Machine-actionable recovery hints: whether the request is retryable and what to do next. See Remediation Hints.

blocking_resourcesarray

Resources 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_countinteger

Total blocking resources, including those omitted from the bounded blocking_resources list.

conflict_detailsarray

Present 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_merchantsarray

On 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.

reasonstring

On 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_idstring

On 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_idstring

On CHECKOUT_SESSION_CURRENT_CHANGED, when known, the ID of the session that is current when a replacement request named a stale one.

Warning:

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.

StatusTypeWhat happenedWhat to do
400, 422validation_errorThe request failed validationFix the field named in param, or each entry in details
401authentication_errorKey missing, invalid, revoked, expired, or not usable in this modeFix credentials. Do not retry
402payment_errorThe payment attempt was declined or blockedSurface a buyer-facing decline and collect another payment method; branch on the specific code
403authorization_errorKey is valid but lacks a required scopeUse a key with the right scopes. Keys cannot upgrade their own scopes
404not_found_errorNo such resource for this merchant in this modeCheck the ID, and check you are not mixing test and live keys
405, 410, 413, 415invalid_request_errorWrong method, a retired API version, a body over 1 MiB, or wrong content typeFix the request method, API version, body size, or content type
409conflict_errorThe operation conflicts with current state: uniqueness, lifecycle, stale checkout revision, or idempotency key reuseRe-read the resource and decide, do not resend blindly
429rate_limit_errorToo many requestsWait Retry-After seconds, then retry
500 and all other error statusesinternal_errorSomething failed on Flint's side, including response expansion assemblyRetry with backoff. If it persists, ask for help with the request_id
502external_service_errorAn upstream dependency failedRetry with backoff and jitter
503unavailable_errorService temporarily unavailableRetry with backoff and jitter
504timeout_errorThe request ran out of time. The operation may still have completedRetry 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:

  • The body is not valid JSON. Usually a serialization bug on your side (validation_error)
  • The body contains a field the endpoint does not accept. Check spelling against the API reference (validation_error)
  • A field has the wrong JSON type, such as a string where a number belongs (validation_error)
  • The body is over 1 MiB, the limit for every endpoint unless its reference names a lower one. Split the write into several requests, such as adding an order's line items in batches (invalid_request_error, HTTP 413)
  • The key cannot authenticate. Rotate or reissue the key, then redeploy the secret (authentication_error)
  • The key cannot authenticate. Rotate or reissue the key, then redeploy the secret (authentication_error)
  • The key cannot authenticate. Rotate or reissue the key, then redeploy the secret (authentication_error)
  • A test-mode key must be sandbox-bound. See test-mode errors (authentication_error)
  • The key lacks a scope this endpoint requires. Create a key with the right scopes (authorization_error)
  • The ID does not exist for this merchant in this mode (not_found_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 (conflict_error)
  • The original request with this key is still executing. This one is safe to retry after a short delay (conflict_error)
  • A standalone payment-intent confirmation was blocked by a Flint risk rule or processor fraud screening (payment_error, HTTP 402). Order pay reports the blocked leg in a 200 response with last_payment_error.code set to payment_blocked. Ask the buyer for a different payment method without exposing rule or fraud details.
  • Capture is gated by an open review. Resolve the review, re-read the payment, and capture only if the authorization remains valid (conflict_error)
  • Another capture, review action, expiry, or cancellation owns this payment. Re-read state instead of starting a competing operation (conflict_error)
  • Risk evaluation could not complete. Retry confirmation with the same idempotency key after a short delay (unavailable_error)
  • The account is not ready to charge yet. Finish onboarding; see Going live (conflict_error)
  • Signup used an email that already has a Flint account. Sign in with flint login or the dashboard instead (conflict_error, HTTP 409)
  • The account already issued its first key through signup. Run flint login, or create another key in the dashboard (conflict_error, HTTP 409)
  • RATE_LIMIT_EXCEEDEDRetryable
    Slow down and honor Retry-After. See Rate limits (rate_limit_error)
  • Generic fallbacks when nothing more specific applies. Handle by status class (varies)
  • INTERNAL_ERRORRetryable
    Generic fallbacks when nothing more specific applies. Handle by status class (varies)
  • SERVICE_UNAVAILABLERetryable
    Generic fallbacks when nothing more specific applies. Handle by status class (varies)

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:

JSON
{
  "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"]
        }
      ]
    }
  }
}
retryableboolean

Whether retrying the same request can succeed. When present, trust it over your own status-based heuristic.

next_stepsstring

Immediate human-readable recovery instruction for this error code.

missing_or_invalid_fieldsarray

Field paths to fix before resubmitting.

next_actionsarray

Concrete 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#

HeaderWhenMeaning
X-Request-IdEvery responseCorrelation ID, identical to error.request_id. Log it even on success
Retry-After429 onlyWhole seconds to wait before retrying
Flint-Rate-Limited-Reason429 onlyWhich limit was hit, such as api-key, merchant, or global-ip
Idempotency-Key, Idempotency-ReplayedIdempotent writesIdempotency-Replayed: true means this is the cached original response, not a new execution
Flint-ModeEvery responsetest 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:

JavaScript
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:

JavaScript
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:

  1. Retry only 429, 5xx, and network failures where no response arrived. Never retry a 4xx unchanged; the one exception is IDEMPOTENCY_KEY_IN_PROGRESS, which succeeds once the original request finishes.
  2. Honor Retry-After when present. Otherwise use exponential backoff with jitter, and cap total attempts.
  3. Send an Idempotency-Key on 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.
  4. Treat conflict_error as a signal to re-read state, not to resend. The resource moved; find out where it is now.
JavaScript
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, and subscription.payment_failed. See Webhooks for reliable processing.
  • Resource state, when you re-fetch. An order reports paid_money and balance_money; a failed refund reports status: "failed" with a failure_reason.

Three rules follow:

  1. Never treat a 2xx from 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.
  2. Mark orders paid from the order.paid event, not from a redirect or a create response.
  3. 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, and param on every non-2xx response.
  • Alert on your 5xx and 429 rates, and on 401/403 spikes, 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 type and code values, keyed on HTTP status class.
  • Subscribe to failure webhook events for every payment flow you run, and test each handler.
  • Quote the request_id when 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_id into a diagnosis.
  • CLI: the same failure classes as stable exit codes, so a script can branch on them without parsing stderr.

Was this helpful?