Skip to content

Quickstart

A cURL walkthrough of a full age-verification flow: create a session, have a wallet respond to it, poll the result, and receive the webhook.

The generated API contract is available at /api/openapi.json and /api/openapi.yaml. Regenerate both files with npm run openapi:generate after changing gateway routes or request/response shapes.

0. Prerequisites

Start the Gateway locally and apply the D1 schema once:

sh
npx wrangler d1 migrations apply id-gateway-db --local
npm run dev

Provision a client. The command prints an API key and a webhook signing secret once each:

sh
npm run provision:client -- --client-id acme-bank --webhook-url https://acme.example.com/webhooks/id
text
Client "acme-bank" provisioned (local D1).
  API key:        sk_live_...
  Signing secret: whsec_...

Without --env, the command uses the top-level live-mode configuration but writes to local D1. Treat the generated key as local-only; it is not a production credential.

Revoke the client when you no longer need it:

sh
npm run revoke:client -- --client-id acme-bank

The command marks the client revoked in D1. Authenticated requests check the current D1 state, so the key stops working immediately.

Export the key for the rest of this guide:

sh
export ID_API_KEY=sk_live_...

Optional relying-party setup

For a local synthetic relying party, see the [relying-party test setup] (/guide/synthetic-rp). For staging certificates and rollout, see the EUDI integration checklist.

1. Create a verification session

issuer_id is the trusted credential issuer this session accepts a presentation from. Version 1 verifies a single age-over-18 claim from that issuer.

sh
curl -s -X POST http://localhost:8787/v1/sessions \
  -H "Authorization: Bearer $ID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"issuer_id": "https://issuer.example.gov"}'
json
{
  "session_id": "01931f3e-...",
  "expires_at": "2026-09-17T10:05:00.000Z",
  "qr_code_url": "openid4vp://?client_id=...&response_uri=...&nonce=...&state=...&presentation_definition=..."
}

Render qr_code_url as a QR code for the end user's wallet app to scan, then save session_id as the handle for every call below.

Shown above is the default OID4VP_REQUEST_MODE=inline profile: the Authorization Request is carried by value, inline in the query string. Set OID4VP_REQUEST_MODE=jar in wrangler.jsonc to use a different qr_code_url: client_id=did:web:<host>&request_uri=.... The wallet fetches the request as a signed JWT. See signed and encrypted OID4VP for details.

2. Wallet responds

The wallet scans the QR code, builds an OID4VP Authorization Response, and POSTs it directly to the Gateway's public response_uri (/v1/oid4vp/response/:session_id). The wallet does not need an API key.

sh
curl -s -X POST http://localhost:8787/v1/oid4vp/response/$SESSION_ID \
  -H "Content-Type: application/json" \
  -d '{
    "vp_token": "<SD-JWT-VC>~<disclosures>~<key-binding-jwt>",
    "state": "<state from the authorization request>",
    "presentation_submission": {
      "id": "...",
      "definition_id": "age-verification",
      "descriptor_map": [
        { "id": "age-over-18", "format": "vc+sd-jwt", "path": "$" }
      ]
    }
  }'

The wallet response must be a valid SD-JWT-VC with a Key Binding JWT bound to the session nonce. The Gateway verifies both against its trust store. To create a valid local response, use the synthetic wallet helper.

A successful response returns {"status": "ok"}, marks the session VERIFIED, and enqueues the webhook in step 4.

3. Poll session status

sh
curl -s http://localhost:8787/v1/sessions/$SESSION_ID \
  -H "Authorization: Bearer $ID_API_KEY"
json
{
  "session_id": "01931f3e-...",
  "status": "VERIFIED",
  "expires_at": "2026-09-17T10:05:00.000Z",
  "claims": { "is_over_18": true }
}

status is one of CREATED, PENDING, VERIFIED, FAILED, EXPIRED. claims appears only after VERIFIED and contains only the claims requested by the session's presentation definition. The Gateway deletes claims and verification details after 30 seconds but keeps the session status. Delete the session sooner when you have read the result (see AGENT.md §3.1).

A live socket is also available at GET /v1/sessions/:id/ws if you'd rather push a UI update the moment the wallet responds than poll.

After you read the claims you need, delete the session instead of waiting for the Durable Object's retention timer:

sh
curl -s -X DELETE http://localhost:8787/v1/sessions/$SESSION_ID \
  -H "Authorization: Bearer $ID_API_KEY"

This purges the VerificationSession Durable Object's entire storage (status included, not just claims) and returns 204. A subsequent GET/DELETE on the same session_id returns 404.

4. Receive the webhook

Once the session settles, the Gateway enqueues a notification (src/services/webhook/index.ts) that's POSTed to the webhook-url given at provisioning time:

json
{
  "sessionId": "01931f3e-...",
  "clientId": "acme-bank",
  "status": "VERIFIED",
  "latencyMs": 842,
  "issuerId": "https://issuer.example.gov"
}

with headers:

text
Content-Type: application/json
X-Signature: <hex HMAC-SHA256>
X-Timestamp: <unix ms>

Verify it with the Stripe-style signature construction. Reject a mismatched signature or an old timestamp to guard against replay:

js
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(body, signature, timestamp, secret, toleranceMs = 5 * 60 * 1000) {
  if (Math.abs(Date.now() - Number(timestamp)) > toleranceMs) {
    throw new Error("Timestamp outside tolerance");
  }
  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    throw new Error("Signature mismatch");
  }
}

The webhook never carries disclosed claims. If you need them, fetch them from the authenticated session endpoint in step 3. Do not put claims in the durable, retried queue.

For destination validation and DNS rebinding limits, see [webhook security] (/guide/webhook-security).

Further guides

Built for developers integrating privacy-preserving identity verification.