Integration guide

Integrating Sentinel

Drop-in widget, custom client integration, and server-side verification — all in one place.

Overview

Sentinel is invisible form spam protection. Integration is in three layers:

  1. Client — collect form fields, behavioral signals, and a honeypot value; exchange them for a signed token via /api/tokenize.
  2. Form submission — include that token in the form payload (as a hidden field, JSON property, etc.).
  3. Server — verify the token against your secret key via /api/verify to get a human/bot verdict before processing the submission.

You can either drop in the prebuilt widget script (zero-code) or implement the client side yourself (form libraries, SPAs, mobile). Both flows hit the same two endpoints.

Two keys per site

  • Public key (pk_...) — embedded in client code, used at /api/tokenize.
  • Secret key (sk_secret_...) — server-only, used at /api/verify. Never expose to the browser.

Generate both from the Sentinel dashboard: https://sentinel.savantly.cloud/dashboard/site-keys.

Option A — Drop-in widget

Recommended for static HTML. Add the script to any page that contains a <form>. The widget finds the nearest form, injects an invisible honeypot, tracks focus/blur signals, intercepts submit, fetches a token, attaches it as a hidden input named sentinel-token, then lets the form submit normally.

<script src="https://sentinel.savantly.cloud/widget.js"
  data-site-key="pk_your_public_key"
  async></script>

Optional: override the tokenize endpoint with data-sentinel-api="https://.../api/tokenize".

After submit, your form action receives all original fields plus a sentinel-token field. Forward that to your server's /api/verify call (see Server verification below).

The widget fails open: on network error or non-200, the form still submits — just without a token. Your server should treat a missing token according to your policy (allow but flag, or reject — your call).

Option B — Custom client

Use this when you can't drop in a script — e.g., a custom React form component, a controlled input library, or a native app.

1. Add a honeypot

A field real users never see but bots fill. The widget convention is name="website", but any name works as long as you pass its value as honeypot in step 3.

<input
  type="text"
  name="website"
  tabIndex={-1}
  autoComplete="off"
  aria-hidden="true"
  style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>

2. Track timeOnForm

Milliseconds between form mount and submit. The timing heuristic flags submissions that come in faster than any human could plausibly fill the form.

const mountedAt = Date.now();
// later, on submit:
const timeOnForm = Date.now() - mountedAt;

3. Tokenize on submit

const res = await fetch("https://sentinel.savantly.cloud/api/tokenize", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    siteKey: "pk_your_public_key",
    fields: {                          // flat string->string map
      email: values.email,
      name: values.name,
      message: values.message,
    },
    signals: { timeOnForm },           // ms, integer
    honeypot: values.website ?? "",    // value of your honeypot field
    timestamp: mountedAt,              // Date.now() at form load
  }),
});

let token: string | null = null;
if (res.ok) {
  const data = await res.json();
  if (typeof data.token === "string") token = data.token;
}

4. Include the token in your form submission

Put it anywhere you control on the server side — a hidden field, an extra JSON property, a header. Your server passes it to /api/verify.

Failure handling

On non-200 or thrown fetch error, fail open: submit the form without a token. The widget does this and your custom integration should too. Sentinel outages should never block real users.

Server verification

Whatever endpoint receives the form submission must verify the Sentinel token before trusting it.

const verify = await fetch("https://sentinel.savantly.cloud/api/verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    token: formData.sentinelToken,     // from the form
    secret_key: process.env.SENTINEL_SECRET_KEY!, // sk_secret_...
    form_name: "contact-us",           // optional, ≤100 chars — shown in the Sentinel dashboard
  }),
});

if (!verify.ok) {
  // Fail open OR reject — your call. Don't crash.
}

const verdict = await verify.json();
// {
//   human: boolean,          // true = let it through
//   score: number,           // 0=human, 1=bot
//   reasons: string[],       // e.g. ["honeypot_filled", "timing_too_fast"]
//   path: "HEURISTIC" | "HEURISTIC_AND_AI"
// }

if (!verdict.human) {
  // Reject, queue for review, silently drop — whatever fits your form.
}

Status codes

  • 200 — verdict in body.
  • 400 missing_token / missing_secret_key / invalid_json — client-side mistake; do not surface to the user.
  • 401 invalid_secret_key / invalid_token — bad secret key or tampered/expired token. Fail open or reject per policy.
  • 429 rate_limited — site key over its tier quota. Honour the Retry-After header.
  • 500 — Sentinel server problem. Fail open.

Never treat a 4xx/5xx as a human/bot verdict — the human field is only present on 200.

Fields & signals reference

`fields` is a flat Record<string, string>. Sentinel infers each field's semantic type from its name. Use these standard names when you can — they unlock better AI judgements:

email, name / first-name / last-name, phone / tel, message / comment / body, subject, company / organization, address / street / city / zip, url / website.

Custom names still get scored but as type "unknown".

`signals.timeOnForm` — milliseconds from form mount to submit. The primary behavioural signal today.

`honeypot` — value of an invisible field real users won't fill. A non-empty value is a conclusive bot signal (no AI fallthrough). Pick a plausible-sounding name like website so bots actually fill it.

Testing your integration

Before going live, dry-run the heuristic engine via the score-test MCP tool — it scores synthetic inputs without persisting them:

  • honeypot: "anything" → conclusive bot, reason honeypot_filled.
  • timeOnForm: 200 (or low value) → conclusive bot, reason timing_too_fast.
  • Otherwise → "inconclusive — would fall through to AI scoring".

After launch:

  • recent-bot-reasons shows which heuristic rules are firing in production. If one reason dominates the list unexpectedly, your rule is over-triggering.
  • list-submissions + label-submission lets you mark real submissions as HUMAN or BOT to feed the AI training loop.

Common gotchas

  • Secret key in client code. sk_secret_ must stay on the server. The browser only ever sees the public pk_ key.
  • Wrong endpoint for the wrong key. /api/tokenize takes the public key (pk_...) in the request body; /api/verify takes the secret key (sk_secret_...). Mixing them produces 401s.
  • Skipping the timestamp. timestamp must be the form-load time (the same instant you start measuring timeOnForm), not Date.now() at submit.
  • Hard-failing on Sentinel errors. If Sentinel is unreachable, real users will be locked out. Always fail open.
  • Bot fills only the honeypot. Real users won't see it; bots fill every field. A filled honeypot is a 100% bot signal — short-circuit immediately if you see one before even calling /api/verify. (The token will already encode this.)

The same content is available to MCP clients via the integration-guide tool — agents and humans see identical instructions.