# @leumas/webhooks

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...


The **inbound door**. An outside system POSTs to an unguessable address; this verifies the signature
over the raw bytes, projects the body onto a callable's *declared* inputs, and runs it through
`@leumas/invoke`.

```js
import { createWebhookIngress, createWebhooksRouter } from '@leumas/webhooks';

app.use('/api/hooks', createWebhookIngress({          // PRE-GATE, anonymous, raw-bodied
  connector, rateLimit, validateInputs,
  invoke: (kind, ref, args, config) => invokeRef(`${kind}:${ref}`, args, binders(), config),
  resolveSecret: readVariable,                        // the secret lives in a `variables` key
  inputsFor: (binding) => declaredInputsFor(binding),
}));
app.use('/api/webhooks', createWebhooksRouter({ connector, gate, adminGate, catalog, inputsFor }));
```

## Two routers, two planes of trust

`/api/hooks` is anonymous by necessity — a third-party sender has no Leumas session and cannot be
given one. `/api/webhooks` is session-gated and **admin for everything**, because a row here is an
execution path reachable with no session at all, which is a stronger claim than `action_types` makes.
All three collections (`webhooks`, `webhook_deliveries`, `webhook_seen`) are in
`PLATFORM_COLLECTIONS`.

## [critical] The parts that are load-bearing

**The body must not be parsed upstream.** `BODY_DEFAULTS.byPrefix['/api/hooks'] = null` in
`@leumas/security` is what arranges it. An HMAC is over the bytes the sender sent; re-serializing
produces different bytes for the same value. Delete that entry and every signed webhook silently
stops verifying — and because `express.json` no-ops on unrecognised content types, it will *look*
fine until a genuine `application/json` body arrives and the route sees zero bytes behind a 200.

**Config is author-owned; the request never contributes to it.** In `@leumas/invoke` an action's
config is merged AFTER the caller's args and wins — that is what distinguishes `list_records` from
`delete_record`. `argMap` may only target inputs the callable **declares**, checked at save time by
`assertArgMap`, and `assertBinding` runs **again at dispatch** because the row could have been written
through `/db` or before a `DENY_ACTIONS` change.

**Some bindings are refused outright.** `http-request`, `send-webhook`, `fs-*`, the `http` kind and
`module-export`: an anonymous URL onto any of them is an SSRF proxy wearing this API's network
position. Unpinned `crud-config` is refused too — it is read *and* delete until the author pins `op`.

**Every refusal looks identical.** Unknown address, disabled row, bad signature, stale timestamp: one
status, one body. Anything else lets a caller enumerate which addresses exist. The reason goes to the
delivery log, which is the only place it can safely be said.

**The address is masked at write time.** The hookId *is* the credential for an unsigned webhook, so a
delivery row never stores it whole — a stored secret outlives whatever gate was in front of it.
Headers are picked from an allowlist for the same reason: a stored `Authorization` is a live
credential at rest.

**Replay protection is two-layered on purpose.** A synchronous in-process claim (the durable check is
read-then-create and N parallel copies would all pass) plus a `webhook_seen` row that survives a
restart. The connector exposes no unique index, so neither half alone is sufficient — that limit is
real and is stated in `verify.js` rather than implied.

## Guards

`pnpm smoke:webhooks` — 31 assertions including the one that matters: a signature over raw bytes
verifies while the **same value re-serialized does not**, which is the only real proof the body was
never parsed upstream.


---
Source: shared/engines/webhooks/README.md
Canonical: https://docs.leumas.tech/p/engines/webhooks
