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

SymptomcodeStatusCauseFix
Every call is rejected, key looks rightINVALID_MERCHANT_KEY401Key missing, wrong, revoked, expired — or sent under the wrong header name.See the 401 below.
Worked yesterday, now nothing startsNO_SUBSCRIPTION402No 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 renewalSUBSCRIPTION_INACTIVE402The subscription is cancelled, paused or past due.Settle or resume it in the dashboard.
Stops mid-month, everything else fineQUOTA_EXHAUSTED402Monthly 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 refusedLAYER_NOT_ENTITLED403The tier does not entitle the verification layer being used.Upgrade. See layered verification.
/result or /status returns nothingNOT_FOUND404No such session, an expired one, or one belonging to another merchant.See the 404 below.
Body rejected outrightVALIDATION_ERROR400Missing 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 redirectREDIRECT_HOST_NOT_ALLOWED400redirectUrl 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 availableMETHOD_UNAVAILABLE503No credentials are configured for the requested method in that deployment.See nothing verifies at all below.
Intermittent failures under loadRATE_LIMIT_EXCEEDED429A per-minute ceiling was reached.See the 429 below.
Sessions fail in bursts, shoppers turned awayBROKER_UNAVAILABLE503The 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 accountBILLING_UNAVAILABLE503The subscription lookup itself failed. Your account is fine.Retry. Do not surface it as a billing problem.
Opaque failure, generic messageINTERNAL_SERVER_ERROR500An 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:

  1. The header name. It is x-merchant-api-key. Header names are case-insensitive, so X-Merchant-Api-Key is the same header — but X-API-Key is not. Nothing reads X-API-Key, so a request carrying it is an unauthenticated request and answers exactly the same 401 as 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.
  2. The key is actually reaching the request. An empty environment variable produces a 401 that looks like a rejected key. Log the header's length, never its value.
  3. 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.
  4. 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.
  • verificationType missing or not one of age, country, both. It is the only required field, and the field name is verificationType.
  • country or both on 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:

RouteCeiling per minute
POST /init, authenticatedBy tier: Starter 50, Standard 100, Scale 500.
POST /init, rejected with 401120 per IP address, counting only the failures. Authenticated traffic never touches it.
GET /:id/result600, counted per merchant rather than per IP, so shops on a shared address do not share a bucket.
GET /:id/status, GET /:id/events120 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. pending or scanned with verified: false means the shopper has not finished. Wait for verified, rejected or expired before the answer decides anything.
  • A rejected session. The check ran and produced a determinate negative answer. That is a working verification, and it is metered as one.
  • A missing verifiedAttributes or qrCodeUrl. 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 code and statusCode from 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.