Skip to content
Integration guide · v1.3

Add Lifecheck to your site.

Lifecheck is a drop-in human check. The widget runs in a sandboxed frame served from Swiftaw, hands the browser a one-time token on success, and you confirm that token on your server before trusting the request. Two lines to install, one call to verify.

Quickstart

The whole flow, top to bottom:

<!-- 1 · load Lifecheck once (in your <head> or before </body>) -->
<script src="https://swiftaw.com/lifecheck/lifecheck.js" async defer></script>

<!-- 2 · put the widget inside the form you want to protect -->
<form method="POST" action="/signup">
  <input name="email" type="email">

  <div class="lifecheck" data-sitekey="YOUR_SITE_KEY"></div>

  <button type="submit">Create account</button>
</form>

Lifecheck auto-renders the .lifecheck element, sizes its own frame, and injects a hidden <input name="lifecheck-token"> so the token rides along with a normal form POST. Then verify it on your server. That's the entire integration.

Just want to see it? The overview page embeds the real widget as a live demo, the same code you're pasting here.

Site & secret keys

Lifecheck uses a key pair, one public and one private:

KeyWhere it livesWhat it does
Site keyIn your HTML, on the widgetPublic. Renders the widget and scopes it to your registered domain(s).
Secret keyOn your server onlyPrivate. Signs the call that verifies a token. Never ship it to the browser.

Register your domain with Swiftaw to receive a pair. Keys look like lc_site_xxx and lc_secret_xxx.

Treat the secret key like a password. If it leaks, rotate it. Anyone who has it can forge verifications.

1 Add the widget

Load the script once per page, then place a container. You can configure it entirely with data- attributes:

<div class="lifecheck"
     data-sitekey="YOUR_SITE_KEY"
     data-callback="onLifecheckPass"
     data-expired-callback="onLifecheckExpired"></div>

See every attribute in the data-attribute reference. Prefer to render manually, say inside a modal that opens later? Skip the auto-render and call the API yourself:

<div id="check"></div>

<script>
  Lifecheck.render("check", {
    sitekey: "YOUR_SITE_KEY",
    callback: function(token){ console.log("passed:", token); }
  });
</script>
Data & consent. Lifecheck records how visitors interact with the check and its mini-games (including robotic or suspicious signals) to run, improve and train Swiftaw's systems and AI. The widget shows this consent inline and links our Terms, Privacy and Products policies - no extra notice is required on your side.

2 Read the token

On a successful check, Lifecheck gives you the token three ways. Use whichever fits:

  • Hidden field. A <input name="lifecheck-token"> is added inside the container, so a plain form POST already carries it.
  • Callback. Your data-callback function is invoked as callback(token, widget).
  • DOM event. The container emits a bubbling lifecheck:verified event with detail.token.

Or pull it on demand:

const token = Lifecheck.getResponse();   // "" until verified

// e.g. gate an AJAX submit
form.addEventListener("submit", async (e) => {
  const t = Lifecheck.getResponse();
  if (!t) { e.preventDefault(); alert("Please complete the check."); }
});
A token is single-use and expires ~2 minutes after it's minted. Call Lifecheck.reset() to hand the user a fresh challenge (e.g. after a failed submit).

3 Verify on your server

The client token proves nothing on its own. Always confirm it from your backend (never browser JS, or you'll leak your secret and hit CORS). Verification is a single call to Lifecheck's function endpoint with your secret key and the token:

POST https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token Content-Type: application/json
cURL
Node.js
PHP
curl -X POST \
  "https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token" \
  -H "apikey: sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf" \
  -H "Content-Type: application/json" \
  -d '{ "p_secret": "YOUR_SECRET_KEY", "p_token": "THE_TOKEN_FROM_THE_BROWSER" }'
// server-side only - keep YOUR_SECRET_KEY out of the browser
const res = await fetch(
  "https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token", {
  method: "POST",
  headers: {
    "apikey": "sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    p_secret: process.env.LIFECHECK_SECRET,
    p_token: req.body["lifecheck-token"]
  })
});
const data = await res.json();
if (!data.success) return res.status(400).send("Failed Lifecheck");
$r = file_get_contents(
  "https://mwszvynzzugbowdngzab.supabase.co/rest/v1/rpc/lifecheck_verify_token", false,
  stream_context_create(["http" => [
    "method"  => "POST",
    "header"  => "apikey: sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf\r\nContent-Type: application/json",
    "content" => json_encode([
      "p_secret" => $SECRET,
      "p_token"  => $_POST["lifecheck-token"]
    ])
  ]]));
