@portable/webhooks
Functions
Webhooks.sign
Signs a body the way the sender does and returns the headers to send with it. Use it to test a webhook handler, or to send Standard Webhooks.
async function sign(
body: string | Uint8Array,
options: sign.Options,
): Promise<Record<string, string>>;| Option | Type | Default | Description |
|---|---|---|---|
scheme | Scheme | Webhooks.standard | How the sender signs. |
secret | Secret | The signing secret. | |
hmac | any | WebCrypto.hmac | Computes the HMAC, called with 'SHA-256'. @noble/hashes is sync and several times faster on Node. |
now | any | new Date() | The time to sign, as a Date or a Temporal.Instant. Schemes without a timestamp ignore it. |
id | string | crypto.randomUUID() | The delivery id. Schemes without an id ignore it. |
signal | AbortSignal | Rejects with signal.reason when aborted. |
Throws
RangeErrorWhen the secret is empty or not valid base64, ornowis an invalid time.TypeErrorWhen the scheme isn't a scheme object.
import * as Webhooks from '@portable/webhooks';
const body = JSON.stringify({ type: 'user.created' });
const headers = await Webhooks.sign(body, {
secret: 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw',
});
await fetch('https://example.com/webhooks', {
method: 'POST',
body,
headers,
});Webhooks.verify
Checks a webhook's signature and timestamp, and returns its body. A Request's body is read,
so it can't be read again.
async function verify(
input: verify.Input,
options: verify.Options,
): Promise<verify.ReturnType>;| Option | Type | Default | Description |
|---|---|---|---|
scheme | Scheme | Webhooks.standard | How the sender signs. |
secret | Secret | readonly Secret[] | The signing secret. Pass several while rotating secrets. Any one of them may match. | |
hmac | any | WebCrypto.hmac | Computes the HMAC, called with 'SHA-256'. @noble/hashes is sync and several times faster on Node. |
tolerance | number | 300000 (five minutes) | Largest allowed difference between the signed timestamp and now, in milliseconds, in either direction. Timestamps are whole seconds, so the difference is too. Infinity turns the check off. The check is skipped when the scheme reads no timestamp. |
now | any | new Date() | The time to compare the signed timestamp with, as a Date or a Temporal.Instant. |
maxBytes | number | Infinity | The longest body verify reads from a Request, in bytes. A longer body throws MaxBytesError. |
signal | AbortSignal | Rejects with signal.reason when aborted. |
Throws
VerificationErrorWhen a header is missing or malformed, no signature matches, or the timestamp is outsidetolerance.RangeErrorWhen a secret is empty or not valid base64, ornowis an invalid time.TypeErrorWhen the scheme isn't a scheme object, or the body isn't a string orUint8Array.
import * as Webhooks from '@portable/webhooks';
export default {
async fetch(
request: Request,
env: { STRIPE_WEBHOOK_SECRET: string },
) {
const { body } = await Webhooks.verify(request, {
scheme: Webhooks.stripe,
secret: env.STRIPE_WEBHOOK_SECRET,
});
const event = JSON.parse(body);
return new Response(null, { status: 204 });
},
};Constants
Webhooks.github
GitHub's x-hub-signature-256: sha256=<hex> header, signed over the body alone, with the
x-github-delivery id. The secret is UTF-8 text. GitHub signs no timestamp, and its SHA-1
x-hub-signature header is ignored.
const github: Scheme;import * as Webhooks from '@portable/webhooks';
const { body, id } = await Webhooks.verify(request, {
scheme: Webhooks.github,
secret,
});Webhooks.slack
Slack's x-slack-signature: v0=<hex> and x-slack-request-timestamp headers, signed over
v0:{timestamp}:{body}. The signing secret is UTF-8 text.
const slack: Scheme;import * as Webhooks from '@portable/webhooks';
const { body } = await Webhooks.verify(request, {
scheme: Webhooks.slack,
secret,
});
const command = new URLSearchParams(body);Webhooks.standard
Standard Webhooks, used by Svix, Resend, Clerk and others.
Reads webhook-id, webhook-timestamp and webhook-signature, or the svix- headers when
those are missing, and signs {id}.{timestamp}.{body}. The secret is base64, with or without
the whsec_ prefix. v1a (ed25519) signatures are ignored. This is the default scheme.
const standard: Scheme;import * as Webhooks from '@portable/webhooks';
const { body } = await Webhooks.verify(request, {
secret: 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw',
});Webhooks.stripe
Stripe's stripe-signature: t=<seconds>,v1=<hex> header, signed over {timestamp}.{body}.
The secret is used as UTF-8 text, whsec_ prefix included.
const stripe: Scheme;import * as Webhooks from '@portable/webhooks';
const { body } = await Webhooks.verify(request, {
scheme: Webhooks.stripe,
secret,
});Errors
Webhooks.MaxBytesError
Thrown by verify when the request body is longer than maxBytes. Respond with a 413 status.
class MaxBytesError extends Errors.MaxBytesError {
readonly kind: string;
}import * as Webhooks from '@portable/webhooks';
try {
await Webhooks.verify(request, {
scheme: Webhooks.github,
secret,
maxBytes: 1_000_000,
});
} catch (error) {
if (error instanceof Webhooks.MaxBytesError) {
return new Response(null, { status: 413 });
}
throw error;
}Webhooks.VerificationError
Thrown when a webhook fails verification. Respond with a 4xx status. A scheme's parse throws
it for a missing or malformed header.
class VerificationError extends Errors.BaseError {
constructor(
options: ErrorOptions & {
code: VerificationError.Code;
message: string;
},
);
readonly kind: string;
readonly code: VerificationError.Code;
}import * as Webhooks from '@portable/webhooks';
try {
await Webhooks.verify(request, { scheme: Webhooks.github, secret });
} catch (error) {
if (error instanceof Webhooks.VerificationError) {
return new Response(error.code, { status: 400 });
}
throw error;
}Types
Webhooks.Scheme
How one sender signs webhooks, as plain functions. verify and sign do the rest the same way
for every scheme: HMAC-SHA256 with the hmac option, the length check and constant-time
compare, several secrets, and the timestamp tolerance.
Use standard (the default), stripe, github or slack, or write one for another sender.
stripe is a complete example. See the README.
type Scheme = {
parse: (headers: Headers) => Scheme.Signed;
payload: (signed: {
id: string | undefined;
timestamp: Date | undefined;
body: Uint8Array;
}) => Uint8Array | string;
headers: (signed: {
signature: Uint8Array;
id: string;
timestamp: Date;
}) => Record<string, string>;
key?: ((secret: string) => Uint8Array) | undefined;
};Webhooks.Secret
A signing secret as the sender shows it, or the raw key bytes. The scheme's key reads a
string as base64 for Standard Webhooks and as UTF-8 text for the others.
type Secret = string | Uint8Array;