stile
Engineering

How to Integrate an Identity Verification API

Learn the secure architecture for identity verification, then implement server-side sessions, a hosted flow, and signed webhooks with Stile’s API.

V
Vlad MarinovCo-founder & CTOAugust 24, 202610 min read
Production identity verification loop: the same backend creates a session, the user completes Stile’s hosted check, and the Stile service returns a signed webhook for the backend to verify.

To integrate identity verification into an app, create each verification session on your server, send the user through a hosted verification flow, and update trusted application state only after your backend verifies a signed result. A browser completion message is interface feedback, not authorization.

That architecture is portable across providers. This guide explains the pattern and the capabilities to evaluate, then implements it with Stile’s HTTP API, hosted <stile-frame>, and signed webhooks.

The production identity verification pattern

A production integration separates the user experience from the server-side trust decision. It has six responsibilities:

  1. Define the verification policy for the action and jurisdictions you support.

  2. Create a session on an authenticated backend and bind it to the correct user or transaction.

  3. Give the browser only short-lived credentials for a hosted or isolated verification experience.

  4. Receive the result over a signed server-to-server channel.

  5. Match the provider session to your local record and apply the result idempotently.

  6. Test success, rejection, replay, mismatch, timeout, and retry paths before launch.

Keep the trust decision on the server

The browser can show progress and completion, but it should not grant access, release an order, approve an account, or mark a local record verified.

What to look for in an identity verification API

Choose an API that makes the trust boundary explicit and gives your backend enough information to apply its own policy safely. Look for:

  • server-side session creation with stable idempotency controls;

  • a hosted or isolated capture flow that keeps secret keys and raw identity evidence out of browser code;

  • configurable policies for proof methods, jurisdictions, liveness, face match, and review;

  • signed webhooks with stable event, session, and client-reference identifiers;

  • a sandbox that can exercise real webhook, replay, mismatch, and failure behavior; and

  • clear documentation for retention, deletion, audit fields, and returning-user behavior.

The proof methods themselves should match your use case and jurisdictions. Document capture, mobile identity documents, liveness, and face match are capabilities; your application still owns the decision that follows the verified result.

Worked example: integrate identity verification with Stile

The rest of this guide maps that architecture to Stile. The implementation uses:

  1. a workflow to define the verification policy;

  2. the Verification Sessions API to create a server-side session;

  3. the hosted widget to run the user-facing flow; and

  4. a signed webhook to deliver the backend result.

For copy-paste examples in multiple backend languages, keep the Stile Quickstart open beside this article. The docs are the source of truth for endpoint and field details.

Before you start

You need:

  • a Stile account;
  • a secret API key beginning with stile_sk_;
  • a published Stile workflow with an ID beginning with wf_;
  • an authenticated backend endpoint in your application; and
  • a public HTTPS endpoint for Stile webhooks.

You do not need OAuth credentials, a client ID and secret pair, or a provider-specific server library. Stile authenticates server requests with a Bearer API key, and the public HTTP API works with any backend language.

How Stile maps to the pattern

A production Stile integration separates three trusted boundaries.

Your browser

The browser renders the hosted <stile-frame> component. The widget handles the user-facing verification flow without sending your secret API key to the client.

Your server

Your server authenticates the user, selects a published workflow, creates the Stile session, and stores the returned session ID against the user, order, account change, delivery, or other action being verified.

Your webhook handler

Stile sends a signed event when the session changes state. Your webhook handler verifies the signature, matches the Stile session to your local record, and applies the result once.

That last step is the source of truth. A browser redirect or stile:verified event is useful for interface feedback, but it is not proof that your backend should trust.

Step 1: Publish a verification workflow

A Stile workflow defines the policy for every session that uses it. Configure:

  • the use case;
  • target jurisdictions;
  • accepted verification methods;
  • liveness and face-match requirements;
  • returning-user preferences; and
  • session expiry or review behavior.

For an identity verification flow, the workflow might offer a mobile driver's license when supported, with physical document capture as another path. Selfie liveness and face match are layers that run alongside a primary proof method rather than methods on their own.

Copy the published workflow ID from the Stile dashboard. Add it and your secret key to the server environment:

bash
STILE_API_KEY=stile_sk_...
STILE_WORKFLOW_ID=wf_...
STILE_WEBHOOK_SECRET=whsec_...

Keep all three values out of client-side code. Do not prefix them with NEXT_PUBLIC_ or place them in a browser bundle.

Verification policy lives in the workflow, not the request

workflow_id is required on every session you create, and a methods array cannot be combined with it. The published workflow controls which methods run, and Stile resolves them for the applicable use case and jurisdiction.

Step 2: Create a verification session on your server

Create an authenticated endpoint such as POST /api/identity-verification/start. The endpoint should load the current user and the local action being verified from your database, then call Stile.

