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.deliveredto mark a message as landed.message.bouncedfor an address that does not exist, which Samva stops sending to.message.complainedfor a recipient who reported the email as spam.message.failedfor an email Samva did not send, or that the recipient's server never accepted.message.openedandmessage.clickedfor engagement observations on tracked sends.contact.unsubscribedandcontact.resubscribedfor 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:
| 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
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:
- 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. - Answer fast. Return a
2xxonce the event is stored and do slow work afterward. A late answer counts as a failure and the event is retried. - Read state, not order. Compare the event's
timestampwith what you stored before overwriting, so a latemessage.sentcannot undo an earliermessage.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
204for an event you handled, and for a type you do not handle. Ignoring an unknown type with a2xxstops a needless retry. - Return a
4xxonly 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.