How to Add Spam Protection to a Resend Contact Form

Resend delivers your email reliably — it doesn't decide whether the message deserves to be sent. Here's how to add contact form spam protection before the Resend call, with a full breakdown of every parameter and why the client IP matters.

Resend is excellent at what it does: it takes an email and delivers it. What it does not do — and was never designed to do — is decide whether that email should have been sent in the first place.

That distinction matters more than it sounds. Every spam submission that reaches your resend.emails.send() call becomes a real, delivered, billed email sitting in your inbox. The bot doesn’t care that you’re using a modern stack. It fills in your form, your handler dutifully forwards it, and Resend does its job perfectly.

The fix is not to replace Resend. It’s to put a check in front of it.


The core principle: check before you send

The single most important architectural decision here is ordering. Spam protection has to run before the Resend call, not alongside it and not after it.

Form submission

InputGate  ← spam check happens here

  is_spam?
   ├── true  → stop. Resend is never called.
   └── false → resend.emails.send()

If you check after sending, you’ve already paid for the email, already polluted your inbox, and already triggered whatever downstream automation listens for new inquiries. The check only has value when it can prevent the send.

This sounds obvious written down, but it’s a genuinely common mistake — usually because spam filtering gets bolted on later, after the email flow already works, and the path of least resistance is to add it at the end.


The basic integration

Here’s the complete pattern in a Next.js App Router route handler:

// app/api/contact/route.ts
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export async function POST(req: Request) {
  const { name, email, message } = await req.json()

  const clientIp =
    req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'

  // 1. Check the submission — before anything else happens
  const check = await fetch('https://api.inputgate.cloud/v1/check', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.INPUTGATE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      fields:    { name, email, message },
      client_ip: clientIp,
      domain:    'yoursite.com',
      source:    'contact_form',
      retention: 'flagged_only',
    }),
  })

  const { is_spam, spam_score, reason } = await check.json()

  // 2. Spam → stop here. Resend is never reached.
  if (is_spam) {
    return Response.json(
      { error: 'Your message could not be submitted.' },
      { status: 400 }
    )
  }

  // 3. Clean → send the email
  await resend.emails.send({
    from:    'Contact <noreply@yoursite.com>',
    to:      'you@yoursite.com',
    replyTo: email,
    subject: `New inquiry from ${name}`,
    text:    message,
  })

  return Response.json({ success: true })
}

Three steps. The email provider never sees a submission that failed the check.


Every parameter you can send

The minimal request needs three fields. But the optional parameters are where accuracy comes from — each one gives the scoring engine more context to work with.

Required

ParameterTypeDescription
fieldsobjectThe text fields from your form. Keys are your own field names.
client_ipstringThe submitter’s IP address (IPv4 or IPv6).
domainstringThe domain the submission came from.

Optional

ParameterTypeDescription
sourcestringWhat kind of form this is — contact_form, quote_request, support_ticket, newsletter_signup. Appears in your logs and helps calibrate scoring.
contextstringA short description of your business and this form’s purpose. Keep it to one sentence — longer values are truncated server-side, not rejected. Genuinely improves accuracy — see below.
retention"full" | "flagged_only" | "none"How much to store. Defaults to flagged_only: clean submissions are scored and discarded.
needLanguagesstring[]At least one of these scripts must appear somewhere in the submitted text. Read the warning below — this is script presence, not “the message is written in this language”.
blockedLanguagesstring[]Flagged if any of these scripts appear in the text.
allowedCountriesstring[]Country allow-list, resolved from the client IP.
blockedCountriesstring[]Country block-list, resolved from the client IP.

A fuller request looks like this:

body: JSON.stringify({
  fields: {
    name:    formData.name,
    email:   formData.email,
    phone:   formData.phone,
    company: formData.company,
    message: formData.message,
  },
  client_ip:        clientIp,
  domain:           'yoursite.com',
  source:           'quote_request',
  context:          'Quote request form for a B2B logistics company',
  retention:        'flagged_only',
  blockedLanguages: ['zh', 'ru'],
  blockedCountries: ['CN', 'RU'],
})

A note on context

This parameter does more work than its size suggests. “Buy cheap watches, click here” is obvious spam on a SaaS contact form — and a completely legitimate inquiry on a watch retailer’s wholesale form. Without context, the engine has to guess. With it, borderline submissions get scored against what your business actually receives.

