Docs · Migrating
Migrating from Resend
Owlpost's sending API takes Resend's request and response shapes on purpose: for the common calls, a Resend caller switches by changing the base URL and the API key. This page shows the switch, then every difference we know of.
Switch the base URL and the key
The official Resend Node SDK reads its base URL from RESEND_BASE_URL. Point it at Owlpost's /v1 and give it an Owlpost key; the SDK's /emails and /emails/batch calls then land on Owlpost unchanged.
export RESEND_BASE_URL=https://api.owlpost.to/v1
export OWLPOST_API_KEY=op_test_…import { Resend } from 'resend';
const resend = new Resend(process.env.OWLPOST_API_KEY); // an op_… key, not re_…
const { data, error } = await resend.emails.send(
{
from: 'Acme <hello@example.com>',
to: ['ada@example.org'],
subject: 'Confirm your spot',
html: '<p>Hi Ada</p>',
text: 'Hi Ada',
replyTo: 'support@example.com',
tags: [{ name: 'category', value: 'confirm' }],
},
{ idempotencyKey: 'confirm-ada-1' },
);
if (error) throw new Error(`${error.status} ${error.title}: ${error.detail}`);
const email = await resend.emails.get(data.id); // last_event, to, reply_to, …
const batch = await resend.batch.send(
[
{ from: 'Acme <hello@example.com>', to: ['ada@example.org'], subject: 'One', html: '<p>one</p>' },
{ from: 'Acme <hello@example.com>', to: ['grace@example.org'], subject: 'Two', text: 'two' },
],
{ idempotencyKey: 'batch-2026-10-03' },
);Or pass it in code: new Resend(key, { baseUrl: 'https://api.owlpost.to/v1' }). Then verify your sending domains with Owlpost (Domains); the MAIL FROM records use bounces., not Resend's send., so both sets can live side by side while you move.
How we check this. tools/resend-compat in the backend repository runs the Resend Node SDK (pinned at 6.30.0) against Owlpost: a send, an idempotent retry, reading the message back, a validation error, and a two-message batch and its retry. Last run, 2026-09-29 against a local build: the four single-send checks passed. The two batch checks have not had a live run yet.
What works the same
| Resend | Owlpost |
|---|---|
POST /emails | POST /v1/emails: same fields, to/cc/bcc/reply_to as a string or a list, base64 attachments, tags, headers, scheduled_at. Answers {"id"}. |
POST /emails/batch | POST /v1/emails/batch: up to 100 messages, answers {"data": [{"id"}, …]} |
GET /emails/{id} | GET /v1/emails/{id}: object, id, from, to, cc, bcc, reply_to, subject, html, text, tags, last_event, created_at, scheduled_at |
Idempotency-Key | Same header, same 256-character limit; same key with a different body is 409 |
| Limits | 50 recipients a message, 50 tags of up to 256 characters, 100 messages a batch |
| Domain records | Same records shape: record, name, type, ttl, value, status, priority |
Differences
- Errors are problem documents. Owlpost answers
application/problem+json(type,title,status,detail,instance, anderrorson validation), not Resend's{name, message, statusCode}. The Node SDK hands the body back aserroruntouched, so readerror.statusanderror.detailrather thanerror.nameanderror.message. See Errors. - Validation reports everything at once, as
422, with one entry per broken field. scheduled_attakes RFC 3339 only, at most 30 days ahead. Natural language such as "in 1 hour" is refused: it has no single meaning without your time zone.- Attachments must be base64 strings in
content. Attachments bypath(a URL to fetch) are refused, not ignored. A NodeBufferpassed ascontentis serialised by the SDK as an object and refused too: callbuffer.toString('base64')first.contentId(inline images) is ignored. - No attachments in a batch. Send those messages one at a time.
- Batches are always all or nothing, like Resend's default
strictmode. The SDK'sbatchValidation: 'permissive'has no effect: one broken message refuses the batch. - Idempotency compares bytes. A retry must send the same body byte for byte; the SDK does when you retry the same call with the same object.
- Templates are refused (
templateanswers422) until Owlpost has them. - Live mail needs a verified domain (
403 domain-not-verified); Resend's sharedonboarding@resend.devsender has no Owlpost equivalent. A test key accepts any sender. - Keys are
op_live_…andop_test_…, and carry scopes. A test key never delivers. - Webhooks are signed with
Cratefield-Signature(HMAC-SHA256 over{t}.{body}), not Svix headers, and event types differ (email.soft_bounced,email.failed,message.received…). Re-register your endpoints and swap the verifier: Webhooks. - Owlpost extensions:
stream(transactionalormarketing),topicfor topic-scoped unsubscribes, and a per-messageidempotency_keyin batches. The Resend SDK drops fields it does not know, so send these with plainfetch.
Not there yet
These Resend endpoints have no Owlpost counterpart today. The SDK methods that call them get an error back (404 or 405).
- Listing sent emails,
GET /emails: Planned (#12). - Updating or cancelling a scheduled email.
- Audiences, contacts, broadcasts, segments, templates and API-key management through the API.
- Domains and webhooks exist, at
/v1/domainsand/v1/webhooks, but their request shapes are Owlpost's own, so call them directly rather than through the SDK.