All pages

Resend

Four emails ship: the purchase receipt, the warning a subscriber gets when a renewal is declined, the notice that their access has ended, and the reminder before a trial converts. 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.

  1. 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.dev without a domain, but only to the address you signed up with. That is enough to see the templates render and not enough to mail a real customer.

  2. 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_...
  3. 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.

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 four emails

Template Sent from Subject
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 receipt uses 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

Updated