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