Events
The observation layer: one RebilderEventV0 per handled request, a frozen v0 schema, and sinks that can never break your serving path.
One event per handled request
Every request the gateway handles — including pass-throughs — emits exactly one RebilderEventV0 when onEvent is configured: requester from detection, request (url, accept, referrer), and response.path with measured render_ms. Emission is fire-and-forget: never awaited, sync throws and async rejections are swallowed; a broken or slow event sink can neither block nor break a response.
The schema is frozen at v0 and additive-only: new fields extend a version or start a new one, and a v0 consumer must accept events produced by a later additive revision (unknown extra keys are allowed at every level). This feed is what the Console renders — agent visits by platform, what they saw, how fast.
RebilderEventV0 field reference
| Field | Type | Notes |
|---|---|---|
event_id | string | Globally unique event id — the idempotency key through the queue and ingest. |
ts | string | Event timestamp, ISO 8601 UTC. |
store_id | string | The storeId from your gateway config. On the hosted ingest API, the store resolved from your API key always overrides this. |
requester | object | Who made the request (fields below). |
requester.kind | 'agent' | 'human' | 'protocol' | 'crawler' | Crawlers ride the human/HTML serving path but are recorded first-class, so they stay distinguishable from humans in the warehouse. |
requester.platform | string (optional) | Present when identifiable: 'chatgpt', 'gemini', 'claude', 'perplexity' are known literals; the set is open — any newly observed platform string is valid (additive schema change). |
requester.verified | boolean | True only for cryptographically verified agents (Web Bot Auth — the gateway’s verification key registry). The shipped registry is empty by design, so this stays false until the operator populates keys — see Protocols. |
request | object | What was asked for (fields below). |
request.url | string | The canonical request URL (all adapters normalize to canonical storefront URLs). |
request.intent_signals | Record<string, unknown> | Intent signals extracted from query/referrer/agent payload. Shape intentionally open in v0. |
request.accept | string (optional) | Raw Accept header, when present (e.g. 'text/markdown'). |
request.referrer | string (optional) | Referrer, when present. |
response | object | What was served (fields below). |
response.path | 'markdown' | 'html-variant' | 'protocol' | Which serving path answered. Pass-throughs are recorded as 'html-variant'. |
response.variant_id | string (optional) | Set when path is 'html-variant' and a composed variant was served — Phase 4; absent today. |
response.render_ms | number | Server-side render/serve time in milliseconds, measured with performance.now() (edge budget: p95 < 50ms). |
outcome | object (optional) | Outcome facts joined asynchronously in the warehouse — never present at edge emission time. The order join is live: report orders via the outcomes API or the Shopify webhook — see Outcomes. |
outcome.cited | boolean (optional) | The product was cited in an agent response. |
outcome.referred | boolean (optional) | A visit was referred from an agent surface. |
outcome.add_to_cart | boolean (optional) | Attributed add-to-cart. |
outcome.purchase | boolean (optional) | Attributed purchase. |
outcome.order_value | number (optional) | Order value in the store’s currency minor units, or as reported by the merchant platform. |
Events record visits; orders arrive separately and are joined at read time with labeled evidence — the contract for reporting them (the POST /v1/outcomes API and the Shopify orders webhook) lives on the Outcomes page.
The wire protocol
Any conforming ingest implementation must accept exactly this:
POST {url}/v1/events
Authorization: Bearer <apiKey>
Content-Type: application/json
{ "events": RebilderEventV0[] }- Success:
202 Acceptedwith body{ "accepted": <n> }— the client treats any 2xx as accepted and does not parse the body. event_idis the idempotency key through the queue: the client retries a batch at most once, so the ingest side deduplicates onevent_id.- 5xx / network error: the client retries the identical batch once after 500ms, then drops it.
- 4xx: the client drops the batch immediately without retrying.
The hosted ingest API (https://api.rebilder.com) additionally responds 401 for an unknown, revoked, or missing API key; 400 for a malformed body (not an array, more than 500 events per request, or structural failure); and 503 when events ingest is not configured on that deployment. Keys are store-scoped: the resolved store_id always overrides any store_id in the payload, so a key can never write another store’s events.
createHttpEventSink
import { createHttpEventSink } from '@rebilder/events'
const sink = createHttpEventSink({
url: 'https://api.rebilder.com', // ingest base — the sink POSTs to `${url}/v1/events`
apiKey: process.env.REBILDER_API_KEY!, // sent as `Authorization: Bearer <apiKey>`
// maxBatch: 20, — flush as soon as the queue reaches this many events
// flushIntervalMs: 2000, — flush timer while the queue is non-empty
// fetchImpl: fetch, — injectable for tests; defaults to globalThis.fetch
// onError: console.warn, — called for every dropped event/batch; never rethrown
})
sink.emit(event) // sync enqueue; invalid events dropped via onError, never thrown
await sink.flush() // send the queue now; resolves even on failure
await sink.close() // flush remaining events + stop the timer; later emits are dropped| Option | Type | Default | Notes |
|---|---|---|---|
url | string | — | Ingest base URL, e.g. https://api.rebilder.com — the sink POSTs to ${url}/v1/events. |
apiKey | string | — | Sent as Authorization: Bearer <apiKey>. |
maxBatch | number | 20 | Flush as soon as the queue reaches this many events. |
flushIntervalMs | number | 2000 | Flush timer interval while the queue is non-empty. |
fetchImpl | typeof fetch | globalThis.fetch | Injectable for tests. |
onError | (err: unknown) => void | console.warn | Called for every dropped event/batch (invalid event, transport failure after retry, 4xx rejection, queue overflow, emit after close). Errors it throws are swallowed — never rethrown into the caller. |
Delivery semantics — observation must never break serving. emit() never throws, flush()/close() never reject; failures are reported through onError and the batch is dropped. The queue is capped at 1000 events — beyond that the oldest event is dropped via onError. emit() after close() drops the event.
Console sink
import { createConsoleEventSink } from '@rebilder/events'
const sink = createConsoleEventSink()
sink.emit(event) // console.info('[rebilder-event] {"event_id":...}')One structured [rebilder-event] {json} line per event via console.info — useful before you have an API key (a log drain captures them). Upgrade path: swap createConsoleEventSink() for createHttpEventSink({...}) once you have ingest credentials; the event shape is already the v0 contract, so nothing else changes.
Wiring the sink into the gateway
import type { GatewayConfig } from '@rebilder/gateway'
import { createHttpEventSink } from '@rebilder/events'
const sink = createHttpEventSink({ url: 'https://api.rebilder.com', apiKey: '...' })
export const gatewayConfig: GatewayConfig = {
storeId: 'my-store',
sources: {
/* ... */
},
onEvent: (event) => sink.emit(event),
}Getting an API key
API keys are store-scoped and issued in the Console: create an account, add your store, and the onboarding flow issues a key (shown once — store it in your secret manager). Rotate or revoke any time in Console → Settings; a rotated key’s predecessor starts returning 401 immediately. The Help Center has a step-by-step walkthrough with screenshots.