Docs

Widget SDK

The public hosted frame, its session contract, and the private-preview in-page widget APIs.

The public Stile widget is <stile-frame>, a framework-agnostic web component loaded from js.stile.id. The repository also contains an in-page <stile-button> and programmatic JavaScript APIs, but @stile/widget is currently a private preview package rather than a public npm release.

Use the CDN for public integrations today

@stile/widget is marked private in the staging monorepo and is not installable from the public npm registry. External integrations should use the CDN-hosted <stile-frame>. The button, verify(), and create() sections below are retained as private-preview reference for approved consumers and should not be generated into a public integration unless package access has been confirmed.

At a glance

SurfaceDistributionIsolationBest for
<stile-frame>Public CDN (~7 KB launcher)iframe — verification UI runs on Stile's hosted pageExternal integrations, cross-origin isolation, no build step
<stile-button>Private @stile/widget previewIn-page Shadow DOM modal — no iframeApproved preview consumers; instant-open modal via prefetch
verify()Private @stile/widget previewShadow DOM modalApproved preview consumers needing promise-based control
create()Private @stile/widget previewShadow DOM, mounts into your containerApproved preview consumers building a custom UI

Installation

<script src="https://js.stile.id/v1/stile.js"></script>

The CDN script is a ~7 KB (gzipped) launcher that registers the <stile-frame> web component. The verification UI itself runs inside an iframe on Stile's hosted page, so the heavy dependencies (camera, barcode scanning, face detection) never load in your page.

@stile/widget is not published to public npm. If Stile has granted your organization preview access, follow the package-access instructions supplied with that preview; otherwise use the CDN integration above.

Choose an auth mode

Both components support the same three auth modes. Pick one:

The three widget auth modes: backend session via session-url (recommended), pre-minted session via client-secret and session-id, and the legacy publishable-key mode

ModeAttributesWhen to use
Backend session (recommended)session-urlYour backend mints the session with your secret key. The widget POSTs to your endpoint and opens the modal with the result. No publishable key needed in the page.
Pre-minted sessionclient-secret + session-idYou already created the session server-side (e.g. as part of an existing API call) and pass the result down to the page.
Publishable key (legacy)publishable-key + workflow-idThe widget creates the session directly from the browser. Fine for prototypes and sandbox testing; being phased out for production — responses carry a Stile-Deprecation header.

Every mode needs a workflow. Workflows are authored and published in the dashboard and carry the use case, target jurisdictions, verification methods, and preferences — compliance is resolved server-side from the workflow, so you never pass products or age tiers from the client.

The session-url contract

In backend-session mode, the widget sends your endpoint a POST with a JSON body and expects the fields from POST /v1/verification_sessions back:

app/api/start-verification/route.ts
export async function POST(req: Request) {
  // Widget may send: { workflowId?, email?, jurisdiction? }.
  // Treat those values as hints, not authorization or policy.
  const user = await requireAuthenticatedUser(req);
  const actionId = new URL(req.url).searchParams.get("actionId") ?? "";
  const action = await requireOwnedPendingAction(user.id, actionId);

  const response = await fetch("https://api.stile.id/v1/verification_sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.STILE_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `action:${action.id}:age-verification`,
    },
    body: JSON.stringify({
      type: "age",
      workflow_id: process.env.STILE_WORKFLOW_ID!,
      email: user.email,
      client_reference_id: action.id,
    }),
  });
  if (!response.ok) throw new Error(`Stile returned ${response.status}`);
  const session = await response.json();

  await saveStileSessionId(action.id, session.id);

  // Widget expects: session_id + client_secret (methods / age_tier
  // let it render the full step rail instead of a generic fallback)
  return Response.json({
    session_id: session.id,
    client_secret: session.client_secret,
    methods: session.methods,
    age_tier: session.age_tier,
  });
}

This example uses /api/start-verification?actionId=... as the session-url; the server validates that the authenticated user owns the pending action before creating anything. Apply your normal rate limit to the route. Keep workflow and jurisdiction policy on your server, derive identity from the login session, persist the returned Stile session ID, and use a stable idempotency key. The Quickstart shows the complete database and webhook transaction.

<stile-button> POSTs to session-url on mount in the background, so the session is usually ready before the user clicks — the modal opens instantly. If the email or workflow changes after prefetch, the stale session is discarded and re-minted at click time.

<stile-frame> — iframe embed

Publicly available through the CDN script; approved private-preview consumers can also import the component from @stile/widget. The verification UI runs on Stile's hosted page inside an iframe — your page only loads the ~7 KB launcher. Choose this when your JS budget is tight, your infosec team requires cross-origin isolation for third-party code, or you want camera permissions granted once to Stile's origin instead of per merchant.

<script src="https://js.stile.id/v1/stile.js"></script>

<!-- "modal" renders a trigger button that opens the iframe in an overlay -->
<stile-frame
  mode="modal"
  session-url="/api/start-verification?actionId=action_123"
  workflow-id="wf_YOUR_WORKFLOW_ID"
