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; for runnable recipes see 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):
urlis required and must start withhttps://.eventsmust be present and non-empty.tenantIdis required and must be one of your own tenants (live or test) — otherwise403.- 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, scopes, 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, scopes when present (scopes is the same <namespace>:<value>
array as the REST response — org:.../client:... or a namespace you registered).
indexFailure on document.failed / record.failed: both event types carry an
indexFailure object explaining the failure — whenever the platform recorded a
classification — so you can act on the event itself rather than calling back to find out
why. It is omitted when no reason was recorded, so treat it as optional. It has a stable
code and a human-readable message:
code | Meaning |
|---|---|
SOURCE_UNAVAILABLE | The underlying item could not be loaded and may have been deleted. |
TEXT_INDEX_FAILED | Keyword indexing failed; the content may still be findable by semantic search. |
EMBEDDING_FAILED | Semantic indexing failed; the content may still be findable by keyword search. |
INDEXING_FAILED | No index leg is serving this content, so it is not findable by search at all. |
VECTOR_LIMIT_EXCEEDED | Your vector storage limit was reached, so semantic indexing was skipped; keyword search is still serving this content. |
INTERNAL | An error on our side; retry, and contact support if it persists. |
Branch on code — message wording may change between releases. The object is present
only on failure events; success deliveries omit it entirely.
The per-tenant semantic (vector) capacity limit is 5,000,000 vectors by default — well
above what the overwhelming majority of use cases need. It is a per-tenant limit, not shared
across customers, and it can be raised for larger use cases (contact us). Past it, new content
still gets keyword-indexed and stays findable via TEXT/HYBRID search; only its semantic
indexing is skipped (VECTOR_LIMIT_EXCEEDED above).
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. |
trigger.failed | An async trigger execution fails terminally — the platform has stopped retrying it. Not per attempt: a retryable failure that later succeeds sends nothing. The payload mirrors a GET /v1/trigger-failures record. |
Adding new fields to
datais a non-breaking, no-version-bump change; removing or renaming a field would require a version bump. PinapiVersionon 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 theX-Vectros-Timestampheader. - 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
POSTuses a 5-second connect timeout and a 30-second response timeout. A2xxresponse marks the deliveryDELIVERED; 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
FAILEDand 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 withdisabledReason = consecutive_failures. Re-enable viaPUT {"status":"ACTIVE"}, which resets the counter. - Delivery records have a 7-day TTL while unresolved. Once a delivery reaches
DELIVEREDit switches to a 30-day retention window instead — long enough to review in the portal, but no longer retained indefinitely. A tenant teardown deletes delivery rows explicitly (they may carry event identifiers) rather than waiting on either TTL.
Delivery record fields (history view)
GET /developer/webhooks/{id}/deliveries returns a paginated envelope:
{"data": [...], "nextCursor": "..."}. Each entry in data carries: id, webhookId,
eventType, sourceId, sourceType, status (PENDING / DELIVERED / FAILED),
attempts, nextRetryAt, createdAt, newest first. limit caps the page size (default
50, max 200); pass a previous page's nextCursor back as the startFrom query parameter
to continue — treat the cursor as opaque, and null means there is no further page. The
envelope payload is never returned by this endpoint — it may carry identifiers.
Notes & limits — webhooks
- Events are limited to the five listed above — the four indexing events, plus
trigger.failedfor a terminally failed async trigger execution. 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
FAILEDstatus.
Response versioning (Vectros-Version)
Any request to /v1/* may carry a Vectros-Version request header that pins the shape
of the response you get back — field names, the envelope, pagination shape, enum values, and
error-body structure. It never changes behavior: authorization, tenant isolation, quota
enforcement, and security fixes are identical no matter which version you send.
- Sending nothing changes nothing. An unversioned request is served exactly as it is today. Adopting the header is entirely opt-in.
- Responses echo
Vectros-Version, so you can confirm which shape you were actually served. 2026-08-01is the only published version today — it describes the API exactly as it behaves right now. A version remains supported for 12 months after a successor is published; once deprecated, responses served under it carryDeprecation(RFC 9745) andSunset(RFC 8594) headers ahead of its retirement.- An unpublished or expired version is a
400, witherrorCode: "UNSUPPORTED_WIRE_VERSION"(see Activity log below — this is the code you'll see there if a client sends a bad value) and a message naming the versions currently supported. - The SDKs do not send this header yet. With no header, resolution falls back to the
calling credential's own
wire_version_pin— stamped onto it at mint time from your tenant's setting at that moment, not read fresh from your account on every call — and only then to the platform floor. A call made through the Node SDK therefore resolves through that fallback rather than pinning a version explicitly; SDK support lands in a later release. If you need to pin a shape today, set the header yourself on a direct HTTP call. - Two surfaces don't honor it yet. The streaming
POST /v1/ragandPOST /v1/chatendpoints, and thePOST /v1/documents/{id}/asksub-resource, currently ignoreVectros-Versionand always serve the current shape — note that this is narrower than "all of documents": ingesting and managing documents through the rest of/v1/documentsdoes honor the header, it's specifically theasksub-resource that doesn't yet. This gap closes before a second version is published. - This is a different axis from two other things this page mentions "version" about:
the webhook envelope's own
apiVersion(above, under Configuration object) versions the webhook payload shape on a per-registration basis, and your SDK's package version moves on any API-surface change — including purely additive ones. Upgrading your SDK does not change the response shape you're served;Vectros-Versiondoes, and only when an existing response shape changes in a breaking way.
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 root key always passes). With no
arguments returns the current calendar period; { year, month } selects a specific period.
A token confined to a single app context sees only that context's usage. The top-level
totals — credits, search, documents, records, identity, inference, readAccess, and
tenants — narrow to your context alone, and the environment (tenants.live/tenants.test)
your context is not bound to reads null rather than the other environment's real totals.
Only a token with cross-context reach (a root key, or a scoped credential with the cross-context
wildcard) sees your full account-wide totals; for it, contextId continues to work as a
display-only filter on the contexts breakdown, and your top-level totals are unaffected by it.
If your integration reads top-level usage totals from a context-confined token, those numbers
now reflect only that context — read contexts[] for the full picture, or use a credential
with cross-context reach.
execution narrows like the other per-context charge fields — a token confined to a single
app context sees only that context's executions. Like every other top-level section it is not
filtered by the contextId query parameter, which stays a display-only filter on contexts.
One caveat specific to it: its credits carry sub-credit remainders forward across your whole
account for the period, so for a confined credential compare execution.creditsMilli against your
account-wide figure rather than recomputing it from that one context's billableMillis.
Two narrower exceptions — do not read this as "everything narrows":
reads.calls.used and reads.dataOut.bytes (the raw call-count and egress-byte counters) have
no per-context breakdown to narrow to at all — they're metered per account, not per context — so
a context-confined token sees 0 for both rather than a narrowed figure. Don't read
reads.calls.used == 0 as "this credential made no calls this period"; it means "this dimension
isn't visible at context granularity." The corresponding charge fields
(reads.calls.overageCredits, reads.dataOut.overageCredits) do narrow correctly, since
overage is charged per context. Separately, credits.limit continues to reflect your whole
plan's ceiling regardless of confinement — only credits.used/credits.remaining narrow, so
credits.remaining may overstate a context-confined credential's true remaining room.
| 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. |
identity.users / identity.entities | object | Write + storage activity for the two identity surfaces (users, and identity entities across every namespace). Reported as an account-level rollup, not narrowed per-namespace. |
readAccess.storageBytes / readAccess.rows | object / number? | PHI read-access-log storage and logged-read count. Present only when read-access logging is enabled on a schema or context. |
reads.calls.used / reads.dataOut.bytes | number | The per-call and data-out (egress) read-metering axes — see the context-narrowing note above for the two exceptions. |
credits.breakdown.scriptExecution | number | Credits consumed by script execution time (trigger firings and POST /v1/scripts/execute alike) beyond the allowance your billable operations earned. Present alongside the other credits.breakdown categories; scriptExecutionMilli is the exact figure. |
execution.executions | number | Trigger-script executions metered this period. Counts every execution that ran your script, including those that ended in an error and those that owed no charge; excludes firings refused before your script ran, which are never metered. |
execution.totalMillis | number | Total measured wall-clock time across those executions. |
execution.includedMillis | number | Execution time included at no charge and actually applied this period. The allowance CONSUMED, not the allowance earned — an execution that finished well inside its included time contributes only what it used, and an execution we do not charge for at all (an internal platform error, say) has its whole duration absorbed here. Both are what make the arithmetic below exact. |
execution.billableMillis | number | The portion actually charged, after the per-execution platform baseline and the included time above. |
execution.credits / execution.creditsMilli | number | Credits charged for execution time. The milli figure is exact; a small charge usually rounds to 0 whole credits. |
tenants.live | object | Same shape (credits / search / inference) scoped to the live tenant. null if your token is confined to a context in the test environment. |
tenants.test | object | Same shape scoped to the test tenant. null if your token is confined to a context in the live environment. |
contexts | array | Per-app-context breakdown, one entry per context with activity. Filtered to a single context when ?contextId= is supplied on a cross-context-reach token. |
The two-axis model
- Monthly credit allowance — covers data-plane work (record writes, document ingests,
searches, read and data-out overage, and script execution time — trigger and synchronous), 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 account level and broken down per tenant; the account total reconciles to
tenants.live + tenants.test. -
Reads do not draw down the credit allowance within your plan's included read and data-out allowances; past those, overage does, and it is reported under
readsand in thecredits.breakdownreads/dataOutcategories. -
Trigger-script execution time draws the same credit allowance, reported by the
executionsection and thecredits.breakdown.scriptExecutioncategory — trigger firings and synchronousPOST /v1/scripts/executecalls are summed together. It is charged on the measured span of each execution — the whole execution, not only your script's own run time: resolving the rule's identity, preparing the sandbox, running your code, and recording the outcome are all inside it, sototalMillisis legitimately larger than what your own instrumentation would report. From that span it subtracts the execution time the execution's billable operations earned, and adds a fixed per-execution platform baseline. Verify a charge like this:billableMillis = totalMillis + (baseline × executions) − includedMillis creditsMilli = billableMillis ÷ (milliseconds per milli-credit)The report gives you every term but two: the per-execution baseline and the rate. Those are published as numbers on the Vectros pricing page and in the
executionschema of the API reference — deliberately not here, since this page does not pin numeric prices and a stale one must never ship from it. Sub-credit remainders carry forward within the period rather than being rounded away, so comparecreditsMilliagainst your period-to-date total, not against a single execution. -
A firing refused before your script ran is never metered at all — a rate limit, a credit ceiling, a concurrency limit, a rule whose principal or grant does not resolve, a deleted rule, an unresolvable script. None of them appears in
execution.executionsorexecution.totalMillis; there is no duration to report, because nothing of yours ran. -
An execution that did run your script is metered, and separately decided to be chargeable or not. A failure we attribute to ourselves — an internal platform error — is metered but not charged, and its whole duration is absorbed into
includedMillis, so the identity above stays exact rather than being thrown off by the executions least likely to be your fault. -
⚠️ An execution that started and then failed is charged, including for reasons that look like refusals. Your script throwing, a timeout, a resource or write-buffer limit, an optimistic-lock conflict on a record another writer touched — and, the two most easily mistaken for pre-flight refusals, a call outside the rule's declared manifest and a permission denial. Those last two are decided while your script runs, and a script that probes, recovers, and then runs for minutes is reported under them: the wall clock was spent either way. Do not read the never-charged list above as covering permission problems generally — it covers a rule that could not be started, not a call your running script was refused.
-
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 account-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.usedrounds the account total, which can differ from the sum of the already-rounded per-tenant values by up to one cent — reconcile onusedMilli, notused. - Pricing rates, plan allowances, and overage policy are not part of this surface; this documentation does not state numeric prices.
- Execution time is not attributed per trigger rule or per script. The
executionsection is an account rollup over the period, and there is no per-rule figure and noexecutionblock inside the per-contextcontexts[]entries — so if you run several trigger rules, the report cannot tell you which script's execution time you are looking at. If you need that separation today, run the script in its own app context and read that context'scredits.usedMilli(which includes the charge), or query with a credential confined to it. If a charge looks wrong and you cannot account for it, raise it with support promptly — the finer-grained evidence behind it is retained for a limited window.
Activity log
client.auth.getAdminLogs(params) (GET /v1/admin/logs). Requires logs:r on a scoped
token (a 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. Accepted values are the data-plane resources (documents, records, search, schemas, folders, usage, models, rag, chat, ask, export, erasure-requests, auth, ping) and the identity surfaces entities, namespaces and users; clients and orgs also validate, matching rows written before those surfaces were folded into entities. An unlisted value is rejected with 400. |
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, requestId, errorCode.
requestId is the call's correlation id — the same value returned as requestId in an error
response body. Quote it when contacting support to have a specific call traced. It is recorded for
successful calls too.
For a failed call, the entry also carries errorCode when the failure had a typed code — an
uppercase token such as RATE_LIMITED, VERSION_CONFLICT or SCRIPT_ERROR.
The set is open and grows as the platform gains new typed failures, so treat any list you see as examples rather than an enumeration: branch on the specific codes you handle and fall back to generic handling for anything else. The codes a given call can return are documented on that endpoint's own error responses;
POST /v1/scripts/executeadditionally reports the name of its failure category, listed in the data-model reference. See Response versioning above for what raisesUNSUPPORTED_WIRE_VERSIONspecifically.
Branch on the errorCode, not the message text. It
is null on successful calls, on failures that carry only a message, and on calls recorded before
the field existed that are still inside the retention window — so read an absent errorCode as
"not recorded", not as "no error". Request and response bodies are never logged, so no further
failure detail is available here by design.
Notes & limits — activity log
- The
resourceandmethodfilters 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).
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-requestsandGET /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 returns501 {"error":"not_implemented"}today — there is no erasure engine behind it yet.- The endpoint requires the root API key; a scoped credential (
ssk_*/st_*) is rejected with a uniform403before 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.
- Data-retention periods are platform constants today; they are not configurable per controller.
Where to go next
- explanation.md — the concepts behind everything cataloged here.
- how-to.md — runnable recipes for webhooks, usage, and the activity log.
- compliance.md — the trust posture, the three sensitive-data mechanisms, retention, and the full reserved list.