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/contactreturned 502 (not 200) - Turnstile
siteverifyalready returnedsuccess: 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 complaint | Fix |
|---|---|
Invalid from format | Use Name <email@domain.com> or a bare verified address |
to not an array | Resend expects "to": ["you@example.com"] |
| Unverified sender domain | Finish DNS in Resend |
| Missing required fields | Ensure 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
- Domain Verified in Resend (not “pending”)
CONTACT_FROM_EMAILuses that domainRESEND_API_KEYset in production secrets (and locally in.dev.varsfor Wrangler)- Redeployed after secret changes
- Read the JSON error
messagefrom one failed curl
Once those align, Resend tends to stay quiet for months.
Related posts
Turnstile + Resend complete setup · Fix Turnstile verification failed · Local .dev.vars vs production secrets
Hero photo: Cat using computer, public domain.