# Email Webhooks: Events, Signatures, and Idempotency
URL: https://samva.dev/guides/email-webhooks
> Subscribe to email events, verify Standard Webhooks signatures, and handle retries idempotently.
- Published: 2026-07-10

Email is asynchronous. A successful send means Samva accepted the message; whether it was delivered,
bounced, drew a spam complaint, or got a reply is known later. Webhooks push those outcomes to your
application as they happen, so you do not poll for them.

## Choose events [#choose-events]

Subscribe an endpoint to the outcomes your application acts on. A typical delivery integration uses:

- `message.delivered` to mark a message as landed.
- `message.bounced` for an address that does not exist, which Samva stops sending to.
- `message.complained` for a recipient who reported the email as spam.
- `message.failed` for an email Samva did not send, or that the recipient's server never accepted.
- `message.opened` and `message.clicked` for engagement observations on tracked sends.
- `contact.unsubscribed` and `contact.resubscribed` for changed topic subscriptions.

Add `message.received` to take in replies. The [webhook event catalog](/docs/platform/webhook-events)
lists every event type and the fields each one carries.

Open and click events are emitted for every observation on tracked sends when tracking is enabled for
the organization and recipient. Apple Mail Privacy Protection can produce automatic opens, and
security scanners can produce automatic opens and clicks, often the first observation. Treat these events as
observations, not proof of human engagement. Likely automatic observations carry `machine: true` and
a `machineReason`; `firstOpen` and `firstClick` mark the first observation for each delivery and
event kind that is not classified as automated.

## Register an endpoint [#register-an-endpoint]

```ts title="create-endpoint.ts"
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

const created = await samva.webhooks.create({
  name: "Delivery events",
  url: "https://your-app.com/webhooks/samva",
  eventTypes: ["message.delivered", "message.bounced", "message.complained", "message.failed"],
  channels: ["email"],
});

// Store this on your server. GET /v1/webhooks/{id}/secret reads it again.
const signingSecret = created.secret;
```

You can attach custom headers to the endpoint, such as a token your gateway checks. Samva stores
their values encrypted and never returns them, and they cannot replace the signature headers.

## What a delivery looks like [#what-a-delivery-looks-like]

