@effected/webhooks

中文文档
0.6.0发布于 2026/11/10MITTypeScriptES Module构建来源已验证近 30 天下载 1980

Inbound webhook signature verification for Effect v4: Standard Webhooks, GitHub, Stripe, Sanity, HMAC header and shared-secret presets.

原始使用文档
文档目录17 节
  1. Why @effected/webhooks
  2. Install
  3. What to provide
  4. Build a WebhookRequest
  5. Verify a delivery
  6. GitHub
  7. Stripe
  8. Standard Webhooks (Svix, Clerk, Resend)
  9. Sanity
  10. Linear
  11. A custom HMAC header
  12. A shared secret
  13. Archive and replay a shared-secret delivery
  14. Errors
  15. Testing
  16. What it does not do
  17. License

@effected/webhooks

npm License: MIT Node.js %3E%3D24.11.0

Verify that an inbound webhook delivery was signed by the sender it claims to come from, for Effect v4. Presets cover Standard Webhooks (Svix, and so Clerk and Resend), GitHub, Stripe and Sanity. A generic HMAC header covers Linear and other bare-HMAC senders, and a shared-secret header covers Cloudflare. Every verifier is a function over plain data (a header lookup, the raw body, the receive time and the secret), with no HTTP framework and no node: import, so it runs in a Node server and a Cloudflare Worker alike.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 and still in 0.x development. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version. Full policy: release strategy.

Why @effected/webhooks

A webhook signature is an HMAC, and core effect/crypto's Mac computes one. The hard part is each vendor's scheme around it, which is easy to get subtly wrong in ways that still look valid:

  • The signed bytes. Stripe and Sanity sign ${t}. then the body, and Standard Webhooks signs ${id}.${timestamp}. then the body. Each must be the raw body bytes, never a re-serialized parse.
  • The secret. Standard Webhooks base64-decodes its whsec_ secret into the key. Stripe uses its whsec_ secret as the literal key string. Swap the two and every signature silently fails to match.
  • The header grammar. Several v1 signatures may be present during a secret rotation, v0 and SHA-1 headers must be ignored, and the last t wins.
  • The order. The signature is checked before the timestamp, so an unsigned probe learns nothing about the tolerance window.
  • The clock. Staleness is judged against the time you received the delivery, not the wall clock, so a delivery replayed from a queue verifies as it did on arrival.

If you only receive from a sender that HMACs the body with no timestamp, HmacHeader here is a few lines, and Hmac.verify from @effected/crypto is the same check one level down. Use core Mac alone only when you are comfortable writing the decoding and the constant-time comparison yourself.

Install

npm install @effected/webhooks effect
pnpm add @effected/webhooks effect

