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:
- Frontend — the widget handles the user-facing verification flow (compliance checks, session creation, QR codes, modals)
- Backend — your server creates sessions with your secret key and receives signed webhooks confirming verification results
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:
| 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 <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 frontendWorkflows
Every session runs inside a workflow — workflow_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
methodsarray 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:
- Detects the user's jurisdiction from their IP address (Cloudflare headers, geo-IP)
- Looks up the regulatory rules for your products in that jurisdiction
- Determines the required age tier (e.g., 21+ for alcohol, 18+ for adult content)
- Filters verification methods to only those allowed in that jurisdiction (e.g., removes mDL in states that don't recognize it)
- 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 prohibitedFor 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:
- Widget flow: Pass the user's email. If they're already verified, the widget resolves instantly — no modal, no QR code.
- Server-side flow: Use
accept_existing: truewith 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.
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.
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.
| 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. |
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:
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
Webhooks Guide
Delivery semantics, retries, signature verification, and local testing.
Error Handling
The full error catalog, the error envelope, and retry strategies.
Returning Users
The three-tier reuse flow: VP tokens, lookup + OTP, full verification.
Testing
Sandbox mode, skip_verification, and the monthly sandbox cap.