POST /api/identity-verification/startjavascript
export async function createIdentityVerification({ user, verificationId }) {
  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": `identity-verification:${verificationId}`,
    },
    body: JSON.stringify({
      type: "identity",
      workflow_id: process.env.STILE_WORKFLOW_ID,
      email: user.email,
      client_reference_id: verificationId,
      return_url: "https://yourapp.com/verification-complete",
    }),
  });

  if (!response.ok) {
    throw new Error(`Stile session creation failed: ${response.status}`);
  }

  const session = await response.json();

  await savePendingVerification({
    verificationId,
    userId: user.id,
    stileSessionId: session.id,
  });

  // The widget's contract, not the API's: it reads session_id, while the
  // API returns the same value as id.
  return {
    session_id: session.id,
    client_secret: session.client_secret,
    methods: session.methods,
    age_tier: session.age_tier,
  };
}

Send only the identifiers the workflow needs

Include email when your workflow uses returning-user lookup or another email-dependent path. Otherwise, omit it. client_reference_id is the server-owned value that binds the Stile session to the user or transaction in your system.

A few details are important:

  • type: "identity" creates an identity verification session. Use type: "age" for an age verification flow, or type: "student" for a student check.
  • workflow_id is required, and it selects the policy you published in Stile.
  • client_reference_id should identify the user or transaction in your system. Stile echoes it in webhook events.
  • Idempotency-Key makes a retry return the original session instead of creating a duplicate. The replay carries an Idempotency-Replayed: true response header, and reusing the same key with a different body is rejected with a 400 rather than silently resolved.
  • client_secret is short-lived and intended for the widget. Return it to the browser, but never log it.
  • Persist session.id before the user completes verification.

Derive identity and ownership from your authenticated server session. Do not accept an arbitrary email, user ID, workflow ID, or transaction ID from the browser without validating it.

Live sessions require a webhook endpoint first

Outside sandbox, Stile refuses to create a session for an organization with no enabled webhook endpoint: the call fails with a 400 and a webhook_required error code. Register the endpoint in Step 4 before you switch a live key on.

Step 3: Add the hosted Stile widget

Load Stile's public widget script and point the component at the backend endpoint you created:

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

<stile-frame
  mode="modal"
  session-url="/api/identity-verification/start"
></stile-frame>

The widget sends a POST to session-url with a small JSON body of hints (workflowId, email, jurisdiction), and expects JSON back containing session_id and client_secret. Note the shape difference: the Stile API returns the session as id, and your endpoint remaps it to session_id. Returning methods and age_tier as well lets the widget render the real step rail instead of falling back to a wallet-only one. Your server authenticates the request, creates the session, and returns only those short-lived fields.

The hosted experience can guide the user through the methods compiled from the workflow. Depending on the workflow and jurisdiction, that can include:

No app download is required for the hosted browser flow.

You may listen for the widget's stile:verified event to show a completion state. Do not use that client-side event to mark the user verified. Its own payload says so: the event detail ships with serverConfirmed: false. Show a confirming state while your frontend waits for your own backend to process the signed webhook.

Step 4: Verify Stile's signed webhook

Add an HTTPS endpoint in the Stile dashboard and subscribe to verification_session.verified. Save the one-time signing secret as STILE_WEBHOOK_SECRET.

Stile sends the signature in this format:

Request headerbash
Stile-Signature: t=1741564800,v1=abc123...

There is no X- prefix on that header name. The v1 value is a lowercase hexadecimal HMAC-SHA256 signature over:

Signed payloadbash
{timestamp}.{raw_request_body}

The request body must remain raw until after signature verification. With Express, register this route before express.json():

POST /webhooks/stilejavascript
import crypto from "node:crypto";
import express from "express";

const app = express();

function verifyStileSignature(rawBody, signatureHeader) {
  if (!signatureHeader) return false;

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => {
      const [key, value] = part.trim().split("=");
      return [key, value];
    }),
  );

  const timestamp = Number(parts.t);
  const signature = parts.v1;

  if (!Number.isFinite(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
    return false;
  }

  // Replay window. This is your policy, not a Stile-side rule: Stile signs
  // the timestamp, your handler decides how old is too old.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) return false;

  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, "utf8"),
    rawBody,
  ]);

  const expected = crypto
    .createHmac("sha256", process.env.STILE_WEBHOOK_SECRET)
    .update(signedPayload)
    .digest();

  const received = Buffer.from(signature, "hex");

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

app.post(
  "/webhooks/stile",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("Stile-Signature");

    if (!verifyStileSignature(req.body, signature)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(req.body.toString("utf8"));

    if (event.type === "verification_session.verified") {
      const session = event.data.object;

      await markVerificationCompleteOnce({
        eventId: event.id,
        verificationId: session.client_reference_id,
        stileSessionId: session.id,
      });
    }

    return res.sendStatus(200);
  },
);

