Integration Guide
Everything you need to know to build a production-ready integration — compliance checks, returning users, mixed carts, VPN detection, and more.
This guide covers the full integration picture. If you haven't done the Quickstart yet, start there first.
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 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 mode enforces this — session creation with live keys (pk_live_ / vk_live_) requires at least one active webhook endpoint on your organization. Test keys work without webhooks for development.
Widget + webhooks (recommended)
The widget handles session creation and the verification UI using your publishable key. Your server only needs a webhook handler to confirm results. This is the simplest setup.
<!-- Frontend: widget handles compliance + verification -->
<stile-button
publishable-key="pk_test_..."
email-selector="#email"
products="alcohol_delivery"
/>// 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 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",
use_case: "alcohol_delivery",
});
// Pass session.client_secret to your frontend
// Still confirm via webhook — don't trust the frontendHow compliance works
When you specify your product types (either via products on the widget, or use_cases on the Compliance API), stile 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.
Mixed carts
If your customer has multiple product types in their cart (e.g., alcohol + nicotine), pass them all:
<stile-button
publishable-key="pk_test_..."
products="alcohol_delivery,tobacco_nicotine"
/>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 prohibitedThe 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 the widget, this is handled automatically — if ALL products are prohibited, the widget shows an error. If only some are prohibited, the remaining products proceed normally.
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
publishable-key="pk_test_..."
email="user@example.com"
products="alcohol_delivery"
/>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",
use_case: "alcohol_delivery",
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.
Default use case on API keys
If your entire site sells a single product category, you can set a default use case on your publishable key in the dashboard via the default_use_case setting. Every session created with that key automatically uses this use case to resolve compliance — no need to pass products or use_case per session.
This is useful for simple sites that only sell one type of product. For mixed-product sites, use products instead.
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. Instead:
Widget fires client-side event
When the user verifies, the widget fires stile:verified. Your frontend sends the session ID to your server and starts polling for confirmation.
Server receives 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
await db.orders.update({
where: { sessionId: event.data.id },
data: { ageVerified: true },
});
}
res.json({ received: true });
});Error handling
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",
use_case: "alcohol_delivery",
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
- Compliance API — product-level regulatory checks
- Verification Sessions API — manual session management
- Error Handling — all error codes and retry strategies
- Webhooks Guide — signature verification, retries, and local testing