Errors
Every failure is JSON in the same envelope, including a 500. Branch on code, show the message, and log the request_id.
The envelope
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "Metered prices must use interval='month'.",
"param": "usage_type",
"request_id": "9f4c2b1ad7e84c0fa1b3e6d590c72b48"
}
} | Field | Notes |
|---|---|
| typestring | Broad class. Always present. |
| codestring | The stable identifier to branch on. Always present. |
| messagestring | English, written for a developer reading a log. Do not parse it, and do not show it to your end users. |
| paramstring | Present when one field caused it. Dotted for nested fields, for example metadata.workspace_id. |
| reasonstring | Present on a handful of cases where code is too coarse to act on. See below. |
| request_idstring | Mirrors the X-Request-Id response header. |
This shape holds for absolutely everything, including unhandled server errors and framework-level validation failures. Your parser never needs a fallback path for a plain-text body.
Log the request id
Every response carries X-Request-Id, and every error repeats it in the body. It is the join key
into BillKit’s own logs. A support conversation that starts with a request id is a short one; one
that starts with “a payment failed this morning” is not.
If you send your own X-Request-Id it is echoed back, provided it is 8 to 128 characters of
letters, digits, _, ., : or -. Anything else is replaced with a generated id, because the
value ends up in logs and an unvalidated one would let a caller forge log lines.
Status codes
| Status | When |
|---|---|
| 400 | A parameter is missing, invalid, or the operation is not allowed in the current state. |
| 401 | Missing, malformed, unknown or revoked API key. |
| 403 | The key is valid but its scopes do not cover this route. |
| 404 | No such object. Also returned for objects in the other mode, or belonging to another account. |
| 409 | A conflict: an idempotency clash, an operation already in flight for this customer, or an object that is no longer in the state the call needs. |
| 422 | The request body failed schema validation. Unknown fields land here. |
| 429 | Rate limited. See Rate limits. |
| 500 | Something failed on our side. Retry with the same Idempotency-Key. |
| 501 | A feature that exists but is not enabled on this deployment, such as PDF rendering. |
404 rather than 403 across accounts. An object that belongs to someone else answers the same as one that does not exist, so the API never confirms that an id is real to someone who cannot see it. Within your own account, a scope problem is a genuine 403.
There is no 402. Card declines do not surface as an error on your API call, because the charge
happens at Mollie after the customer leaves your server. A decline arrives as
payment.failed or checkout.session.expired over webhooks.
Codes
| code | type · status |
|---|---|
| authentication_required | authentication_error · 401. No Authorization header. |
| invalid_api_key | authentication_error · 401. Malformed or unknown key. |
| revoked_api_key | authentication_error · 401. The key was revoked. |
| insufficient_scope | permission_error · 403. The key lacks the route’s scope. |
| resource_missing | not_found_error · 404. |
| parameter_invalid | invalid_request_error · 400 or 422. Check param. |
| parameter_missing | invalid_request_error · 400. |
| idempotency_in_progress | conflict_error · 409. Same key still running. |
| idempotency_key_in_use | conflict_error · 409. Same key, different body or route. |
| operation_pending | conflict_error · 409. A payment operation is already in flight for this customer. |
| payment_method_in_use | conflict_error · 409. Removing this saved method would strand an active subscription. |
| checkout_session_not_open | 409. The session was already completed or expired. |
| rate_limited | rate_limit_error · 429. |
| configuration_missing | api_error · 500. A setting the operator has to populate is absent. |
| not_implemented | api_error · 501. |
| unhandled | api_error · 500. Report it with the request_id. |
Reason codes
code answers “what kind of failure”; many distinct situations share one. reason is the
i18n-stable supplement for the cases where a user-facing surface has to say something specific.
Map reason to your own copy, and fall back to code when you do not recognise it. New reasons
can be added at any time, which is safe precisely because unknown ones fall back.
| reason | Meaning |
|---|---|
| vat_not_recognised | VIES rejected the VAT number. |
| vat_unverifiable | VIES could not be reached or the member state did not answer. |
| no_active_mandate | No usable payment mandate. The customer has to reauthorize. |
| customer_not_registered_with_provider | The customer has no counterpart at the payment provider yet. |
| subscription_terminal | The subscription is cancelled and cannot be changed. |
| period_window_expired | The paid-through window has passed, so there is nothing to resume. |
| coupon_invalid / coupon_expired / coupon_exhausted / coupon_not_applicable / coupon_already_applied | Why a coupon code was refused. These map straight to checkout copy. |
| customer_has_active_subscription | A customer delete or a GDPR purge was refused. Cancel the subscription first. |
| idempotency_key_too_long | The Idempotency-Key header exceeded 255 characters. |
Retrying
429 and 5xx are worth retrying. Everything in the 4xx range other than 429 will fail
identically however many times you send it.
Use exponential backoff with jitter, and send the same
Idempotency-Key on every attempt. A 500 clears the stored key, so the
retry does the work; a success or a client error replays the original response. Either way you
cannot double-charge by retrying.
BillKit