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 responsibility | Migration destination |
|---|---|
| Direct send request and response handling | samva.messages.send or POST /v1/messages |
| Hand-built HTML or provider templates | Versioned server-side templates, or inline HTML first |
| Configuration-set or notification plumbing | Signed delivery webhooks |
| Custom bounce and complaint list | Suppression and message status in the email platform |
| Receipt rules and raw inbound mail parsing | Inbound messages with conversation context |
A local thread_id convention | The conversation returned by Samva |
| Separate operational scripts | The 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:
- Send a controlled test from the verified domain.
- Reconcile the Samva message id with the delivery event and your order or account record.
- Exercise a bounce or failure path in a non-production test flow.
- If the flow receives replies, confirm the
message.receivedevent and conversation id. - Compare rendered templates, headers, links, and unsubscribe behavior where applicable.
- Route the next small production slice through Samva and watch event and support records.
- 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.