@vibedout/webhooks
v0.2.1
Published
Webhooks in the Standard Webhooks format, sent and received, for Workers, browsers and Node: signing and verifying (webhook-id, webhook-timestamp, webhook-signature v1 with a rotation window), the event envelope, the 24-hour retry schedule, a target check
Downloads
287
Maintainers
Readme
@vibedout/webhooks
Outgoing webhooks in the Standard Webhooks format, written once: signing and verifying,
the event envelope, the 24-hour retry schedule, a check on where a webhook may be sent, and one delivery call that says what the
answer means. WebCrypto only; @vibedout/web-crypto and @vibedout/provider-kit are bundled, so there is no runtime dependency.
It is the core of the connectors feature (templates/_shared/blocks/connectors): the feature stores endpoints and runs the queue,
this package is what must not drift. Anything that receives these webhooks (the n8n node, a Zapier app, your own server) can use
verify from here, or the same ten lines in any language: see "Receiving".
import { deliver, deriveSecret, buildEnvelope, serializeEnvelope, retryDelaySeconds, matchesEvent } from "@vibedout/webhooks";Modules
| Module | What it is |
|---|---|
| secret | whsec_ + base64 of 24 to 64 bytes. generateSecret(), secretBytes(), and deriveSecret(master, endpointId, version): a secret computed from one server secret, so none is stored per endpoint and rotating is bumping the version |
| sign | sign, signatureHeader (several secrets, newest first, space separated: a rotation window), signedHeaders, and verify for a receiver (signature first, then the clock) |
| envelope | { id, type, timestamp, subject, data } and the exact text that is signed (serializeEnvelope) |
| schedule | retryDelaySeconds(attemptsMade): ten attempts over 24 hours, up to a tenth of each wait taken off at random |
| patterns | *, an exact type or domain.*; matchesEvent(patterns, type) |
| target | checkTarget(url): https only, no credentials, no loopback, private, link-local or reserved address in any spelling, no .local/.internal name, no host of your own |
| inbound | Receiving (50.16-T28): InboundSource (what a feature adds to the webhooks-in route: it verifies, claims and applies one delivery), the registry the generated barrel registers sources in (registerWebhookSource, read with webhookSources() inside a request), WebhookRefused (a delivery not to be trusted: status and reason), readCapped (a body read with a ceiling), InboundContext.atomic (the host's claims written in the same D1 batch as a delivery's effects, so a Worker that dies between a claim and its effect loses nothing: BatchedD1ReceiptStore of @vibedout/web-crypto, which the generic sources use and hand to their handle) and two generic sources: standardWebhooksSource (the format above) and hmacSource (a plain HMAC-SHA256 of the body, or of timestamp.body, with its header, prefix, encodings and id header chosen by the caller; a time header is allowed only with timestamp.body, because a time the signature does not cover proves nothing and the call throws). The registry lives on the isolate's global object under Symbol.for("@vibedout/webhooks/inbound.sources"), so two copies of the package in one Worker share it |
| deliver | deliver({ url, id, timestamp, body, secrets }): one signed POST with a timeout, no redirects, 2 KiB of the answer kept; the outcome is delivered (2xx), gone (410), retry (anything else) or blocked (the address is not allowed) |
The wire format
POST <endpoint url>
content-type: application/json
user-agent: vibedout-webhooks/1
webhook-id: 01K7... the event's id; the same on every retry and replay
webhook-timestamp: 1760000000 whole seconds, of THIS attempt
webhook-signature: v1,<base64> v1,<base64> HMAC-SHA256 of "{id}.{timestamp}.{body}"; two while a secret is being rotated
{"id":"01K7...","type":"order.paid","timestamp":"2026-10-15T12:00:00.000Z","subject":{"type":"order","id":"..."},"data":{...}}Any 2xx is a success. 410 Gone disables the endpoint (nothing is retried). Any other answer, a timeout or a refused connection is
retried at these moments after the first attempt: at once, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h. After the tenth the
delivery has failed. A receiver should answer quickly with a 2xx and work afterwards, and dedupe on webhook-id.
Receiving
import { verify } from "@vibedout/webhooks/sign";
const body = await request.text(); // the raw text: never JSON that was parsed and written again
const result = await verify(request.headers, body, { secrets: process.env.WEBHOOK_SECRET });
if (!result.ok) return new Response("bad signature", { status: 400 }); // result.reason: no_match, expired, ...In another language: HMAC-SHA256 over "{webhook-id}.{webhook-timestamp}.{body}" keyed by the base64-decoded part of the secret
(after whsec_), base64 of the result, compared in constant time with each v1, value of the header, and a timestamp within five minutes.
Where it is tested
pnpm --filter @vibedout/webhooks test (80 tests): the signature of the Standard Webhooks documentation example, Node's own HMAC as an
independent oracle, every spelling of a private address, the schedule's sums, and deliver against a recorded fetch (a 500, a 410, a
redirect, a timeout, a body that never ends), and inbound (the signature before the clock, a replay under a fresh id, a refusal that releases the claim, the atomic claim a source uses when the host has one, a body past its cap, the registry shared by two copies of the module).