$ok = json_decode($r)->success;

Response

{
  "success":      true,
  "score":        0.85,                  // 0..1, real as of v1.3
  "passed":       "signals",             // "signals" | "challenge" | "signals-invisible"
  "mode":         "checkbox",            // "checkbox" | "invisible"
  "challenge_ts": "2026-07-31T18:04:11Z",
  "hostname":     "yoursite.com",
  "v":            "1.3",
  "error-codes":  []
}
Gate your action on success === true. p_secret is your private secret and must stay server-side. Tokens are single-use and expire ~2 minutes after issue.
The apikey header is not your Lifecheck key. It is Lifecheck's public API key - the fixed value shown above, identical for every customer, and safe to ship. Sending your own lc_site_… or lc_secret_… key there gets you 401 Invalid API key before the token is ever read. Your keys go in the JSON body: p_secret only.
Keys are checked live, so managing them takes effect right away - remove a key and it stops working immediately, no redeploy needed.

No backend? Verify in the browser safely

A direct browser call to the verify RPC fails CORS on purpose - it stops you shipping your secret to the client. For a fully static site, deploy the lifecheck-verify Edge Function (in supabase/functions/): the browser sends only the public site key + token, and the function looks up the secret server-side and returns a CORS-enabled verdict. Your secret never leaves Supabase. Try it live on the token verifier.

// deploy once:  supabase functions deploy lifecheck-verify --no-verify-jwt
const r = await fetch(
  "https://mwszvynzzugbowdngzab.supabase.co/functions/v1/lifecheck-verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sitekey: "lc_site_...", token: Lifecheck.getResponse() })
});
const verdict = await r.json();   // { success:true, passed:"challenge", ... }
Browser verification trades a little strictness for convenience: it takes the public key, so it's best for low-risk gates. For anything sensitive, verify server-side with the secret as shown above.

Data-attribute reference

AttributeDescription
class="lifecheck"requiredMarks the element for auto-render. (Or data-lifecheck.)
data-sitekeyrequiredYour public site key.
data-callbackoptionalName of a global function called as fn(token, widget) on pass. Dotted paths like app.onPass work.
data-expired-callbackoptionalGlobal function called when a token expires.
data-error-callbackoptionalGlobal function called as fn({code, message}, widget) when the widget can't verify at all - sitekey-rejected (unknown key, or this domain isn't allow-listed), issue-request-failed (Lifecheck unreachable) or no-sitekey. Use it to fall back; the pass callback will never fire.
data-response-fieldoptionalRenames the hidden input (default lifecheck-token).
data-size="invisible"optionalv1.3 Renders nothing. Call Lifecheck.execute() when you need the check; it passes silently or unfolds a challenge in place. See invisible mode.

JavaScript API

Everything hangs off the global Lifecheck object.

MethodReturnsDescription
Lifecheck.render(el, opts?)idRenders a widget into el (element or id). opts: sitekey, callback, expired-callback, error-callback. Returns a numeric widget id.
Lifecheck.getResponse(ref?)stringCurrent token, or "" if not yet verified. ref = id, element, or container id; defaults to the first widget.
Lifecheck.reset(ref?)voidClears the token and loads a fresh challenge.
Lifecheck.execute(ref?)widgetv1.3 Runs an invisible-mode check now. Forwards the page's cursor trail to the frame, which either mints a token (your pass callback fires) or unfolds a challenge.
Lifecheck.versionstringLoader version, e.g. "1.3".

Verify endpoint

