MainSDK
Search⌘K
Integrations

Resend

Use Resend for MainSDK email and the notify email channel - domain verification, API keys, main.yaml, local testing and swapping.

2 interfaces2 driversWritten guide
Updated 2026-10-28

Resend is the default provider behind email. Your app sends receipts, sign-up confirmations and newsletters with email.send() and email.marketing(), and Resend delivers them from your own domain. The same driver carries the email channel of notify when a person is best reached by email. Pick Resend when you want the default path with the least setup; pick Amazon SES when you already run on AWS and want sending inside your AWS account.

Interfaces it serves

Pick Resend in main.yaml for any of these. Your calls stay the same, and you can swap later.

Before you start

You need a Resend account and a domain you can add DNS records to.

  1. Add your sending domain. In the Resend dashboard, add the domain (or a subdomain such as mail.pinecart.dev) your app sends from. Resend shows the DNS records it needs, typically for SPF and DKIM. Using a subdomain keeps your app's mail apart from your team's own mail.
  2. Add the DNS records at your DNS host and wait for Resend to show the domain as verified. Mail from an unverified domain is refused. A DMARC record on the parent domain is good practice for deliverability.
  3. Create an API key. In the API keys area, create a key for this app. Resend lets you limit a key to sending only, and to one domain; use the narrowest key that works.
  4. Use separate keys per environment. A key for development and stage, another for production, so you can revoke one without touching the other.

Store the keys in your secrets manager, never in code or a committed env file.

Configure

main.yaml
email:
  driver: resend
  key: secret:RESEND_API_KEY              # the Resend API key
  from: "Pinecart <hello@mail.pinecart.dev>"   # on the domain you verified
  reply-to: support@pinecart.dev
  webhook: resend-events                  # bounces and complaints, see below
  tracking:
    opens: false                          # the default: no tracking pixel
    clicks: false                         # the default: no rewritten links
webhooks:
  inbound:
    resend-events:
      preset: standard                    # Resend signs webhooks with Svix, which follows Standard Webhooks
      secret: secret:RESEND_WEBHOOK_SECRET
      path: /api/webhooks/resend

Put the production key in an environment overlay so only the difference changes:

main.env.prod.yaml
email:
  key: secret:RESEND_PROD_API_KEY   # suggested name

A literal key in main.yaml fails to load with MAIN-CONFIG-0004. Without email.key, the driver reads secret:RESEND_API_KEY.

What the driver sends

Each message is one POST https://api.resend.com/emails with Authorization: Bearer <key> and an Idempotency-Key header. The JSON body holds from, to, subject, text, html when the template has one, reply_to, headers (the List-Unsubscribe pair on marketing mail), and tags for the template, the kind (transactional or marketing), the campaign and the tenant. The call goes through MainSDK's network layer, does not follow redirects, and reads at most 64 KiB of the answer. A 429, a 5xx or no answer is a retryable MAIN-EMAIL-0004, so a fallback driver is tried; any other refusal is not retryable. The message and the provider's reply are not put in the error.

Bounces and complaints

Add a webhook in the Resend dashboard that points at your path with the events email.bounced, email.complained and email.delivered, and put its signing secret in secret:RESEND_WEBHOOK_SECRET. MainSDK checks the signature, then reads each event: a permanent bounce (any type but Transient) and a complaint add the address to the suppression list, a transient bounce does not, and a delivery is only reported to email.on listeners. The tags tie each event to its campaign and tenant. Without email.webhook in production, mainsdk check warns with MAIN-EMAIL-0010.

To reach people by email through notify, add email to the channel list. The email channel uses your email block, so there is nothing else to configure:

main.yaml
notify:
  channels: [whatsapp, sms, email]   # email is tried last

Use it

Send a transactional email. The idempotency key ties it to the order, so a retried action sends once.

app/orders/receipt.ts
import { email } from "mainsdk";

export async function sendReceipt(user: { email: string }, orderId: string) {
  const { id } = await email.send({
    to: user.email,
    template: "receipt",
    data: { orderId },
    idempotencyKey: `receipt:${orderId}`,
  });
  return id;
}

Notify a person on whichever channel they allow; Resend delivers it when email is the channel chosen.

app/orders/ship.ts
import { notify } from "mainsdk";

await notify(user, {
  template: "order.shipped",
  data: { orderId: order.id },
  category: "orders",
});

Local development and testing

  • No Resend account: mainsdk dev sends every email to the local inbox, where you can see the rendered message and its headers.
  • Resend before your domain is ready: Resend provides test recipient addresses that simulate delivered, bounced and complained outcomes, which is a safe way to see how bounces reach your suppression list.
  • Tests: createTestApp() from @mainsdk/testing runs email on the local mailbox, which enforces the unsubscribe rule and the suppression list. mailbox(app).failNext(1) simulates an outage, for example to prove a fallback to SES. fakeMessagingServer("resend") records the request the real driver makes, with no network.
app/orders/receipt.test.ts
import { it } from "node:test";
import { createTestApp, mailbox } from "@mainsdk/testing";
import { sendReceipt } from "./receipt";

it("sends one receipt", async () => {
  const app = createTestApp({ main: "./main.yaml" });
  await sendReceipt({ email: "a@pinecart.dev" }, "ord_1");
  mailbox(app).toHaveSent({ to: "a@pinecart.dev", template: "receipt", times: 1 });
});

mainsdk check --env prod fails with MAIN-CHECK-0026 if email still resolves to the local driver.

What MainSDK adds on top

  • Marketing email does not compile without an unsubscribe, and every marketing message carries one-click List-Unsubscribe headers.
  • Unsubscribes, hard bounces and complaints go to a suppression list that every later send checks, whichever provider sends.
  • No open-tracking pixels or rewritten links unless you turn them on in main.yaml.
  • A retry with the same idempotency key never sends twice.

See Email for the full contract.

Limits and notes

  • The email:resend driver is built (spec email-notify). notify has no Resend driver of its own: its email channel uses your email block.
  • Tracking: with tracking.opens off, MainSDK removes tracking pixels from the HTML before it reaches Resend.
  • Resend sends from the regions it offers for each domain. Check that they fit your MainSDK data region (US, EU or India).
  • The Resend driver declares its own host (api.resend.com), so your network.allow list doesn't change. The signed webhook path is exempt from CSRF automatically.
  • email.raw() returns the Resend client for features email does not wrap. mainsdk check lists every use.

Swap to or from it

main.yaml
email:
  driver: ses   # was resend

Point key at the new provider's credential. Email is stateless: your calls, templates and the suppression list stay the same, and mainsdk swap can run the contract tests and a shadow send before switching (see Swap a driver). Verify your sending domain with the new provider first, or its sends are refused. To fail over instead of swapping, list fallback: { email: [resend, ses] }.

FAQ

Do I need a Resend account to start building? No. mainsdk dev uses the local inbox and tests use the in-memory fake. You need an account and a verified domain to send real mail.

Can I track opens with Resend? Yes, by setting tracking.opens: true in main.yaml. It is off by default, and mainsdk info lists it when on.

Does Resend keep my unsubscribe list? You don't rely on it. MainSDK keeps the suppression list and checks it before any send reaches Resend, so it survives a provider swap.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet