HookReceiver options#
| option | default | notes |
|---|---|---|
cache |
— (required) | Where verified payloads land, keyed ${locale}/${collection}/${id}. |
privateKey |
— | Per-hook key: a string, or a (locale, collection) => key resolver. When resolvable, the access-seed signature (or the deprecated access_seed) is verified and a bad/missing signature rejects the push; omit to accept unverified (opt-in security). |
maxAgeMs |
— (unbounded) | Replay window for the seed timestamp. undefined, 0 and Infinity all mean "no bound" — the standalone receiver has no default of its own (WebSite supplies 5 min). |
acceptUnbound |
true |
Seeds are verified bound to the push's {locale}/{collection}/{id} label with an unbound fallback; set false to require the bound form once the backend signer always binds. |
transform |
— | Reshape the entity before caching (e.g. pre-render, strip fields). Returning undefined skips the write (reason: 'transform_skipped'). |
onReceive |
— | Awaited side-effect after a successful write/eviction — logging, render-cache invalidation. Also fires for a superseded push (ctx.superseded: true, body not applied): evicting derived caches is idempotent, a missed eviction lasts the whole TTL. |
receive() returns {ok, key?, deleted?, superseded?, reason?}; failure
reasons are bad_signature, stale_timestamp (authentic but out of window),
missing_id, missing_context, transform_skipped. superseded: true
(still ok) means the ordering guard dropped the push's body: its
x-drop-updated was not newer than what the cache already applied — an
out-of-order or duplicate delivery whose correct handling IS the drop
(onReceive still ran). The guard lives under the sidecar key
pushVersionKey(key) ({key}#v) in the same cache slot as {v, at} — the
version and the receiver's own apply time. Authoritative core reads
(DataRepository write-backs and miss-deletes) stamp v with the read's
start minus GUARD_SKEW_MS (2 s), never lowering it — a push for a write
that committed mid-read still applies — and skip their write-back entirely
when a push landed while they ran (at ≥ start − skew). A push with no
version applies unguarded and keeps the stored v (old-backend compat).
Background: .dev/plans/render-cache-eviction-races.md.
Multi-event hook ingress (receiveEventHook)#
The standard hook form is the MULTI hook: one all entry in the store's
setup/hooks subscribes one endpoint + one key to many events (events: ['server'] = the render feed — data + files + templates + elements + setup +
slug; the
admin's "Server Hook" helper creates exactly this). Point it at one route and
hand everything to the site:
app.post('/hook/:env/:sid', express.json({limit: '1mb'}), async (req, res) => {
const r = await site.receiveEventHook(flattenHeaders(req.headers), req.body ?? {});
res.status(r.ok ? 200 : r.reason === 'bad_signature' || r.reason === 'stale_timestamp' ? 401 : 400).json(r);
});Dispatch is on x-drop-event:
| event | effect |
|---|---|
data (or header absent) |
the data receiver — warm the doc + evict dependent pages (ordering-guarded) |
files |
receiveFilesHook — evict/re-warm the asset |
templates |
evict the pushed {schemaId}/{version} template + clear render+deps — in-place template edits propagate sub-second |
elements |
evict the element's meta entries (the unmasked key, the ELEMENT_RENDER_MASK key and the pre-2026-10 html,css,js key) + clear render+deps — also how a module enable / disable arrives: the write is to the module's runtime doc {moduleId}_runtime |
setup |
{id} = web / data / design / elements / $store — evict every meta-cache key variant of that doc (metaKeysFor) + clear render+deps |
slug |
{key, collection?, category?, locale?} (key = the registry document id — the URL key; category / x-drop-category names a category registry, and requires collection) — evict the slug registry entry (slug/{key}, routes/{c}/slugs/{key} or routes/{c}/routes/{cat}/slugs/{key}) + that registry's sitemap index (slug-targets / slug-targets:{collection} / slug-targets:{collection}:{category}); rendered pages stay (they are keyed by document, not URL) |
services |
{id, version} (x-drop-id), label services/{id} — a write to one service doc (publish, rollback, settings, a secret change via secrets_rev): drops that service's edge-cache answers (a fresh generation) and its package on the injected services host, then hands through to onEvent |
orders / invoices / forms |
seed-verified hand-through to WebSiteOptions.onEvent(event, body, headers) (render servers cache none of these; store projects consume them) |
| anything else | {ok: true} no-op — forward compatibility (incl. the admin's test verify ping) |
Server-event pushes (templates/elements/setup/slug/services) are
signal-shaped — id stubs, never doc bodies; their seeds verify bound to
templates/{schemaId}/v{n} / elements/{id} / setup/{id} / slug/{key} / services/{id}
(slug/{collection}/{key} on the route tier, slug/{collection}/{category}/{key}
on a category registry) labels reconstructed from the
x-drop-schema / x-drop-version / x-drop-id / x-drop-collection /
x-drop-category headers. WebPreviewServer answers POST /hook/{env}/{sid} with exactly this
dispatch, so a server built on the class needs no route of its own.
WebSite.flushTemplates() / flushAll() remain the manual levers.