Skip to main content
Getting started File 005

Moving from Resend

Move a Resend app to Postboi in steps, keeping Resend as the provider for as long as you like. Field mapping, webhooks, domains, and what doesn't carry over.

You can move in three steps, and each one ships on its own. Stop after any of them.

  1. Swap the SDK, keep Resend. Replace resend.emails.send() with mail() and set the provider to resend. Your mail still goes out through your Resend account, on your Resend plan, from your Resend domain. Nothing about delivery changes.
  2. Move sending to Postboi. bunx postboi migrate resend brings your domains, audiences and webhooks across, then you change one line of config. Your mail() calls stay the same.
  3. Retire Resend. Check your webhooks, carry your suppressions over, and take Resend’s DNS records down once nothing sends through it.

If you’re starting fresh rather than moving, Quick start is quicker.

1. Swap the SDK, keep Resend

Install Postboi and tell it to use Resend. bunx postboi init asks which provider you send with: pick bring your own provider, then Resend, and it asks for your Resend API key and writes the config. By hand it’s one file:

// postboi.config.ts
import { config } from 'postboi'

export default config({
	provider: 'resend',
	default: { from: 'Acme <hello@acme.com>' },
})

RESEND_API_KEY stays in your environment, where it already is.

Then change the sends. Before:

import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

const { data, error } = await resend.emails.send(
	{
		from: 'Acme <hello@acme.com>',
		to: ['ada@example.com'],
		subject: 'Your receipt',
		html: '<p>Thanks for your order.</p>',
		replyTo: 'support@acme.com',
		tags: [{ name: 'kind', value: 'receipt' }],
	},
	{ idempotencyKey: 'receipt/order-42' }
)
if (error) throw new Error(error.message)

After:

import { mail } from 'postboi'

const { id } = await mail({
	to: 'ada@example.com',
	subject: 'Your receipt',
	body: '<p>Thanks for your order.</p>',
	reply_to: 'support@acme.com',
	tags: ['receipt'],
	idempotency_key: 'receipt/order-42',
})

from comes from the config default, and the plain-text part is written from body for you, as Resend does from html.

Errors throw. Resend’s Node SDK hands back { data, error } and never throws. mail() returns { id } on success and throws a PostboiError on failure, with the provider’s own error name in code. If you checked error after every send, that becomes a try/catch, or nothing at all where a failed send should fail the request anyway.

Field by field

Resend (Node SDK) mail() Notes
from from Or set it once as default.from in the config
to, cc, bcc to, cc, bcc A string or an array, as before
replyTo reply_to
subject subject
html body
text text Written from body when you leave it out
react body: render(<Email />) See React Email below
headers headers
attachments: [{ filename, content }] attachments: [new File([content], filename)] Standard File objects
tags: [{ name, value }] tags: ['value'] Plain strings, using only the letters, numbers, _ and - Resend allows
scheduledAt scheduled_at A Date, an ISO 8601 string or { days: 1 }, not 'in 1 hour'
{ idempotencyKey } (second argument) idempotency_key In the message itself
resend.emails.cancel(id) cancel(id) import { cancel } from 'postboi'
resend.batch.send([...]) mail([...]) One result per message, never throws. See bulk sending
template: { id, variables } No equivalent Resend-hosted templates live in Resend. Render to HTML in your code instead
topicId No equivalent Postboi’s lists handle opt-in
attachments[].contentId (inline cid: images) Not carried over Link images by URL instead

A batch where everyone gets the same email with a few values swapped is shorter than Resend’s, and on Resend it still goes out as one request to its batch endpoint:

await mail({
	to: ['ada@example.com', 'linus@example.com'],
	subject: 'Hey {name}',
	body: '<p>Hi {name}</p>',
	data: {
		'ada@example.com': { name: 'Ada' },
		'linus@example.com': { name: 'Linus' },
	},
})

React Email

Resend takes a React element in react. Postboi takes HTML, so render it first with React Email’s own render(). The template doesn’t change:

import { render } from '@react-email/render'
import { mail } from 'postboi'
import Welcome from './emails/welcome'

await mail({
	to: user.email,
	subject: 'Welcome to Acme',
	body: render(<Welcome name={user.name} />),
})

body takes the promise render() returns, so there’s no need to await it.

2. Move sending to Postboi

Once your app sends through mail(), moving off Resend is configuration.

Get a Postboi account. Run bunx postboi init and pick Postboi. It signs you in and writes a POSTBOI_TOKEN.

Bring your Resend account across. One command reads your Resend account with your RESEND_API_KEY and recreates what Postboi can hold:

