stile
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: 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

Never trust client-side events alone

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:

ModeHow it worksUse 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-idYour 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-idThe widget creates the session directly from the browser.Prototyping only — being phased out for production.

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 <stile-button> (npm) and <stile-frame> (CDN) support session-url — and <stile-button> prefetches the session in the background on mount, so the modal opens instantly on click.

<!-- Frontend: widget mints the session via your backend, then runs verification -->
<stile-button session-url="/api/start-verification" workflow-id="wf_YOUR_WORKFLOW_ID" />
// 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.

// 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
// (<stile-button client-secret session-id> or create() picks them up)
// Still confirm via webhook — don't trust the frontend

Workflows

Every session runs inside a workflowworkflow_id is required on session creation. Workflows are authored and published in the dashboard 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.

Where use cases live

Session creation takes a workflow_id — the use case lives on the workflow. The Compliance API 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:

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:

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.
<stile-button
  session-url="/api/start-verification"
  workflow-id="wf_YOUR_WORKFLOW_ID"
  email="user@example.com"
/>

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 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:

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`);
}
{
  "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.

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.

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 });
});

Return a 2xx as soon as you've queued the work

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 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.

CodeStatusWhen it happens
webhook_required400Creating a session on a live org with no active webhook endpoint on your organization. Add one at dashboard.stile.id/webhooks.
use_case_prohibited422The workflow's use case is prohibited in the user's resolved jurisdiction.
jurisdiction_unresolvable422The user's jurisdiction can't be determined from their IP. See below.
accept_existing_rate_limited429Too many accept_existing attempts for one email. See below.
test_quota_exceeded402A 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:

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

On this page