></stile-frame>

Attributes

ParameterTypeDescription
session-urlstringBackend-session mode (recommended). URL the frame POSTs to for session creation. See the session-url contract above.
client-secretstringPre-minted-session mode. The session's client secret. Pair with session-id.
session-idstringPre-minted-session mode. The vks_... session ID. Pair with client-secret.
publishable-keystringPublishable-key (legacy) mode. Your stile_pk_ key. Pair with workflow-id.
workflow-idstringID of the published workflow (wf_...). Required in session-url and publishable-key modes.
methodsstringPre-minted mode only: comma-separated methods from the session creation response, so the iframe renders the workflow's full step rail.
age-tierstringPre-minted mode only: the age_tier from the session creation response.
emailstringPre-fills the user's email and powers returning-user lookup.
jurisdictionstringOverride IP-based jurisdiction detection (e.g. "US-OR").
mode"inline" | "modal"= "inline"inline embeds the iframe in place; modal renders a trigger button that opens the iframe in a full-screen overlay.
labelstring= "Verify"Trigger button text (modal mode).
verified-labelstring= "Verified"Trigger button text after successful verification (modal mode).
min-heightnumber= 400Initial iframe height in px, before the first resize message.
requiredbooleanWhen present inside a <form>, blocks form submission until verified.
form-field-namestring= "stile_session_id"Name of the hidden input injected into the parent form after verification.
confirmation-urlstringYour endpoint to poll for server-side confirmation. When set, stile:server-confirmed fires once your webhook has confirmed the session.

Events

EventDetailDescription
stile:verified{ sessionId, vpToken? }Client-side success signal. Wait for the webhook (or stile:server-confirmed) before granting access.
stile:review{ message: string }The session is held for manual review. Keep fulfillment paused; this is not a failure or a verification.
stile:server-confirmed{ sessionId }Fired after the poll against your confirmation-url reports the webhook-backed state as verified.
stile:error{ message: string }Verification failed.
stile:cancelUser closed the flow.

<stile-button> — in-page button

Available in the private @stile/widget preview. It renders the verification UI directly in your page inside a Shadow DOM modal — no iframe. Same three auth modes and the same event names as <stile-frame>.

<stile-button
  session-url="/api/start-verification?actionId=action_123"
  workflow-id="wf_YOUR_WORKFLOW_ID"
  email-selector="#checkout-email"
></stile-button>

Attributes

ParameterTypeDescription
session-urlstringBackend-session mode (recommended). URL the button POSTs to for session creation. Prefetched on mount for an instant click.
client-secretstringPre-minted-session mode. Pair with session-id.
session-idstringPre-minted-session mode. Pair with client-secret.
publishable-keystringPublishable-key (legacy) mode. Pair with workflow-id.
workflow-idstringID of the published workflow (wf_...). Required in session-url and publishable-key modes.
email-selectorstringCSS selector for an email input on the page. The button reads its value automatically.
emailstringPass the email directly instead of using a selector.
jurisdictionstringOverride IP-based jurisdiction detection (e.g. "US-OR").
success-urlstringURL to redirect to after successful verification. The session ID is appended as a query parameter (e.g. "/done?session_id=vks_...").
cancel-urlstringURL to redirect to if the user cancels verification.
requiredbooleanWhen present inside a <form>, blocks form submission until verification completes. The button shakes if the user tries to submit early.
form-field-namestring= "stile_session_id"Name for the hidden input injected into the parent form after verification.
confirmation-urlstringYour endpoint to poll for server-side confirmation after the client-side success signal.
labelstring= "Verify"Button text.
verified-labelstring= "Verified"Text shown after successful verification.
disabledbooleanDisable the button.

Events

Listen for custom events on the <stile-button> element:

EventDetailDescription
stile:verifiedVerifyResultClient-side success. Wait for webhook-backed confirmation before fulfillment.
stile:review{ message: string }Held for manual review; keep fulfillment paused.
stile:server-confirmed{ sessionId }The merchant confirmation endpoint returned { "status": "verified" }.
stile:error{ message: string }Verification failed, including a confirmation endpoint returning { "status": "failed" }.
stile:cancelUser closed the modal.
const btn = document.querySelector("stile-button");

btn.addEventListener("stile:verified", (e) => {
  console.log("Verified!", e.detail);
  // e.detail.sessionId, etc.
});

btn.addEventListener("stile:error", (e) => {
  console.error("Failed:", e.detail.message);
});

btn.addEventListener("stile:cancel", () => {
  console.log("User cancelled");
});

The confirmation-url contract

Set confirmation-url when you want the component to wait for your server's webhook-backed state. After stile:verified, the widget makes a GET request with the session ID appended as ?sessionId=vks_... and expects JSON:

{ "status": "verified" }

Return { "status": "pending" } while the webhook has not been applied, { "status": "verified" } after your database transaction confirms it, or { "status": "failed" } after a terminal failure. The widget begins polling after 2 seconds, backs off to a 60-second interval, and has no hard timeout by default.

app/api/verification-status/route.ts
export async function GET(request: Request) {
  const user = await requireAuthenticatedUser(request);
  const sessionId = new URL(request.url).searchParams.get("sessionId");

  const order = await db.order.findFirst({
    where: { userId: user.id, stileSessionId: sessionId ?? "" },
    select: { status: true },
  });
  if (!order) return Response.json({ error: "Not found" }, { status: 404 });

  return Response.json({
    status: order.status === "ready_for_fulfillment" ? "verified" : "pending",
  });
}

Authenticate this endpoint and only expose the state of a session owned by the current user. It must read your database state written by the signed webhook—not trust a session ID supplied by the browser as proof.

Zero-JS redirect flow

For the simplest possible integration, use success-url and cancel-url to handle verification results without writing any JavaScript:

<stile-button
  session-url="/api/start-verification"
  workflow-id="wf_YOUR_WORKFLOW_ID"
  success-url="/checkout/complete"
  cancel-url="/cart"
></stile-button>

After verification, the user is redirected to /checkout/complete?session_id=vks_.... Treat that query parameter as untrusted. The destination should look up the matching transaction in your database and wait for the signed webhook to mark it verified. Use GET /v1/verification_sessions/:id only as a server-side reconciliation fallback.

Events (stile:verified, stile:cancel) still fire before the redirect, so you can combine redirect URLs with JavaScript event listeners if needed.

Form integration

When placed inside a <form>, the button automatically injects hidden inputs after verification:

<form action="/api/place-order" method="POST">
  <input name="email" type="email" />
  <input name="address" type="text" />

  <stile-button
    session-url="/api/start-verification"
    workflow-id="wf_YOUR_WORKFLOW_ID"
    email-selector="[name=email]"
    required
  ></stile-button>

  <button type="submit">Place Order</button>
</form>

After verification, the form will include these hidden fields automatically:

<input type="hidden" name="stile_session_id" value="vks_..." />
<input type="hidden" name="stile_verified" value="true" />

The required attribute prevents form submission until the user completes verification. Use form-field-name to customize the hidden input name if your backend expects a different field. <stile-frame> supports the same form gating.

Hidden fields are not proof

stile_session_id and stile_verified are browser-controlled form values. Use them only to find a candidate transaction. Before granting access or fulfilling an order, require the signed webhook-backed state in your database and confirm the stored session belongs to the authenticated user or order.

verify() — JavaScript API

For approved private-preview integrations, verify() opens a modal, handles the entire flow, and returns a promise.

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

const result = await verify({
  publishableKey: "stile_pk_...",
  workflowId: "wf_YOUR_WORKFLOW_ID",
  email: "user@example.com",
});

console.log(result.sessionId);
ParameterTypeDescription
publishableKeystringYour publishable key. The widget creates the session from the browser (legacy mode).
workflowIdstringID of the published workflow. Required when publishableKey is set — the workflow carries the use case, jurisdictions, and method preferences.
clientSecretstringPre-created session mode: the client_secret from your backend. Pair with sessionId; publishableKey and workflowId are not needed.
sessionIdstringPre-created session mode: the vks_... session ID. Pair with clientSecret.
methodsstring[]Pre-created session mode: pass the methods array from the session creation response so the widget renders the workflow's full step rail.
ageTierstringPre-created session mode: the age_tier from the session creation response (e.g. "min_age_21").
emailstringThe user's email address.
jurisdictionstringOverride the auto-detected jurisdiction.

The promise rejects with a VerifyError if the user cancels (error.reason === "cancelled") or if verification fails.

create() — Low-level API

For approved private-preview integrations building fully custom UIs, create() manages the widget lifecycle. Create the session on your backend, then mount the widget with the returned client_secret and session ID:

import { create } from "@stile/widget";

const widget = create({
  clientSecret: session.client_secret, // from your backend
  sessionId: session.id,
  methods: session.methods, // from the same response
  ageTier: session.age_tier,
  onSuccess: (result) => {
    console.log("Verified!", result);
  },
  onError: (error) => {
    console.error("Failed:", error.message);
  },
  onExpired: () => {
    console.log("Session expired");
  },
});

widget.mount("#verify-container");
// later: widget.destroy()

Framework examples

The web components work natively in any framework:

export function Checkout() {
  return (
    <stile-frame
      mode="modal"
      session-url="/api/start-verification"
      workflow-id="wf_YOUR_WORKFLOW_ID"
    />
  );
}
<template>
  <stile-frame
    mode="modal"
    session-url="/api/start-verification"
    workflow-id="wf_YOUR_WORKFLOW_ID"
  />
</template>
<stile-frame
  mode="modal"
  session-url="/api/start-verification"
  workflow-id="wf_YOUR_WORKFLOW_ID"
/>
<stile-frame
  mode="modal"
  session-url="/api/start-verification"
  workflow-id="wf_YOUR_WORKFLOW_ID"
></stile-frame>

Web components are supported in all modern browsers. The elements auto-register when the script loads — no setup required.

Next steps

On this page