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.

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.

Bash
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" } }
    ]
  }'
JSON
{
  "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"
  }
}
typestringrequired

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

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

Field-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_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.

remediationobject

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

conflict_detailsarray

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

TypeStatusWhat happenedWhat to do
validation_error400A field failed validationFix the field named in param, or each entry in details
invalid_request_error400, 405, 409, 415The request itself is not acceptable: wrong method, wrong content type, or a stale checkout revisionFix the request shape, or re-read state on a revision conflict
authentication_error401Key missing, invalid, revoked, expired, or not usable in this modeFix credentials. Do not retry
authorization_error403Key is valid but lacks a required scopeUse a key with the right scopes. Keys cannot upgrade their own scopes
not_found_error404No such resource for this merchant in this modeCheck the ID, and check you are not mixing test and live keys
conflict_error409The operation conflicts with current state: uniqueness, lifecycle, or idempotency key reuseRe-read the resource and decide, do not resend blindly
rate_limit_error429Too many requestsWait Retry-After seconds, then retry
internal_error500Something failed on Flint's side, including response expansion assemblyRetry with backoff. If it persists, contact support with the request_id
unavailable_error503Service temporarily unavailableRetry with backoff and jitter
external_service_error503An upstream dependency failedRetry with backoff and jitter
timeout_error504The 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:

CodeTypeWhat it means and what to do
INVALID_JSONvalidation_errorThe body is not valid JSON. Usually a serialization bug on your side
UNKNOWN_FIELDvalidation_errorThe body contains a field the endpoint does not accept. Check spelling against the API reference
INVALID_FIELD_TYPEvalidation_errorA field has the wrong JSON type, such as a string where a number belongs
RESOURCE_LIMIT_EXCEEDEDvalidation_errorThe request body is too large
INVALID_API_KEY, API_KEY_REVOKED, API_KEY_EXPIREDauthentication_errorThe key cannot authenticate. Rotate or reissue the key, then redeploy the secret
SANDBOX_SELECTION_REQUIREDauthentication_errorA test-mode key must be sandbox-bound. See test-mode errors
INSUFFICIENT_SCOPEauthorization_errorThe key lacks a scope this endpoint requires. Create a key with the right scopes
RESOURCE_NOT_FOUNDnot_found_errorThe ID does not exist for this merchant in this mode
IDEMPOTENCY_KEY_REUSEDconflict_errorThe 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_PROGRESSconflict_errorThe original request with this key is still executing. This one is safe to retry after a short delay
MERCHANT_ONBOARDING_REQUIREDconflict_errorThe account is not ready to charge yet. Finish onboarding; see Going Live
RATE_LIMIT_EXCEEDEDrate_limit_errorSlow down and honor Retry-After. See Rate Limits
REQUEST_FAILED, INTERNAL_ERROR, SERVICE_UNAVAILABLEvariesGeneric 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:

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

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

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 LLM Integration.

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.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),
  }),
);

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

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.
  • 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.
Rate this doc