Conventions
Envelopes, pagination, idempotency, scope headers and correlation — the same everywhere, so they are not repeated per endpoint.
These hold across the whole API. The per-endpoint pages do not repeat them.
#Base URLs
| Serves | Called by | |
|---|---|---|
| Control plane | /v1/* |
People, console, CLI, your services |
| Gateway | /gateway/v1/* |
Agents only |
Different processes, different ingresses. The gateway additionally serves
/.well-known/veefore-platform-key.
#Response envelopes
Three shapes, not one
The envelope varies by endpoint and it is worth knowing which is which, because
assuming the wrong one is a silent undefined in your client.
A single resource — returned directly, with no wrapper:
json{ "id": "agt_01JAV…", "name": "finance-agent", "status": "ACTIVE" }
A collection with a total:
json{ "items": [ … ], "total": 42 }
Used by /v1/agents and /v1/tools.
A cursor-paginated collection:
json{ "data": [ … ], "page": { "next_cursor": "eyJvZmZzZXQiOjUwfQ", "has_more": true } }
Used by /v1/actions and /v1/audit-events — the two that grow without bound.
A plain array, for collections that are small by nature:
json[ { … }, { … } ]
Used by /v1/policies, /v1/delegations, /v1/credentials, /v1/approvals,
/v1/incidents, /v1/mcp/servers, /v1/a2a/connections, /v1/roles,
/v1/members and others.
#Errors
One shape, everywhere:
json{
"error": {
"code": "AUTHORITY_EXCEEDED",
"message": "Requested action exceeds delegated authority.",
"request_id": "req_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"details": { "capability": "refund.create", "ceiling": 50000, "requested": 120000 }
}
}
Branch on `code`, never on `message`
Messages are written for humans and get refined over time. Codes are a contract.
Messages also never contain secret values, key material, internal hostnames or stack detail — which means they are safe to surface, and also that they will never carry the diagnostic you were hoping for. The action record carries that, behind authentication.
Every code is at Error codes, and live at
GET /v1/meta/error-codes with no authentication.
#Pagination
Cursor-based:
httpGET /v1/actions?cursor=eyJvZmZzZXQiOjUwfQ&limit=50
json{ "data": [ … ], "page": { "next_cursor": "…", "has_more": true } }
Treat the cursor as opaque. It is not an offset and its encoding is not part of the contract.
Some list endpoints additionally accept limit and offset, which is
convenient for a UI and unreliable for iterating a growing collection — rows
shift under you. Use the cursor for anything that must not miss or duplicate.
#Scope headers
httpGET /v1/agents
Authorization: Bearer aos_sk_…
x-veefore-workspace: wsp_01JAV7YQ4T2K9M3E8H6R5N1XZ0
x-veefore-environment: env_01JAV7YQ4T4ZC1P8W2M6B9K3QD
Optional. Omit them and you get the organization-wide view, subject to your permissions.
The tenant never comes from a header
It comes from the authenticated session. Scope headers select a scope within your organization and cannot select another one.
Supplying a workspace or environment you have no access to is refused. These are a filter over what you can already see.
#Idempotency
httpPOST /v1/agents
Idempotency-Key: register-finance-agent
| Situation | Result |
|---|---|
| Same key, same body | The original result is returned. Nothing runs again |
| Same key, different body | 409 IDEMPOTENCY_KEY_REUSED |
| Different key, same body | A new, separate operation |
Endpoints that accept it are marked accepts Idempotency-Key in the
API reference.
A fresh UUID per attempt is the same as no key
The key must identify the operation, not the attempt. crypto.randomUUID()
looks correct in review because there is a key, and defeats the entire
mechanism because the retry carries a different one.
Derive it from the business operation. → Idempotency
#Correlation
Every response:
httpx-request-id: req_01JAV7YQ4T2K9M3E8H6R5N1XZ0
x-trace-id: 4bf92f3577b34da6a3ce929d0e0e4736
Both appear on the action record, on the audit event, and in every structured log line the request produced.
Given a request_id you have the decision, its full lineage, the audit event
and the logs. That is the intended debugging path, which is why the ids are on
the response rather than only in the logs.
#Identifiers
ULID-based, with a type prefix. Sortable by creation time, and the prefix tells you what you are holding.
| Prefix | |
|---|---|
org_ |
Organization |
wsp_ |
Workspace |
env_ |
Environment |
agt_ |
Agent |
pol_ |
Policy |
del_ |
Delegation |
tool_ |
Tool |
cred_ |
Credential |
act_ |
Action |
apr_ |
Approval |
evt_ |
Audit event |
inc_ |
Incident |
req_ |
Request |
apk_ |
API key |
An id is not a credential
Ids appear in URLs, logs and error messages. Knowing an agent id gets you nothing; acting as that agent requires its private key.
#Timestamps
ISO-8601, UTC, millisecond precision, always Z:
json{ "created_at": "2026-10-03T12:38:04.123Z" }
Including in signed assertions, where iat and exp are ISO-8601 strings
rather than numeric epoch seconds. That departs from common JWT practice
deliberately — one format across the entire surface is worth more than
conformance to a convention in one place.
#Field naming
| Layer | Convention |
|---|---|
| HTTP request and response bodies | snake_case |
| TypeScript SDK | camelCase |
| Python SDK | snake_case |
The SDKs convert. Writing against HTTP directly, use snake_case.
Two names worth checking
required_capability, not capability, when registering a tool.
confirm_blast_radius, not anything else, on an emergency control.
Both are 400s that look like a schema problem and are a field-name problem.
#Amounts
Minor currency units, as integers. 250_00 is ₹250.00.
A policy input and recorded decision context, nothing more. Veefore reads the number, decides, records what it decided, and moves nothing. → Scope
#Rate limiting
http429 Too Many Requests
Retry-After: 2
Honour Retry-After and retry with backoff and jitter.
Without Redis, limits are per instance
In a multi-node deployment with REDIS_URL unset, each instance keeps its own
rate limit and nonce state. The effective limit is multiplied by your instance
count, and behaviour depends on which instance a request lands on.
Set REDIS_URL for anything multi-node.
#Versioning
Everything is under /v1. Within v1 the contract does not break — new fields
are added, existing fields keep their meaning.
A breaking change would arrive as v2, served alongside v1, with v1
supported through a deprecation window. There is no silent change to the meaning
of an existing field, which is the one thing a client cannot defend against.