Skip to content
LogoLogo

@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>>;
OptionTypeDefaultDescription
schemeSchemeWebhooks.standardHow the sender signs.
secretSecretThe signing secret.
hmacanyWebCrypto.hmacComputes the HMAC, called with 'SHA-256'. @noble/hashes is sync and several times faster on Node.
nowanynew Date()The time to sign, as a Date or a Temporal.Instant. Schemes without a timestamp ignore it.
idstringcrypto.randomUUID()The delivery id. Schemes without an id ignore it.
signalAbortSignalRejects with signal.reason when aborted.

Throws

  • RangeError When the secret is empty or not valid base64, or now is an invalid time.
  • TypeError When 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>;
OptionTypeDefaultDescription
schemeSchemeWebhooks.standardHow the sender signs.
secretSecret | readonly Secret[]The signing secret. Pass several while rotating secrets. Any one of them may match.
hmacanyWebCrypto.hmacComputes the HMAC, called with 'SHA-256'. @noble/hashes is sync and several times faster on Node.
tolerancenumber300000 (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.
nowanynew Date()The time to compare the signed timestamp with, as a Date or a Temporal.Instant.
maxBytesnumberInfinityThe longest body verify reads from a Request, in bytes. A longer body throws MaxBytesError.
signalAbortSignalRejects with signal.reason when aborted.

Throws

  • VerificationError When a header is missing or malformed, no signature matches, or the timestamp is outside tolerance.
  • RangeError When a secret is empty or not valid base64, or now is an invalid time.
  • TypeError When the scheme isn't a scheme object, or the body isn't a string or Uint8Array.
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;