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.27.0. None of the calls on this page require the0.26+-only surface (PATCH /typeNameXORschemaId), 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: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:
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:
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:
{
"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:
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:
{
"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:
# 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:
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:rpermission.
Step 1 — Read the report
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:
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:
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:
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:rpermission.
Step 1 — Query a time window
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:
// 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 — every webhook field, envelope key, usage section, log filter, limit, and error code.
- explanation.md — the mental model behind webhooks, the two-axis usage model, and the teardown-versus-erasure distinction.
- 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.