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.
Prerequisites
You need a Stile account, a publishable key, and a published workflow. Get all three from the dashboard. 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:
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, 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.
2. Add the widget
Include the Stile widget on your page. It's a standard web component — no build step required. The script registers <stile-frame>, which renders a verify button (in modal mode) and runs the verification UI in an iframe on Stile's hosted page.
<script src="https://js.stile.id/v1/stile.js"></script>
<stile-frame
mode="modal"
publishable-key="stile_pk_..."
workflow-id="wf_YOUR_WORKFLOW_ID"
></stile-frame>The widget handles everything — session creation, jurisdiction detection, the verification flow, and result events.
Publishable-key drop-in works in a sandbox org
The publishable-key snippet above creates the session straight from the browser — which works out
of the box in a sandbox 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.
export function Checkout() {
const handleVerified = (e: CustomEvent) => {
console.log("Verified!", e.detail);
};
return (
<div>
<input id="email" type="email" placeholder="you@example.com" />
<stile-frame
mode="modal"
publishable-key="stile_pk_..."
workflow-id="wf_YOUR_WORKFLOW_ID"
ref={(el) => el?.addEventListener("stile:verified", handleVerified)}
/>
</div>
);
}<script src="https://js.stile.id/v1/stile.js"></script>
<input id="email" type="email" placeholder="you@example.com" />
<stile-frame
mode="modal"
publishable-key="stile_pk_..."
workflow-id="wf_YOUR_WORKFLOW_ID"
></stile-frame>
<script>
document.querySelector("stile-frame")
.addEventListener("stile:verified", (e) => {
console.log("Verified!", e.detail);
});
</script>No JavaScript required. The widget blocks form submission until verified, then injects a hidden stile_session_id field automatically.
<script src="https://js.stile.id/v1/stile.js"></script>
<form action="/api/place-order" method="POST">
<input name="email" type="email" placeholder="you@example.com" />
<stile-frame
mode="modal"
publishable-key="stile_pk_..."
workflow-id="wf_YOUR_WORKFLOW_ID"
required
></stile-frame>
<button type="submit">Place Order</button>
</form>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):
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);Production setup: mint sessions on your backend
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 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 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.)
npm install @stile/nodeimport 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:
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:
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.
Two different keys
Your publishable key (stile_pk_…) goes in the frontend widget. Your secret API key
(stile_sk_…) stays on your server. See Authentication for
details.
Webhooks are required for live orgs
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.
Next steps
Integration Guide
Backend session creation, returning users, prohibited products, and VPN detection.
Widget SDK
All components, attributes, events, and the JavaScript API.
Webhook Verification
Complete copy-paste handlers for Go, Ruby, PHP, Python, and raw Node.
Build with AI
llms.txt, the full-corpus docs file, and a starter prompt for coding agents.