Protecting form submissions
Score contact, signup, and lead forms server-side with POST /v1/check from your form handler — Next.js, route handlers, Cloudflare Pages Functions, and TanStack Start.
The beacon scores pageviews. To protect a form, call POST /v1/check from the server handler that processes the submission — the server action, route handler, or function that sends the email or writes the row. You send the form fields plus the visitor’s IP; you get back an allow / review / block decision in about 40ms and branch on it.
This is a server-side call with a secret key (fs_live_…), never the publishable beacon key. Create one in your project’s Settings with the check scope and store it as an environment variable — never ship it to the browser.
The pattern
Wherever your form is handled, do the check before you act on the submission, and treat FormShield as advisory infrastructure: fail open. If the call errors or times out, let the submission through — a FormShield outage must never break your contact form.
A reusable, fail-open helper (TypeScript, runs on any server runtime):
// formshield.ts — fail-open: any error/timeout/missing-key returns null → caller proceeds normally.
export async function formShieldCheck(input: {
formId: string
formData: Record<string, unknown>
ip?: string | null
email?: string | null
userAgent?: string | null
}): Promise<{ decision: "allow" | "review" | "block"; score: number } | null> {
const key = process.env.FORMSHIELD_SECRET_KEY
if (!key) return null
try {
const res = await fetch("https://api.formshield.dev/v1/check", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify({
form_id: input.formId,
form_data: input.formData,
metadata: {
ip: input.ip ?? undefined,
email: input.email ?? undefined,
user_agent: input.userAgent ?? undefined,
},
}),
signal: AbortSignal.timeout(2500),
})
if (!res.ok) return null
const json = (await res.json()) as { data?: { decision?: string; score?: number } }
return {
decision: (json.data?.decision as "allow" | "review" | "block") ?? "allow",
score: json.data?.score ?? 0,
}
} catch {
return null // FormShield must never break the form
}
}The response is wrapped in the standard envelope, so the decision is at json.data.decision. Pass the visitor’s real IP and email when you have them — the more signals, the sharper the score.
Forwarding beacon signals (optional, strongest human evidence)
If the page also runs the beacon, it embeds a signed client_signals payload you can forward in metadata.client_signals. The server then verifies the handshake token (HMAC signature + freshness + single-use per surface) and a verified token is the strongest “real browser” credit the score can earn. Two notes:
- Also forward
metadata.site— the page hostname the beacon ran on (e.g."www.example.com"). The token is cryptographically bound to it, so supplying it lets verification match exactly. Omitting it never hurts (an unresolvable binding is treated as unverifiable, not forged); supplying it can only help. - Calls without
client_signalsare perfectly fine — a plain server-to-server check is not penalized for lacking browser signals. If you want its absence treated as a bot tell (because you know the beacon is deployed on every page), opt in by passingoptions.signalsincluding"fingerprint".
Choosing what to do with the decision
Start conservative and tighten once you trust the scores. A good first deployment delivers every submission and only tags it, so you get full analytics with zero risk of dropping a real lead:
const fs = await formShieldCheck({ formId: "contact", formData, ip, email, userAgent })
const tag =
fs?.decision === "block" ? "[FormShield: BLOCKED] " :
fs?.decision === "review" ? "[FormShield: REVIEW] " : ""
// prepend `tag` to the notification email subject; send as normal.Once the dashboard shows the scores are right for your traffic, switch block to a hard drop (skip the email/insert and return a generic success so you don’t tip off bots), and route review to a moderation queue.
Next.js — server action
A server action has no request object, so read the IP and user agent from next/headers.
"use server"
import { headers } from "next/headers"
import { formShieldCheck } from "@/lib/formshield"
export async function submitContact(data: { name: string; email: string; message: string }) {
const h = await headers()
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip")
const fs = await formShieldCheck({
formId: "contact",
formData: data,
ip,
email: data.email,
userAgent: h.get("user-agent"),
})
const tag = fs?.decision === "block" ? "[FormShield: BLOCKED] " : fs?.decision === "review" ? "[FormShield: REVIEW] " : ""
// …send the email with `tag` prefixed to the subject…
}Next.js — route handler
A route handler has the request directly:
// app/api/contact/route.ts
import { NextResponse, type NextRequest } from "next/server"
import { formShieldCheck } from "@/lib/formshield"
export async function POST(request: NextRequest) {
const data = await request.json()
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip")
const fs = await formShieldCheck({
formId: "contact",
formData: data,
ip,
email: data.email,
userAgent: request.headers.get("user-agent"),
})
// branch on fs?.decision, then send/store…
return NextResponse.json({ ok: true })
}Cloudflare Pages Functions
Functions read environment via context.env (not process.env), and Cloudflare gives you the real client IP in cf-connecting-ip. Inline the helper or adapt it to take the key as an argument.
// functions/api/contact.ts
export const onRequestPost: PagesFunction<{ FORMSHIELD_SECRET_KEY: string }> = async (ctx) => {
const data = await ctx.request.json()
const ip = ctx.request.headers.get("cf-connecting-ip")
// call /v1/check with ctx.env.FORMSHIELD_SECRET_KEY, ip, data, user-agent…
return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } })
}TanStack Start — server function
In a createServerFn handler, read request headers from the @tanstack/react-start/server subpath with getRequestHeader (not the unexported getWebRequest).
import { createServerFn } from "@tanstack/react-start"
import { getRequestHeader } from "@tanstack/react-start/server"
import { formShieldCheck } from "~/lib/formshield"
export const submitContact = createServerFn({ method: "POST" })
.validator((d: { name: string; email: string; message: string }) => d)
.handler(async ({ data }) => {
const ip = getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim() ?? getRequestHeader("x-real-ip")
const fs = await formShieldCheck({
formId: "contact",
formData: data,
ip,
email: data.email,
userAgent: getRequestHeader("user-agent"),
})
// branch on fs?.decision, then send/store…
})Key handling
FORMSHIELD_SECRET_KEY string path required Your secret key (fs_live_…) with the check scope. Server-side only — set it as an environment variable / platform secret, never expose it in the browser or commit it. This is not the publishable fs_pub_live_… key the beacon uses.
The beacon’s publishable key and this secret key are different keys for different layers: the publishable key scores pageviews from the browser; the secret key scores form submissions from your server. A project can have both.