Skip to content
Webhooks

Email webhooks: events, signature verification, and idempotent handlers

Subscribe to email delivery events, verify the webhook signature, and write an idempotent handler that survives retries, with runnable TypeScript.

Published

Email is asynchronous. Your send call returns in a few hundred milliseconds, but whether the message reaches the inbox is decided later, by a mail server you do not control. A webhook is how the API tells your application what happened after the send: the message was delivered, it bounced, it failed, or a reply came back. You register an HTTPS endpoint, subscribe to the events you care about, and Samva posts a signed JSON body to it as each event fires.

This guide covers the email events you can subscribe to, the shape of a webhook delivery, how to verify the signature, and how to write a handler that stays correct when the same event arrives twice. The examples use Samva, but the mechanics of signed webhooks and idempotent handling apply to any provider you integrate.

Why webhooks beat polling for delivery state

The response to a send tells you the message was accepted. It does not tell you the message landed. message.sent is the handoff to the sending provider, not confirmation that the recipient received anything. Delivery, bounce, and failure resolve afterward, sometimes seconds later and sometimes minutes later when a receiving server defers and retries.

You could poll the message by id on a loop and wait for its status to change. That burns requests on every send, lags behind the real event, and still leaves you watching for a bounce that may arrive long after you stopped checking. Webhooks invert the flow. Samva posts the event to your endpoint the moment it resolves, so a single endpoint tracks delivery state for thousands of sends with no polling loop. When a bounce arrives, you suppress the address in your handler. When a delivery confirms, you update the record. You react to the outcome instead of asking for it.

The email events you can subscribe to

A message moves through a lifecycle, and each transition emits an event you can subscribe to:

EventFires when
message.sentA provider accepted the email for delivery.
message.deliveredThe recipient's mail server confirmed delivery.
message.bouncedAn accepted email was rejected, or the recipient marked it as spam.
message.failedThe email could not be delivered.
message.receivedAn inbound email was routed to you and threaded into a conversation.

Two distinctions matter when you decide what to act on. message.sent and message.delivered are different milestones: the first is acceptance by the provider, the second is the recipient's server confirming receipt. And message.bounced and message.failed cover different failures. A bounce fires when the recipient's server rejects a message it already accepted, or when the recipient files a spam complaint. A failure fires when the email could not be delivered at all, which includes a provider error, a suppressed recipient, or an error while processing the send. Route a bounce to suppression and reputation logic; route a failure to your retry and alerting path.

Subscribe only to the events your application acts on, and pass them in the events array when you register the endpoint. You can register more than one endpoint to send different events to different services.

Register an endpoint

Create a webhook by posting the public URL Samva should call and the list of events you want delivered to it. With the TypeScript SDK:

import { createClient } from "samva";

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

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

if (result.error) {
  console.error("Failed to register webhook:", result.error);
} else {
  console.log("Registered webhook:", result.data?.id);
}

The same call over the REST API:

curl -X POST https://api.samva.app/v1/webhooks \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Delivery events",
    "url": "https://your-app.com/webhooks/samva",
    "events": ["message.delivered", "message.bounced", "message.failed"]
  }'

The endpoint has a signing secret. Store it in your environment (for example as SAMVA_WEBHOOK_SECRET) so your handler can verify every request before trusting it.

Anatomy of a webhook delivery

Each delivery is a POST with three headers that describe it and a JSON body in a fixed envelope. The headers:

POST /webhooks/samva HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: sha256=<hex digest>
X-Webhook-Event: message.delivered
X-Webhook-Id: <endpoint id>

X-Webhook-Signature carries the HMAC you verify. X-Webhook-Event names the event type and X-Webhook-Id names the endpoint the delivery targets, so you can route requests without parsing the body first.

The body wraps every event in the same envelope. event is the type string, messageId is the message the event is about, timestamp is the delivery time in ISO 8601, and data carries the event-specific fields:

{
  "event": "message.delivered",
  "messageId": "3f2a9c1e-7b4d-4e8a-9c2f-1a2b3c4d5e6f",
  "timestamp": "2026-01-15T09:42:14.882Z",
  "data": {
    "channel": "email",
    "status": "delivered",
    "providerMessageId": "0100018f2a...-000000",
    "subject": "Welcome to Samva",
    "toEmails": ["ada@example.com"]
  }
}

Treat the fields inside data as optional. The object is populated on a best-effort basis, so a field is present only when the underlying provider event supplied it. Read providerMessageId or subject defensively and fall back cleanly when they are absent.

Verify the signature

Any endpoint on the public internet receives forged requests, so verify every webhook before you act on it. The signature is an HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret, hex-encoded and prefixed with sha256=. Recompute the HMAC over the exact bytes you received, build the same sha256=<hex> string, and compare the two in constant time.

import crypto from "crypto";
import express from "express";

const app = express();

