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

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:

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

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.

Checkout.tsx
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>
  );
}
checkout.html
<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.

checkout.html
<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):

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

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

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:

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.

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

On this page