One sentence is enough. "Contact form on a B2B SaaS landing page" or "Appointment booking for a dental clinic" measurably reduces false positives on domain-specific vocabulary.

A warning about the language filters

needLanguages and blockedLanguages work on Unicode script detection, not linguistic analysis. Supported values are en, latin, ru, ar, he, zh, ja, ko, and ka.

The subtlety: needLanguages: ["en"] is satisfied by any Latin characters anywhere in the submitted text — and an email address is Latin characters. So this passes the filter:

{
  "fields": {
    "email":   "ivan@mail.ru",
    "message": "Здравствуйте, я хотел бы узнать цену."
  },
  "needLanguages": ["en"]
}

The message is entirely Cyrillic, but ivan@mail.ru supplies the Latin characters that satisfy en. Remove the email field and the same request is flagged with "Content must include: en".

In practice this means needLanguages rarely does what people expect on a form that collects an email address. blockedLanguages is the more reliable of the twoblockedLanguages: ["ru"] flags any submission containing Cyrillic, and that behaves exactly as you’d expect.

What not to put in fields

Send real text fields — name, email, phone, subject, company, message. Skip dropdown states, hidden tokens, CSRF values, UI flags, and styling metadata. They add noise to the analysis, inflate your payload, and store data you didn’t need to store.


Why the client IP matters — and what happens without it

client_ip is required, but not all IPs are equal in value. It’s worth understanding what the engine does with it, because that determines how much accuracy you lose when you can’t provide a real one.

A genuine IP unlocks several signal layers:

  • Reputation history — whether this address has submitted spam across other domains
  • Velocity detection — the same IP hitting many forms in a short window
  • Geographic filteringallowedCountries and blockedCountries resolve from the IP, and without a real one they simply cannot work
  • Infrastructure signals — whether the request originates from a datacenter, a VPN, or a residential connection

That last one is a strong signal on its own. Real customers filling in a contact form are on residential or mobile networks. Automated submissions overwhelmingly originate from cloud infrastructure.

The vibe-coding case

Here’s a scenario that comes up constantly now: someone builds a site on a no-code or AI-assisted platform, wires up a form, connects Resend, and ships it. The email arrives. It works.

But depending on how that platform is wired, the form may post to a hosted backend, a shared serverless function, or a third-party form service that then triggers Resend — and by the time your handler runs, the original visitor’s IP may be gone. What you have is the platform’s own IP, or nothing at all.

This is worth checking before assuming you don’t have one. Very often the IP is available — it just isn’t being read. It commonly lives in:

// Vercel, most reverse proxies
req.headers.get('x-forwarded-for')?.split(',')[0].trim()

// Cloudflare Workers and Pages
req.headers.get('cf-connecting-ip')

// Some platforms use these
req.headers.get('x-real-ip')
req.headers.get('true-client-ip')

Note the .split(',')[0] on x-forwarded-for. That header is a comma-separated chain of proxies — 203.0.113.42, 70.41.3.18, 150.172.238.178 — and the original client is the first entry.

Passing the raw header string is one of the most common integration bugs. The good news is that it fails loudly rather than silently: the API rejects it with a 400 and a clear message.

{
  "error":   "bad_request",
  "message": "Field \"client_ip\" must be a valid IPv4 or IPv6 address.",
  "request_id": "ig_req_9f127d714bcd"
}

If you see that error in your logs, you’re passing the chain instead of the first entry.

If you genuinely can’t get an IP

Send a placeholder — '0.0.0.0' is fine — and be explicit with yourself about the tradeoff.

What still works without a real IP:

  • Content analysis — spam language, promotional patterns, gibberish detection
  • Structural signals — field consistency, suspicious formatting, injected links and payloads
  • Language detection and needLanguages / blockedLanguages rules
  • Email address quality checks
  • Prompt injection and script injection detection

What you lose:

  • IP reputation and cross-domain history
  • Velocity and rate-based detection
  • All country filtering
  • Datacenter and VPN origin signals

The practical result: you still block the large majority of spam, because most contact form spam is caught on content alone — it’s promotional, it’s off-topic, it’s machine-generated, and it reads that way. What slips through more often is the sophisticated, content-plausible submission that would have been caught by reputation or velocity.

So a missing IP is a real, measurable downgrade — not a dealbreaker. If you’re in that situation, ship it with a placeholder now, and treat “get the real IP through to the handler” as a worthwhile follow-up rather than a blocker.


Handling the response

The response is small and predictable:

