stile
Getting Started

Quickstart

Get age verification working in your app in under 5 minutes.

Prerequisites

You need a stile account and a publishable key. Get both from the dashboard.

1. Add the widget

Include the stile widget on your page. It's a standard web component — no build step required.

<script src="https://cdn.stile.dev/v1/stile.js"></script>
npm install @stile/widget

2. Drop in the button

Add <stile-button> to your page with your publishable key and the products you sell. The widget handles everything — compliance checks, session creation, the verification modal, and result callbacks.

<stile-button
  publishable-key="pk_test_..."
  email-selector="#email"
  products="alcohol_delivery"
></stile-button>

The products attribute tells stile what you're selling. The widget automatically:

  • Calls the Compliance API to determine the required age tier for the user's jurisdiction
  • Filters verification methods to only those allowed in that jurisdiction
  • Creates a verification session with the correct settings
  • Opens the verification modal
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-button
        publishable-key="pk_test_..."
        email-selector="#email"
        products="alcohol_delivery"
        ref={(el) => el?.addEventListener("stile:verified", handleVerified)}
      />
    </div>
  );
}
checkout.html
<script src="https://cdn.stile.dev/v1/stile.js"></script>

<input id="email" type="email" placeholder="you@example.com" />
<stile-button
  publishable-key="pk_test_..."
  email-selector="#email"
  products="alcohol_delivery"
></stile-button>

<script>
  document.querySelector("stile-button")
    .addEventListener("stile:verified", (e) => {
      console.log("Verified!", e.detail);
    });
</script>

No JavaScript required. The button blocks form submission until verified, then injects a hidden stile_session_id field automatically.

checkout.html
<script src="https://cdn.stile.dev/v1/stile.js"></script>

<form action="/api/place-order" method="POST">
  <input name="email" type="email" placeholder="you@example.com" />

  <stile-button
    publishable-key="pk_test_..."
    email-selector="[name=email]"
    products="alcohol_delivery"
    required
  ></stile-button>

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

verify.ts
import { verify } from "@stile/widget";

const result = await verify({
  publishableKey: "pk_test_...",
  email: "user@example.com",
  products: ["alcohol_delivery"],
});

console.log("Verified!", result.sessionId);

Available product types

ProductValue
Alcohol deliveryalcohol_delivery
Alcohol in-personalcohol_in_person
Cannabis dispensarycannabis_dispensary
Cannabis deliverycannabis_delivery
Tobacco / nicotinetobacco_nicotine
Gambling (online)gambling_online
Gambling (in-person)gambling_in_person
Adult contentadult_content
Social mediasocial_media
Firearmsfirearms

For mixed carts, pass multiple products: products="alcohol_delivery,tobacco_nicotine". The widget resolves the most restrictive age tier automatically.

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 that verifies the signature and processes the event:

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

That's it — you have age verification working. The widget handles compliance and the verification flow, your server confirms the result via signed webhook. See the full verification guide for Go, Ruby, PHP, and more.

Two different keys

Your publishable key (pk_test_ / pk_live_) goes in the frontend widget. Your secret API key (vk_test_ / vk_live_) stays on your server. See Authentication for details.

Webhooks are required in live mode

Live mode keys (pk_live_ / vk_live_) require at least one active webhook endpoint before sessions can be created. Test keys work without webhooks for development. This ensures verification results are always confirmed server-side in production.

Next steps

  • Integration Guide — handling returning users, prohibited products, mixed carts, and VPN detection
  • Widget SDK — all attributes, events, and the JavaScript API
  • HTTP API — direct API access from any language
  • Webhook Verification — copy-paste handlers for Node.js, Python, Go, Ruby, and PHP
  • Node.js SDK — typed convenience wrapper for JS/TS developers

On this page