The verified-domain ceremony
Deliverability is infrastructure you stand up once and reuse forever. You do not re-prove who you are on every feature; you verify a domain once, and from then on every send the app makes — the welcome email this chapter ships, the verification email in the next unit, invitations, billing receipts, the notification dispatcher’s email channel — inherits the same authenticated identity for free. This lesson is that one-time stand-up. You will not write a single line of application code here. You will create a Resend account, hand Resend a sending subdomain, copy the DNS records it issues into your registrar, and wait for the domain to read Verified — the deliverability floor that gates every send in the rest of the chapter.
It lives in its own lesson for one reason: DNS propagation is the slowest, flakiest step in the whole project, and a stalled record is the single most common place students stall. By keeping it separate from the code, a record that takes twenty minutes to resolve never blocks you from writing the wrapper or the template — you kick off verification here, move on, and come back when it flips green. The chapter 048 lessons explain the why behind each record; the alignment rule, the SPF / DKIM / DMARC mechanics, the 2026 enforcement bar. This lesson is the doing, on your own domain.
One prerequisite carries over from the project overview, and it gates everything below: you need a cheap real domain. Resend’s onboarding@resend.dev sandbox sender is out — it cannot prove a DKIM signature on a domain you control, which is the entire point of the exercise. If you skipped that step, get a .com from Namecheap, Porkbun, or Cloudflare Registrar (around $8–12 a year), or use a subdomain of a domain you already own.
Create the Resend account and API keys
Section titled “Create the Resend account and API keys”Start at the Resend dashboard. If you already made an account while reading Resend and the first verified send, sign in and skip to the keys.
-
Sign up at resend.com with the email you want to administer the account from — this is the account owner, not the sender address.
-
Open API Keys and create two keys, not one: name the first
devand the secondproduction. Set the permission on both to Sending access only, not Full access. -
Copy the
devkey (it is shown once) into your password manager. You will paste it into.envin the next lesson, not here.
Two keys from day one is the discipline from Resend and the first verified send: one key per environment, scoped to sending only. A key that can only send is a key that, if it leaks out of a dev machine or a CI log, cannot read your domains, rotate other keys, or touch production config — it can do one thing, and you can revoke it without touching the production key. You will not need the production key until you deploy near the end of the course, but creating it now means you never reach for the dev key in production out of convenience.
Add the sending subdomain in Resend
Section titled “Add the sending subdomain in Resend”Now tell Resend which domain you send from.
-
Open Domains and click Add Domain.
-
Enter a subdomain, not your apex:
send.<your-domain>.<tld>— for examplesend.example.com, neverexample.com. -
Pick the region closest to you and confirm. Resend now shows you a table of DNS records to publish.
Sending from a subdomain rather than your root domain is the per-purpose isolation from the transactional subdomain split: a deliverability mistake on send. — a bad campaign, a spam complaint spike — never bleeds into the reputation of the apex your real mail and your website live on. Keep the split; do not add the apex here.
Resend generates a set of records scoped to that subdomain. The exact hostnames depend on what you entered, but the shapes look like this:
# SPF — authorizes Resend's servers to send for the subdomainType: TXTHost: sendValue: v=spf1 include:amazonses.com ~all
# DKIM — the public key receivers use to verify the signatureType: TXTHost: resend._domainkey.sendValue: p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... (one long string)
# MX — routes bounces and complaints back to Resend (optional but recommended)Type: MXHost: sendValue: feedback-smtp.us-east-1.amazonses.comPriority: 10The DKIM record uses the selector resend._domainkey — that prefix is how a receiver knows which public key to fetch when it checks the signature. Leave the values exactly as Resend prints them; you are about to copy them verbatim.
Publish the records at your registrar
Section titled “Publish the records at your registrar”Open the DNS panel for your domain — at Namecheap it is Advanced DNS, at Porkbun DNS Records, at Cloudflare the DNS tab. Add each record Resend issued, matching the type, host, and value exactly.
-
Add the SPF TXT record: type
TXT, the host Resend gave you, thev=spf1 ...value pasted whole. -
Add the DKIM TXT record: type
TXT, theresend._domainkey...host, and the longp=...key as one unbroken string. -
Add the MX record if Resend listed one: type
MX, the host, the value, and the priority (usually10). -
Save. Most registrars apply DNS changes within minutes.
Two failure modes account for almost every stalled verification, and both happen at this step.
The second trap is the host field, and it is the most common stall in this whole lesson. Registrars disagree on whether you type the host relative to your domain or as the absolute name, and getting it wrong puts the record at the wrong place where Resend will never find it.
Host: resend._domainkey.sendYou enter only the part in front of your domain. Most registrars append your domain automatically, so the saved record resolves to resend._domainkey.send.yourdomain.com.
Name: resend._domainkey.send.yourdomain.comType the whole thing, ending in your domain. Cloudflare and a few others want the fully-qualified name.
Pick by what your registrar’s UI shows, and verify by reading the saved record back — if it reads as the full name resend._domainkey.send.yourdomain.com either way, you got it right. There is no universal answer here. Whichever way you enter it, the record has to resolve to the fully-qualified name resend._domainkey.send.<your-domain>. The registrars’ own DNS guides, linked at the end of this lesson, screenshot the exact field for each provider.
Wait for verification
Section titled “Wait for verification”Back in Resend, open your domain’s page and click Verify. Resend re-checks DNS; the status flips to Verified once SPF and DKIM both resolve. This usually takes a few minutes, occasionally up to 24 hours depending on your registrar’s propagation.
If it has not flipped after about half an hour, do not keep clicking — diagnose it. A DKIM stall past thirty minutes is almost always one of the two traps from the last step: a truncated key, or the host at the wrong name. Read the record back from your own machine with dig and compare it to what Resend issued:
dig TXT resend._domainkey.send.example.com +shortA healthy response prints the full p=... key as one quoted string (long records may come back split into several quoted chunks on one line — that is normal, the resolver reassembles them):
"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..." "...rest of the key"An empty response means the record is not where you typed it — the host convention is wrong, so move it to the other form from the previous step. A response that prints a short or clipped value means the registrar truncated the key — re-paste it whole. Fix whichever it is, save, and click Verify again.
Publish the DMARC record
Section titled “Publish the DMARC record”Resend’s Verified badge covers SPF and DKIM. DMARC is the third record, and you add it yourself — it tells receivers what to do when a message fails the first two checks.
-
Add one more TXT record, this time at the apex, not the sending subdomain: host
_dmarc, which resolves to_dmarc.<your-domain>. -
Set the value to a monitoring-only policy:
v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; -
Save.
p=none is deliberately the weakest policy: it tells receivers to enforce nothing and just report. That is the right starting point — you observe how your mail authenticates in the wild before you ask receivers to quarantine or reject anything, which is exactly the progression Authenticating the sender: SPF, DKIM, DMARC lays out. This chapter ships at p=none and stays there.
The rua address is where aggregate reports land. For a side project, your own inbox is fine; production teams point it at a parsing service that turns the raw XML into dashboards.
The experienced habit here is to set a calendar reminder: after a week of clean aggregate reports, graduate the policy from p=none to p=quarantine, and later to p=reject. You will not do that today — p=none is what ships now — but the reminder is what turns a monitoring policy into an enforcing one instead of leaving it at none forever.
Confirm with a test send
Section titled “Confirm with a test send”The Verified badge says your records resolve. A real test send says the receiving inbox agrees — and that is the gate for the whole ceremony.
-
On the domain’s page in Resend, use Send test email and address it to your own personal inbox (a Gmail account is ideal for the next step).
-
Open the message in Gmail, click the three-dot menu, and choose Show original.
-
Read the authentication-results panel at the top. You are looking for exactly three lines: SPF: PASS, DKIM: PASS, DMARC: PASS.
Three PASS lines is the only acceptable outcome. If any line reads FAIL, NEUTRAL, or SOFTFAIL, the corresponding record is wrong or has not propagated — go back to the matching step, re-check the value with dig, and re-test. Do not move on with a failing line; every send in the rest of the chapter depends on this passing.
Match the seed placeholder to your domain
Section titled “Match the seed placeholder to your domain”One repo edit, and it is the only change to the codebase in this entire lesson. The seed ships a placeholder suppressed address on the acme.example domain; point it at your own so the suppression path you test in later lessons reads as a real address on your verified domain.
.values({ email: 'suppressed@send.acme.example', reason: 'complaint' });The starter ships the placeholder on the acme.example domain.
.values({ email: 'suppressed@send.example.com', reason: 'complaint' });Swap in your own verified subdomain. This is the single line you change.
Then, if you want the seeded row to match your domain, re-run the seed:
pnpm db:seedThis address never needs to be a real, deliverable mailbox. The suppression check fires at the application layer — inside the sendEmail wrapper you build next lesson — and short-circuits before Resend would ever attempt delivery. On the suppression path the destination is irrelevant: the wrapper sees the address is on the list and returns a forbidden result without making a network call at all. The point of matching it to your domain is purely so the address reads coherently when you exercise that path; nothing is ever sent there.
Where this leaves you
Section titled “Where this leaves you”No application code changed, and that was the goal. What you have now is the foundation every later send stands on:
- Your transactional subdomain
send.<your-domain>readsVerifiedin Resend. - SPF, DKIM, and DMARC are live at your registrar and confirmed by a real test send showing all three PASS lines in “Show original”.
- The
devAPI key is in your password manager, ready to drop into.env. - The seed’s suppressed address matches your domain.
In the next lesson you paste that key into .env, add the email entries to the src/env.ts schema so the server fails closed without them, and build src/lib/email.ts — the single send seam that reads the suppression list and requires an idempotency key before it ever calls Resend.
External resources
Section titled “External resources”Official reference for adding a domain, reading its status, and the SPF/DKIM/DMARC records Resend issues.
Screenshots the exact Advanced DNS fields for the relative-host registrar workflow this lesson uses.
The absolute-host convention from this lesson, shown in Cloudflare's DNS panel.
The authoritative primer on DMARC tag syntax and the p=none to p=reject rollout you set a reminder for.