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.

shell
export RESEND_BASE_URL=https://api.owlpost.to/v1
export OWLPOST_API_KEY=op_test_…
Node.js
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

ResendOwlpost
POST /emailsPOST /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/batchPOST /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-KeySame header, same 256-character limit; same key with a different body is 409
Limits50 recipients a message, 50 tags of up to 256 characters, 100 messages a batch
Domain recordsSame records shape: record, name, type, ttl, value, status, priority

Differences

  • Errors are problem documents. Owlpost answers application/problem+json (type, title, status, detail, instance, and errors on validation), not Resend's {name, message, statusCode}. The Node SDK hands the body back as error untouched, so read error.status and error.detail rather than error.name and error.message. See Errors.
  • Validation reports everything at once, as 422, with one entry per broken field.
  • scheduled_at takes 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 by path (a URL to fetch) are refused, not ignored. A Node Buffer passed as content is serialised by the SDK as an object and refused too: call buffer.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 strict mode. The SDK's batchValidation: '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 (template answers 422) until Owlpost has them.
  • Live mail needs a verified domain (403 domain-not-verified); Resend's shared onboarding@resend.dev sender has no Owlpost equivalent. A test key accepts any sender.
  • Keys are op_live_… and op_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 (transactional or marketing), topic for topic-scoped unsubscribes, and a per-message idempotency_key in batches. The Resend SDK drops fields it does not know, so send these with plain fetch.

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/domains and /v1/webhooks, but their request shapes are Owlpost's own, so call them directly rather than through the SDK.