Backend integration
API keys & authentication
Creating and rotating API keys, roles and tenant scoping, the base URL, RFC 9457 errors and rate limits.
Every call your backend makes to Agentic Workforce ME carries two headers: a tenant API key and the tenant id. Both are checked before any route runs; every table is tenant-scoped with row-level security, so a key can never read or write another tenant.
# Every platform call: bearer API key + the tenant id
curl https://console.agenticworkforce.me/v1/agents?limit=5 \
-H "Authorization: Bearer $HIVE_API_KEY" \
-H "X-Tenant-Id: $HIVE_TENANT_ID"API keys
Authorization: Bearer hive_…— create one in the console (Settings → API keys) or withPOST /v1/api-keys. The secret is shown once; the platform stores a hash.X-Tenant-Id: <uuid>— the tenant the key belongs to. A key presented with another tenant’s id is rejected with the same 401UNAUTHORIZEDas an unknown key: the key is looked up inside the tenant you named, so the API cannot tell — and deliberately does not reveal — that it exists elsewhere. Without the header the call is a 400TENANT_REQUIRED.last_used_atis updated on every authenticated call, which is how you find keys that are safe to revoke.
# Mint a dedicated key for the integration (admin). The secret is shown once.
curl -X POST https://console.agenticworkforce.me/v1/api-keys \
-H "Authorization: Bearer $HIVE_ADMIN_API_KEY" \
-H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"name":"orders-service"}'
# → 201 { "id": "…", "name": "orders-service", "prefix": "hive_ab12…", "secret": "hive_…" }Roles: what a key may do
Routes are gated by member role (member < admin < owner). An API key always acts as admin: it can read and mutate every tenant resource this section uses (agents, workflows, triggers, MCP servers, connections, tool grants, approval policies, webhooks, other API keys), and it can decide approvals assigned to member, admin or manager. Owner-only routes and owner-assigned approvals return 403. There are no narrower scopes on a key today — if you need least privilege between services, create one key per service and keep them apart in your secret manager.
Some routes are for people only. Changing a member’s role (making or demoting an owner included), custom roles, org-node roles and MFA answer 403 HUMAN_REQUIRED to an API key (and to support sessions). Removing an owner answers 403 OWNER_REQUIRED: only an owner signed in to the console may. A member’s private agent stays private: a key keeps admin governance reads and actions on it (manual runs and triggers included) but cannot open or post to its conversations (403 AGENT_PRIVATE).
In the audit log a key appears as apikey:<key id>, so the actor of every provisioning call and approval decision made by your backend is attributable to the key you created for it.
Rotation
- Create the new key and store its secret.
- Deploy the new secret to your service; confirm calls succeed.
DELETE /v1/api-keys/:idon the old key. Revocation is immediate; in-flight requests with the old key start failing with 401UNAUTHORIZED.
Base URL and conventions
- Base URL: your deployment’s API origin (in these docs
https://console.agenticworkforce.me). All resources live under/v1; the one exception is the public hook endpoint/hooks/:triggerId, which authenticates with an HMAC instead of a key. - JSON request bodies (
Content-Type: application/json); ids are UUIDs (v7); timestamps are ISO 8601 in UTC. - Lists paginate with
?cursor=&limit=(default 25, max 100) and return{ items, next_cursor }. GET /v1/openapi.jsonis the live OpenAPI document; the TypeScript SDK types are generated from the same Zod DTOs the API validates with.
A client in your language
import { HiveClient } from '@hive/sdk';
export const hive = new HiveClient({
baseUrl: process.env.HIVE_API_URL!, // https://console.agenticworkforce.me
apiKey: process.env.HIVE_API_KEY!, // hive_… (server-side only)
tenantId: process.env.HIVE_TENANT_ID!, // the tenant the key belongs to
});// Plain fetch — the same three headers on every call.
export async function hive<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${process.env.HIVE_API_URL}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env.HIVE_API_KEY}`,
'x-tenant-id': process.env.HIVE_TENANT_ID!,
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
// RFC 9457 problem+json: { type, title, status, code, detail? }
const problem = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${problem.code ?? ''} ${problem.detail ?? ''}`);
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}import os
import requests
HIVE = os.environ["HIVE_API_URL"]
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + os.environ["HIVE_API_KEY"],
"X-Tenant-Id": os.environ["HIVE_TENANT_ID"],
})
def hive(method: str, path: str, json=None):
res = session.request(method, HIVE + path, json=json, timeout=30)
if res.status_code >= 400:
problem = res.json() # RFC 9457: type, title, status, code, detail
raise RuntimeError(f"{res.status_code} {problem.get('code')}: {problem.get('detail') or problem.get('title')}")
return res.json() if res.content else Noneusing System.Net.Http.Headers;
using System.Net.Http.Json;
var hive = new HttpClient { BaseAddress = new Uri(Environment.GetEnvironmentVariable("HIVE_API_URL")!) };
hive.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("HIVE_API_KEY"));
hive.DefaultRequestHeaders.Add("X-Tenant-Id", Environment.GetEnvironmentVariable("HIVE_TENANT_ID"));
var res = await hive.GetAsync("/v1/agents?limit=5");
if (!res.IsSuccessStatusCode)
{
// application/problem+json → { type, title, status, detail, code }
var problem = await res.Content.ReadFromJsonAsync<Dictionary<string, object>>();
throw new Exception($"{(int)res.StatusCode} {problem?["code"]}: {problem?["detail"]}");
}import java.net.URI;
import java.net.http.*;
HttpClient http = HttpClient.newHttpClient();
String base = System.getenv("HIVE_API_URL");
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/v1/agents?limit=5"))
.header("Authorization", "Bearer " + System.getenv("HIVE_API_KEY"))
.header("X-Tenant-Id", System.getenv("HIVE_TENANT_ID"))
.GET()
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
// application/problem+json body: { "type", "title", "status", "detail", "code" }
throw new RuntimeException(res.statusCode() + " " + res.body());
}Errors (RFC 9457)
Every error is an application/problem+json document with a stable machine-readable code. Branch on code, show detail to a developer, never parse title.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{ "type": "about:blank", "title": "Requires admin role", "status": 403, "code": "FORBIDDEN" }
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{ "type": "about:blank", "title": "Bad Request", "status": 400, "code": "VALIDATION",
"detail": "events: Too small: expected array to have >=1 items; url: Invalid URL" }| Code | Status | Meaning | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing, revoked or unknown API key (or no session) — including a live key presented with another tenant’s X-Tenant-Id: the key is looked up inside that tenant, so the API cannot tell (and deliberately does not reveal) that it exists elsewhere. | Send Authorization: Bearer hive_… with a live key of the tenant in X-Tenant-Id. |
TENANT_REQUIRED | 400 | API key calls need the tenant id. | Add X-Tenant-Id: <tenant uuid>. |
FORBIDDEN | 403 | Role too low for the route or the approval. | Admin routes need an API key or admin/owner session; owner-assigned approvals cannot be decided with a key. |
VALIDATION | 400 | Body or query failed Zod validation. | Read detail: <field path>: <message> pairs separated by ; (for example agent_id: Invalid UUID). |
NOT_FOUND | 404 | Unknown id in this tenant (also every unknown/disabled hook URL). | Check the id and the tenant header. |
RATE_LIMITED | 429 | Per-tenant token bucket exhausted. | Honour Retry-After (seconds); spread bursts. |
INVALID_SIGNATURE | 401 | Hook HMAC does not match the raw body. | Sign the exact bytes you send; check the secret and the sha256= prefix. |
INVALID_BODY | 400 | Hook body is not JSON. | Send a JSON object with Content-Type: application/json. |
AGENT_NOT_PUBLISHED | 409 | The target agent has no published version. | Publish the draft (POST /v1/agents/:id/versions/:v/publish). |
WORKFLOW_NOT_PUBLISHED | 409 | The target workflow has no published version. | Publish it (POST /v1/workflows/:id/versions/:v/publish). |
TRIGGER_DISABLED | 409 | Manual fire of a paused trigger. | Enable it with PATCH /v1/triggers/:id. |
SLUG_TAKEN | 409 | Slug already exists in the tenant. | Pick another slug or reuse the existing resource (idempotent provisioning). |
BPMN_PARSE_ERROR | 422 | The BPMN upload is not well-formed XML, contains a <!DOCTYPE (refused before parsing — XXE / entity-expansion defence), uses an undeclared entity, or is not a BPMN 2.0 <definitions> document. reason names which. | Export the diagram again from the modeller (Camunda Modeler / bpmn.io / Signavio / Bizagi / Visio → BPMN 2.0 XML) and remove any DOCTYPE. |
BPMN_NO_PROCESS | 422 | The definitions contain no <process> element (collaboration-only or empty file). | Export a diagram that contains at least one process; pick it with options.process_id when there are several. |
BPMN_TOO_LARGE | 413 | 413: the body / file exceeds 2 MiB. 422 (same code): the parser budget was exceeded (5 000 elements, depth 64, 64 KiB text) or the mapped graph exceeds 100 nodes / 400 edges (nodes, edges, max_nodes, max_edges in the problem). | Split the diagram by sub-process or pool and import each part as its own workflow (call the parts with subworkflow nodes). |
INVALID_GRAPH | 422 | On a BPMN import commit: the bound graph fails validation — unbound[] lists every agent-like node / parallel branch (node_id or node_id/branch_id) still missing a binding, problems[] the remaining structural issues. Also raised by publish / test-run when a stored graph is structurally invalid. | Send a bindings entry for every report.bindings[] row (or switch the node with overrides), then commit again; the console review page does this for you. |
INVALID_URL | 422 | Webhook URL rejected by the SSRF guard. | Use https and a public host; for local testing expose a tunnel URL. |
ALREADY_DECIDED | 409 | Approval is no longer pending or you already decided it. | Treat as success if your decision matches; otherwise read the approval. |
BAD_STATE | 409 | The run is not in a state the operation applies to — cancelling an already-terminal run, or delivering a wait_event to a run that already finished (succeeded / failed / cancelled). | Read the run (GET /v1/runs/:id); a terminal status means there is nothing left to cancel or resume. A queued/running run accepts the event (it is buffered). |
NO_PENDING_WAITER | 409 | No wait_event node in the run’s workflow declares this event_name, or the event was already delivered to it. | Check the node’s event_name in the published graph; treat a repeat delivery as success (one event per node per run). |
INVALID_ARGS | 422 | edited_args on an edit decision failed the tool’s JSON Schema. | Read detail; send arguments the tool schema accepts (or approve/reject instead). |
MCP_UNREACHABLE | 502 | The platform could not connect to your MCP server or list its tools. | Check the endpoint is reachable from the platform’s worker, the bearer credential, and the SSRF allow-list for local hosts. |
MCP_STDIO_NOT_ALLOWED | 403 | A stdio MCP server was registered or patched with a command that is not one of the MCP servers bundled with the deployment. The same check runs before any process starts, so an older row with such a command is refused at run time too. | Host your server and register it with transport: "http" (Streamable HTTP). |
UNSUPPORTED_TYPE | 415 | A thread attachment is not PNG, JPEG, WebP or PDF. | Convert the file; detail names the rejected MIME type and filename. |
FILE_TOO_LARGE | 413 | A thread attachment exceeds 20 MB. | Compress or split the file before uploading. |
UNAVAILABLE | 503 | Memory-graph search and human authoring need the deployment’s embedding gateway, and none is configured. | Reads that do not embed (entities, edges, episodes, neighborhood) still work; ask the platform operator to configure embeddings. |
ENTITY_NAME_TAKEN | 409 | Renaming a memory-graph entity would collide with another active entity of the same scope. | Merge the two entities (POST /v1/memory/graph/entities/:id/merge) instead of renaming. |
SCOPE_MISMATCH | 409 | Memory-graph entities can only be merged within one scope (same scope, agent_id, end_user_id and crew_id). | Pick a target entity in the same scope; a contact’s entity never merges into an agent-wide or another contact’s one. |
Rate limits
A token bucket per tenant (per client IP when no tenant header is present) with a default capacity of 120 requests refilled at 20 per second (deployment-configurable). When it is empty you get 429 RATE_LIMITED with a Retry-After header in seconds and the same value as retry_after in the body. Sustained throughput above 20 requests/s from one service is a sign to batch (bulk approval decisions, list endpoints with limit=100) rather than retry harder.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 1
{ "type": "about:blank", "title": "Too Many Requests", "status": 429, "code": "RATE_LIMITED",
"detail": "Per-tenant request rate limit exceeded.", "retry_after": 1 }Some self-serve routes carry stricter scoped buckets (their detail names the scope). Health probes are never limited. The hook endpoint and SSE streams count like any other request when they start; an open SSE connection does not consume tokens while it streams.
API key endpoints
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDEN
Response
| Field | Type | Description |
|---|---|---|
items[].id | uuid | Key id (use it to rename / revoke). |
items[].name | string | Display name. |
items[].prefix | string | Display prefix such as hive_ab12… — enough to recognise a key. |
items[].last_used_at | ISO date-time | null | Updated on every authenticated call. |
items[].revoked_at | ISO date-time | null | Set once revoked; revoked keys stop authenticating immediately. |
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENVALIDATION
Request
| Field | Type | Description |
|---|---|---|
name | string | Display name (1–120). |
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id. |
secret | string | The full hive_… secret. Store it in your secret manager now — it is hashed at rest and never shown again. |
prefix | string | Display prefix. |
- Audited as
api_key.create.
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENNOT_FOUND
Request
| Field | Type | Description |
|---|---|---|
name | string | New display name. |
- Audited as
api_key.rename. A revoked key returns 404.
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENNOT_FOUND
- Audited as
api_key.revoke.