Receive webhooks

Create an endpoint, verify each signed request, and process retried deliveries once.

Webhooks push email events, such as deliveries, bounces, and inbound replies, to an HTTPS endpoint in your application. This guide creates an endpoint, verifies its requests, and handles retries. The webhook event catalog lists every event, its payload, and the delivery limits.

1. Create an endpoint

Send a public HTTPS URL and the event types it should receive. Add channels: ["email"] to receive only email events, or leave it out to receive every channel.

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

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

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

// Store this now. Samva does not return it again.
const signingSecret = created.secret;
curl -X POST https://api.samva.dev/v1/webhooks \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order events",
    "url": "https://your-app.com/webhooks/samva",
    "eventTypes": ["message.delivered", "message.bounced", "message.failed", "message.opened", "message.clicked"],
    "channels": ["email"]
  }'

Keep the signing secret on your server, for example as SAMVA_WEBHOOK_SECRET. To replace it, call webhooks.regenerateSecret; the previous secret keeps working for a limited overlap so you can deploy the new one.

The URL must be HTTPS, must not contain credentials, and must resolve only to public addresses. Samva does not follow redirects, so register the URL that answers the request. You can add custom headers, such as a token your gateway checks; they cannot replace the signature headers. Samva stores custom header values encrypted and never returns them: endpoint responses report only hasCustomHeaders.

message.opened and message.clicked are emitted for tracked sends when tracking is enabled for the effective send settings and recipient preference. Apple Mail Privacy Protection can produce automatic opens, and security scanners can produce automatic opens and clicks, often the first observation. Likely automatic observations carry machine: true and a machineReason; they still produce webhooks. See the event catalog for payloads and interpretation.

contact.unsubscribed and contact.resubscribed report each changed unsubscribe group. They include the source and may include a validated message and campaign; see the event catalog.

2. Verify each request

Verify the signature against the exact raw body before you parse it. verifyRequest from samva/webhooks reads the headers and body of a Fetch API Request, checks the signature and timestamp, and returns the event.

app/webhooks/samva/route.ts
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;
  }
}

For Express and other frameworks without a Request, and for Effect code, see Verify webhooks.

3. Process each event once

Delivery is at least once and unordered, and a failed attempt is retried on a fixed schedule. Every attempt of an event carries the same verified.id, so record it under a unique constraint before you apply side effects, and skip an ID you have already recorded.

app/webhooks/samva/handle-once.ts
import type { SamvaWebhookEvent } from "samva/webhooks";

// Your storage: `recordOnce` inserts the ID under a unique constraint and
// resolves to false when the ID was already there.
import { markUndeliverable, recordOnce } from "./store";

export async function handleOnce(id: string, event: SamvaWebhookEvent) {
  if (!(await recordOnce(id))) return;

  if (event.type === "message.bounced" || event.type === "message.failed") {
    await markUndeliverable(event.data);
  }
}

Return a 2xx status once the event is stored, and do slow work afterward: an answer that arrives after the response timeout counts as a failure and is retried. Branch on event.type and ignore types you do not handle.

4. Test the endpoint

Send a webhook.test event with webhooks.test, or from Developers → Webhooks in the dashboard. Delivery logs show each attempt's status code and duration, and you can redeliver an event with webhooks.retryDelivery. An endpoint that fails for too long is disabled; the delivery rules say when and how to turn it back on.

Next steps

Related resources

  • Inbound email API

    See how replies arrive as parsed, threaded conversations and signed webhook events.

Related documentation

On this page