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.
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). |
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. |
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. |
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.