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. localhostis enabled only for the Kenshiki demo key, not partner keys.
Request received. We will review origins, workflow, and license acknowledgement.
Something did not validate. Check origins, acknowledgements, and work email fields.
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
- Replace
pk_beta_issued_by_kenshikiwith your publishable key. - Set
PULSE_SERVER_KEYonly on your backend and keepcompletion_secretout of the browser. - Serve from a registered HTTPS origin.
- Create a session from your backend and confirm the returned
api_base_urlishttps://kenshiki-pulse-worker-production.pulsekenshikilabscom.workers.dev. - Open the page and scan the QR with the Pulse app. The state should move
pending -> bonded. - Confirm your server rejects missing, expired, wrong-workflow, wrong-action, and unbonded session ids.
- 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."
]
}