# Operations and trust — how-to guides

Goal-oriented, runnable recipes for the operational surface: registering and verifying a
webhook, building a receiver that validates signatures, reading your usage report, and
querying your activity log. All examples use **synthetic data only**. Webhook registration
is a developer-portal operation; usage and logs are reachable through the Node SDK.

> **SDK version note.** The API spec is currently at `0.29.9`. None of the calls on this
> page require the `0.26+`-only surface (PATCH / `typeName` XOR `schemaId`), so they work on
> any current client — the 0.23 staging build the reference apps pin as well as the 0.26
> build the CLI and MCP server bundle. Construct the client once:
>
> ```ts
> import { VectrosClient } from '@vectros-ai/sdk';
>
> const client = new VectrosClient({
>   token: process.env.VECTROS_API_KEY!,      // sk_live_* or sk_test_*
>   environment: 'https://api.vectros.ai',    // or your staging base URL
> });
> ```

---

## Register a webhook and verify deliveries

**Goal:** receive a signed `document.indexed` event at an endpoint you control, and verify
the signature so you can trust it.

### Prerequisites

- A developer account and a partner API key for the tenant you are registering against
  (live or test — registrations are per environment).
- An **HTTPS** endpoint with a publicly resolvable hostname. Plain HTTP is rejected.
- That hostname's **domain must already be verified** for your account. You verify domains
  in the developer portal under Domains (the same place webhooks are registered). A webhook
  pointing at an unverified domain is rejected with a `403`.

### Step 1 — Stand up a synthetic receiver

A receiver is any HTTPS endpoint that accepts `POST` with a JSON body. It must validate the
signature before trusting the payload. Here is a minimal Node/Express receiver — note it
reads the **raw** body, because the signature is computed over the exact bytes:

```ts
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.VECTROS_WEBHOOK_SECRET!; // the 64-char hex secret, shown once at registration
const app = express();

// Capture the raw body — the signature is over the exact bytes, not a re-serialization.
app.use(express.raw({ type: 'application/json' }));

app.post('/vectros/webhooks', (req, res) => {
  const signatureHeader = req.header('X-Vectros-Signature') ?? '';  // "sha256=<hex>"
  const timestamp = req.header('X-Vectros-Timestamp') ?? '';        // unix seconds
  const body = req.body.toString('utf8');

  // 1. Reject stale deliveries (replay window: 300 seconds).
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || ageSeconds > 300) {
    return res.status(400).send('stale or missing timestamp');
  }

  // 2. Recompute HMAC-SHA256 over "<timestamp>.<body>" with the shared secret.
  const expected = crypto
    .createHmac('sha256', Buffer.from(SECRET, 'hex'))
    .update(`${timestamp}.${body}`, 'utf8')
    .digest('hex');

  // 3. Constant-time compare against the header value (strip the "sha256=" prefix).
  const provided = signatureHeader.replace(/^sha256=/, '');
  const ok =
    provided.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
  if (!ok) {
    return res.status(401).send('bad signature');
  }

  // 4. Signature valid — process the event. Respond 2xx promptly.
  const event = JSON.parse(body);
  console.log('verified event', event.type, 'for', event.data.id);
  return res.status(200).send('ok');
});

app.listen(8080);
```

The four headers Vectros sends on every delivery are:

| Header | Value |
|---|---|
| `X-Vectros-Delivery` | The unique delivery id (also the `id` inside the envelope). |
| `X-Vectros-Timestamp` | Unix seconds at signing time — used for replay rejection. |
| `X-Vectros-Signature` | `sha256=<hex>`, the HMAC over `"<timestamp>.<body>"`. |
| `Content-Type` | `application/json`. |

### Step 2 — Register the endpoint

Registration is a developer-portal API call. Using the developer API directly:

```bash
curl -X POST https://api.vectros.ai/developer/webhooks \
  -H "Authorization: Bearer $DEVELOPER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tnt_live_synthetic_example",
    "url": "https://hooks.example-clinic.test/vectros/webhooks",
    "events": ["document.indexed", "document.failed"]
  }'
```

**Expected result** — `201 Created`, and the response carries the **signing secret one time
only**:

```json
{
  "id": "wh_a1b2c3d4",
  "url": "https://hooks.example-clinic.test/vectros/webhooks",
  "domain": "hooks.example-clinic.test",
  "events": ["document.indexed", "document.failed"],
  "status": "ACTIVE",
  "consecutiveFailures": 0,
  "apiVersion": "2024-01",
  "tenantId": "tnt_live_synthetic_example",
  "secret": "4f3c...<64 hex chars>...e91a"
}
```

Capture `secret` now — it is **never returned again** by any `GET` or list call. To rotate
it, delete the webhook and create a new one. The `tenantId` must be one of your own
(`liveTenantId` or `testTenantId`); pointing at a tenant you do not own returns `403`.

### Step 3 — Trigger an event and watch it arrive

Ingest a document with synthetic content. When indexing completes, your receiver gets a
`document.indexed` delivery:

```ts
const doc = await client.documents.ingestDocument({
  title: 'Synthetic Intake Note — Jordan Vance',  // clearly fictional
  text: 'Patient reports seasonal allergies. No acute distress. Follow-up in 6 months.',
  indexMode: 'TEXT',
});
console.log('ingested', doc.id, '— awaiting document.indexed webhook');
```

The envelope your receiver verifies and parses looks like:

```json
{
  "id": "wd_9f8e7d6c",
  "version": "2024-01",
  "type": "document.indexed",
  "created": 1735689600,
  "tenantId": "tnt_live_synthetic_example",
  "livemode": true,
  "data": {
    "id": "doc_5a4b3c2d",
    "status": "INDEXED",
    "indexMode": "TEXT",
    "folderId": "fld_1122"
  }
}
```

