API Interceptor
The API Interceptor mechanism lets a module attach behavior to an
HTTP endpoint owned by another module — gating a request before the handler
runs, or reshaping a successful response — without editing the target module or
any shared registry file: cross-module interaction goes
through documented interfaces only. Reach for it when you need to extend an
endpoint in place; reach for the overlay pattern when a
deployment must replace a whole unit (service, route, module); reach for the
in-process EventBus when you only need to react after the fact and the
request/response itself must not change.
What an interceptor may do — and what it is guaranteed
- Position in the lifecycle — a
preinterceptor runs after the route's ownpreHandlerguards (requireAdmin/requireCustomer/ API-key checks) and after Zod validation, immediately before the handler. Apostinterceptor runs atpreSerialization, only for successful responses (statusCode < 400). - Ambient context — interceptors execute inside the same ambient
TenantContextand see the resolved sales channel as the endpoint itself; services called from an interceptor are scoped identically to services called from the handler. - Deterministic ordering — within one endpoint and phase, interceptors run
in ascending
order(default0), ties broken lexicographically by(module, id). The order is identical across restarts and independent of composition wiring order. - Lifecycle gating — before each execution the dispatcher consults the module enabled-set; an interceptor whose owning module is disabled is skipped silently and resumes when the module is re-enabled.
- Fail-closed boot validation — every target is checked against the live
route table in
onReady, before any traffic. An unknown target (typo, route not mounted in this deployment), apostinterceptor on a streaming endpoint, or a duplicate(module, id)refuses startup with an error naming the module, the interceptor id, and the target. Registering afterapp.ready()throws — the dispatch index is sealed. - Attribution — every execution and failure is logged through a child logger
pre-bound with
{interceptorModule, interceptorId, phase, interceptorTarget}, so operators can always tell endpoint behavior from interceptor behavior.
Endpoints with no registered interceptors pay one Map lookup — nothing else.
Anatomy of a registration
A module registers its interceptors through ctx.interceptors(...), which stamps
module from the module's own id — never by importing another module's
internals, and never with a hand-written module name. That is the same seam for a
core module and for a per-deployment overlay module: an overlay
module is composed by the kernel container exactly as a core module is, so the
OverlayModuleContext.apiInterceptors handle this page used to describe no
longer exists. Registration happens during
composition, in the contributing module's own code:
apiInterceptors.register({
module: 'loyalty', // owning module id — lifecycle-gates execution
id: 'enrich-order-detail', // unique within the module, kebab-case
target: 'GET /api/v1/orders/:id', // endpoint identity; string or string[]
phase: 'post', // 'pre' | 'post'
order: 100, // ascending; default 0
handler, // phase-specific signature
});
The endpoint identity is the string "<METHOD> /path/pattern" with params
in :param form — e.g. POST /api/v1/orders,
GET /api/v1/admin/orders/:id. It is the same key the OpenAPI auto-registration
dedupes on, so it exists for every endpoint with zero retrofit, and it is as
stable as the API itself: changing a URL is a versioned breaking change.
The two phases
Pre — observes the validated request data (body, query, params,
post-Zod) plus a read-only request view (actor, salesChannel, headers,
attribution logger). It may adjust the request body by mutating ctx.body or
by returning { body }, and it may veto the request by throwing
HttpError(status, code, message, details?) with a registered ErrorCode from
@endora-commerce/contracts — the handler then never runs.
Post — receives the un-serialized response payload (and the
statusCode, always < 400). Return a replacement payload, or undefined to
leave it unchanged. Post interceptors never run on error responses, and the
reshaped payload must stay conformant to the endpoint's published response
contract.
Veto vs. unexpected failure
- Veto (pre only) is a normal business outcome: the thrown
HttpErrorrenders through the standard error envelope exactly like an endpoint-raised business error, with the chosen status and code, and is logged atinfo— not as a failure. Veto messages pass through the error-envelope i18n bridge like any module error: the human-readablemessagemay be localized to the caller's preferred language, whilecodeanddetailsare preserved verbatim. - Unexpected failure (any non-
HttpErrorthrow, either phase) is fail-closed: it is logged aterrorwith full attribution and rethrown, yielding the standard500 INTERNALenvelope. The mechanism never skips a crashed interceptor and continues.
Guardrails & non-goals
- Auth is untouchable — interceptors run only after the route's auth/authz guards. They can narrow access further (veto), never widen it.
- Error responses are never mutated — post interceptors do not run for
statusCode >= 400, including envelopes produced by the error handler. - Post interceptors MUST be persistence-side-effect-free — the endpoint's own write may already be committed when a post interceptor runs, so a post-phase crash cannot roll it back. Anything transactional belongs in a pre interceptor, a Command, or an EventBus subscriber.
- Pre interceptors must not flush persistent state themselves — a veto guarantees "nothing persisted" only if the interceptor itself wrote nothing.
- No handler replacement or suppression — an interceptor cannot swap out or short-circuit an endpoint's implementation. Whole-unit replacement is the overlay pattern's job.
- Chained adjustments are last-writer-wins — later interceptors see earlier interceptors' body/payload adjustments, in execution order.
- Latency is the author's responsibility — interceptor time is request time; the platform's request timeout applies to the whole chain.
- HTTP boundary only — internal service-to-service calls, background jobs, and EventBus events are not intercepted. Only requests crossing the HTTP surface run interceptors.
- Streaming/binary endpoints reject the post phase — routes annotated with
config: { streamingResponse: true }(PDF/asset downloads, CSV exports) bypass serialization; a post interceptor targeting one fails boot validation.
Worked example
A compliance module gates order placement on another module's endpoint
(pre + veto), and a loyalty module enriches the order detail (post):
// compliance/plugin.ts — pre-gate with veto
apiInterceptors.register({
module: 'compliance',
id: 'sanctions-gate',
target: 'POST /api/v1/orders',
phase: 'pre',
handler: async ({ request, body }) => {
const verdict = await screening.check(request.raw.actor);
if (!verdict.ok) {
throw new HttpError(422, ERROR_CODES.COMPLIANCE_SCREENING_FAILED, 'Order blocked by screening');
}
(body as PlaceOrderRequest).metadata = {
...(body as PlaceOrderRequest).metadata,
screeningId: verdict.id,
};
},
});
// loyalty/plugin.ts — post-enrichment
apiInterceptors.register({
module: 'loyalty',
id: 'enrich-order-detail',
target: 'GET /api/v1/orders/:id',
phase: 'post',
order: 100,
handler: async ({ payload }) => ({
...(payload as object),
loyaltyPoints: await points.forOrder(payload),
}),
});
A failing screening returns the standard envelope with the compliance code
and no order row exists; a 404 from the order-detail route carries no
loyaltyPoints (post never runs on errors); disabling the loyalty module
restores the original response shape.
Diagnostics
GET /api/v1/admin/api-interceptors (permission: platform.modules.read)
returns every registration — target, phase, order, module, id — sorted in
execution order (target, then pre before post, then order/tie-break), with
moduleEnabled resolved live from the enabled-set at request time. The list
is the execution plan. Read-only: registrations are shipped module code, not
runtime data, so there is no write surface.
Not the webhooks module
Do not confuse this mechanism with the webhooks module: webhooks deliver
platform events outbound to external HTTP consumers after the fact; API
interceptors act inbound, inside the platform's own request/response cycle.