Move one receiving hostname first, such as replies.example.com. Keep your SendGrid handler
available while you prove the Samva path, then change that hostname's MX records. Outbound sending
can stay on SendGrid throughout this migration.
This guide is for product teams already using SendGrid Inbound Parse. For the product decision, start with Samva vs SendGrid. For a new integration, use the shorter inbound email guide.
Map the Fields Your Application Actually Uses
SendGrid posts multipart form data. Samva posts a JSON event with type, timestamp, and data.
The event signals that a message was received; fetch email details when you need headers, separate
body formats, or attachment metadata.
| SendGrid input | Samva destination | Migration decision |
|---|---|---|
from, to, subject | data.from, data.to, data.subject | Keep sender display names separate from addresses. data.toEmails lists recipients routed to this endpoint. |
envelope.to | Routed recipients in data.toEmails | Recheck alias and ticket routing. Header recipients and SMTP envelope recipients are different concepts. |
text, html | data.body, or email content URLs | body can be HTML or text; it is not two separate fields. Fetch the explicit text/HTML representation if the distinction matters. |
headers | messages.getEmail: messageIdHeader, inReplyTo, references | Use these to reconcile existing application threads. Fetch raw MIME for other headers. |
attachments, attachment-info, file parts | hasAttachments, attachmentCount, attachments, raw MIME | Metadata is separate from bytes. Retrieve the raw email to extract attachments. |
Raw-mode email | s3ContentUrls.rawMime from messages.getEmail | Download the MIME message using a fresh signed URL. |
SPF, dkim | spfVerdict, dkimVerdict, dmarcVerdict on email details | Review your policy against the verdict values; webhook authentication does not authenticate the email author. |
spam_score, spam_report | No equivalent fields in the public email response | A workflow depending on these values needs its own replacement before cutover. |
The SendGrid field names and raw-mode distinction follow its Inbound Parse payload documentation, checked September 22, 2026. Samva's get email details reference shows the complete response. Do not treat missing fields as empty content.
Keep Authentication Separate for Each Endpoint
Keep /webhooks/sendgrid and /webhooks/samva as separate routes during the transition.
SendGrid supports ECDSA signatures, OAuth, or both through an Inbound Parse security policy.
Its signature headers are X-Twilio-Email-Event-Webhook-Signature and
X-Twilio-Email-Event-Webhook-Timestamp, even for Inbound Parse. Validate the original multipart
bytes before a framework extracts form fields or uploaded files. Keep the existing public key and
OAuth policy active while old deliveries drain. See
SendGrid's Inbound Parse security instructions.
For Samva, register an endpoint subscribed to message.received and store its returned signing
secret. Pass the untouched request to verifyRequest, which verifies the signature and timestamp
before decoding JSON. Do not reuse the SendGrid key or run its verifier on the Samva route.
Install samva and zod for this example. The handler factory deliberately takes your durable
inbox operation as an argument: wire it to a database transaction that inserts the event under a
unique (organization, provider, eventId) key and enqueues the worker in the same transaction.
A duplicate should succeed without a second job. A failed transaction must reject so delivery can
retry. The organization comes from your endpoint configuration, never an untrusted body field.
import { verifyRequest, WebhookVerificationError } from "samva/webhooks";
import { z } from "zod";
const receivedEmail = z.object({
messageId: z.string().min(1),
conversationId: z.string().min(1),
from: z.string().min(1),
to: z.string().min(1),
toEmails: z.array(z.string().min(1)).min(1),
hasAttachments: z.boolean(),
isAutoReply: z.boolean(),
});
type InboxItem = {
eventId: string;
email: z.infer<typeof receivedEmail>;
};
export function createInboundHandler(
secret: string,
acceptOnce: (item: InboxItem) => Promise<void>,
) {
return async function POST(request: Request): Promise<Response> {
let verified;
try {
verified = await verifyRequest(request, secret);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("Invalid webhook", { status: 401 });
}
throw error;
}
if (verified.event.type !== "message.received") {
return new Response(null, { status: 204 });
}
const email = receivedEmail.parse(verified.event.data);
await acceptOnce({ eventId: verified.id, email });
return new Response(null, { status: 204 });
};
}
This schema validates the fields this worker requires; it is not the complete event catalog.
An unexpected payload or failed inbox write must become a non-2xx response in your framework,
with an alert and enough protected diagnostic context to recover it. Do not catch processing
failures and return success. Keep automatic responses out of this request handler. In the worker,
check toEmails against your accepted-address rules before assigning a ticket. Record
isAutoReply messages but suppress automated replies to them.
Retrieve Attachment Bytes and the Exact Body Format
Use the received messageId to read email details. The attachments array contains metadata,
including each attachment's filename, size, content type, and inline content ID. The webhook does
not upload file parts to your endpoint. For a public SDK integration, download raw MIME and pass
those bytes to your MIME parser to extract attachments, including inline images.
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
export async function readInboundEmail(messageId: string) {
const email = await samva.messages.getEmail({
id: messageId,
includeS3Urls: "true",
urlExpiresIn: "300",
});
const rawUrl = email.s3ContentUrls?.rawMime;
if (!rawUrl) throw new Error("Raw email unavailable; keep the inbox job retryable");
const response = await fetch(rawUrl);
if (!response.ok) throw new Error(`Raw email download failed: ${response.status}`);
return { email, rawMime: new Uint8Array(await response.arrayBuffer()) };
}
Treat the signed URL as a temporary credential: fetch it on the server, do not log it, and request
a new URL if it expires. Persist attachment bytes or a durable processing result before completing
the inbox job. Apply your file-size, malware, filename, and content-type policies before exposing
files. Sanitize HTML before rendering it. Use s3ContentUrls.text or s3ContentUrls.html when you
need one body format; either can be null. The API option includeS3Urls is the literal SDK field
name for these content URLs.
Separate Retries from Message and Ticket Identity
A Samva webhook-id identifies an event, data.messageId identifies a stored message, and
data.conversationId identifies its conversation. An email's RFC Message-ID header is a fourth
identifier. Keep all four in distinct columns.
| Situation | Identity and action |
|---|---|
| Samva retries the same event | Deduplicate verified.id. Retries keep it but refresh timestamp and signature. |
| Two events refer to the same message | Make the downstream business effect unique by organization, message ID, and action. Event deduplication alone does not cover this. |
| The old and new routes both observe an email | Reconcile the RFC Message-ID, receiving address, and existing ticket record. Provider event IDs cannot deduplicate across providers. |
A sender omits or reuses Message-ID | Quarantine ambiguous matches for review; do not collapse mail solely on subject or sender. |
| A worker fails after accepting a webhook | Retry the durable inbox job. The provider stops retrying once the endpoint returns 2xx. |
Samva delivery is at least once and unordered. Return 2xx after durable acceptance and do slower attachment and ticket work in the worker. Its retry schedule and redelivery semantics differ from SendGrid's. SendGrid documents retries for unsuccessful deliveries. Monitor the old queue during the drain window using SendGrid's delivery guidance.
Keep your existing ticket ID as the application authority. When a newly received message belongs
to that ticket, add its Samva conversation ID to the mapping after checking the receiving address
and reply headers. Migration does not import historical SendGrid messages or preserve an old
provider's conversation identifiers. A Samva conversation is not automatically your ticket.
The reply example shows
conversationId and inReplyToMessageId for a reply to a message already stored in Samva.
Cut Over One Hostname with a Rollback Record
- Inventory the current route. Save the SendGrid receiving hostname, destination URL, raw-mode and spam-check settings, security policy, and exact MX values, priorities, and TTL. Record every alias and recipient rule your application expects. Keep outbound authentication records unchanged when only moving inbound routing.
- Prove a separate receiving subdomain. Configure a verified Samva domain and endpoint using configure receiving. Use a test hostname before the existing production hostname. Receiving is catch-all for that domain; enforce your application's accepted-address rules after verification.
- Exercise the migration checklist below. Use controlled test mail and local signed fixtures. A webhook test alone does not prove SMTP routing, MIME parsing, or attachment availability.
- Prepare DNS. Lower the receiving hostname's TTL in advance and wait out its previous TTL. Confirm Samva receiving is enabled for the exact hostname. Copy the MX target and priority shown for that domain; do not guess a regional hostname. Avoid an automatic DNS apply that would move traffic before you are ready.
- Replace that hostname's MX route. SendGrid's documented target is
mx.sendgrid.net. Replace the saved route with the Samva records. MX priority is failover preference, not a percentage rollout or a way to duplicate every email to both services. Leave your primary mailbox domain alone if you are moving only a reply subdomain. - Observe and retain both handlers. Inspect authoritative and public-resolver MX answers, then reconcile controlled received mail through signature verification, durable inbox storage, body/attachment retrieval, and ticket mapping. Keep SendGrid accepting queued webhooks for at least its documented three-day retry window after its last received message, and longer if old-route arrivals or unresolved work remain. DNS caches and sender queues can outlast a TTL.
- Rollback on missing or misrouted mail. Restore the saved MX records, keep both webhook handlers and workers running, and reconcile mail already accepted by either provider. DNS rollback affects future routing; it does not move already stored messages or cancel jobs. Retire the old endpoint only after the queues, application records, and observation window agree.
Prove These Cases Before Moving Customer Replies
| Case | Required result |
|---|---|
| Valid and tampered webhook | Valid event reaches the durable inbox; changed body or missing signature cannot create work. |
| Concurrent duplicate deliveries | One inbox job; a fresh retry signature still maps to the same event. |
| Inbox write fails, then recovers | First request is non-2xx; a retry can persist and process it. |
| Plain text, HTML, Unicode, and raw-mode source mail | Intended content survives; absent body formats remain absent. |
| File attachment and inline image | MIME extraction preserves expected bytes, filename, media type, and content ID. |
| Reply to a pre-migration ticket | Existing ticket mapping is explicit; a new Samva ID does not silently create a duplicate ticket. |
| Out-of-office reply | Message is recorded without sending another automated reply. |
| MX rollback with work in flight | Both queues drain; accepted mail remains recoverable and business effects do not repeat. |
Start with receiving setup, then use this checklist as your migration acceptance record. Keep the old route until it proves the behavior your application needs.