# Stile Documentation (full) > Complete documentation for Stile — age and identity verification API + widget. Index version: https://docs.stile.id/llms.txt --- # Quickstart Section: Getting Started URL: https://docs.stile.id/getting-started/quickstart > Publish a workflow, drop in the widget, and confirm results with a signed webhook — your first verified session in three steps. Go from zero to your first verified session in three steps: publish a workflow, drop in the widget, confirm results with a signed webhook. You need a Stile account, a publishable key, and a published workflow. Get all three from the [dashboard](https://dashboard.stile.id). Prefer to delegate the whole integration to a coding agent? Copy the starter prompt at the bottom of this page — it pins the agent to the current API shape. ## How it fits together Your page never sees a secret, and fulfillment never trusts the browser — the secret key stays on your server, and the signed webhook is the source of truth: ![How a verification flows: the widget asks your server for a session, your server creates it with Stile, the user verifies in the modal, and Stile confirms the result to your server with a signed webhook](/img/verification-flow.svg) ## 1. Create a workflow Every verification session runs inside a **workflow** — a dashboard-configured recipe that carries your use case (e.g. alcohol delivery), target jurisdictions, verification methods, and preferences. Compliance is resolved server-side from the workflow, so your frontend never decides age tiers or methods. Go to [Workflows](https://dashboard.stile.id/workflows), create one for your use case, and **publish** it. Copy the workflow ID (`wf_...`) — you'll pass it to the widget. New to workflows? See the [Workflows concept guide](/concepts/workflows). ## 2. Add the widget Include the Stile widget on your page. It's a standard web component — no build step required. The script registers ``, which renders a verify button (in `modal` mode) and runs the verification UI in an iframe on Stile's hosted page. ```html ``` The widget handles everything — session creation, jurisdiction detection, the verification flow, and result events. The publishable-key snippet above creates the session straight from the browser — which works out of the box in a [sandbox](/guides/testing) organization. In a **live** organization, browser-side session creation with a publishable key requires a CAPTCHA, so use the backend **`session-url`** flow for production: your server mints the session with a secret key (no CAPTCHA) and hands the widget the `client_secret`. See the [`session-url` contract](/sdks/widget#the-session-url-contract). ```tsx title="Checkout.tsx" export function Checkout() { const handleVerified = (e: CustomEvent) => { console.log("Verified!", e.detail); }; return (
el?.addEventListener("stile:verified", handleVerified)} />
); } ```
```html title="checkout.html" ``` No JavaScript required. The widget blocks form submission until verified, then injects a hidden `stile_session_id` field automatically. ```html title="checkout.html"
``` Your server receives `stile_session_id` and `stile_verified` in the form body. Call `GET /v1/verification_sessions/:id` with your secret key to confirm the result.
With the npm package (`npm install @stile/widget`): ```ts title="verify.ts" import { verify } from "@stile/widget"; const result = await verify({ publishableKey: "stile_pk_...", workflowId: "wf_YOUR_WORKFLOW_ID", email: "user@example.com", }); console.log("Verified!", result.sessionId); ```
The publishable-key mode above is the fastest way to a working prototype, but it's being phased out for production. For live traffic, point the widget at your own endpoint with `session-url="/api/start-verification"` — your server creates the session with your secret key and the widget opens instantly with the result. See the [Widget SDK](/sdks/widget) for the `session-url` contract. ## 3. Confirm verification on your server (required) The widget fires a client-side event when verification completes, but **client-side events can be spoofed**. You must set up a server-side webhook to confirm results before granting access. **Step 1:** Go to [Webhooks](https://dashboard.stile.id/webhooks) in the dashboard and add your server's URL. Save the **webhook secret**. **Step 2:** Create a webhook handler. The SDK handles signature parsing, the 5-minute timestamp tolerance, and timing-safe comparison — don't maintain hand-rolled crypto you can import. (On other stacks, grab a complete handler from [Webhook Verification](/guides/webhook-verification).) ```bash npm install @stile/node ``` ```ts title="app/api/webhooks/route.ts" import Stile from "@stile/node"; const stile = new Stile(process.env.STILE_API_KEY!); export async function POST(req: Request) { // Verifies the signature, checks the 5-minute timestamp window, // and parses the event — throws WebhookSignatureError on anything fishy. const event = await stile.webhooks.fromRequest( req, process.env.STILE_WEBHOOK_SECRET!, ); if (event.type === "verification_session.verified") { // Grant access, update your database, etc. } return Response.json({ received: true }); } ``` Only for Node apps that can't take a dependency — otherwise use `@stile/node` and skip maintaining this: ```ts title="app/api/webhooks/route.ts" import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.STILE_WEBHOOK_SECRET!; export async function POST(req: Request) { const rawBody = await req.text(); const sig = req.headers.get("stile-signature") ?? ""; // Parse t= and v1= from the header const parts = sig.split(","); const ts = parts.find((p) => p.startsWith("t="))?.slice(2); const v1 = parts.find((p) => p.startsWith("v1="))?.slice(3); if (!ts || !v1) return new Response("Bad signature", { status: 400 }); if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return new Response("Expired", { status: 400 }); const expected = createHmac("sha256", SECRET) .update(`${ts}.${rawBody}`) .digest("hex"); if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) return new Response("Invalid", { status: 400 }); const event = JSON.parse(rawBody); if (event.type === "verification_session.verified") { // Grant access, update your database, etc. } return Response.json({ received: true }); } ``` On Python? This complete handler is maintained as part of the docs and matches the current signing scheme: ```python title="webhooks.py" import hmac, hashlib, time, json, os from flask import Flask, request, jsonify app = Flask(__name__) SECRET = os.environ["STILE_WEBHOOK_SECRET"] @app.route("/webhooks", methods=["POST"]) def handle_webhook(): raw_body = request.get_data(as_text=True) sig = request.headers.get("stile-signature", "") parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p) ts, v1 = parts.get("t"), parts.get("v1") if not ts or not v1: return "Bad signature", 400 if abs(time.time() - int(ts)) > 300: return "Expired", 400 expected = hmac.new(SECRET.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(v1, expected): return "Invalid", 400 event = json.loads(raw_body) if event["type"] == "verification_session.verified": pass # Grant access return jsonify(received=True) ``` Respond `2xx` as soon as you've queued the work — do the heavy lifting (database writes, emails, fulfillment) after acknowledging, so retries don't pile up behind a slow handler. That's it — you have age verification working. The workflow carries the compliance rules, the widget runs the verification flow, and your server confirms the result via signed webhook. Your **publishable key** (`stile_pk_…`) goes in the frontend widget. Your **secret API key** (`stile_sk_…`) stays on your server. See [Authentication](/getting-started/authentication) for details. Live (non-sandbox) organizations require at least one active webhook endpoint before sessions can be created. Sandbox organizations are exempt and work without webhooks for development — capped at 500 sandbox sessions per calendar month. This ensures verification results are always confirmed server-side in production. The free tier covers 100 verifications/month by default. Prefer to delegate the whole integration to a coding agent? This starter prompt pins the agent to the current API shape — adjust the stack to yours: ```text You are integrating Stile age verification (docs: https://docs.stile.id/llms-full.txt). My stack: Next.js App Router + TypeScript, deployed on Vercel. My workflow ID: wf_YOUR_WORKFLOW_ID (already published in the Stile dashboard). Build: 1. A POST /api/start-verification route that creates a verification session with @stile/node using STILE_API_KEY, passing { type: "age", workflow_id, email }, and returns { session_id, client_secret, methods, age_tier } — this is the widget's session-url contract. 2. A checkout page that loads https://js.stile.id/v1/stile.js and renders . 3. A POST /api/webhooks route that verifies the Stile-Signature header with stile.webhooks.fromRequest() and STILE_WEBHOOK_SECRET, and marks the order verified on verification_session.verified. Rules: never trust the client-side stile:verified event for fulfillment — only the signed webhook. Use stile_sk_ keys; test against the 500/month sandbox quota in a sandbox org. Never expose the secret key to the browser. ``` For `llms.txt`, the full-corpus docs file, and the OpenAPI spec, see [Build with AI](/getting-started/llms). ## Next steps --- # Integration Guide Section: Getting Started URL: https://docs.stile.id/getting-started/integration-guide > Everything you need to build a production-ready integration — session-url architecture, workflows, compliance checks, returning users, VPN detection, and webhook-gated checkout. This is the production companion to the [Quickstart](/getting-started/quickstart): how the frontend and backend split responsibilities, how workflows and compliance decide what each user must prove, and the patterns — webhook-gated checkout, returning-user reuse, mismatch detection — that keep real traffic safe. If you haven't shipped your first verified session yet, start with the Quickstart. ## Architecture overview Every Stile integration has two parts: 1. **Frontend** — the widget handles the user-facing verification flow (compliance checks, session creation, QR codes, modals) 2. **Backend** — your server creates sessions with your secret key and receives signed webhooks confirming verification results ![How a verification flows: the widget asks your server for a session, your server creates it with Stile, the user verifies in the modal, and Stile confirms the result to your server with a signed webhook](/img/verification-flow.svg) The widget fires a `stile:verified` event when the user completes verification, but this can be spoofed. Always confirm verification via a signed server-side webhook before granting access, processing orders, or unlocking content. **Live orgs enforce this** — session creation requires at least one active webhook endpoint on your organization. Sandbox orgs are exempt and work without webhooks for development. The widget can obtain a session three ways. Pick `session-url` for production: | Mode | How it works | Use it for | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `session-url` (recommended) | The widget POSTs to an endpoint on your server, which creates the session with your secret key and returns the secret. | Production. | | Pre-minted `client-secret` + `session-id` | Your backend creates the session inside an existing request and hands the credentials to the widget. | Flows where session creation is part of your own business logic. | | `publishable-key` + `workflow-id` | The widget creates the session directly from the browser. | Prototyping only — being phased out for production. | ### Widget + session-url (recommended) The widget POSTs to a small endpoint on your backend, which creates the session with your secret key and returns the `client_secret`. Your server also has a webhook handler to confirm results. The contract is small: the widget POSTs `{ workflowId?, email?, jurisdiction? }` to your endpoint and expects `{ session_id, client_secret, methods?, age_tier? }` back. Both `` (npm) and `` (CDN) support `session-url` — and `` prefetches the session in the background on mount, so the modal opens instantly on click. ```html ``` ```ts // Backend: mint the session (called by the widget) app.post("/api/start-verification", async (req, res) => { const session = await stile.verificationSessions.create({ type: "age", workflow_id: req.body.workflowId, email: req.body.email, jurisdiction: req.body.jurisdiction, }); res.json({ session_id: session.id, client_secret: session.client_secret, methods: session.methods, age_tier: session.age_tier, }); }); // Backend: webhook confirms verification app.post("/webhooks", async (req, res) => { const event = await stile.webhooks.fromRequest(req, WEBHOOK_SECRET); if (event.type === "verification_session.verified") { await db.orders.update({ where: { sessionId: event.data.id }, data: { ageVerified: true } }); } res.json({ received: true }); }); ``` ### Server-side session creation (advanced) For complex flows, your backend creates sessions via the API as part of an existing request and passes the `client_secret` to the frontend. This lets you run custom business logic (compliance checks, cart validation, fraud screening) before verification starts. ```ts // Backend: check compliance, create session, return client_secret const compliance = await stile.compliance.check({ use_cases: ["alcohol_delivery"], }); const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", }); // Pass session.client_secret + session.id to your frontend // ( or create() picks them up) // Still confirm via webhook — don't trust the frontend ``` ## Workflows Every session runs inside a **workflow** — `workflow_id` is required on session creation. Workflows are authored and published in the [dashboard](https://dashboard.stile.id/workflows) and are the single source of truth for: - The **use case** (e.g. `alcohol_delivery`) that compliance rules are resolved from - The **verification methods** and how they compose (an explicit `methods` array on the request is rejected with a validation error) - **Target jurisdictions** and per-jurisdiction overrides - Preferences like the returning-user OTP gate This means changing your verification recipe is a dashboard edit, not a deploy. Create separate workflows for separate use cases (e.g. one for alcohol checkout, one for account signup). ## How compliance works When Stile resolves the use case (from the session's workflow, or from `use_cases` on the Compliance API), it automatically: 1. **Detects the user's jurisdiction** from their IP address (Cloudflare headers, geo-IP) 2. **Looks up the regulatory rules** for your products in that jurisdiction 3. **Determines the required age tier** (e.g., 21+ for alcohol, 18+ for adult content) 4. **Filters verification methods** to only those allowed in that jurisdiction (e.g., removes mDL in states that don't recognize it) 5. **Flags prohibited products** (e.g., cannabis delivery in states where it's illegal) You don't need to know what age is required in each state — Stile handles it. Session creation takes a `workflow_id` — the use case lives on the workflow. The [Compliance API](/api-reference/compliance) is the one surface that takes `use_cases` directly, because it answers product-level questions before any session exists. ### Mixed carts If your customer has multiple product types in their cart (e.g., alcohol + nicotine), check them together with the Compliance API: ```ts const compliance = await stile.compliance.check({ use_cases: ["alcohol_delivery", "tobacco_nicotine"], }); // compliance.most_restrictive gives you the merged result: // - Highest age tier across all products // - Intersection of allowed methods // - Which products are prohibited ``` For widget integrations, configure the workflow with the use case that covers your catalog (or the most restrictive one you sell). If your carts mix product types dynamically, run the compliance check on your backend inside your `session-url` endpoint and pick the workflow accordingly. The system resolves the **most restrictive** rules across all products. If alcohol requires 21+ and nicotine requires 21+, the session verifies for 21+. If one product is prohibited, the response tells you which one so you can handle it in your checkout. ### Prohibited products Some products are illegal in certain jurisdictions (e.g., cannabis delivery in many states). The Compliance API flags these: ```ts const compliance = await stile.compliance.check({ use_cases: ["alcohol_delivery", "cannabis_delivery"], jurisdiction: "US-ID", // Idaho }); if (compliance.most_restrictive.any_prohibited) { // compliance.most_restrictive.prohibited_use_cases → ["cannabis_delivery"] // Remove from cart or show error to user } ``` With a widget integration, the session's workflow carries the use case — if it's prohibited in the user's jurisdiction, session creation fails with a `422 use_case_prohibited` error and the widget surfaces it. For mixed carts, check compliance on your backend first and remove prohibited items before starting verification. ## Returning users When a user has previously verified on your site (or another site in the Stile network), they can skip re-verification. This is handled automatically: 1. **Widget flow:** Pass the user's email. If they're already verified, the widget resolves instantly — no modal, no QR code. 2. **Server-side flow:** Use `accept_existing: true` with the user's email. ```html ``` Under the hood this is a three-tier ladder: a VP token cached in the user's browser resolves instantly; failing that, an email/phone lookup plus a one-time code proves the user controls the address; only then does a full verification run. The OTP proves email ownership — never age or identity. See [Returning Users](/guides/returning-users) for the full flow. ### Credential validity Verification credentials don't last forever. Each product type has a `credential_validity_days` set by jurisdiction rules — for example, gambling credentials may expire after 30 days, while alcohol credentials last 365 days. When a credential expires, the user must re-verify. This is automatic — the lookup filters out expired credentials, so stale verifications are never reused. ### Age tier compatibility Credentials are scoped to the age tier they were verified for. A credential verified for social media (16+) **cannot** be reused for alcohol (21+). A credential verified for alcohol (21+) **can** be reused for social media (16+) since 21 > 16. ## VPN and geolocation mismatch detection If you know the user's shipping address, you can pass it to detect discrepancies between the delivery location and the user's IP address: ```ts const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", delivery_jurisdiction: "US-OR", // from shipping address }); if (session.jurisdiction_mismatch) { // User's IP is in US-CA but shipping to US-OR // Flag for review, show warning, or block console.warn(`IP: ${session.ip_jurisdiction}, Delivery: US-OR`); } ``` ```json { "jurisdiction": "US-CA", "ip_jurisdiction": "US-CA", "jurisdiction_mismatch": true } ``` This is purely informational — Stile flags the mismatch but doesn't block the session. Your application decides how to handle it. When `jurisdiction_mismatch` is `true`, consider: - Showing a warning to the user asking them to disable their VPN - Requiring a higher-assurance verification method (e.g., document capture instead of self-attestation) - Logging the mismatch for fraud review - Blocking the transaction if your compliance requirements demand it `delivery_jurisdiction` is optional. If you don't pass it, the mismatch fields are omitted from the response. ## Data retention and privacy Stile automatically purges personal data based on jurisdiction-specific retention rules: - **Collected PII** (names, dates of birth, addresses) — purged after `data_retention_days` - **Document images** (ID scans) — purged after `document_image_retention_days` - **Biometric data** (face match scores) — purged after `biometric_retention_days` These limits are set by the compliance rules for each jurisdiction. For example, California (CCPA) may require 30-day PII retention limits, while Illinois (BIPA) sets 3-year limits for biometric data. Verification credentials (the record that a user verified) are **not affected** by data retention — they persist until their `credential_validity_days` expires. This means returning users can still skip re-verification even after their raw PII has been purged. ## Webhook-gated checkout (recommended pattern) For e-commerce, the recommended pattern is **webhook-gated checkout** — don't trust the client-side `stile:verified` event alone. Treat webhook delivery as the source of truth, and use the client-side event only to kick off confirmation: ### Widget fires the client-side event When the user completes verification, the widget fires `stile:verified`. Your frontend sends the session ID to your server and starts polling for confirmation. ### Server receives the webhook Stile sends a signed `verification_session.verified` webhook to your server. Your server verifies the signature, then marks the order as age-verified. ### Frontend polls for confirmation Your frontend polls your server until it confirms the webhook was received. Only then does the checkout proceed. This ensures that even if someone manipulates the client-side event, the order only proceeds after server-side webhook confirmation. ```ts title="Server webhook handler" app.post("/api/webhooks", async (req, res) => { const event = await stile.webhooks.fromRequest(req, WEBHOOK_SECRET); if (event.type === "verification_session.verified") { // Mark order as age-verified in your database // For heavier work than a single update, queue the job and respond 2xx first await db.orders.update({ where: { sessionId: event.data.id }, data: { ageVerified: true }, }); } res.json({ received: true }); }); ``` Your endpoint has 30 seconds to respond before a delivery counts as failed. Acknowledge first, then do the heavy lifting (database writes, emails, fulfillment) — failed deliveries are retried on a backoff schedule, so a slow handler turns one event into several deliveries — dedupe on the event id. See the [Webhooks guide](/guides/webhooks) for delivery semantics and retries. ## Error handling A handful of error codes come up specifically during integration. The full catalog, the error envelope, and retry strategies live in the [Error Handling guide](/guides/error-handling). | Code | Status | When it happens | | ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `webhook_required` | 400 | Creating a session on a live org with no active webhook endpoint on your organization. Add one at [dashboard.stile.id/webhooks](https://dashboard.stile.id/webhooks). | | `use_case_prohibited` | 422 | The workflow's use case is prohibited in the user's resolved jurisdiction. | | `jurisdiction_unresolvable` | 422 | The user's jurisdiction can't be determined from their IP. See below. | | `accept_existing_rate_limited` | 429 | Too many `accept_existing` attempts for one email. See below. | | `test_quota_exceeded` | 402 | A sandbox org has hit its 500-session cap for the calendar month. The quota resets on the 1st. | ### Jurisdiction unresolvable If Stile can't determine the user's jurisdiction from their IP (both geolocation providers fail), the API returns a `422` with code `jurisdiction_unresolvable`. Pass `jurisdiction` explicitly to resolve this: ```ts const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", jurisdiction: "US-CA", // explicit override }); ``` This only happens for public IPs when both geolocation services are down — it's extremely rare. In local development, the system defaults to "US". ### Rate limiting on accept_existing To prevent credential enumeration attacks, `accept_existing` is rate-limited to 5 attempts per email per 15-minute window. If exceeded, the API returns `429` with code `accept_existing_rate_limited`. ## Next steps --- # Authentication Section: Getting Started URL: https://docs.stile.id/getting-started/authentication > How Stile API keys work — publishable vs. secret, sandbox mode, rate limits, and zero-downtime key rotation. Every request to the Stile API is authenticated with an API key. This page covers the two key types and what each is allowed to do, how sandbox mode lets you build and test, the rate limits that apply per key, and how to rotate a key without downtime. ## Key types Stile uses two types of API keys, each with a specific purpose: | Key type | Prefix | Where to use | Purpose | | --------------- | -------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Publishable key | `stile_pk_...` | Frontend (safe to expose) | Used by the [widget](/sdks/widget)'s legacy publishable-key mode to create sessions from the browser — deprecated; see [Publishable keys and session creation](#publishable-keys-and-session-creation). | | Secret key | `stile_sk_...` | Server only (never expose) | Full API access — create sessions, manage webhooks, read events, and verify webhook signatures. | Create and manage your keys in the [dashboard](https://dashboard.stile.id/api-keys). ### Anatomy of a key Every key encodes its role in the prefix, so you can tell what a key is — and what it's allowed to touch — at a glance, in code review or in a log line: | Segment | Meaning | | ---------------- | -------------------------------------------------------------------------------------- | | `stile_` | All Stile keys share this prefix — easy to grep for and to wire into secret scanners. | | `pk` / `sk` | The role: **p**ublishable **k**ey (frontend-safe) or **s**ecret **k**ey (server-only). | | Everything after | The secret itself. For secret keys it's shown once at creation — store it immediately. | Secret API keys (`stile_sk_...`) grant full access to your organization. Never commit them to source control, include them in frontend code, or log them. Store them in environment variables. Publishable keys (`stile_pk_...`) are safe to include in your HTML. ## Sandbox mode Every organization is **live** by default — there's no separate test key. To build and test without running real verifications, enable **sandbox mode** on a dedicated testing organization (an org-level setting; turn it on from your dashboard org settings or by contacting support). In a sandbox organization: - `skip_verification: true` instantly mints a verified session — no camera, ID, or liveness check — fires real webhooks, and issues a VP token. See the [testing guide](/guides/testing). - Sessions are **unbilled**, and a [webhook endpoint](/guides/webhooks) is **not required**. - Usage is capped at **500 verifications per calendar month**; requests over the cap return `402 test_quota_exceeded`. A **live** (non-sandbox) organization runs real identity verification, rejects `skip_verification` (`400 parameter_invalid`), and requires at least one active [webhook endpoint](/guides/webhooks) before sessions can be created (the API returns `400 webhook_required` otherwise) — this ensures verification results are always confirmed server-side. The first 100 verifications per calendar month are free (100/month by default, per organization). Beyond that, usage is billed via prepaid credits. Failed verifications are never charged and don't count against the free allotment. ## Making API requests Pass your **secret key** in the `Authorization` header as a Bearer token. With the Node.js SDK, set the key once — it's sent automatically with every request. ```bash curl https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```ts import Stile from "@stile/node"; const stile = new Stile(process.env.STILE_API_KEY!); ``` Requests with a missing, malformed, or revoked key return `401` with an `authentication_error` of code `api_key_invalid`. See the [error handling guide](/guides/error-handling) for the full error envelope. If you're using the [widget](/sdks/widget) with a publishable key, you don't need to make API calls yourself — the widget handles session creation automatically. The API is for advanced use cases like server-side session management, reading events, or managing webhook endpoints programmatically. ## Publishable keys and session creation Publishable keys can create verification sessions directly from the browser, but this path is **deprecated** — responses carry a `Stile-Deprecation: publishable-key-session-create` header, and organizations can be migrated to backend-created sessions (after which the call returns `403 publishable_session_create_blocked`). Prefer the widget's `session-url` mode, where your server creates the session with a secret key and hands the `client_secret` to the widget. If you do create sessions with a publishable key from the browser, a Cloudflare Turnstile `captcha_token` is required on the request — this defends the pk-only flow against stolen-key replay. Requests without it fail with `400 captcha_required`. Secret keys don't need it. ## Rate limits Requests are rate-limited per API key on a per-minute rolling window — **1,000 requests/minute** for secret keys and **100/minute** for publishable keys (which are browser-exposed). Every response includes rate limit headers: ```bash X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 997 X-RateLimit-Reset: 1741564920 # Unix timestamp of window reset ``` When you exceed the limit, the API returns `429 Too Many Requests` with a `Retry-After` header indicating how many seconds to wait. Retry after that interval with exponential backoff — the [error handling guide](/guides/error-handling) has a reference implementation. ## Rotating keys Rotation is a create-then-revoke flow, so both keys stay valid while you roll over — there's no forced cutover and no downtime. ### Create a replacement key In the [dashboard](https://dashboard.stile.id/api-keys), create a new key of the same type (publishable or secret) as the one you're replacing. The old key keeps working until you revoke it. ### Store the new secret The secret is only shown once at creation — copy it into your secrets manager immediately. ### Deploy with the new key Update your environment variables (e.g. `STILE_API_KEY`) and redeploy your application. ### Revoke the old key Once all traffic is on the new key, revoke the old one in the dashboard. Any straggler requests still using it will fail with `401 api_key_invalid` — a useful signal that something wasn't redeployed. ## Next steps --- # Build with AI Section: Getting Started URL: https://docs.stile.id/getting-started/llms > Feed the Stile docs to your AI assistant or coding agent — llms.txt, a full-corpus file, and the OpenAPI spec. Most Stile integrations are now written with an AI assistant in the loop. These docs are published in machine-friendly formats so your assistant works from the real API surface instead of guessing: | Resource | URL | What it is | | ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | `llms.txt` | [docs.stile.id/llms.txt](https://docs.stile.id/llms.txt) | Index of every docs page with descriptions, per the [llms.txt](https://llmstxt.org) convention. | | `llms-full.txt` | [docs.stile.id/llms-full.txt](https://docs.stile.id/llms-full.txt) | The entire documentation as one markdown file — fits comfortably in modern context windows. | | OpenAPI 3.1 spec | [docs.stile.id/openapi.yaml](https://docs.stile.id/openapi.yaml) | Machine-readable schema for every endpoint, request, and response. | ## Using them **Coding agents (Claude Code, Codex, Cursor, Copilot Workspace)** — point the agent at `https://docs.stile.id/llms-full.txt` in your prompt, or download it into the repo so it's part of the agent's searchable context: ```bash curl -o docs/stile-docs.md https://docs.stile.id/llms-full.txt ``` **Chat assistants (Claude, ChatGPT)** — paste the contents of `llms-full.txt` into the conversation (or attach it as a file), then describe your stack and what you're building. **Cursor** — add `https://docs.stile.id/llms-full.txt` as a custom docs source (`Settings → Indexing & Docs`), then reference it with `@Docs` in chat. **SDK generators and API tooling** — consume `https://docs.stile.id/openapi.yaml` directly. ## Starter prompt A prompt that reliably produces a working integration — adjust the stack to yours: ```text You are integrating Stile age verification (docs: https://docs.stile.id/llms-full.txt). My stack: Next.js App Router + TypeScript, deployed on Vercel. My workflow ID: wf_YOUR_WORKFLOW_ID (already published in the Stile dashboard). Build: 1. A POST /api/start-verification route that creates a verification session with @stile/node using STILE_API_KEY, passing { type: "age", workflow_id, email }, and returns { session_id, client_secret, methods, age_tier } — this is the widget's session-url contract. 2. A checkout page that loads https://js.stile.id/v1/stile.js and renders . 3. A POST /api/webhooks route that verifies the Stile-Signature header with stile.webhooks.fromRequest() and STILE_WEBHOOK_SECRET, and marks the order verified on verification_session.verified. Rules: never trust the client-side stile:verified event for fulfillment — only the signed webhook. Use stile_sk_ keys and test against the 500/month test quota. Never expose the secret key to the browser. ``` The API requires `workflow_id` on every session and rejects a per-request `methods` array — models trained on older API shapes sometimes invent `use_case` or `products` parameters. If the generated code passes those, it predates the workflow model; re-point the assistant at the docs above. ## Next steps --- # Workflows Section: Concepts URL: https://docs.stile.id/concepts/workflows > A workflow is the dashboard-configured recipe every verification session runs inside — it decides the use case, the methods, the jurisdictions, and the outcome. Every verification session runs inside a **workflow**. A workflow is a recipe you author and publish in the [dashboard](https://dashboard.stile.id/workflows): it carries the use case, the verification methods to offer, the jurisdictions you operate in, and the preferences that shape each check. Your server passes a `workflow_id` when it creates a session, and Stile resolves everything else from the published workflow — so compliance, method selection, and age tiers are decided server-side, never in your frontend. Putting the verification recipe in a workflow means you change it in one place — the dashboard — without shipping code. It's also why `POST /v1/verification_sessions` requires `workflow_id` and **rejects** a per-request `methods` array: the published workflow is the single source of truth for which methods run. ## What a workflow carries ![Anatomy of a workflow: a use case and target markets feed a set of verification methods, which compose into a verified, failed, or review outcome](/img/workflow-anatomy.svg) | Element | What it controls | | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | | **Use case** | The product or context being gated (e.g. alcohol delivery). Drives the compliance rule and the required age tier. | | **Target jurisdictions** | The markets you operate in. The method list is filtered per jurisdiction at session time. | | **Methods** | Which [verification methods](/concepts/verification-methods) the session may offer. | | **Composition** | Whether the user completes **one** primary method or **all** of them (see below). | | **Preferences** | Tuning such as the liveness strategy and when a face match is required. | | **Outcome** | The terminal state — verified, failed, or routed to manual review. | The session's `methods` array is **compiled** from this configuration and the compliance rule for the user's detected jurisdiction — for example, mDL is dropped automatically in markets that don't recognize digital driver's licenses, even if the workflow lists it. ## Two ways to build a workflow Workflows come in two modes. Most integrations only ever need the first. A **simple** workflow starts from the rule-accepted baseline for your use case and target markets, then lets you curate it: add methods, remove methods, and set per-jurisdiction overrides for markets that need a different mix. It's the fastest path to a compliant flow and covers the large majority of use cases. Per-jurisdiction overrides let one workflow serve many markets — e.g. accept mDL everywhere it's recognized, fall back to document capture where it isn't. A **node-based** workflow is a visual graph for flows that need branching. You wire together verification steps, branch nodes that route on conditions (the previous step's result, risk level, age tier, jurisdiction, or your own session metadata), and terminal status nodes (verified, failed, or review). Reach for this when the path through verification should change based on what happens mid-flow — for example, escalating to a stronger method when a risk signal fires. ## Method composition In a simple workflow, **composition** decides how the primary methods relate: - **Any of** (default) — the user picks one primary method, and passing it verifies the session. Best for choice and conversion. - **All of** — the user must complete every primary method in the flow. Best for high-assurance use cases that stack independent checks. Supplementary layers — liveness, face match, and signal checks like carrier lookup — always run regardless of the composition setting; they're added on top of the primary chain. ## Publishing and versioning A workflow only takes effect once it's **published**. Sessions always run the published version, so you can edit a draft freely without affecting live traffic, then publish when you're ready. The `workflow_id` you pass at session creation always resolves to the current published version. Create test-mode sessions against a workflow (with `skip_verification` for end-to-end webhook testing) before pointing live traffic at it. See [Testing & Sandbox](/guides/testing). ## Using a workflow Pass the published workflow's ID when you create a session — that's the only required field beyond `type`: ```bash curl https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID" }' ``` ```ts const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", }); ``` The widget takes the same `workflow-id` attribute, or resolves it through your `session-url` endpoint — see the [Widget SDK](/sdks/widget). ## Next steps --- # Verification Methods Section: Concepts URL: https://docs.stile.id/concepts/verification-methods > The full menu of verification methods a workflow can offer — from government digital credentials to age estimation — and how strong each one is. A **method** is a single way a user can prove their age or identity. You don't choose methods per request — you curate them on a [workflow](/concepts/workflows), and Stile compiles the session's method list from that selection, filtered by the user's jurisdiction. This page is the reference for every method available, grouped by how it works. A workflow can list a method that isn't valid everywhere. At session time, Stile removes methods that aren't recognized in the user's detected jurisdiction — for example, mDL is dropped in markets that don't accept digital driver's licenses. ## Government digital credentials Wallet-held, government-issued credentials — the highest-assurance methods, presented directly from the user's phone. | Method | Value | What it is | | ----------------------- | ---------- | -------------------------------------------------------------------- | | Mobile Driver's License | `mdl` | An ISO 18013-5 mDL presented from the phone's wallet via OID4VP. | | Mobile Identity | `mid` | A government-issued mobile identity credential. | | EUDI PID | `eudi_pid` | A European Digital Identity (Person Identification Data) credential. | ## Document and chip Verify a physical identity document — by photo or by reading its secure chip. | Method | Value | What it is | | ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Document Capture | `document_capture` | Photograph a physical ID; Stile extracts and validates the data (OCR + barcode cross-reference) and matches a selfie to the document portrait. | | NFC Passport | `nfc_passport` | Read the passport's NFC chip (ICAO 9303) for a cryptographically signed identity. Requires an NFC-capable device. | A workflow can restrict which document types `document_capture` accepts: | Document type | Value | | ---------------- | ------------------ | | ID card | `ID_CARD` | | Passport | `PASSPORT` | | Driver's license | `DRIVERS_LICENSE` | | Residence permit | `RESIDENCE_PERMIT` | | Health card | `HEALTH_CARD` | ## Biometric Use the camera to estimate age or confirm a live, matching person. | Method | Value | What it is | | --------------- | ----------------- | --------------------------------------------------------------------------------- | | Facial Age | `facial_age` | AI age estimation from a selfie — no document required. Ideal for pure age gates. | | Selfie Liveness | `selfie_liveness` | Confirms a real, physically-present person to defeat photo and replay attacks. | | Selfie Match | `selfie_match` | Matches a live selfie against the portrait on a verified document. | Liveness can run with one of three strategies, set as a workflow preference: `3d_action` (the user performs a movement such as a head turn), `flashing` (screen-light reflection), or `passive` (no user action). ## Signal-based Derive an age or identity signal from an account the user already holds. | Method | Value | What it is | | -------------- | ---------------- | ---------------------------------------------------------- | | Carrier Lookup | `carrier_lookup` | Uses mobile carrier records as an age and identity signal. | | Open Banking | `open_banking` | Uses bank account data as an identity signal. | ## Declaration and consent Lower-assurance methods for contexts where a regulation permits them, or where a guardian is involved. | Method | Value | What it is | | ---------------- | ------------------ | ---------------------------------------------------------- | | Self Attestation | `self_attestation` | The user declares their age or identity. Lowest assurance. | | Parental Consent | `parental_consent` | Collects consent from a parent or guardian. | | Student | `student` | Verifies academic enrollment. | ## Device requirements Some methods need specific hardware. When a user is on a device that can't run a method — say document capture on a desktop with no camera — the widget offers a [desktop → mobile handoff](/guides/device-handoff) automatically. | Method | Needs | | ------------------------------------------------- | ---------------------------------------------------------------- | | `mdl`, `eudi_pid` | A digital wallet app on the user's phone (presented via OID4VP). | | `mid` | A government mobile-identity app. | | `nfc_passport` | An NFC-capable phone. | | `document_capture` | A camera. | | `facial_age`, `selfie_liveness`, `selfie_match` | A camera. | | `carrier_lookup` | The user's mobile number / network. | | `open_banking` | The user's online banking login. | | `self_attestation`, `parental_consent`, `student` | No special hardware. | ## Choosing methods You curate methods on a [workflow](/concepts/workflows); these tradeoffs help you pick: - **Assurance vs. friction.** Government digital credentials (mDL, mID, EUDI PID) and chip-read passports are the highest assurance but require the user to have the credential or app. Document capture is broadly available but asks for a camera and a few taps. Facial age estimation is the lowest-friction age gate when identity isn't needed. - **Offer alternatives.** With **any-of** composition, list several methods so users can pick the one they can complete — e.g. mDL for those who have it, document capture for everyone else. - **Let jurisdiction filtering work.** Methods not recognized in the user's market are removed automatically, so you can list a superset and trust the compiled list to be valid where each user is. - **Stack for high assurance.** With **all-of**, combine independent checks (e.g. document capture plus a liveness selfie) when the use case warrants it. ## Strength ranking When you reuse a prior verification for a [returning user](/guides/returning-users), methods are ranked by strength. A credential at a given rank satisfies any requirement at that rank or below. | Rank | Strength | Method | | ---- | ------------------ | --------------------------- | | 1 | `self_attestation` | User declaration | | 2 | `facial_age` | AI age estimation | | 3 | `carrier_lookup` | Mobile carrier verification | | 4 | `open_banking` | Bank account verification | | 5 | `document_capture` | Physical ID scan + selfie | | 6 | `mdl` | Mobile Driver's License | | 7 | `mid` | Mobile ID | | 8 | `eudi_pid` | EU Digital Identity | Selfie liveness and selfie match are counted at document-capture strength; student and parental consent at self-attestation strength. ## Next steps --- # Glossary Section: Concepts URL: https://docs.stile.id/concepts/glossary > The core Stile terms — workflow, session, client secret, VP token, age tier, and the rest — in one place. A quick reference for the vocabulary used throughout these docs. ### Verification session A single identity or age verification attempt, identified by a `vks_` id. Created via `POST /v1/verification_sessions`, it moves through statuses (`created` → … → `verified` / `failed` / `cancelled` / `expired`) and is the object your webhooks report on. See [Verification Sessions](/api-reference/verification-sessions). ### Workflow The dashboard-configured recipe a session runs inside, identified by a `wf_` id. It carries the use case, target jurisdictions, methods, composition, and preferences. `workflow_id` is required on every session. See [Workflows](/concepts/workflows). ### Method A single way to prove age or identity — mobile driver's license, document capture, facial age estimation, and so on. Methods are curated on a workflow, never passed per request. See [Verification Methods](/concepts/verification-methods). ### Use case The product or context being gated (e.g. `alcohol_delivery`). It drives the compliance rule and the required age tier. The use case lives on the workflow; the [Compliance API](/api-reference/compliance) is the one surface that takes a `use_cases` parameter directly. ### Age tier The minimum-age requirement a verification proves: `min_age_13`, `min_age_16`, `min_age_18`, or `min_age_21`. A higher tier satisfies any lower one, never the reverse. ### Jurisdiction The market a user is in, as an ISO 3166 code (e.g. `US`, `US-CA`, `DE`). Detected from the request, or set explicitly. It determines which methods are valid and what age tier applies. ### Compliance rule The regulatory ruleset for a use case in a jurisdiction — the required age tier, allowed methods, prohibitions, and retention windows. Resolved automatically at session time. See the [Compliance API](/api-reference/compliance). ### Publishable key / secret key The two API key types. Publishable keys (`stile_pk_`) are frontend-safe and limited in scope; secret keys (`stile_sk_`) are server-only with full access. There's a single environment — keys carry no `test`/`live` segment. See [Authentication](/getting-started/authentication). ### Client secret A short-lived JWT returned when a session is created. Your backend hands it to the widget, which uses it to run the verification flow for that one session — without exposing your secret key. ### `session-url` mode The recommended widget integration: the widget POSTs to an endpoint on your server, which creates the session with the secret key and returns the `client_secret`. See the [Widget SDK](/sdks/widget). ### VP token A "verified person" token — a signed proof, stored in the browser's `localStorage`, that lets a returning user skip re-verification instantly. See [Returning Users](/guides/returning-users). ### Verified person The cross-site identity anchor a verification is linked to, identified by a `vp_` id. Lets a user who verified once be recognized again — on your site or, with consent, across operators. See the [Verified Person API](/api-reference/verified-person). ### Trust reuse The opt-in mechanism that lets a verification a user completed at one operator be reused at another, gated by the user's consent. See [Trust Reuse](/guides/trust-reuse). ### Composition (`any_of` / `all_of`) How a workflow's primary methods relate: `any_of` (the user completes one) or `all_of` (the user completes every one). Supplementary layers always run regardless. ### Credential strength The assurance ranking used when reusing a prior verification — from `self_attestation` (weakest) to `eudi_pid` (strongest). A stronger credential satisfies a weaker requirement. See [Verification Methods](/concepts/verification-methods#strength-ranking). ### Sandbox mode An organization-level setting for building and testing, capped at 500 verifications/month. In a sandbox org, sessions are unbilled, a webhook endpoint isn't required, and `skip_verification: true` instantly mints a verified session for end-to-end testing. See [Testing](/guides/testing). ### Webhook A signed, server-to-server POST Stile sends when an event occurs — the source of truth for verification results. See [Webhooks](/guides/webhooks). ### Event A record of something that happened in your account (`evt_` id), delivered to your webhook endpoints. See the [Events API](/api-reference/events). ## Next steps --- # Overview Section: HTTP API URL: https://docs.stile.id/api-reference/overview > Base URL, authentication, request and response conventions, pagination, the error object, idempotency, and rate limits — everything shared across the Stile HTTP API. The Stile API is a standard REST API: predictable resource URLs, JSON request and response bodies, conventional HTTP status codes, and Bearer-token authentication. Any language that can make HTTP requests can integrate — no SDK required. This page covers the conventions shared by every endpoint; the resource pages linked at the bottom document each endpoint in detail. Most integrations only need the [widget](/sdks/widget) (``) for the frontend and a [webhook handler](/guides/webhook-verification) on the backend. You may never need to call the API directly. ## Base URL ``` https://api.stile.id/v1/ ``` All endpoint paths in this reference are relative to this base. The API version (`v1`) is part of the URL path. ## Authentication Pass your secret key as a Bearer token in the `Authorization` header: ```bash curl https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_YOUR_SECRET_KEY" \ -H "Content-Type: application/json" ``` There are two kinds of keys: | Key type | Format | Where it belongs | | ----------- | -------------- | ---------------------------------- | | Secret | `stile_sk_...` | Server-side only. Full API access. | | Publishable | `stile_pk_...` | Frontend-safe. Limited scope. | Keys are managed at [dashboard.stile.id/api-keys](https://dashboard.stile.id/api-keys); secret keys are shown once at creation. There's a single environment — every organization is live by default. To build and test without billing or a webhook endpoint, enable [sandbox mode](/guides/testing) on a testing organization. See [Testing](/guides/testing) for what sandbox mode covers. Never embed a `stile_sk_` key in browser code or a mobile app. Anything client-side should use the widget's `session-url` mode — your backend creates the session and hands the page the session's `client_secret`. See [Security](/guides/security). See [Authentication](/getting-started/authentication) for the full breakdown of key types and scopes. ## Making a request Here's the same API call — creating a verification session — in every supported language: ```bash curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}' ``` ```python import requests res = requests.post( "https://api.stile.id/v1/verification_sessions", headers={"Authorization": "Bearer stile_sk_..."}, json={"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}, ) session = res.json() ``` ```go body := strings.NewReader(`{"type":"age","workflow_id":"wf_YOUR_WORKFLOW_ID"}`) req, _ := http.NewRequest("POST", "https://api.stile.id/v1/verification_sessions", body) req.Header.Set("Authorization", "Bearer stile_sk_...") req.Header.Set("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) ``` ```ts import Stile from "@stile/node"; const stile = new Stile("stile_sk_...", { baseUrl: "https://api.stile.id" }); const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", }); ``` Every session runs inside a published [workflow](https://dashboard.stile.id/workflows) — `workflow_id` is required, and the workflow carries the use case, jurisdictions, and verification methods. See [Verification Sessions](/api-reference/verification-sessions) for the full parameter reference. ## Request format - **Content-Type:** `application/json` for all POST requests - **Query parameters:** for GET requests and filtering ```bash # POST — JSON body curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}' # GET — query parameters curl "https://api.stile.id/v1/verification_sessions?limit=10&status=verified" \ -H "Authorization: Bearer stile_sk_..." ``` ## Response format Responses are JSON. Every resource includes an `object` field identifying its type. Persistent objects also carry an `id` with a resource-specific prefix (`vks_` for sessions, `evt_` for events) and Unix-second timestamps (`created`, `updated`, `expires_at`, and so on): ```json { "id": "vks_abc123", "object": "verification_session", "status": "verified", "type": "age", "created": 1741564800 } ``` ## Pagination List endpoints use cursor-based pagination: | Parameter | Description | | ---------------- | --------------------------------------------------------------------------------------- | | `limit` | Number of results (1-100; default 10 for most lists — webhook deliveries default to 20) | | `starting_after` | Cursor: return results after this ID | | `ending_before` | Cursor: return results before this ID. Supported on most list endpoints | List responses share a common envelope with `object: "list"` and a `has_more` flag indicating whether more results exist: ```json { "object": "list", "url": "/v1/verification_sessions", "has_more": true, "data": [...] } ``` To paginate forward, pass the last item's `id` as `starting_after`: ```bash curl "https://api.stile.id/v1/verification_sessions?limit=10&starting_after=vks_abc123" \ -H "Authorization: Bearer stile_sk_..." ``` ## Expanding responses Some endpoints support the `expand[]` parameter to include related objects inline. For example, both retrieving and listing verification sessions accept `expand[]=results` to embed each session's per-method verification results: ```bash # Include verification results in the session response curl "https://api.stile.id/v1/verification_sessions/vks_abc123?expand[]=results" \ -H "Authorization: Bearer stile_sk_..." ``` ## The error object All errors return a JSON body with this structure: ```json { "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "No such verification_session: 'vks_unknown'", "param": "id", "request_id": "req_abc123" } } ``` ### Error types | Type | Meaning | | ----------------------- | ------------------------------------------------------------- | | `invalid_request_error` | The request was malformed or can't be processed as sent. | | `authentication_error` | The API key is missing, invalid, or lacks the required scope. | | `rate_limit_error` | Too many requests — back off and retry. | | `api_error` | Something failed on Stile's side. Safe to retry with backoff. | ### HTTP status codes | Status | Meaning | | ------------ | ------------------------------------------------------------------------------------------ | | `200`, `201` | Success. | | `400` | Bad request — malformed or missing parameters (e.g. `parameter_invalid`). | | `401` | Authentication failed — missing or invalid API key (`api_key_invalid`). | | `402` | Billing issue (`billing_suspended`, `test_quota_exceeded`). | | `403` | The key isn't allowed to do this (e.g. `publishable_key_scope`). | | `404` | Resource doesn't exist (`resource_missing`, `session_not_found`). | | `409` | Conflict with current state (e.g. `session_terminal`, `idempotency_key_reuse`). | | `422` | Request understood but rejected (e.g. `use_case_prohibited`, `jurisdiction_unresolvable`). | | `429` | Rate limited (`rate_limit_exceeded`) — retry after the `Retry-After` header. | | `500` | Server error — retry with backoff. | Retry `429` (after `Retry-After`) and `5xx` responses with exponential backoff — `min(500ms * 2^attempt + jitter, 30s)` is a good schedule. Never auto-retry other `4xx` errors; fix the request instead. See [Error Handling](/guides/error-handling) for resource-specific codes and recovery strategies. ## Idempotency POST requests accept an `Idempotency-Key` header to prevent duplicates: ```bash curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_12345" \ -d '{"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}' ``` Pick a key tied to the operation you're protecting (an order ID, a user ID plus action). The semantics: - **Same key, same body** — the original response is replayed; no duplicate is created. - **Same key, different body** — the request is rejected with `409` `idempotency_key_reuse`. The [Node.js SDK](/sdks/node) accepts the same key as a per-request option: `stile.verificationSessions.create(params, { idempotencyKey: "order_12345" })`. ## Rate limiting Requests are rate-limited per API key over a per-minute rolling window — **1,000 requests/minute** for secret keys, **100/minute** for publishable keys. Rate limit headers are included in every response: ``` X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 997 X-RateLimit-Reset: 1741564920 ``` When exceeded, the API returns `429` with a `Retry-After` header. Wait the specified seconds before retrying. ## Node.js SDK If you prefer a typed client for Node.js / TypeScript, the [`@stile/node`](/sdks/node) package wraps every endpoint documented here. Every SDK method maps 1:1 to an HTTP call — choose whichever you prefer. ## API playground The core endpoints in this reference have interactive counterparts in the **API Playground** section of these docs, generated from Stile's OpenAPI spec ([`/openapi.yaml`](/openapi.yaml)). Use it to explore request and response schemas alongside the prose reference here. ## Explore the reference --- # Verification Sessions Section: HTTP API URL: https://docs.stile.id/api-reference/verification-sessions > Create, retrieve, cancel, and list verification sessions — the core resource of every Stile integration. A verification session represents a single attempt to verify a user's age or identity. Your server creates one, hands the `client_secret` to the [widget](/sdks/widget), and listens for the [webhook](/guides/webhooks) that reports the outcome. Every session runs inside a published [workflow](https://dashboard.stile.id/workflows), which carries the use case, target jurisdictions, and verification methods. This is the endpoint behind the widget's `session-url` mode — your backend creates the session and returns the `client_secret` to the page. You'll also use it to retrieve, cancel, or list sessions programmatically. Examples show cURL, Python, Go, and Node.js. You can also use the [Node.js SDK](/sdks/node) as a typed convenience wrapper, or call the API from any language — see [HTTP API Overview](/api-reference/overview). ## The verification session object Every endpoint on this page returns (or lists) this object. Timestamps are Unix seconds. ## Session statuses | Status | Description | | ---------------- | ------------------------------------------------------------ | | `created` | Session created, waiting for the user to start verification. | | `pending` | User has opened the verification UI. | | `processing` | Verification is actively being processed by a method. | | `requires_input` | A method requires additional user input. | | `verified` | Verification succeeded. Terminal state. | | `failed` | All methods were exhausted without success. Terminal state. | | `cancelled` | Cancelled by your server or the user. Terminal state. | | `expired` | The 24-hour expiry passed before completion. Terminal state. | ## Create a session Creates a session and returns the full object, including the one-time `client_secret` your frontend needs. Use a secret key on your backend. Browser-side creation with a publishable key is deprecated — responses carry a `Stile-Deprecation: publishable-key-session-create` header and require a `captcha_token`. The widget's `session-url` mode gives you the server-side pattern with no extra client code. d" (e.g. "30d" for 30 days).', }, { name: "required_methods", type: "string[]", description: "Require ALL of these methods to be previously verified when reusing existing credentials.", }, { name: "credential_hash", type: "string", description: "Credential hash from a prior verification, used to match and reuse an existing verified credential.", }, { name: "delivery_jurisdiction", type: "string", description: 'Optional delivery/shipping jurisdiction (e.g. "US-OR"). When provided, the response includes ip_jurisdiction and jurisdiction_mismatch to flag VPN/geolocation discrepancies.', }, { name: "vp_token", type: "string", description: "Reusable VP token from a prior verification (the widget stores it in localStorage and passes it automatically). See Returning Users.", }, { name: "skip_verification", type: "boolean", description: "Sandbox orgs only; rejected otherwise. Instantly creates a verified session — no document capture or liveness — so you can exercise webhooks end-to-end. Rejected with a 400 in a non-sandbox org.", }, { name: "captcha_token", type: "string", description: "Cloudflare Turnstile token. Required only when creating sessions with a publishable key from the browser; not needed for secret keys.", }, ]} /> ```bash curl https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID", "return_url": "https://yourapp.com/done", "client_reference_id": "user_123" }' ``` ```python import requests res = requests.post( "https://api.stile.id/v1/verification_sessions", headers={"Authorization": "Bearer stile_sk_..."}, json={ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID", "return_url": "https://yourapp.com/done", "client_reference_id": "user_123", }, ) session = res.json() ``` ```go body := strings.NewReader(`{ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID", "return_url": "https://yourapp.com/done", "client_reference_id": "user_123" }`) req, _ := http.NewRequest("POST", "https://api.stile.id/v1/verification_sessions", body) req.Header.Set("Authorization", "Bearer stile_sk_...") req.Header.Set("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) ``` ```ts const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", return_url: "https://yourapp.com/done", client_reference_id: "user_123", }); // Returns: // { // id: "vks_abc123", // object: "verification_session", // status: "created", // type: "age", // client_secret: "eyJ...", ← pass to frontend // methods: ["mdl", "document_capture"], ← resolved from the workflow // age_tier: "min_age_21", ← resolved from workflow + jurisdiction // jurisdiction: "US-CA", ← auto-detected // requires_email_otp: false, ← true when a returning user must prove email ownership // expires_at: 1741651200, // created: 1741564800, // ip_jurisdiction: "US-CA", ← only when delivery_jurisdiction is set // jurisdiction_mismatch: false, ← true if delivery ≠ IP jurisdiction // } ``` POST requests also accept an `Idempotency-Key` header. The same key with the same body replays the original response; the same key with a different body returns a 409 `idempotency_key_reuse`. ## Retrieve a session Pass `expand[]=results` to include the full verification results array in the response. ```bash curl "https://api.stile.id/v1/verification_sessions/vks_abc123?expand[]=results" \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/verification_sessions/vks_abc123", headers={"Authorization": "Bearer stile_sk_..."}, params={"expand[]": "results"}, ) session = res.json() ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/verification_sessions/vks_abc123?expand[]=results", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const session = await stile.verificationSessions.retrieve("vks_abc123", { expand: ["results"], }); // session.results — array of VerificationResult objects ``` Treat [webhook delivery](/guides/webhooks) as the source of truth for status changes — poll this endpoint only on cold start or for reconciliation. ## Cancel a session Cancellable while the session is still open (`created`, `pending`, or `requires_input`). A session in a terminal state (`verified`, `failed`, `cancelled`, or `expired`) returns a 409 `session_terminal`. ```bash curl -X POST https://api.stile.id/v1/verification_sessions/vks_abc123/cancel \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.post( "https://api.stile.id/v1/verification_sessions/vks_abc123/cancel", headers={"Authorization": "Bearer stile_sk_..."}, ) session = res.json() ``` ```go req, _ := http.NewRequest("POST", "https://api.stile.id/v1/verification_sessions/vks_abc123/cancel", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const session = await stile.verificationSessions.cancel("vks_abc123"); // session.status === "cancelled" ``` ## List sessions Returns a paginated list of sessions for your organization, newest first. ```bash curl "https://api.stile.id/v1/verification_sessions?limit=20" \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/verification_sessions", headers={"Authorization": "Bearer stile_sk_..."}, params={"limit": 20}, ) data = res.json() ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/verification_sessions?limit=20", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const { data, has_more } = await stile.verificationSessions.list({ limit: 20, }); for (const session of data) { console.log(session.id, session.status); } ``` ## Verification methods The session's `methods` array is compiled from the published workflow — you configure methods in the workflow editor, not per request. These are the methods a workflow can use: | Method | Value | Description | | ----------------------- | ------------------ | ------------------------------------------------------------- | | Mobile Driver's License | `mdl` | ISO 18013-5 mDL via OID4VP. Highest assurance. | | Mobile Identity | `mid` | Government-issued digital identity credential. | | EUDI PID | `eudi_pid` | European Digital Identity credential. | | Facial Age Estimation | `facial_age` | AI age estimate from a selfie. No document required. | | Selfie Liveness | `selfie_liveness` | Confirms a real person is present via a live selfie check. | | Selfie Match | `selfie_match` | Matches a selfie against a previously verified photo. | | Self Attestation | `self_attestation` | User self-declares their age or identity. Lowest assurance. | | Document Capture | `document_capture` | Extracts and verifies data from a physical ID document photo. | | NFC Passport | `nfc_passport` | Reads the passport chip via NFC (ICAO 9303). | | Carrier Lookup | `carrier_lookup` | Phone carrier verification for age signals. | | Open Banking | `open_banking` | Bank account data for identity signals. | | Parental Consent | `parental_consent` | Collects consent from a parent or guardian. | | Student Verification | `student` | Academic enrollment verification. | Methods are automatically filtered by jurisdiction — for example, mDL is removed in states that don't recognize digital driver's licenses, even if the workflow includes it. ## Compliance resolution When you create a session, regulatory rules are resolved internally from the workflow's use case and the user's jurisdiction — the result lands in the session's `compliance` block. To inspect those rules yourself (e.g. to drop prohibited items from a cart), use the [Compliance API](/api-reference/compliance). ## Errors on this resource All errors share the standard shape (`error.type`, `error.code`, `error.message`, `error.param`, `error.request_id`). These are the codes you'll most likely hit on session endpoints: | Code | Status | Description | | ------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `captcha_required` | 400 | A Cloudflare Turnstile token is required when creating sessions from the browser with a publishable key. Pass `captcha_token`. | | `webhook_required` | 400 | A live (non-sandbox) org requires at least one active webhook endpoint before sessions can be created. See [Webhook Endpoints](/api-reference/webhook-endpoints). | | `email_not_verified` | 400 | The returning user must prove email ownership via OTP before their prior verification can be reused. | | `jurisdiction_unresolvable` | 422 | No jurisdiction could be resolved for the request. Pass `jurisdiction` explicitly. | | `use_case_prohibited` | 422 | The workflow's use case is prohibited in the resolved jurisdiction. | | `accept_existing_rate_limited` | 429 | Too many `accept_existing` attempts — limited to 5 per email per 15 minutes. | | `test_quota_exceeded` | 402 | The sandbox quota (500 verifications per calendar month) is used up. Resets on the 1st. | | `billing_suspended` | 402 | Billing on your organization is suspended; requests are rejected until it is resolved. | | `idempotency_key_reuse` | 409 | The `Idempotency-Key` was already used with a different request body. | | `session_not_found` | 404 | No session with this ID exists for your organization. | See [Error Handling](/guides/error-handling) for the full catalog, status codes, and retry guidance. ## Next steps --- # Compliance Section: HTTP API URL: https://docs.stile.id/api-reference/compliance > Look up regulatory rules by product type and jurisdiction — required age tiers, prohibited products, allowed verification methods, and most-restrictive resolution for mixed carts. The Compliance API tells you what the rules are before you ask anyone to verify. Give it one or more product types and a jurisdiction, and it returns the required age tier, whether the product is prohibited, and which verification methods that jurisdiction permits. Use it to: - **Pre-check carts** — catch prohibited products before checkout, not after the user has verified - **Show age requirements in your UI** — display "21+ required in California" before launching verification - **Pick between workflows** — when you run multiple published workflows, choose the one whose use case matches what the rules require - **Resolve mixed carts** — multiple product types collapse into one `most_restrictive` summary: the highest age tier, the intersected allowed methods When you create a verification session, compliance is resolved internally from the workflow's use case and the user's jurisdiction — you don't need to call this API first. Use it when you want to inspect the rules yourself: pre-checking carts for prohibited products, showing age requirements in your UI, or picking between workflows. ## Check compliance Returns compliance details for one or more product types in a jurisdiction, plus a `most_restrictive` summary that merges the rules across all products. ### Available product types | Product type | Description | | --------------------- | -------------------------------- | | `alcohol_delivery` | Alcohol delivery (online orders) | | `alcohol_in_person` | Alcohol retail (point-of-sale) | | `cannabis_dispensary` | Cannabis dispensary sales | | `cannabis_delivery` | Cannabis delivery | | `gambling_online` | Online gambling / iGaming | | `gambling_in_person` | In-person gambling / casinos | | `tobacco_nicotine` | Tobacco and nicotine products | | `adult_content` | Adult content / pornography | | `social_media` | Social media age gates | | `firearms` | Firearms sales | ### Example: single product Check one product type against an explicit jurisdiction: ```bash curl "https://api.stile.id/v1/compliance/check?use_cases=alcohol_delivery&jurisdiction=US-CA" \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/compliance/check", headers={"Authorization": "Bearer stile_sk_..."}, params={"use_cases": "alcohol_delivery", "jurisdiction": "US-CA"}, ) result = res.json() print(result["most_restrictive"]["required_age_tier"]) # "min_age_21" ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/compliance/check?use_cases=alcohol_delivery&jurisdiction=US-CA", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const result = await stile.compliance.check({ use_cases: ["alcohol_delivery"], jurisdiction: "US-CA", }); console.log(result.most_restrictive.required_age_tier); // "min_age_21" ``` ### Example: mixed cart When a cart contains multiple product types, the API returns per-product details and a merged `most_restrictive` summary with the highest age tier and intersected allowed methods. If any single product is prohibited, `any_prohibited` flips to `true` and the offending product types are listed in `prohibited_use_cases`. ```bash curl "https://api.stile.id/v1/compliance/check?use_cases=alcohol_delivery,tobacco_nicotine&jurisdiction=US-UT" \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/compliance/check", headers={"Authorization": "Bearer stile_sk_..."}, params={ "use_cases": "alcohol_delivery,tobacco_nicotine", "jurisdiction": "US-UT", }, ) result = res.json() if result["most_restrictive"]["any_prohibited"]: print("Cannot sell:", result["most_restrictive"]["prohibited_use_cases"]) ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/compliance/check?use_cases=alcohol_delivery,tobacco_nicotine&jurisdiction=US-UT", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const result = await stile.compliance.check({ use_cases: ["alcohol_delivery", "tobacco_nicotine"], jurisdiction: "US-UT", }); // Check if any products are prohibited in this jurisdiction if (result.most_restrictive.any_prohibited) { console.log("Cannot sell:", result.most_restrictive.prohibited_use_cases); // Remove prohibited items from the cart } // Per-product details for (const r of result.results) { console.log(r.use_case, r.prohibited ? "PROHIBITED" : r.required_age_tier); } // Create a session — the workflow carries the use case, and the // age tier is resolved internally const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", }); ``` ### Response The response includes the resolved jurisdiction, a `results` array (one entry per product type), and a `most_restrictive` summary. ```json { "jurisdiction": "US-CA", "jurisdiction_source": "explicit", "results": [ { "use_case": "alcohol_delivery", "jurisdiction": "US-CA", "prohibited": false, "prohibited_reason": null, "required_age_tier": "min_age_21", "allowed_methods": ["mdl", "facial_age", "carrier_lookup", "document_capture"], "mdl_acceptance_status": "supplementary", "governing_regulator": "State Alcohol Beverage Control Boards", "consent_required": false, "credential_validity_days": 365, "rule_version": "2026-04-02" } ], "most_restrictive": { "required_age_tier": "min_age_21", "allowed_methods": ["mdl", "facial_age", "carrier_lookup", "document_capture"], "consent_required": false, "data_retention_days": null, "any_prohibited": false, "prohibited_use_cases": [] } } ``` ### Top-level fields ### Per-result fields ### Most-restrictive summary fields Age tiers are ordered: `min_age_13` < `min_age_16` < `min_age_18` < `min_age_21`. The summary returns the highest tier required by any non-prohibited product in the cart — a verification at a higher tier satisfies any lower requirement, never the reverse. ## Typical integration flow The Compliance API is informational — it tells you the rules but doesn't enforce them. Your application decides what to do with the results. **Server-side flow:** 1. Call `stile.compliance.check()` with the product types in the user's cart and their jurisdiction 2. If any products are prohibited, remove them from the cart or show an error before starting verification 3. Create the session with the matching `workflow_id` — the age tier is resolved internally from the workflow's use case **Widget flow (automatic):** 1. Point the widget at a published workflow (`workflow-id`, or let your `session-url` endpoint choose one) 2. Session creation resolves compliance from the workflow internally — no extra API call needed ## Next steps --- # Verified Person Section: HTTP API URL: https://docs.stile.id/api-reference/verified-person > Look up whether a user already holds a verified credential — on your site or across the Stile network — and skip re-verification for returning users. The Verified Person API answers one question: **has this user already been verified?** A user who completed verification on your site — or on another site in the Stile network — holds a reusable credential. Look it up by email or phone before starting a new session, and skip the camera-and-ID flow entirely for returning users. Two ways to use it: - **Manual lookup** — call `POST /v1/verified_person/lookup` from your backend and branch on the result yourself. - **Session-level reuse** — pass `accept_existing` (plus `email` or `phone`, and an optional `min_strength`) when [creating a session](/api-reference/verification-sessions) and let Stile run the lookup and reuse flow for you. See [Combine with session-level reuse](#combine-with-session-level-reuse) below. Examples show cURL, Python, Go, and Node.js. The Node.js examples assume an initialized [`@stile/node`](/sdks/node#installation) client. ## Look up a verified person Call this from your backend with your secret key before loading the widget. If the user is already verified at the required strength, you can skip the widget entirely. ```bash curl -X POST https://api.stile.id/v1/verified_person/lookup \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "min_strength": "document_capture" }' ``` ```python import requests res = requests.post( "https://api.stile.id/v1/verified_person/lookup", headers={"Authorization": "Bearer stile_sk_..."}, json={ "email": "user@example.com", "min_strength": "document_capture", }, ) result = res.json() if result["verified"]: print(result["verified_person_id"]) ``` ```go body := strings.NewReader(`{"email":"user@example.com","min_strength":"document_capture"}`) req, _ := http.NewRequest("POST", "https://api.stile.id/v1/verified_person/lookup", body) req.Header.Set("Authorization", "Bearer stile_sk_...") req.Header.Set("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) ``` ```ts const result = await stile.verifiedPersons.lookup({ email: "user@example.com", min_strength: "document_capture", max_age: "30", }); if (result.verified) { console.log(result.verified_person_id); console.log(result.credentials); // [{ method: "MDL", strength: "MDL", verified_at: "...", expires_at: "..." }] } ``` ### Response ```json { "object": "verified_person_lookup", "verified": true, "verified_person_id": "vp_abc123", "credentials": [ { "method": "MDL", "strength": "MDL", "verified_at": "2025-03-01T12:00:00.000Z", "expires_at": "2026-03-01T12:00:00.000Z" } ] } ``` | Field | Type | Description | | -------------------- | --------- | ---------------------------------------------------------------------------- | | `object` | `string` | Always `"verified_person_lookup"`. | | `verified` | `boolean` | Whether a matching credential exists that satisfies your filters. | | `verified_person_id` | `string` | Opaque identifier for the verified person (`vp_...`), or `null` if no match. | | `credentials` | `array` | Matching credentials: `method`, `strength`, `verified_at`, `expires_at`. | Credential `method` and `strength` values in the response are UPPERCASE (e.g. `"MDL"`), while request parameters like `min_strength` use lowercase (e.g. `"document_capture"`). Normalize accordingly when comparing. ### No match found When no matching credential exists, `verified` is `false` and `credentials` is empty: ```json { "object": "verified_person_lookup", "verified": false, "verified_person_id": null, "credentials": [] } ``` Treat this as a first-time user: create a verification session and run the normal flow. ## Credential strength When using `min_strength`, credentials are ranked from weakest to strongest: | Rank | Strength | Method | | ---- | ------------------ | --------------------------- | | 1 | `self_attestation` | User declaration | | 2 | `facial_age` | AI age estimation | | 3 | `carrier_lookup` | Mobile carrier verification | | 4 | `open_banking` | Bank account verification | | 5 | `document_capture` | Physical ID scan + OCR | | 6 | `mdl` | Mobile Driver's License | | 7 | `mid` | Mobile ID | | 8 | `eudi_pid` | EU Digital Identity | A credential at a given strength satisfies any request at that level or below. For example, a `document_capture` credential (rank 5) satisfies a `min_strength: "facial_age"` request (rank 2). The reverse is never true — a weaker credential cannot satisfy a stronger requirement, and the user must complete a step-up verification. Methods not listed above map onto this ranking: `selfie_match` and `selfie_liveness` count at `document_capture` strength; `student` and `parental_consent` count at `self_attestation` strength. ## Combine with session-level reuse You don't have to orchestrate reuse yourself. Pass the reuse parameters on [`POST /v1/verification_sessions`](/api-reference/verification-sessions) and Stile runs the lookup as part of the session: | Parameter | Effect | | ------------------ | ------------------------------------------------------------------------------------------- | | `email` / `phone` | Identifies the user for the Verified Person lookup. | | `accept_existing` | When `true`, accepts an existing credential instead of requiring a new verification. | | `min_strength` | Minimum credential strength to accept, using the ranking above. | | `max_age` | Maximum age of the existing verification. Format `"30d"` — note the `d` suffix on sessions. | | `required_methods` | Require ALL of these methods to have been previously verified before reusing. | The lookup endpoint takes `max_age` as a number of days (`"30"`); session creation takes a duration string (`"30d"`). Don't swap them. When a returning user matches, the widget skips the camera flow. If the workflow requires it, the user first proves ownership of the email with a one-time code — the session's `requires_email_otp` field tells you when this gate applies. The OTP proves control of the email address only; the age or identity proof always comes from the underlying credential. Repeated `accept_existing` attempts for the same email are rate-limited: 5 attempts per 15-minute window, after which session creation returns 429 `accept_existing_rate_limited`. See [Error Handling](/guides/error-handling) for the full code list. For the end-to-end returning-user model — VP tokens, email/phone lookup with OTP, and full verification as the fallback — read the [Returning Users guide](/guides/returning-users). ## Next steps --- # Risk Section: HTTP API URL: https://docs.stile.id/api-reference/risk > Score the fraud risk of an IP, email, phone, or device — standalone, before or alongside a verification session. The Risk API scores the fraud risk of a set of signals — IP, email, phone, device — and returns a level and a recommended action. Use it to pre-screen a user before starting verification, to gate a high-value action, or to enrich your own fraud model. It's a standalone endpoint: no verification session is required, though you can attach one. Identifiers you send (IP, email, phone) are hashed server-side before anything is persisted — the raw values are used only to compute the score for this request. ## Score risk At least one identifier — `ip`, `email`, `phone`, or `device_id` — is required. ```bash curl https://api.stile.id/v1/risk/score \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "ip": "203.0.113.10", "email": "user@example.com", "claimed_country": "US-CA" }' ``` ```ts const res = await fetch("https://api.stile.id/v1/risk/score", { method: "POST", headers: { Authorization: `Bearer ${process.env.STILE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ ip: "203.0.113.10", email: "user@example.com", claimed_country: "US-CA", }), }); const assessment = await res.json(); ``` ```python import requests, os res = requests.post( "https://api.stile.id/v1/risk/score", headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"}, json={"ip": "203.0.113.10", "email": "user@example.com", "claimed_country": "US-CA"}, ) assessment = res.json() ``` ### Response ```json { "id": "risk_assessment_abc123", "object": "risk_assessment", "risk_score": 18, "risk_level": "low", "recommendation": "allow", "triggered_signals": ["geo_mismatch"], "reasons": ["Claimed jurisdiction differs from IP-derived location"], "signal_details": {}, "created": 1741564800 } ``` ## Retrieve an assessment Fetch a previously computed assessment by its `id`. ```bash curl https://api.stile.id/v1/risk/risk_assessment_abc123 \ -H "Authorization: Bearer stile_sk_..." ``` You don't have to call this API to benefit from risk scoring — supplementary signals (IP analysis, device risk, geolocation) run automatically inside a verification session when your workflow includes them. This endpoint is for scoring outside of, or ahead of, a session. ## Next steps --- # Webhook Endpoints Section: HTTP API URL: https://docs.stile.id/api-reference/webhook-endpoints > Register, manage, and debug the HTTPS endpoints that receive Stile's signed event notifications. A webhook endpoint is an HTTPS URL on your server where Stile delivers signed [events](/api-reference/events) — session verified, failed, expired, and more. This page covers the management API: creating endpoints, rotating secrets, and inspecting individual deliveries. For signature verification and handler patterns, see the [Webhooks guide](/guides/webhooks). You can also manage endpoints in the [dashboard](https://dashboard.stile.id/webhooks). Examples show cURL, Python, Go, and Node.js. You can also use the [Node.js SDK](/sdks/node) as a typed convenience wrapper. You cannot create verification sessions until at least one active webhook endpoint exists (the API returns 400 `webhook_required`). Sandbox organizations are exempt. ## The webhook endpoint object | Field | Type | Description | | ---------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `string` | Unique identifier, prefixed `we_`. | | `url` | `string` | The HTTPS URL events are delivered to. | | `enabled_events` | `string[]` | Subscribed event types. `["*"]` means all events. | | `status` | `string` | `enabled` or `disabled`. Disabled endpoints receive no deliveries. | | `description` | `string` | Human-readable label. | | `metadata` | `object` | Key-value string pairs you attach. | | `secret` | `string` | The signing secret used to compute `Stile-Signature`. Returned **once** — on create and on [rotate](#rotate-the-signing-secret) only. | ## Create an endpoint Your endpoint must be a publicly reachable HTTPS URL (plain HTTP is allowed for sandbox/local testing only) and must respond with a 2xx within 30 seconds. Return a 2xx as soon as you've queued the work — process asynchronously. ```bash curl https://api.stile.id/v1/webhook_endpoints \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/api/webhooks", "enabled_events": ["verification_session.verified", "verification_session.failed"] }' ``` ```python import requests res = requests.post( "https://api.stile.id/v1/webhook_endpoints", headers={"Authorization": "Bearer stile_sk_..."}, json={ "url": "https://yourapp.com/api/webhooks", "enabled_events": [ "verification_session.verified", "verification_session.failed", ], "description": "Production webhook", }, ) endpoint = res.json() # IMPORTANT: save endpoint["secret"] — it's only shown once! print("Webhook secret:", endpoint["secret"]) ``` ```go body := strings.NewReader(`{ "url": "https://yourapp.com/api/webhooks", "enabled_events": ["verification_session.verified", "verification_session.failed"], "description": "Production webhook" }`) req, _ := http.NewRequest("POST", "https://api.stile.id/v1/webhook_endpoints", body) req.Header.Set("Authorization", "Bearer stile_sk_...") req.Header.Set("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) ``` ```ts const endpoint = await stile.webhookEndpoints.create({ url: "https://yourapp.com/api/webhooks", enabled_events: [ "verification_session.verified", "verification_session.failed", "verification_session.expired", ], description: "Production webhook", }); // IMPORTANT: save endpoint.secret — it's only shown once! console.log("Webhook secret:", endpoint.secret); ``` The response includes the endpoint's signing `secret` exactly once. Store it in your secret manager right away — you need it to verify the `Stile-Signature` header on every delivery. If you lose it, [rotate the secret](#rotate-the-signing-secret) to get a new one. ## Retrieve an endpoint ```ts const endpoint = await stile.webhookEndpoints.retrieve("we_abc123"); ``` The `secret` is not included — it is only returned on create and rotate. ## Update an endpoint ```ts // Subscribe to all events await stile.webhookEndpoints.update("we_abc123", { enabled_events: ["*"], }); // Temporarily pause delivery await stile.webhookEndpoints.update("we_abc123", { status: "disabled", }); ``` ## Delete an endpoint ```ts await stile.webhookEndpoints.del("we_abc123"); ``` ## List endpoints ```bash curl https://api.stile.id/v1/webhook_endpoints \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/webhook_endpoints", headers={"Authorization": "Bearer stile_sk_..."}, ) data = res.json() ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/webhook_endpoints", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const { data } = await stile.webhookEndpoints.list(); ``` ## Rotate the signing secret Generates a new signing secret for the endpoint and returns it once. ```bash curl -X POST https://api.stile.id/v1/webhook_endpoints/we_abc123/rotate-secret \ -H "Authorization: Bearer stile_sk_..." ``` Update your environment variables as soon as you rotate — deliveries signed with the old secret will stop validating. ## List deliveries Returns a paginated list of delivery attempts for a specific endpoint, newest first. Useful for debugging failed deliveries and monitoring webhook health. Each delivery records the event type, the attempt number, and the HTTP status your endpoint returned; the delivery's id is also sent to your endpoint as the `Stile-Webhook-Id` header. ```ts const { data } = await stile.webhookEndpoints.listDeliveries("we_abc123", { limit: 10, }); for (const delivery of data) { console.log(delivery.event_type, delivery.response_status, delivery.attempt); } ``` ## Retrieve a delivery Returns the full detail for a single delivery attempt, including the request payload and the response your endpoint returned — useful when debugging a failing handler. ## Retry a delivery Re-queues a failed delivery immediately instead of waiting for the automatic retry schedule (5 min → 30 min → 2 h → 8 h after the initial failure, after which the delivery is marked permanently failed — see the [Webhooks guide](/guides/webhooks)). ```bash curl -X POST https://api.stile.id/v1/webhook_endpoints/we_abc123/deliveries/whd_xyz789/retry \ -H "Authorization: Bearer stile_sk_..." ``` Retries reuse the original event `id` but get a new delivery id — deduplicate your handler on the event `id`. ## Event types See the full catalog in the [Events API reference](/api-reference/events#event-types) — verification session lifecycle (`verification_session.*`), manual review outcomes (`session_review.*`), and trust-reuse grants (`trust_reuse_grant.*`, `trust_reuse_consent.*`). Use `["*"]` in `enabled_events` to subscribe to everything. ## Next steps --- # Events Section: HTTP API URL: https://docs.stile.id/api-reference/events > Events record every significant change in your Stile account — the same objects delivered to your webhook endpoints. Every time something significant happens — a session is verified, a review is resolved, a trust-reuse grant changes — Stile creates an event. Webhook deliveries POST these same event objects to your endpoints; the Events API lets you retrieve and list them on demand. Treat webhook delivery as the source of truth — poll this API on cold start or for reconciliation. Examples show cURL, Python, Go, and Node.js. You can also use the [Node.js SDK](/sdks/node) as a typed convenience wrapper. ## The event object ```json { "id": "evt_abc123", "object": "event", "type": "verification_session.verified", "created": 1741564800, "pending_webhooks": 0, "data": { "id": "vks_xyz789", "object": "verification_session", "status": "verified", "type": "identity", "client_reference_id": "user_123", "expires_at": 1741651200, "completed_at": 1741564800, "created": 1741561200 } } ``` The `data` payload reflects the session as it was when the event fired. If you need the current state — for example after processing a backlog — retrieve the session via the [Verification Sessions API](/api-reference/verification-sessions#retrieve-a-session). ## Retrieve an event ```bash curl https://api.stile.id/v1/events/evt_abc123 \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/events/evt_abc123", headers={"Authorization": "Bearer stile_sk_..."}, ) event = res.json() print(event["type"]) # "verification_session.verified" ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/events/evt_abc123", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const event = await stile.events.retrieve("evt_abc123"); console.log(event.type); // "verification_session.verified" console.log(event.data); // The verification session object ``` ## List events Returns a paginated list of events. Filter by type, time window, or session to reconcile your records against what Stile recorded. ```bash curl "https://api.stile.id/v1/events?limit=50" \ -H "Authorization: Bearer stile_sk_..." ``` ```python import requests res = requests.get( "https://api.stile.id/v1/events", headers={"Authorization": "Bearer stile_sk_..."}, params={"limit": 50}, ) data = res.json() ``` ```go req, _ := http.NewRequest("GET", "https://api.stile.id/v1/events?limit=50", nil) req.Header.Set("Authorization", "Bearer stile_sk_...") res, _ := http.DefaultClient.Do(req) ``` ```ts const { data } = await stile.events.list({ limit: 50 }); for (const event of data) { console.log(event.type, event.created); } ``` ## Event types The complete catalog. Events cover three areas: the verification session lifecycle (`verification_session.*`), manual review outcomes (`session_review.*`), and trust-reuse grants (`trust_reuse_grant.*`, `trust_reuse_consent.*`). Subscribe per endpoint via `enabled_events` — or use `["*"]` to receive everything (see [Webhook Endpoints](/api-reference/webhook-endpoints)). | Event type | Trigger | | ------------------------------------- | ------------------------------------------------------------------------ | | `verification_session.created` | A new verification session was created. | | `verification_session.verified` | The session completed successfully. | | `verification_session.failed` | All verification methods were exhausted. | | `verification_session.cancelled` | The session was cancelled. | | `verification_session.expired` | The session expired without completion. | | `session_review.flagged` | A verified session was flagged for manual review by fraud signals. | | `session_review.approved` | A reviewer approved a flagged session. | | `session_review.rejected` | A reviewer rejected a flagged session — treat it as not verified. | | `session_review.escalated` | A flagged session was escalated for senior review. | | `trust_reuse_grant.created` | A returning user reused a verification at another Stile operator. | | `trust_reuse_grant.revoked` | A trust-reuse grant was revoked. See [Trust Reuse](/guides/trust-reuse). | | `trust_reuse_consent.revoked_by_user` | A user withdrew their trust-reuse consent. | ## Pending webhooks The `pending_webhooks` field indicates how many webhook deliveries are still queued for this event. Once all subscribed endpoints have acknowledged the delivery (HTTP 2xx), it drops to 0. Endpoints with delivery failures continue retrying on the automatic schedule — see [retry behavior](/guides/webhooks#retry-behavior) in the Webhooks guide. A non-zero `pending_webhooks` on an older event is a useful health signal: it means at least one of your endpoints hasn't accepted the delivery yet. Inspect the failing deliveries via the [deliveries API](/api-reference/webhook-endpoints#list-deliveries). ## Deduplication One event can produce multiple webhook deliveries — one per subscribed endpoint, plus retries after failures. The event `id` is the stable identity across all of them. Retries reuse the same event `id` but arrive with a **new** delivery ID in the `Stile-Webhook-Id` header. Record processed event IDs and skip events you've already handled — see [handling duplicates](/guides/webhooks#handling-duplicates) for a worked example. ## Next steps --- # Webhooks Section: Guides URL: https://docs.stile.id/guides/webhooks > Receive real-time notifications when verification events occur — endpoint setup, signature verification, retries, deduplication, and local testing. Webhooks are how Stile tells your server that something happened — a session verified, a manual review resolved, a trust-reuse grant revoked — without you polling for it. This guide is the canonical reference for receiving webhooks: registering an endpoint, verifying signatures, handling retries and duplicates, and testing locally. ## How delivery works When an event occurs, Stile sends an HTTP `POST` with a signed JSON payload to every endpoint subscribed to that event type. Your endpoint acknowledges with a `2xx` response; anything else (or a timeout) triggers the [retry schedule](#retry-behavior). Your endpoint must meet three requirements: - **Publicly accessible HTTPS URL.** Plain HTTP is allowed in sandbox / local testing only. - **Respond with `2xx` within 30 seconds.** Slower responses count as failures. - **Live orgs require at least one active webhook endpoint** before sessions can be created — `POST /v1/verification_sessions` returns `400 webhook_required` otherwise. Sandbox orgs are exempt. Beyond the session lifecycle (`verification_session.*`), events also fire for manual review outcomes (`session_review.*`) and trust-reuse grants and consent revocations (`trust_reuse_grant.*`, `trust_reuse_consent.*`) — see the [full event-type catalog](/api-reference/events#event-types). ## Setup ### Register an endpoint Register a webhook endpoint in the [dashboard](https://dashboard.stile.id/webhooks) or via the API. Subscribe to the event types you handle, or use `["*"]` to receive everything. ```bash curl -X POST https://api.stile.id/v1/webhook_endpoints \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/api/webhooks", "enabled_events": ["verification_session.verified", "verification_session.failed"] }' ``` ```ts const endpoint = await stile.webhookEndpoints.create({ url: "https://yourapp.com/api/webhooks", enabled_events: ["verification_session.verified", "verification_session.failed"], }); ``` ### Store the signing secret The create response includes a `secret` — it's only shown once. Save it to your environment variables (e.g. `WEBHOOK_SECRET`); you'll need it to verify every delivery. If a secret is ever exposed, [rotate it](/api-reference/webhook-endpoints#rotate-the-signing-secret) — the new secret is likewise returned exactly once. ### Implement your handler Verify the signature, deduplicate on the event `id`, queue the work, and return `2xx`. With `@stile/node`, `fromRequest()` does the verification in one call — it works with any framework that uses the standard Web API `Request` object (Next.js, Hono, Cloudflare Workers, Bun, Deno, etc.): ```ts title="app/api/webhooks/route.ts" import Stile from "@stile/node"; const stile = new Stile(process.env.STILE_API_KEY!); export async function POST(req: Request) { let event; try { event = await stile.webhooks.fromRequest(req, process.env.WEBHOOK_SECRET!); } catch (err) { console.error("Webhook signature verification failed:", err); return new Response("Invalid signature", { status: 400 }); } switch (event.type) { case "verification_session.verified": await handleVerified(event.data); break; case "verification_session.failed": await handleFailed(event.data); break; case "verification_session.expired": await handleExpired(event.data); break; } return Response.json({ received: true }); } ``` ### Watch deliveries Every delivery is recorded. View delivery history in the dashboard under **Webhooks > Deliveries**, retrieve it via the [deliveries API](/api-reference/webhook-endpoints#list-deliveries), or [retry a failed delivery](/api-reference/webhook-endpoints#retry-a-delivery) manually. Log the `Stile-Webhook-Id` header from each request to correlate what your server received with the delivery record. ## Payload and headers Every delivery is an HTTP `POST` carrying a JSON event object and these headers (HTTP header names are case-insensitive; most frameworks expose them lowercase, e.g. `stile-signature`): | Header | Example | Description | | ------------------ | --------------------------- | ----------------------------------------------------------------- | | `Stile-Signature` | `t=1741564800,v1=abc123...` | Timestamp + HMAC-SHA256 signature. Verify before processing. | | `Stile-Webhook-Id` | `whd_abc123` | The delivery ID — unique per delivery attempt, including retries. | | `User-Agent` | `Stile/1.0` | Identifies Stile's webhook dispatcher. | | `Content-Type` | `application/json` | The body is always a JSON event object. | The body is an event object whose `data` is a snapshot of the verification session at the moment the event fired: ```json { "id": "evt_abc123", "object": "event", "type": "verification_session.verified", "created": 1741564800, "data": { "id": "vks_xyz789", "object": "verification_session", "status": "verified", "type": "identity", "client_reference_id": "user_123", "expires_at": 1741651200, "completed_at": 1741564800, "created": 1741561200 } } ``` Retries of the same event arrive with the **same event `id` but a new delivery ID** — deduplicate on `event.id`, not on `Stile-Webhook-Id`. ## Signature verification The `Stile-Signature` header has the format `t={timestamp},v1={signature}`, where the signature is an HMAC-SHA256 over `{timestamp}.{raw_body}` keyed with your endpoint secret. Always verify it before processing the event; signatures with a timestamp older than 5 minutes are rejected to prevent replay. ### Using `fromRequest()` (recommended) The handler in [Setup](#setup) above shows the full pattern: pass the Web API `Request` plus your secret, and `fromRequest()` reads the raw body, checks the timestamp, and verifies the HMAC in one call. ### Using `constructEvent()` (low-level) If your framework doesn't use the standard `Request` object, use `constructEvent()` directly with the raw body and signature header: ```ts const event = stile.webhooks.constructEvent( rawBody, // string or Buffer — the unmodified request body signatureHeader, // the stile-signature header value webhookSecret, // your endpoint's signing secret ); ``` Both methods throw `WebhookSignatureError` on failure — respond `400` in every case: | `code` | Meaning | | -------------------- | ----------------------------------------------------- | | `missing_header` | No `Stile-Signature` header on the request. | | `invalid_header` | Header doesn't match the `t=...,v1=...` format. | | `timestamp_expired` | Timestamp is older than the 5-minute tolerance. | | `signature_mismatch` | HMAC doesn't match — wrong secret or a modified body. | JSON body parsers transform the request body before signature verification, which will always fail. Make sure you pass the raw, unmodified request body to either `fromRequest()` or `constructEvent()`. You don't need an SDK to verify webhooks. See the [Webhook Signature Verification](/guides/webhook-verification) guide for the raw algorithm and copy-paste handlers in Python, Go, Ruby, and PHP. ## Best practices Verify the signature, enqueue, acknowledge. Database writes, emails, and fulfillment belong after the acknowledgment (or on a job queue) — a handler that does heavy work inline will hit the 30-second timeout under load and turn healthy events into retries. Webhooks are your primary integration path for verification outcomes. Poll `GET /v1/verification_sessions/:id` only on cold start or for reconciliation — not as your main way of learning that a session finished. ## Retry behavior If your endpoint returns a non-`2xx` response or doesn't respond within 30 seconds, Stile retries the delivery with exponential backoff: ![Webhook retry timeline: attempt 1 is immediate, then attempts follow after 5 minutes, 30 minutes, 2 hours, and 8 hours, for five attempts total](/img/webhook-retry.svg) | Attempt | Delay after previous failure | | ----------- | ---------------------------- | | 1 (initial) | Immediate | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 (final) | 8 hours | After the final attempt (roughly 10.5 hours from the initial delivery), the delivery is marked as permanently failed. Failed deliveries stay visible in the dashboard and the [deliveries API](/api-reference/webhook-endpoints#list-deliveries), and can be [retried manually](/api-reference/webhook-endpoints#retry-a-delivery) once your endpoint is healthy again. ## Handling duplicates Due to retries, your endpoint may receive the same event more than once. Use the event `id` to deduplicate: ```ts const alreadyProcessed = await db.processedEvents.findUnique({ where: { eventId: event.id }, }); if (alreadyProcessed) { return Response.json({ received: true }); // Acknowledge but skip } await handleEvent(event); await db.processedEvents.create({ data: { eventId: event.id } }); ``` ## Local testing Use a tunnel tool to expose your local server during development: ```bash # Using ngrok ngrok http 3000 # Your webhook URL becomes something like: # https://abc123.ngrok-free.app/api/webhooks ``` ```bash # Using Cloudflare Tunnel (quick tunnel, no account required) cloudflared tunnel --url http://localhost:3000 # Your webhook URL becomes something like: # https://.trycloudflare.com/api/webhooks ``` Register the tunnel URL as a webhook endpoint in the dashboard, then trigger test events by creating verification sessions with your `stile_sk_` key. In a sandbox org, `skip_verification: true` produces an instantly verified session that fires real webhooks — see the [Testing guide](/guides/testing). ## Next steps --- # Webhook Signature Verification Section: Guides URL: https://docs.stile.id/guides/webhook-verification > Verify the Stile-Signature header — three lines on Node.js with @stile/node, complete copy-paste handlers for every other stack. Every webhook delivery is signed with an HMAC-SHA256 signature in the `Stile-Signature` header. Verify it before processing any event — an unverified endpoint will accept spoofed payloads from anyone who knows your URL. (HTTP header names are case-insensitive — the examples below read it as `stile-signature`, which is how most frameworks normalize it.) ## On Node.js? Three lines Don't implement verification yourself — `@stile/node` ships the verifier: ```ts const stile = new Stile(process.env.STILE_API_KEY!); const event = await stile.webhooks.fromRequest(req, process.env.STILE_WEBHOOK_SECRET!); ``` `fromRequest()` parses the header, enforces the timestamp window, and compares in constant time — and it stays correct as the signing scheme evolves. It works with any framework built on the Web API `Request` object (Next.js, Hono, Cloudflare Workers, Bun, Deno); if yours isn't, `stile.webhooks.constructEvent(rawBody, signatureHeader, secret)` takes the raw body directly. On failure it throws `WebhookSignatureError` with a `code` of `missing_header`, `invalid_header`, `timestamp_expired`, or `signature_mismatch` — catch it and respond `400`. See the [Node SDK reference](/sdks/node) for details. **The rest of this page is for every other stack.** The complete handlers below — Python, Go, Ruby, PHP, and framework-agnostic Node.js — are maintained as part of the docs. ## Algorithm The verification algorithm is the same in every language: 1. Extract the `stile-signature` header from the request 2. Parse the header to get the timestamp (`t`) and signature (`v1`) 3. Build the signed payload: `"{timestamp}.{raw_body}"` 4. Compute `HMAC-SHA256(webhook_secret, signed_payload)` as a hex string 5. Compare the computed signature with `v1` using a timing-safe comparison 6. Reject if the timestamp is more than 5 minutes old (replay protection) ### Header format ``` stile-signature: t=1741564800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` — Unix timestamp (seconds) when the webhook was sent - `v1` — HMAC-SHA256 signature as lowercase hex ## Complete handlers Each example below is a complete, copy-paste-ready webhook handler. Pick your language: ```ts title="app/api/webhooks/route.ts" import { createHmac, timingSafeEqual } from "node:crypto"; const WEBHOOK_SECRET = process.env.STILE_WEBHOOK_SECRET!; const TOLERANCE = 300; // 5 minutes export async function POST(req: Request) { const rawBody = await req.text(); const sig = req.headers.get("stile-signature"); if (!sig) { return new Response("Missing signature", { status: 400 }); } // Parse header const parts = sig.split(","); const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2); const signature = parts.find((p) => p.startsWith("v1="))?.slice(3); if (!timestamp || !signature) { return new Response("Malformed signature", { status: 400 }); } // Check timestamp (replay protection) const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp)); if (age > TOLERANCE) { return new Response("Timestamp expired", { status: 400 }); } // Compute expected signature const expected = createHmac("sha256", WEBHOOK_SECRET) .update(`${timestamp}.${rawBody}`) .digest("hex"); // Timing-safe comparison const valid = timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex"), ); if (!valid) { return new Response("Invalid signature", { status: 400 }); } // Signature verified — process the event const event = JSON.parse(rawBody); if (event.type === "verification_session.verified") { // Grant access, update your database, etc. } return Response.json({ received: true }); } ``` ```python title="webhooks.py" import hmac, hashlib, time, json, os from flask import Flask, request, jsonify app = Flask(__name__) WEBHOOK_SECRET = os.environ["STILE_WEBHOOK_SECRET"] TOLERANCE = 300 # 5 minutes @app.route("/webhooks", methods=["POST"]) def handle_webhook(): raw_body = request.get_data(as_text=True) sig_header = request.headers.get("stile-signature", "") # Parse header parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p) timestamp = parts.get("t") signature = parts.get("v1") if not timestamp or not signature: return "Missing signature", 400 # Check timestamp (replay protection) if abs(time.time() - int(timestamp)) > TOLERANCE: return "Timestamp expired", 400 # Compute expected signature payload = f"{timestamp}.{raw_body}" expected = hmac.new( WEBHOOK_SECRET.encode(), payload.encode(), hashlib.sha256, ).hexdigest() # Timing-safe comparison if not hmac.compare_digest(signature, expected): return "Invalid signature", 400 # Signature verified — process the event event = json.loads(raw_body) if event["type"] == "verification_session.verified": # Grant access, update your database, etc. pass return jsonify(received=True) ``` ```go title="webhooks.go" package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "time" ) var webhookSecret = os.Getenv("STILE_WEBHOOK_SECRET") func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) sigHeader := r.Header.Get("stile-signature") // Parse header var timestamp, signature string for _, part := range strings.Split(sigHeader, ",") { if strings.HasPrefix(part, "t=") { timestamp = part[2:] } else if strings.HasPrefix(part, "v1=") { signature = part[3:] } } if timestamp == "" || signature == "" { http.Error(w, "Missing signature", 400) return } // Check timestamp (replay protection) ts, _ := strconv.ParseInt(timestamp, 10, 64) if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, "Timestamp expired", 400) return } // Compute expected signature mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write([]byte(fmt.Sprintf("%s.%s", timestamp, body))) expected := hex.EncodeToString(mac.Sum(nil)) // Timing-safe comparison if !hmac.Equal([]byte(signature), []byte(expected)) { http.Error(w, "Invalid signature", 400) return } // Signature verified — process the event var event map[string]interface{} if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "Invalid JSON", 400) return } if event["type"] == "verification_session.verified" { // Grant access, update your database, etc. } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"received":true}`)) } ``` ```ruby title="webhooks.rb" require "sinatra" require "openssl" require "json" WEBHOOK_SECRET = ENV["STILE_WEBHOOK_SECRET"] TOLERANCE = 300 # 5 minutes post "/webhooks" do raw_body = request.body.read sig_header = request.env["HTTP_STILE_SIGNATURE"] || "" # Parse header parts = sig_header.split(",").map { |p| p.split("=", 2) }.to_h timestamp = parts["t"] signature = parts["v1"] halt 400, "Missing signature" unless timestamp && signature # Check timestamp (replay protection) halt 400, "Timestamp expired" if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE # Compute expected signature payload = "#{timestamp}.#{raw_body}" expected = OpenSSL::HMAC.hexdigest("sha256", WEBHOOK_SECRET, payload) # Timing-safe comparison halt 400, "Invalid signature" unless OpenSSL.secure_compare(signature, expected) # Signature verified — process the event event = JSON.parse(raw_body) if event["type"] == "verification_session.verified" # Grant access, update your database, etc. end content_type :json { received: true }.to_json end ``` ```php title="webhooks.php" $tolerance) { http_response_code(400); exit("Timestamp expired"); } // Compute expected signature $payload = "{$timestamp}.{$rawBody}"; $expected = hash_hmac("sha256", $payload, $webhookSecret); // Timing-safe comparison if (!hash_equals($signature, $expected)) { http_response_code(400); exit("Invalid signature"); } // Signature verified — process the event $event = json_decode($rawBody, true); if ($event["type"] === "verification_session.verified") { // Grant access, update your database, etc. } header("Content-Type: application/json"); echo json_encode(["received" => true]); ``` If you're using Node.js, the `@stile/node` SDK provides a convenience method: ```bash npm install @stile/node ``` ```ts title="app/api/webhooks/route.ts" import Stile from "@stile/node"; const stile = new Stile(process.env.STILE_API_KEY!); export async function POST(req: Request) { const event = await stile.webhooks.fromRequest( req, process.env.STILE_WEBHOOK_SECRET!, ); if (event.type === "verification_session.verified") { // Grant access, update your database, etc. } return Response.json({ received: true }); } ``` ## Verification checklist Whether you use the SDK or one of the handlers above, a correct webhook handler does all five of these: | Check | Why it matters | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Verify against the **raw body** | Body parsers re-serialize JSON; even an equivalent payload produces a different signature | | Use a **timing-safe comparison** | Plain string equality (`===`, `==`) leaks information through response timing | | Enforce the **5-minute tolerance** | Rejecting stale timestamps blocks replay of captured deliveries | | Respond **400** on any failure | Never return `2xx` for — or process — a payload you couldn't verify | | **Dedupe on the event `id`** | Retries reuse the event `id` (with new delivery IDs) — see [handling duplicates](/guides/webhooks#handling-duplicates) | The endpoint secret is shown once at creation — store it in your environment, never in client code. If it leaks, rotate it via the [rotate-secret endpoint](/api-reference/webhook-endpoints#rotate-the-signing-secret), which returns the new secret once. ## Common pitfalls ### Body parsers modify the raw body Many frameworks (Express, Django, Rails) parse the JSON body before your handler runs. Signature verification requires the **raw, unmodified** request body. | Framework | How to access raw body | | ------------------ | ---------------------------------------------------------------------- | | Next.js App Router | `request.text()` (built-in) | | Express | Use a raw body middleware on the webhook route (skip `express.json()`) | | Flask | `request.get_data(as_text=True)` | | Django | `request.body.decode()` | | Sinatra | `request.body.read` | | Go `net/http` | `io.ReadAll(r.Body)` | | PHP | `file_get_contents("php://input")` | ### Clock skew The timestamp check rejects events older than 5 minutes. If your server's clock is significantly off, legitimate webhooks will be rejected. Use NTP to keep your server clock synchronized. ### Timing-safe comparison Always use a constant-time comparison function (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, etc.) to prevent timing attacks that could reveal the signature byte-by-byte. ## Next steps --- # Error Handling Section: Guides URL: https://docs.stile.id/guides/error-handling > Parse Stile's error envelope, branch on stable error codes, and retry safely with exponential backoff and idempotency keys — in any language, no SDK required. Stile errors are designed to be handled programmatically. Every failure returns the same JSON envelope, a stable machine-readable `code`, and an HTTP status that tells you whether a retry can succeed. This page catalogs every status and error code, shows handler patterns in five languages, and covers safe retries with backoff and idempotency keys. ## Anatomy of an error Every non-`2xx` response carries a single top-level `error` object: ```json { "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "Missing required parameter: workflow_id", "param": "workflow_id", "request_id": "req_abc123" } } ``` Include the `request_id` when contacting support — it lets us trace the exact request in our logs. ## HTTP status codes | Code | Meaning | | ---- | ---------------------------------------------------------------------------------------- | | 200 | OK — request succeeded. | | 201 | Created — resource was created successfully. | | 400 | Bad Request — missing or invalid parameters. | | 401 | Unauthorized — invalid, missing, or revoked API key. | | 402 | Payment Required — billing is suspended or the sandbox monthly quota is exhausted. | | 403 | Forbidden — the key is valid but not allowed to perform this operation. | | 404 | Not Found — the requested resource doesn't exist. | | 409 | Conflict — idempotency key collision or state conflict. | | 422 | Unprocessable Entity — the request is valid but can't be fulfilled. | | 429 | Too Many Requests — rate limit exceeded. Retry after the `Retry-After` header value. | | 500 | Internal Server Error — something went wrong on our end. Retry with exponential backoff. | ## Error types `type` groups failures into four coarse categories — useful when you want one handler per failure class rather than per code. | Type | When it occurs | | ----------------------- | ------------------------------------------------------------------------------------- | | `invalid_request_error` | A parameter is missing, invalid, or the operation isn't allowed in the current state. | | `authentication_error` | The API key is missing, malformed, revoked, or expired. | | `rate_limit_error` | Too many requests were sent in the current window. | | `api_error` | An unexpected server error occurred. Safe to retry. | ## Error codes `code` identifies the exact failure and is the value to branch on programmatically: | Code | Status | Description | | ------------------------------------ | ------ | -------------------------------------------------------------------------------------------------- | | `parameter_invalid` | 400 | A required parameter is missing or has an invalid value. | | `resource_missing` | 404 | The requested session, event, or endpoint doesn't exist. | | `billing_suspended` | 402 | Your organization's billing is suspended — requests are blocked until billing is resolved. | | `test_quota_exceeded` | 402 | Sandbox mode is capped at 500 verifications per calendar month. Resets on the 1st. | | `use_case_prohibited` | 422 | The workflow's use case is not permitted in the detected jurisdiction. | | `jurisdiction_unresolvable` | 422 | Could not determine the user's jurisdiction from their IP. Pass `jurisdiction` explicitly. | | `accept_existing_rate_limited` | 429 | Too many `accept_existing` attempts for the same email (5 per 15-minute window). | | `rate_limit_exceeded` | 429 | Too many requests in the current window. Retry after the `Retry-After` header value. | | `publishable_key_scope` | 403 | A publishable key was used on an endpoint that requires a secret key. | | `publishable_session_create_blocked` | 403 | Your organization has migrated to backend-created sessions — create them with a secret key. | | `captcha_required` | 400 | Publishable-key session creation from the browser requires a Cloudflare Turnstile `captcha_token`. | | `email_not_verified` | 400 | Email verification (OTP) is required before creating a session for a returning user. | | `webhook_required` | 400 | Live (non-sandbox) organizations require at least one active webhook endpoint. | | `session_not_found` | 404 | The verification session does not exist or belongs to a different organization. | | `session_terminal` | 409 | The session is in a terminal state (verified, failed, cancelled, expired) and cannot be modified. | | `session_not_redeemable` | 409 | The session is not in a state where a VP token or OTP can be redeemed against it. | | `session_locked_to_other_device` | 409 | The session is being completed on another device (desktop→mobile handoff lock). | | `vp_token_invalid` | 400 | The supplied VP token is malformed, expired, or revoked. Fall back to full verification. | | `no_matching_vp` | 400 | `accept_existing` found no reusable verification for this email/phone at the required strength. | | `otp_not_proven` | 400 | The returning user hasn't completed the email OTP challenge yet. | | `idempotency_key_reuse` | 409 | A different request body was sent with the same idempotency key. | | `api_key_invalid` | 401 | The API key is missing, malformed, revoked, or expired. | ## Handling errors Parse the JSON error body and branch on `type` or `status` to handle different failures: ```ts async function stileRequest(method, path, body) { const res = await fetch(`https://api.stile.id/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.STILE_API_KEY}`, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json(); if (!res.ok) { const err = data.error; switch (err.type) { case "authentication_error": throw new Error(`Auth failed: ${err.message}`); case "rate_limit_error": throw new Error(`Rate limited. Retry after ${res.headers.get("Retry-After")}s`); case "invalid_request_error": throw new Error(`Bad request [${err.code}]: ${err.message}`); default: throw new Error(`API error: ${err.message} (${err.request_id})`); } } return data; } ``` ```python import requests, os def stile_request(method, path, json=None): res = requests.request( method, f"https://api.stile.id/v1{path}", headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"}, json=json, ) data = res.json() if not res.ok: err = data["error"] if err["type"] == "authentication_error": raise Exception(f"Auth failed: {err['message']}") elif err["type"] == "rate_limit_error": raise Exception(f"Rate limited. Retry after {res.headers.get('Retry-After')}s") elif err["type"] == "invalid_request_error": raise Exception(f"Bad request [{err['code']}]: {err['message']}") else: raise Exception(f"API error: {err['message']} ({err['request_id']})") return data ``` ```go type StileError struct { Type string `json:"type"` Code string `json:"code"` Message string `json:"message"` Param string `json:"param"` RequestID string `json:"request_id"` Status int } func (e *StileError) Error() string { return fmt.Sprintf("[%s] %s: %s (request_id: %s)", e.Type, e.Code, e.Message, e.RequestID) } func stileRequest(method, path string, body io.Reader) ([]byte, error) { req, _ := http.NewRequest(method, "https://api.stile.id/v1"+path, body) req.Header.Set("Authorization", "Bearer "+os.Getenv("STILE_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() data, _ := io.ReadAll(res.Body) if res.StatusCode >= 400 { var errResp struct{ Error StileError `json:"error"` } json.Unmarshal(data, &errResp) errResp.Error.Status = res.StatusCode return nil, &errResp.Error } return data, nil } ``` ```ruby require "net/http" require "json" class StileError < StandardError attr_reader :type, :code, :param, :request_id, :status def initialize(err, status) @type = err["type"] @code = err["code"] @param = err["param"] @request_id = err["request_id"] @status = status super(err["message"]) end end def stile_request(method, path, body = nil) uri = URI("https://api.stile.id/v1#{path}") req = Net::HTTP.const_get(method.capitalize).new(uri) req["Authorization"] = "Bearer #{ENV['STILE_API_KEY']}" req["Content-Type"] = "application/json" req.body = body.to_json if body res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } data = JSON.parse(res.body) raise StileError.new(data["error"], res.code.to_i) unless res.is_a?(Net::HTTPSuccess) data end ``` ```php class StileError extends Exception { public string $type; public string $code; public ?string $param; public string $requestId; public int $status; public function __construct(array $err, int $status) { $this->type = $err["type"]; $this->code = $err["code"]; $this->param = $err["param"] ?? null; $this->requestId = $err["request_id"]; $this->status = $status; parent::__construct($err["message"]); } } function stileRequest(string $method, string $path, ?array $body = null): array { $ch = curl_init("https://api.stile.id/v1{$path}"); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Authorization: Bearer " . getenv("STILE_API_KEY"), "Content-Type: application/json", ]); if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($response, true); if ($status >= 400) { throw new StileError($data["error"], $status); } return $data; } ``` ## Retry with exponential backoff Retry on `429` (rate limit) and `5xx` (server error). Never retry `4xx` client errors — fix the request first. The retry formula: `delay = min(500ms * 2^attempt + random(0-500ms), 30s)` ```ts async function stileRequestWithRetry(method, path, body, maxRetries = 2) { for (let attempt = 0; attempt <= maxRetries; attempt++) { const res = await fetch(`https://api.stile.id/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.STILE_API_KEY}`, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 429 || res.status >= 500) { if (attempt < maxRetries) { const delay = Math.min(500 * 2 ** attempt + Math.random() * 500, 30000); await new Promise((r) => setTimeout(r, delay)); continue; } } const data = await res.json(); if (!res.ok) throw new Error(data.error.message); return data; } } ``` ```python import time, random def stile_request_with_retry(method, path, json=None, max_retries=2): for attempt in range(max_retries + 1): res = requests.request( method, f"https://api.stile.id/v1{path}", headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"}, json=json, ) if res.status_code in (429, 500, 502, 503, 504): if attempt < max_retries: delay = min(0.5 * 2**attempt + random.random() * 0.5, 30) time.sleep(delay) continue data = res.json() if not res.ok: raise Exception(data["error"]["message"]) return data ``` ```go func stileRequestWithRetry(method, path string, body io.Reader, maxRetries int) ([]byte, error) { for attempt := 0; attempt <= maxRetries; attempt++ { data, err := stileRequest(method, path, body) if stileErr, ok := err.(*StileError); ok { if stileErr.Status == 429 || stileErr.Status >= 500 { if attempt < maxRetries { delay := math.Min(500*math.Pow(2, float64(attempt))+rand.Float64()*500, 30000) time.Sleep(time.Duration(delay) * time.Millisecond) continue } } } return data, err } return nil, fmt.Errorf("max retries exceeded") } ``` ```ruby def stile_request_with_retry(method, path, body = nil, max_retries: 2) (0..max_retries).each do |attempt| begin return stile_request(method, path, body) rescue StileError => e raise unless [429, 500, 502, 503, 504].include?(e.status) raise if attempt >= max_retries delay = [0.5 * 2**attempt + rand * 0.5, 30].min sleep(delay) end end end ``` ```php function stileRequestWithRetry(string $method, string $path, ?array $body = null, int $maxRetries = 2): array { for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { try { return stileRequest($method, $path, $body); } catch (StileError $e) { if (!in_array($e->status, [429, 500, 502, 503, 504]) || $attempt >= $maxRetries) { throw $e; } $delay = min(0.5 * pow(2, $attempt) + lcg_value() * 0.5, 30); usleep((int)($delay * 1_000_000)); } } } ``` Client errors (400, 401, 404) indicate a problem with the request itself. Retrying them won't help — fix the underlying issue first. Only retry `429` (rate limit) and `5xx` (server errors). The [Node SDK](/sdks/node) retries `429`, `5xx`, and network failures automatically (`maxRetries` defaults to `2`) — you don't need to implement backoff yourself. ## Retryable vs permanent errors | Status | Retryable? | Action | | ------------------ | ---------- | ------------------------------------------------------------------------ | | 429 | Yes | Wait for `Retry-After` header duration, then retry | | 500, 502, 503, 504 | Yes | Retry with exponential backoff | | 400 | No | Fix the request parameters | | 401 | No | Check your API key | | 402 | No | Resolve billing in the dashboard, or wait for the sandbox quota to reset | | 403 | No | Use the right key type (secret vs publishable) for the endpoint | | 404 | No | The resource doesn't exist | | 409 | No | Use a different idempotency key | | 422 | No | The operation is not allowed in the current state | ## Rate limits Limits are applied per API key over a rolling one-minute window: 1,000 requests/min for secret keys, 100/min for publishable keys. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers; a `429` additionally carries `Retry-After`. See [Rate limiting](/api-reference/overview#rate-limiting) in the API reference. ## Idempotency A network timeout leaves you not knowing whether your request landed. Send an `Idempotency-Key` header on session creation so a retry can never create a duplicate: ```bash curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_12345" \ -d '{"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}' ``` ```ts const session = await stile.verificationSessions.create( { type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID" }, { idempotencyKey: "order_12345" }, ); ``` If a request with the same key was already processed, the original response is returned without creating a duplicate. Reusing a key with a **different** request body returns `409 idempotency_key_reuse` — derive the key from the logical operation (one per order, not one per attempt). ## Next steps --- # Rate Limits Section: Guides URL: https://docs.stile.id/guides/rate-limits > Per-key request limits, the rate-limit headers on every response, and how to back off cleanly. Stile rate-limits API requests per key on a rolling per-minute window. The limits are generous for normal traffic and exist to bound the blast radius of a leaked key or a runaway retry loop. ## Limits Limits are per key, by key type: | Key type | Limit | | ------------------------- | -------------------- | | Secret (`stile_sk_`) | 1,000 requests / min | | Publishable (`stile_pk_`) | 100 requests / min | Publishable keys get a tighter budget because they're exposed in the browser. Limits are per key, not per organization — a separate key for each service gets its own budget. ## Rate-limit headers Every response includes the current state of your window, so you can throttle proactively instead of waiting for a `429`: ``` X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 997 X-RateLimit-Reset: 1741564920 ``` | Header | Meaning | | ----------------------- | ------------------------------------------------ | | `X-RateLimit-Limit` | Your ceiling for the current window. | | `X-RateLimit-Remaining` | Requests left before you're limited. | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets. | ## When you're limited Exceeding the limit returns `429 Too Many Requests` with code `rate_limit_exceeded` and a `Retry-After` header (seconds to wait). Honor it: ```ts if (res.status === 429) { const retryAfter = parseInt(res.headers.get("Retry-After") || "1", 10); await new Promise((r) => setTimeout(r, retryAfter * 1000)); // retry the request } ``` `@stile/node` automatically retries `429` and `5xx` responses with exponential backoff (up to `maxRetries`, default 2). You only need to handle rate limits manually when calling the API directly. `accept_existing` is additionally rate-limited to 5 attempts per email per 15-minute window (`429 accept_existing_rate_limited`) to prevent credential enumeration. See [Returning Users](/guides/returning-users). ## Next steps --- # Testing & Sandbox Section: Guides URL: https://docs.stile.id/guides/testing > Build against sandbox mode, simulate verified sessions with skip_verification, test webhooks through a tunnel, and run the going-live checklist. Sandbox mode lets you exercise your entire integration — session creation, webhooks, VP tokens, returning-user flows — without verifying a real user. This page covers sandbox mode, the `skip_verification` flag, local webhook testing, CI suites, and the checklist for moving an integration onto a live organization. ## Sandbox mode Stile runs a single environment: every organization is live by default. For building and testing, enable **sandbox mode** on a dedicated testing organization — an org-level setting you turn on through your dashboard org settings or by contacting support. The same `stile_sk_…` / `stile_pk_…` keys work everywhere; what changes is the organization they belong to. | | Sandbox org | Live org | | ------------------ | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Verifications | Real or simulated — `skip_verification` completes sessions instantly | Real verifications only | | Webhook endpoint | Optional | **Required** — at least one active endpoint before sessions can be created | | Webhook URL scheme | HTTPS or HTTP | HTTPS only | | Billing | Free, capped at 500 verifications/month | Billed beyond the free tier (100 verifications/month by default) | Both follow the same per-key rate limit — **1,000 requests/min** for secret keys, **100/min** for publishable keys. Use a sandbox org for development, CI pipelines, and staging environments. Point your integration at a live org when you're ready to verify real users — and follow the [going-live checklist](#going-live-checklist) below when you do. ### Sandbox quota Sandbox mode is capped at **500 verifications per calendar month** per organization. When you hit the cap, session creation returns `402` with code `test_quota_exceeded`; the quota resets on the 1st of the next month. Sandbox verifications are unbilled, so this cap keeps a runaway CI or load test in check — if you need more volume, run those against a live org. Creating a verification session in a live (non-sandbox) organization will fail with `webhook_required` if the org has no active webhook endpoints. Register one in the [dashboard](https://dashboard.stile.id/webhooks) or via the [Webhook Endpoints API](/api-reference/webhook-endpoints) before going live. Sandbox orgs are exempt. ## The `skip_verification` flag In a sandbox org, pass `skip_verification: true` when creating a session to skip the entire camera/ID scanning flow. The session transitions straight to `verified`, fires webhooks, and issues a VP token — all without user interaction. This is useful for: - Testing webhook handlers end-to-end - Validating VP token flows and returning-user logic - Exercising checkout integrations in CI - Verifying email binding and OTP behavior ```bash curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID", "email": "test@example.com", "skip_verification": true }' ``` ```ts import Stile from "@stile/node"; const stile = new Stile(process.env.STILE_API_KEY!); // stile_sk_... const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", email: "test@example.com", skip_verification: true, }); ``` The response is identical to a real verified session — same shape, same webhook events, same VP token issuance. Whatever you build against it works unchanged against real verifications. The `skip_verification` flag is rejected in a live org — creating a session with `skip_verification: true` against a non-sandbox organization returns a `400` with code `parameter_invalid`. This is deliberate: there is no way to mint a verified session in a live org without a real verification. ## Local webhook testing During development your server runs on `localhost`, which isn't reachable by Stile's webhook infrastructure. Use a tunnel to expose it. ```bash # 1. Start your local server npm run dev # listening on port 3000 # 2. In a separate terminal, start ngrok ngrok http 3000 # 3. Copy the forwarding URL (e.g. https://abc123.ngrok-free.app) # 4. Register it as a webhook endpoint: curl -X POST https://api.stile.id/v1/webhook_endpoints \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://abc123.ngrok-free.app/api/webhooks", "enabled_events": ["verification_session.verified"] }' ``` ```bash # 1. Start your local server npm run dev # listening on port 3000 # 2. Start the tunnel cloudflared tunnel --url http://localhost:3000 # 3. Use the generated *.trycloudflare.com URL as your webhook endpoint ``` Once the endpoint is registered, create a session with `skip_verification: true` to trigger a real, signed `verification_session.verified` delivery against your local handler — including the `Stile-Signature` header, so you can test [signature verification](/guides/webhook-verification) exactly as it runs in production. You can also register webhook endpoints directly in the [dashboard](https://dashboard.stile.id/webhooks) instead of using the API. ## E2E testing in CI `skip_verification: true` is designed for end-to-end suites — it lets you exercise the full integration without camera interactions: - Session creation and status transitions - Webhook delivery and signature verification against your real handler - VP token issuance and returning-user reuse - Email binding and OTP flows A typical Playwright/Cypress flow: 1. Create a session with `skip_verification: true` via your backend. 2. Assert your webhook handler marked the order verified. 3. Assert your UI unlocked. Keep an eye on the 500/month sandbox quota if your suite runs on every commit. ## Sandbox data hygiene A few best practices to keep your sandbox organization clean: - **Use unique emails per test run** — append a timestamp or UUID (e.g. `test+1712345678@example.com`) to avoid collisions with previous runs. - **Sandbox sessions expire after 24 hours** — unverified sandbox sessions are automatically cleaned up, so you don't need to delete them manually. - **Isolate testing in a sandbox org** — keep your CI and manual testing in a dedicated sandbox organization, separate from the live org that serves real users. ## Going live checklist When your integration works end-to-end in a sandbox org, work through these steps before pointing it at a live organization. ### Point at a live org Use the `stile_sk_…` key for your live organization on the server and its `stile_pk_…` key in the widget. Keys are managed at [dashboard.stile.id/api-keys](https://dashboard.stile.id/api-keys) — secret keys are shown once at creation, so store them in your environment, never in code. A live org is billed beyond the free tier (100 verifications/month by default). ### Register a webhook endpoint (required) A live org requires at least one active webhook endpoint **before** you can create sessions — without one, `POST /v1/verification_sessions` returns `400` with code `webhook_required`. Register a public HTTPS URL in the [dashboard](https://dashboard.stile.id/webhooks) or via the [Webhook Endpoints API](/api-reference/webhook-endpoints), and save the signing secret. See the [Webhooks guide](/guides/webhooks) for setup and retry behavior. ### Verify webhook signatures Treat the signed webhook — not client-side events — as the source of truth for granting access. Your handler must verify the `Stile-Signature` header against the raw request body before processing any event. Use `stile.webhooks.fromRequest()` from `@stile/node`, or grab a copy-paste handler for your stack from [Webhook Verification](/guides/webhook-verification). ### Remove `skip_verification` Audit your session-creation code paths for `skip_verification: true`. In a live org the flag is rejected with `400` `parameter_invalid`, so any leftover sandbox shortcut becomes a hard failure on your first production session. Gate it behind an environment check if your test suite still needs it. ### Handle `402` and `429` responses Make sure your error handling covers the billing and rate-limit cases: `402` means `test_quota_exceeded` in a sandbox org or `billing_suspended` in a live org — surface these rather than retrying. `429` (`rate_limit_exceeded`) is retryable: respect the `Retry-After` header and back off exponentially. The rate limit is the same everywhere (1,000 requests/min for secret keys, 100/min for publishable keys), so a load pattern that throttles in staging will throttle the same way in production — but the handling should be in place either way. See [Error Handling](/guides/error-handling) for the full retry policy. ## Next steps --- # Dashboard Section: Guides URL: https://docs.stile.id/guides/dashboard > A tour of the Stile dashboard — where you build workflows, manage keys, watch sessions, configure webhooks, and handle billing. The [dashboard](https://dashboard.stile.id) is the operational home for your integration. It's where you build and publish workflows, mint API keys, watch verifications come in, configure webhooks, and manage billing and team access. This page orients you to the main areas; the linked guides go deeper on each. ## Overview Your landing view: a real-time read on API usage, geographic distribution, method health, and success rates. Use it to spot anomalies — a drop in success rate or a spike in a particular jurisdiction — at a glance. ## Workflows Where you build the recipe every verification runs inside. Create a [workflow](/concepts/workflows), pick its use case and target markets, choose methods and composition, set preferences, and **publish** it. Two builders are available — a simple use-case-driven editor and a node-based visual graph for branching flows. Sessions always run the published version, so you can edit a draft without touching live traffic. A `workflow_id` only resolves once the workflow is published. Copy it from the workflow's page to pass to the API or widget. ## Sessions A searchable log of every verification session, with its status, method, jurisdiction, and result. Drill into a session to see its timeline and — where your plan includes it — a compliance evidence trail. This is the first place to look when debugging a specific verification. ## API keys Create and revoke [publishable (`stile_pk_…`) and secret (`stile_sk_…`) keys](/getting-started/authentication), and review per-key usage. Secret keys are shown once at creation — store them immediately. You can also see which keys are driving traffic and revoke a leaked one here. ## Webhooks Register the [webhook endpoints](/api-reference/webhook-endpoints) that receive verification events, choose which event types each subscribes to, and inspect delivery history. The deliveries view shows each attempt, the response your endpoint returned, and lets you retry a failed delivery once your endpoint is healthy. Rotate an endpoint's signing secret here if it may have leaked. ## Compliance Inspect the regulatory rules that apply to your use cases by jurisdiction — required age tiers, allowed methods, prohibitions, and retention windows — the same data the [Compliance API](/api-reference/compliance) returns. Useful for understanding why a session resolved the way it did. ## Billing Manage your plan, view usage, and top up prepaid credits. The free tier (100 verifications/month by default) covers your first verifications each calendar month; usage beyond that is billed via prepaid credits. Sandbox mode — an org-level setting you enable for a testing organization — is unbilled (capped at 500 sessions/month). See [Testing](/guides/testing) for how to build against sandbox mode. ## Team & roles Invite teammates and assign roles to control who can manage keys, edit workflows, or view sessions. Organizations and applications let larger setups separate teams and products. ## Settings Org-level configuration, including: - **Trust Reuse** — opt in to cross-operator [verification reuse](/guides/trust-reuse), set the max credential age, jurisdiction lock, and accepted methods. - **Biometric & consent** — configure biometric collection and the consent templates shown to users where required. - **API inspector** — optionally log recent API requests/responses for debugging. - **Data & erasure** — action data-subject and [erasure requests](/guides/data-retention). ## Next steps --- # Security Best Practices Section: Guides URL: https://docs.stile.id/guides/security > Webhook-first access control, signature verification, key management, rate limits, and anti-fraud measures for production integrations. A verification result is only as trustworthy as the channel that delivers it. This guide covers the practices that keep your integration secure in production — webhook-first access control, signature verification, API key hygiene, and rate-limit handling — plus how Stile's design minimizes the PII you ever touch. ## Webhooks are the source of truth The client-side `stile:verified` event is a convenience signal for updating your UI. It fires in the user's browser, which means anyone with dev tools open can fake it. Never grant access, unlock content, or fulfill orders based on client events alone. Always wait for the signed `verification_session.verified` webhook before granting access. The webhook is sent server-to-server over HTTPS and includes an HMAC-SHA256 signature that proves it came from Stile. Live organizations enforce this pattern at the API level: you can't create sessions until at least one active webhook endpoint is registered (the API returns `400 webhook_required`). Sandbox organizations are exempt, so you can prototype first and wire up webhooks before going live. ## Webhook signature verification Every delivery includes a `Stile-Signature` header. Verify it before processing the event — an unverified webhook endpoint is equivalent to no verification at all, because anyone can POST a fake payload to your URL. | Rule | Detail | | ----------------- | --------------------------------------------------------------------------------------------- | | Header format | `Stile-Signature: t={timestamp},v1={signature}` | | Algorithm | HMAC-SHA256 over `{timestamp}.{raw_body}`, keyed with your endpoint secret | | Comparison | Timing-safe — never use plain string equality | | Replay protection | Reject timestamps older than 5 minutes (300 seconds) | | Body | Always verify against the **raw** request body — parsing and re-serializing changes the bytes | With `@stile/node` this is one call — `await stile.webhooks.fromRequest(req, secret)` verifies the signature and parses the event in a single step, throwing `WebhookSignatureError` on any failure (respond with `400`). See the [Webhook Signature Verification](/guides/webhook-verification) guide for the raw algorithm and copy-paste handlers in Node.js, Python, Go, Ruby, and PHP. If an endpoint's signing secret may have leaked, rotate it with `POST /v1/webhook_endpoints/:id/rotate-secret` — the new secret is returned once in the response. ## Key management Stile uses two types of API keys: | Key type | Prefix | Where to use | | --------------- | ------------ | ----------------------------------------------------------------------------------------------- | | Secret key | `stile_sk_…` | Server-side only. Creates sessions, reads results, manages webhooks. | | Publishable key | `stile_pk_…` | Safe for frontend code. Opens the verification widget. Cannot read results or manage resources. | Secret keys are displayed once at creation — store them immediately in your secrets manager or environment configuration, never in source control. Secret keys (`stile_sk_`) must never appear in frontend code, client-side bundles, mobile apps, or public repositories. If a secret key is compromised, rotate it immediately in the [dashboard](https://dashboard.stile.id/api-keys). **Key rotation** — You can rotate keys in the dashboard without downtime. Both the old and new key remain valid during a configurable grace period, giving you time to update your servers. **Prefer backend session creation** — Creating sessions with a publishable key from the browser is deprecated (responses carry a `Stile-Deprecation: publishable-key-session-create` header). Create sessions server-side with your secret key and hand the `client_secret` to the widget instead. ## Rate limiting Rate limits bound the blast radius of a leaked key or a runaway retry loop. API requests are rate-limited per key, per minute, on a rolling window: **1,000 requests/minute** for secret keys, **100/minute** for the browser-exposed publishable keys. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers so you can throttle proactively. When you exceed the limit, the API returns `429 Too Many Requests` with a `Retry-After` header indicating how many seconds to wait before retrying. ```ts if (res.status === 429) { const retryAfter = parseInt(res.headers.get("Retry-After") || "1", 10); await new Promise((r) => setTimeout(r, retryAfter * 1000)); // retry the request } ``` See [Error Handling](/guides/error-handling) for full retry-with-backoff examples in multiple languages. ## PII handling Stile is designed to minimize your PII exposure: - **Raw identity data is purged after verification** — images, full names, dates of birth, and document numbers are not stored long-term. - **Only hashed anchors persist** — email hashes, phone hashes, and document fingerprints are retained for deduplication and returning-user lookup. - **Verification outcomes persist** — the age tier result (e.g. `min_age_21`), credential method, and expiry are stored so returning users can skip re-verification. This means you never need to store or handle raw identity documents yourself. The verification result tells you what you need to know (age tier, pass/fail) without exposing the underlying PII. ## Anti-spoofing measures Stile employs multiple layers to prevent identity fraud: | Measure | What it catches | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | **Barcode cross-reference** | Printed photos of IDs — the barcode data must match the visual fields. | | **Head-turn liveness challenge** | Flat images and photos held up to the camera — the user must turn their head to prove they are physically present. | | **Server-side frame analysis** | SDK spoofing and injected video feeds — frames are analyzed server-side, not just on the client. | | **Face match** | Stolen or borrowed IDs — the selfie must match the photo on the document. | | **Document expiry check** | Expired documents are rejected. | ## VPN and geolocation detection If your use case involves physical delivery (e.g. alcohol, cannabis), pass the `delivery_jurisdiction` parameter when creating a session: ```bash curl -X POST https://api.stile.id/v1/verification_sessions \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID", "delivery_jurisdiction": "US-CA" }' ``` Stile compares the user's IP-based geolocation against the delivery jurisdiction. If there is a significant mismatch (e.g. the user's IP is in a different country), the session is flagged for review. When you pass `delivery_jurisdiction`, the create response also includes `ip_jurisdiction` and `jurisdiction_mismatch` fields so your backend can act on the comparison directly. A geolocation mismatch does not automatically fail the session. It adds a flag to the verification result that your backend can use to apply additional checks or manual review. ## Email OTP as ownership proof When a returning user is looked up by email, they must complete an OTP challenge to prove they control that email address. This is an important distinction: - **OTP proves**: "This person controls this email address." - **OTP does not prove**: Age, identity, or any other verification claim. The actual proof of age or identity comes from the underlying verification credential that was previously issued. The OTP simply gates access to that credential so a different person can't reuse it. See [Returning Users](/guides/returning-users) for the full reverification flow. ## Security checklist A summary of the do/don't items from this page: | Area | Do | Don't | | ---------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Access control | Wait for the signed `verification_session.verified` webhook before granting access | Unlock content or fulfill orders on the client-side `stile:verified` event | | Webhook handler | Verify `Stile-Signature` on every delivery, against the raw body, with a timing-safe compare | Process unverified payloads or verify against a re-serialized body | | Replay defense | Reject signatures whose timestamp is older than 5 minutes | Accept old deliveries without checking the timestamp | | API keys | Keep `stile_sk_` keys server-side; rotate immediately if exposed | Ship secret keys in frontend bundles, mobile apps, or repositories | | Session creation | Create sessions server-side and pass the `client_secret` to the widget | Rely on deprecated publishable-key session creation in production | | Rate limits | Honor `Retry-After` on `429` and back off | Hammer the API in a tight retry loop | | PII | Rely on the verification result (age tier, pass/fail) | Store raw identity documents yourself | | Geolocation | Pass `delivery_jurisdiction` for physical delivery and review mismatch flags | Auto-fail sessions on a geolocation mismatch — it's advisory | | Returning users | Treat OTP as proof of email control only | Treat OTP success as proof of age or identity | ## Next steps --- # Data Retention & Privacy Section: Guides URL: https://docs.stile.id/guides/data-retention > What Stile stores, what gets purged and when, and how the platform minimizes the PII you ever touch. Stile is built to minimize the personal data you handle. Raw identity material is collected only to run a verification, then purged — what persists is the verification outcome and a set of hashed anchors that let returning users skip re-verification. This page covers what's stored, what's discarded, and how long things live. ## What persists vs. what's purged | Data | Retention | | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Raw identity data** (ID images, name, DOB, document numbers) | Purged after verification completes. Not stored long-term. | | **Hashed anchors** (email hash, phone hash, document fingerprint) | Retained for deduplication and returning-user lookup. Not reversible to PII. | | **Verification outcome** (age tier, method, expiry) | Retained — so returning users can skip re-verification even after raw PII is gone. | The raw image bytes for document capture and NFC passport reads are purged as soon as the verification result is computed — the capture record keeps only the extracted, validated fields and is then stripped of PII. Because raw documents are purged server-side, you never receive or store ID images yourself. The verification result tells you what you need (age tier, pass/fail, method) without exposing the underlying PII. ## Jurisdiction-specific retention Retention windows aren't one-size-fits-all — they're set by the compliance rule for the jurisdiction the verification ran in. Each rule carries three independent limits: | Limit | Governs | | ------------------------------- | ------------------------------------------------- | | `data_retention_days` | Collected PII (names, dates of birth, addresses). | | `document_image_retention_days` | Captured ID document images. | | `biometric_retention_days` | Biometric data such as face-match templates. | For example, a jurisdiction with strict biometric law (e.g. Illinois BIPA) sets a shorter biometric window than a jurisdiction without one. Stile applies the correct limit automatically based on where the verification was performed — you don't configure it per request. Verification credentials (the record that a user verified) are governed by their `credential_validity_days`, not by these retention limits. A returning user can still skip re-verification after their raw PII has been purged — until the credential itself expires. ## Biometric consent Where biometric processing requires explicit consent (e.g. BIPA §15), the widget collects it inside the flow before any biometric step runs, and the consent is recorded against the session. Retention of biometric templates then follows the jurisdiction's `biometric_retention_days`. ## Erasure and data-subject requests Stile supports deleting a user's biometric and verification data on request, so you can satisfy right-to-be-forgotten obligations (GDPR, CCPA, BIPA). Reach out through your account's support channel or the dashboard to action an erasure; the underlying biometric template for a session can be deleted independently of the verification outcome. Retention windows and consent requirements are driven by jurisdiction rules in the platform, but your own compliance obligations depend on your use case. Confirm the specifics with your compliance team. ## Next steps --- # Returning User Verification Section: Guides URL: https://docs.stile.id/guides/returning-users > Verify once, reuse many times — VP tokens, credential lookup with OTP, and the strength, age-tier, and expiry rules that decide when a user must re-verify. Most verification systems force users through the full camera + ID flow on every visit. Stile verifies once, then issues a reusable signed proof — a **VP token** — so returning users pass instantly with the same security guarantees. This guide covers the three-tier reverification model, how credentials travel across sites, and the rules (strength, age tier, expiry) that decide when a user must verify again. ## The three-tier model When a user arrives, the system checks three tiers in order. The first tier that succeeds is used — no unnecessary steps. ![The three-tier returning-user model: a valid VP token passes instantly; otherwise an email or phone lookup with an OTP reuses an existing credential; otherwise the user completes a full verification](/img/returning-users-tiers.svg) | Tier | User experience | When it's used | | ---------- | ----------------------------- | ---------------------------------------------------------------------------------- | | **Tier 1** | Instant — no user interaction | User has a valid VP token in `localStorage` for this origin. | | **Tier 2** | Email + OTP code | User has verified before (on this or another site) but doesn't have a local token. | | **Tier 3** | Full camera + ID flow | First-time user, or existing credentials are expired or insufficient. | ## Tier 1: VP tokens A VP token is a signed JWT stored in the browser's `localStorage`, scoped to the current origin. It is issued automatically after a successful verification and contains: - A verified person ID (opaque identifier) - The credential method and age tier - An expiry timestamp - A signature from Stile's servers The lifecycle: 1. User completes a full verification (Tier 3) or the OTP reuse flow (Tier 2). 2. Stile issues a VP token and the widget stores it in `localStorage`. 3. On the next visit, the widget finds the token and sends it to Stile for validation. 4. If valid, the user passes instantly — no camera, no OTP, no friction. If you orchestrate this yourself, pass the token as `vp_token` when [creating a session](/api-reference/verification-sessions). A malformed, expired, or revoked token returns `400 vp_token_invalid` — treat that as "no token" and fall through to Tier 2. The VP token is a convenience for fast client-side checks, but your server must always validate the token by checking the actual credentials in the database — not just the JWT claims. A tampered token will fail server-side validation. ## Tier 2: Cross-site credential reuse `localStorage` is scoped per origin: a VP token from `shop-a.com` is not accessible on `shop-b.com`. Cross-site reuse instead works through **email/phone lookup + OTP**: ### User arrives without a token A user visits `shop-b.com` for the first time. There's no VP token in `localStorage` for this origin, so Tier 1 fails silently. ### User enters their email The widget collects the address (or your backend passes `email` on session creation). ### Stile finds an existing credential The credential was created when the user verified on `shop-a.com` and is linked to that email. ### User completes an OTP challenge A one-time code proves they own the email address — and nothing more (see [OTP is not proof of age](#otp-is-not-proof-of-age)). ### A new VP token is issued for `shop-b.com` No camera, no ID scan. The next visit passes at Tier 1. Cross-site credential reuse requires `networkDedup` to be enabled for the receiving organization. Contact support or enable it in the dashboard. ### Check for an existing credential server-side Before loading the widget at all, your backend can ask whether the user already holds a credential that meets your bar: ```bash curl -X POST https://api.stile.id/v1/verified_person/lookup \ -H "Authorization: Bearer stile_sk_..." \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "min_strength": "document_capture" }' ``` ```ts const result = await stile.verifiedPersons.lookup({ email: "user@example.com", min_strength: "document_capture", max_age: "30", }); if (result.verified) { console.log(result.verified_person_id); console.log(result.credentials); // [{ method: "MDL", strength: "MDL", verified_at: "...", expires_at: "..." }] } ``` See the [Verified Person API](/api-reference/verified-person) for the full parameter and response reference. ### Reuse at session creation You don't have to orchestrate the lookup yourself. Pass the reuse parameters on [`POST /v1/verification_sessions`](/api-reference/verification-sessions) and Stile runs the flow as part of the session: | Parameter | Effect | | ------------------ | ---------------------------------------------------------------------------------------- | | `email` / `phone` | Identifies the user for the credential lookup. | | `accept_existing` | When `true`, accepts an existing credential instead of requiring a new verification. | | `min_strength` | Minimum credential strength to accept (see [the ranking](#credential-strength-ranking)). | | `max_age` | Maximum age of the existing verification, as a duration string — e.g. `"30d"`. | | `required_methods` | Require ALL of these methods to have been previously verified before reusing. | If the workflow requires an ownership proof, the session's `requires_email_otp` field is `true` and the user must complete the OTP gate before reuse — otherwise requests fail with `400 email_not_verified` or `400 otp_not_proven`. When no reusable credential matches your filters, session creation returns `400 no_matching_vp`. Repeated `accept_existing` attempts for the same email are rate-limited to 5 per 15-minute window (`429 accept_existing_rate_limited`). ## Email binding VP tokens are bound to the email address used during verification. This prevents token sharing: - If user A verifies with `alice@example.com`, their VP token is bound to that email. - If user B tries to use the same device, a different email triggers re-verification. - Changing the email on an existing session forces a full Tier 3 verification. ## Credential strength ranking Not all verification methods carry the same weight. Stile ranks credential methods by strength: | Rank | Method | Description | | ---- | ------------------ | -------------------------------------------------- | | 1 | `self_attestation` | User self-declares their age (weakest). | | 2 | `facial_age` | AI-based age estimation from a selfie. | | 3 | `carrier_lookup` | Mobile carrier age check. | | 4 | `open_banking` | Age derived from bank account data. | | 5 | `document_capture` | Government-issued ID scan + selfie match. | | 6 | `mdl` | Mobile driver's license (ISO 18013-5). | | 7 | `mid` | Mobile identity document. | | 8 | `eudi_pid` | EU Digital Identity wallet credential (strongest). | Methods not listed map onto this ranking: `selfie_match` and `selfie_liveness` count at `document_capture` strength; `student` and `parental_consent` count at `self_attestation` strength. A stronger credential always satisfies a weaker requirement: a user verified with `document_capture` (rank 5) satisfies a request for `self_attestation` (rank 1) without re-verifying. The reverse is never true — a `self_attestation` credential cannot satisfy a `document_capture` requirement, and the user must complete a [step-up verification](#step-up-verification). ## Age tier compatibility Age tiers follow a strict hierarchy: ``` min_age_21 --> satisfies min_age_18, min_age_16, and min_age_13 min_age_18 --> satisfies min_age_16 and min_age_13 min_age_16 --> satisfies min_age_13 min_age_13 --> satisfies min_age_13 only ``` A credential proving `min_age_21` automatically satisfies any lower requirement. A weaker tier (e.g. `min_age_16`) can never satisfy a stronger one (e.g. `min_age_21`) — that, too, routes the user to a step-up verification. ## Credential expiry Credentials expire based on jurisdiction compliance rules. The default expiry is **365 days** from the date of verification. When a credential expires: - Tier 1 (VP token) stops working — the token is rejected during validation. - Tier 2 (email lookup) finds the credential but sees it's expired. - The user is routed to Tier 3 for a full re-verification. Some jurisdictions require shorter credential lifetimes. Stile automatically applies the correct expiry based on the jurisdiction where the verification was performed. You can also enforce a stricter recency bar than the credential's own expiry — pass `max_age` at lookup or session creation to refuse credentials older than your policy allows. ## Step-up verification A valid, non-expired credential can still be insufficient if the new request requires a stronger method or a higher age tier. **Example**: A user verified with `self_attestation` (rank 1) visits a site that requires `document_capture` (rank 5). Their existing credential doesn't meet the requirement, so they complete a new verification with the stronger method. After the step-up, the new (stronger) credential replaces the old one. The user now holds a `document_capture` credential that satisfies both `document_capture` and `self_attestation` requirements going forward. ## OTP is not proof of age A common misconception: completing an OTP challenge does not prove anything about the user's age or identity. - **OTP proves**: "This person controls this email address." - **OTP enables**: Reuse of an existing, previously-verified credential. - **OTP does not prove**: Age, identity, or anything else. The actual proof always comes from the underlying verification credential (ID scan, facial age estimation, etc.). The OTP is a gate that ensures only the rightful owner of the email can access the credential linked to it. ## Next steps --- # Desktop → Mobile Handoff Section: Guides URL: https://docs.stile.id/guides/device-handoff > How the widget moves a verification from a desktop to the user's phone when a step needs hardware the desktop doesn't have — automatically. Some verification steps need hardware a laptop doesn't have — a camera for document capture, NFC for an ePassport, or a wallet app for a mobile driver's license. When that happens, the widget offers a **desktop → mobile handoff**: it shows a QR code, the user continues on their phone, and the desktop picks the result back up. This is built into the widget — you don't implement it — but it surfaces on the session object, so this page explains what's happening and how to observe it. ## How it works The widget decides when a handoff is needed based on the workflow's methods and the current device. You don't trigger it; you just see the session move between devices. 1. The user starts verification on their desktop. 2. A step requires mobile hardware (camera, NFC, or a wallet). The widget renders a QR code and a countdown. 3. The user scans it and continues on their phone — the hosted verification page takes over there. 4. While the phone holds the flow, the session is **locked** to that device. 5. On completion, the lock releases and the desktop's poll sees the terminal result. ## Observing it on the session The session object reflects the handoff state, so a desktop client (or your backend) can render a "continue on your phone" state while the phone is active: | Field | Meaning | | ------------------ | --------------------------------------------------------------- | | `active_device` | `"mobile"` while the phone holds the lock; `null` otherwise. | | `device_locked_at` | Unix timestamp (seconds) when the current device took the lock. | The lock clears automatically after a terminal status, an explicit release, or a short idle timeout — so a desktop poll never gets stuck waiting on an abandoned phone session. The widget polls a lightweight session-status endpoint (authenticated with the session's `client_secret`) to track the handoff without exposing full session data to the page. If you build a custom desktop UI, retrieve the session from your backend and read `active_device` the same way. ## Customizing the hosted page The QR code points at Stile's hosted verification page. If you run the widget through the [Widget SDK](/sdks/widget), the `verify-url` attribute (or `verifyUrl` option) sets which hosted page the QR targets — useful for local development against a non-production verify URL. The phone-side widget never offers a second handoff (there's nothing to hand off to), so the experience is a single hop: desktop → phone → done. ## Next steps --- # Trust Reuse Section: Guides URL: https://docs.stile.id/guides/trust-reuse > Accept verifications a user already completed at other Stile operators — verify once, recognized everywhere. Trust Reuse lets a returning user who already verified at another Stile operator — and explicitly consented to share — pass verification at your application instantly: no camera, no ID upload. This page covers how to enable it, how reused sessions show up in your existing integration, the webhook events involved, and the privacy model behind it. ## What it is When a user verifies with Stile at one operator, they can opt in to share that verification with other operators on the platform. If that user later shows up at your application, the shared credential satisfies your verification session in place of a fresh verification flow. This is **opt-in on both sides**: - The **user** explicitly consents on the success screen of their first verification ("Save my verification for use at other Stile operators") - The **operator** explicitly opts in in their Trust Reuse settings to _accept_ reused verifications If either side hasn't opted in, the user goes through the normal verification flow at your application — exactly as they would today. Trust Reuse is the cross-operator layer. For reuse of a user's own prior verification via VP tokens or email + OTP lookup, see [Returning User Verification](/guides/returning-users). ## Why use it - **Lower abandonment** — returning users skip a 60–120 second camera + ID flow - **Same trust level** — you only accept verifications that match your method, jurisdiction, and recency policy - **You're not on the hook for someone else's bad data** — you set the acceptance bar (max age, allowed methods, jurisdiction lock); credentials that don't meet your bar fall through to fresh verification automatically - **Full audit trail** — every accepted grant has its source operator, source credential, method, and timestamps recorded against your org ## Enabling Trust Reuse for your application In the dashboard, go to **Settings → Trust Reuse**. You'll see: | Setting | Purpose | | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Accept reused verifications** | Master toggle. Off by default — flipping it on requires explicit liability acknowledgement (see below) | | **Maximum credential age (days)** | Refuse to reuse credentials verified more than this many days ago. Common values: 30, 90, 365. | | **Same jurisdiction only** | Strongly recommended on. Only accept credentials issued in the same jurisdiction as the requesting session. | | **Accepted methods** | Leave empty to accept all methods your application is configured for, or pick a specific subset. | By enabling Trust Reuse, you accept verifications performed by other platform operators as equivalent to your own. Your organization retains ultimate liability for accepted verifications. Flipping the master toggle from off → on requires you to explicitly acknowledge this each time. ## How it shows up in your flow You don't need to change any code to participate. After enabling Trust Reuse: 1. Your existing `POST /v1/verification_sessions` calls work exactly the same — same request, same response shape 2. When the user happens to qualify for reuse, the session is created with `status: "verified"` immediately, instead of `status: "created"` (which would normally wait for the camera flow) 3. The standard `verification_session.verified` webhook fires 4. Optionally, you also receive a `trust_reuse_grant.created` webhook — see below Look at `session.verification_path` on the retrieved session — it'll be `"trust_reuse"` for reused sessions vs. the method name (e.g. `"document_capture"`) for fresh verifications. ## Webhook events Subscribe to these in **Settings → Webhooks** to receive structured signals about Trust Reuse activity affecting your application. They're delivered through your standard [webhook endpoints](/guides/webhooks) and signed like every other event — verify them the same way (see [Webhook verification](/guides/webhook-verification)). ### `trust_reuse_grant.created` Fires when a reuse grant is created for your application. The `data.object` contains: ```json { "object": "trust_reuse_grant", "id": "trg_...", "source_org_id": "org_...", "target_org_id": "org_yours", "session_id": "vks_...", "verified_person_id": "vp_...", "method": "DOCUMENT_CAPTURE", "age_tier": "MIN_AGE_21", "strength": "DOCUMENT_CAPTURE", "granted_at": "2026-05-12T10:00:00Z", "expires_at": "2027-05-12T10:00:00Z", "revoked_at": null, "revoked_reason": null } ``` You'd typically log this for compliance audit and treat it as equivalent to a fresh `verification_session.verified` event. ### `trust_reuse_grant.revoked` Fires when an accepted grant is revoked. Common reasons: - `USER_REVOKED` — the user clicked the revoke link in our notification email - `USER_REVOKED_CONSENT` — the user pulled all sharing across all operators, which includes your grant - `ERASURE_REQUEST` — the user was erased from the platform; their verification is gone everywhere - `TARGET_OPERATOR_REVOKED` — you yourself revoked this grant via the dashboard or API - `SOURCE_CREDENTIAL_REVOKED` — the source operator removed the underlying credential You should treat the user as no-longer-verified for future actions. Past sessions are not retroactively invalidated. ### `trust_reuse_consent.revoked_by_user` Fires when the user revokes their sharing consent platform-wide. Sent to every operator currently holding a derived grant. If your downstream system caches "this user is verified," this is your cue to clear it. ## Revoking access from your side In **Settings → Trust Reuse → Accepted grants**, you can revoke any individual grant. Revoking does: - Marks the grant as revoked in our ledger - Fires `trust_reuse_grant.revoked` to your webhook endpoint (closes the loop with your downstream systems) - Does **not** invalidate any verification sessions already marked verified — those are historical facts; future reuse with this person won't fire ## What the user sees After a successful first verification (at any Stile operator), the user sees a single optional checkbox on the success screen: > Save my verification so other operators don't have to re-verify me. Default is **unchecked** — they have to actively opt in. If they do opt in, every time a future operator accepts a reuse grant from their consent, the user gets a transactional email from us: > Acme Carrier just used your existing Stile verification. If you didn't expect this, click here to revoke their access. The link in the email is a one-shot signed URL — one click revokes the specific grant, with no login, no password, no account page. They can also "revoke all sharing" from the same email to pull consent platform-wide. ## Operator-to-operator privacy Trust Reuse is designed so that you can never learn anything about which other operators a user has been verified with **unless the user explicitly tells you** (by getting reused at your application). Specifically: - You cannot enumerate which users have been verified elsewhere - A failed reuse attempt (any decline reason) is internally indistinguishable from a user who has never been on the platform — both fall through to fresh verification with identical observable behavior - The cross-operator anchor table (`verified_persons`) is accessible only via narrow SECURITY DEFINER functions that return existence booleans, not row metadata ## Frequently asked questions Every grant your application accepts is recorded in your org's audit log — you have full visibility into who accepted what and when. Each grant also emits `trust_reuse_grant.created` and `trust_reuse_grant.revoked` webhooks, so you can mirror the activity into your own systems. Reuse runs entirely within Stile's existing platform compliance model; it doesn't change how verification data is handled. You see the method, age tier, strength, source operator id, and timestamps — same as any other verified session. You don't see the user's name, ID document, or face image — those stay with the source operator and aren't part of the reuse. The source operator revokes the credential → we cascade-revoke all derived grants → you receive `trust_reuse_grant.revoked` with reason `SOURCE_CREDENTIAL_REVOKED`. You should prompt the user to re-verify on their next action. Yes. Trust Reuse only applies at session creation time. If your business logic decides this particular session is high-risk (large transaction, new device, suspicious IP), you can explicitly create a session with `accept_existing: false` to force fresh verification regardless of any active grants. Yes. A `DOCUMENT_CAPTURE` credential satisfies a request for `SELF_ATTESTATION` (because document capture is strictly stronger), but not vice versa. A `MIN_AGE_21` credential satisfies a `MIN_AGE_18` request. Recency is independent — a 10-day-old DOCUMENT_CAPTURE won't satisfy your policy if you set max-age to 7 days. ## Next steps --- # SDKs Section: SDKs URL: https://docs.stile.id/sdks/overview > Two official SDKs — a frontend widget and a server-side Node.js client — plus a language-agnostic HTTP API for everything else. Stile ships two official SDKs that split cleanly by where your code runs. A typical integration uses **both**: the widget on the frontend to run the verification flow, and the Node.js client on the backend to create sessions and verify webhooks. | SDK | Package | Runs | Use it to | | --------------- | --------------- | ----------- | --------------------------------------------------------------------- | | **Widget SDK** | `@stile/widget` | The browser | Embed the verification UI — web components plus a JavaScript API. | | **Node.js SDK** | `@stile/node` | Your server | Call the API with types — create sessions, verify webhook signatures. | Both SDKs wrap the same [HTTP API](/api-reference/overview). If you work in Python, Ruby, Go, PHP, or any other language, call the API directly — every endpoint is documented, and webhook signatures can be verified in any language. ## Widget SDK — `@stile/widget` The frontend half of an integration. Load `` from the CDN with a single script tag, or install the npm package for the full kit (``, the `` iframe embed, and the `verify()` / `create()` JavaScript APIs). The widget handles the camera, document capture, and the entire verification UI — your page just listens for the result. ```html ``` ## Node.js SDK — `@stile/node` The backend half. A typed, server-only client where every method maps 1:1 to an HTTP endpoint — create sessions, manage webhook endpoints, read events, and verify webhook signatures in one line. ```bash npm install @stile/node ``` ```ts const stile = new Stile(process.env.STILE_API_KEY!); ``` ## Which do I need? - **Just gating a checkout or signup?** The Widget SDK on the frontend plus a webhook handler on the backend is the whole integration. Start with the [Quickstart](/getting-started/quickstart). - **Creating sessions from your backend (recommended for production)?** Use the Node.js SDK to mint sessions and hand the `client_secret` to the widget via its `session-url` mode. - **Not on Node, or building a custom UI?** Use the [HTTP API](/api-reference/overview) directly. ## Next steps --- # Widget SDK Section: SDKs URL: https://docs.stile.id/sdks/widget > Framework-agnostic web components and JavaScript API for embedding verification in any frontend. The Stile widget is a drop-in verification UI for any frontend: two framework-agnostic web components plus a programmatic JavaScript API. Load `` from the CDN with a single script tag, or install `@stile/widget` from npm for the full kit — both speak the same auth modes and emit the same events. ## At a glance | Surface | Distribution | Isolation | Best for | | ---------------- | --------------------------- | ---------------------------------------------------- | ------------------------------------------------------------ | | `` | CDN (~7 KB launcher) or npm | iframe — verification UI runs on Stile's hosted page | Tight JS budgets, cross-origin isolation, no build step | | `` | npm (`@stile/widget`) | In-page Shadow DOM modal — no iframe | Bundler-based apps; instant-open modal via session prefetch | | `verify()` | npm (`@stile/widget`) | Shadow DOM modal | Promise-based control — open the flow from your own code | | `create()` | npm (`@stile/widget`) | Shadow DOM, mounts into your container | Fully custom UIs that manage the widget lifecycle themselves | ## Installation ```html ``` The CDN script is a ~7 KB (gzipped) launcher that registers the `` web component. The verification UI itself runs inside an iframe on Stile's hosted page, so the heavy dependencies (camera, barcode scanning, face detection) never load in your page. ```bash npm install @stile/widget ``` The npm package is the full kit for bundler-based apps: `` (in-page Shadow DOM verification), ``, and the `verify()` / `create()` JavaScript APIs. ## Choose an auth mode Both components support the same three auth modes. Pick one: ![The three widget auth modes: backend session via session-url (recommended), pre-minted session via client-secret and session-id, and the legacy publishable-key mode](/img/auth-modes.svg) | Mode | Attributes | When to use | | --------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Backend session (recommended)** | `session-url` | Your backend mints the session with your secret key. The widget POSTs to your endpoint and opens the modal with the result. No publishable key needed in the page. | | **Pre-minted session** | `client-secret` + `session-id` | You already created the session server-side (e.g. as part of an existing API call) and pass the result down to the page. | | **Publishable key (legacy)** | `publishable-key` + `workflow-id` | The widget creates the session directly from the browser. Fine for prototypes and sandbox testing; being phased out for production — responses carry a `Stile-Deprecation` header. | Every mode needs a **workflow**. [Workflows](/concepts/workflows) are authored and published in the [dashboard](https://dashboard.stile.id/workflows) and carry the use case, target jurisdictions, verification methods, and preferences — compliance is resolved server-side from the workflow, so you never pass products or age tiers from the client. ### The `session-url` contract In backend-session mode, the widget sends your endpoint a POST with a JSON body and expects the fields from `POST /v1/verification_sessions` back: ```ts title="app/api/start-verification/route.ts" const stile = new Stile(process.env.STILE_API_KEY!); export async function POST(req: Request) { // Widget sends: { workflowId?, email?, jurisdiction? } const { workflowId, email, jurisdiction } = await req.json(); const session = await stile.verificationSessions.create({ type: "age", workflow_id: workflowId ?? "wf_YOUR_WORKFLOW_ID", email, jurisdiction, }); // Widget expects: session_id + client_secret (methods / age_tier // let it render the full step rail instead of a generic fallback) return Response.json({ session_id: session.id, client_secret: session.client_secret, methods: session.methods, age_tier: session.age_tier, }); } ``` `` POSTs to `session-url` on mount in the background, so the session is usually ready before the user clicks — the modal opens instantly. If the email or workflow changes after prefetch, the stale session is discarded and re-minted at click time. ## `` — iframe embed Available via the CDN script or npm. The verification UI runs on Stile's hosted page inside an iframe — your page only loads the ~7 KB launcher. Choose this when your JS budget is tight, your infosec team requires cross-origin isolation for third-party code, or you want camera permissions granted once to Stile's origin instead of per merchant. ```html ``` ### Attributes , blocks form submission until verified.", }, { name: "form-field-name", type: "string", default: '"stile_session_id"', description: "Name of the hidden input injected into the parent form after verification.", }, { name: "confirmation-url", type: "string", description: "Your endpoint to poll for server-side confirmation. When set, stile:server-confirmed fires once your webhook has confirmed the session.", }, ]} /> ### Events | Event | Detail | Description | | ------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------ | | `stile:verified` | `{ sessionId, vpToken? }` | Client-side success signal. Wait for the webhook (or `stile:server-confirmed`) before granting access. | | `stile:server-confirmed` | — | Fired after the poll against your `confirmation-url` reports the webhook landed. | | `stile:error` | `{ message: string }` | Verification failed. | | `stile:cancel` | — | User closed the flow. | ## `` — in-page button Available via npm (`@stile/widget`). Renders the verification UI directly in your page inside a Shadow DOM modal — no iframe. Same three auth modes and the same event names as ``. ```html ``` ### Attributes , blocks form submission until verification completes. The button shakes if the user tries to submit early.", }, { name: "form-field-name", type: "string", default: '"stile_session_id"', description: "Name for the hidden input injected into the parent form after verification.", }, { name: "confirmation-url", type: "string", description: "Your endpoint to poll for server-side confirmation after the client-side success signal.", }, { name: "label", type: "string", default: '"Verify"', description: "Button text." }, { name: "verified-label", type: "string", default: '"Verified"', description: "Text shown after successful verification.", }, { name: "disabled", type: "boolean", description: "Disable the button." }, ]} /> ### Events Listen for custom events on the `` element: | Event | Detail | Description | | ---------------- | --------------------- | ----------------------- | | `stile:verified` | `VerifyResult` | Verification succeeded. | | `stile:error` | `{ message: string }` | Verification failed. | | `stile:cancel` | — | User closed the modal. | ```js const btn = document.querySelector("stile-button"); btn.addEventListener("stile:verified", (e) => { console.log("Verified!", e.detail); // e.detail.sessionId, etc. }); btn.addEventListener("stile:error", (e) => { console.error("Failed:", e.detail.message); }); btn.addEventListener("stile:cancel", () => { console.log("User cancelled"); }); ``` ### Zero-JS redirect flow For the simplest possible integration, use `success-url` and `cancel-url` to handle verification results without writing any JavaScript: ```html ``` After verification, the user is redirected to `/checkout/complete?session_id=vks_...`. Your server should call `GET /v1/verification_sessions/:id` with your secret key to confirm the result before granting access. Events (`stile:verified`, `stile:cancel`) still fire before the redirect, so you can combine redirect URLs with JavaScript event listeners if needed. ### Form integration When placed inside a `
`, the button automatically injects hidden inputs after verification: ```html
``` After verification, the form will include these hidden fields automatically: ```html ``` The `required` attribute prevents form submission until the user completes verification. Use `form-field-name` to customize the hidden input name if your backend expects a different field. `` supports the same form gating. ## `verify()` — JavaScript API For more control, use the `verify()` function from the npm package. It opens a modal, handles the entire flow, and returns a promise. ```ts const result = await verify({ publishableKey: "stile_pk_...", workflowId: "wf_YOUR_WORKFLOW_ID", email: "user@example.com", }); console.log(result.sessionId); ``` The promise rejects with a `VerifyError` if the user cancels (`error.reason === "cancelled"`) or if verification fails. ## `create()` — Low-level API For fully custom UIs, use `create()` to manage the widget lifecycle yourself. Create the session on your backend, then mount the widget with the returned `client_secret` and session ID: ```ts const widget = create({ clientSecret: session.client_secret, // from your backend sessionId: session.id, methods: session.methods, // from the same response ageTier: session.age_tier, onSuccess: (result) => { console.log("Verified!", result); }, onError: (error) => { console.error("Failed:", error.message); }, onExpired: () => { console.log("Session expired"); }, }); widget.mount("#verify-container"); // later: widget.destroy() ``` ## Framework examples The web components work natively in any framework: ```tsx export function Checkout() { return ( ); } ``` ```vue ``` ```svelte ``` ```html ``` Web components are supported in all modern browsers. The elements auto-register when the script loads — no setup required. ## Next steps --- # Node.js SDK Section: SDKs URL: https://docs.stile.id/sdks/node > The typed Node.js and TypeScript client for the Stile API — resource methods, automatic retries, idempotency, and built-in webhook signature verification. `@stile/node` is the server-side client for the Stile API. Every method maps 1:1 to an HTTP endpoint, with typed parameters and responses, automatic retries, and built-in webhook signature verification. The Node.js SDK is a thin convenience wrapper — every method maps 1:1 to an HTTP endpoint documented in the [HTTP API reference](/api-reference/overview). Use the HTTP API directly if you're working in Python, Ruby, Go, PHP, or any other language. For webhook signature verification in any language, see the [verification guide](/guides/webhook-verification). ## Installation ```bash npm install @stile/node ``` ```bash pnpm add @stile/node ``` ```bash yarn add @stile/node ``` The Node.js SDK is server-only — it authenticates with your secret key, which must never reach the browser. For frontend integration, use the [Widget SDK](/sdks/widget). ## Initialization Create a single client instance and reuse it throughout your application. Pass your API key as the first argument — keys are managed at [dashboard.stile.id/api-keys](https://dashboard.stile.id/api-keys). ```ts const stile = new Stile(process.env.STILE_API_KEY!); ``` Use the same `stile_sk_` secret key everywhere — there's one environment, so there are no separate test and live keys. For building and testing, enable [sandbox mode](/api-reference/overview) on a dedicated testing organization. ## Configuration The constructor accepts an options object as its second argument: ```ts const stile = new Stile(process.env.STILE_API_KEY!, { baseUrl: "https://api.stile.id", maxRetries: 2, // default, retries on 5xx, network errors, and 429s timeout: 30_000, // default, 30 seconds }); ``` ## Resources Each resource on the client maps to one section of the HTTP API reference: | Resource | Methods | Reference | | ---------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `stile.verificationSessions` | `create`, `retrieve`, `cancel`, `list` | [Verification Sessions](/api-reference/verification-sessions) | | `stile.webhookEndpoints` | `create`, `retrieve`, `update`, `del` (alias `delete`), `list`, `listDeliveries` | [Webhook Endpoints](/api-reference/webhook-endpoints) | | `stile.events` | `retrieve`, `list` | [Events](/api-reference/events) | | `stile.compliance` | `check` | [Compliance](/api-reference/compliance) | | `stile.verifiedPersons` | `lookup` | [Verified Person](/api-reference/verified-person) | | `stile.webhooks` | `fromRequest`, `constructEvent` | [Webhook verification](/guides/webhook-verification) | ## Verification sessions ### Create a session Pass the `workflow_id` of a published [workflow](https://dashboard.stile.id/workflows) — it carries the use case, jurisdictions, and verification methods, and the API resolves the required age tier internally based on the user's jurisdiction. Don't pass a `methods` array: methods are configured on the workflow, and the API rejects requests that combine the two. ```ts const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", return_url: "https://yourapp.com/verify/done", cancel_url: "https://yourapp.com/verify/cancel", client_reference_id: "user_123", // Optional: pass delivery address jurisdiction for VPN mismatch detection delivery_jurisdiction: "US-OR", }); // session.id — e.g. "vks_abc123" // session.client_secret — pass to the frontend widget // session.methods — resolved from the workflow // session.age_tier — e.g. "min_age_21", resolved from workflow + jurisdiction // session.expires_at — Unix timestamp (24h from now) // session.status — "created" // session.ip_jurisdiction — "US-CA" (detected from IP, if delivery_jurisdiction was set) // session.jurisdiction_mismatch — true if delivery ≠ IP jurisdiction ``` For the full list of create parameters, see the [Verification Sessions reference](/api-reference/verification-sessions). To prevent duplicate sessions when a request is retried, pass an idempotency key as the second argument — it's sent as the `Idempotency-Key` header: ```ts const session = await stile.verificationSessions.create( { type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID" }, { idempotencyKey: "order_12345" }, ); ``` Replaying the same key with the same body returns the original response. Reusing the key with a different body fails with a 409 `idempotency_key_reuse` error. ### Retrieve a session ```ts const session = await stile.verificationSessions.retrieve("vks_abc123"); // Include the full verification results array: const expanded = await stile.verificationSessions.retrieve("vks_abc123", { expand: ["results"], }); ``` ### Cancel a session ```ts const session = await stile.verificationSessions.cancel("vks_abc123"); // session.status === "cancelled" ``` ### List sessions ```ts const { data, has_more } = await stile.verificationSessions.list({ limit: 20, starting_after: "vks_xyz", status: "verified", }); ``` ## Webhook endpoints Manage webhook endpoints in code, or from the [dashboard](https://dashboard.stile.id/webhooks). `enabled_events` accepts specific [event types](/api-reference/events) or `["*"]` for everything. Session creation fails with a 400 `webhook_required` error until your organization has at least one active webhook endpoint. Sandbox organizations are exempt. ### Create an endpoint ```ts const endpoint = await stile.webhookEndpoints.create({ url: "https://yourapp.com/api/webhooks", enabled_events: ["verification_session.verified", "verification_session.failed"], description: "Production webhook", }); // endpoint.secret — save this to verify signatures ``` ### Retrieve, update, delete The `update` method accepts `url`, `enabled_events`, `status` (`"enabled"` or `"disabled"`), `description`, and `metadata`. `del()` is also exported under the alias `delete()`. ```ts // Retrieve const ep = await stile.webhookEndpoints.retrieve("we_abc123"); // Update await stile.webhookEndpoints.update("we_abc123", { enabled_events: ["*"], status: "enabled", }); // Delete await stile.webhookEndpoints.del("we_abc123"); ``` ### List endpoints and deliveries `listDeliveries` returns the delivery attempts for one endpoint and paginates with `limit` (default 20), `starting_after`, and `ending_before`: ```ts // List const { data } = await stile.webhookEndpoints.list(); // List delivery attempts const { data: deliveries } = await stile.webhookEndpoints.listDeliveries("we_abc123"); ``` ## Events Every webhook delivery carries an event, and the events API is the durable record behind it. Treat webhook delivery as the source of truth — poll events only on cold start or for reconciliation. `list` filters by `limit`, `starting_after`, and `type`. ```ts // Retrieve a single event const event = await stile.events.retrieve("evt_abc123"); // List events, optionally filtered by type const { data } = await stile.events.list({ limit: 20, type: "verification_session.verified", }); ``` ## Compliance Check product-level compliance rules for one or more products in a jurisdiction. Returns per-product details and a most-restrictive merged summary. Useful for checking prohibited products or inspecting compliance details before creating a session. ```ts // Check what's allowed in the user's jurisdiction const compliance = await stile.compliance.check({ use_cases: ["alcohol_delivery", "tobacco_nicotine"], jurisdiction: "US-CA", }); if (compliance.most_restrictive.any_prohibited) { // Some products can't be sold here console.log("Prohibited:", compliance.most_restrictive.prohibited_use_cases); // Remove prohibited items from the cart or show an error } // Create a session — the workflow carries the use case, and the API // resolves the age tier internally const session = await stile.verificationSessions.create({ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID", client_reference_id: "order_456", }); ``` If you're using the [widget SDK](/sdks/widget) client-side, the session's workflow resolves compliance automatically. The server-side flow above is for backends that need to inspect the rules — e.g. to drop prohibited items from a cart before starting verification. ## Verified persons Check if a user has been previously verified across any site in the Stile network. Call this from your backend before loading the SDK to skip verification for returning users — see the [Returning Users guide](/guides/returning-users). ```ts const result = await stile.verifiedPersons.lookup({ email: "user@example.com", methods: ["document_capture"], min_strength: "document_capture", max_age: "30", // days }); if (result.verified) { // User already verified — grant access, skip SDK console.log(result.verified_person_id, result.credentials); } else { // Create a session and load the SDK } ``` The response carries `verified`, `verified_person_id`, and a `credentials` array — each credential has `method`, `strength`, `verified_at`, and `expires_at`, with method and strength values in uppercase (e.g. `"MDL"`). At least one of `email` or `phone` is required. ## Webhook signature verification Verify incoming webhook signatures to prevent processing spoofed events. Both methods compute the HMAC timing-safe, reject signature timestamps older than 5 minutes, and return the parsed event. The SDK provides two methods: ### `fromRequest()` — for any Web API framework Works with Next.js, Hono, Cloudflare Workers, Bun, Deno, and any framework using the standard `Request` object: ```ts export async function POST(req: Request) { const event = await stile.webhooks.fromRequest(req, process.env.WEBHOOK_SECRET!); if (event.type === "verification_session.verified") { console.log("Session verified:", event.data.id); } return Response.json({ received: true }); } ``` ### `constructEvent()` — low-level For frameworks that don't use the standard `Request` object, pass the raw body and header directly: ```ts const event = stile.webhooks.constructEvent( rawBody, // string or Buffer signatureHeader, // stile-signature header value process.env.WEBHOOK_SECRET!, ); ``` JSON body parsers modify the request body before it reaches your handler, which invalidates the signature. Always pass the raw, unmodified request body. Both methods throw a `WebhookSignatureError` when verification fails — respond with HTTP 400 when you catch one: | Code | Meaning | | -------------------- | ---------------------------------------------------------------- | | `missing_header` | The request has no `Stile-Signature` header. | | `invalid_header` | The header is present but malformed. | | `timestamp_expired` | The signature timestamp is older than the 5-minute tolerance. | | `signature_mismatch` | The computed HMAC doesn't match — wrong secret or modified body. | ## Error handling Every API error throws a `StileError` (or a subclass) carrying `type`, `code`, `statusCode`, `param`, and `requestId`: ```ts try { await stile.verificationSessions.create({ type: "identity", workflow_id: "wf_..." }); } catch (err) { if (err instanceof StileAuthenticationError) { // 401 — invalid or expired API key } else if (err instanceof StileRateLimitError) { // 429 — the SDK already retried (maxRetries); back off before trying again } else if (err instanceof StileError) { console.error(err.statusCode, err.type, err.code, err.message); // Include err.requestId when contacting support } } ``` | Class | Thrown when | Properties | | -------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- | | `StileError` | Base class for every API error response. | `type`, `code`, `statusCode`, `param`, `requestId` | | `StileAuthenticationError` | 401 — invalid or expired API key. | Extends `StileError` | | `StileRateLimitError` | 429 — rate limited after the SDK's automatic retries. | Extends `StileError` | | `WebhookSignatureError` | Webhook signature verification failed; respond with HTTP 400. | `code` — see [codes above](#webhook-signature-verification) | For a complete guide to error types, retry strategies, and idempotency, see [Error Handling](/guides/error-handling). ## Next steps --- # Changelog Section: More URL: https://docs.stile.id/changelog > Notable changes to the Stile API, SDKs, and docs. Notable customer-facing changes, newest first. Internal fixes and refactors are omitted. Building with an AI assistant? Point it at the always-current [`llms-full.txt`](https://docs.stile.id/llms-full.txt) — see [Build with AI](/getting-started/llms). ## June 2026 **Single environment.** Collapsed test/live into one live environment with a free tier (100 verifications/month by default) and an org-level [sandbox mode](/guides/testing) for testing. Removed the `livemode` field and the `_test_`/`_live_` key prefixes — keys are now just `stile_sk_…` / `stile_pk_…`. Legacy keys keep working. **Documentation overhaul.** Docs were brought current with the workflow-era API and rebuilt to a fuller standard: new [Concepts](/concepts/workflows) section, [SDK overview](/sdks/overview), diagrams throughout, and machine-readable [`llms.txt`](https://docs.stile.id/llms.txt) / [`llms-full.txt`](https://docs.stile.id/llms-full.txt) / [`openapi.yaml`](https://docs.stile.id/openapi.yaml) for AI tooling. **Credit-based billing.** Added prepaid cash credits alongside usage billing. The free tier (100 verifications/month by default) covers your first verifications each calendar month; beyond that, usage draws down prepaid credits. Failed verifications are never charged. ## May 2026 **`js.stile.id` CDN.** The widget launcher now ships from a dedicated CDN at `https://js.stile.id/v1/stile.js` — a ~7 KB script that registers [``](/sdks/widget) with the heavy verification UI running inside the hosted iframe. **Widget `session-url` mode.** The widget can now mint sessions through an endpoint on your backend — the recommended production pattern. The widget POSTs to your `session-url`, your server creates the session with the secret key, and the modal opens with the result. See the [Widget SDK](/sdks/widget). **Webhook delivery hardening.** Dispatch failures are now surfaced and recovered through the retry scheduler rather than swallowed silently. See [Webhooks](/guides/webhooks). **Trust Reuse.** Launched cross-operator verification reuse: a user who verified at one operator can, with explicit consent, be recognized at another — no re-verification. See [Trust Reuse](/guides/trust-reuse). **Returning-user OTP gate.** Added a per-workflow email-OTP gate for returning users, with hybrid VP-token / OTP redemption so reuse is both fast and ownership-proven. See [Returning Users](/guides/returning-users). **Workflow-first session creation.** Verification sessions now run inside a published [workflow](/concepts/workflows): `POST /v1/verification_sessions` takes a required `workflow_id` and the workflow is the single source of truth for methods, jurisdictions, and the use case. A per-request `methods` array is no longer accepted.