← all parts

Part · webhooks.ingest

What's actually behind webhooks.ingest

The part exactly as partkit add webhooks.ingest vendors it into your repo — verified, locked, every byte readable. Nothing here is mocked.

webhooks.ingestv1.1.0

✓ attested🔒 read-only↑ ctrlai upgradewebhooks.ingest@1

Lives at parts/webhooks.ingest/ in your repo — open, owned, readable. Not buried in node_modules. 403 lines of source you can audit.

18 conformance tests passedverified 2026-06-15adapter standardwebhooks↗ CI run
content hash 1aa48db7c5…4bccafpinned in parts.lockctrlai guard fails CI if a single byte changes
tested against node 25.3.0

Public API — what your seam calls

  • 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

Invariants — guarantees the contract pins

  1. Importing the part performs no I/O and never throws; configuration is validated at call time with typed errors
  2. 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)
  3. A signed timestamp outside the tolerance window (default 300 seconds, past or future) is rejected
  4. A delivery replayed within the tolerance window is rejected (v1: in-memory per instance — serverless limitation stated in SPEC.md)
  5. Verification operates on the raw payload bytes as received; any re-serialization that changes a single byte fails verification
  6. All failures surface as typed WebhookError values carrying a recommended HTTP status; secret values never appear in error messages
  7. 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

Dependencies

zero-dep — runs on your Postgres

Swappable adapters

stripestandardwebhooks
SOURCEparts/webhooks.ingest/16 files · click to read
parts/webhooks.ingest/src/index.tstypescript · 4,942 bytes
/**
 * webhooks.ingest — public interface. The ONLY legal import surface.
 * Contract: ../contract.json · What your app must provide: ../seams.md
 */
import { adapter } from "../adapters/selected/adapter";
import { requireEnv, toleranceSeconds } from "./internal/config";
import { sha256Hex } from "./internal/crypto";
import { WebhookError } from "./internal/errors";
import { normalizeHeaders } from "./internal/headers";
import { redactSecrets } from "./internal/redact";
import { assertNotReplayed } from "./internal/replay";
import type { Unsubscribe, VerifiedWebhook, WebhookRequest } from "./internal/types";

export { WebhookError } from "./internal/errors";
export type { WebhookErrorCode } from "./internal/errors";
export type {
  Unsubscribe,
  VerifiedWebhook,
  WebhookHeaders,
  WebhookRequest,
} from "./internal/types";

/**
 * Verify one inbound webhook delivery: HMAC signature over the exact raw
 * payload bytes (timing-safe), signed-timestamp window, per-instance replay
 * defense (contract invariants 2–5).
 *
 * Importing this module performs no I/O; configuration is validated here, at
 * call time, with typed errors (invariant 1 — serverless-safe). Every failure
 * surfaces as a WebhookError carrying the HTTP status your route should
 * answer with, and secrets are redacted from every message (invariant 6).
 */
export async function verifyWebhook(request: WebhookRequest): Promise<VerifiedWebhook> {
  try {
    const configured = requireEnv("WEBHOOK_ADAPTER");
    if (configured !== adapter.name) {
      throw new WebhookError(
        "config",
        `WEBHOOK_ADAPTER is "${configured}" but the vendored adapter is "${adapter.name}" — ` +
          `re-vendor with: partkit upgrade webhooks.ingest --adapter=${configured}`,
      );
    }
    const secret = requireEnv("WEBHOOK_SECRET");
    const tolerance = toleranceSeconds();
    const payload =
      typeof request.payload === "string"
        ? Buffer.from(request.payload, "utf8")
        : Buffer.from(request.payload);
    const nowEpochSeconds = Math.floor(Date.now() / 1000);

    const verified = adapter.verify({
      payload,
      headers: normalizeHeaders(request.headers),
      secret,
      nowEpochSeconds,
      toleranceSeconds: tolerance,
    });
    // Replay key = the verified signature: identical for byte-identical
    // replays, fresh on legitimate redeliveries (vendors re-sign retries).
    assertNotReplayed(
      `${adapter.name}:${sha256Hex(verified.matchedSignature)}`,
      nowEpochSeconds,
      tolerance,
    );

    return {
      id: verified.id,
      timestamp: new Date(verified.timestampEpochSeconds * 1000),
      payload: payload.toString("utf8"),
      adapter: adapter.name,
    };
  } catch (e) {
    if (e instanceof WebhookError) {
      throw new WebhookError(e.code, redactSecrets(e.message));
    }
    throw new WebhookError("unknown", redactSecrets(e instanceof Error ? e.message : String(e)));
  }
}

/**
 * Module-scope registration, re-evaluated per cold start — the only
 * sanctioned subscription form under serverless runtimes (docs/02 §2).
 * Register in the same module that mounts webhookHandler (seams.md §3).
 */
const handlers = new Set<(event: VerifiedWebhook) => void | Promise<void>>();

export function onWebhook(
  handler: (event: VerifiedWebhook) => void | Promise<void>,
): Unsubscribe {
  handlers.add(handler);
  return () => {
    handlers.delete(handler);
  };
}

function jsonResponse(status: number, error?: string): Response {
  return new Response(JSON.stringify(error === undefined ? { received: true } : { error }), {
    status,
    headers: { "content-type": "application/json" },
  });
}

/**
 * The route the app mounts (contract http_routes): verifies the delivery,
 * dispatches to every onWebhook handler IN ORDER, and acknowledges 2xx only
 * after all of them completed (invariant 7). Verification failures answer a
 * generic 400 — no detail leaks to unauthenticated callers; configuration
 * problems and handler failures answer 500 so the vendor redelivers.
 */
export async function webhookHandler(request: Request): Promise<Response> {
  let event: VerifiedWebhook;
  try {
    if (handlers.size === 0) {
      throw new WebhookError(
        "config",
        "no webhook handlers registered — call onWebhook() at module scope " +
          "in the file that mounts webhookHandler (seams.md §3)",
      );
    }
    const payload = Buffer.from(await request.arrayBuffer());
    event = await verifyWebhook({ payload, headers: request.headers });
  } catch (e) {
    const status = e instanceof WebhookError ? e.status : 500;
    return jsonResponse(
      status,
      status >= 500 ? "webhook ingest misconfigured" : "webhook verification failed",
    );
  }
  try {
    for (const handler of handlers) await handler(event);
  } catch {
    return jsonResponse(500, "webhook handler failed");
  }
  return jsonResponse(200);
}