Verify webhooks
Reference for the samva/webhooks verifier functions, their options and results, and the errors they report.
samva/webhooks checks that a request came from Samva and was not changed or replayed, then returns
the parsed event. It implements Standard Webhooks verification
with Web Crypto, so the same code runs on Node, Bun, Deno, and Cloudflare Workers. The headers,
signature format, and timestamp tolerance it checks are listed in the
webhook event catalog.
bun add samva| Import | Use |
|---|---|
samva/webhooks | Promise functions. |
samva/webhooks/effect | The same checks as Effects, for Effect programs. |
Both entrypoints are also published on their own as @samva/webhooks and @samva/webhooks/effect,
with the same exports, for services that verify webhooks without calling the API.
verifyRequest
verifyRequest(request, secret, options?) verifies a Fetch API Request, as received by Next.js
route handlers, Hono, Cloudflare Workers, Bun, and Deno. It reads the body from a clone, so the
original request stays readable. It resolves to a VerifiedWebhook and rejects with a
verification error.
import { verifyRequest, WebhookVerificationError } from "samva/webhooks";
import { handleOnce } from "./handle-once";
export async function POST(request: Request) {
try {
const { id, event } = await verifyRequest(request, process.env.SAMVA_WEBHOOK_SECRET!);
await handleOnce(id, event);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(null, { status: 400 });
}
throw error;
}
}verify
verify(options) verifies a request from its raw body and header values, for frameworks that do
not expose a Request. The body must be the bytes exactly as received: a JSON body parser that runs
first re-serializes the body and the signature no longer matches.
import express from "express";
import {
verify,
WEBHOOK_ID_HEADER,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
WebhookVerificationError,
} from "samva/webhooks";
import { handleOnce } from "./handle-once";
const app = express();
app.post("/webhooks/samva", express.raw({ type: "application/json" }), async (req, res) => {
try {
const { id, event } = await verify({
body: req.body,
id: req.header(WEBHOOK_ID_HEADER),
timestamp: req.header(WEBHOOK_TIMESTAMP_HEADER),
signature: req.header(WEBHOOK_SIGNATURE_HEADER),
secret: process.env.SAMVA_WEBHOOK_SECRET!,
});
await handleOnce(id, event);
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) {
res.sendStatus(400);
return;
}
throw error;
}
});Options
| Option | Type | Notes |
|---|---|---|
body | string | Uint8Array | The raw request body. |
id | string | null | undefined | The WEBHOOK_ID_HEADER header value. Pass an absent header through as undefined or null; verification rejects it with InvalidWebhookHeadersError. |
timestamp | string | null | undefined | The WEBHOOK_TIMESTAMP_HEADER header value. |
signature | string | null | undefined | The WEBHOOK_SIGNATURE_HEADER header value, unmodified. |
secret | string | The endpoint's signing secret, including its prefix. See the secret format. |
toleranceSeconds | number | Optional. How far the timestamp may be from your clock, in seconds. Defaults to DEFAULT_TOLERANCE_SECONDS. |
verifyRequest takes toleranceSeconds in its third argument. Raise it only to absorb clock drift
on your server; a larger tolerance lets a captured request be replayed for longer.
safeVerify
safeVerify(options) runs verify and resolves to a result instead of rejecting: either
{ verified: true, value } with a VerifiedWebhook, or { verified: false, error } with
the verification error. Any other error, such as a TypeError from an undefined secret,
rejects.
import { safeVerify, type VerifyOptions } from "samva/webhooks";
import { handleOnce } from "./handle-once";
export async function receive(delivery: VerifyOptions): Promise<Response> {
const result = await safeVerify(delivery);
if (!result.verified) {
console.warn("Rejected webhook", result.error._tag);
return new Response(null, { status: 400 });
}
await handleOnce(result.value.id, result.value.event);
return new Response(null, { status: 204 });
}Result
Every function returns a VerifiedWebhook:
| Field | Type | Notes |
|---|---|---|
id | string | The event ID. It is the same on every attempt, so use it to process each event once. |
event.type | string | The event type. |
event.timestamp | string | When Samva published the event, ISO 8601. |
event.data | event-specific object | Known payloads narrow after isKnownEvent(event) and an event.type check. |
event is a WebhookEvent, combining the known SamvaWebhookEvent discriminated union with
UnknownWebhookEvent. Call isKnownEvent(event) first, then check event.type to narrow its
payload without a cast:
import { isKnownEvent, type WebhookEvent } from "samva/webhooks";
export function handleEvent(event: WebhookEvent) {
if (!isKnownEvent(event)) return;
switch (event.type) {
case "message.bounced":
console.log(event.data.recipient, event.data.occurredAt, event.data.failureReason);
break;
case "message.complained":
console.log(event.data.recipient, event.data.occurredAt);
break;
}
}Signature verification establishes authenticity. Signed event types introduced after your installed
package version pass through with data: unknown; isKnownEvent returns false. Extra envelope and
payload fields are accepted and preserved. Invalid envelopes and structurally invalid payloads for
known event types fail with MalformedWebhookPayloadError. Update the package to gain types for
new events.
Decode a stored event
The Effect entrypoint exports schemas generated from the same payload contracts as verification:
import { Schema } from "effect";
import { MessageBouncedEventSchema } from "samva/webhooks/effect";
const event = Schema.decodeUnknownSync(MessageBouncedEventSchema)(storedEvent);
console.log(event.data.recipient, event.data.occurredAt);Promise consumers can use WebhookEventJsonSchemas["message.bounced"] from samva/webhooks
with a JSON Schema validator. Per-event payload types such as MessageBouncedPayload and
ContactUnsubscribedPayload are exported from both entrypoints.
Errors
Every failure is a subclass of WebhookVerificationError with a _tag equal to its class name and a
detail string for your logs. Answer a failed verification with a 4xx status and do not echo
detail to the caller.
| Error | Reported when |
|---|---|
InvalidWebhookHeadersError | A signing header is missing or empty. Its header field names which one. |
WebhookTimestampError | The timestamp is not whole Unix seconds or is outside the tolerance. Check your server clock. |
WebhookSignatureError | No signature matches. Check the secret, and that the body reached the verifier unparsed. |
MalformedWebhookPayloadError | The signature matches but the body is not a known Samva event or its payload is invalid. |
InvalidWebhookSecretError | The secret you passed is not a valid signing secret. Copy it from Samva again. |
WebhookCryptoError | The runtime could not compute the signature, or the Effect entrypoint could not read the body. |
Effect
samva/webhooks/effect exports verify and verifyRequest with the same arguments. They succeed
with a VerifiedWebhook, fail with the errors above in the error channel, and read the time from
Effect's Clock, so TestClock controls the tolerance window in tests. Any other error, such as a
TypeError from an undefined secret, is a defect rather than a failure.
import { Effect } from "effect";
import * as Webhooks from "samva/webhooks/effect";
import { handleOnce } from "./handle-once";
export const receive = (request: Request, secret: string) =>
Webhooks.verifyRequest(request, secret).pipe(
Effect.flatMap(({ id, event }) => Effect.promise(() => handleOnce(id, event))),
Effect.as(new Response(null, { status: 204 })),
Effect.catchTags({
WebhookSignatureError: () => Effect.succeed(new Response(null, { status: 400 })),
WebhookTimestampError: () => Effect.succeed(new Response(null, { status: 400 })),
}),
);Other exports
WEBHOOK_ID_HEADER,WEBHOOK_TIMESTAMP_HEADER, andWEBHOOK_SIGNATURE_HEADERname the headers.DEFAULT_TOLERANCE_SECONDSis the tolerance used when you pass none.generateWebhookSecret()andsignWebhook({ body, id, timestamp, secret })create a secret and a signature header value, for testing your handler with requests you sign yourself.
To create endpoints and handle retries, see Receive webhooks.