A cat using a computer

Fix Resend 403 and 422 errors on an Astro contact form (Cloudflare Workers)


Turnstile can pass and your API route can still return 502 to the browser when Resend rejected the send. On this stack (Astro 6, @astrojs/cloudflare v13, src/pages/api/contact.ts), Resend’s 403 or 422 usually appears inside that 502 message body.

The form code is often fine. The fix is almost always domain verification, from address, or secrets on the Worker.

How you know it is Resend (not Turnstile)

Typical symptoms:

  • Browser shows something like Could not send email (403) or (422)
  • Server logs show POST /api/contact returned 502 (not 200)
  • Turnstile siteverify already returned success: true

In my handler I surface Resend’s message when present:

if (!res.ok) {
  const data = (await res.json()) as { message?: string };
  return Response.json(
    { error: `Could not send email (${res.status}). ${data.message ?? ''}` },
    { status: 502 },
  );
}

Read that message string first. It is more specific than the HTTP status alone.

Fix Resend 403 (forbidden)

403 almost always means the API key or sending domain is not allowed to send as from.

1) Verify the domain in Resend

In the Resend dashboard, add your domain (e.g. mayfield.io) and complete DNS records (SPF, DKIM, and any required CNAMEs). Until the domain shows Verified, you cannot send from @mayfield.io addresses reliably.

2) Match CONTACT_FROM_EMAIL to a verified domain

I use:

CONTACT_FROM_EMAIL=contact@mayfield.io
CONTACT_TO_EMAIL=contact@mayfield.io

If CONTACT_FROM_EMAIL is still onboarding@resend.dev in production, or points at an unverified domain, Resend returns 403 even when RESEND_API_KEY is set.

3) Confirm RESEND_API_KEY is a Worker secret (not public)

On Cloudflare Workers, RESEND_API_KEY must be a secret binding, not a public var. A typo here sometimes looks like “email not configured” (503) instead of 403, but when the key is present and wrong-scope, 403 is common.

Redeploy after changing secrets:

npx wrangler secret put RESEND_API_KEY
npm run deploy

4) Test with curl against your API

After Turnstile is configured, test the full path from production (or temporarily bypass Turnstile in dev only):

curl -i -X POST "https://mayfield.io/api/contact" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"you@example.com","message":"Resend probe from curl","turnstileToken":"VALID_TOKEN"}'

If you get 502 with 403 in the body text, stay on Resend config—not the Astro form markup.

Fix Resend 422 (validation error)

422 means Resend understood the request but rejected the payload shape or values.

Common causes:

Resend complaintFix
Invalid from formatUse Name <email@domain.com> or a bare verified address
to not an arrayResend expects "to": ["you@example.com"]
Unverified sender domainFinish DNS in Resend
Missing required fieldsEnsure subject, text or html, from, to are all set

My send payload keeps the shape boring on purpose:

body: JSON.stringify({
  from: `mayfield.io contact <${fromAddress}>`,
  to: [to],
  reply_to: `${payload.name} <${payload.email}>`,
  subject: `Contact from ${payload.name}`,
  text: `From: ${payload.name} <${payload.email}>\n\n${payload.message}`,
}),

If you add html, make sure you still send text for clients that prefer plain text.

Checklist I run before blaming Astro

  1. Domain Verified in Resend (not “pending”)
  2. CONTACT_FROM_EMAIL uses that domain
  3. RESEND_API_KEY set in production secrets (and locally in .dev.vars for Wrangler)
  4. Redeployed after secret changes
  5. Read the JSON error message from one failed curl

Once those align, Resend tends to stay quiet for months.

Turnstile + Resend complete setup · Fix Turnstile verification failed · Local .dev.vars vs production secrets


Hero photo: Cat using computer, public domain.