In-Store Age Verification quickstart

Extends Nexiel Age's remote EUDI wallet flow to a physical checkout: alcohol, tobacco, and lottery purchases, each independently country-and-category gated, since a single country (Ireland, for one) can already have three different legal answers across those three categories today. Same service, same route, same wallet protocol as every other Nexiel Verify check: only the checkType and a new required countryCode field differ.

Preview. No country or category is enabled by default: every (country, category) pair starts at NOT_SUPPORTED, and nothing (not this API, not the Legislation Watch Copilot) can move one to ENABLEDexcept a human reviewer's own explicit action in the dashboard. The endpoint and the gate are real and tested today. There is no live in-store deployment yet, so use this page to evaluate the flow rather than to integrate a live checkout against it.

This check runs on Nexiel Verify, which is live in production: compliance.nexiel.io serves /v1/verify/* today and always runs in production mode. No country/category pair for alcohol, tobacco, or lottery is enabled there yet, per the Preview note above, so a real request against it would get a 403for every one of them today, regardless of authentication; once a country/category is enabled, production completion of the underlying check still fails closed for both alternatives the same way every other EUDI wallet age check on this service does today, for two independent reasons: the PID fallback pending the EU's own eIDAS infrastructure publishing PID-Provider trust anchors, and the AV attestation path pending this deployment having a current AV trust anchor snapshot configured. The requests and responses below are real, executed against a self-run sandbox-mode instance with IE/alcohol pre-enabled, so you can see the exact shape you will integrate against once a country/category is enabled in production.

What this is, and what this isn't

This is not a new verification engine, and it is not proximity/NFC-based. It reuses Nexiel Verify's existing remote OpenID4VP/DCQL flow exactly as-is: the same QR-code-then-wallet-app round trip a phone or laptop checkout uses, just displayed on (or linked from) a physical terminal. The EUDI Architecture Reference Framework already permits this remote/QR flow at a point of sale. No proximity, Bluetooth, or NFC-based wallet handshake is built or required here.

This is not one generic “restricted purchase” check. Alcohol, tobacco, and lottery are three separate checkType values: restricted-purchase-alcohol-18, restricted-purchase-tobacco-18, and restricted-purchase-lottery-18. Each can be independently country/category-gated and independently audited, even though (see the mechanism section below) every one of them offers the exact same two alternative underlying wallet claims today.

The mechanism: same two alternatives, three gated check types

No real EUDI PID attestation publishes an age_over_18boolean claim at all: the PID Rulebook has no age-derived claim of any kind. DCQL 1.0 Final also has no query-time predicate/range operator, so there is no way to ask a wallet for “over 16” or any other threshold, even if one did exist. So each restricted-purchase check type offers the exact same two alternatives, via DCQL credential_sets, as Nexiel Age's own age-verification check: the EU's official Age Verification (AV) attestation, a native age_over_18 boolean disclosed as-is, or the PID's birthdate claim, computed into age_over_18 server-side and discarded immediately. The three differ only in identifier and legal gating, not in what they ask the wallet to disclose:

The restricted-purchase-alcohol-18 DCQL query
{
  "id": "restricted-purchase-alcohol-18",
  "version": 2,
  "description": "In-store/self-checkout restricted-purchase check for alcohol. Offers TWO alternative ways to satisfy the same age-over-18 question, via DCQL credential_sets: the EU's official Age Verification (AV) attestation (a native age_over_18 boolean claim, disclosed as-is), or the PID's birthdate claim (used only to compute age_over_18 server-side, then discarded immediately -- never persisted or logged). Same AV-preferred / PID-fallback mechanism as age-verification.",
  "dcqlQuery": {
    "credentials": [
      {
        "id": "av_age_over_18_restricted_purchase_alcohol",
        "format": "mso_mdoc",
        "meta": {
          "doctype_value": "eu.europa.ec.av.1"
        },
        "claims": [
          {
            "path": [
              "eu.europa.ec.av.1",
              "age_over_18"
            ]
          }
        ]
      },
      {
        "id": "pid_age_over_18_restricted_purchase_alcohol",
        "format": "dc+sd-jwt",
        "meta": {
          "vct_values": [
            "urn:eudi:pid:1"
          ]
        },
        "claims": [
          {
            "path": [
              "birthdate"
            ]
          }
        ]
      }
    ],
    "credential_sets": [
      {
        "options": [
          [
            "av_age_over_18_restricted_purchase_alcohol"
          ],
          [
            "pid_age_over_18_restricted_purchase_alcohol"
          ]
        ],
        "required": true
      }
    ]
  }
}

Per jurisdiction, the two alternatives requested never change. What changes is whether the session is allowed to start at all. Every (countryCode, category) pair has a current status of NOT_SUPPORTED, PENDING_REVIEW, or ENABLED, and that status is checked before a session is created, and before any DCQL template is even loaded: a missing status, or one that isn't ENABLED, means no session is created at all. A database-level constraint enforces “never ENABLEDwithout a human explicitly entering it live” independently of the application code, so the guarantee holds even against a direct database write. Flipping a country/category live is exclusively a dashboard reviewer action: the Legislation Watch Copilot can only draft a PENDING_REVIEW proposal with evidence for a human to approve. It can never write ENABLED itself.

Before you start

Same service as every other Nexiel Verify/Age flow. There is nothing separate to install. The one extra step versus a plain age-verification check: a country/category has to be ENABLED before a session for it will initiate.

There is no API call or database access a customer uses to flip IE/alcohol to ENABLED. That only ever happens through an operator enabling a country/category pair from the compliance dashboard, the same human-approval action described above. The example below assumes IE/alcohol has already been enabled that way.

The session-initiate step below is a plain HTTP request you can call directly. Completing a session requires a wallet-signed presentation, which a bare HTTP client can't produce on its own. The responses shown are the exact shapes your server returns for an enabled country/category (IE/alcohol) and a not-yet-enabled one (DE/alcohol, left at its default NOT_SUPPORTED).

Initiate a session for an enabled country

curl -s -X POST https://compliance.nexiel.io/v1/verify/sessions \
  -H "Content-Type: application/json" \
  -d '{"clientId": "docs-example-client", "checkType": "restricted-purchase-alcohol-18", "countryCode": "IE"}'
200 (IE/alcohol, ENABLED)
{
  "sessionId": "ead7d441-10b2-40c4-a475-49c69603da42",
  "templateVersion": "restricted-purchase-alcohol-18@2",
  "authorizationRequest": {
    "client_id": "https://verify.nexiel.eu",
    "response_type": "vp_token",
    "response_mode": "direct_post",
    "response_uri": "https://compliance.nexiel.io/v1/verify/sessions/ead7d441-10b2-40c4-a475-49c69603da42/responses",
    "dcql_query": {
      "credentials": [
        {
          "id": "av_age_over_18_restricted_purchase_alcohol",
          "format": "mso_mdoc",
          "meta": {
            "doctype_value": "eu.europa.ec.av.1"
          },
          "claims": [
            {
              "path": [
                "eu.europa.ec.av.1",
                "age_over_18"
              ]
            }
          ]
        },
        {
          "id": "pid_age_over_18_restricted_purchase_alcohol",
          "format": "dc+sd-jwt",
          "meta": {
            "vct_values": [
              "urn:eudi:pid:1"
            ]
          },
          "claims": [
            {
              "path": [
                "birthdate"
              ]
            }
          ]
        }
      ],
      "credential_sets": [
        {
          "options": [
            [
              "av_age_over_18_restricted_purchase_alcohol"
            ],
            [
              "pid_age_over_18_restricted_purchase_alcohol"
            ]
          ],
          "required": true
        }
      ]
    },
    "nonce": "CuEc0qH6S5-IKLtGc0U-V5_xaiYc05W4vh7E9Qq6PDc",
    "state": "balePAbk9i0pUEBNUCb8zmfrujcJHvEGip9JMaVQjJg"
  },
  "authorizationRequestUri": "openid4vp://authorize?client_id=https%3A%2F%2Fverify.nexiel.eu&response_type=vp_token&...(truncated)",
  "expiresAt": "2026-07-06T11:12:43.795Z"
}

Initiate a session for a not-yet-enabled country

Same request shape, a different countryCode. DE/alcohol is left at its default NOT_SUPPORTED, so no session is created. The response is a 403, not a 400: the request itself is well-formed, it's an eligibility failure.

curl -s -X POST https://compliance.nexiel.io/v1/verify/sessions \
  -H "Content-Type: application/json" \
  -d '{"clientId": "docs-example-client", "checkType": "restricted-purchase-alcohol-18", "countryCode": "DE"}'
403 (DE/alcohol, NOT_SUPPORTED)
{
  "error": "restricted_category_not_enabled",
  "message": "restricted-purchase category \"alcohol\" is not enabled for country \"DE\" (current status: \"NOT_SUPPORTED\") — a human reviewer must approve this in the dashboard before a session can be initiated for it"
}

Omitting countryCode entirely for a restricted-purchase-* checkType is a separate, genuinely malformed request: a 400, not a 403:

400 (countryCode omitted)
{
  "error": "country_code_required",
  "message": "checkType \"restricted-purchase-alcohol-18\" is a restricted-purchase check and requires a countryCode (alcohol/tobacco/lottery legal enablement is per-country) — none was supplied"
}

The wallet discloses a birthdate; you get back a boolean

Exactly like Nexiel Age's own completion response, with one difference: the boolean proof key is ageOver18ForRestrictedPurchase, not ageOver18, so an audit trail can distinguish a restricted-purchase decision from a plain age-verification one, even though both offer the same two underlying alternatives. templateVersion (already persisted) disambiguates exactly which category ran.

POST /v1/verify/sessions/{sessionId}/responses
{
  "sessionId": "ead7d441-10b2-40c4-a475-49c69603da42",
  "method": "eudi_wallet",
  "booleanProofs": {
    "ageOver18ForRestrictedPurchase": true
  },
  "amlResult": null,
  "pepFlag": null,
  "sanctionedFlag": null,
  "listsChecked": [],
  "humanReviewRequired": false,
  "templateVersion": "restricted-purchase-alcohol-18@2",
  "frozenDecisionArtifact": null
}

amlResult, pepFlag, sanctionedFlag, listsChecked, and frozenDecisionArtifact are all null/empty for the same reason they are on a plain age-verification check: no restricted-purchase-* check ever runs the sanctions/PEP matching engine, so there is no screening decision to human-review or freeze.

What gets persisted

Reference

There is no separate in-store API reference document: every route, parameter, and response schema this page uses is documented on the Verify API reference. The routes this page walks through are POST /v1/verify/sessions and POST /v1/verify/sessions/{sessionId}/responses, with checkType one of restricted-purchase-alcohol-18, restricted-purchase-tobacco-18, or restricted-purchase-lottery-18, plus the new required countryCode field.

Scope boundaries

Only “over 18” is supported. This API already requests a birthdate to compute that fixed boolean, but it deliberately never derives a different threshold, like Germany's 16, from it: the real PID Rulebook has no age-derived claim to ask an issuer for directly, and using the same disclosed birthdate to compute an arbitrary additional threshold would be a claims-minimization regression from this product's own design principle, not a technical limitation. Proximity and NFC-terminal integration aren't part of this either: every flow here uses the existing remote/QR OpenID4VP flow described above.

Roadmap