{
  "spam_score": 94,
  "is_spam": true,
  "reason": "aggressive promotional language, disposable email domain",
  "request_id": "ig_req_aa1070759717",
  "latency_ms": 970
}

The simplest integration branches on is_spam. But spam_score lets you build a middle tier:

const { spam_score, is_spam, reason } = await check.json()

if (spam_score > 85) {
  // High confidence spam — reject silently
  return Response.json({ error: 'Your message could not be submitted.' }, { status: 400 })
}

if (spam_score > 55) {
  // Suspicious — deliver, but flag it
  await resend.emails.send({
    from:    'Contact <noreply@yoursite.com>',
    to:      'you@yoursite.com',
    subject: `[SUSPICIOUS ${spam_score}] New inquiry from ${name}`,
    text:    `${message}\n\n---\nFlagged: ${reason}`,
  })
  return Response.json({ success: true })
}

// Clean
await resend.emails.send({ /* normal send */ })

This is the pattern most teams settle on. Certain spam disappears, borderline messages still arrive but clearly marked, and nothing real is ever silently dropped.

Always return a generic error message to the client. "Your message could not be submitted." tells a bot nothing. "Blocked: spam score 94, promotional language detected" tells it exactly what to change.


Failing open

One more decision worth making deliberately: what happens if the spam check itself fails — network error, timeout, service degradation?

let is_spam = false
let spam_score = 0

try {
  const check = await fetch('https://api.inputgate.cloud/v1/check', {
    method:  'POST',
    headers: { /* ... */ },
    body:    JSON.stringify({ /* ... */ }),
    signal:  AbortSignal.timeout(3000),
  })

  if (check.ok) {
    ({ is_spam, spam_score } = await check.json())
  }
} catch {
  // Fail open — a missed spam email costs less than a lost customer
}

if (is_spam) {
  return Response.json({ error: 'Your message could not be submitted.' }, { status: 400 })
}

await resend.emails.send({ /* ... */ })

For contact forms, fail open is almost always right. The cost of letting one spam message through during an outage is trivial. The cost of silently dropping a real customer inquiry is not.

Add an explicit timeout. Without one, a hanging request holds your form submission open indefinitely, and the user sees a spinner that never resolves.

On timeout values: a typical check that reaches the AI engine returns in roughly 0.9–1.0 seconds, while submissions caught by a rule (blocked country, blocked language) short-circuit and return in under 0.4 seconds. A 3-second timeout gives comfortable headroom above the normal case without leaving the user waiting if something is genuinely wrong.


Inbound email, not just forms

If you’re using Resend’s inbound email features, the same ordering applies — the fields just come from the webhook payload instead of a form:

export async function POST(req: Request) {
  const { from, subject, text } = await req.json()

  const check = await fetch('https://api.inputgate.cloud/v1/check', {
    method:  'POST',
    headers: {
      'Authorization': `Bearer ${process.env.INPUTGATE_API_KEY}`,
      'Content-Type':  'application/json',
    },
    body: JSON.stringify({
      fields:    { email: from, subject, message: text },
      client_ip: '0.0.0.0',        // no meaningful IP for inbound mail
      domain:    'yoursite.com',
      source:    'inbound_email',
    }),
  })

  const { is_spam } = await check.json()

  // Swallow silently — never bounce, it confirms the address is live
  if (is_spam) return Response.json({ ok: true })

  await forwardToHelpdesk({ from, subject, text })
  return Response.json({ ok: true })
}

Note the placeholder IP here is legitimate rather than a workaround — there is no meaningful client IP for an inbound email, and content analysis carries the decision.


Summary

  • Order matters most. The check runs before resend.emails.send(), or it isn’t doing anything useful.
  • Send more than the minimum. source and context are cheap to add and meaningfully improve accuracy.
  • Chase the real client IP. Read x-forwarded-for and take the first entry, or use cf-connecting-ip on Cloudflare. It’s often available even when it looks like it isn’t.
  • A missing IP degrades detection, it doesn’t disable it. Content analysis still catches most spam. Reputation, velocity, and geo filtering are what you give up.
  • Fail open, with a timeout. Never let a spam-check outage cost you a real inquiry.
  • Keep client errors generic. Detailed rejection reasons are free tuning feedback for bots.

Resend handles delivery. Something has to handle the decision of whether to deliver at all — and that belongs one step earlier in your handler.

Start free with InputGate or read the API reference for the full parameter list.