# Operations and trust — reference

The exhaustive catalog for the operational surface: webhook configuration and delivery,
the event envelope, the usage report, the activity log, and teardown. For the conceptual
model see [explanation.md](explanation.md); for runnable recipes see [how-to.md](how-to.md).
For the raw endpoint/parameter listing, see the generated API reference (the OpenAPI/Scalar
spec) — this page documents behavior, fields, limits, and honest limitations, not the wire
schema.

---

## Webhooks

### Configuration object

A webhook registration is per environment (live and test tenants are registered
separately). Fields, as returned by the developer-portal webhook endpoints:

| Field | Type | Notes |
|---|---|---|
| `id` | string | The webhook configuration id. |
| `url` | string | Full HTTPS endpoint. Must use `https://`; plain HTTP is rejected. |
| `domain` | string | Hostname extracted from `url`, stored for the delivery-time check. |
| `events` | string[] | Subscribed event types. Must be non-empty. |
| `status` | string | `ACTIVE` or `DISABLED`. |
| `disabledReason` | string \| null | `consecutive_failures`, `manual`, or `ssrf_blocked` when disabled. |
| `consecutiveFailures` | int | Running count of consecutive delivery failures; reset to 0 on any success or on re-enable. |
| `apiVersion` | string | Payload format version. Defaults to `2024-01`. |
| `tenantId` | string | The tenant (live or test) the registration belongs to. |
| `createdAt` | number | Creation timestamp (epoch millis). |
| `secret` | string | **Returned only in the create response.** 64-char hex (32 random bytes). Never returned by `GET`/list. |

### Operations

| Operation | Method / path | Notes |
|---|---|---|
| Register | `POST /developer/webhooks` | `201`; returns the secret once. |
| Get one | `GET /developer/webhooks/{id}` | `200`; secret omitted. |
| List | `GET /developer/webhooks?tenantId=...` | `200`; `tenantId` query param required; secret omitted. |
| Update | `PUT /developer/webhooks/{id}` | Change `url`, `events`, `apiVersion`, or `status`. |
| Delete | `DELETE /developer/webhooks/{id}` | `204`. There is no in-place secret rotation — delete and re-create. |
| List deliveries | `GET /developer/webhooks/{id}/deliveries` | `200`; delivery records, payload bodies omitted. |
| Retry a delivery | `POST /developer/webhooks/{id}/deliveries/{deliveryId}/retry` | Re-queues a `FAILED` delivery. |

### Registration validation rules

Enforced at `POST` (and re-run on `PUT` when the URL changes):

- `url` is required and must start with `https://`.
- `events` must be present and non-empty.
- `tenantId` is required and must be one of your own tenants (live or test) — otherwise
  `403`.
- The URL hostname must resolve, and **every** resolved IP must be publicly routable. A
  hostname resolving to any private, loopback, link-local, any-local, multicast, CGNAT, or
  IPv6 unique-local-address range is rejected with `400`. This includes the cloud
  metadata address.
- The hostname's domain must be **verified** for your account, or the registration is
  rejected with `403`.

### The delivery-time SSRF gate

The registration-time DNS check is defense-in-depth and fast feedback; the **real**
security boundary is re-validation at delivery. Immediately before each `POST`, the
destination hostname is re-resolved and every resolved IP must pass the same
public-routability predicate. The predicate rejects:

- Loopback (`127.0.0.0/8`, `::1`)
- IPv4 private ranges (RFC 1918 site-local)
- Link-local (`169.254.0.0/16`, `fe80::/10`) — including the cloud metadata IP
- Any-local and the `0.0.0.0/8` "this network" block
- Multicast
- CGNAT `100.64.0.0/10` (RFC 6598)
- IPv6 unique-local `fc00::/7` (RFC 4193)
- IPv4-mapped/compatible IPv6 forms are unwrapped to their IPv4 address first, so a private
  IPv4 cannot hide inside an IPv6 wrapper.

If delivery-time resolution returns a non-public IP, the delivery is failed **and the
webhook is auto-disabled** with `disabledReason = ssrf_blocked` — a DNS-rebinding attempt
takes the registration offline rather than merely dropping one delivery. A null or
unresolvable address fails closed (treated as non-public).

### The event envelope

The body delivered to your endpoint:

| Key | Type | Notes |
|---|---|---|
| `id` | string | Unique delivery id; also sent as the `X-Vectros-Delivery` header. |
| `version` | string | Envelope version, currently `2024-01`. |
| `type` | string | The event type (see below). |
| `created` | number | Unix **seconds** at envelope build time. |
| `tenantId` | string | The tenant the event belongs to. |
| `livemode` | boolean | `true` for the live tenant, `false` for test. |
| `data` | object | Event-type-specific fields. |

**`data` for `document.*` events:** `id` (document id), `status`, `indexMode`, and
optionally `userId`, `orgId`, `clientId`, `folderId` when present. The document
**title is intentionally excluded** — it is the filename, a top-level (non-typed) value
that the field-masking machinery does not cover, so it is never egressed in an envelope.
Retrieve the title via `GET /v1/documents/{id}`, where reveal-scope and tenant/scope
enforcement apply.

