To receive email in Node.js, enable receiving on a verified Samva domain and point it at an HTTPS endpoint that subscribes to message.received. Samva accepts the mail, parses it, and POSTs a signed JSON event to your server. Your code has two jobs: verify the signature against the exact request bytes, then act on the event. This guide builds that endpoint with node:http, reads the full email and its attachments, shows the Express variant, and tests the whole path locally without sending a message.
Before you start
You need three things from Samva:
- A verified domain with receiving enabled, or a subdomain such as
reply.example.comwhen your team's mailboxes already use the root domain. - A webhook endpoint subscribed to
message.received. Creating it returns the signing secret once; keep it inSAMVA_WEBHOOK_SECRET. - An API key in
SAMVA_API_KEY, for reading the full email after the event arrives.
npm install samva
The SDK is ESM and ships its own types. On Node 24 and later, node receive.ts runs the TypeScript files below directly.
Verify and accept the event
The handler reads the body as bytes, verifies the three webhook-* headers against the signing secret, and answers 204. The signature covers the exact bytes Samva sent, so verification must happen before anything parses or re-serializes the body.
import { createServer } from "node:http";
import { isKnownEvent, verify, WebhookVerificationError } from "samva/webhooks";
import { handleReceived } from "./handle-received.ts";
const secret = process.env.SAMVA_WEBHOOK_SECRET!;
const seen = new Set<string>(); // use a database unique key in production
export const server = createServer(async (req, res) => {
try {
if (req.method !== "POST" || req.url !== "/webhooks/samva") {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks);
const verified = await verify({
body,
id: req.headers["webhook-id"] as string | undefined,
timestamp: req.headers["webhook-timestamp"] as string | undefined,
signature: req.headers["webhook-signature"] as string | undefined,
secret,
});
if (
isKnownEvent(verified.event) &&
verified.event.type === "message.received" &&
!seen.has(verified.id)
) {
await handleReceived(verified.event.data);
seen.add(verified.id); // mark only after success so a failure is retried
}
res.writeHead(204).end();
} catch (error) {
if (error instanceof WebhookVerificationError) {
res.writeHead(400).end("Invalid webhook");
return;
}
console.error(error);
res.writeHead(500).end(); // Samva retries any non-2xx answer
}
});
if (process.argv[1] === import.meta.filename) {
server.listen(Number(process.env.PORT ?? 3000));
}
Three behaviors matter here:
- A request that fails verification gets
400and never reaches your code. Verification rejects a missing header, a bad signature, and a timestamp more than five minutes from now. - Delivery is at least once.
verified.idis thewebhook-id, stable across retries of the same event, so record it and skip repeats. The in-memorySetis for the example only; use a unique key in a database so a restart does not forget what you handled. - Any non-
2xxanswer makes Samva retry, up to eight automatic attempts on the published schedule.node:httpdoes not answer a request whose async callback rejects, and an unhandled rejection can stop the process, so thecatchturns a failure in your own processing into an explicit500and leaves the event unmarked for the retry.
Read the event, then the full email
The event carries what a router needs. This is a real message.received payload; the field list is in the event catalog.
{
"type": "message.received",
"timestamp": "2026-01-15T10:05:33.900Z",
"data": {
"messageId": "msg_01j8k4n6p8q2r4s6t9vw",
"organizationId": "org_01j8k0a2b4c6d8e0f2gh",
"channel": "email",
"direction": "inbound",
"from": "ada@example.com",
"to": "support@your-domain.com",
"toEmails": ["support@your-domain.com"],
"subject": "Re: Welcome to Samva",
"conversationId": "conv_01j8k5p7q9r2s4t6v8wx",
"hasAttachments": false,
"isAutoReply": false,
"body": "Thanks for the welcome. How do I invite a teammate?"
}
}
body is the HTML when the email has one and the text otherwise. When you need more, call samva.messages.getEmail with the messageId. The details list every attachment and, with includeDownloadUrls, return signed links to the HTML, the text, and the raw MIME. The attachments come from the same response, so there is no second lookup before you pick which to download.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient } from "samva";
import type { MessageReceivedPayload } from "samva/webhooks";
const samva = createClient({
apiKey: process.env.SAMVA_API_KEY!,
baseUrl: process.env.SAMVA_API_URL ?? "https://api.samva.dev",
});
export async function handleReceived(event: MessageReceivedPayload) {
// Out-of-office and delivery-status mail is machine mail: record it, never answer it.
if (event.isAutoReply) return;
const email = await samva.messages.getEmail({ id: event.messageId, includeDownloadUrls: true });
console.log(email.fromEmail, email.subject, email.conversationId);
if (!event.hasAttachments) return;
await mkdir("attachments", { recursive: true });
for (const attachment of email.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("attachments", attachment.id), Buffer.from(await response.arrayBuffer()));
}
}
The download link expires, so fetch the file when you need it and keep your own copy if you must retain it. Check the content type and size before you open a file, and treat the sender's filename as display text.
Samva returns the body as the sender wrote it, including any history their mail client quoted below the reply. To answer on the same conversation, follow how a reply finds its conversation.
Use Express
Express needs one change. express.json() parses and re-serializes the body, which breaks the signature, so mount express.raw on the webhook route and pass the Buffer to verify:
import express from "express";
import { isKnownEvent, verify, WebhookVerificationError } from "samva/webhooks";
import { handleReceived } from "./handle-received.ts";
const app = express();
app.post("/webhooks/samva", express.raw({ type: "application/json" }), async (req, res) => {
try {
const verified = await verify({
body: req.body,
id: req.header("webhook-id"),
timestamp: req.header("webhook-timestamp"),
signature: req.header("webhook-signature"),
secret: process.env.SAMVA_WEBHOOK_SECRET!,
});
if (isKnownEvent(verified.event) && verified.event.type === "message.received") {
await handleReceived(verified.event.data);
}
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.sendStatus(400);
throw error;
}
});
app.listen(3000);
Express 5 forwards a rejected promise to its error handler, which answers 500; on Express 4, wrap the handler so a thrown error reaches next.
Test it without sending an email
signWebhook produces the same signature Samva sends. Sign a fixture, POST it with the three headers, and confirm the handler accepts it and rejects a tampered copy:
import { randomUUID } from "node:crypto";
import { signWebhook } from "samva/webhooks";
const url = process.env.TARGET_URL ?? "http://localhost:3000/webhooks/samva";
const secret = process.env.SAMVA_WEBHOOK_SECRET!;
const body = JSON.stringify({
type: "message.received",
timestamp: new Date().toISOString(),
data: {
messageId: "msg_test_1",
organizationId: "org_test_1",
channel: "email",
direction: "inbound",
from: "ada@example.com",
to: "support@your-domain.com",
toEmails: ["support@your-domain.com"],
subject: "Re: Welcome",
conversationId: "conv_test_1",
hasAttachments: false,
isAutoReply: true, // the handler returns before it calls the API, so no Samva account is needed
body: "How do I invite a teammate?",
},
});
const id = `whevt_${randomUUID()}`;
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signWebhook({ body, id, timestamp, secret });
async function post(payload: string) {
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"webhook-id": id,
"webhook-timestamp": String(timestamp),
"webhook-signature": signature,
},
body: payload,
});
return response.status;
}
console.log("signed:", await post(body)); // 204
console.log("tampered:", await post(body.replace("invite", "delete"))); // 400
The fixture's messageId does not exist in Samva, so it is marked isAutoReply: the handler returns before it reads the email and the test needs no account. To exercise the read and the attachment download, send a messageId from mail you received and set isAutoReply back to false.
Run the server in one terminal with SAMVA_WEBHOOK_SECRET=whsec_... node receive.ts and the test in another. To check the real path from Samva to a local port, expose it with a tunnel and send a webhook.test event with webhooks.test; the handler verifies it and answers 204 because it is not a message.received. The signed fixture is what you keep in your test suite.
What to read next
- Receive, thread, and reply to configure receiving and send an answer on the same conversation.
- Receive email in Python for the same endpoint with the standard library and Flask.
- The inbound email API page for what the parsed event contains.
- Email webhooks for the retry schedule, secret rotation, and redelivery.