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.
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.