# Receive Email in Node.js with a Signed Webhook
URL: https://samva.dev/guides/receive-email-nodejs
> Receive inbound email in Node.js: verify Samva's signed message.received webhook, read the full email and attachments, and test it locally without sending mail.
- Published: 2026-10-27



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 [#before-you-start]

You need three things from Samva:

* A [verified domain with receiving enabled](/guides/inbound-email-parsing#configure-receiving), or a subdomain such as `reply.example.com` when 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 in `SAMVA_WEBHOOK_SECRET`.
* An API key in `SAMVA_API_KEY`, for reading the full email after the event arrives.

```bash
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 [#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.

```ts title="receive.ts"
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 `400` and 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.id` is the `webhook-id`, stable across retries of the same event, so record it and skip repeats. The in-memory `Set` is for the example only; use a unique key in a database so a restart does not forget what you handled.
* Any non-`2xx` answer makes Samva retry, up to eight automatic attempts on the [published schedule](/docs/platform/webhook-events#retry-schedule). `node:http` does not answer a request whose async callback rejects, and an unhandled rejection can stop the process, so the `catch` turns a failure in your own processing into an explicit `500` and leaves the event unmarked for the retry.

## Read the event, then the full email [#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](/docs/platform/webhook-events#messagereceived).

```json
{
  "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.

```ts title="handle-received.ts"
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](/guides/inbound-email-parsing#how-a-reply-finds-its-conversation).

## Use Express [#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`:

```ts title="express-receive.ts"
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 [#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:

```ts title="send-test.ts"
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 [#what-to-read-next]

* [Receive, thread, and reply](/guides/inbound-email-parsing) to configure receiving and send an answer on the same conversation.
* [Receive email in Python](/guides/receive-email-python) for the same endpoint with the standard library and Flask.
* The [inbound email API](/features/inbound-email) page for what the parsed event contains.
* [Email webhooks](/guides/email-webhooks) for the retry schedule, secret rotation, and redelivery.

## FAQ

### How do I receive email in Node.js?

Enable receiving on a verified Samva domain and point it at a webhook endpoint that subscribes to message.received. Samva parses each incoming email and posts it to your Node.js server as a signed JSON event. Verify the signature against the raw request body, then act on the event.

### Do I need an SMTP server in my Node.js app?

No. Samva's receiving servers accept the SMTP connection and deliver the parsed email to your HTTPS endpoint, so your app only needs a route that accepts a POST.

### Why does verification fail when I use express.json()?

The signature covers the exact bytes Samva sent. A JSON body parser re-serializes the body, so the bytes no longer match. Mount express.raw({ type: "application/json" }) on the webhook route and pass the Buffer to verify.

### How do I get the attachments from an inbound email in Node.js?

The webhook sets hasAttachments. Read the email details with samva.messages.getEmail, which lists each attachment's id, filename, content type, size, and whether it is inline. Then call samva.attachments.downloadUrl with an id for a time-limited link and fetch the file.

### How do I test an inbound email handler without sending an email?

Sign a fixture event with signWebhook from samva/webhooks and POST it to your handler with the three webhook headers. The handler cannot tell it from a real delivery, and you can also send a tampered copy to prove it rejects one.
## Related

- [Receive email in Python](/guides/receive-email-python): The same signed webhook handled with the standard library, Flask, and requests.
- [Inbound email parsing: receive, thread, and reply](/guides/inbound-email-parsing): Configure receiving, then send a reply on the same conversation.
- [Inbound email API](/features/inbound-email): What the parsed webhook contains, compared with a parse webhook.
- [Send email with Node.js](/guides/send-email-nodejs): The send side of the same SDK.
- [Email webhooks](/guides/email-webhooks): Events, retries, and idempotent handlers for every webhook type.
- [Webhook event catalog](/docs/platform/webhook-events): The message.received fields, headers, and retry schedule.
