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.