Skip to content
Webhooks

Email Webhooks: Events, Signatures, and Idempotency

Subscribe to email events, verify Standard Webhooks signatures, and handle retries idempotently.

Updated

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

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 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

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

Every delivery is a POST with a JSON body and a set of signing headers, which the event catalog lists. The body names the event type, when Samva published it, and the payload in data:

{
  "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

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:

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.

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:

ErrorUsual causeFix
WebhookSignatureErrorA body parser ran first and re-serialized the body, or the secret is wrongVerify the raw bytes before any parsing; copy the secret from the endpoint again
WebhookTimestampErrorYour server clock is off, or someone replayed an old requestFix the clock; do not widen the tolerance to hide a replay
InvalidWebhookHeadersErrorA proxy or gateway stripped a signing headerForward every signing header unchanged
InvalidWebhookSecretErrorThe secret is truncated or lacks its prefixStore 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

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.
create table webhook_events (
  id text primary key,
  received_at timestamptz not null default now()
);
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 does, so a retry cannot repeat it.

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 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

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

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 library or with the standard library alone, as the Python guide does.

Frequently Asked Questions

Related Resources

Send

Ship your first email today.

Transactional and product email through one typed API, with signed events, conversation threading, and deliverability handled.