Note what the envelope deliberately **does not** carry: the document *title*. The title is
the filename and is treated as potentially sensitive, so it is never placed in an envelope
delivered to an external endpoint. Fetch it with reveal-aware tenant enforcement via
`client.documents.getDocument({ id })` if you need it.

### Step 4 — Inspect delivery history and re-drive a failure

If your receiver was down, you can see the attempt history and manually retry a failed
delivery from the portal:

```bash
# Delivery history for one webhook (payload bodies are never returned).
curl https://api.vectros.ai/developer/webhooks/wh_a1b2c3d4/deliveries \
  -H "Authorization: Bearer $DEVELOPER_TOKEN"

# Re-queue a delivery that ended in FAILED.
curl -X POST \
  https://api.vectros.ai/developer/webhooks/wh_a1b2c3d4/deliveries/wd_9f8e7d6c/retry \
  -H "Authorization: Bearer $DEVELOPER_TOKEN"
```

A retry only succeeds on a delivery in `FAILED` status; re-driving anything else returns a
`400`. After 10 consecutive failures the webhook auto-disables — re-enable it by updating
its status, which also resets the failure counter:

```bash
curl -X PUT https://api.vectros.ai/developer/webhooks/wh_a1b2c3d4 \
  -H "Authorization: Bearer $DEVELOPER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "ACTIVE"}'
```

---

## Read your usage report

**Goal:** read the current period's consumption — the monthly credit allowance and the
inference balance — broken down by environment.

### Prerequisites

- A partner API key, **or** a scoped token carrying the `billing:r` permission.

### Step 1 — Read the report

```ts
const usage = await client.auth.getUsage();
```

`getUsage()` is **not** wrapped in the list envelope — it returns the full report object
directly, not `{ data, nextCursor }`. With no arguments it returns the current calendar
period; pass an explicit period to read a specific month:

```ts
const june = await client.auth.getUsage({ year: 2026, month: 6 });
```

**Expected result** — a report whose `period` matches `YYYY-MM`, with credit, search,
document, record, and inference sections, plus a per-environment breakdown:

```ts
console.log(usage.period);                         // "2026-06"
console.log(usage.credits.used);                   // whole credits used this period
console.log(usage.credits.usedMilli);              // exact figure in milli-credits (1 credit = 1000)
console.log(usage.search.queries.hybrid.count);    // hybrid searches this period
console.log(usage.inference.balanceCents);         // pre-paid inference balance (never negative)
console.log(usage.inference.endpoints.chat.calls); // chat calls this period

// Per-environment: the partner total reconciles to live + test.
console.log(usage.tenants.live.credits.usedMilli);
console.log(usage.tenants.test.credits.usedMilli);
```

The inference section always carries all three endpoint keys (`chat`, `rag`, `ask`) even
when a count is zero — defaulting is per-endpoint, not omit-when-zero — so you can render a
dashboard without null-guarding each one.

### Step 2 — Hand a billing-only view to a dashboard

Mint a scoped token that can read usage and nothing else, and use it from a read-only
internal dashboard:

```ts
const minted = await client.auth.mintToken({
  scope: { allowedActions: ['billing:r'] },
});

const billingClient = new VectrosClient({
  token: minted.token,
  environment: 'https://api.vectros.ai',
});

const report = await billingClient.auth.getUsage();   // succeeds — billing:r grants /v1/usage
```

A token *without* `billing:r` is rejected with a uniform `403`, so a token scoped to, say,
`records:r` cannot read your billing figures.

---

## Query your activity log

**Goal:** see your tenant's recent API calls — for debugging, an internal audit view, or
spotting a spike of errors.

### Prerequisites

- A partner API key, **or** a scoped token carrying the `logs:r` permission.

### Step 1 — Query a time window

```ts
const startTime = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // last hour

const logs = await client.auth.getAdminLogs({
  startTime,
  limit: 50,
});

for (const entry of logs.entries) {
  console.log(entry.timestamp, entry.method, entry.resource, entry.status);
}
```

**Expected result** — an object with `entries` (newest first), a `truncated` flag, a
`queryDurationMs`, and the `tenantId` the query ran against. The tenant is derived from your
credential; there is no request channel to point the query at another tenant.

> **Ingestion lag.** There is a short pipeline lag (seconds) between a request completing
> and its log entry becoming queryable. For tests or tight loops, poll the window rather
> than asserting immediately.

### Step 2 — Narrow the results

The log query supports server-side filters; each is allow-list validated, so an unknown
filter value is rejected rather than silently ignored:

```ts
// Only errors (status >= 400):
await client.auth.getAdminLogs({ startTime, errorsOnly: true, limit: 50 });

// Only one resource:
await client.auth.getAdminLogs({ startTime, resource: 'search', limit: 50 });

// Only one HTTP method:
await client.auth.getAdminLogs({ startTime, method: 'GET', limit: 50 });

// Only calls authored by one key (this is the credential's key id, not the raw secret;
// key ids are alphanumeric with hyphen/underscore):
await client.auth.getAdminLogs({ startTime, keyId: 'key_abc123', limit: 50 });
```

An inverted window (an `endTime` earlier than `startTime`) returns `400` before any backend
query runs. A token without `logs:r` is rejected with `403`.

---

## Where to go next

- [reference.md](reference.md) — every webhook field, envelope key, usage section, log
  filter, limit, and error code.
- [explanation.md](explanation.md) — the mental model behind webhooks, the two-axis usage
  model, and the teardown-versus-erasure distinction.
- [compliance.md](compliance.md) — why the document title is kept out of the envelope, how
  the signing secret is redacted from audit history, and the full trust posture.
