Skip to content
Amazon SES

Replace an Amazon SES email stack without rebuilding delivery

A practical migration path from Amazon SES and custom glue to a typed email API with templates, conversation threading, and signed webhooks.

Published

Amazon SES gives you a reliable transport primitive. The application around it is your team's responsibility: request shapes, templates, domain setup, delivery events, bounce handling, inbound parsing, and the dashboard that explains what happened. If that surrounding code has become a second product to maintain, a managed email API can replace the glue without forcing a rewrite of every email flow at once.

This guide shows a staged migration to Samva. It is for teams that want a typed send surface and a single place for templates, delivery events, inbound email, and conversations. It is not a claim that a managed layer beats raw transport on price. Compare the total engineering and operating work, not only the cost per thousand messages.

Map the stack before you move it

Inventory the pieces that currently surround SendEmail or an SMTP relay. The goal is to preserve the behavior your product needs while replacing the code you no longer want to own.

Current responsibilityMigration destination
Direct send request and response handlingsamva.messages.send or POST /v1/messages
Hand-built HTML or provider templatesVersioned server-side templates, or inline HTML first
Configuration-set or notification plumbingSigned delivery webhooks
Custom bounce and complaint listSuppression and message status in the email platform
Receipt rules and raw inbound mail parsingInbound messages with conversation context
A local thread_id conventionThe conversation returned by Samva
Separate operational scriptsThe dashboard, CLI, API, and typed SDK

Keep this inventory beside the migration pull request. It gives you a checklist for parity and prevents a successful first send from hiding an unfinished bounce or reply path.

Start with one transactional flow

Choose a flow with a clear trigger and a measurable result, such as an order confirmation or a password reset. Keep the existing path for other flows while you prove the new one.

Verify a sending domain first. Samva generates the DKIM and custom MAIL FROM records for the domain; publish those records and wait for verification before routing production mail. The email authentication guide explains how SPF, DKIM, and DMARC fit together, and Verify your sending domain covers the Samva workflow.

Then make the smallest possible send call:

import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

const message = await samva.messages.send({
  to: [{ email: "ada@example.com" }],
  channel: "email",
  email: {
    subject: "Your order shipped",
    html: "<h1>On its way</h1><p>Order A-1042 is out for delivery.</p>",
  },
});

console.log(message.id, message.status);

The REST equivalent is the same product operation:

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: $SAMVA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-A-1042-confirmation" \
  -d '{
    "to": [{ "email": "ada@example.com" }],
    "channel": "email",
    "email": {
      "subject": "Your order shipped",
      "html": "<h1>On its way</h1><p>Order A-1042 is out for delivery.</p>"
    }
  }'

The response gives your application a message id and an initial status. Do not treat acceptance as delivery. Subscribe to the events that decide whether the flow succeeded.

Make retries safe before cutover

Networks can drop a response after the server accepts a send. Give each logical email a stable Idempotency-Key and reuse it when the application retries. An identical replay returns the original message for 24 hours; changing the request under the same key returns a conflict instead of silently sending different content.

Use a business identifier that stays stable across process restarts. For example, derive the key from the order id and the email purpose, not from a random request id. Record the Samva message id with the order so support can follow the send without searching provider logs.

Move delivery events next

Create one endpoint for the events your flow needs. Delivery webhooks are signed, delivered at least once, and retried when your endpoint does not return a successful response. Persist the webhook-id before applying a side effect so a retry is harmless.

import { verifyRequest } from "samva/webhooks";

export async function POST(request: Request) {
  const verified = await verifyRequest(
    request,
    process.env.SAMVA_WEBHOOK_SECRET!,
  );

  await persistAndHandleOnce(verified.id, verified.event);
  return new Response(null, { status: 204 });
}

Subscribe to message.delivered, message.bounced, and message.failed for the first flow. Add message.received when you move inbound email. The webhook guide has the full retry schedule, raw-body verification rules, and event catalog.

Move templates after the send is proven

Do not block the migration on a template rewrite. Inline HTML is a valid first step. Once the transport and event path are reconciled, move the body into a versioned template and pass data at send time:

await samva.messages.send({
  to: [{ contactId: "contact_k9mvt4znw8rh7q2x" }],
  channel: "email",
  email: {
    templateSlug: "order-update",
    templateData: { orderId: "A-1042", eta: "Tomorrow" },
  },
});

Versioning separates a content change from a deploy. Publish and test the new version, then keep the send code focused on the data that belongs in the message.

Add inbound conversations when the outbound path is stable

If your SES setup stores raw inbound mail or maintains a separate thread table, migrate that path after the first outbound flow is healthy. Verify the domain's receiving setup, subscribe to message.received, and use its conversation context to fetch the surrounding history.

async function handleReply(event: {
  type: "message.received";
  data: { conversationId: string; messageId: string };
}) {
  const conversation = await samva.conversations.getById(event.data.conversationId);
  await supportInbox.append({
    messageId: event.data.messageId,
    conversationId: conversation.id,
  });
}

The important boundary is the conversation id. Your application can keep its ticket or case id, but it no longer needs to infer message membership from a subject line or maintain a parallel thread_id convention. See Inbound email for the product behavior and Enable inbound email for setup.

Cut over with evidence

Run both paths only for the chosen flow while you compare the outcomes that matter to customers:

  1. Send a controlled test from the verified domain.
  2. Reconcile the Samva message id with the delivery event and your order or account record.
  3. Exercise a bounce or failure path in a non-production test flow.
  4. If the flow receives replies, confirm the message.received event and conversation id.
  5. Compare rendered templates, headers, links, and unsubscribe behavior where applicable.
  6. Route the next small production slice through Samva and watch event and support records.
  7. Remove the old path only after the retained traffic window is quiet.

Keep the old provider configuration available during the rollback window. A migration is complete when your application can explain every send, failure, and reply from its own records, not when the first HTTP request returns 201.

What changes in the cost model

Direct Amazon SES can be the right choice when your team wants to own the application layer and optimize for raw transport cost. Samva earns its place when the saved engineering and operational work matters more: typed SDKs, idempotent sends, versioned templates, authenticated domains, signed webhooks, inbound parsing, conversation records, a dashboard, and a CLI in one email integration.

Price the migration honestly. Count the code you can delete, the operational paths you no longer have to maintain, and the time it takes to answer “what happened to this email?” Then compare that with the managed email plan that fits your volume. The result should be a simpler product path, not a promise that every team will pay less for transport.

Continue with a typed integration

Once the first flow is stable, use the surface that fits your runtime. The Effect SDK guide composes send, webhook, and conversation operations as Effects with typed errors and retry policy. The TypeScript SDK guide covers the Promise API, while the REST API guide is the neutral path for other runtimes.

Start with one flow, prove the event loop, then move the next piece of the stack.

Sources

Frequently Asked Questions

Related Resources

Get started

Ship your first email today.

Transactional and product email through one typed API. Signed events, conversation threading, deliverability handled.