Troubleshooting
Symptom, cause and fix for every error the verification API returns, keyed to the error code.
Last updated: 2026-09-04
Every failure the verification API returns carries a stable code in a consistent envelope. Find the code, find the row, and you have the cause.
How to read an error
{
"error": {
"message": "invalid_merchant_key",
"code": "INVALID_MERCHANT_KEY",
"statusCode": 401
}
}
code is the token to match on. message is prose for a human and gets reworded — every response, including a 429 and a malformed session id on /events, follows this same shape. See errors and status codes.
Symptom, cause, fix
| Symptom | code | Status | Cause | Fix |
|---|---|---|---|---|
| Every call is rejected, key looks right | INVALID_MERCHANT_KEY | 401 | Key missing, wrong, revoked, expired — or sent under the wrong header name. | See the 401 below. |
| Worked yesterday, now nothing starts | NO_SUBSCRIPTION | 402 | No subscription on the account behind the key. | Subscribe, or check the integration is using the key you think it is. |
| Same, after a failed card renewal | SUBSCRIPTION_INACTIVE | 402 | The subscription is cancelled, paused or past due. | Settle or resume it in the dashboard. |
| Stops mid-month, everything else fine | QUOTA_EXHAUSTED | 402 | Monthly allowance spent and the credit balance will not cover one overage. | Top up the credit balance or move up a tier. See billing. |
| Subscription is active and paid, still refused | LAYER_NOT_ENTITLED | 403 | The tier does not entitle the verification layer being used. | Upgrade. See layered verification. |
/result or /status returns nothing | NOT_FOUND | 404 | No such session, an expired one, or one belonging to another merchant. | See the 404 below. |
| Body rejected outright | VALIDATION_ERROR | 400 | Missing or invalid verificationType, an unknown property, a malformed session id, or a type the active path cannot serve. | See rejected request bodies below. |
| Session refuses to start with a redirect | REDIRECT_HOST_NOT_ALLOWED | 400 | redirectUrl is not https on a public domain name. | Use a public HTTPS host. localhost and bare IPs are refused at creation, on purpose. |
| Nothing works, no method available | METHOD_UNAVAILABLE | 503 | No credentials are configured for the requested method in that deployment. | See nothing verifies at all below. |
| Intermittent failures under load | RATE_LIMIT_EXCEEDED | 429 | A per-minute ceiling was reached. | See the 429 below. |
| Sessions fail in bursts, shoppers turned away | BROKER_UNAVAILABLE | 503 | The identity provider could not be reached. Not a verdict. | Retry with backoff. Never show the shopper a failure message for this. |
| "Payment required" on a paid, current account | BILLING_UNAVAILABLE | 503 | The subscription lookup itself failed. Your account is fine. | Retry. Do not surface it as a billing problem. |
| Opaque failure, generic message | INTERNAL_SERVER_ERROR | 500 | An unexpected fault. In production the message is replaced with generic text. | Nothing to parse. Report the failing request with its timestamp. |
The 401 that will not tell you why
A missing key and an unknown, revoked or expired key produce the byte-identical response. That is deliberate: it stops the endpoint being used to find out which keys exist. The cost is that a 401 cannot tell you which half is broken, so work through the causes in this order:
- The header name. It is
x-merchant-api-key. Header names are case-insensitive, soX-Merchant-Api-Keyis the same header — butX-API-Keyis not. Nothing readsX-API-Key, so a request carrying it is an unauthenticated request and answers exactly the same401as sending no header at all. This is the single most common cause, because it is what several other verification APIs use and it fails silently. - The key is actually reaching the request. An empty environment variable produces a
401that looks like a rejected key. Log the header's length, never its value. - The key is still live. Rotating a key revokes the old one immediately, with no grace period, so an integration still holding the previous value starts failing the moment someone clicks rotate. Check the dashboard for what is current — see API keys.
- The key belongs to this deployment. A key issued against one environment does not authenticate against another.
Authentication also fails closed: if the key lookup itself cannot run, the answer is still 401 rather than an admission. So a 401 storm affecting a key you know is good is worth reporting rather than debugging.
Nothing verifies at all
If every session fails and no error points at your configuration, check what the deployment can actually serve:
curl https://api.eidas-pro.com/api/config
Read supportedMethods:
- An empty array means no verification method is configured in that deployment. No key, plan, plugin setting or retry changes that. Every attempt to start a session fails closed, and it is meant to — a deployment with no configured method must refuse rather than pretend.
- A method key present, such as
idin, means that method is ready to accept requests there.
Do not infer availability from anything else. supportedVerificationTypes mirrors supportedMethods: it lists only the types a live method there can actually deliver — empty while supportedMethods is empty, age once a broker method is configured, since that path serves age only. The request body's verificationType field still validates against all three values regardless of what is live, so a syntactically valid request can still fail at session start if you ask for a type the deployment cannot serve, and the presence of an integration in your admin does not imply a live method behind it.
If you request a method that is not in supportedMethods, the session start answers 503 METHOD_UNAVAILABLE rather than silently using a different one. Requesting eudi on a deployment where it is not enabled, for example, answers:
{
"error": {
"message": "PRODUCTION mode is not available in the production backend: EUDI verification is not enabled in this deployment. See GET /api/config for the methods this deployment supports.",
"code": "METHOD_UNAVAILABLE",
"statusCode": 503
}
}
The message says only that this one method is not enabled here — it says nothing about what is. supportedMethods from GET /api/config is the authoritative check for what a deployment actually has; never infer it from the wording of an error message.
Rejected request bodies
VALIDATION_ERROR at 400 covers several distinct mistakes:
- An unknown property. The body is validated strictly: a field the API does not accept fails the whole request rather than being quietly dropped. Anything that builds the request can introduce one — a plugin, a client library, or code carried over from another provider — so this is not necessarily something you configured, and it is often not visible from any setting you can see. The field most often involved is an age threshold: no request field sets the age, and the implemented path checks a fixed 18-and-over threshold. If you did not assemble the request body yourself, report the failure rather than searching your settings for a cause that is not there.
verificationTypemissing or not one ofage,country,both. It is the only required field, and the field name isverificationType.countryorbothon the broker path. The contract accepts all three types; the implemented path serves age only and rejects the other two rather than downgrading them to a check that could never satisfy what you asked for.- A session id that is not a UUID. Rejected before any lookup happens.
The 429 ceiling
A 429 uses the standard envelope, with code RATE_LIMIT_EXCEEDED and a top-level retryAfter field alongside it:
{
"error": {
"message": "Too many requests. Please try again later.",
"code": "RATE_LIMIT_EXCEEDED",
"statusCode": 429
},
"retryAfter": "42"
}
Back off by the seconds in either the retryAfter body field or the Retry-After header — they carry the same value.
Which ceiling you hit depends on the route:
| Route | Ceiling per minute |
|---|---|
POST /init, authenticated | By tier: Starter 50, Standard 100, Scale 500. |
POST /init, rejected with 401 | 120 per IP address, counting only the failures. Authenticated traffic never touches it. |
GET /:id/result | 600, counted per merchant rather than per IP, so shops on a shared address do not share a bucket. |
GET /:id/status, GET /:id/events | 120 per session, so one shopper's polling cannot throttle another's. |
If you are hitting the result ceiling, poll less often per shopper rather than spreading the same rate across more addresses — the bucket follows the merchant, not the address. If you are hitting the 401 bucket, you have an authentication problem rather than a throughput one; fix the 401 and the throttle disappears.
A 404 does not only mean "no such session"
On GET /:sessionId/result, a 404 covers three cases that are deliberately indistinguishable:
- the session id was never issued;
- the session existed and has since expired and been swept;
- the session belongs to another merchant.
The third is why the answer is a 404 and not a 403: a 403 would confirm the session exists, which is exactly what a caller holding somebody else's id must not learn. If you see a 404 for a session you are sure you created, check that the key making the call belongs to the same account that created it.
Either way, treat a 404 as "this is over and you did not get a pass", not as something to retry.
Things that are not errors
Three responses look like failures and are not:
- A non-terminal
status.pendingorscannedwithverified: falsemeans the shopper has not finished. Wait forverified,rejectedorexpiredbefore the answer decides anything. - A
rejectedsession. The check ran and produced a determinate negative answer. That is a working verification, and it is metered as one. - A missing
verifiedAttributesorqrCodeUrl. Both are declared by response contracts for paths that produce them. The implemented path produces neither, so both are absent. Treat them as optional; never assume they are there.
When to escalate
Collect these before opening a ticket, because they are what makes a report actionable and none of them contains a secret:
- the
codeandstatusCodefrom the envelope; - the
sessionId, if the failure happened after one existed; - the timestamp of the failing request, with a timezone;
- the output of
curl https://api.eidas-pro.com/api/config.
Never send the API key itself, in any channel. If you believe a key has been exposed, rotate it first and report afterwards.