# [Events](https://rebilder.com/docs/events)

> The observation layer: one `RebilderEventV0` per handled request, a frozen v0 schema, and sinks that can never break your serving path.

- **Updated:** 2026-08-15
- **Publisher:** Rebilder

## 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](/console) renders: agent visits by platform, what they saw, and how fast.

With no sink configured, those events are built and then handed to nothing. They exist for the length of one request inside your own process, we never see them, and the Console has nothing to render: no visits, no coverage, no Agent Miss Report. Wiring a sink is [step 4 of the quickstart](/docs/quickstart) for that reason, not an optional extra.

## 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, so any newly observed platform string is valid (additive schema change). |
| `requester.verified` | `boolean` | True only for cryptographically verified agents (Web Bot Auth, via the gateway’s `verification` key registry). The shipped registry is empty by design, so this stays `false` until the operator populates keys; see [Protocols](/docs/protocols#checkout-verification). |
| `request` | `object` | What was asked for (fields below). |
| `request.url` | `string` | The canonical request URL (all adapters normalize to canonical storefront URLs). **The query string is filtered at emission**: only `page`, `variant`, `sku`, `lang`, `locale` and `currency` survive (lower-cased and sorted); the fragment and any userinfo are dropped. Tracking params, reset-password and magic-link tokens never leave your server, and free-text search never rides the URL. It is recorded only in `intent_signals.query`, after the fail-closed screen described in [Agent intent](/docs/intent). |
| `request.intent_signals` | `Record<string, unknown>` | Intent signals extracted from the request (events v0.4): the well-known keys are `query` (search text, recorded only after a fail-closed PII screen), `query_param`, `referrer_platform`, `utm_source`, `utm_medium`, `tool`, and `result_count`. Shape stays open; see [Agent intent](/docs/intent). |
| `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. No emitter sets it today, so this is absent today. |
| `response.render_ms` | `number` | Server-side render/serve time in milliseconds, measured with `performance.now()` (edge budget: p95 < 50ms). |
| `response.source` | `string` (optional) | Which configured source answered: `'product'`, `'policies'`, `'catalog'`, `'document'`, `'collection'`, or `'none'` when the markdown path ran and nothing matched. Absent when the markdown path was never attempted. Open set, like `requester.platform`: a future source kind is an additive change. |
| `response.coverage` | `'sourced' | 'unsourced' | 'not-applicable'` (optional) | Did a configured source resolve for this URL? `'unsourced'` means an agent asked for markdown and **every** configured resolver returned null; that single bit is the Agent Miss Report. `'not-applicable'` covers humans, crawlers, and protocol routes, where no source was consulted. Read this, **not** `response.path`: a miss is recorded with `path: 'html-variant'` because the gateway passed through to your HTML. |
| `outcome` | `object` (optional) | Outcome facts joined asynchronously in the warehouse (citation checks, referral attribution, order webhooks), so it is never present at edge emission time. The order join is live: report orders via the outcomes API or the Shopify webhook; see [Outcomes](/docs/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](/docs/outcomes) page.

## Coverage: what an agent asked for and did not get

Every event carries `response.coverage`. `sourced` means a configured source answered in markdown. `unsourced` means an agent asked for markdown and **every** configured resolver returned `null`, so the gateway passed through to your HTML and the agent got a page it has to guess at. `not-applicable` means no source was consulted at all: humans, search crawlers, and protocol routes.

**Read `coverage`, not `path`.** A miss is emitted with `response.path: "html-variant"`, because passing through to your canonical HTML is exactly what the gateway did, so `path` alone cannot tell a human page view apart from an unanswered agent request. That is the whole reason `coverage` is a separate field, and it is the entire input to the Agent Miss Report: no sampling, no model, no second request, and it works at N = 1 on your own traffic.

When `coverage` is `sourced`, `response.source` names which resolver answered (`product`, `policies`, `catalog`, `document`, `collection`), which is useful for spotting a URL served by a lower-precedence source than you intended. When it is `unsourced`, `source` is `"none"`.

Both fields are **additive and optional**, added in `@rebilder/events` 0.2.0. A gateway on an earlier version emits events without them and they stay valid; nothing about the v0 contract was removed, narrowed, or made required.

## The wire protocol

Any conforming ingest implementation must accept exactly this:

POST /v1/events

```
POST {url}/v1/events
Authorization: Bearer <apiKey>
Content-Type: application/json

{ "events": RebilderEventV0[] }
```

- **Success:** `202 Accepted` with body `{ "accepted": <n> }`; the client treats any 2xx as accepted and does not parse the body.
- **`event_id` is the idempotency key** through the queue: the client retries a batch at most once, so the ingest side deduplicates on `event_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

sink.ts

```
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` | (required) | Ingest base URL, e.g. `https://api.rebilder.com`; the sink POSTs to `${url}/v1/events`. |
| `apiKey` | `string` | (required) | 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.

## Getting an API key

API keys are store-scoped and issued in the [Console](/console): create an account, add your site, and the onboarding flow issues a key (shown **once**, so put it straight into your secret manager). After that, [Console → Install](/console/install) carries the site ID to use as `storeId`, and Console → Settings mints a replacement whenever you need one. Rotating revokes the predecessor immediately, which starts returning `401` on its next batch.

We store only the sha-256 hash of a key, never the key, so a lost key is replaced rather than recovered. The [Help Center](/help) has the same walkthrough written out step by step.

## Wiring the sink into the gateway

One line in the config you already wrote. Create the sink at module scope, not inside a handler: it batches at 20 events or every 2 seconds, so a per-request sink is discarded before it can flush.

lib/gateway-config.ts

```
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),
}
```

The [quickstart](/docs/quickstart) has the same wiring as a complete file, with the `sources` resolvers beside it and the key read from the environment rather than passed as a literal.

## Console sink: the no-credentials fallback

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`. Reach for it when you have no API key yet, or when your platform gives you a log drain and you would rather own the pipe: nothing about it reaches us, so nothing about it reaches the Console either. `createHttpEventSink` is the standard wiring, and swapping between the two changes nothing else, because the event handed to each is the same v0 object.