Four emails ship: the fulfillment invite, the purchase receipt, the warning a subscriber gets when a renewal is declined, and the notice that their access has ended. All four are React Email templates rendered by Resend.
You will leave with an API key and a verified From: address. Skip this page entirely and the app still runs, with every send logged as email.skipped and nothing thrown.
After you grant account access, the agent can use Resend's CLI or API to inventory domains, create the sending domain, inspect its DNS records, trigger verification, and create a sending-scoped key. You retain control of account login and DNS ownership. If the DNS provider cannot be controlled through an available tool, adding the returned records is the human handoff.
-
Create an account and add a domain
Sign in at resend.com, add your sending domain, and add the DNS records it gives you at your registrar. Verification takes as long as your DNS propagation does.
You can send from
onboarding@resend.devwithout a domain, but only to the address you signed up with. That is enough to see the templates render and not enough to fulfill a real purchase. -
Create an API key
Under API Keys, create one with sending permission and copy it. It starts
re_and is shown once.# apps/api/.env RESEND_API_KEY=re_... -
Set the From address
EMAIL_FROM=Your Product <hello@yourdomain.com>It goes out verbatim, display name included. It has to be at a domain you verified in the previous step, or Resend rejects the send with
invalid_from_address.This is a variable rather than a constant because the reference implementation hard-coded a personal domain, so every fork sent mail as somebody else. It is also why the templates carry no brand name in their copy: the sender identity lives in this one value.
Both values or neither
RESEND_API_KEY and EMAIL_FROM are read together. A deployment holding a key and no From: address is not configured either, and letting it through trades a clear 501 for Resend's own rejection on your first sale.
The client is constructed lazily rather than at import, so an unconfigured deployment boots normally and only finds out when something tries to send.
A send failure is reported, never thrown
type SendResult =
| { sent: true; id: string }
| { sent: false; reason: 'not_configured' | 'send_failed' }
Every caller is a side effect of something that already happened. The receipt and the past-due warning are sent from the Stripe webhook: a throw there fails the webhook, Stripe redelivers, and the event it reports has already happened, so the retry cannot fix the mail and can only re-run everything around it. The invite is sent from fulfillment, which is guarded by an atomic claim, so a throw would burn an attempt over a courtesy mail after the collaborator invite it announces already succeeded.
Swallowing the failure silently would be the other half of that mistake, so it lands in the log and in the return value. The two reasons stay separate because not_configured is a buyer who declined email and is not an incident, while send_failed is one.
The log line carries the mail's kind and the Resend id, never the recipient's address.
The five emails
| Template | Sent from | Subject |
|---|---|---|
Invite |
the fulfillment flush, after the GitHub invite succeeds | Your invitation to <owner>/<repo> |
Receipt |
the Stripe webhook, on charge.succeeded |
Your receipt for your purchase |
AccessAtRisk |
the Stripe webhook, on invoice.payment_failed |
Your payment failed, and your access ends unless it is fixed |
AccessEnded |
the Stripe webhook, on a customer.subscription.* event that writes canceled or unpaid |
Your access has ended |
TrialEnding |
the Stripe webhook, on customer.subscription.trial_will_end |
Your trial ends soon, and the first charge follows |
There is no welcome mail, and that is a decision rather than an omission. Clerk's verification email already lands on signup, and a second one in the same minute reads as a bug.
Renewal reminders, subscription-started and plan-changed mail are not coming at all, because they duplicate what Stripe sends well and none of them reports a change in access. docs/adr/0032-mail-follows-entitlement-not-invoices.md has the line each of those falls on.
Every mail is guarded by an atomic claim in email_deliveries, one row per kind and Stripe object. A redelivered charge.succeeded does not send a second receipt, invoice.payment_failed fires once per Smart Retry without sending four warnings, and a subscription that keeps emitting updates after it ended still mails one revocation. Stripe orders charge.succeeded and checkout.session.completed in neither direction, so whichever lands second sends the receipt, and exactly one of them does.
The invite and the receipt use the locale saved on the Purchase at checkout. A later
browser language change does not rewrite an already completed purchase or change the
language of its delayed email. The past-due warning, the
access-ended notice and the trial reminder read users.locale instead, because a
subscription has no Purchase row and those mails are about a person's access.
Keys you now hold
| Value | Goes in | As |
|---|---|---|
| API key | apps/api/.env |
RESEND_API_KEY |
| Sending address | apps/api/.env |
EMAIL_FROM |
Related
- Add an email, the recipe for a fifth one
- Fulfillment, where the invite is sent from
docs/adr/0014-two-tier-env-schema.md, why both values are optional- Resend CLI
- Resend domains