Build an IP Intelligence Firewall for Newsletter Signups
Protect Your Newsletter with Advanced IP Filtering Techniques

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
If you’ve followed my guides on building the Next.js newsletter form and wiring Brevo to Sanity, you know the stack that powers buildwithmatija.com. It was humming along nicely—more than 1,000 readers strong—until a traffic spike drew in a wave of Russian spam signups. reCAPTCHA v3 and Vercel bot protection slowed them down, but bad actors still slipped into Brevo. I needed a harder gate that fit the workflow I’d already shared.
Here’s the exact pipeline I added: capture browser hints, resolve and geolocate the client IP, run AbuseIPDB scoring, divert suspicious signups into a dedicated Sanity collection, and only sync clean contacts to Brevo. You can graft the same firewall onto your own setup in a weekend.
Step 1: Capture client hints alongside the form submission
The first step still happens in the browser. I want timezone, language, and the page URL so I can compare the visitor’s story with what the server learns later. Every signup surface reuses a small hook that hydrates hidden inputs.
// File: src/hooks/use-signup-metadata.ts
'use client'
import { useEffect, useState } from 'react'
type SignupMetadata = {
timezone: string
language: string
signupUrl: string
}
const fallback: SignupMetadata = {
timezone: '',
language: '',
signupUrl: '',
}
export function useSignupMetadata(): SignupMetadata {
const [metadata, setMetadata] = useState<SignupMetadata>(fallback)
useEffect(() => {
try {
setMetadata({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
language: navigator.language || '',
signupUrl: window.location.href || '',
})
} catch (error) {
console.warn('useSignupMetadata: failed to resolve metadata', error)
setMetadata(fallback)
}
}, [])
return metadata
}
This keeps the markup clean—each form adds hidden fields for timezone, language, and signupUrl. Those hints become useful once we cross-check them with the server-side lookups coming next.
Step 2: Resolve the signup IP and geo-enrich it
The server action already has access to the request headers, so grabbing an origin IP meant choosing the most reliable header Vercel exposes (x-forwarded-for) and falling back through the usual suspects. I then wrapped an ipinfo.io call in a helper so the logic stayed testable and reusable.
// File: src/lib/brevo/ip-geolocation.ts
import 'server-only'
interface GeoCoordinates {
latitude?: number
longitude?: number
}
export interface IpGeoResult extends GeoCoordinates {
city?: string
region?: string
country?: string
timezone?: string
hostname?: string
organization?: string
postalCode?: string
}
export async function lookupGeoForIp(ipAddress: string | null | undefined): Promise<IpGeoResult | null> {
if (!ipAddress) return null
const token = process.env.IPINFO_TOKEN
if (!token) {
console.warn('lookupGeoForIp: IPINFO_TOKEN not configured')
return null
}
const trimmed = ipAddress.trim()
if (!trimmed || trimmed === '::1' || trimmed.startsWith('127.')) return null
try {
const response = await fetch(`https://ipinfo.io/${trimmed}/json?token=${token}`, {
headers: { Accept: 'application/json' },
next: { revalidate: 3600 },
})
if (!response.ok) {
console.error('lookupGeoForIp: request failed', {
status: response.status,
statusText: response.statusText,
})
return null
}
const data: {
city?: string
region?: string
country?: string
timezone?: string
loc?: string
hostname?: string
org?: string
postal?: string
} = await response.json()
const result: IpGeoResult = {
city: data.city || undefined,
region: data.region || undefined,
country: data.country || undefined,
timezone: data.timezone || undefined,
hostname: data.hostname || undefined,
organization: data.org || undefined,
postalCode: data.postal || undefined,
}
if (data.loc) {
const [lat, lng] = data.loc.split(',').map((value) => parseFloat(value))
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
result.latitude = lat
result.longitude = lng
}
}
return result
} catch (error) {
console.error('lookupGeoForIp: unexpected error', error)
return null
}
}
ipinfo’s free tier gives city, region, country, timezone, hostname, ISP, and even coordinates. I cache responses for an hour to avoid hammering the endpoint when the same bot keeps hitting the form. The helper quietly skips loopback addresses and logs a warning if the token is missing so deployments don’t fail silently.
Step 3: Score the IP with AbuseIPDB
The second piece of the puzzle is reputation. AbuseIPDB has a generous free quota and the API is dead simple. I wrapped it in another helper that returns a normalized object.
// File: src/lib/brevo/abuseipdb.ts
import 'server-only'
type AbuseIpResponse = {
ipAddress: string
isPublic: boolean
ipVersion: number
isWhitelisted: boolean
abuseConfidenceScore: number
countryCode?: string
usageType?: string
isp?: string
domain?: string
hostnames?: string[]
isTor: boolean
totalReports: number
numDistinctUsers: number
lastReportedAt?: string
}
export interface AbuseLookupResult {
ipAddress: string
isPublic: boolean
isWhitelisted: boolean
abuseConfidenceScore: number
usageType?: string
isp?: string
domain?: string
hostnames?: string[]
isTor: boolean
totalReports: number
numDistinctUsers: number
lastReportedAt?: string
countryCode?: string
}
export async function lookupAbuseForIp(ipAddress: string | undefined): Promise<AbuseLookupResult | null> {
if (!ipAddress) return null
const apiKey = process.env.ABUSEIP_DB_API_KEY
if (!apiKey) {
console.warn('lookupAbuseForIp: ABUSEIP_DB_API_KEY not configured')
return null
}
try {
const url = new URL('https://api.abuseipdb.com/api/v2/check')
url.searchParams.set('ipAddress', ipAddress)
const response = await fetch(url.toString(), {
headers: {
Key: apiKey,
Accept: 'application/json',
},
next: { revalidate: 3600 },
})
if (!response.ok) {
console.error('lookupAbuseForIp: request failed', {
status: response.status,
statusText: response.statusText,
})
return null
}
const payload: { data?: AbuseIpResponse } = await response.json()
if (!payload.data) return null
const data = payload.data
return {
ipAddress: data.ipAddress,
isPublic: data.isPublic,
isWhitelisted: data.isWhitelisted,
abuseConfidenceScore: data.abuseConfidenceScore,
usageType: data.usageType,
isp: data.isp,
domain: data.domain,
hostnames: data.hostnames,
isTor: data.isTor,
totalReports: data.totalReports,
numDistinctUsers: data.numDistinctUsers,
lastReportedAt: data.lastReportedAt,
countryCode: data.countryCode,
}
} catch (error) {
console.error('lookupAbuseForIp: unexpected error', error)
return null
}
}
The abuseConfidenceScore ranges from 0–100. Anything over ~50 is sketchy, but the goal here is to use the score as a trigger—not just to log it.
Step 4: Codify the diversion threshold
Once I saw how conservative AbuseIPDB scoring is, I picked a threshold of 70 to separate obviously malicious hosts from occasional noise. I also decided that any lookup failure should be treated as suspicious and sent to manual review. That logic now lives in a small helper so the newsletter action and comment opt-in can share it.
// File: src/lib/newsletter/spam.ts
import type { AbuseLookupResult } from '@/lib/brevo/abuseipdb'
export const SPAM_SCORE_THRESHOLD = 70
export type SpamDiversionReason = 'abuse-score-high' | 'abuse-lookup-failed'
export interface SpamAssessment {
action: 'allow' | 'divert'
reason?: SpamDiversionReason
abuseConfidenceScore?: number
}
type AbuseConfidenceCarrier = Pick<AbuseLookupResult, 'abuseConfidenceScore'> | null | undefined
export function assessSubscriptionRisk(
abuseResult: AbuseConfidenceCarrier
): SpamAssessment {
if (!abuseResult || typeof abuseResult.abuseConfidenceScore !== 'number') {
return {
action: 'divert',
reason: 'abuse-lookup-failed',
}
}
if (abuseResult.abuseConfidenceScore >= SPAM_SCORE_THRESHOLD) {
return {
action: 'divert',
reason: 'abuse-score-high',
abuseConfidenceScore: abuseResult.abuseConfidenceScore,
}
}
return {
action: 'allow',
abuseConfidenceScore: abuseResult.abuseConfidenceScore,
}
}
Keeping the decision in one place makes it easy to tune the threshold later and gives me a consistent reason code (abuse-score-high versus abuse-lookup-failed) when I inspect diverted submissions.
Step 5: Extend the server action to branch on spam risk
With the helper in place, the newsletter action normalizes the IP, enriches it, runs the assessment, and only syncs to Brevo when the signup is clean. Suspicious signups now land in a separate Sanity collection for manual review.
// File: src/actions/subscribeToNewsletter.ts
'use server'
import { headers } from 'next/headers'
import { createEmailSubscription, createSpamEmailSubscription } from '@/lib/sanity/sanity-server'
import { addContactToBrevoList } from '@/lib/brevo/brevo'
import { verifyRecaptcha } from '@/lib/captcha/recaptcha'
import { lookupGeoForIp } from '@/lib/brevo/ip-geolocation'
import { lookupAbuseForIp } from '@/lib/brevo/abuseipdb'
import { assessSubscriptionRisk } from '@/lib/newsletter/spam'
import type { EmailSubscriptionInput } from '@/lib/sanity/sanity-server'
function normalizeIpAddress(candidate: string | undefined | null): string | undefined {
if (!candidate) return undefined
const trimmed = candidate.trim()
if (
trimmed.length === 0 ||
trimmed === '::1' ||
trimmed === '0.0.0.0' ||
trimmed.startsWith('127.') ||
trimmed === '::ffff:127.0.0.1'
) {
return undefined
}
return trimmed
}
async function subscribeToNewsletter(
previousState: { error?: string; success?: boolean } | null,
formData: FormData
) {
try {
const token = formData.get('recaptchaToken')
const action = formData.get('recaptchaAction')
const requestHeaders = await headers()
const forwardedFor = requestHeaders.get('x-forwarded-for')
const rawIpCandidate = forwardedFor?.split(',')[0]?.trim()
|| requestHeaders.get('x-real-ip')?.trim()
|| requestHeaders.get('cf-connecting-ip')?.trim()
|| requestHeaders.get('fastly-client-ip')?.trim()
|| requestHeaders.get('true-client-ip')?.trim()
|| requestHeaders.get('x-client-ip')?.trim()
|| requestHeaders.get('x-cluster-client-ip')?.trim()
const ipAddress = normalizeIpAddress(rawIpCandidate)
if (typeof token !== 'string' || token.length === 0) {
return { error: 'Security verification failed. Please refresh and try again.' }
}
const verification = await verifyRecaptcha({
token,
expectedAction: typeof action === 'string' && action.length > 0 ? action : 'newsletter_subscription',
remoteIp: ipAddress,
})
if (!verification.success) {
return {
error: verification.error || 'Security verification failed. Please try again later.',
}
}
const email = formData.get('email')
if (typeof email !== 'string' || email.length === 0) {
return { error: 'Email is required' }
}
const name = formData.get('name')
const source = formData.get('source')
const timezone = formData.get('timezone')
const language = formData.get('language')
const signupUrl = formData.get('signupUrl')
const nameStr = typeof name === 'string' && name.length > 0 ? name : undefined
const sourceStr = source?.toString() || 'homepage'
const timezoneStr = typeof timezone === 'string' && timezone.length > 0 ? timezone : undefined
const languageStr = typeof language === 'string' && language.length > 0 ? language : undefined
let signupUrlStr: string | undefined
if (typeof signupUrl === 'string' && signupUrl.length > 0) {
try {
signupUrlStr = new URL(signupUrl).toString()
} catch {
signupUrlStr = undefined
}
}
const geoResult = await lookupGeoForIp(ipAddress)
if (ipAddress && !geoResult) {
console.warn('Geo lookup did not return location data', { ipAddress })
}
const abuseResult = await lookupAbuseForIp(ipAddress)
if (ipAddress && !abuseResult) {
console.warn('Abuse IP lookup did not return data', { ipAddress })
}
const spamAssessment = assessSubscriptionRisk(abuseResult)
const resolvedTimezone = timezoneStr || geoResult?.timezone
const abuseLastReportedIso = abuseResult?.lastReportedAt
? new Date(abuseResult.lastReportedAt).toISOString()
: undefined
console.log('Newsletter subscription metadata resolved', {
email,
timezone: resolvedTimezone,
language: languageStr,
source: sourceStr,
ipProvided: Boolean(ipAddress),
geoEnriched: Boolean(geoResult),
abuseScore: abuseResult?.abuseConfidenceScore,
spamAction: spamAssessment.action,
spamReason: spamAssessment.reason,
})
const baseSubscription: EmailSubscriptionInput = {
email,
name: nameStr,
source: sourceStr,
timezone: resolvedTimezone,
language: languageStr,
signupUrl: signupUrlStr,
ipAddress,
ipHostname: geoResult?.hostname,
geoCity: geoResult?.city,
geoRegion: geoResult?.region,
geoCountry: geoResult?.country,
geoLatitude: geoResult?.latitude,
geoLongitude: geoResult?.longitude,
geoPostalCode: geoResult?.postalCode,
geoOrganization: geoResult?.organization,
abuseConfidenceScore: abuseResult?.abuseConfidenceScore,
abuseIsPublic: abuseResult?.isPublic,
abuseIsWhitelisted: abuseResult?.isWhitelisted,
abuseUsageType: abuseResult?.usageType,
abuseIsp: abuseResult?.isp,
abuseDomain: abuseResult?.domain,
abuseHostnames: abuseResult?.hostnames,
abuseIsTor: abuseResult?.isTor,
abuseTotalReports: abuseResult?.totalReports,
abuseNumDistinctUsers: abuseResult?.numDistinctUsers,
abuseLastReportedAt: abuseLastReportedIso,
abuseCountryCode: abuseResult?.countryCode,
}
if (spamAssessment.action === 'divert') {
const spamResult = await createSpamEmailSubscription({
...baseSubscription,
reason: spamAssessment.reason ?? 'abuse-lookup-failed',
})
if (!spamResult.success) {
return { error: spamResult.error || 'Failed to record newsletter signup' }
}
return { success: true }
}
const sanityResult = await createEmailSubscription(baseSubscription)
if (!sanityResult.success) {
return { error: sanityResult.error || 'Failed to subscribe to newsletter' }
}
const brevoResult = await addContactToBrevoList({
email,
name: nameStr,
source: sourceStr,
timezone: resolvedTimezone,
language: languageStr,
signupUrl: signupUrlStr,
ipAddress,
geoCity: geoResult?.city,
geoRegion: geoResult?.region,
geoCountry: geoResult?.country,
geoLatitude: geoResult?.latitude,
geoLongitude: geoResult?.longitude,
}, 7)
if (!brevoResult.success) {
console.error('Brevo sync failed, but Sanity creation succeeded:', brevoResult.error)
}
return { success: true }
} catch (error) {
console.error('Failed to subscribe to newsletter:', error)
return { error: 'Failed to subscribe to newsletter. Please try again later.' }
}
}
export default subscribeToNewsletter
The safe path hasn’t changed: clean contacts go into the emailSubscription collection and sync to Brevo immediately. The interesting bit is the early return—anything that failed the check is written to a spam holding area and never touches Brevo. The UI still shows a friendly “thanks” so attackers don’t get feedback.
Step 6: Persist diverted signups in their own Sanity collection
Spam that never surfaces is great, but I still want an audit trail so I can promote false positives. A second document type mirrors the primary subscription schema, adds a reason field, and gives reviewers room for notes.
// File: src/lib/sanity/schemaTypes/spamEmailSubscription.ts
import { defineField, defineType } from 'sanity'
export default defineType({
name: 'spamEmailSubscription',
title: 'Spam Email Subscription',
type: 'document',
fieldsets: [
{
name: 'metadata',
title: 'Signup Metadata',
options: { collapsible: true, collapsed: true },
},
{
name: 'abuseCheck',
title: 'IP Abuse Check',
options: { collapsible: true, collapsed: true },
},
{
name: 'review',
title: 'Review',
options: { collapsible: true, collapsed: false },
},
],
fields: [
defineField({ name: 'email', title: 'Email', type: 'string', validation: (Rule) => Rule.required().email() }),
defineField({ name: 'name', title: 'Name', type: 'string' }),
defineField({ name: 'subscriptionDate', title: 'Subscription Date', type: 'datetime', initialValue: () => new Date().toISOString() }),
defineField({ name: 'source', title: 'Source', type: 'string', description: 'Where the subscription originated from' }),
defineField({ name: 'timezone', title: 'Timezone', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'language', title: 'Language', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'signupUrl', title: 'Signup URL', type: 'url', fieldset: 'metadata' }),
defineField({ name: 'ipAddress', title: 'IP Address', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'ipHostname', title: 'IP Hostname', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'geoCity', title: 'City', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'geoRegion', title: 'Region', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'geoCountry', title: 'Country', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'geoLatitude', title: 'Latitude', type: 'number', fieldset: 'metadata' }),
defineField({ name: 'geoLongitude', title: 'Longitude', type: 'number', fieldset: 'metadata' }),
defineField({ name: 'geoPostalCode', title: 'Postal Code', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'geoOrganization', title: 'Organization', type: 'string', fieldset: 'metadata' }),
defineField({ name: 'abuseConfidenceScore', title: 'Abuse Confidence Score', type: 'number', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseIsPublic', title: 'Is Public IP', type: 'boolean', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseIsWhitelisted', title: 'Is Whitelisted', type: 'boolean', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseUsageType', title: 'Usage Type', type: 'string', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseIsp', title: 'ISP', type: 'string', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseDomain', title: 'Domain', type: 'string', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseHostnames', title: 'Hostnames', type: 'array', of: [{ type: 'string' }], fieldset: 'abuseCheck' }),
defineField({ name: 'abuseIsTor', title: 'TOR Exit Node', type: 'boolean', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseTotalReports', title: 'Total Reports', type: 'number', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseNumDistinctUsers', title: 'Distinct Reporters', type: 'number', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseLastReportedAt', title: 'Last Reported At', type: 'datetime', fieldset: 'abuseCheck' }),
defineField({ name: 'abuseCountryCode', title: 'Country Code', type: 'string', fieldset: 'abuseCheck' }),
defineField({
name: 'reason',
title: 'Spam Reason',
type: 'string',
fieldset: 'review',
options: {
list: [
{ title: 'Abuse score above threshold', value: 'abuse-score-high' },
{ title: 'Abuse lookup failed', value: 'abuse-lookup-failed' },
],
},
validation: (Rule) => Rule.required(),
}),
defineField({ name: 'notes', title: 'Reviewer Notes', type: 'text', fieldset: 'review' }),
],
preview: {
select: {
title: 'email',
subtitle: 'subscriptionDate',
},
},
})
This lives alongside emailSubscription in src/lib/sanity/schemaTypes/index.ts, so Sanity Studio exposes both collections. When I spot a legitimate signup stuck in the spam queue, I can copy the record into the primary collection, note the decision, and trigger Brevo manually.
Persisting the documents only takes a thin wrapper in the Sanity server helpers:
// File: src/lib/sanity/sanity-server.ts
export interface SpamEmailSubscriptionInput extends EmailSubscriptionInput {
reason: 'abuse-score-high' | 'abuse-lookup-failed'
notes?: string
}
export async function createSpamEmailSubscription(data: SpamEmailSubscriptionInput) {
try {
const result = await sanityClient.create({
_type: 'spamEmailSubscription',
email: data.email,
name: data.name,
source: data.source || 'unknown',
subscriptionDate: new Date().toISOString(),
timezone: data.timezone,
language: data.language,
signupUrl: data.signupUrl,
ipAddress: data.ipAddress,
ipHostname: data.ipHostname,
geoCity: data.geoCity,
geoRegion: data.geoRegion,
geoCountry: data.geoCountry,
geoLatitude: data.geoLatitude,
geoLongitude: data.geoLongitude,
geoPostalCode: data.geoPostalCode,
geoOrganization: data.geoOrganization,
abuseConfidenceScore: data.abuseConfidenceScore,
abuseIsPublic: data.abuseIsPublic,
abuseIsWhitelisted: data.abuseIsWhitelisted,
abuseUsageType: data.abuseUsageType,
abuseIsp: data.abuseIsp,
abuseDomain: data.abuseDomain,
abuseHostnames: data.abuseHostnames,
abuseIsTor: data.abuseIsTor,
abuseTotalReports: data.abuseTotalReports,
abuseNumDistinctUsers: data.abuseNumDistinctUsers,
abuseLastReportedAt: data.abuseLastReportedAt,
abuseCountryCode: data.abuseCountryCode,
reason: data.reason,
notes: data.notes,
})
return { success: true, data: result }
} catch (error: any) {
console.error('Error creating spam email subscription:', error)
return { success: false, error: error.message || 'Failed to store spam subscription' }
}
}
The input type extends the existing EmailSubscriptionInput, so callers can pass the same metadata regardless of the path.
Step 7: Map the attributes for Brevo (only when clean)
The clean path still feeds Brevo the attributes it expects. The difference now is that this mapper only executes after the spam gate, so Brevo never sees the quarantined contacts.
// File: src/lib/brevo/brevo.ts
import { ContactsApi, ContactsApiApiKeys } from '@getbrevo/brevo'
const brevoApiKey = process.env.BREVO_API_KEY
const contactsApi: ContactsApi | null = brevoApiKey ? new ContactsApi() : null
if (contactsApi && brevoApiKey) {
contactsApi.setApiKey(ContactsApiApiKeys.apiKey, brevoApiKey)
} else {
console.warn('BREVO_API_KEY environment variable is not configured; Brevo sync disabled.')
}
interface BrevoContact {
email: string
name?: string
source?: string
timezone?: string
language?: string
signupUrl?: string
ipAddress?: string
geoCity?: string
geoRegion?: string
geoCountry?: string
geoLatitude?: number
geoLongitude?: number
}
export async function addContactToBrevoList(contact: BrevoContact, listId: number = 7) {
if (!contactsApi) {
console.warn('Skipping Brevo sync because BREVO_API_KEY is missing.', {
email: contact.email,
listId,
})
return {
success: true,
data: { message: 'Brevo integration disabled' },
}
}
try {
const attributes: Record<string, string> = {}
if (contact.name) {
const nameParts = contact.name.trim().split(' ')
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(' ').trim()
if (firstName) {
attributes.FIRSTNAME = firstName
}
if (lastName) {
attributes.LASTNAME = lastName
}
}
if (contact.source) {
attributes.SOURCE = contact.source
}
if (contact.timezone) {
attributes.CONTACT_TIMEZONE = contact.timezone
}
if (contact.language) {
attributes.LANGUAGE = contact.language
}
if (contact.signupUrl) {
attributes.SIGNUP_PAGE = contact.signupUrl
}
if (contact.ipAddress) {
attributes.SIGNUP_IP = contact.ipAddress
}
if (contact.geoCity) {
attributes.CITY = contact.geoCity
}
if (contact.geoRegion) {
attributes.REGION = contact.geoRegion
}
if (contact.geoCountry) {
attributes.COUNTRY = contact.geoCountry
}
if (typeof contact.geoLatitude === 'number') {
attributes.LOCATION_LAT = String(contact.geoLatitude)
}
if (typeof contact.geoLongitude === 'number') {
attributes.LOCATION_LNG = String(contact.geoLongitude)
}
const brevoContact = {
email: contact.email,
attributes,
listIds: [listId],
updateEnabled: true // This handles existing contacts gracefully
}
const result = await contactsApi.createContact(brevoContact)
return { success: true, data: result.body }
} catch (error: any) {
console.error('❌ Failed to add contact to Brevo:', {
email: contact.email,
error: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
response: error.response?.body || error.response?.data,
fullError: error
})
if (error.response?.body?.code === 'duplicate_parameter') {
console.log('Contact already exists in Brevo, this is okay')
return { success: true, data: { message: 'Contact already exists' } }
}
if (error.response?.status === 401) {
console.error('Brevo API authentication failed - check API key validity')
}
return {
success: false,
error: error.message || 'Failed to add contact to Brevo'
}
}
}
This still matches Brevo’s attribute table exactly: timezone, language, signup URL, source, IP, city, region, country, and coordinates. Pairing that with the clean Sanity record keeps my audience list pristine and ready for nurture sequences.
As a bonus, the same spam gating now protects the comment opt-in checkbox. The comment action reuses assessSubscriptionRisk, so spammy commenters don’t slip into the newsletter either.
Wrapping up
When the audience crossed four digits, the spammer traffic followed. Instead of scrapping the stack, I layered in intelligence: browser hints, ipinfo geolocation, AbuseIPDB scoring, a shared spam assessment helper, and a Sanity spam queue that keeps Brevo spotless. If you follow the same steps, your newsletter pipeline can welcome legitimate readers while silently diverting the garbage.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija