Skip to content
Amazon SES

Replace Amazon SES 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.

Updated

Start with Samva vs Amazon SES if you are deciding whether the switch is worth making. It compares AWS's published transport rate with the email product you would operate around it. Return here once the decision is made: this is the implementation path for one reversible flow, not another pricing comparison.

Amazon SES gives you transport. 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 path and a single place for templates, delivery events, inbound email, and conversations. Raw transport can still be cheaper per thousand emails, so weigh that rate against the engineering and operating work it leaves you.

Define the cutover contract

Pick one transactional flow and write down the evidence required before it moves. A password reset or order confirmation works well because the trigger, recipient, and expected outcome are clear.

CheckpointEvidence to retainRollback condition
DomainA verified Samva sending domain and authenticated From addressVerification is incomplete or the sender is not cleared for production
SendThe application record, idempotency key, and Samva message id agreeThe new path cannot return or persist a message id
DeliveryA signed event is stored once for the expected message idDelivery, failure, or bounce handling cannot be reconciled
Reply, when applicableA message.received event carries a conversation id your application can readA reply cannot be attached to the expected support or product record
RollbackThe SES path remains deployable for the selected flowAny checkpoint fails during the agreed observation window

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. This uses the same email.send() facade as the send-an-email quickstart:

import { createClient } from "samva";

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

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

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. Persist the message id with the order or account record. 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 in the same transaction as the effect it guards, or record durable processing state that only flips to complete after the handler succeeds, so a retry after a partial failure cannot skip or double-apply an event. The verifier must receive the original Request, before any JSON parsing:

import { verifyRequest, WebhookVerificationError } from "samva/webhooks";

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

    await persistAndHandleOnce(verified.id, verified.event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response("Invalid webhook", { status: 401 });
    }
    throw error;
  }
}

Subscribe to message.delivered, message.bounced, and message.failed for the first flow. Add message.received when you move inbound email. The webhook documentation has the current retry schedule and raw-body verification rules; the webhook guide explains the event choices and idempotency pattern.

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. Do not send the same production email through both providers to the same recipient. Use controlled test recipients for side-by-side checks, then route a small production slice to one path at a time:

  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. Record the observation window, volume, and any unexplained events in the migration pull request.
  8. 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.

Known limits

  • This guide does not cover migration of historical SES events, raw inbound objects, or suppression records. Export and retain those records under your existing data and privacy policy before retiring an SES path.
  • Route each production flow through one active path at a time. Do not duplicate a recipient's transactional email to compare providers.
  • Inbound cutover needs its own rollback plan. Preserve the SES receipt-rule destination until you have tested receiving records, message.received, and the conversation lookup with a controlled reply.
  • Provider acceptance, delivery, and recipient engagement are different events. The migration proof is your stored send and webhook record, not an inbox-placement claim.

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. AWS still publishes à la carte outbound at $0.10 per 1,000 emails (verified September 29, 2026 on Amazon SES pricing). New accounts start on Essentials at $0.16 per 1,000 unless you switch. 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. The dated side-by-side lives on Samva vs Amazon SES.

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.

Continue with a typed integration

Once the first flow is stable, use the client 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

Send

Ship your first email today.

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