bunx postboi migrate resend --dry-run   # see what it would do
bunx postboi migrate resend
  • Domains are added to Postboi and their DNS records printed. Publish the three DKIM CNAME records. They sit beside Resend’s rather than replacing them: Resend signs with resend._domainkey and uses a send. subdomain for bounces, while Postboi uses its own DKIM names and a bounce. subdomain. Both can be live at once, so nothing you send through Resend is affected while the domain verifies. Your DMARC record stays as it is.
  • Audiences become lists. Contacts arrive subscribed, so nobody is asked to confirm again, and anyone who unsubscribed on Resend stays unsubscribed.
  • Webhooks are registered at the same URLs with the same events in Postboi’s names, each with a new signing secret. See webhooks below.

Running it twice is safe: anything already on Postboi is skipped. If one thing is refused (a domain another account owns, say), the rest still moves and the command tells you what was left. The CLI page has the flags, including --only to move one kind at a time.

Switch the provider. Change the one line:

// postboi.config.ts
export default config({
	provider: 'postboi',
	default: { from: 'Acme <hello@acme.com>' },
})

That’s the whole change to your app. Every send above works as it did, and you now get delivery status per message, mail.messages.get(id), a message log and timeline in the dashboard, and postboi sync’s types, which reject a from address your account can’t send from at build time.

Two things to know first:

  • Limits are per plan. The free plan sends 3,000 a month and 100 a day, the same as Resend’s free plan. Above that you need a paid plan before you switch production over. See limits.
  • A new domain earns its reputation. Mailbox providers judge a sending domain partly by its history. If you send in volume, move one kind of mail first (receipts, say) and the rest a few days later, rather than everything in one go.

Want to move gradually? Switch the provider per environment with POSTBOI_PROVIDER (per-environment config), or construct the two providers side by side and pick per send while you compare:

import Postboi from 'postboi/postboi'
import Resend from 'postboi/resend'

const postboi = new Postboi({ default: { from: 'hello@acme.com' } })
const resend = new Resend({ api_key: process.env.RESEND_API_KEY, default: { from: 'hello@acme.com' } })

const provider = kind === 'receipt' ? postboi : resend
await provider.send({ to, subject, body })

Or keep Resend doing the delivery. With your Resend key synced to Postboi, the dashboard’s Send via setting sends through your Resend account while Postboi keeps the log, suppressions and timeline. Your code is the same as step 2. See relay for what that does and doesn’t report back.

3. Retire Resend

Webhooks

postboi/webhooks reads Resend’s webhooks and Postboi’s into one event shape, so you can rewrite your handler once and point either provider at it:

import { webhook } from 'postboi/webhooks'

export const POST = webhook(async (event) => {
	if (event.type === 'bounced' && event.bounce?.category === 'hard') {
		await mark_undeliverable(event.email)
	}
})

While Resend is your provider, it verifies the signature with RESEND_WEBHOOK_SECRET, the whsec_… from Resend’s dashboard. migrate resend registered the same URLs on Postboi with new secrets, and bunx postboi sync writes the secret to POSTBOI_WEBHOOK_SECRET. Once you’ve switched, the handler doesn’t change.

Resend event event.type
email.sent sent
email.delivered delivered
email.delivery_delayed delayed
email.bounced bounced
email.complained complained
email.opened opened
email.clicked clicked
email.failed failed

Resend’s email.scheduled, email.suppressed and email.received, and its contact.*, domain.* and topic.* events, aren’t read from Resend. On Postboi, a reply arrives as a received event (see replies). Postboi doesn’t send webhooks for delayed, scheduled or suppressed mail, so migrate resend leaves those events off and says so. A delay shows on the message’s timeline instead.

Lists and contacts

migrate resend moved your audiences. Broadcasts on Postboi are ordinary sends on your plan, with no separate charge per contact. See lists and broadcasts.

Suppressions

Addresses Resend has suppressed for hard bounces or complaints should stay suppressed, and Resend’s API doesn’t export them, so the command can’t move them. Add the ones you know about before your first send from Postboi with mail.suppressions.add().

DNS

When nothing has sent through Resend for a while, remove Resend’s resend._domainkey record and its send. subdomain records. Leave Postboi’s DKIM records and your DMARC record alone.

What carries over and what doesn’t

Moves with your code: recipients, subject, HTML and text, attachments, headers, tags, scheduling and cancelling, idempotency keys, batches, and webhook handling. All of it is the same mail() call on either provider.

Moves with migrate resend: domains, audiences and their contacts, and webhooks.

Stays in Resend: hosted templates and their variables, topics, inline cid: images, API keys, past broadcasts and the suppression list.

Postboi doesn’t have yet: SDKs in languages other than TypeScript (anything else talks to the REST API), an SMTP relay for sending into Postboi, and dedicated IPs. If you depend on one of those, keep Resend as your provider and use step 1 alone. The comparison has the rest.