app.use(express.json());

The event envelope is { id, object: "event", type, created, data: { object } }, where data.object is the verification session.

Your application-specific markVerificationCompleteOnce operation should use one database transaction to:

  1. claim event.id under a unique constraint;
  2. find a pending local record whose Stile session ID matches session.id;
  3. confirm its local ID matches session.client_reference_id; and
  4. transition that record to verified exactly once.

Return 2xx after the event is safely persisted or queued. Return 400 for an invalid signature. Return 5xx for a temporary processing failure so Stile can retry: delivery is attempted up to five times with an escalating backoff, and a 4xx other than 429 is treated as permanent and not retried.

A valid signature proves Stile sent the event. Matching the session ID and client_reference_id proves the result belongs to the action your application is about to approve.

Step 5: Test the full flow in sandbox

Use a dedicated Stile sandbox organization for development and automated tests. The API base URL and key format stay the same.

In sandbox only, add skip_verification: true to create a session that moves directly to verified and fires a real signed webhook:

Sandbox onlyjavascript
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",
  },
  body: JSON.stringify({
    type: "identity",
    workflow_id: process.env.STILE_WORKFLOW_ID,
    email: "test@example.com",
    client_reference_id: "test_verification_123",
    skip_verification: true,
  }),
});

Use an HTTPS tunnel such as ngrok or Cloudflare Tunnel to expose a local webhook handler. Then test:

  • valid and invalid signatures;
  • duplicate webhook delivery;
  • a mismatched session ID or client_reference_id;
  • retries with the same idempotency key;
  • unauthenticated session-creation requests; and
  • a browser completion event arriving before the webhook.

Remove skip_verification before using a live organization. Live sessions reject it with a 400.

What your application receives

Stile returns a signed verification outcome, not an authorization decision. The event includes the session status, completed method, resolved jurisdiction, age tier when applicable, and audit fields. Your server binds that outcome to the stored session and client_reference_id, then applies its own access, fulfillment, or review policy.

In the public integration, identity-document images do not enter your application. Captured document evidence is purged after a final verification or review decision. Session-collected PII follows the resolved retention policy; verification outcomes and sensitive one-way hashed anchors can persist for audit, deduplication, and returning-user checks. Review the retention details for lifecycle specifics.

Proof with less data

Your application receives the result it needs to evaluate the action without becoming another store of document images, selfies, or raw document data.

Production checklist

Treat production readiness as three launch gates. Every gate must pass before your policy trusts a verification outcome.

1. Session setup

  • Publish the workflow in the intended Stile organization.

  • Protect the session endpoint with authentication, ownership checks, and rate limiting.

  • Keep API keys and workflow selection on the server, and use a stable idempotency key for session creation.

  • Store the returned Stile session ID against the matching local transaction.

2. Browser handoff

  • Pass only the short-lived session fields the hosted widget needs.

  • Use the browser’s stile:verified event for interface feedback only, never to update trusted state.

3. Webhook gate

  • Register and enable a live HTTPS webhook endpoint before creating live sessions.

  • Verify Stile-Signature against the raw request body before applying the event.

  • Deduplicate by event.id, then match both the Stile session ID and client_reference_id to the pending record.

  • Let access or fulfillment depend only on webhook-backed server state.

Run the complete loop in sandbox

Create an account, publish a workflow, and send a sandbox request with skip_verification: true. Confirm that your webhook handler authenticates the event and updates only the matching pending record. Remove skip_verification before switching to a live organization.

Start free · Open the production quickstart · Review the Verification Sessions API

Frequently asked questions

How do you integrate identity verification into an app?

Create sessions on an authenticated server, run the user through a hosted or isolated verification flow, and update trusted application state only after verifying a signed server-to-server result. In the Stile example, those pieces are the Verification Sessions API, <stile-frame>, and a signed webhook.

Do I need a Stile server SDK?

No. Stile’s public API uses standard HTTPS and Bearer authentication, so any backend language can create sessions. The hosted <stile-frame> component handles the browser experience.

Can my application trust the browser’s stile:verified event?

No. Use that event only to update the interface. Grant access, release an order, or mark a local record verified only after your server authenticates the signed webhook and matches both the Stile session ID and client_reference_id.

How can I test the flow without a real identity document?

In a sandbox organization, create a session with skip_verification: true. The session moves to verified and sends a real signed webhook. Live organizations reject this field.

Does my application receive ID images or selfies?

Not through the public integration. Your application receives the verification outcome and relevant result fields. Captured document evidence remains inside Stile until the verification or review reaches a final decision; verification outcomes and sensitive hashed anchors can persist for audit and returning-user use cases.

Share this article