Authentication
How merchant API keys authenticate the verification API, which three routes require one, and why the rest are deliberately open.
Last updated: 2026-09-04
The verification API authenticates a merchant with a single API key sent in a request header. There is no OAuth flow, no token exchange, and no session cookie.
The header
Every authenticated request sends the key as x-merchant-api-key:
curl -X POST https://api.eidas-pro.com/api/verification/init \
-H "x-merchant-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"verificationType": "age"
}'
Header names are case-insensitive over HTTP, so X-Merchant-Api-Key works too — but the header is x-merchant-api-key and nothing else. If you find an older document or generated client sending X-API-Key, it is wrong: that header is read by nothing, so a request carrying it is an unauthenticated request and answers 401.
Keys are issued from your merchant dashboard. Treat one as a server-side secret: it authorises starting billable verification sessions and reading their verdicts. Never ship it in browser JavaScript, a mobile app bundle, or a public repository.
Which routes need a key
Exactly three:
| Route | Why it is authenticated |
|---|---|
POST /api/verification/init | Starts a billable session against your account. |
GET /api/verification/:sessionId/result | Returns the verdict for a session, and must only return it to the merchant that owns it. |
GET /api/merchant/subscription | Returns your account's tier and subscription status, scoped to the calling merchant. This is what the WooCommerce plugin and Shopify app use to sync your plan — no personal data. |
Nothing else takes a key.
Why the other routes are open
Every other route on the verification surface is unauthenticated on purpose. That is not a gap somebody forgot to close — none of those callers can hold a merchant secret:
GET /:sessionId/statusandGET /:sessionId/eventsare read by the shopper's own browser while it waits for the check to finish. Putting your key in that page would publish it to every visitor.GET /api/verification/broker-callbackis where the bank or wallet redirects the shopper's browser back after the identity step. A redirect target is entered by a third party's browser and can carry no secret of yours.POST /:sessionId/callbackis the wallet's post-back for the credential-presentation flow. It is entered by the wallet, not by you, so it has no merchant key to send either; what it presents is a signed credential, which is verified on its own terms.GET /api/configexists so a client can discover what a deployment supports before it has anything to authenticate with.
What protects the two session-scoped routes instead is the session id itself: it is a random UUID that only the parties to that session hold, and requests on those routes are rate-limited per session so one shopper's polling cannot affect another's. The verdict a merchant acts on is still read through the authenticated /result route — see session result for why that matters.
Failed authentication
A missing key and an unknown, revoked, or expired key produce the byte-identical response:
{
"error": {
"message": "invalid_merchant_key",
"code": "INVALID_MERCHANT_KEY",
"statusCode": 401
}
}
The 401 is deliberately uninformative
Because the two cases are indistinguishable, the endpoint cannot be used as an oracle to discover which keys exist — an attacker probing candidate keys learns nothing from the response that separates "this key is wrong" from "you sent no key at all". If you are debugging an integration, that means a 401 never tells you which half is broken; check that the header name is exactly x-merchant-api-key before you assume the key itself is bad.
Authentication also fails closed. If the key-lookup backend is unreachable, the answer is still 401 rather than an admission — a broken authentication backend must never let traffic through.
Authenticated but not authorised
A key can be genuine and still not be allowed to start a session:
| Status | code | Meaning |
|---|---|---|
402 | NO_SUBSCRIPTION | The key is valid; the account behind it has no active subscription. |
403 | LAYER_NOT_ENTITLED | The subscription is active but does not include the electronic-identity verification layer. |
503 | BILLING_UNAVAILABLE | The subscription could not be checked right now. Your key is fine — retry. |
BILLING_UNAVAILABLE is a 503 rather than a 402 on purpose: telling a paying merchant "payment required" because a lookup timed out is a false statement about their account, and integrations act on it. See errors and status codes for the complete list.
Rate limiting
Unauthenticated POST /api/verification/init attempts are capped at 120 per minute per IP address, counting only the ones that answer 401. An authenticated merchant's traffic never touches that bucket, however busy — so a correctly configured integration cannot be throttled by it, and a key-guessing loop is stopped early.
Responses from the /api/verification/* routes carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers, so a client can pace itself rather than discover the ceiling by hitting it. The one exception is POST /api/verification/:sessionId/callback — the EUDI wallet's own callback, undocumented and unauthenticated — which sits behind no rate limiter and carries none of them. Those headers otherwise come from the limiters mounted on each route, not from a global one: /api/config is not rate-limited and carries none of them either, so do not write a client that expects them on every response. A throttled request answers 429 with a Retry-After header, the standard envelope (code RATE_LIMIT_EXCEEDED), and a top-level retryAfter field carrying the same seconds-to-wait value. See rate limiting.