**`data` for `record.*` events:** `id` (record id), `typeName`, `indexStatus`, and
optionally `userId`, `orgId`, `clientId` when present.

The webhook `data` field names match the public REST API surface exactly — `typeName`
and `userId` are the same keys the REST request/response uses, so a consumer can share
models across both surfaces without remapping.

**Event types:**

| Event | Fires when |
|---|---|
| `document.indexed` | A document finishes indexing successfully. |
| `document.failed` | A document fails indexing. |
| `record.indexed` | A record finishes indexing successfully. |
| `record.failed` | A record fails indexing. |

> Adding new fields to `data` is a non-breaking, no-version-bump change; removing or
> renaming a field would require a version bump. Pin `apiVersion` on the registration if you
> need the envelope structure frozen.

### Delivery headers

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-Vectros-Delivery` | The delivery id. |
| `X-Vectros-Timestamp` | Unix seconds at signing time. |
| `X-Vectros-Signature` | `sha256=<hex>` — HMAC-SHA256 of `"<timestamp>.<body>"` keyed by the hex-decoded secret. |

### Signing and verification contract

- The signature is computed over the literal string `<timestamp>.<body>`, where `<body>`
  is the exact bytes of the JSON payload and `<timestamp>` is the value in the
  `X-Vectros-Timestamp` header.
- The secret is hex; decode it to its 32 raw bytes before using it as the HMAC key.
- The signature is recomputed **fresh on every attempt** (including retries) so the
  timestamp is always current. Receivers should reject a delivery whose timestamp is more
  than **300 seconds** from now — this is the replay window.
- Use a constant-time comparison when checking the signature.

### Delivery, retry, and auto-disable

- Delivery is **at-least-once**. Your receiver must be idempotent — dedupe on the delivery
  `id`.
- The HTTP `POST` uses a 5-second connect timeout and a 30-second response timeout. A `2xx`
  response marks the delivery `DELIVERED`; anything else (non-2xx, timeout, connection
  error) is a failure.
- Retry backoff after the first attempt fails: **30s → 5m → 30m → 2h → 8h**. After the
  fifth retry delay is exhausted the delivery is marked `FAILED` and not retried
  automatically (you can re-drive it manually).
- Each consecutive failure increments the webhook's `consecutiveFailures`; a success resets
  it to 0. At **10** consecutive failures the webhook is auto-disabled with
  `disabledReason = consecutive_failures`. Re-enable via `PUT {"status":"ACTIVE"}`, which
  resets the counter.
- **Delivery records have a 7-day TTL** while unresolved; the TTL is cleared once a delivery
  reaches `DELIVERED` so it remains visible in the portal. A tenant teardown deletes
  delivery rows explicitly (they may carry event identifiers) rather than waiting on the
  TTL.

### Delivery record fields (history view)

`GET /developer/webhooks/{id}/deliveries` returns, per delivery: `id`, `webhookId`,
`eventType`, `sourceId`, `sourceType`, `status` (`PENDING` / `DELIVERED` / `FAILED`),
`attempts`, `nextRetryAt`, `createdAt`. The `limit` query parameter caps the count (default
50, max 200). The **envelope payload is never returned** by this endpoint — it may carry
identifiers.

### Notes & limits — webhooks

- Events are limited to the four indexing events above. There is no webhook for synchronous
  CRUD operations, deletes, search, inference, identity, or billing.
- Registration is per environment; there is no account-wide registration spanning live and
  test.
- **No in-place secret rotation** — delete and re-create to roll the secret.
- No payload customization, header injection, or per-event endpoint routing — one
  registration receives all of its subscribed event types at one URL.
- Manual retry only applies to deliveries in `FAILED` status.

---

## Usage and billing

### `getUsage` — the report

`client.auth.getUsage()` (`GET /v1/usage`). **Not enveloped** — returns the report object
directly. Requires `billing:r` on a scoped token (a partner root key always passes). With no
arguments returns the current calendar period; `{ year, month }` selects a specific period.

| Field | Type | Notes |
|---|---|---|
| `period` | string | `YYYY-MM`. |
| `credits.used` | number | Credits consumed this period, rounded down to whole credits. |
| `credits.usedMilli` | number | Exact consumption in milli-credits (1 credit = 1000 milli-credits; use this for reconciliation). |
| `credits.limit` | number? | The period allowance, when applicable. |
| `search.queries.text.count` | number | TEXT searches this period. |
| `search.queries.semantic.count` | number | SEMANTIC searches this period. |
| `search.queries.hybrid.count` | number | HYBRID searches this period. |
| `documents.ingest.text.count` | number? | Inline-text document ingests. |
| `documents.ingest.file.count` | number? | File-upload document ingests. |
| `records.writes.count` | number? | Record writes this period. |
| `records.writes.indexCount` | number? | Logical index credits maintained (ownership + externalId + schema lookups; an equality index counts 1, a range-enabled index 3). |
| `inference.balanceCents` | number | Pre-paid inference balance in cents. Never negative — the deduct path floors at 0. |
| `inference.endpoints.chat.calls` | number | Chat calls this period. |
| `inference.endpoints.rag.calls` | number | RAG calls this period. |
| `inference.endpoints.ask.calls` | number | Document-ask calls this period. |
| `tenants.live` | object | Same shape (credits / search / inference) scoped to the live tenant. |
| `tenants.test` | object | Same shape scoped to the test tenant. |

### The two-axis model

- **Monthly credit allowance** — covers data-plane work (record writes, document ingests,
  searches), resets each calendar month, reported by `credits`.
- **Pre-paid inference balance** — covers chat / RAG / document-ask, denominated in cents,
  drawn down per inference call and topped up out of band, reported by `inference.balanceCents`.

### Metering semantics

- Counts are tracked at the **partner** level and broken down per tenant; the partner total
  reconciles to `tenants.live + tenants.test`.
- **Reads do not draw down the credit allowance** — only writes and searches count.
- Counters tick as operations **dispatch** (e.g. a document ingest ticks at dispatch, before
  indexing completes; a chat call ticks after the stream finalizes), so the report is
  near-real-time and eventually consistent with in-flight settlement.
- The inference endpoint section always carries all three keys (`chat`, `rag`, `ask`) even at
  zero — defaulting is per-endpoint.
- The all-three inference keys are present on both the partner-level and per-tenant sections.

### Notes & limits — usage

- The report is **read-only and observability-oriented**; it is not an invoice or a line-item
  transaction export.
- Rounding: `credits.used` rounds the partner total, which can differ from the sum of the
  already-rounded per-tenant values by up to one cent — reconcile on `usedMilli`, not `used`.
- Pricing rates, plan allowances, and overage policy are **not** part of this surface; this
  documentation does not state numeric prices.

---

## Activity log

`client.auth.getAdminLogs(params)` (`GET /v1/admin/logs`). Requires `logs:r` on a scoped
token (a partner root key always passes). The tenant is derived from the credential — there
is **no** request channel to query another tenant.

### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `startTime` | ISO-8601 string | Start of the query window. |
| `endTime` | ISO-8601 string? | End of the window. An `endTime` earlier than `startTime` returns `400`. |
| `limit` | int? | Caps returned entries. |
| `errorsOnly` | boolean? | When true, only entries with `status >= 400`. |
| `resource` | string? | Allow-list-validated resource filter (e.g. `search`). |
| `method` | string? | Allow-list-validated HTTP method filter (e.g. `GET`). |
| `keyId` | string? | Filter to entries authored by one key. |

### Response

| Field | Type | Notes |
|---|---|---|
| `entries` | array | Log entries, newest first. |
| `truncated` | boolean | True if the window held more than `limit` entries. |
| `queryDurationMs` | number | Backend query latency. |
| `tenantId` | string | The tenant the query ran against (credential-derived). |

Each entry: `timestamp` (ISO-8601), `method`, `resource`, `status`, and optionally `keyId`,
`durationMs`, `path`.

### Notes & limits — activity log

- The `resource` and `method` filters are allow-list validated at the boundary; an
  out-of-allow-list value is rejected, not silently dropped.
- There is a short ingestion lag (seconds) between a request completing and its entry
  becoming queryable.
- This is an **operational API call log**, not the compliance audit/version history (which is
  a separate, retained data-layer mechanism — see [compliance.md](compliance.md)).

---

## Teardown and erasure

### Per-customer / per-context hard-delete — implemented

- **Context delete** runs an owner-filtered cascade that removes the records, documents,
  folders, and schemas under a context (the isolation boundary). It is the mechanism for
  removing one customer's or one application's footprint.
- **Tenant teardown** decommissions an entire live or test tenant, cascading across its
  contexts and tenant-level config (including webhook registrations and delivery rows, which
  are deleted explicitly because they may carry identifiers).
- These operations are irreversible and are control-plane actions.

### End-subject right-to-erasure — RESERVED (not implemented)

- `POST /v1/erasure-requests` and `GET /v1/erasure-requests/{id}` exist as a **frozen
  contract stub**. The request/response shapes are stable so SDK integrations will not break
  when the engine ships, but the endpoint **returns `501 {"error":"not_implemented"}`** today
  — there is no erasure engine behind it yet.
- The endpoint requires the **root partner API key**; a scoped credential (`ssk_*` / `st_*`)
  is rejected with a uniform `403` before the stub runs.
- This is **distinct from** context/tenant hard-delete. Per-customer deletion works today;
  per-individual ("erase everything about this one subject everywhere") does not.

### Notes & limits — teardown

- Right-to-erasure is reserved, not turnkey.
- Read-access logging / accounting-of-disclosures is available but **off by default** (opt-in
  per context); its admin accounting endpoint is live — see [compliance.md](compliance.md).
- Data-retention periods are platform constants today; they are not configurable per
  controller.

---

## Where to go next

- [explanation.md](explanation.md) — the concepts behind everything cataloged here.
- [how-to.md](how-to.md) — runnable recipes for webhooks, usage, and the activity log.
- [compliance.md](compliance.md) — the trust posture, the three sensitive-data mechanisms,
  retention, and the full reserved list.
