← all parts

webhooks.ingest v1.0.1

Verify inbound webhooks — HMAC signature, timestamp window, replay defense — through a contract-stable interface with pluggable, attested signature-scheme adapters.

provides webhooks.ingest@1

Attestations

adapterstandardwebhooks
verified2026-06-11
expires2026-06-25
conformance18 tests
signaturedev (pre-v0)
adapterstripe
verified2026-06-11
expires2026-06-25
conformance18 tests
signaturedev (pre-v0)

Invariants

Testable claims, not adjectives — each maps to at least one named conformance test.

  • Importing the part performs no I/O and never throws; configuration is validated at call time with typed errors
  • A request whose HMAC signature does not verify over the exact raw payload bytes — tampered payload, tampered signature, or missing signature headers — is rejected with a typed error; signature comparison is timing-safe (crypto.timingSafeEqual)
  • A signed timestamp outside the tolerance window (default 300 seconds, past or future) is rejected
  • A delivery replayed within the tolerance window is rejected (v1: in-memory per instance — serverless limitation stated in SPEC.md)
  • Verification operates on the raw payload bytes as received; any re-serialization that changes a single byte fails verification
  • All failures surface as typed WebhookError values carrying a recommended HTTP status; secret values never appear in error messages
  • webhookHandler acknowledges 2xx only after every registered handler has completed; verification failures return 400 with a generic body; handler failures return 500 so the vendor redelivers

Interface

verifyWebhook(request: WebhookRequest): Promise<VerifiedWebhook> onWebhook(handler: (event: VerifiedWebhook) => void | Promise<void>): Unsubscribe webhookHandler(request: Request): Promise<Response> class WebhookError extends Error { code: WebhookErrorCode; status: number } types: WebhookRequest, VerifiedWebhook, WebhookErrorCode, Unsubscribe
mountPOST /api/webhooks/ingest → one-line re-export of webhookHandler

Environment

WEBHOOK_ADAPTERrequired · one of: stripe | standardwebhooks
WEBHOOK_SECRETrequired · secret
WEBHOOK_TOLERANCE_SECONDSoptional

Seams — what your app writes

Sufficient without reading src/; that is part of the quality bar.

# Seams — webhooks.ingest What YOUR app provides. Reading `contract.json` + this file is enough to wire the part — you never need to read `src/`. Never edit `src/` or `adapters/` (attested interiors; edits void the attestation and fail CI). ## 1. Environment | Var | Required | Notes | |---|---|---| | `WEBHOOK_ADAPTER` | yes | Must equal the vendored adapter — `partkit add` already set it in `.env.example`. | | `WEBHOOK_SECRET` | yes | Secret. The signing secret from your vendor's webhook settings (`whsec_…` for both schemes). | | `WEBHOOK_TOLERANCE_SECONDS` | no | Signed-timestamp window, default `300`. Widen only for vendors with documented delivery lag. | Which adapter for which vendor: `stripe` for Stripe; `standardwebhooks` for any Svix-delivered or Standard-Webhooks-compliant sender (Resend, Clerk, …). ## 2. Import path Add one tsconfig alias (recommended): ```jsonc // tsconfig.json → compilerOptions "paths": { "@parts/*": ["./parts/*/src"] } ``` Then: ```ts import { onWebhook, webhookHandler, verifyWebhook, WebhookError } from "@parts/webhooks.ingest"; ``` Plain relative imports of `parts/webhooks.ingest/src/index.js` work too. Never deep-import `src/internal/**` or `adapters/**` (lint-enforced). ## 3. The mount (the route seam) The contract declares one route: `POST /api/webhooks/ingest`. In Next App Router YOU own the route file — create `app/api/webhooks/ingest/route.ts`, register your handlers, and re-export the part's handler. Start from `examples/next-route.ts`, which is outside the boundary and freely copyable: ```ts import { onWebhook, webhookHandler } from "@parts/webhooks.ingest"; onWebhook(async (event) => { const body = JSON.parse(event.payload); // dispatch on YOUR vendor's event types }); export const POST = webhookHandler; ``` Rules that make this safe: - **Register at module scope, in the same file that mounts the handler.** Registration is re-evaluated per cold start (serverless-sanctioned). A mounted route with zero registered handlers answers `500` on purpose — the vendor keeps redelivering until your deploy is fixed, so no events are lost. - **The handler runs before the vendor gets its 2xx.** Keep handlers short (record + enqueue); a throwing handler answers `500` and the vendor redelivers. Make handlers idempotent — at-least-once delivery is the vendor's contract, and a redelivery after a crash WILL re-run them. - **Raw body is handled for you.** `webhookHandler` reads the exact raw bytes from the `Request`. Do not wrap it in middleware that parses or re-encodes the body. ## 4. Verifying without the mount (`verifyWebhook`) Using your own route or a non-Next runtime? Call `verifyWebhook` directly — the ONE rule is: pass the raw body string, never a parsed-then-re-serialized object (verification is over exact bytes and will rightly fail otherwise): ```ts export async function POST(req: Request) { const payload = await req.text(); // RAW body — before any .json() try { const event = await verifyWebhook({ payload, headers: req.headers }); // event.id, event.timestamp (the SIGNED time), event.payload } catch (e) { if (e instanceof WebhookError) return new Response(null, { status: e.status }); throw e; } return Response.json({ received: true }); } ``` ## 5. Error handling Every failure is a `WebhookError` with `.code` (`"config" | "missing_header" | "invalid_signature" | "timestamp_out_of_window" | "replayed" | "unknown"`) and `.status` — the HTTP status to answer the vendor with (400 = don't redeliver this request, 500 = our side is broken, do redeliver). Trust `event.timestamp` (it is signed); never trust timestamps inside the payload. ## 6. Replay defense — what v1 does and does not give you Signed-timestamp window plus an in-memory replay cache **per instance**. On serverless, concurrent instances do not share the cache, so a replay can be accepted once per instance within the window (SPEC.md#threat-model). If your handler is idempotent (§3) this is harmless. Durable cross-instance replay defense arrives as an additive minor with the DB story. ## 7. Switching schemes `partkit upgrade webhooks.ingest --adapter=standardwebhooks` re-vendors and updates env — never edit `adapters/` by hand. (Until `upgrade` ships: `partkit eject` then re-`add` with the other adapter.) ## 8. What you must NOT do - Edit or import anything under `src/internal/**` or `adapters/**`. - Parse-then-re-serialize the body before verification (§4 — bytes matter). - Log `WEBHOOK_SECRET` or echo verification error details back to the caller. - Treat a `replayed` or `timestamp_out_of_window` rejection as a bug — it is the defense working; the vendor's genuine retries are re-signed and pass.

Install

$ partkit add webhooks.ingest --adapter=stripe|standardwebhooks