Developers

Pulse beta embed

Add the Worker-hosted Pulse gate before one protected browser action. The browser can collect a phone bond, but your server is the enforcement point.

Before you start

Prerequisites

  • A Pulse beta publishable key for the browser and a server credential for session creation.
  • Your production and staging HTTPS origins, registered with Kenshiki.
  • A backend route that can mint a short-lived Pulse session and keep the completion secret server-side.
  • A protected workflow/action pair, such as credit_application / submit_application. These are tenant values, not SDK constants.
  • The Pulse iOS app to test scanning — join the TestFlight.

Customer apps do not use VITE_*, Vercel env vars, or the Web project as a proxy. Load pulse.js from the Worker, mint sessions against the Worker, and let the QR/app return to the same Worker origin.

Step 1

Request a publishable key

Request a Pulse beta key for exact origins you control. Kenshiki reviews production FI requests before activation.

  • The key is not a secret; it ships in your page but only works from approved origins.
  • It is scoped to approved workflows and actions, such as credit_application.
  • localhost is enabled only for the Kenshiki demo key, not partner keys.

Request received. We will review origins, workflow, and license acknowledgement.

Exact HTTPS origin only. No paths or wildcards.

Leave blank only if staging is not available yet.

Only required if workflow is Other.

Only required if action is Other.

Optional. Share only if useful.

What browser action will Pulse gate? No customer/member data.

Optional. Keep it high level.

Use only from approved origins. Production FI use requires written Kenshiki approval.

By submitting, you agree to the Terms and Privacy Notice.

Prefer email? Send the same details to hello@kenshikilabs.com.

Email instead

Step 2

Load the Worker-hosted SDK

Install the SDK at runtime with a script tag. The customer supplies runtime values through init() and their own backend route; the SDK does not read the host app's env system.

Current Worker script: https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/v1/pulse.js. Current session endpoint: https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Pulse embed</title>
  </head>
  <body>
    <!-- Put this where the Pulse-owned trust gate should appear. -->
    <div id="pulse-trust-gate"></div>

    <!-- Load the Worker-hosted SDK once. Do not self-host or proxy it through your web app. -->
    <script src="https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/v1/pulse.js" defer></script>

    <!-- Your backend creates the short-lived session; the browser only renders and watches it. -->
    <script type="module">
      const PULSE_WORKFLOW = "credit_application"; // your tenant-scoped workflow, not SDK-hard-coded
      const PULSE_ACTION = "submit_application"; // the exact protected action

      async function startPulseGate() {
        const created = await fetch("/api/pulse/sessions", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "same-origin",
          body: JSON.stringify({ workflow: PULSE_WORKFLOW, action: PULSE_ACTION }),
        }).then((response) => {
          if (!response.ok) throw new Error("pulse_session_create_failed");
          return response.json();
        });

        const pulse = window.KenshikiPulse.init({
          publishableKey: "pk_beta_issued_by_kenshiki",
          workflow: PULSE_WORKFLOW,
          containerId: "pulse-trust-gate",
          sessionEndpoint: "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions",
        });

        return pulse.requireBond({ session: created.session, action: PULSE_ACTION });
      }

      startPulseGate().catch((error) => {
        document.getElementById("pulse-trust-gate").textContent =
          error instanceof Error ? error.message : "Pulse could not start.";
      });
    </script>
  </body>
</html>

The example uses credit_application because that is the demo workflow. Replace it with the workflow/action Kenshiki enabled for your tenant.

Required UI boundary

Reserve space for the Pulse-owned trust gate

Your page owns the surrounding form. Pulse owns the QR, phone handoff, applicant help, reassurance copy, fail-closed state, and proof status inside the trust gate. Keep it visible and stable.

/* Required: reserve visible space for the Pulse-owned trust gate. */
#pulse-trust-gate,
[data-pulse-widget] {
  display: block;
  min-height: 168px;
  max-width: 360px;
}

/* Optional: theme around Pulse, without hiding/rewording its state. */
.application-form #pulse-trust-gate {
  margin-block: 16px;
}