function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
  const hmac = crypto.createHmac("sha256", secret);
  const expected = `sha256=${hmac.update(payload).digest("hex")}`;
  const signatureBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);
  // timingSafeEqual throws on differing lengths; guard before comparing.
  if (signatureBuffer.length !== expectedBuffer.length) return false;
  return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}

// Register the raw body parser so req.body is the exact bytes Samva signed.
// A JSON parser would re-serialize the payload and break the HMAC.
app.post("/webhooks/samva", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  const secret = process.env.SAMVA_WEBHOOK_SECRET;
  const rawBody = req.body.toString("utf8");

  if (typeof secret !== "string" || secret.length === 0) {
    return res.status(500).send("Webhook secret is not configured");
  }

  if (typeof signature !== "string" || !verifyWebhookSignature(rawBody, signature, secret)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(rawBody);
  // Signature verified; handle the event, then acknowledge.
  res.status(200).send("OK");
});

Two details make or break this. Compare in constant time with crypto.timingSafeEqual rather than ===, so an attacker cannot learn the secret from how long a comparison takes. And run the HMAC over the raw body: if your framework parses the JSON and re-serializes it, the bytes change and verification fails on legitimate requests. The express.raw parser above hands you the exact buffer Samva signed.

If you run on a Web Request runtime, the samva SDK ships a verifier so you do not maintain the HMAC yourself. The samva/webhooks export uses WebCrypto, so the same code runs on Node, Bun, and edge runtimes:

import { verifyRequest, WebhookVerificationError } from "samva/webhooks";

export async function POST(req: Request) {
  try {
    const event = await verifyRequest(req, process.env.SAMVA_WEBHOOK_SECRET!);
    switch (event.event) {
      case "message.delivered":
        // mark the send delivered
        break;
      case "message.bounced":
        // add the recipient to your suppression list
        break;
    }
    return new Response(null, { status: 200 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response("invalid signature", { status: 400 });
    }
    throw err;
  }
}

Retries and idempotent handlers

Samva treats any 2xx response as accepted. A non-2xx response, or no response at all, is retried by the delivery queue, and deliveries are at-least-once. Two consequences follow, and you design for both.

First, acknowledge fast. Return a 2xx as soon as you have verified and accepted the event, then run any slow work (writing to a database, calling another service, sending a follow-up email) asynchronously. If you block the response on that work and it stalls, Samva sees a slow or failed delivery and retries, so you end up processing the same event again.

Second, handle duplicates. Because delivery is at-least-once, your handler can receive the same event more than once, and it has to reach the same end state whether it runs once or three times. Dedupe on the event type and the messageId together. Keying on messageId alone is wrong: a single message emits several events across its lifecycle, so that key would collapse a delivery and a later bounce into one and drop the second.

const processed = new Set<string>();

function handleEvent(event: { event: string; messageId: string }): void {
  const key = `${event.event}:${event.messageId}`;
  if (processed.has(key)) return; // already handled this exact event
  processed.add(key);

  switch (event.event) {
    case "message.delivered":
      // mark the send delivered
      break;
    case "message.bounced":
      // add the recipient to your suppression list
      break;
    case "message.failed":
      // log and alert
      break;
  }
}

The in-memory Set above shows the shape. In production, replace it with a durable store: a row keyed on (event, messageId) under a unique constraint, where the insert either succeeds and you process the event or conflicts and you skip it. That survives restarts and keeps two instances of your handler from processing the same delivery twice.

Testing webhooks locally

Your endpoint has to be reachable over HTTPS, which a localhost port is not. Expose your local server through an HTTPS tunnel, then register that public URL as a webhook endpoint the same way you would a production one. Point a separate endpoint at each environment so local test traffic never mixes with production deliveries.

With the endpoint registered, send a real email to an address you control and watch the events arrive. Log the raw request body and the X-Webhook-Signature header on the way in so you can confirm your handler reads the exact bytes it verifies. To exercise the retry path, return a non-2xx from your handler on purpose and watch the delivery come back, then fix the response and confirm it acknowledges. That loop verifies signature checking, event routing, and idempotency against live payloads before you move traffic to production.

Webhooks and inbound replies

message.received is a webhook event like the others, delivered to the same endpoint with the same signature and envelope, but it carries inbound mail rather than the state of something you sent. When a recipient replies, Samva parses the inbound email and posts a message.received event with the sender and recipient addresses, the subject, and the conversationId the reply was threaded into, so you route the message into the right conversation in your app instead of maintaining your own thread-matching logic.

That gives you a clean division. The delivery events, message.sent through message.failed, tell you what happened to mail you sent, so you track state and manage suppression. message.received tells you a customer wrote back, so you thread the reply into your product. Both arrive through the one signed webhook channel, so a single verified handler covers outbound delivery state and inbound replies together.

FAQ

Frequently Asked Questions

Related Resources

Get started

Ship your first email today.

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