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):

ts
// 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:

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

ts
"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:

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

ts
// 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).

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

Next steps

Type to search…

↑↓ navigate open esc close