POST /rest/v1/rpc/lifecheck_verify_token (JSON body) request parameters:

ParamDescription
p_secretrequiredYour secret key (server-side only).
p_tokenrequiredThe token returned by the widget in the browser.

Send Lifecheck's public API key (sb_publishable_dqsqX2klo1j4xSyEFA7O1w_UjM8lEGf - the same for everyone, not one of your own keys) in the apikey header. The endpoint accepts cross-origin requests, but call it from your backend so p_secret never reaches a browser.

Response fields:

FieldTypeMeaning
successbooleanWhether the token is valid and unused. Gate on this.
scorenumber0.0-1.0 confidence the visitor is human, from the nine signals the widget read. v1.3 - before this it was always 0.9, and tokens minted by an older widget still answer 0.9. Gate on success; use score for softer calls like holding a submission for review.
passedstring"signals" (passed on behaviour), "challenge" (solved a task) or "signals-invisible".
modestringv1.3 "checkbox" or "invisible".
challenge_tsstringISO-8601 timestamp of the check.
hostnamestringDomain the check was solved on.
vstringLifecheck version ("1.3").
error-codesarrayPresent when success is false. See below.

Error codes

CodeMeaning
missing-input-secretThe p_secret parameter was not sent.
invalid-input-secretThe secret key is unknown (or its key was deleted).
missing-input-tokenThe p_token parameter was not sent.
invalid-input-tokenNo such token for this key.
timeout-or-duplicateThe token has expired or was already verified once. Since v1.3 the widget warns you first: it fires data-expired-callback and resets itself just before a token goes stale, so a form left open no longer submits one that is already dead.
invalid-input-sitekeyBrowser endpoint only. No key registered under the sitekey you sent.
invalid-input-token on a LC1.3-preview_… tokenNot a bug. Preview tokens come from a reserved _public site key, are minted entirely in the browser, and are meant to be unverifiable - that is what makes them safe to demo with. Use a real lc_site_… key to get a token that verifies.

Invisible mode v1.3

Add data-size="invisible" and Lifecheck draws nothing at all. Call Lifecheck.execute() at the moment you actually need a human - on submit, usually. Most visitors never see a thing; the ones the check isn't sure about get a challenge that unfolds in place.

<div class="lifecheck"
     data-sitekey="YOUR_SITE_KEY"
     data-size="invisible"
     data-callback="onPass"></div>

<script>
  // the token arrives here, exactly as in checkbox mode
  function onPass(token){ form.submit(); }

  submitBtn.addEventListener('click', function(e){
    e.preventDefault();
    Lifecheck.execute();
  });
</script>

The container gets data-lifecheck-expanded="true" and fires a lifecheck:expand DOM event when a challenge appears, so you can make room for it. Leave the element where the challenge should show up.

Know what you're trading. A zero-size frame never sees the pointer, so the loader forwards your page's cursor trail into it - and anything collected on your page can be fabricated by script running on your page, unlike the samples the frame gathers for itself. Lifecheck holds host-supplied signals to a much higher bar before it will pass anyone on them alone, and still runs its environment checks inside the frame where the page can't reach them. Invisible mode buys less friction, not more certainty. For a login or a payment, the checkbox is the stronger choice.

Frame events (advanced)

Under the hood the sandboxed widget talks to the loader over postMessage. You normally never touch this (the loader turns it into the callbacks above), but for custom hosts, messages are shaped:

{ source: "swiftaw-lifecheck", v: "1.3", event: "verified", token: "LC1.3_…" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "resize",   height: 74 }
{ source: "swiftaw-lifecheck", v: "1.3", event: "ready" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "error",   code: "sitekey-rejected", message: "…" }
{ source: "swiftaw-lifecheck", v: "1.3", event: "expand" }   // invisible mode wants a challenge
{ source: "swiftaw-lifecheck", v: "1.3", event: "collapse" } // …and is done with it
Always validate event.origin against Lifecheck's origin before trusting a message. The bundled loader does this for you.

Testing aids v1.3

