OpenSES

Migrate from Resend

Move a transactional Node.js integration from Resend to OpenSES with minimal application changes.

OpenSES includes a Resend-compatible facade for the transactional email path. Existing code can keep the resend variable, emails.send() call, and { data, error } response handling.

Before changing code

OpenSES sends through your Amazon SES account. Complete these dashboard steps first:

  1. Connect an AWS account with SES access.
  2. Verify the sender domain used by the application.
  3. Create an OpenSES project API key.

A Resend API key cannot be used with OpenSES.

Replace the package

npm remove resend
npm install @openses/openses

Change the import and API key:

- import { Resend } from 'resend';
+ import { Resend } from '@openses/openses';

- const resend = new Resend(process.env.RESEND_API_KEY);
+ const resend = new Resend(process.env.OPENSES_API_KEY, {
+   baseUrl: 'https://email.mycompany.com',
+ });

The facade temporarily falls back to RESEND_API_KEY to make staged deployments easier, but the variable must contain an OpenSES opk_live_... or opk_test_... key. Renaming it to OPENSES_API_KEY is recommended.

Existing send code

This call shape remains valid:

const { data, error } = await resend.emails.send(
  {
    from: 'Acme <noreply@acme.com>',
    to: 'user@example.com',
    subject: 'Welcome',
    html: '<h1>Welcome</h1>',
    replyTo: ['support@acme.com', 'billing@acme.com'],
    headers: {
      'X-Entity-ID': 'user_123',
    },
    tags: [
      { name: 'source', value: 'signup' },
    ],
  },
  {
    idempotencyKey: 'welcome-user-123',
  },
);

if (error) {
  console.error(error.name, error.message);
} else {
  console.log(data.id);
}

Unlike the native OpenSES client, the Resend facade returns errors instead of throwing them.

Compatibility

Resend transactional featureOpenSES compatibility
emails.send() and emails.create()Supported
HTML and plain textSupported
React EmailSupported when @react-email/render is installed
CC, BCC, custom headersSupported
Multiple reply-to addressesSupported
Buffer, base64, local path, and URL attachmentsSupported, 40 MB total
TagsSupported; the facade converts tag arrays to OpenSES tags
TemplatesSupported; the Resend template id maps to an OpenSES template slug
Idempotency keysSupported, including mismatched-payload protection
emails.get()Supported
emails.list({ after, limit })Supported
emails.cancel()Supported
{ data, error, headers } responsesSupported
webhooks.verify()Supported through Standard Webhooks headers

OpenSES continues to send its original X-OpenSES-* webhook headers and now also sends webhook-id, webhook-timestamp, and webhook-signature. Existing Resend verification code can therefore keep this shape:

const event = resend.webhooks.verify({
  payload: rawBody,
  headers: {
    id: request.headers.get('webhook-id')!,
    timestamp: request.headers.get('webhook-timestamp')!,
    signature: request.headers.get('webhook-signature')!,
  },
  webhookSecret: process.env.OPENSES_WEBHOOK_SECRET!,
});

Current differences

Resend featureStatus
scheduledAt and emails.update()Not implemented; the facade returns a validation error
batch.send()Not implemented; OpenSES does not emulate atomic batch behavior with multiple sends
emails.list({ before })Not implemented; forward cursor pagination is supported
TopicsNot implemented; OpenSES is transactional-first
Receiving and attachment retrievalNot implemented
Broadcasts, automations, contacts, segmentsOut of the transactional migration scope
Domain, template, API key, and webhook management SDKsDashboard operations over tRPC today

OpenSES requires from even when using a stored template because sender identity belongs to the connected SES account. A template may supply its own subject.

Remaining implementation plan

  1. Persist scheduled_at, enqueue delayed BullMQ jobs, and support rescheduling.
  2. Add a public batch REST endpoint with per-item validation and idempotency.
  3. Add project API-key REST resources for domains, templates, API keys, and webhooks.
  4. Add inbound email receiving after the transactional migration surface is complete.

The public SDK stays on REST because it uses project API keys and must work across languages. Dashboard administration remains on tRPC with Better Auth sessions.