/* Do not set display:none, visibility:hidden, opacity:0, pointer-events:none,
   position:absolute off-screen, or transform scaling on the Pulse gate. */

Do not render qr_svg_url as a bare image in production. Use the hosted widget so the required help, status, fail-closed, and tamper-detection behavior stays attached to the challenge.

Step 3

Mint sessions on your backend

For production-grade flows, your backend creates the Pulse session and stores the per-session completion_secret. The browser receives only the public session object needed to render and watch the gate.

// Your backend: POST /api/pulse/sessions
const PULSE_SESSION_ENDPOINT = process.env.PULSE_SESSION_ENDPOINT ?? "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions";
const PULSE_SERVER_KEY = mustGetEnv("PULSE_SERVER_KEY"); // issued by Kenshiki; never sent to the browser

app.post("/api/pulse/sessions", async (req, res) => {
  const workflow = req.body.workflow;
  const action = req.body.action;

  if (workflow !== "credit_application" || action !== "submit_application") {
    return res.status(400).json({ error: "unsupported_pulse_scope" });
  }

  const pulseRes = await fetch(PULSE_SESSION_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + PULSE_SERVER_KEY,
    },
    body: JSON.stringify({ workflow, action }),
  });
  const session = await pulseRes.json();
  if (!pulseRes.ok) return res.status(pulseRes.status).json(session);

  await pulseSessionStore.save({
    id: session.id,
    workflow,
    action,
    expiresAt: session.expires_at,
    completionSecret: session.completion_secret,
  });

  const { completion_secret: _completionSecret, ...publicSession } = session;
  return res.status(201).json({ session: publicSession });
});

Step 4

Gate your protected submit

Intercept the protected action after normal client-side validation, request a server-minted session, and pass that session to requireBond({ session }).

<!doctype html>
<html lang="en">
  <body>
    <form id="application-form" action="/applications" method="post">
      <!-- Keep your existing fields and validation. -->
      <input type="hidden" name="bonded_session_id" />
      <div id="pulse-trust-gate"></div>
      <button type="submit">Submit application</button>
    </form>

    <script src="https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/v1/pulse.js"></script>
    <script>
      const EXPECTED_WORKFLOW = "credit_application";
      const EXPECTED_ACTION = "submit_application";
      const form = document.getElementById("application-form");

      const pulse = window.KenshikiPulse.init({
        publishableKey: "pk_beta_issued_by_kenshiki",
        workflow: EXPECTED_WORKFLOW,
        containerId: "pulse-trust-gate",
        sessionEndpoint: "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions",
      });

      async function createPulseSession() {
        const response = await fetch("/api/pulse/sessions", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "same-origin",
          body: JSON.stringify({ workflow: EXPECTED_WORKFLOW, action: EXPECTED_ACTION }),
        });
        if (!response.ok) throw new Error("pulse_session_create_failed");
        return response.json(); // { session } without completion_secret
      }

      form.addEventListener("submit", async (event) => {
        if (!form.checkValidity()) return;
        event.preventDefault();

        const submitter = event.submitter;
        if (submitter) submitter.disabled = true;

        try {
          const created = await createPulseSession();
          const bond = await pulse.requireBond({
            session: created.session,
            action: EXPECTED_ACTION,
            context: { form_id: form.id || "application-form" },
          });

          if (bond.state !== "bonded" || !bond.sessionId) throw new Error("pulse_not_bonded");
          form.elements.bonded_session_id.value = bond.sessionId;

          // Submit to YOUR backend. It verifies and completes the bond server-side.
          const res = await fetch("/applications", { method: "POST", body: new FormData(form) });
          if (!res.ok) throw new Error("application_submit_failed");
        } finally {
          if (submitter) submitter.disabled = false;
        }
      });
    </script>
  </body>
</html>

Step 5 · Required

Verify the bond on your server

The browser result is UX, not authority. A user can call form.submit() from the console or POST directly to your endpoint with a forged bonded_session_id. Before you trust a submit, fetch the Worker receipt and enforce state, freshness, workflow, action, and evidence policy.

