{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/webhooks",
  "slug": "engines/webhooks",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/webhooks",
  "name": "@leumas/webhooks",
  "eyebrow": null,
  "chip": null,
  "summary": "Leumas Webhooks — the inbound door. A programmable endpoint that verifies a signature over the RAW request bytes, refuses replays durably, projects the body onto a callable's DECLARED inputs through...",
  "keywords": [
    "webhooks",
    "author-owned",
    "ingress",
    "session-gated",
    "sender",
    "webhooks api",
    "leumas webhooks",
    "pre-gate"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# @leumas/webhooks\n\nThe **inbound door**. An outside system POSTs to an unguessable address; this verifies the signature\nover the raw bytes, projects the body onto a callable's *declared* inputs, and runs it through\n`@leumas/invoke`.\n\n```js\nimport { createWebhookIngress, createWebhooksRouter } from '@leumas/webhooks';\n\napp.use('/api/hooks', createWebhookIngress({          // PRE-GATE, anonymous, raw-bodied\n  connector, rateLimit, validateInputs,\n  invoke: (kind, ref, args, config) => invokeRef(`${kind}:${ref}`, args, binders(), config),\n  resolveSecret: readVariable,                        // the secret lives in a `variables` key\n  inputsFor: (binding) => declaredInputsFor(binding),\n}));\napp.use('/api/webhooks', createWebhooksRouter({ connector, gate, adminGate, catalog, inputsFor }));\n```\n\n## Two routers, two planes of trust\n\n`/api/hooks` is anonymous by necessity — a third-party sender has no Leumas session and cannot be\ngiven one. `/api/webhooks` is session-gated and **admin for everything**, because a row here is an\nexecution path reachable with no session at all, which is a stronger claim than `action_types` makes.\nAll three collections (`webhooks`, `webhook_deliveries`, `webhook_seen`) are in\n`PLATFORM_COLLECTIONS`.\n\n## [critical] The parts that are load-bearing\n\n**The body must not be parsed upstream.** `BODY_DEFAULTS.byPrefix['/api/hooks'] = null` in\n`@leumas/security` is what arranges it. An HMAC is over the bytes the sender sent; re-serializing\nproduces different bytes for the same value. Delete that entry and every signed webhook silently\nstops verifying — and because `express.json` no-ops on unrecognised content types, it will *look*\nfine until a genuine `application/json` body arrives and the route sees zero bytes behind a 200.\n\n**Config is author-owned; the request never contributes to it.** In `@leumas/invoke` an action's\nconfig is merged AFTER the caller's args and wins — that is what distinguishes `list_records` from\n`delete_record`. `argMap` may only target inputs the callable **declares**, checked at save time by\n`assertArgMap`, and `assertBinding` runs **again at dispatch** because the row could have been written\nthrough `/db` or before a `DENY_ACTIONS` change.\n\n**Some bindings are refused outright.** `http-request`, `send-webhook`, `fs-*`, the `http` kind and\n`module-export`: an anonymous URL onto any of them is an SSRF proxy wearing this API's network\nposition. Unpinned `crud-config` is refused too — it is read *and* delete until the author pins `op`.\n\n**Every refusal looks identical.** Unknown address, disabled row, bad signature, stale timestamp: one\nstatus, one body. Anything else lets a caller enumerate which addresses exist. The reason goes to the\ndelivery log, which is the only place it can safely be said.\n\n**The address is masked at write time.** The hookId *is* the credential for an unsigned webhook, so a\ndelivery row never stores it whole — a stored secret outlives whatever gate was in front of it.\nHeaders are picked from an allowlist for the same reason: a stored `Authorization` is a live\ncredential at rest.\n\n**Replay protection is two-layered on purpose.** A synchronous in-process claim (the durable check is\nread-then-create and N parallel copies would all pass) plus a `webhook_seen` row that survives a\nrestart. The connector exposes no unique index, so neither half alone is sufficient — that limit is\nreal and is stated in `verify.js` rather than implied.\n\n## Guards\n\n`pnpm smoke:webhooks` — 31 assertions including the one that matters: a signature over raw bytes\nverifies while the **same value re-serialized does not**, which is the only real proof the body was\nnever parsed upstream.\n",
  "source": {
    "path": "shared/engines/webhooks/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 3848,
    "hash": "c942e354d842cd67155f6dbb60fdd2625bdce820"
  },
  "urls": {
    "html": "/p/engines/webhooks",
    "json": "/docs/engines/webhooks.json",
    "md": "/docs/engines/webhooks.md"
  },
  "links": {
    "composes": [
      "pkg:@leumas/adapter-webhook-forge",
      "pkg:@leumas/api-kit"
    ],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  },
  "exports": null
}
