This page is not translated yet and is shown in English.

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:

RouteWhy it is authenticated
POST /api/verification/initStarts a billable session against your account.
GET /api/verification/:sessionId/resultReturns the verdict for a session, and must only return it to the merchant that owns it.
GET /api/merchant/subscriptionReturns 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/status and GET /:sessionId/events are 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-callback is 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/callback is 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/config exists 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:

StatuscodeMeaning
402NO_SUBSCRIPTIONThe key is valid; the account behind it has no active subscription.
403LAYER_NOT_ENTITLEDThe subscription is active but does not include the electronic-identity verification layer.
503BILLING_UNAVAILABLEThe 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.