Every delivery is a `POST` with a JSON body and a set of signing headers, which the [event catalog](/docs/platform/webhook-events#request-headers) lists. The body names the event `type`, when Samva published it, and the payload in `data`:

```json
{
  "type": "message.delivered",
  "timestamp": "2026-01-15T09:42:11.204Z",
  "data": {
    "messageId": "msg_01j8k3m5n7p9q2r4s6tv",
    "channel": "email",
    "status": "delivered",
    "toEmails": ["ada@example.com"]
  }
}
```

The event ID header is the same on every attempt of an event, and that makes it the key for deduplication. The timestamp and signature headers change on each attempt, because each one is signed again. Read `data` by `type`, and ignore fields you do not recognize: Samva can add fields to a payload.

## Verify the exact request [#verify-the-exact-request]

Every request is signed with the endpoint's secret. The signature covers the event ID, a timestamp,
and the exact body, so verify against the raw request before you parse it:

```ts title="app/webhooks/samva/route.ts" sdk-typecheck-skip="Application example requires ./handle-once from the surrounding project"
import { verifyRequest, WebhookVerificationError } from "@samva/webhooks";

import { handleOnce } from "./handle-once";

export async function POST(request: Request) {
  try {
    const verified = await verifyRequest(request, process.env.SAMVA_WEBHOOK_SECRET!);
    await handleOnce(verified.id, verified.event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response("Invalid webhook", { status: 400 });
    }
    throw error;
  }
}
```

Frameworks without a Fetch API `Request`, such as Express, and Effect programs use the other
functions in [Verify webhooks](/docs/integrations/webhooks).

## Fix a failing verification [#fix-a-failing-verification]

When `verifyRequest` rejects a request, the error says why. These are the causes you will meet, in the order to check them:

| Error | Usual cause | Fix |
| --- | --- | --- |
| `WebhookSignatureError` | A body parser ran first and re-serialized the body, or the secret is wrong | Verify the raw bytes before any parsing; copy the secret from the endpoint again |
| `WebhookTimestampError` | Your server clock is off, or someone replayed an old request | Fix the clock; do not widen the tolerance to hide a replay |
| `InvalidWebhookHeadersError` | A proxy or gateway stripped a signing header | Forward every signing header unchanged |
| `InvalidWebhookSecretError` | The secret is truncated or lacks its prefix | Store the whole secret exactly as Samva showed it |

After you rotate a secret, requests carry a signature for both the new and the previous secret until the previous one expires, so a deploy that lags the rotation keeps working. Answer a failed verification with a `4xx` and do not echo the error's `detail` to the caller; it is for your logs.

## Handle each event once [#handle-each-event-once]

Delivery is at least once and unordered. A slow or failing endpoint gets the same event again, and events can arrive out of order. Three habits keep a handler correct:

1. **Record the ID in the same transaction as the effect.** Every attempt of an event carries the same `verified.id`. Insert it under a unique constraint and apply the event in one database transaction. If the effect fails, the insert rolls back with it and the retry is processed instead of skipped. Recording the ID in one step and acting in another loses an event whenever the process dies between them.
2. **Answer fast.** Return a `2xx` once the event is stored and do slow work afterward. A late answer counts as a failure and the event is retried.
3. **Read state, not order.** Compare the event's `timestamp` with what you stored before overwriting, so a late `message.sent` cannot undo an earlier `message.delivered`.

```sql title="webhook-events.sql"
create table webhook_events (
  id text primary key,
  received_at timestamptz not null default now()
);
```

```ts title="handle-once.ts" sdk-typecheck-skip="Application example requires postgres from the surrounding project"
import postgres from "postgres";
import type { SamvaWebhookEvent } from "@samva/webhooks";

const sql = postgres(process.env.DATABASE_URL!);

export async function handleOnce(id: string, event: SamvaWebhookEvent) {
  await sql.begin(async (tx) => {
    const recorded = await tx`
      insert into webhook_events (id) values (${id})
      on conflict do nothing
      returning id
    `;
    if (recorded.length === 0) return; // an earlier attempt already handled this event

    if (event.type === "message.bounced") {
      const { organizationId, toEmails = [] } = event.data as {
        organizationId: string;
        toEmails?: string[];
      };
      for (const address of toEmails) {
        // Scope by organization: one endpoint can receive events for several organizations.
        await tx`update contacts set undeliverable = true
          where organization_id = ${organizationId} and email = ${address}`;
      }
    }
  });
}
```

Some effects cannot roll back: sending another email, charging a card, calling a partner API. Pass `verified.id` as the `idempotencyKey` of that call, as the [inbound guide](/guides/inbound-email-parsing) does, so a retry cannot repeat it.

## What your response tells Samva [#what-your-response-tells-samva]

An attempt succeeds when your endpoint answers with any `2xx`. Any other status, a redirect, a connection error, or a timeout fails the attempt and schedules the next one, so what you return decides whether you see the event again:

- Return `204` for an event you handled, and for a type you do not handle. Ignoring an unknown type with a `2xx` stops a needless retry.
- Return a `4xx` only for a request that failed verification. It is not from Samva, or it is damaged.
- Let an unexpected error in your own code surface as a `5xx`, so Samva retries the event after your fix ships.
- Register the URL that answers. Samva does not follow redirects.

The [delivery rules](/docs/platform/webhook-events#delivery) give the retry schedule, the response timeout, how `Retry-After` is honored, when a failing endpoint is disabled, how long logs are kept, and how secret rotation overlaps. To redeliver an event after a fix, use `webhooks.retryDelivery` or the delivery log in the dashboard.

## Test your handler without Samva [#test-your-handler-without-samva]

`@samva/webhooks` can sign requests for you, so a test sends your handler the same bytes Samva would:

```ts title="webhook.test.ts" sdk-typecheck-skip="Application example requires ./app/webhooks/samva/route.ts from the surrounding project"
import assert from "node:assert/strict";
import { test } from "node:test";
import {
  generateWebhookSecret,
  signWebhook,
  WEBHOOK_ID_HEADER,
  WEBHOOK_SIGNATURE_HEADER,
  WEBHOOK_TIMESTAMP_HEADER,
} from "@samva/webhooks";

import { POST } from "./app/webhooks/samva/route.ts";

test("accepts a signed delivery and rejects a changed body", async () => {
  const secret = generateWebhookSecret();
  process.env.SAMVA_WEBHOOK_SECRET = secret;

  const body = JSON.stringify({
    type: "message.delivered",
    timestamp: new Date().toISOString(),
    data: { messageId: "msg_test", channel: "email", status: "delivered" },
  });
  const id = "evt_test_1";
  const timestamp = Math.floor(Date.now() / 1000);
  const signature = await signWebhook({ body, id, timestamp, secret });
  const headers = {
    [WEBHOOK_ID_HEADER]: id,
    [WEBHOOK_TIMESTAMP_HEADER]: String(timestamp),
    [WEBHOOK_SIGNATURE_HEADER]: signature,
  };

  const ok = await POST(new Request("https://app.test/webhooks", { method: "POST", headers, body }));
  assert.equal(ok.status, 204);

  const tampered = await POST(
    new Request("https://app.test/webhooks", { method: "POST", headers, body: `${body} ` }),
  );
  assert.equal(tampered.status, 400);
});
```

The route calls `handleOnce`, so point `DATABASE_URL` at a test database or stub that module in the test. Run it with `node --test webhook.test.ts`. To try the real path end to end, send a `webhook.test` event from the dashboard or with `webhooks.test`.

From another language, check the signature with a [Standard Webhooks](https://www.standardwebhooks.com/) library or with the standard library alone, as the [Python guide](/guides/send-email-python#know-what-happened-to-the-email) does.

## FAQ

### How do I verify a Samva webhook signature?

Pass the raw request and your endpoint's signing secret to verifyRequest from @samva/webhooks. It checks the signature and the timestamp against the exact body and rejects a request that was changed or replayed.

### Which email events can I subscribe to?

Email events include delivery outcomes, tracked opens and clicks, and inbound email, alongside contact, conversation, domain, and endpoint changes. The webhook event catalog lists every type with its payload.

### What happens if my endpoint is down?

Samva retries each event on a fixed schedule with increasing delays, and honors a Retry-After header within limits. An endpoint that keeps failing is eventually disabled. The webhook event catalog lists the exact schedule and limits.

### Why does signature verification fail on a valid request?

Almost always because the body changed before it reached the verifier. A JSON body parser re-serializes the body, so the signature no longer matches. Verify against the raw bytes, with the endpoint's current signing secret, and check that your server clock is close to real time.

### How do I make my webhook handler idempotent?

Record verified.id under a unique constraint before applying side effects, and skip IDs you have already recorded. Every attempt of the same event carries the same id.
## Related

- [Receive webhooks](/docs/platform/webhooks): Create an endpoint, verify requests, and process each event once.
- [Webhook event catalog](/docs/platform/webhook-events): Every event Samva delivers, its payload, and the delivery limits.
- [Inbound email parsing](/guides/inbound-email-parsing): Parse replies into your app and continue the conversation.
- [Migrate from Resend](/guides/migrate-from-resend): Map Resend webhook events onto Samva, including the events with no equivalent.
