inmake DOCS
Docs / Server / Hook receiver

Hook receiver

Receiving the platform's data feed: options, the multi-event ingress, the ordering guard.

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.