Two query parameters on the frame make a widget easier to look at while you're wiring it up. Both only affect what you can see - neither makes a check easier to pass.

ParameterDoes
?lcdebug=1Logs every signal and event the widget records to the console, and keeps them on window.__lcEvents so you can read exactly what Lifecheck saw.
?lcgame=tracePins the challenge to one game instead of picking at random, so you can look at the same one repeatedly. Any of the eleven ids: imagepick, oddone, tally, grid, slider, rotate, sequence, code, trace, catchit, pairs.

Anti-theft model

People ask how they can integrate Lifecheck without being able to just copy it. Here's the honest answer:

  • The detection logic and the five challenges live in embed.html, served only from swiftaw.com and loaded inside a sandboxed <iframe>. Integrators embed a URL, not a re-hostable copy of the checks.
  • Tokens are minted server-side. When a visitor passes, the widget asks Swiftaw to issue a token, which only happens if your site key still exists and the host is on its allow-list. A copied widget with no valid key gets nothing back.
  • Live revocation. Delete a key and token issuance stops that instant, and the secret-side verify can no longer find the key. Pages already open can't keep verifying - no reload required.
  • The token is single-use and useless until your secret key verifies it. A stolen widget shell can't produce tokens that pass your server check.
Like any browser check, a determined bot can still drive the widget itself - the honest guarantee is the pairing of live key validation and server-side secret verification (step 3), not client-side friction. Never skip step 3.

Changelog

v1.3current
  • Eleven signals, one score. The cursor checks stopped being pass/fail questions: more signals joined the original three, and each costs confidence rather than deciding on its own. The odds of being challenged now follow the score rather than a fixed coin flip.
  • Half of all sessions play a game. The other half are read from behaviour and only interrupted when the signals don't add up. Split at random per visit, so looking smooth is no longer a way through on its own. Invisible mode is exempt, by design.
  • Expiring tokens announce themselves. data-expired-callback has been documented since v1.1 and could never actually fire; it does now, and the widget resets itself so the visitor can simply check again.
  • Every board is unique. Tiles get their own rotation, scale and mirror, the beacons carry real numbers instead of always 1 to 5, and the traced beam is sometimes an S-bend. No two boards render the same pixels.
  • The two pointer-only games offer a way out. Trace and Tag need a mouse, so they are never shown on touch, refuse keyboard activation, and carry a one-press swap to a fully keyboard-playable challenge.
  • Three new mini-games. Trace the beam, tag the probe, find the twins - eleven in all.
  • Every challenge is keyboard-playable. Tab and Enter to pick, arrows to steer the slider and the dial, real labels on everything. Grab-anywhere dragging, Enter to submit the code, and no more empty-grid submissions.
  • Invisible mode. data-size="invisible" plus Lifecheck.execute(). See the section above, including what it trades away.
  • Friendlier failures. A check that can't run says so in its own voice and offers another crank, instead of dead-ending on an error the visitor can't do anything about.
  • Reserved preview keys. A site key ending in _public never reaches the server and mints an unverifiable LC1.3-preview_… token. It's how our own demos run; it is not something to ship on a real form.
  • Richer interaction insights, so the check - and Swiftaw's AI - keeps getting better.
v1.2previous
  • New mini-games. Pick the life form, spot the odd one out, and count the life forms - drawn with Twemoji so they look the same everywhere.
  • Refreshed widget. The LifeCheck wordmark, a wider card, and smoother motion.
  • Interaction insights that help the check keep getting better.
  • Hardened verification and behind-the-scenes tuning.
v1.1previous
  • Per-site API keys, server-issued tokens, and a verify endpoint.
  • Self-sizing frame via resize messages, so the box never clips.
  • Hidden-field auto-injection for zero-JS form submits.
  • lifecheck:verified DOM event + dotted-path callbacks.
v1.0initial
  • Cursor-behaviour detection (distance, jitter, reaction time) with touch fallback.
  • Themed challenges + the “I'm not a robot” checkbox widget.

Ready to wire it up? Jump back to the quickstart or see the overview.