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. Absent on an error replayed from an idempotency record, so read the header too. |
This shape holds for everything, including unhandled server errors, schema validation failures
and requests to a path that does not exist (404 route_not_found) or with a method the path does
not serve (405 method_not_allowed, with an Allow header). 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 unknown. Every validation failure is a 400, whether the schema or a business rule caught it: a required field left out (parameter_missing), a malformed body, an out-of-range value such as limit=500 (parameter_invalid), a body field or query parameter the route does not have (parameter_unknown), and an update that sends null for a field that cannot be cleared. param names the field. The API never answers 422. |
| 401 | Missing, malformed, unknown or revoked API key. |
| 402 | Either your BillKit account is suspended (tenant_suspended: nothing is wrong with the request or the key; settle the account in the console), or the payment provider refused a charge or refund the call had to make on the spot (payment_refused, type: payment_error). |
| 403 | The key is valid but cannot make this call: its scopes do not cover the route, or an expand it asked for (insufficient_scope), or the call needs a key of the other mode (mode_mismatch). |
| 404 | No such object (resource_missing). Also returned for objects in the other mode, or belonging to another account. A path that matches no route is also a 404, with the code route_not_found. |
| 405 | The path exists, but not for this method (method_not_allowed). The Allow header lists the methods it does serve. |
| 409 | A conflict: an idempotency clash, an operation already in flight for this customer, or an object that is not in the state the call needs (state_conflict, or one of the specific codes below), such as refunding a payment that is unpaid or past its refund window. The request itself is fine; sending it again fails the same way until the object changes. |
| 429 | Rate limited. See Rate limits. |
| 500 | Something failed on our side (api_error). Retry with the same Idempotency-Key. Declared on every operation in the OpenAPI document, so a generated client decodes it into the same error type as any other status. |
| 501 | A feature that exists but is not enabled on this deployment, such as PDF rendering. |
| 502 | A write the payment provider failed, did not answer, or answered with something unusable, for example while creating a refund. The outcome is unknown: it may have taken effect. Retry with the same Idempotency-Key, or read the object to see where it stands. A provider failure on a read answers 500 instead. |
| 503 | Temporarily at capacity, or the request was cut off before it finished. reason says which. See Retrying. |
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.
Card declines on checkouts and renewals never 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, subscription.past_due or checkout.session.expired over webhooks. The calls
that move money synchronously are the exception: a plan change’s proration charge, the payment
of a past-due period through reauthorize_payment_method, and a refund answer
402 payment_refused (type: payment_error, reason: payment_refused) when Mollie refuses to
create the payment or refund, and nothing moves.
The other 402 is tenant_suspended, about your own BillKit account.
Codes
| code | type · status |
|---|---|
| authentication_required | authentication_error · 401. No Authorization header, or an empty bearer token. |
| invalid_api_key | authentication_error · 401. Malformed or unknown key. |
| revoked_api_key | authentication_error · 401. The key was revoked. |
| tenant_suspended | permission_error · 402. Your BillKit account is suspended. |
| insufficient_scope | permission_error · 403. The key lacks the route’s scope, or the read scope of a resource it asked to expand (then param is expand). |
| mode_mismatch | permission_error · 403. The call needs a key of the other mode: a live key on test clocks, which exist only in test mode, or a test key changing a setting shared by both modes (branding, billing details) or the live provider credential. The same request with a key of the right mode succeeds. |
| resource_missing | not_found_error · 404. The object does not exist, or is not yours to see. |
| route_not_found | invalid_request_error · 404. No route at this path. Check the path, and that it starts with /v1. |
| method_not_allowed | invalid_request_error · 405. The path exists but not for this method. The Allow header lists what it serves. |
| parameter_invalid | invalid_request_error · 400. Check param. |
| parameter_missing | invalid_request_error · 400. A required parameter was left out, whether the schema always requires it or the combination you sent does (payment_method on a hosted one-off payment, duration_in_months on a repeating coupon). param names it. |
| parameter_unknown | invalid_request_error · 400. A body field or query parameter the route does not have, usually a typo such as customer= for customer_id=. param names it. Refused rather than ignored, because a dropped filter returns the whole list and looks like a correct answer. |
| state_conflict | conflict_error · 409. The object exists and the request is well formed, but its current state does not allow the operation: pausing a canceled subscription, refunding a payment that was never paid, deleting a customer with an active subscription. No param, because no parameter is wrong. reason narrows it where you need to act differently. |
| 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 (a renewal payment still processing says when to retry), or a webhook delivery you asked to redeliver is being sent right now. Retry once it finishes. A plan change whose provider answer was lost holds this for up to about an hour while BillKit resolves it. |
| subscription_past_due | conflict_error · 409. A plan change or a pause on a subscription whose renewal is unpaid. Have the customer update their payment method (POST /v1/subscriptions/{id}/reauthorize_payment_method), which pays the outstanding period, then retry; retrying without that fails the same way. To stop billing instead, cancel it. |
| invoice_not_voidable | conflict_error · 409. The invoice is paid, so it cannot be voided. Refund the payment instead. |
| charge_already_refunded | conflict_error · 409. The charge has nothing left to refund: earlier refunds, including pending ones, already cover it. Retrying will not change that. |
| charge_disputed | conflict_error · 409. An open dispute has taken back what earlier refunds did not already cover, so nothing is left to refund. This changes only if the dispute is reversed. |
| refund_duplicate | conflict_error · 409. A refund request without an Idempotency-Key was identical to an earlier one (same charge, same amount), so the provider returned the earlier refund and no second one was created. Resending fails the same way; send a distinct Idempotency-Key if you mean a second refund. |
| payment_refused | payment_error · 402. The payment provider refused a charge or refund the call made on the spot: a plan change’s proration, the payment of a past-due period, or a refund. Nothing moved and nothing changed. reason is payment_refused, and the provider’s reason is in message. Resending the same request is refused the same way. |
| webhook_endpoint_disabled | conflict_error · 409. Redelivery against a disabled endpoint. Enable it first. |
| webhook_endpoint_limit_reached | conflict_error · 409. The mode already has 16 webhook endpoints, disabled ones included. Delete one first; retrying the same request fails the same way. |
| request_too_large | invalid_request_error · 413. The request body is over 1 MiB. |
| 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. |
| rendering_pending | api_error · 501. A document PDF cannot be rendered on this deployment. The invoice or credit note is still available as JSON from its retrieve endpoint. |
| unhandled | api_error · 500 or 502. Report a 500 with the request_id. |
| service_unavailable | api_error · 503. Either we were at capacity and refused the request before running it (reason: not_attempted), or it started and was cut off before it finished (reason: outcome_unknown). Comes with Retry-After. Not a bug, so report it only if it persists. |
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_id_locked | Your account’s own VAT id is already set and cannot be changed or removed through the API. Contact support. |
| no_active_mandate | No usable payment mandate. The customer has to reauthorize. |
| payment_refused | Sent with 402 payment_refused. The payment provider refused the charge a plan change needed, the payment of a past-due period, or a refund, so no money moved and nothing changed. For a charge the payment method usually needs updating; for a refund the charge usually has nothing left to refund, often because of a chargeback BillKit has not heard about yet. |
| activation_required | Sent with 409 state_conflict. An imported subscription has no payment method yet, so there is nothing to reauthorize. The customer activates it through the billing portal instead. |
| plan_change_not_supported | The plan change is a downgrade whose credit exceeds the new price. Cancel at period end and subscribe to the cheaper plan instead. |
| quote_expired | Billing portal only, with 400 parameter_invalid. A plan change was confirmed against a price quote that no longer describes the period: older than 15 minutes, or from before a renewal. The portal previews the change again. |
| renewal_processing | Sent with 409 operation_pending. A renewal payment for the subscription has not settled yet, so a cancel, pause or plan change has to wait for it. The message gives the UTC time to retry after. |
| 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 | Sent with 409 state_conflict. A customer delete or a GDPR purge was refused. Cancel the subscription first. |
| customer_has_open_invoice | Sent with 409 state_conflict. A customer delete or a GDPR purge was refused because an open invoice is still being collected from the payment method on file, typically the final bill of a canceled metered subscription. The message names the invoice. Wait for it to be paid, or void it, then try again. |
| idempotency_key_too_long | The Idempotency-Key header exceeded 255 characters. |
| idempotency_key_blank | The Idempotency-Key header was sent with an empty or whitespace-only value. Send a unique key, or leave the header out. |
| not_attempted | Sent with a 503. No part of the request ran. Retry it. |
| outcome_unknown | Sent with a 503 when the request started and was interrupted, and with some 502s when the payment provider did not answer. Either way it may or may not have taken effect. Retry with the same Idempotency-Key. The retry runs the request again rather than replaying a stored answer. Not every 502 carries it: the 502 from a refund or a plan change has no reason at all. Treat any 502 on a write as an unknown outcome, whether or not it says so. |
| nul_character | A text value contains the NUL character (U+0000), which cannot be stored. param names where it was. |
Retrying
429, every 5xx except 501, 409 idempotency_in_progress and 409 operation_pending are
worth retrying. A 501 is a permanent answer about this deployment, and every other 4xx will
fail identically however many times you send it.
Use exponential backoff with jitter, and send the same
Idempotency-Key on every attempt. A retryable response clears the stored
key, so the retry does the work; a success or any other client error replays the original
response. Either way you cannot double-charge by retrying.
503: read reason before assuming nothing happened
A 500 tells you a request failed somewhere in the middle. Retrying with the same
Idempotency-Key runs it again, and the money paths are built so that a repeat cannot charge or
refund twice. A 503 service_unavailable comes in two kinds, told apart by reason:
not_attempted: we were at our capacity ceiling and refused the request before running it, so nothing was attempted.outcome_unknown: the request started and was cut off before it finished (it waited too long for a lock or a query, or lost its database connection). Its own database writes were undone, but a payment it had already asked the provider for may exist. Treat it like a500: retry with the sameIdempotency-Key. The retry runs the request again rather than replaying a stored answer.
It carries Retry-After: 1. The official SDKs ignore it in favour of their own jittered backoff,
because a fixed delay synchronises every shed client into one retry, which is the stampede that
caused the shedding. Honour it if you are writing your own client and have no backoff of your
own; prefer jitter if you do.
Always send the same Idempotency-Key. For outcome_unknown it is what stops a retry from
charging twice; for not_attempted it still matters, because the retry may race a different
attempt that did run.
BillKit