33 — Developer
Errors
Every provider speaks its own dialect of failure. Finscale normalizes all of them into one envelope, one set of types, and one decline-code vocabulary — whichever provider ran the transaction.
The error envelope
Any non-2xx response carries a single error object:
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "The card was declined by the issuer.",
"doc_url": "https://docs.finscale.dev/errors/#card_declined",
"request_id": "req_7Hf3kQd2"
}
}
| Field | Always present | Meaning |
|---|---|---|
type | Yes | Which class of failure — drives your handling branch. See error types. |
code | Usually — null when no specific code applies | Machine-readable specific cause, stable across API versions. |
message | Yes | Human-readable explanation. For logs and dashboards — don't show it verbatim to customers, and don't parse it. |
param | Validation errors | The request field that failed, e.g. "param": "currency". Only on invalid_request_error. |
doc_url | Most errors | Deep link to the relevant section of this page. |
request_id | Yes | Quote this id (req_7Hf3kQd2) in support requests — it indexes the full request trace. |
A validation failure looks like this — note param:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "amount must be a positive integer in minor units (4900 = €49.00).",
"param": "amount",
"request_id": "req_7Hf3kQd2"
}
}
Error types
| Type | HTTP status | What it means | Retry? |
|---|---|---|---|
invalid_request_error |
400 / 404 | Malformed request or unknown resource. param names the offending field. |
No — fix the request. |
authentication_error |
401 | Missing, invalid, expired, or under-scoped API key. See Authentication. | No — fix the key. |
card_error |
402 | The payment was declined. code carries the decline code — the normal, expected failure class of a payments API. |
Only with a new instrument or per the decline code. |
idempotency_error |
409 | An Idempotency-Key was reused with a different payload. See Idempotency. |
No — use a fresh key. |
rate_limit_error |
429 | Too many requests. Honor the Retry-After response header. |
Yes, with backoff. |
api_error |
5xx | Something failed on Finscale's side. Rare by design. | Yes — safely, with the same idempotency key. |
Decline codes
When type is card_error (or a local-method equivalent), code tells you why — already normalized across providers. Hard declines are final for that instrument; soft declines are circumstantial and eligible for failover or a later retry.
| Code | Kind | Meaning | What to do |
|---|---|---|---|
card_declined | hard | Generic issuer decline with no further detail given. | Ask the customer for another payment method. |
do_not_honor | hard | The issuer refused without a reason code. Common and opaque. | Don't retry the same card; offer an alternative method. |
insufficient_funds | hard | Not enough balance or credit at authorization time. | Retry later or offer another method. Test with 4000 0000 0000 9995. |
expired_card | hard | The card's expiry date has passed. | Ask for updated card details. |
incorrect_cvc | hard | Security code check failed. | Ask the customer to re-enter their details. |
incorrect_number | hard | The card number fails validation (Luhn or length). | Ask the customer to re-enter the number. |
lost_card | hard | Reported lost by the cardholder. | Never retry. Request a different instrument. |
stolen_card | hard | Reported stolen. The generic message deliberately doesn't say so to the customer. | Never retry. Request a different instrument. |
fraud_suspected | hard | Declined by issuer or provider risk screening. | Don't auto-retry; route the order to manual review. |
authentication_required | soft | The issuer demands 3-D Secure before approving. | Re-create the payment; Finscale returns requires_action with the challenge redirect. |
processing_error | soft | A transient error occurred while processing at the provider. | Nothing — failover retries this automatically. |
provider_unavailable | soft | The routed provider was down or timed out. | Nothing — failover retries on the next provider automatically. |
limit_exceeded | soft | The transaction exceeds a velocity or amount limit at the issuer or method (common with Blik and bank transfers). | Retry later or with a smaller amount. |
currency_not_supported | hard | No connected provider can process this currency/method pair. | Charge in a supported currency or enable another method. |
payment_canceled | n/a | The customer abandoned or canceled the redirect flow (iDEAL bank page, 3DS challenge, …). | Invite the customer to try again; the payment is terminal. |
Branch on error.code. The message string can be reworded at any time without an API version bump.
How failover interacts with declines
Declines come in two kinds, and smart routing treats them very differently:
- Soft declines —
provider_unavailable,processing_error, timeouts — say something about the provider, not the customer. Finscale retries the transaction on the next-best provider automatically, inside the same API call. You only ever see an error if every eligible provider fails. - Hard declines —
insufficient_funds,stolen_card,expired_card,fraud_suspected— say something about the instrument. Re-sending the same card to a different acquirer would produce the same answer (and hurt your fraud metrics), so Finscale surfaces the decline immediately and never re-routes it.
The provider_attempts trail on the payment object shows exactly what happened:
{
"id": "pay_8Q2mX4nT1cVb",
"status": "succeeded",
"provider": "prov_eu_acq_02",
"provider_attempts": [
{ "provider": "prov_eu_acq_01", "outcome": "timeout", "decline_code": null },
{ "provider": "prov_eu_acq_02", "outcome": "approved" }
]
}
Simulate this yourself with the magic amount 4999 — see Testing.
A minimal handling recipe
Branch on HTTP status first, then on error.type:
2xx— inspectstatuson the payment object;requires_actionmeans redirect the customer, not success.402 card_error— show a customer-friendly message keyed onerror.code; offer another payment method.409 idempotency_error— a bug on your side; logrequest_idand generate keys per logical operation, as described in Idempotency.429— back off forRetry-Afterseconds, then retry with the same idempotency key.5xxor network timeout — retry with the same idempotency key; the replay guarantee makes this safe even if the original request actually went through.