SwissPay returns conventional HTTP status codes plus a JSON body with a stable error.code and a human-readable error.message.
{
"error": {
"code": "invalid_params",
"message": "amount must be a positive integer"
}
}
The code is stable — match against it in your code. The message may evolve to be clearer over time and should be treated as human-readable only.
Error catalogue
| HTTP | code |
When you'll see it |
|---|---|---|
| 400 | missing_idempotency_key |
No Idempotency-Key header on POST /payments. |
| 401 | missing_api_key |
No Authorization header. |
| 401 | invalid_api_key |
Unknown or revoked key. |
| 404 | customer_not_found |
Payment referenced an unknown cus_..., or one belonging to a different merchant. |
| 404 | (no body) | Any other unknown ID (pay_..., etc.) returns 404 without a body. |
| 409 | key_reused |
Same Idempotency-Key, different request body. |
| 422 | invalid_params |
Validation failure — see Common 422 causes below. |
| 422 | customer_email_taken |
Email already on file for this merchant (unique per merchant, case-insensitive). |
| 422 | customer_external_id_taken |
external_id already on file for this merchant. |
| 422 | provider_not_configured |
Your account has no active payment-provider connection. |
| 502 / 503 | provider_error |
The upstream payment provider failed or timed out. |
Decline handling
Declined card payments return HTTP 200, not a 4xx. We do this because the API call itself succeeded — the provider received the authorisation request and the issuer made a decision. The decision happened to be "no".
{
"id": "pay_01HABC...",
"status": "failed",
"failure": {
"code": "refused",
"reason": "Refused by issuer"
},
"amount": 2999,
"currency": "CHF"
}
If your test framework asserts only on HTTP status, declines will silently pass as successes. Always check status first:
res = requests.post(url, json=body, headers=headers)
res.raise_for_status() # 4xx / 5xx → exception
data = res.json()
if data["status"] != "succeeded": # 200 OK with failure
handle_decline(data["failure"]["code"], data["failure"]["reason"])
Failure codes
When status: "failed", the failure.code will be one of:
| Code | Meaning |
|---|---|
refused |
Generic issuer refusal — most common. |
expired_card |
The card on file is past its expiry. |
insufficient_funds |
Self-explanatory. |
lost_card, stolen_card, pickup_card |
Card reported in the issuer's risk lists. |
3ds_failed |
The cardholder failed the 3-D Secure challenge. |
3ds_abandoned |
The cardholder never completed the challenge. |
3ds_token_expired |
The cardholder arrived at the challenge page after the 15-minute window. |
3ds_not_available |
Your connection is in 3DS-required mode but the issuer can't perform 3-D Secure. |
Common 422 causes
amount≤ 0 or non-integer.- Bad
email(failed RFC 5322 validation). localenot inxx-XXform.metadataexceeds 20 keys or a value exceeds 500 chars.payment_method.holder_namemissing (required on every card payment).success_url/failure_urlmissing or non-HTTPS when 3-D Secure is enabled.
What to log
For every API request, log the request ID we return in the Swisspay-Request-Id response header. If something goes wrong and you contact support, quoting that ID gets you to a resolution faster than any other piece of information.
Retries
5xxand connection timeouts: safe to retry with the sameIdempotency-Key.4xx: don't retry — the request is malformed; fix it first.200 status: failed(decline): don't retry the same card. The customer can choose a different payment method.
Was this article helpful?
That’s Great!
Thank you for your feedback
Sorry! We couldn't be helpful
Thank you for your feedback
Feedback sent
We appreciate your effort and will try to fix the article