On Node, requires Node.js >=24.11.0; it also runs on any other WebCrypto runtime, Cloudflare Workers included. effect v4 is the only peer, and @effected/crypto comes with it. All @effected/* packages are ESM-only.

What to provide

OperationRequires
StandardWebhooks.verify, GitHub.verify, Stripe.verify, Sanity.verify, HmacHeader.verify, SharedSecret.verify, SharedSecret.attest, SharedSecret.verifyAttestedMac.Mac
Webhooks.checkFreshnessnothing

No verifier needs Digest or SecureRandom itself; SecureRandom is what the WebCrypto Mac layer is built on.

ServiceAny WebCrypto runtime (Workers included)Node
Mac.MacMac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)) from effect/cryptoNodeCrypto.layerMac from @effect/platform-node
import { Layer } from "effect";
import { Mac, SecureRandom } from "effect/crypto";

const MacLive = Mac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto));

Build a WebhookRequest

Every verifier takes a WebhookRequest: a header lookup that the presets call with lower-case names, the raw body bytes, the receivedAt time as a DateTime.Utc, and the endpoint's secret as a Redacted string, in the form the vendor shows it. Adapting one from a fetch Request, as a Worker or any fetch-based server receives it:

import type { WebhookRequest } from "@effected/webhooks";
import { DateTime, Effect, Option } from "effect";
import type { Redacted } from "effect";

const fromRequest = (request: Request, secret: Redacted.Redacted<string>) =>
  Effect.gen(function* () {
    const body = new Uint8Array(yield* Effect.promise(() => request.arrayBuffer()));
    const receivedAt = yield* DateTime.now;
    const delivery: WebhookRequest = {
      header: (name) => Option.fromNullishOr(request.headers.get(name)),
      body,
      receivedAt,
      secret,
    };
    return delivery;
  });
// Effect<WebhookRequest>

The adapter does not need to trim or filter. The presets treat an empty value as absent, and the signature presets trim the value and treat a whitespace-only one as absent too. When you reprocess a stored delivery, pass its original arrival time as receivedAt.

Verify a delivery

GitHub

import type { WebhookRequest } from "@effected/webhooks";
import { GitHub } from "@effected/webhooks";

const verifyGitHub = (delivery: WebhookRequest) => GitHub.verify(delivery);
// Effect<void, WebhookVerificationError, Mac>

Checks x-hub-signature-256: sha256= and the lower-case hex HMAC-SHA256 of the body under the secret's UTF-8 bytes. The SHA-1 x-hub-signature is ignored. GitHub signs no timestamp, so a replay looks exactly like a redelivery; deduplicate on x-github-delivery.

Stripe

import type { WebhookRequest } from "@effected/webhooks";
import { Stripe } from "@effected/webhooks";

const verifyStripe = (delivery: WebhookRequest) => Stripe.verify(delivery, { tolerance: "10 minutes" });
// Effect<StripeVerified, WebhookVerificationError, Mac>

Checks stripe-signature, a list of key=value pairs. The last valid t (Unix seconds) wins, any v1 may match, which covers a secret roll when Stripe signs with both secrets, and v0 is ignored. The key is the whsec_ secret string itself, undecoded. StripeVerified carries the signed timestamp. tolerance is any Duration.Input and defaults to Webhooks.DEFAULT_TOLERANCE, five minutes either way.

Standard Webhooks (Svix, Clerk, Resend)

import type { WebhookRequest } from "@effected/webhooks";
import { StandardWebhooks } from "@effected/webhooks";

const verifySvix = (delivery: WebhookRequest) => StandardWebhooks.verify(delivery);
// Effect<StandardWebhooksVerified, WebhookVerificationError, Mac>

Reads svix-id, svix-timestamp and svix-signature, each falling back to its unbranded webhook- name. The signature header is a space-separated list of v1,<base64>, any of which may match. The secret is whsec_ (the prefix is optional) followed by strict, padded base64, and its decoded bytes are the key. StandardWebhooksVerified carries the message id, which the sender repeats on every retry, so deduplicate on it, and the signed timestamp.

Sanity

import type { WebhookRequest } from "@effected/webhooks";
import { Sanity } from "@effected/webhooks";

const verifySanity = (delivery: WebhookRequest) => Sanity.verify(delivery);
// Effect<SanityVerified, WebhookVerificationError, Mac>

Checks sanity-webhook-signature, t=<unix milliseconds>,v1=<signature> with exactly one v1: the unpadded base64url HMAC-SHA256 of ${t}. and the body, under the secret's UTF-8 bytes. Note that t is in milliseconds, unlike Stripe's. SanityVerified carries the signed timestamp.

Linear

There is no Linear preset, because Linear needs none: it signs the body with a bare hex HMAC in linear-signature, which HmacHeader checks, and puts its timestamp inside the JSON body as webhookTimestamp, in milliseconds. Verify the signature, decode the body, then check freshness with Webhooks.checkFreshness:

import type { WebhookRequest } from "@effected/webhooks";
import { HmacHeader, Webhooks } from "@effected/webhooks";
import { Effect, Schema } from "effect";

const LinearPayload = Schema.Struct({ action: Schema.String, webhookTimestamp: Schema.Number });

const verifyLinear = (delivery: WebhookRequest) =>
  Effect.gen(function* () {
    yield* HmacHeader.verify(delivery, { header: "linear-signature", encoding: "hex" });
    const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(LinearPayload))(
      new TextDecoder().decode(delivery.body),
    );
    yield* Webhooks.checkFreshness(payload.webhookTimestamp, delivery.receivedAt, Webhooks.DEFAULT_TOLERANCE);
    return payload;
  });
// Effect<{ readonly action: string; readonly webhookTimestamp: number }, WebhookVerificationError | SchemaError, Mac>

checkFreshness takes a DateTime or epoch milliseconds. A number no DateTime can represent (out of range, NaN, infinite) is stale_timestamp, never a throw, so a sender-supplied value can go straight in. An optional fourth argument names the preset on the error; it defaults to HmacHeader.

A custom HMAC header

import type { WebhookRequest } from "@effected/webhooks";
import { HmacHeader } from "@effected/webhooks";

const verifyCustom = (delivery: WebhookRequest) =>
  HmacHeader.verify(delivery, { header: "x-signature", encoding: "base64", algorithm: "SHA-512", prefix: "sha512=" });
// Effect<void, WebhookVerificationError, Mac>

For any sender that HMACs the body alone under the secret's UTF-8 bytes. header is the lower-case header name, encoding is hex or padded base64, algorithm is SHA-256 (the default) or SHA-512, and prefix is a literal the value starts with. The signature is decoded and compared as bytes, so hex in either case verifies; unpadded base64 does not decode and is bad_signature. The four vendor presets instead compare the encoded strings, because their senders write only one spelling.

A shared secret

import type { WebhookRequest } from "@effected/webhooks";
import { SharedSecret } from "@effected/webhooks";

const verifyCloudflare = (delivery: WebhookRequest) => SharedSecret.verify(delivery, { header: "cf-webhook-auth" });
// Effect<void, WebhookVerificationError, Mac>

For a sender that puts the secret itself in a header, such as Cloudflare's cf-webhook-auth, or a path secret your proxy moves into one. It compares with ConstantTime.secretsEqual from @effected/crypto, which MACs both sides under a fresh key first, so neither the secret's length nor a matching prefix leaks. That is why it needs Mac too. The value is compared exactly as the lookup returns it, never trimmed, because trimming would loosen a secret comparison. A fetch Request's Headers already strips surrounding whitespace before your lookup sees it, so through the adapter above a padded secret matches.

Archive and replay a shared-secret delivery

A shared-secret vendor's "signature" is the route secret itself, so an archive of raw deliveries must never keep that header, and without it an archived delivery cannot be re-verified on replay. SharedSecret.attest mints a proof to archive in its place, and SharedSecret.verifyAttested accepts either the live secret or that proof:

import type { WebhookRequest } from "@effected/webhooks";
import { SharedSecret } from "@effected/webhooks";
import { Effect } from "effect";

const route = { header: "cf-webhook-auth", attestation: "x-archive-attestation", context: ["cloudflare", "production"] };

// On arrival: verify the live secret, then mint the attestation to archive instead of the secret header.
const receive = (delivery: WebhookRequest) =>
  Effect.gen(function* () {
    yield* SharedSecret.verify(delivery, { header: route.header });
    return yield* SharedSecret.attest(delivery, { context: route.context });
  });
// Effect<string, WebhookVerificationError | AttestationContextError, Mac>

// On arrival or on replay: the live secret when the delivery carries it, else the archived attestation.
const verifyRoute = (delivery: WebhookRequest) => SharedSecret.verifyAttested(delivery, route);
// Effect<void, WebhookVerificationError | AttestationContextError, Mac>

An attestation is the lower-case hex HMAC-SHA256, under the route secret's UTF-8 bytes, of a domain-separated prefix followed by the raw body. The prefix is effected-attestation.v1.<count>.<part>.<part>.: the versioned tag (SharedSecret.ATTESTATION_TAG), the number of context parts, then each part, every one followed by .. For ["cloudflare", "production"] it is effected-attestation.v1.2.cloudflare.production.. The tag keeps an attestation from doubling as any vendor's HMAC of the body under a reused secret, the context binds it to its route, and the count keeps a part from moving across the boundary into the body. Each context part must be non-empty and must not contain .; otherwise both functions fail with AttestationContextError (with the offending index and a reason of empty or contains_separator), before any header is read.

verifyAttested runs two steps. When header is given and the delivery carries it with a non-empty value, the live secret is compared raw, exactly as SharedSecret.verify compares it, and that answer is final: a wrong secret is bad_signature even beside a valid attestation, and a whitespace-only value is a wrong secret. Otherwise it reads the attestation header and checks it with Hmac.verify from @effected/crypto, which decodes the hex and compares bytes in constant time, so either case verifies. Neither present is missing_signature. Omit header to accept only the attestation. Archive the attestation under a header name the vendor never sends, and strip that header from what arrives on the wire, so an attestation is only ever read back from your own archive. Forging one needs the secret, so accepting it admits nothing the live secret would not.

Errors

Every preset's verify fails with WebhookVerificationError alone. It carries reason, the routing key; preset, the scheme being verified (StandardWebhooks, GitHub, Stripe, Sanity, HmacHeader or SharedSecret); and an optional human-readable detail. Route on reason, never on message or detail, which never carry the secret, a signature or the body.

reasonMeaningTypical status
missing_signatureThe signature header, or for SharedSecret the secret header, is absent or empty.401
bad_signatureNo signature matches, or the signature header is present but off its grammar: a GitHub value that is not sha256= and 64 lower-case hex digits, a Stripe header with no t or no v1, a Sanity header that is not t=<ms>,v1=<sig>, a missing HmacHeader prefix, or a signature that does not decode.401
malformedA header the scheme needs besides the signature is absent or ill-shaped: a Standard Webhooks id or timestamp, or a timestamp that is not 1 to 15 digits of Unix seconds.400
stale_timestampThe signature matches, but the signed timestamp sits outside the tolerance of receivedAt, either way. The boundary counts as fresh.400
import type { WebhookVerificationError } from "@effected/webhooks";

const statusFor = (error: WebhookVerificationError): 400 | 401 =>
  error.reason === "missing_signature" || error.reason === "bad_signature" ? 401 : 400;

Because the signature is checked first, a stale delivery with a bad signature reports bad_signature. SharedSecret.attest and SharedSecret.verifyAttested can also fail with AttestationContextError for a context part that is empty or contains .; it is typed because a context is usually built from routing, so a caller can answer it rather than crash. A wiring mistake is a defect, not a typed failure: a Standard Webhooks secret that is not whsec_ base64, an empty secret for an HMAC preset (which core Mac refuses as InvalidKey), or a CryptoError from a misbehaving Mac layer. On the WebCrypto layer, a runtime with no crypto.subtle dies too. WebhookVerificationReason and WebhookPreset are exported as schemas.

Testing

The package ships no test double. Provide MacLive from What to provide and build the delivery in the test, signing it with an implementation other than the one under test, such as node:crypto's createHmac, or the vendor's own SDK where one exists (stripe's generateTestHeaderString, svix's Webhook.sign). Pass a fixed receivedAt and sign with a timestamp relative to it, and no test needs TestClock. On Mac.layerWebCrypto every verify awaits a real promise, so a test that forks one and then adjusts TestClock can hang: await it, or gate the clock on a latch. Test the failure paths as well as the happy one: change each part of the signed bytes in exactly one way and assert bad_signature.

What it does not do

  • Decide delivery identity. It returns the parts of the signature (the message id and timestamp where the scheme has them); deduplication, routing and vendor naming are yours.
  • Decode the payload. Decode the verified body with your own schema.
  • Wrap an HTTP framework. The input is plain data, so it fits core effect/http, a Workers Request or a test without an adapter package.
  • Read the wall clock. Freshness is always judged against the receivedAt you pass.

License

MIT

版本与发布记录

@effected/webhooks 共发布 10 个版本,首次发布于 2026/10/23;过去 12 个月发布了 10 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.6.0latest2026/11/104 天前
0.5.02026/11/095 天前
0.4.02026/11/0311 天前
0.3.02026/11/0113 天前
0.2.02026/10/2916 天前
0.1.32026/10/2619 天前

关于 @effected/webhooks 的常见问题

@effected/webhooks 是什么?

Inbound webhook signature verification for Effect v4: Standard Webhooks, GitHub, Stripe, Sanity, HMAC header and shared-secret presets.

如何安装 @effected/webhooks?

在项目目录运行 npm install @effected/webhooks,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/webhooks 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/webhooks 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/webhooks 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/webhooks 可以商用吗?

@effected/webhooks 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/webhooks 的下载量有多少?

根据 npm 官方下载统计,@effected/webhooks 在 2026/10/13 – 2026/11/11 这 30 天内被下载 1,980 次,日均约 66 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/webhooks 还在维护吗?

最新版本 0.6.0 发布于 2026/11/10(4 天前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/10/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。