URL: /guides/form-protection

---
title: Protecting form submissions
description: 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](/guides/pageview-tracking) 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](/api-reference/introduction#response-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" } })
}
```

<Warning>
  Cloudflare Pages does not type-check files under `functions/` during the site build — they are bundled at deploy. Type-check them yourself (`tsc --noEmit`) so a broken handler does not ship silently.
</Warning>

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

<ParamField path="FORMSHIELD_SECRET_KEY" type="string" 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.
</ParamField>

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

<CardGroup cols={2}>
  <Card title="Content Filter" icon="shield" href="/products/content-filter">
    The full `/v1/check` request shape, the per-category scores, and `/v1/content` for raw text.
  </Card>
  <Card title="Pageview tracking" icon="chart-line" href="/guides/pageview-tracking">
    Add the beacon so pageviews are scored alongside your form submissions.
  </Card>
</CardGroup>
