Switch from Resend
Replace Resend's transactional API with Tinbox, including the exact project edits, Resend SDK base URL option, field mapping, and compatibility limits.
Tinbox can replace the API side of a Resend integration when your app mainly
uses resend.emails.send() for transactional mail: receipts, password resets,
alerts, onboarding, and product notifications.
The smallest migration is not a rewrite. Keep the Resend SDK, replace the API
key, and change one host line: baseUrl or RESEND_BASE_URL. That lets you
test-drive Tinbox without removing resend, touching call sites, or changing
dependencies. For a cleaner long-term integration, swap to tinbox/sdk after
the cutover.
What changes
| Resend project setting | Tinbox replacement |
|---|---|
RESEND_API_KEY=re_... | TINBOX_API_KEY=tbx_..., or keep the old env name temporarily and store the Tinbox key there |
https://api.resend.com | https://<your-tinbox-host>/api/send |
new Resend(process.env.RESEND_API_KEY) | new Resend(process.env.TINBOX_API_KEY, { baseUrl: process.env.RESEND_BASE_URL }) |
resend.emails.send(...) | Can stay in place for simple sends, or become tinbox.emails.send(...) |
| Resend dashboard logs | Tinbox send ledger and tinbox.emails.get(id) |
| Resend verified domain | Tinbox sending domain verification in your own Cloudflare account |
One-line test drive
If your app already has this:
const resend = new Resend(process.env.RESEND_API_KEY);
you can test Tinbox by changing the key and adding one host option:
const resend = new Resend(process.env.TINBOX_API_KEY!, {
baseUrl: `${process.env.TINBOX_HOST}/api/send/compat/resend/v1`,
});
Every existing resend.emails.send(...) call can stay in place while you prove
delivery, logging, and sender authorization in Tinbox.
First, inventory your Resend usage
Find the places that call Resend:
rg "from ['\"]resend['\"]|new Resend\\(|resend\\.(emails|batch)\\.send"
rg "RESEND_API_KEY|RESEND_BASE_URL|api.resend.com"
Classify each call site:
| Usage | Migration path |
|---|---|
emails.send with from, to, subject, html, text, replyTo/reply_to, cc, bcc, headers, base64 attachments, idempotency | Drop-in or near drop-in |
batch.send with an array of email payloads | Drop-in if your route points to Tinbox's batch endpoint, or switch to tinbox.emails.batch |
React email via the Resend SDK react field | Works when you keep the Resend SDK, because the SDK renders React to html before it POSTs |
| Hosted Resend templates | Needs an app-side template render before calling Tinbox |
scheduledAt, topicId, Resend broadcasts, audiences, contacts, domains, logs APIs | Not a transactional drop-in; migrate separately |
| Webhook signature verification for Resend events | Replace with Tinbox's event/log flow; do not reuse Resend webhook verification code |
If direct resend.emails.send() calls are scattered through routes, wrap them
behind one sendEmail() function before changing providers. That makes the
actual cutover one file and keeps retries, errors, and idempotency consistent.
Deploy and create a sending key
Deploy Tinbox, verify the sending domain, and create a sending-scoped API key.
The key must be allowed to send from the from address your app uses.
Store the values in your application environment:
TINBOX_HOST=https://mail.yourdomain.com
TINBOX_API_KEY=tbx_...
# Only needed if you keep the Resend SDK during cutover:
RESEND_BASE_URL=https://mail.yourdomain.com/api/send/compat/resend/v1
You can also keep RESEND_API_KEY as the secret name during the first deploy
and put the tbx_... key in it. That reduces code churn, but renaming it to
TINBOX_API_KEY later makes incident debugging clearer.
Validate the key before sending:
import { Tinbox } from "tinbox/sdk";
const tinbox = new Tinbox({
host: process.env.TINBOX_HOST!,
apiKey: process.env.TINBOX_API_KEY!,
});
await tinbox.auth.check();
await tinbox.emails.validateSendAccess({
from: "Acme <hello@yourdomain.com>",
});
Option A: keep the Resend SDK and change the host
The current Resend Node SDK supports a custom API host in two ways:
- constructor option:
new Resend(key, { baseUrl }) - environment variable:
RESEND_BASE_URL
The SDK posts emails.send() to ${baseUrl}/emails, so set the base URL to
Tinbox's send mount, not the bare host:
import { Resend } from "resend";
const resend = new Resend(process.env.TINBOX_API_KEY!, {
baseUrl: `${process.env.TINBOX_HOST}/api/send/compat/resend/v1`,
});
const { data, error } = await resend.emails.send({
from: "Acme <hello@yourdomain.com>",
to: "ada@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
replyTo: "support@yourdomain.com",
});
if (error) throw new Error(error.message);
console.log(data?.id);
Or with environment variables only:
RESEND_API_KEY=tbx_...
RESEND_BASE_URL=https://mail.yourdomain.com/api/send/compat/resend/v1
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
This is the closest drop-in path because the Resend SDK already maps JavaScript
names to wire names (replyTo to reply_to, scheduledAt to scheduled_at,
contentType to content_type) before making the HTTP request. Tinbox ignores
none of the critical simple-send fields; unsupported fields are either rejected
by validation or simply not useful until Tinbox has the matching feature.
Option B: switch to the Tinbox SDK
Use this when you want Tinbox's native response shape, send status reads, suppressions, and TypeScript types:
import { Tinbox } from "tinbox/sdk";
const tinbox = new Tinbox({
host: process.env.TINBOX_HOST!,
apiKey: process.env.TINBOX_API_KEY!,
});
const res = await tinbox.emails.send({
from: "Acme <hello@yourdomain.com>",
to: "ada@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
replyTo: "support@yourdomain.com",
idempotencyKey: "receipt-4821",
});
if (!res.ok) {
throw new Error(JSON.stringify(res.recipients));
}
console.log(res.id, res.message_id);
The Tinbox SDK accepts both camelCase and wire-format names for the fields that matter during a Resend migration:
| JavaScript name | Wire name |
|---|---|
replyTo | reply_to |
idempotencyKey | idempotency_key |
contentType | content_type |
Field mapping
| Resend field | Tinbox support | Notes |
|---|---|---|
from | Yes | String, address object, or "Name <addr>". Enforced against the key's sender scope. |
to | Yes | String, address object, or arrays. |
cc, bcc | Yes | Same recipient shapes as to. |
subject | Yes | Empty string is allowed but usually a bad product choice. |
html, text | Yes | Provide at least one. |
react | Via Resend SDK only | The Resend SDK renders this to html client-side before POSTing. tinbox/sdk does not render React. |
replyTo / reply_to | Yes | Tinbox stores/sends reply_to; SDKs can accept replyTo. |
headers | Partial | Tinbox accepts custom X-* headers only. |
attachments[].content | Yes | Must be base64 content in Tinbox. |
attachments[].filename | Yes | Required in Tinbox. |
attachments[].contentType / content_type | Yes | Either spelling works through SDK normalization. |
attachments[].path | No | Fetch or read the file in your app, then pass base64 content. |
attachments[].contentId / content_id | Not currently | Inline CID attachments need a compatibility addition before they are drop-in. |
tags | Not currently | Put correlation data in an X-* header or your own app log for now. |
scheduledAt / scheduled_at | Not currently | Schedule in your app or queue before calling Tinbox. |
template | Not currently | Render templates in your app, then pass html/text. |
topicId / topic_id | Not currently | This belongs to marketing/list flows, not the transactional replacement. |
Response mapping
Resend returns:
{
data: { id: "..." },
error: null
}
Tinbox returns the send result directly:
{
id: "receipt-4821",
ok: true,
message_id: "<...@yourdomain.com>",
recipients: [{ recipient: "ada@example.com", status: "sent" }],
suppressed: []
}
If you keep the Resend SDK, your code still receives the SDK wrapper
{ data, error }, but data is Tinbox's richer object rather than only
{ id }. Code that only reads data.id can stay unchanged. Code that expects a
Resend-specific error object or event type should be adjusted.
Batch sends
Tinbox supports:
POST /api/send/compat/resend/v1/emails/batch
The native Tinbox SDK call is:
await tinbox.emails.batch([
{ from: "hello@yourdomain.com", to: "ada@example.com", subject: "Hi", text: "..." },
{ from: "hello@yourdomain.com", to: "grace@example.com", subject: "Hi", text: "..." },
]);
If you keep the Resend SDK, confirm the SDK version you run posts batch requests
as a bare array to /emails/batch. Tinbox accepts both a bare array and
{ emails: [...] }, so that shape is compatible.
Idempotency
Resend's Node SDK supports an idempotencyKey request option, which becomes the
Idempotency-Key header. Tinbox also accepts that header, and it also accepts
idempotency_key in the JSON body.
With Resend SDK:
await resend.emails.send(
{
from: "hello@yourdomain.com",
to: "ada@example.com",
subject: "Your receipt",
text: "Thanks.",
},
{ idempotencyKey: "receipt-4821" },
);
With Tinbox SDK:
await tinbox.emails.send({
from: "hello@yourdomain.com",
to: "ada@example.com",
subject: "Your receipt",
text: "Thanks.",
idempotencyKey: "receipt-4821",
});
Use stable keys for one-time business events such as receipts and booking confirmations. Use a unique key, or omit the key, for emails that should send again on every attempt, such as one-time codes.
Attachments
Resend allows attachment content or path. Tinbox's transactional API accepts
base64 content.
Change path-based attachments:
await resend.emails.send({
from,
to,
subject,
html,
attachments: [{ filename: "invoice.pdf", path: invoiceUrl }],
});
To app-provided bytes:
const invoice = await fetch(invoiceUrl);
const bytes = Buffer.from(await invoice.arrayBuffer()).toString("base64");
await tinbox.emails.send({
from,
to,
subject,
html,
attachments: [
{
filename: "invoice.pdf",
content: bytes,
contentType: "application/pdf",
},
],
});
Keep the total outbound message under Tinbox's send-size limit. Large files should usually be links to authenticated downloads rather than email attachments.
A compatibility route for true drop-in behavior
The cleanest host switch is:
RESEND_BASE_URL=https://mail.yourdomain.com/api/send
That still works because Tinbox keeps /api/send/emails as a Resend-style alias.
For new compatibility work, prefer the explicit provider route:
RESEND_BASE_URL=https://mail.yourdomain.com/api/send/compat/resend/v1
Resend calls /emails under the configured base URL, so the explicit route maps
to /api/send/compat/resend/v1/emails.
If you want to point the Resend SDK at a different internal route, add a small proxy that forwards all Resend-style paths to Tinbox's send mount:
app.all("/resend/*", async (c) => {
const url = new URL(c.req.url);
const suffix = url.pathname.replace(/^\/resend/, "");
url.pathname = `/api/send${suffix}`;
return fetch(url, {
method: c.req.method,
headers: c.req.raw.headers,
body: c.req.raw.body,
redirect: "manual",
});
});
Then configure:
RESEND_BASE_URL=https://mail.yourdomain.com/resend
Only do this if you need a stable internal route for migration. For most apps,
pointing RESEND_BASE_URL directly at /api/send is simpler.
Cutover checklist
- Deploy Tinbox and verify the sending domain.
- Create a sending API key scoped to the app's
fromidentity or domain. - Add
TINBOX_HOSTandTINBOX_API_KEYto the app. - If keeping the Resend SDK, set
RESEND_BASE_URL=https://<host>/api/send/compat/resend/v1. - Send a smoke test with the same
fromaddress production uses. - Check
tinbox.emails.get(id)or the send ledger for the per-recipient result. - Check the received email headers for SPF, DKIM, and DMARC pass.
- Ramp traffic while leaving the old Resend domain records in DNS.
- Remove the Resend API key and old DNS records after the rollback window.
When it is not drop-in
Do not treat this as a one-line migration if your app depends on Resend as a broader product, not just a transactional send API. These need separate work:
- hosted templates and server-side template rendering;
- scheduled sends;
- audiences, contacts, broadcasts, topics, and marketing workflows;
- Resend webhook event names and signature verification;
- attachment URLs or inline CID attachments;
- non-
X-*custom headers.
For those cases, migrate the transactional sends first, then move the marketing or eventing surface deliberately.
Source note
This guide relies on the Resend Node SDK behavior current as of v6.17.2: the SDK
constructor accepts { baseUrl }, it reads RESEND_BASE_URL when no option is
passed, and emails.send() posts to /emails under that base URL. Re-check the
SDK source when upgrading Resend across major versions.