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:
- Client — collect form fields, behavioral signals, and a honeypot value; exchange them for a signed token via
/api/tokenize. - Form submission — include that token in the form payload (as a hidden field, JSON property, etc.).
- Server — verify the token against your secret key via
/api/verifyto 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 theRetry-Afterheader. - 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, reasonhoneypot_filled.timeOnForm: 200(or low value) → conclusive bot, reasontiming_too_fast.- Otherwise → "inconclusive — would fall through to AI scoring".
After launch:
recent-bot-reasonsshows which heuristic rules are firing in production. If one reason dominates the list unexpectedly, your rule is over-triggering.list-submissions+label-submissionlets 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 publicpk_key. - Wrong endpoint for the wrong key.
/api/tokenizetakes the public key (pk_...) in the request body;/api/verifytakes the secret key (sk_secret_...). Mixing them produces 401s. - Skipping the timestamp.
timestampmust be the form-load time (the same instant you start measuringtimeOnForm), notDate.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.