// Your backend, handling POST /applications:
const bondedSessionId = body.bonded_session_id;
if (!bondedSessionId) return reject("missing_pulse_session");

const res = await fetch(PULSE_SESSION_ENDPOINT + "/" + encodeURIComponent(bondedSessionId), {
  cache: "no-store",
  headers: { Authorization: "Bearer " + PULSE_SERVER_KEY },
});
const session = await res.json();

if (!res.ok || session.state !== "bonded") return reject("unverified");
if (session.workflow !== "credit_application") return reject("wrong_workflow");
if (session.action !== "submit_application") return reject("wrong_action");
if (Date.parse(session.expires_at) <= Date.now()) return reject("expired");

const stored = await pulseSessionStore.get(bondedSessionId);
if (!stored || stored.workflow !== session.workflow || stored.action !== session.action) {
  return reject("unknown_or_mismatched_session");
}

const v = session.verification;
if (!v) return reject("missing_verification");
if (!v.device_possession) return reject("device_unverified");
if (v.local_auth !== "verified") return reject("local_auth_failed");
if ((v.human_presence_confidence ?? v.assertion_confidence ?? 0) < 0.55) {
  return flagForReview("weak_session_assertion");
}
if (v.carrier?.sim_swap_recent) return flagForReview("recent_sim_swap");
if ((v.sensor_continuity?.continuity_score ?? 0) < 0.6) return flagForReview("weak_continuity");

// Passport is a workflow policy, not a global requirement.
if (workflowPolicy.requiresPassport && v.passport?.tier !== "chip") {
  return reject("passport_chip_required");
}

// Safe to process. After the action commits, call /form-completed with the stored completion secret.

Step 6

Close the bond after the action commits

A bond is opened for one action. Once that action durably succeeds on your side, close it; the session moves bonded -> completed and the phone's Pulse drawer flips to success.

// Your backend, after the protected action has durably succeeded:
const stored = await pulseSessionStore.get(bondedSessionId);
if (!stored?.completionSecret) return reject("missing_completion_secret");

await fetch(
  PULSE_SESSION_ENDPOINT + "/" + encodeURIComponent(bondedSessionId) + "/form-completed",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer " + PULSE_SERVER_KEY,
      "X-Pulse-Completion-Secret": stored.completionSecret,
    },
  },
);
// The session moves bonded -> completed and the phone's Pulse drawer closes on success.

Step 7

Test the flow end to end

  1. Replace pk_beta_issued_by_kenshiki with your publishable key.
  2. Set PULSE_SERVER_KEY only on your backend and keep completion_secret out of the browser.
  3. Serve from a registered HTTPS origin.
  4. Create a session from your backend and confirm the returned api_base_url is https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev.
  5. Open the page and scan the QR with the Pulse app. The state should move pending -> bonded.
  6. Confirm your server rejects missing, expired, wrong-workflow, wrong-action, and unbonded session ids.
  7. After your action succeeds, complete the bond and watch the phone show the completed state.

Endpoint check

Confirm the Worker SDK is reachable

This page does not create partner sessions through the Web project. Use this check to confirm the Worker-hosted SDK endpoint is reachable, then test your own key from your registered origin.

Partner keys are origin-scoped. Test a real key by serving the starter from your registered origin and creating the session from your backend; this page does not proxy session creation through Web.

Reference

Content Security Policy

If your site uses CSP, allow the Worker script, session/QR fetches, and WebSocket channel.

Content-Security-Policy:
  script-src 'self' https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev;
  connect-src 'self' https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev wss://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev;
  img-src 'self' data: https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev;

Reference

Session object

Returned by POST /api/v1/sessions and GET /api/v1/sessions/:id. Once the phone signs the proof, the bonded session carries a bounded verification summary.

