Inbound email matters when a reply changes what your product does next. A support reply can update a ticket, a customer response can unblock an approval, and a forwarded message can become an application event. The useful integration is a small, verified path from a received email to the conversation your application already understands.
This page is the minimum working path with the TypeScript SDK. The complete, runnable recipe lives in the inbound email cookbook, including the full example, durable deduplication, and production hardening.
Configure receiving
Start with a verified domain and a public HTTPS endpoint. Creating the endpoint returns a signing secret; enabling receiving with that endpoint ID starts routing mail for the domain and reports the recipient addresses it accepts. Publish the root MX record Samva proposes before mail can route, and use a dedicated subdomain when an existing mailbox owns your primary MX route.
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const endpoint = await samva.webhooks.create({
name: "Inbound replies",
url: "https://app.example.com/webhooks/samva",
eventTypes: ["message.received"],
channels: ["email"],
});
await samva.email.enableReceiving({
id: process.env.SAMVA_DOMAIN_ID!,
domain: "replies.example.com",
catchAll: true,
endpointId: endpoint.endpoint.id,
});
Endpoint names are unique per organization. If enabling receiving fails after you create the
endpoint, list endpoints and reconcile the existing one with webhooks.update instead of creating a
second. A signing secret is shown only once; rotate it with webhooks.regenerateSecret if the first
response was lost.
Choose a destination
A catch-all domain delivers to a webhook endpoint, to forwarding rules, or to both. Pass endpointId
to deliver message.received to a webhook. Leave it out to deliver through forwarding rules alone;
that needs at least one active rule that covers the domain, and the request fails without one. Mail
that matches neither a rule nor an endpoint is rejected as an unknown recipient, so Samva never
accepts a message it has nowhere to send. A forwarding-only domain publishes no message.received.
Move a domain's receiving to Samva
Moving receiving from another provider, such as Cloudflare Email Routing, changes the domain's MX record. Sending setup never touches it. The domain's Overview lists what is left, in order, with the live state of each step:
- Sending verified. Settings appear after the domain's DNS verifies for sending.
- Destination. Choose a webhook or add a forwarding rule in Settings.
- Receiving on. Enable catch-all in Settings.
- Inbound MX. Samva looks up the domain's MX record and names the target that currently takes the mail. Replace it with the record shown, or let your DNS provider add it when one-click setup is available. The record appears only after the first three steps hold.
Read the same status from the API with
GET /v1/email/domains/:id/receiving-status, or from
the CLI with samva email receiving status <domain-id>.
Verify, read the thread, and reply
message.received carries parsed fields including from, to, subject, conversationId,
hasAttachments, and isAutoReply, plus a parsed body when available. Verify the raw request before
parsing JSON, then read the conversation and reply on the same thread.
import { createClient } from "samva";
import { verifyRequest } from "samva/webhooks";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
export async function POST(request: Request) {
const verified = await verifyRequest(request, process.env.SAMVA_WEBHOOK_SECRET!);
if (verified.event.type !== "message.received") return new Response(null, { status: 204 });
const reply = verified.event.data as {
messageId: string;
conversationId: string;
from: string;
subject?: string;
isAutoReply?: boolean;
};
if (reply.isAutoReply) return new Response(null, { status: 204 });
const conversation = await samva.conversations.getById({ id: reply.conversationId });
await samva.email.send(
{
conversationId: conversation.id,
to: reply.from,
subject: reply.subject ? `Re: ${reply.subject}` : "Re: your message",
html: "<p>Thanks for your reply. We will follow up shortly.</p>",
inReplyToMessageId: reply.messageId,
},
{ headers: { "idempotency-key": verified.id } },
);
return new Response(null, { status: 204 });
}
Filter isAutoReply so an out-of-office response never triggers another reply. Deduplicate on
webhook-id, the event id that is stable across retries, and pass it as the idempotency-key header
so a concurrent retry cannot double-send while a transient failure stays retryable. Keep
conversationId and inReplyToMessageId so your application and the recipient see one continuous
conversation.
Read the full email
The webhook carries the fields a router needs. When the handler needs more, read the message's email details with its messageId. They include the addresses beyond from and to, the parsed threading headers, receivedAt (when Samva's receiving server accepted the email, not the sender's Date header), and signed, time-limited links to the HTML, the text, and the raw MIME:
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
export async function readEmail(messageId: string) {
const email = await samva.messages.getEmail({ id: messageId, includeDownloadUrls: "true" });
console.log(email.fromEmail, email.toEmails, email.ccEmails, email.replyToEmails);
console.log(email.messageIdHeader, email.inReplyTo, email.references);
console.log(email.isAutoReply, email.autoReplyReason);
return email;
}
isAutoReply is a boolean in the webhook, and autoReplyReason here says why: out-of-office, auto-response, delivery-status, or read-receipt. Treat all four as machine mail, and never answer a delivery-status with another email. The API reference lists every field.
Download attachments
hasAttachments tells you whether to look. List the attachments for the message, then ask for a download link for each one. The link expires, so fetch the file when you need it and store your own copy if you must keep it.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
export async function saveAttachments(messageId: string, directory: string) {
await mkdir(directory, { recursive: true });
const { attachments } = await samva.attachments.list({ messageId });
for (const attachment of attachments) {
if (attachment.isInline) continue; // images embedded in the HTML body
const { url } = await samva.attachments.downloadUrl({ id: attachment.id });
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
// Name the file by its id: the sender chose `attachment.filename`, so never build a path from it.
await writeFile(join(directory, attachment.id), Buffer.from(await response.arrayBuffer()));
}
}
Each attachment has an id, filename, contentType, size, and isInline, with a contentId for inline images. Check the content type and size before you open a file, and keep the sender's filename as display text only.
How a reply finds its conversation
Samva places each inbound email in a conversation using the headers the sender's mail client preserves. The reply's In-Reply-To and References headers point at the email it answers, and Samva checks them first. An email that carries no such headers is matched by its subject and participants instead. An email whose headers name a parent Samva cannot find starts a new conversation and is not matched by subject, so a reply to an email sent from another system can open a separate thread.
Outbound email carries the headers replies need, so a customer who answers in their mail client lands in the right conversation without any work from you. The other direction is the part your app owns. A send joins a conversation only when you pass the conversation or the message it replies to, so keep conversationId and inReplyToMessageId from the inbound event and send them with the answer. Keep the Re: subject as well, so the customer sees one thread in their inbox.
Samva does not strip quoted history or signatures from the body. You get what the sender wrote, including the earlier messages the client quoted below it.
Receive on a subdomain
Receiving moves the root MX records for the domain you enable. If your team's mailboxes already run on example.com, enable receiving on a subdomain such as reply.example.com instead, and set the reply-to of your outbound email to an address there. Customer replies reach your app, and your team's inbox stays untouched.
The cookbook covers the durable deduplication store, MX and subdomain handling, and the runnable example.
For the event schema, see the webhook event catalog. For endpoint verification and retry behavior, see receive webhooks.