{
  "id": "sess_9f2c...",
  "state": "bonded",
  "action": "submit_application",
  "workflow": "credit_application",
  "tenant_id": "example-bank",
  "api_base_url": "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev",
  "qr_payload": "pulse://bond?session_id=sess_9f2c...&nonce=nonce_abc...",
  "qr_svg_url": "/v1/sessions/sess_9f2c.../qr.svg?nonce=nonce_abc...",
  "universal_link": "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/bond?session_id=sess_9f2c...&nonce=nonce_abc...",
  "websocket_url": "wss://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/sessions/sess_9f2c...",
  "expires_at": "2026-06-28T22:40:00.000Z",
  "verification": {
    "device_possession": true,
    "local_auth": "verified",
    "assertion_confidence": 0.91,
    "human_presence_confidence": 0.91,
    "assertion_band": "strong",
    "continuity_score_long_lived": 0.84,
    "carrier": {
      "number_verified": true,
      "sim_swap_recent": false,
      "device_status": "active"
    },
    "sensor_continuity": {
      "motion_present": true,
      "continuity_score": 0.84
    },
    "passport": {
      "tier": "chip"
    }
  }
}
id
Session id (sess_...). The browser puts this in bonded_session_id; your server still treats it as untrusted input until it verifies the Worker receipt.
state
pending -> bonded once the phone signs the proof; then completed after the protected action succeeds, or expired/killed. Trust only fresh bonded sessions before processing.
api_base_url
The Worker origin that minted the session. The QR/app must come back to this same origin; do not route through the Web/Vercel project.
workflow
Your tenant-scoped workflow, such as credit_application, account_opening, account_recovery, or wire_transfer. It is not hard-coded in the SDK.
action
The single protected action the bond unlocks, such as submit_application or approve_wire.
qr_payload / qr_svg_url / universal_link / websocket_url
Handoff and real-time channel values consumed by the hosted widget. Do not render qr_svg_url as a bare production QR.
completion_secret
Returned only when the session is created. In the production shape, your backend stores it and never sends it to the browser.
expires_at
ISO timestamp. Reject the submit if it has passed.
tenant_id
Your tenant id. Confirm it matches your account where present.
verification
The bounded proof summary, present only when bonded: device_possession, local_auth, assertion_confidence / human_presence_confidence, assertion_band, carrier, sensor_continuity, and optional passport.tier when that workflow asks for passport evidence.

Reference

Error responses

Session routes return a JSON error with a code and a human-readable message.

unauthorized
When: No credential was sent, the server credential is wrong, or a browser-only key tried to create a session in a server-authoritative beta flow.
Fix: Mint sessions from your backend with the Kenshiki-issued server credential. Keep publishable keys in the browser and server credentials on the server.
forbidden
When: The origin, tenant, workflow, or action is not enabled for the key/credential.
Fix: Embed only on registered HTTPS origins. Ask Kenshiki to add an origin or enable a workflow/action.
invalid_request
When: Missing session id, unsupported workflow/action, malformed JSON, or a request sent to the wrong endpoint.
Fix: Use https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions for session create/get/complete and pass the workflow/action enabled for your tenant.
not_found
When: The app or browser looked up a session on a different Worker origin than the one that minted it, or the session expired.
Fix: Use the api_base_url/Worker origin returned on session creation; do not send QR/app traffic through the Web or Vercel project.

Reference

Tenant and Worker configuration

Kenshiki scopes publishable keys, server credentials, origins, workflows, and actions. The customer passes workflow/action at runtime; the Worker decides whether that key is allowed to use them.

{
  "environment": "production",
  "workerBaseUrl": "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev",
  "scriptUrl": "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/v1/pulse.js",
  "sessionEndpoint": "https://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev/api/v1/sessions",
  "publishableKey": "pk_beta_issued_by_kenshiki",
  "serverCredential": "PULSE_SERVER_KEY",
  "tenantId": "example-bank",
  "allowedOrigins": [
    "https://www.examplebank.com",
    "https://staging.examplebank.com"
  ],
  "workflows": [
    {
      "name": "credit_application",
      "actions": [
        "submit_application"
      ],
      "passport": "optional"
    }
  ],
  "notes": [
    "The customer passes workflow/action at runtime.",
    "The Worker validates that the key is allowed to use those values.",
    "Do not expose the server credential or completion_secret to the browser."
  ]
}