API Reference
BlackLake runs in two modes. In local mode, started with npx blacklake serve, the API is available at http://localhost:3100. In cloud mode, the base URL is https://api.blacklake.systems.
The API is identical in both modes. All /v1/* endpoints require authentication via the x-api-key header. In local mode, authentication is present for compatibility but is not enforced — you can pass any non-empty string. Proxy endpoints (/proxy/*) do not require a BlackLake key.
Authentication#
BlackLake supports two authentication modes. The mechanism used determines which endpoints are accessible and what identity is resolved.
API key — Pass the key in the x-api-key header or as Authorization: Bearer <key>. This is the mode used by the SDK, the CLI, MCP integrations, and direct API consumers. The resolved identity is an organisation only; no user context is present (userId is null on the server side). API keys are prefixed bl_ and are issued at signup or via POST /v1/api-keys.
x-api-key: bl_8c17dd680f...
Session cookie — Used by the console at console.blacklake.systems. A bl_session httpOnly cookie is set by POST /v1/auth/login or POST /v1/auth/signup and cleared by POST /v1/auth/logout. The resolved identity includes both the user and their active organisation. Sessions are rolling: every authenticated request advances last_seen_at and, once past the halfway mark, extends expires_at by the full 30-day TTL — so active users are never logged out unexpectedly. Idle sessions expire naturally after 30 days without activity.
Read-only endpoints under /v1/users/me/* (profile, sessions, push subscriptions) accept user-scoped API keys (bl_usr_…) as well as session cookies. Write endpoints (POST /v1/users/me/switch-org, DELETE /v1/users/me/sessions/:id) require a session cookie; org-scoped bl_ key callers receive 403 SESSION_REQUIRED.
Conventions#
A handful of API-wide behaviours that apply to every endpoint.
CORS#
Cloud mode allows browser requests only from https://console.blacklake.systems. Other origins are rejected at the edge — call the API server-to-server, not from a browser at a different origin. Local mode allows any origin (no CORS restrictions) so dev tools can probe http://localhost:3100 freely.
Rate limiting#
Cloud mode enforces a sliding window of 1800 requests per 5 minutes per API key. Every response carries:
| Header | Meaning |
|---|---|
x-ratelimit-limit | Window size (1800). |
x-ratelimit-remaining | Requests left in the current window. |
x-ratelimit-reset | Unix epoch seconds when the window resets. |
When the limit is exceeded the API returns 429 RATE_LIMITED with a Retry-After header. The SDK's BlackLakeError.isRetriable() returns true for 429, so callers can use exponential backoff.
Sort and pagination#
List endpoints use a uniform envelope:
{ "data": [...], "total": 142, "limit": 50, "offset": 0, "sort": "created_at", "order": "desc" }
Pass ?limit= (1–200; values above 200 are clamped to 200 and the response carries an X-Limit-Clamped-From header echoing what you requested), ?offset=, ?sort=<field>, ?order=asc|desc. Sortable fields are listed per endpoint. Sorting is codepoint-ordered (e.g. emoji sorts before 'a'); locale-aware sort is on the roadmap. Passing an unknown ?sort= or ?order= value returns 400 INVALID_QUERY_PARAM with a field hint — silent fallback was masking client-side typos.
Errors#
Every error response has the shape:
{
"error": { "code": "VALIDATION_ERROR", "message": "...", "details": { ... } },
"request_id": "req_…"
}
Quote request_id in support tickets. Validation errors include a hint field on details listing accepted values.
Status code semantics
| Status | When you get it | SDK class | Retry? |
|---|---|---|---|
400 Bad Request | JSON syntax error, missing required field, wrong type, value not in an enum (e.g. unsupported period). Fix the request shape. | BlackLakeError | No |
401 Unauthorized | Missing/invalid x-api-key or session cookie, or webhook signature failed. | BlackLakeError | No |
402 Payment Required | Free-tier monthly action quota exhausted (PLAN_LIMIT_REACHED). Upgrade or wait for the period to roll over. | PaymentRequiredError | After upgrade or next period |
403 Forbidden | Authenticated, but the actor lacks the role required for this action. | BlackLakeError | No |
404 Not Found | The resource doesn't exist or isn't visible to this workspace. | BlackLakeError | No |
409 Conflict | Concurrency or uniqueness collision — duplicate name, binding already exists, installation linked to another org. Retrying with the same input will fail again. | ConflictError | No |
410 Gone | Resource is permanently unrecoverable (e.g. soft-delete restore window expired). | BlackLakeError | No |
422 Unprocessable Entity | Request shape is fine, but the data violates a business rule — APPROVAL_ALREADY_DECIDED, LAST_ADMIN, CONFIRMATION_MISMATCH, NO_FAILED_DELIVERIES, HEADERS_REQUIRED for a specific upstream type, etc. Fix the entity state, not the JSON. | BusinessRuleError | No |
429 Too Many Requests | Rate limit exceeded. Honour the Retry-After header. | BlackLakeError | Yes (after delay) |
5xx | Server-side fault. Transient. | BlackLakeError | Yes (with backoff) |
Why 400 vs 422 matters: before BL-FND-16 the API returned 400 for both "your JSON is malformed" and "this approval was already decided". A REST client reading status codes had no way to tell shape errors from data-state errors. Splitting them lets a SDK / SIEM / dashboard branch on intent: a 400 means the developer made a mistake, a 422 means the workflow state has changed since the user opened the page.
The TypeScript SDK exposes BusinessRuleError, ConflictError, and PaymentRequiredError as subclasses of BlackLakeError so callers can catch (e) and instanceof the specific subclass instead of inspecting .status.
Singular response envelope (FND-17, content-negotiated)#
List endpoints have always returned { data: [...], total, limit, offset, sort, order }. Singular endpoints (POST / PATCH / GET-by-id) historically returned the bare object — making SDK code-generation special-case every route.
To unify the shape without breaking pre-FND-17 clients, singular responses are now content-negotiated. Send Accept-Envelope: v2 and the response is wrapped:
// Without Accept-Envelope (legacy):
GET /v1/agents/agent_01jv...
{ "id": "agent_01jv...", "name": "checkout-bot", "status": "active", ... }
// With Accept-Envelope: v2:
{ "data": { "id": "agent_01jv...", "name": "checkout-bot", "status": "active", ... } }
The TS SDK sends Accept-Envelope: v2 automatically and peels .data so callers always get the bare object. Bare-shape responses include a BlackLake-Singular-Envelope: deprecated; ... header so legacy callers know the shape is moving.
Migration is incremental: agent endpoints are migrated as of 2026-05-10. Other singular endpoints will follow in subsequent releases. A future release will flip the default once all in-house clients have moved.
Internal & Maintenance#
These endpoints are documented for completeness — they're rarely needed in normal use but operators reach for them often enough that they should be visible.
POST /v1/feedback#
Send free-form feedback to the BlackLake team. Authenticated. The message is emailed to hello@blacklake.systems (or FEEDBACK_TO_EMAIL on a private/local deployment) along with the org name and contact email so the team can reply directly.
Request body — { "message": "..." } (1–4000 chars)
Response — 202 Accepted (best-effort; returns 202 even if the email transport fails)
POST /v1/test/run, POST /v1/test/archive-test-resources#
Smoke-test workflow for verifying the governance pipeline end-to-end without polluting real inventory. Documented in Smoke Testing below — the cleanest hello-world for a new workspace.
POST /v1/organisation/reset#
Wipes all workspace data (agents, tools, policies, evaluations, approvals, cost records, audit events, ingest logs) while preserving the workspace itself, its members, and its API keys. Documented in Organisation below — useful between demos or when migrating from a sandbox to a production workspace.
User Accounts & Auth#
These endpoints handle account creation, login, logout, email verification, password reset, and invitation acceptance. They are public — no x-api-key or session cookie is required to call them (rate limits apply in cloud mode).
POST /v1/auth/signup#
Create a new user account, a new organisation, and an initial API key, then sign the user in immediately by setting a bl_session cookie.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
organisation_name | string | yes | Display name for the new organisation (2–100 chars). |
email | string | yes | Contact email address. |
password | string | yes | Minimum 10 characters. |
accept_terms | true | yes | Must be the literal boolean true. |
turnstile_token | string | no | Cloudflare Turnstile token if Turnstile is enabled on the deployment. |
Response — 201 Created + sets bl_session cookie
{
"user": { "id": "usr_01jv...", "email": "ops@acme.example", "email_verified_at": null },
"organisation": { "id": "org_01jv...", "name": "Acme Corp", "contact_email": "ops@acme.example" },
"api_key": "bl_8c17dd680f...",
"api_key_warning": "This is the only time your API key will be shown. Save it now."
}
The api_key is shown once. A welcome email with the same key is sent to the contact address. An email verification link is also sent; the account is usable before verification, but email_verified_at will be null until the link is clicked.
If a user with this email exists but all their organisations are soft-deleted, the existing user row is reused (password replaced, prior sessions revoked) and a fresh organisation is attached. This avoids permanently locking someone out of their email address after deleting a workspace.
Errors
| Code | Status | Cause |
|---|---|---|
CAPTCHA_FAILED | 400 | Turnstile token verification failed. |
EMAIL_EXISTS | 409 | A user with this email already has an active organisation membership. |
POST /v1/auth/login#
Authenticate an existing user and set a bl_session cookie.
Request body
{ "email": "ops@acme.example", "password": "correcthorsebattery" }
Response — 200 OK + sets bl_session cookie
{
"user": { "id": "usr_01jv...", "email": "ops@acme.example", "email_verified_at": "2026-04-06T09:00:00.000Z" },
"active_organisation_id": "org_01jv..."
}
Returns 401 INVALID_CREDENTIALS for an incorrect email or password. The response is deliberately constant-time and does not indicate which field was wrong.
Returns 403 NO_ACTIVE_WORKSPACE if the user exists but has no memberships in non-soft-deleted organisations. Sign up as a new workspace, or restore the deleted one via POST /v1/organisation/restore.
POST /v1/auth/logout#
Revoke the current session and clear the bl_session cookie. Safe to call even when no session is present.
Response — 200 OK
{ "logged_out": true }
POST /v1/auth/verify-email#
Consume the one-time token sent during signup to mark the account's email as verified.
Request body
{ "token": "<token from email link>" }
Response — 200 OK
{ "verified": true }
Returns 400 INVALID_TOKEN if the token is missing, already used, or expired (tokens are valid for 24 hours from signup).
POST /v1/auth/resend-verification#
Re-issue an email-verification link for an unverified user. Always returns { sent: true } regardless of whether the email matches a real user — same anti-enumeration response shape as /password-reset. If the email maps to an active, unverified user, BlackLake invalidates any prior outstanding verification tokens for that account and dispatches a fresh email.
Approval push and email fan-out gates on email_verified_at IS NOT NULL, so a user who lost their original verification email gets zero notifications until they verify. This endpoint is the supported recovery path.
Request body
{ "email": "ops@acme.example" }
Response — 200 OK
{ "sent": true }
Rate-limited at 5 / hour per IP. Public route — no auth required.
POST /v1/auth/password-reset#
Initiate a password reset. Always returns { sent: true } regardless of whether the email matches a user — this is intentional to prevent user enumeration. If a match is found, an email containing a 1-hour reset link is sent.
Request body
{ "email": "ops@acme.example" }
Response — 200 OK
{ "sent": true }
POST /v1/auth/password-reset/confirm#
Consume a password-reset token and set a new password. Clicking the reset link proves control of the email address, so email_verified_at is also stamped on confirm — useful for accounts that were created but never verified.
Request body
{ "token": "<token from reset email>", "password": "newpassword123" }
Response — 200 OK
{ "password_changed": true }
Returns 400 INVALID_TOKEN if the token is missing, already used, or expired.
POST /v1/auth/invitation/accept#
Accept a pending workspace invitation. The invitation is identified by a short-lived token emailed to the invitee.
If the invitee's email does not yet have a BlackLake account, password is required and a new user is created. Existing users simply join without providing a password. On success, a bl_session cookie is set for the joined workspace.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Token from the invitation email. |
password | string | conditional | Required if no existing account exists for the invited email. Minimum 10 characters. |
Response — 200 OK + sets bl_session cookie
{
"accepted": true,
"organisation": { "id": "org_01jv...", "name": "Acme Corp" },
"user": { "id": "usr_01jv...", "email": "colleague@acme.example" }
}
Errors
| Code | Status | Cause |
|---|---|---|
INVALID_INVITATION | 400 | Token is invalid, expired, or already accepted. |
PASSWORD_REQUIRED | 400 | No account exists for the invited email and no password was supplied. |
WORKSPACE_UNAVAILABLE | 410 | The workspace was soft-deleted before the invitation was accepted. |
User Profile & Sessions#
All endpoints in this section require session-cookie authentication. API-key callers receive 403 SESSION_REQUIRED.
GET /v1/users/me#
Return the current user's profile, their organisation memberships, and their active organisation for this session.
Response — 200 OK
{
"user": {
"id": "usr_01jv...",
"email": "ops@acme.example",
"email_verified_at": "2026-04-06T09:00:00.000Z",
"created_at": "2026-04-06T09:00:00.000Z"
},
"memberships": [
{
"organisation_id": "org_01jv...",
"organisation_name": "Acme Corp",
"role": "admin",
"joined_at": "2026-04-06T09:00:00.000Z"
}
],
"active_organisation_id": "org_01jv..."
}
POST /v1/users/me/switch-org#
Set the active organisation for the current session. Subsequent requests in this session will scope to the new organisation.
Request body
{ "organisation_id": "org_01jv..." }
Response — 200 OK
{ "active_organisation_id": "org_01jv..." }
Returns 400 NOT_A_MEMBER if the user is not a member of the target organisation, or if the organisation is soft-deleted.
GET /v1/users/me/sessions#
List all active (non-revoked, non-expired) sessions for the current user. Session IDs are truncated in id for display; the full value is in full_id and should be used for revocation.
Response — 200 OK
{
"sessions": [
{
"id": "a1b2c3d4…",
"full_id": "a1b2c3d4e5f6g7h8...",
"created_at": "2026-04-06T09:00:00.000Z",
"last_seen_at": "2026-04-28T10:00:00.000Z",
"expires_at": "2026-05-28T10:00:00.000Z",
"client_ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
}
]
}
DELETE /v1/users/me/sessions/:id#
Revoke a session by its full_id. Only sessions belonging to the calling user may be revoked.
Response — 204 No Content
Returns 404 SESSION_NOT_FOUND if the session does not exist or does not belong to the caller.
GET /v1/users/me/push-subscriptions#
List Web Push subscriptions registered to the current user. Each row carries id, user_agent, created_at, and last_seen_at. The endpoint and key material are intentionally withheld — they're stored only on the server and never echoed back.
Used by the console settings page to render "you have N devices subscribed for approval notifications" with one-click delete.
POST /v1/users/me/push-subscriptions#
Register a new Web Push subscription. Body is the standard PushSubscription JSON the browser produces from serviceWorkerRegistration.pushManager.subscribe() — { endpoint, keys: { p256dh, auth } }. Idempotent on endpoint.
Approval push fan-out gates on email_verified_at IS NOT NULL. Subscriptions registered before the email is verified persist but receive nothing until verification.
DELETE /v1/users/me/push-subscriptions/:id#
Drop one push subscription. Used by the per-device "remove" action in console settings.
Members & Invitations#
These endpoints scope to the caller's active organisation. Any authenticated caller (API key or session) may read the member directory. Inviting, updating roles, removing members, and revoking invitations require admin role.
GET /v1/organisation/members#
List current members and pending invitations for the active organisation.
Response — 200 OK
{
"members": [
{
"user_id": "usr_01jv...",
"email": "ops@acme.example",
"email_verified_at": "2026-04-06T09:00:00.000Z",
"role": "admin",
"joined_at": "2026-04-06T09:00:00.000Z"
}
],
"pending_invitations": [
{
"id": "inv_01jv...",
"email": "colleague@acme.example",
"role": "member",
"invited_at": "2026-04-28T10:00:00.000Z",
"expires_at": "2026-05-12T10:00:00.000Z"
}
]
}
POST /v1/organisation/members/invite#
Send an invitation email to a new member. The invitation link is valid for 14 days. Admin only.
Request body
{ "email": "colleague@acme.example", "role": "member" }
role is admin or member. Defaults to member if omitted.
Response — 201 Created
{
"id": "inv_01jv...",
"email": "colleague@acme.example",
"role": "member",
"expires_at": "2026-05-12T10:00:00.000Z"
}
Errors
| Code | Status | Cause |
|---|---|---|
FORBIDDEN | 403 | Caller does not have admin role. |
ALREADY_MEMBER | 409 | The email is already a member of this organisation. |
INVITATION_PENDING | 409 | A non-accepted invitation for this email is already outstanding. |
PATCH /v1/organisation/members/:user_id#
Update a member's role. Admin only.
Request body
{ "role": "admin" }
Response — 200 OK
{ "user_id": "usr_01jv...", "role": "admin" }
Returns 422 LAST_ADMIN if demoting the last admin in the organisation.
DELETE /v1/organisation/members/:user_id#
Remove a member from the organisation. Admin only. You cannot remove yourself; use a separate leave-workspace flow (forthcoming).
Response — 204 No Content
Errors
| Code | Status | Cause |
|---|---|---|
LAST_ADMIN | 422 | Removing this member would leave the organisation with no admins. |
CANNOT_REMOVE_SELF | 422 | Caller attempted to remove their own membership. |
MEMBER_NOT_FOUND | 404 | No member with this user ID in the organisation. |
DELETE /v1/organisation/invitations/:id#
Revoke a pending invitation so the link can no longer be accepted. Admin only.
Response — 204 No Content
Returns 404 INVITATION_NOT_FOUND if the invitation does not exist or does not belong to the caller's organisation.
MCP Upstreams#
Each workspace maintains a registry of upstream MCP servers. Tool calls reach BlackLake at the per-upstream proxy endpoint POST https://api.blacklake.systems/mcp/u/<name>, run through governance, and are forwarded to the upstream with the right outbound auth — static credentials for legacy MCPs, or OAuth 2.1 with per-user access tokens for OAuth-protected MCPs (Atlassian, Linear, Sentry, Notion, Slack). The local blacklake serve exposes the same path at http://localhost:3100/mcp/u/<name>.
Setup for an OAuth upstream:
POST /v1/mcp/upstreamsto register the upstream URL.POST /v1/mcp/upstreams/:id/oauth/configure— BlackLake fetches the upstream's OAuth metadata (RFC 8414 / MCP spec) and registers itself as a client (RFC 7591). One-shot per upstream.- Each user signs in to the console and clicks Connect on the upstream row —
POST /v1/mcp/upstreams/:id/oauth/startreturns an authorization URL; the user consents at the upstream; BlackLake stores the resulting refresh + access tokens encrypted, scoped to that user. - Mint a user-scoped API key (
bl_usr_…) atPOST /v1/api-keys/user; configure the user's MCP client with it; the user's MCP tool calls now route through governance with the right OAuth token attached.
Every upstream has a policy that governs how the proxy handles tool calls from that server:
allow— tool calls succeed unless an explicit deny policy matches.deny— tool calls fail closed unless an explicit allow policy matches.ask— tool calls always proceed but every call is recorded as an evaluation for review.
Headers (such as Authorization tokens for the upstream) are stored encrypted at rest. API responses never return the decrypted values — only the key names present in header_keys.
GET /v1/mcp/upstreams#
List all registered upstreams for the caller's organisation. Each row carries a
derived readiness field — prefer it over piecing together auth_type,
oauth_state, and the user's connection state by hand.
Response — 200 OK
{
"upstreams": [
{
"id": "mcps_01jv...",
"name": "github-tools",
"url": "https://mcp.example/github",
"policy": "allow",
"enabled": true,
"header_keys": ["Authorization"],
"auth_type": "oauth2",
"oauth_state": "client_registered",
"readiness": {
"state": "user_connection_required",
"label": "Sign-in required",
"hint": "OAuth client is configured. Sign in to authorize the connection."
},
"created_at": "2026-04-10T08:00:00.000Z",
"updated_at": "2026-04-10T08:00:00.000Z"
}
]
}
readiness.state is one of:
| State | Meaning |
|---|---|
ready | Either static-headers (always ready) or OAuth with an active user token. The proxy can call this upstream right now. |
user_connection_required | OAuth client is registered but the calling user hasn't connected. Use the Connect button or POST /:id/oauth/start. |
auth_configuration_required | The upstream is OAuth but no client is registered yet. Use Set up auth or POST /:id/oauth/configure. |
reachable | Upstream URL responds; auth not yet configured. |
error | OAuth metadata or registration failed. Re-run /oauth/configure. |
unknown | The upstream has not been tested since the last config change. |
GET /v1/mcp/upstreams/oauth/providers#
List reusable OAuth provider profiles. Profiles let the console prefill known
authorization/token endpoints, default scopes, and setup hints for big-name
providers (Google Cloud, Microsoft Graph, AWS Cognito, Slack, GitHub, Atlassian,
Linear, Sentry, Notion, Cloudflare). The OAuth flow itself stays
provider-agnostic — profiles are a UX shortcut on top of the same
POST /:id/oauth/configure endpoint.
Response — 200 OK
{
"providers": [
{
"id": "google-cloud",
"label": "Google Cloud",
"summary": "Cloud Run, Cloud Storage, BigQuery — anything via gcloud or the GCP MCP server.",
"registration_mode": "manual",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"issuer": "https://accounts.google.com",
"default_scopes": ["https://www.googleapis.com/auth/cloud-platform"],
"developer_console_url": "https://console.cloud.google.com/apis/credentials",
"setup_hints": ["…"]
}
]
}
POST /v1/mcp/upstreams/oauth/inspect#
Universal OAuth prerequisite checker. Given an upstream URL, classifies what
setup work is needed and returns a structured checklist. Use it before running
/oauth/configure so the operator knows whether they should expect auto
(RFC 7591) or manual setup, and which endpoints (if any) need to be pasted by
hand.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | The upstream MCP URL to inspect. Public HTTPS only. |
Response — 200 OK
{
"url": "https://mcp.example/github",
"classification": "auto_dcr_available",
"redirect_uri": "https://api.blacklake.systems/integrations/mcp-oauth/callback",
"metadata": {
"issuer": "https://mcp.example",
"authorization_endpoint": "https://mcp.example/oauth/authorize",
"token_endpoint": "https://mcp.example/oauth/token",
"registration_endpoint": "https://mcp.example/oauth/register",
"revocation_endpoint": null,
"scopes_supported": ["read", "write"],
"code_challenge_methods_supported": ["S256"]
},
"findings": [
{ "severity": "ok", "key": "oauth_metadata", "message": "Found OAuth metadata at …" },
{ "severity": "ok", "key": "dynamic_registration", "message": "Dynamic Client Registration (RFC 7591) endpoint advertised — auto setup will work." },
{ "severity": "ok", "key": "pkce", "message": "PKCE S256 is supported." }
]
}
classification is one of auto_dcr_available, manual_required,
endpoints_missing, unsupported.
POST /v1/mcp/upstreams#
Register a new upstream MCP server. URLs must be public https:// addresses — localhost, private IP ranges, and bare IPv4 addresses are rejected.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique name within the workspace (1–100 chars). Lowercase letters, numbers, hyphens, underscores; must start with a letter or number. |
url | string | yes | Public HTTPS URL of the upstream MCP server. |
headers | object | no | Key-value map of HTTP headers to send to the upstream (e.g. { "Authorization": "Bearer <token>" }). Values are stored encrypted. |
policy | allow | deny | ask | no | Default: allow. |
enabled | boolean | no | Default: true. |
Response — 201 Created
{
"id": "mcps_01jv...",
"name": "github-tools",
"url": "https://mcp.example/github",
"policy": "allow",
"enabled": true,
"header_keys": ["Authorization"],
"created_at": "2026-04-10T08:00:00.000Z",
"updated_at": "2026-04-10T08:00:00.000Z"
}
Errors
| Code | Status | Cause |
|---|---|---|
UPSTREAM_NAME_EXISTS | 409 | An upstream with this name already exists in the workspace. |
UPSTREAM_LIMIT_REACHED | 409 | The workspace has reached the 50-upstream limit. |
DELETE /v1/mcp/upstreams/:id#
Remove a registered upstream. The workspace proxy evicts the upstream from its in-memory registry immediately, and the agent that auto-registered with the upstream is archived alongside any tools and the default policy that came with it. The cascade payload tells you exactly what was cleaned up so the operator can audit the side effects.
Response — 200 OK
{
"deleted": true,
"cascaded": {
"agent_archived": true,
"tools_archived": 12,
"policy_deleted": true
}
}
agent_archived is false when the upstream was registered manually (no auto-registered agent). tools_archived is the count of tool rows that were soft-deleted. policy_deleted is true when the default mcp:<name> policy was removed.
Returns 404 UPSTREAM_NOT_FOUND if the upstream does not exist or belongs to a different organisation.
POST /v1/mcp/upstreams/:id/test#
Probe the upstream's connection and count the tools it advertises. Use this to verify credentials and URL before relying on the upstream in production.
Response — 200 OK
{ "connected": true, "tool_count": 12 }
On failure:
{ "connected": false, "tool_count": 0, "error": "connect ECONNREFUSED" }
tool_countis the upstream's tool advertisement, not your registered tool count. A successful probe means the upstream returned 12 tools in itstools/listresponse. Those tools are not yet rows in/v1/tools— auto-registration only fires when a real proxied call routes through/mcp/u/<upstream-name>and references a specific tool. Calling/testzero times vs ten times produces the same/v1/toolsstate.
Returns 404 UPSTREAM_NOT_FOUND if the upstream does not exist in the caller's organisation.
GET /v1/mcp/upstreams/:id/health#
Recent health-ping history for one upstream. Up to ?limit=N (default 288 = 24 hours at 5-minute intervals, capped at 1000) of the most recent ping rows are returned alongside the upstream's current last_pinged_at, last_health_status, and consecutive_failure_count. Drives the upstream-detail health chart in the console.
{
"upstream_id": "ups_…",
"last_pinged_at": "2026-05-25T15:00:00Z",
"last_health_status": "healthy",
"consecutive_failure_count": 0,
"pings": [ { "pinged_at": "…", "status": "healthy", "latency_ms": 92 } ]
}
GET /v1/mcp/upstreams/count#
Returns { "count": <int> }. Lightweight helper the console uses to decide whether to show the empty-state CTA on the MCP Upstreams page.
POST /v1/mcp/upstreams/:id/rotate#
Rotate credentials for an upstream without losing the row or its connected tool history. Use this when a static-headers token leaks or expires, or when an OAuth user needs to reconsent (e.g. after a scope change).
The route behaves differently per auth_type:
static_headers— pass newheadersin the body. The current headers are replaced (encrypted at rest); the workspace registry and aggregate cache are invalidated so the next MCP call reloads with fresh credentials.headersis required and must be non-empty (returns422 HEADERS_REQUIREDotherwise).oauth2— session auth is required (an org-scoped API key has no user to re-authorize). The calling user's stored OAuth credential row is cleared and a fresh PKCE authorization flow is minted. The OAuth client registration is reused — you do not need to re-runPOST /v1/mcp/upstreams/:id/oauth/configure. The caller receives anauthorization_urlto redirect the user through consent again.
Request body
| Field | Type | Required for | Description |
|---|---|---|---|
headers | Record<string, string> | static_headers | New header map (e.g. { "Authorization": "Bearer …" }). Replaces the existing headers entirely. |
Response — static_headers rotation, 200 OK
{
"rotation": "headers_rotated",
"upstream_id": "mcps_…",
"message": "Static headers replaced. The workspace registry has been invalidated.",
"upstream": { /* full upstream resource */ }
}
Response — oauth2 rotation, 200 OK
{
"rotation": "oauth_authorization_required",
"upstream_id": "mcps_…",
"authorization_url": "https://upstream.example/authorize?…&state=…",
"expires_at": "2026-05-23T18:30:00.000Z",
"message": "Redirect the user to authorization_url to consent to the new credential."
}
Error codes
| Code | When |
|---|---|
UPSTREAM_NOT_FOUND | The upstream id does not exist in the caller's organisation. |
HEADERS_REQUIRED (422) | static_headers rotation called with empty or missing headers. |
SESSION_REQUIRED (401) | oauth2 rotation called with an API key instead of a session. |
POST /v1/mcp/upstreams/:id/oauth/configure#
Configure OAuth for an upstream. One-shot per upstream — flips auth_type from static_headers to oauth2 and persists the client credentials encrypted at rest. Two modes:
Mode 1 — auto (RFC 7591 dynamic client registration).
Empty body (or just scopes). BlackLake fetches the upstream's /.well-known/oauth-authorization-server, registers itself as a client, and stores the resulting credentials. Works with Atlassian, Linear, Cloudflare, Sentry, Notion, and any other upstream that supports dynamic registration.
Mode 2 — manual (caller-provided client).
Pass client_id (and optionally client_secret) you obtained from the upstream's developer console. Required for GCP, Microsoft Graph, AWS, Slack, and any provider that doesn't expose RFC 7591. Endpoints are autodiscovered when possible; pass them explicitly when discovery returns nothing (e.g. GCP).
Important: register https://api.blacklake.systems/integrations/mcp-oauth/callback as an allowed redirect URI at the upstream's developer console before calling this with manual mode — most providers reject the authorization request with redirect_uri_mismatch otherwise.
Request body (all fields optional)
| Field | Type | Description |
|---|---|---|
scopes | string[] | Scopes BlackLake will request at consent time. Defaults to the upstream's scopes_supported (auto mode) or stays unset (manual mode — pass explicitly). |
client_id | string | Manual mode trigger. Client ID from the upstream's developer console. Presence of this field switches the route into manual mode and skips RFC 7591. |
client_secret | string | Manual mode only. Client secret. Optional — public clients (PKCE-only) don't need one. Stored encrypted at rest, never echoed back. |
authorization_endpoint | string | Manual mode override. Used when the upstream doesn't publish OAuth metadata (e.g. https://accounts.google.com/o/oauth2/v2/auth). |
token_endpoint | string | Manual mode override. E.g. https://oauth2.googleapis.com/token. |
issuer | string | Manual mode override. Informational. |
revocation_endpoint | string | Manual mode override. |
Response — 200 OK
{
"upstream_id": "mcps_…",
"auth_type": "oauth2",
"oauth_state": "client_registered",
"registration_mode": "auto",
"issuer": "https://upstream.example",
"authorization_endpoint": "https://upstream.example/authorize",
"token_endpoint": "https://upstream.example/token",
"registration_endpoint": "https://upstream.example/register",
"revocation_endpoint": null,
"scopes_supported": ["mcp.read", "mcp.write"],
"code_challenge_methods_supported": ["S256"],
"client_id": "cli_…",
"has_client_secret": true,
"redirect_uri": "https://api.blacklake.systems/integrations/mcp-oauth/callback"
}
In manual mode, registration_mode is "manual", registration_endpoint is null, and the response includes a reminder field about the redirect URI.
Errors
| Code | Status | Cause |
|---|---|---|
OAUTH_METADATA_NOT_FOUND | 400 | Auto mode — upstream advertises no /.well-known/oauth-authorization-server and no /.well-known/oauth-protected-resource. Retry with manual mode (pass client_id). |
OAUTH_REGISTRATION_UNSUPPORTED | 400 | Auto mode — metadata found but no registration_endpoint. Create an OAuth client at the upstream's developer console and retry with manual mode. |
OAUTH_REGISTRATION_REJECTED | 502 | Auto mode — upstream rejected the registration POST. |
OAUTH_ENDPOINTS_REQUIRED | 400 | Manual mode — discovery yielded no endpoints and the caller didn't provide authorization_endpoint/token_endpoint. Pass them explicitly. |
UPSTREAM_URL_INVALID | 400 | Upstream URL must be HTTPS. |
Worked example — GCP (manual mode)
curl -X POST https://api.blacklake.systems/v1/mcp/upstreams/$ID/oauth/configure \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_id": "12345-abcdef.apps.googleusercontent.com",
"client_secret": "GOCSPX-xxxxxxxxxxxxxxxxxxxxxx",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"issuer": "https://accounts.google.com",
"scopes": ["https://www.googleapis.com/auth/cloud-platform.read-only"]
}'
POST /v1/mcp/upstreams/:id/oauth/start#
Session-auth required. Begin the per-user authorization-code + PKCE flow. Inserts a short-lived state row (10-minute TTL) and returns the authorization URL the console redirects the user's browser to.
Request body (optional)
| Field | Type | Description |
|---|---|---|
scopes | string[] | Scopes to request at the authorization step. Defaults to scopes_supported from registration. |
return_to | string | URL to redirect the user to after the callback completes. |
Response — 200 OK
{
"authorization_url": "https://upstream.example/authorize?response_type=code&client_id=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256&scope=mcp.read mcp.write",
"state": "…opaque base64url string…",
"expires_at": "2026-04-30T13:10:00.000Z"
}
Errors: OAUTH_CLIENT_NOT_REGISTERED (400) if /oauth/configure hasn't run yet; UNAUTHORIZED (401) for API-key callers (no logged-in user to bind tokens to).
GET /integrations/mcp-oauth/callback?code=…&state=…#
Public OAuth redirect target — the upstream sends the user's browser here. Exchanges the code for tokens at the upstream's token_endpoint, persists encrypted access + refresh tokens scoped to the calling user, marks the state row consumed, and renders a small HTML page that auto-redirects back to the console.
Not called directly by API clients.
POST /v1/mcp/upstreams/:id/oauth/disconnect#
Session-auth required. Revoke the calling user's stored credential for this upstream. Idempotent.
Response — 200 OK
{ "disconnected": true }
GET /v1/mcp/upstreams/:id/oauth/status#
Connection status for the calling user.
Response — 200 OK
{
"upstream_id": "mcps_…",
"auth_type": "oauth2",
"oauth_state": "client_registered",
"user_connected": true,
"user_status": "active",
"user_scopes_granted": ["mcp.read", "mcp.write"],
"user_expires_at": "2026-04-30T14:10:00.000Z",
"user_connected_at": "2026-04-30T13:00:00.000Z"
}
MCP Data-Path Proxy#
POST /mcp/u/:upstream_name#
API-key auth required. The cloud (and blacklake serve local) data-path proxy. Accepts a single JSON-RPC body, runs governance on tools/call, attaches outbound auth (static headers or per-user OAuth token), forwards to the upstream's URL, and returns the JSON-RPC response.
For OAuth upstreams the API key MUST be user-scoped (bl_usr_…) — see POST /v1/api-keys/user. Org-scoped keys on OAuth upstreams return a clear JSON-RPC error pointing to the console.
Headers
| Header | Notes |
|---|---|
Authorization: Bearer <key> or x-api-key: <key> | Required. |
Mcp-Session-Id | Optional — passed through both directions for clients that track sessions. |
Request body — any JSON-RPC request the upstream supports. Most clients send tools/list and tools/call. initialize and ping are answered at the proxy directly; everything else is forwarded.
Response — JSON-RPC response from the upstream, or a synthesized response when governance denies the call. Denied calls return result.isError = true with a signed receipt under result._meta.blacklake:
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [{ "type": "text", "text": "Tool call '<name>' was denied by BlackLake policy. …" }],
"isError": true,
"_meta": {
"blacklake": {
"decision": "deny",
"evaluation_id": "eval_…",
"decision_token": "…",
"policy_id": "pol_…"
}
}
}
}
Approval-gated calls. When governance returns approval_required the proxy
holds the call for up to 110 seconds (under Cloudflare's 120 s edge cut).
If a reviewer decides within the window the call is forwarded; otherwise the
proxy returns a JSON-RPC result.isError = true with _meta.blacklake.status = "timeout" and an approval_url your client can open to surface the pending
approval.
{
"jsonrpc": "2.0",
"id": 17,
"result": {
"content": [{ "type": "text", "text": "Tool call 'github__create-issue' is waiting for approval. Reviewers can decide at https://console.blacklake.systems/approvals/approval_… — retry the call once a reviewer decides." }],
"isError": true,
"_meta": {
"blacklake": {
"status": "timeout",
"approval_id": "approval_…",
"approval_url": "https://console.blacklake.systems/approvals/approval_…",
"evaluation_id": "eval_…",
"decision_token": "…"
}
}
}
}
Recommended client timeouts. Set the MCP client timeout to at least 120 s when calling approval-gated tools — anything shorter and the client gives up before the proxy reports a usable status. Many MCP clients default to 60 s, which is too short for human-in-the-loop approvals. Concretely:
- Claude Code / mcp-remote:
--timeout 180000(180 s) is a safe default. - Cursor / Continue / Cline: configure the MCP server entry with a timeout ≥ 120 s.
- Custom HTTP clients: set the request timeout to 180 s and treat any 5xx after 110 s as "approval still pending — retry the call".
If the client times out before a reviewer decides, the approval stays pending in BlackLake and the next call from the same agent + tool either reuses the in-flight approval (if it's still pending) or creates a new one. The original call's evaluation row remains in the audit log either way.
POST /v1/api-keys/user#
Session-auth required. Mint an API key bound to the calling user. The key carries user identity into governance, the audit ledger, and the OAuth token store, so it's the right key type for MCP clients.
Request body
| Field | Type | Description |
|---|---|---|
name | string | Human-readable label. Required. 1–100 chars. |
Response — 201 Created
{
"id": "key_…",
"name": "My laptop",
"user_id": "user_…",
"key": "bl_usr_<64 hex chars>",
"created_at": "2026-04-30T13:00:00.000Z",
"warning": "Save this key — it will not be shown again. Treat it like a password; it carries your identity into BlackLake."
}
The key field is shown exactly once. List keys via GET /v1/api-keys; revoke via DELETE /v1/api-keys/:id. Org-scoped keys (no user identity) are still available at POST /v1/api-keys for service accounts.
Error Format#
All error responses use a consistent envelope:
{
"error": {
"code": "AGENT_NOT_FOUND",
"message": "Agent agent_01j not found"
}
}
Validation errors (HTTP 400, code: VALIDATION_ERROR) include a structured
details payload with the offending field, what was expected, what was
received, and a hint when one applies. Use it instead of regexing the message:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "approval_mode: Invalid enum value. Expected 'auto_approve' | 'require_approval' | 'block', received 'manual'",
"details": {
"field": "approval_mode",
"expected": ["auto_approve", "require_approval", "block"],
"received": "manual",
"hint": "Did you mean 'require_approval'? Allowed: auto_approve, require_approval, block.",
"issues": [
{
"field": "approval_mode",
"message": "Invalid enum value. Expected 'auto_approve' | 'require_approval' | 'block', received 'manual'",
"expected": ["auto_approve", "require_approval", "block"],
"received": "manual",
"hint": "Did you mean 'require_approval'? Allowed: auto_approve, require_approval, block."
}
]
}
}
}
Enums#
Canonical enum values used across the API. Read the live catalog at
GET /v1/enums. No auth is required in cloud or local mode — the
endpoint is public so SDK generators and console form components can read
it without provisioning a key first. The enum shape is stable across
deploys — safe to bundle into your SDK.
| Domain | Field | Values |
|---|---|---|
| Agent | environment | development, staging, production |
| Agent | risk_classification | low, medium, high, critical |
| Agent | status | active, suspended, disabled |
| Agent | approval_mode | auto_approve, require_approval, block |
| Agent | source | manual, mcp, sdk, ci, shell, cloud_audit, existing_workflow_engine, depth |
| Tool | risk_classification | low, medium, high, critical |
| Tool | source | manual, mcp, sdk, ci, shell, cloud_audit, existing_workflow_engine, depth |
| Policy | outcome | allow, deny, approval_required |
| Decision | (govern result) | allow, deny, approval_required, default_deny, unknown (simulator only) |
| Approval | status | pending, approved, rejected, expired |
| Approval | decision_action | approve, reject, break-glass |
| Approval | channel | email, push, console, api, slack |
| Evaluation | result.status | succeeded, failed, skipped, unknown |
| MCP upstream | policy | allow, ask, deny |
| MCP upstream | auth_type | static_headers, oauth2 |
| MCP upstream | oauth_state | pending_discovery, discovered, client_registered, error |
| MCP upstream | readiness.state | unverified, auth_configuration_required, user_connection_required, ready, error, unknown |
| MCP upstream | registration_mode | auto, manual |
| Webhook | event | approval.created, approval.approved, approval.rejected, budget.threshold_crossed, budget.limit_exceeded, evaluation.created, evaluation.denied, evaluation.approval_required, cost.recorded, upstream.unhealthy, upstream.recovered (canonical source: GET /v1/enums) |
| Budget | scope_type | workspace, agent, tool, user, workflow, run, step |
| Budget | period | per_task, day, week, month |
| Budget | state | ok, soft_50, soft_80, over_soft, over_hard |
| Budget | threshold (webhook) | soft_50, soft_80, soft_100, hard_100 |
| Cost | provider | anthropic, openai, bedrock, vertex, foundry, gemini, ollama, unknown |
| Cost | capture_path | mcp, sdk, ci, proxy (deprecated alias for mcp) |
| Receipt | version | 1, 2 (number, not string) |
| Receipt | decision_token_prefix | bldt_v1:, bldt_v2: |
| Observation | kind | anomaly, observation, drift, pattern, decomposition, model_choice |
| Observation | severity | info, warning, critical |
| Observation | anomaly_category | high_retry, token_spike, cache_miss_heavy, long_tail, extended_thinking, idle_context |
| External event | source | gcp, aws, azure, github, other |
| Member | role | admin, member |
Note on naming. The agent approval_mode is an enum on the agent record
(applied at registration time as a default). The policy outcome is what
governance actually returns when a policy matches. They are not the same
field — auto_approve (agent) and allow (policy decision) describe
different stages of the pipeline.
GET /v1/enums#
Returns the same table above as a JSON document. Useful when generating an
SDK or building a console form: read the canonical values from the API
instead of hardcoding them. Always returns 200 OK with no caching. Public — no API key required.
curl https://api.blacklake.systems/v1/enums
System#
GET /health#
Health check endpoint. Does not require authentication.
Response — 200 OK
{ "status": "ok" }
curl
curl http://localhost:3100/health
GET /v1/mode#
Returns the current operating mode. Unauthenticated — like /health and /v1/version, no API key is required so clients can discover which environment they are pointed at before they have credentials.
Response — 200 OK
{ "mode": "local" }
The mode field is either "local" or "cloud". Use this to verify which environment your client is connected to.
curl
curl http://localhost:3100/v1/mode
MCP Servers#
GET /v1/mcp#
List MCP servers currently configured in ~/.blacklake/mcp-config.json and their status. Returns { servers: [], config_path } when the local MCP proxy isn't running.
Response — 200 OK
{
"servers": [
{
"name": "filesystem",
"connected": true,
"tools": 5,
"policy": "ask",
"error": null
}
],
"config_path": "/home/you/.blacklake/mcp-config.json"
}
| Field | Description |
|---|---|
name | The server name from mcp-config.json. |
connected | true if the local proxy is currently connected to the upstream's stdio process. |
tools | Number of tools discovered from this server. |
policy | The policy declared in mcp-config.json for this server: allow, deny, or ask. |
error | Error message if the server failed to start or crashed. null otherwise. |
config_path | Absolute path to the active mcp-config.json. |
curl
curl http://localhost:3100/v1/mcp \
-H "x-api-key: local"
POST /v1/mcp/servers/:name/reconnect#
Trigger an immediate reconnect attempt against an MCP server without restarting the full CLI. Use after editing mcp-config.json or after the upstream stdio process crashed.
Path parameters
| Parameter | Description |
|---|---|
name | The server name as it appears in mcp-config.json. |
Response — 200 OK
{
"connected": true,
"tools": 5
}
On failure the response is 500 with { "connected": false, "tools": 0, "error": "..." }. Returns 503 if the local MCP proxy isn't running ({ "error": "MCP proxy not running" }).
curl
curl -X POST http://localhost:3100/v1/mcp/servers/filesystem/reconnect \
-H "x-api-key: local"
Usage#
GET /v1/usage#
Return a usage summary for LLM API calls proxied through BlackLake.
Query parameters
| Parameter | Type | Description |
|---|---|---|
period | string | Time bucket: day (last 24h), week (last 7 days), or month (last 30 days). Default: month. |
provider | string | Filter by provider: anthropic, openai, or ollama. Optional. |
limit | integer | Number of recent rows to return in recent. Default: 50. |
Response — 200 OK
{
"period": "month",
"totals": {
"total_calls": 142,
"total_cost_usd": 1.24,
"total_input_tokens": 284000,
"total_output_tokens": 71000
},
"by_model": [
{
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"total_calls": 98,
"total_input_tokens": 196000,
"total_output_tokens": 49000,
"total_cost_usd": 0.86
},
{
"provider": "openai",
"model": "gpt-4o",
"total_calls": 44,
"total_input_tokens": 88000,
"total_output_tokens": 22000,
"total_cost_usd": 0.38
}
],
"recent": [
{
"id": "log_…",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"input_tokens": 1200,
"output_tokens": 320,
"total_tokens": 1520,
"cost_usd": 0.0148,
"request_path": "/v1/messages",
"status_code": 200,
"duration_ms": 842,
"created_at": "2026-05-01T12:34:56.000Z"
}
],
"tool_calls": {
"total": 312,
"by_agent": [{ "agent_id": "agent_…", "total": 240 }],
"top_tools": [{ "tool_id": "tool_…", "total": 180 }]
}
}
tool_calls aggregates policy_evaluations so the same endpoint covers both the LLM-spend and governed-tool-call lenses. Cost estimates use published per-token rates and are approximations. Ollama calls are tracked but have no cost estimate.
curl
curl "http://localhost:3100/v1/usage?period=month&provider=anthropic" \
-H "x-api-key: local"
GET /v1/usage/api-quota#
Per-workspace rate-limit bucket state. Returns one row per active rate-limit bucket the workspace currently has open (e.g. govern, cost-record, auth), with the current count, the bucket's window length in ms, and how many milliseconds remain in the window. Use this when a 429 fires and you want to know which bucket tripped and when it resets.
{
"buckets": [
{
"bucket": "govern",
"bucket_key": "ratelimit:govern:org:org_…",
"count": 142,
"window_ms": 60000,
"window_started_at": "2026-05-25T15:00:00Z",
"reset_in_ms": 12000,
"reset_at_iso": "2026-05-25T15:01:12Z"
}
]
}
Smoke Testing#
POST /v1/test/run#
Run an end-to-end smoke test against this workspace. Creates an isolated
agent + tool + binding + policy (all tagged owner = "__bl_smoke_test__"),
calls govern(), and returns the full chain. Use it to validate that the
governance pipeline is wired correctly without polluting your real inventory.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
outcome | string | no | Policy outcome to use: allow (default), deny, or approval_required. |
archive_when_done | boolean | no | Default: true. When true (default), the agent + tool are soft-deleted and the policy is hard-deleted immediately after the run — fixtures don't accumulate across repeat runs. Pass false to keep the resources visible for inspection; clean up later via POST /v1/test/archive-test-resources. The evaluation and approval (if any) always remain in the audit log. |
Response — 200 OK
{
"ok": true,
"archived": false,
"resources": {
"agent_id": "agent_…", "agent_name": "bl-smoke-agent-…",
"tool_id": "tool_…", "tool_name": "bl-smoke-tool-…",
"policy_id": "pol_…", "policy_name": "bl-smoke-policy-…",
"binding_id": "bind_…"
},
"decision": {
"decision": "allow",
"evaluation_id": "eval_…",
"evaluation_url": "https://console.blacklake.systems/evaluations/eval_…",
"policy_id": "pol_…",
"reason": "Matched policy: bl-smoke-policy-… (agent: name=bl-smoke-agent-…; tool: name=bl-smoke-tool-…)",
"evaluated_at": "2026-05-01T…",
"decision_token": "bldt_v1:…"
}
}
POST /v1/test/archive-test-resources#
Bulk soft-delete every smoke-test resource in the workspace. Returns the number of agents/tools archived and policies deleted. Useful as a one-click clean-up after a CI run.
{ "archived_agents": 3, "archived_tools": 3, "deleted_policies": 3 }
Policy Simulation#
POST /v1/policies/simulate#
Replay historical evaluations against a draft policy and report the change in decision counts. Use this before enabling a new gate to measure blast radius.
Request body
| Field | Type | Description |
|---|---|---|
priority | integer | Priority of the simulated policy. Lower wins. |
outcome | allow | deny | approval_required | Decision the draft would emit on a match. |
agent_selector | object | Optional. Same format as a real policy's selector. |
tool_selector | object | Optional. Same format as a real policy's selector. |
window_days | integer | Days of history to replay. Default 30, max 365. |
sample_limit | integer | Cap on returned changed-evaluation samples. Default 50. |
Response — 200 OK
{
"window_days": 30,
"evaluations_analysed": 1842,
"evaluations_skipped": 3,
"before": { "allow": 1500, "deny": 200, "approval_required": 100, "default_deny": 42 },
"after": { "allow": 1450, "deny": 200, "approval_required": 150, "default_deny": 42 },
"changes": {
"total_changed": 50,
"transitions": { "allow_to_approval_required": 50 },
"samples": [
{
"evaluation_id": "eval_01jzx...",
"agent": "deploy-bot",
"tool": "gcloud.run.deploy",
"before": "allow",
"after": "approval_required"
}
]
}
}
evaluations_skipped counts evaluations whose agent or tool has been deleted since the call — those can't be replayed safely against the draft selectors.
The transitions map keys are <before>_to_<after> (e.g. allow_to_deny, default_deny_to_allow). Counts always equal the sum of differences between before and after.
The cost_impact block surfaces estimated dollar savings if the draft policy had been enforced over the window:
{
"cost_impact": {
"flips_to_blocked": 12,
"evaluations_with_cost_matched": 11,
"estimated_savings_usd": 4.83,
"note": null
}
}
Only counts permissive→deny transitions (allow or approval_required → deny) and sums cost_records.cost_usd for the affected evaluations. When some flipped evaluations have no attached cost records yet (CG-1 hadn't seen the upstream call), the figure is conservative and note calls that out.
The response also surfaces two pre-save signals:
{
"current_match": {
"agents": 1,
"tools": 2,
"live_agents_total": 47,
"live_tools_total": 14
},
"shadowed_by": [
{
"id": "pol_01jv...",
"name": "ask-before-prod-deploys",
"priority": 5,
"outcome": "approval_required",
"overlap_evaluations": 12
}
]
}
current_match answers "what does this draft cover now?" — counts of currently-live agents and tools whose fields satisfy the draft's selectors. Zero on either side usually means a typo'd selector value.
shadowed_by lists every existing enabled policy whose selectors are a subset of the draft's AND whose priority is strictly lower (so it wins on every overlap). overlap_evaluations is how many historical evaluations in the window were already matched by that higher-priority policy. Empty array when nothing shadows the draft.
Cost capture, budgets, baselines, observations#
BlackLake captures cost on every observed LLM call and binds it to the governance receipt. The proxy paths (/proxy/anthropic, /proxy/openai, /proxy/ollama) capture automatically; for direct calls (Bedrock / Vertex / Foundry / Gemini / custom OpenAI-compatible) attribute via POST /v1/cost/record.
POST /v1/cost/record#
Caller-attributed cost ingest. Use when BlackLake didn't see the wire (most non-proxy LLM calls). Tie the cost back to a governance decision via evaluation_id, or to an agent + tool by name.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | One of anthropic, openai, bedrock, vertex, foundry, gemini, ollama, unknown. |
model | string | Yes | Versioned model name (e.g. claude-opus-4-7). |
input_tokens | integer | Yes | Prompt tokens. |
output_tokens | integer | Yes | Completion tokens. |
cache_read_tokens | integer | No | Anthropic prompt-cache hits. |
cache_write_tokens | integer | No | Cache-creation tokens. |
thinking_tokens | integer | No | Extended-thinking tokens (where billed separately). |
evaluation_id | string | No | Tie to a govern() receipt — enables v2 decision token + receipt cost summary. |
agent / agent_id | string | No | Resolve by name or pass id directly. |
tool / tool_id | string | No | Resolve by name or pass id directly. |
user_id | string | No | For per-user attribution. |
capture_path | string | No | One of proxy, sdk, mcp, ci, manual. Defaults to sdk. |
environment | string | No | Free-form (e.g. production). |
session_id / task_id | string | No | Group related calls. |
pricing_version | string | No | Override the current pricing snapshot. Rare. |
Returns the persisted cost_records row including computed cost_usd and cost_breakdown.
POST /v1/cost/estimate#
Pre-call estimation. Used by cost-aware policies and the policy engine so denials can happen before the spend.
{
"provider": "anthropic",
"model": "claude-opus-4-7",
"input_tokens": 12000,
"output_ceiling_tokens": 4000
}
Returns the same cost_breakdown shape as /cost/record.
GET /v1/cost?period=30d#
Workspace cost summary with breakdowns by provider, model, capture path, agent, tool, user, environment. Period values: day, 7d, 30d, 90d. Backs the console Usage page.
totals includes two coverage signals beyond the headline numbers:
unpriced_calls— count of rows billed at$0despite token usage. The cost record was captured but pricing didn't resolve. Surface as "X% of calls were billed at $0 because the model isn't priced" so operators don't assume spend is genuinely zero.distinct_pricing_versions— number of distinctpricing_versionvalues in the window.> 1means the totals span more than one pricing snapshot and direct comparison of earlier vs later spend is misleading.
by_model rows include unpriced_calls per (provider, model) so the breakdown can flag fully or partially unpriced rows.
GET /v1/cost/pricing?version=2026-05#
Full priced-model catalogue. Returns every (provider, model) entry in the requested pricing snapshot, tagged with whether it's an exact match or a longest-prefix fallback. Operators use this to spot gaps before they ship a cost-aware policy. Backs the console Pricing coverage page.
Pricing coverage by provider (as of 2026-05)
| Provider | Coverage | Notes |
|---|---|---|
anthropic | Full | Opus, Sonnet, Haiku — all variants. |
openai | Full | gpt-4o and listed models. |
vertex | Partial | claude-opus and claude-sonnet covered; haiku and other models fall back to $0. |
bedrock | None | Token count captured; all cost estimates return $0. Warning emitted on each unpriced call. |
foundry | None | Same as Bedrock. |
gemini | None | Same as Bedrock. |
ollama | None | Local models — expected $0. |
Calls from providers with no pricing return cost_usd: 0. The /v1/cost summary endpoint surfaces unpriced_calls so operators can see how many $0 records exist in the window.
{
"version": "2026-05",
"current_version": "2026-05",
"available_versions": ["2026-05"],
"rows": [
{
"provider": "anthropic",
"model": "claude-opus-4-7",
"match_kind": "exact",
"rates": {
"input_per_1m": 15,
"output_per_1m": 75,
"cache_read_per_1m": 1.5,
"cache_write_per_1m": 18.75,
"thinking_per_1m": 0
}
}
]
}
version defaults to the current snapshot. Pass an older version explicitly to inspect what was priced when a historical receipt was costed.
GET /v1/cost/timeseries?period=30d#
Daily aggregation — used by the spend chart on Usage.
GET /v1/cost/decomposition?period=30d#
Multi-level cost tree: agent → tool → model with per-component dollars (input / output / cache / thinking). Useful for cost-attribution audits.
GET /v1/cost/by-evaluation/:id#
All cost_records attached to one evaluation, plus the rolled-up cost_summary and a v2 decision token (when the receipt is at v2). Verify the v2 token via POST /v1/decisions/verify.
GET /v1/cost/export?format=csv|ndjson&period=30d#
Streaming export of cost-attributed evaluations. CSV and NDJSON. Designed to be handed to finance / procurement / auditors.
GET /v1/cost/orphans?limit=100#
Cost records that landed without a governed evaluation to attach to — typically a SDK upload arriving before its parent /govern write, or a legacy ingestion path that never went through /govern. Each row is the raw cost_records shape. Reconcile by either re-running the missing /govern call or by accepting the rows as unattributed spend. Default 100, max 500.
POST /v1/budgets#
Budget primitive. Soft + hard USD limits scoped to workspace, agent, tool, or user.
{
"name": "Production Opus / month",
"scope_type": "agent",
"scope_id": "agent_01jvqr5x...",
"period": "month",
"timezone": "Europe/London",
"soft_limit_usd": 800,
"hard_limit_usd": 1000
}
scope_type is one of workspace, agent, tool, user, workflow, run, or step. "organisation" is not a valid value — use workspace for a workspace-wide budget. period is per_task, day, week, or month. Hard limits deny at govern() time; soft limits fire webhooks at 50% / 80% / 100%.
GET /v1/budgets, GET /v1/budgets/:id, and GET /v1/budgets/workspace/status all also return a scope_name field — the friendly name of the agent / tool / user the scope_id resolves to (null for workspace scope or when the scope row is gone). The console shows this so budgets read as billing-operations-agent rather than agent_db523….
GET /v1/budgets/:id/status#
Current spend vs limits for one budget.
{
"budget_id": "bgt_01jvqr5x...",
"period_key": "2026-05",
"spend_usd": 412.18,
"soft_limit_usd": 800,
"hard_limit_usd": 1000,
"soft_pct": 51.5,
"hard_pct": 41.2,
"state": "soft_50",
"forecast": {
"soft_hit": "2026-05-18T14:00:00Z",
"hard_hit": null
}
}
State: ok | soft_50 | soft_80 | over_soft | over_hard. forecast.soft_hit / forecast.hard_hit are ISO timestamps for the projected limit-crossing time at current run-rate, or null if not enough data yet or the projection is past the period boundary.
GET /v1/insights/baselines?window_days=30#
Per-(agent, tool) rolling baselines: median / p95 / p99 input + output tokens, median / p95 cost, retry_rate, error_rate, per-model breakdown. Computed on demand via POST /v1/insights/baselines/recompute.
POST /v1/insights/baselines/recompute?window_days=30#
Recompute every (agent, tool) baseline in the workspace from the requested window. Cheap on small workspaces; capped server-side on large ones. Returns the count of baselines refreshed.
POST /v1/insights/baselines/recompute/:agent_id/:tool_id?window_days=30#
Recompute one specific (agent, tool) baseline only. Use when a high-traffic pair has shifted recently and you don't want to wait for the full workspace refresh.
GET /v1/insights/receipt-context/:evaluation_id#
How this evaluation compares to its (agent, tool) baseline — input/output token deltas, cost vs median. Surfaces alongside the receipt on the console evaluation detail page (AN-2).
GET /v1/insights/explain/:evaluation_id#
Why this evaluation decided the way it did. Returns the matched policy (if any), every other policy considered with its per-selector match result (agent_match, tool_match, cost_match, request_match), and a counterfactual block showing the next policy that would have decided differently. Use this when an "approval_required" or "deny" outcome is surprising and you need to point to a specific rule.
{
"evaluation_id": "eval_…",
"decision": "deny",
"decided_at": "2026-05-25T14:29:43Z",
"actual_match": { "policy_id": "pol_…", "name": "block-prod-opus", "outcome": "deny" },
"counterfactual": {
"would_match": "pol_…",
"name": "allow-sandbox-tools",
"outcome": "allow",
"priority": 10,
"note": "If 'block-prod-opus' did not exist, policy 'allow-sandbox-tools' (priority 10) would have decided 'allow'."
},
"policies_considered": [
{ "policy_id": "pol_…", "priority": 1, "outcome": "approval_required", "agent_match": { "matched": true }, "tool_match": { "matched": false, "expected": "stripe.refund", "actual": "stripe.charge" } }
]
}
GET /v1/insights/anomalies#
Receipts that deviate materially from baselines. Categories: high_retry, token_spike, cache_miss_heavy, long_tail, extended_thinking, idle_context. Tunable thresholds, dismissible per pattern. Trigger detection via POST /v1/insights/anomalies/recompute.
POST /v1/insights/anomalies/:id/dismiss#
Mark one anomaly as dismissed — the row stays in the audit trail but stops surfacing on the Anomalies page. Body is empty. Idempotent. Use when an anomaly is a known artefact (e.g. a deliberate token-heavy run) rather than something to investigate.
GET /v1/insights/observations#
Workspace observations feed — anomalies, drift, cost decomposition entries, policy-author hints, coverage gaps. Filter by kind. Each entry presents a fact; the user concludes whether it matters.
GET /v1/insights/health-snapshot#
Weekly digest of cost, top agents, anomaly count, decision breakdown. Designed to be the artefact a workspace owner shares with their team or finance lead.
GET /v1/insights/drift#
Workspace cost change vs prior weeks. Surfaces dollar delta plus hypothesis list (model switch, expanded toolset, heavier workload).
GET /v1/insights/risk#
Risk dashboard data over a fixed 30-day window. Returns the workspace's decision breakdown (allow / deny / approval_required / default_deny), approval outcome rates (pending / approved / rejected / expired), and the top actors by deny count and by approval-required count — each with agent metadata (name, environment, risk classification). Drives the console Risk page. No query parameters.
GET /v1/insights/coverage/trend?window_days=30#
Densified per-day time-series of governed evaluations broken down by actor source. Each row is { day, total, by_source: { mcp, sdk, ci, … } } — days with zero traffic are emitted with total: 0 so consumers don't have to fill gaps. Window is clamped to [1, 180]. Both ?window_days= (preferred, matches the rest of /v1/insights/*) and the legacy ?days= are accepted.
GET /v1/insights/model-choice?window_days=30#
Per-(agent, tool) breakdown across models actually used. Counts, costs, approval-rate per model. Descriptive only — the user concludes whether to constrain.
GET /v1/insights/model-substitution?from=...&to=...&window_days=30#
Counterfactual: "if from_model calls had been routed to to_model, what would the cost have been?" Equivalence (approval-rate match, downstream tool usage) is left to the user — see /model-choice for the underlying data.
Cost-aware policy DSL (CG-5)#
Extend any policy with cost_conditions:
{
"name": "block-expensive-opus-on-prod",
"priority": 5,
"agent_selector": { "environment": "production" },
"outcome": "deny",
"mode": "monitor",
"cost_conditions": {
"all": [
{ "signal": "tool.estimated_cost_usd", "op": "gt", "value": 1.00 },
{ "signal": "tool.model", "op": "in", "value": ["claude-opus-4-7", "claude-opus-4-6"] }
]
}
}
Signals: agent.spend_today_usd, agent.spend_per_task_usd, agent.cumulative_spend_session_usd, tool.estimated_cost_usd, tool.input_tokens, tool.model, workspace.spend_today_usd, user.spend_today_usd. Operators: gt, gte, lt, lte, eq, ne, in. mode is enforce (apply outcome) or monitor (observed but never denies — defaults to monitor when cost_conditions is set).
v2 decision tokens (CG-9)#
When an evaluation has a cost_summary (any cost record attached), GET /v1/cost/by-evaluation/:id also returns decision_token_v2. The v2 token's HMAC binds (evaluation_id, decision, cost_summary) so a verifier can confirm not just the decision but also the dollar figure. Verify with the same endpoint:
POST /v1/decisions/verify
{ "evaluation_id": "eval_…", "decision_token": "bldt_v2:…" }
Successful verify includes token_version: "v2" and cost_signed: "Cost is cryptographically bound to this decision via the v2 token.". v1 tokens still validate without cost — receipts written before any cost was captured (receipt_version: 1) only support v1 verification.
Budgets#
First-class budget primitive. A budget is (scope_type, scope_id?, period) paired with a USD hard_limit_usd and optional soft_limit_usd. The govern() engine evaluates active budgets on every call; crossing the soft limit fires budget.threshold_crossed webhooks, crossing the hard limit returns a deny decision.
scope_type is one of workspace (no scope_id), agent, tool, user, workflow, run, or step. period is day, week, or month (aligned to timezone, defaults to UTC).
POST /v1/budgets#
Create a budget.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique name within the org. Max 200 chars. |
scope_type | enum | yes | workspace | agent | tool | user | workflow | run | step. |
scope_id | string | iff scope_type ≠ workspace | ID of the scoped entity. Must be omitted for workspace scope. |
period | enum | yes | day | week | month. |
timezone | string | no | IANA tz (e.g. America/New_York). Defaults to UTC. |
soft_limit_usd | number | no | When crossed, fires budget.threshold_crossed. Must be < hard_limit_usd. |
hard_limit_usd | number | yes | Positive. govern() returns deny once exceeded. Max $10,000,000. |
enabled | boolean | no | Defaults to true. Disabled budgets are skipped at govern time. |
Response — 201 Created — singular envelope { data: <budget> }.
GET /v1/budgets#
List budgets for the workspace. Supports the standard list query (?limit, ?offset, ?sort, ?order) plus ?include_deleted=true / ?only_deleted=true for soft-deleted rows.
GET /v1/budgets/workspace/status#
Convenience endpoint — returns the current spend and limit for the workspace-scope budget(s). Useful for header-banner UIs without needing to look up the budget id first.
GET /v1/budgets/:id#
Get a single budget.
GET /v1/budgets/:id/status#
Get the current spend, the period window, and the remaining headroom for a budget. Use this on a billing or cost-overview page to render progress bars.
PATCH /v1/budgets/:id#
Update a budget. Body fields are all optional; pass only what you want to change. The (scope_type, scope_id) pair is immutable — to re-scope, archive this budget and create a new one.
DELETE /v1/budgets/:id#
Soft-delete a budget. The row stays for audit purposes; the budget no longer participates in enforcement. Pass ?include_deleted=true on the list endpoint to see archived rows.
GitHub App#
The GitHub App turns BlackLake into a passive observer of repo, PR, workflow, and deployment events. It complements the GitHub Action — the action gates a step and emits a receipt; the App watches everything that happens in the repo and stores it in the audit ledger so future reconciliation can highlight ungoverned production changes.
The App lives at https://github.com/apps/<your-app> and is registered separately by the BlackLake operator. The endpoints below are what the running API exposes.
POST /integrations/github/webhook#
GitHub-facing webhook receiver. Public, unauthenticated, but every request must carry a valid X-Hub-Signature-256 HMAC computed with GITHUB_APP_WEBHOOK_SECRET. Mismatched signatures get 400 SIGNATURE_INVALID.
The receiver looks up the installation by the payload's installation.id, attributes the event to the linked organisation, and stores the raw payload along with event_type, action, repository.full_name, sender.login, and X-GitHub-Delivery. Events from unknown installations are still stored, with organisation_id: null, so a misconfigured install is visible rather than silently dropped.
If GITHUB_APP_WEBHOOK_SECRET is not configured, the receiver returns 200 { ok: false } to keep webhook delivery green while the operator finishes setup.
POST /v1/integrations/github/installations#
Link an installation to the current org. Caller passes installation_id (the numeric ID GitHub assigns) and optionally account_login and account_type.
{ "installation_id": 12345678, "account_login": "acme", "account_type": "Organization" }
The installation_id is unique globally — attempting to link one that already belongs to a different org returns 409 INSTALLATION_ALREADY_LINKED.
The link is unverified until a real GitHub-signed webhook for that installation_id lands. The webhook receiver stamps verified_at on first valid signed delivery (signature already validated against GITHUB_APP_WEBHOOK_SECRET). Fake/manual installation_id values never reach verified state because GitHub never signs a payload with that secret. The console badges unverified installs as "Unverified — awaiting signed webhook".
GET /v1/integrations/github/installations#
List installations linked to this org. Each row carries verified_at (ISO 8601 once verified, null until then) — see the unverified-state caveat above.
DELETE /v1/integrations/github/installations/:id#
Unlink an installation. Existing events are retained in the audit ledger; future webhook deliveries with that installation_id will be stored with organisation_id: null.
GET /v1/integrations/github/events#
List webhook events received for this org. Supports event_type and repository query filters plus the standard limit / offset / sort pagination params.
{
"data": [
{
"id": "ghev_01jzx...",
"event_type": "pull_request",
"action": "opened",
"repository_full_name": "acme/widget",
"sender_login": "alice",
"delivery_id": "..."
}
],
"total": 1
}
Audit Ingest#
External cloud audit log forwarders can push events into BlackLake to widen coverage beyond what the governed paths already see. The receiver stores every event and best-effort reconciles it with the evaluation log, so the uncovered set highlights production mutations that bypassed BlackLake.
POST /v1/audit/ingest#
Ingest one event per request — the body is a single flat object, not a { events: [...] } array. Forwarders that need to push many events should fan-out one request per event in their pipeline. Authenticated like any other API call.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
source | string | Yes | One of gcp, aws, azure, github, other. |
source_event_id | string | Yes | The source system's primary identifier for the event. Used as the first reconciliation key. |
event_type | string | Yes | Source-specific event class, e.g. cloudaudit.googleapis.com/data_access. |
resource | string | Yes | Resource name, e.g. projects/foo/instances/bar. |
principal | string | Yes | Whoever or whatever performed the action. |
action | string | Yes | Verb the source recorded, e.g. cloudsql.instances.delete. |
occurred_at | ISO 8601 string | Yes | When the source recorded the event. |
payload | object | No | Raw provider payload, kept for forensic detail. Optional — the matcher uses it only when present. |
A typical curl:
curl -X POST https://api.blacklake.systems/v1/audit/ingest \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "github",
"source_event_id": "workflow-run-987654321",
"event_type": "workflow_run.completed",
"resource": "acme/app",
"principal": "alice@acme.example",
"action": "deploy",
"occurred_at": "2026-04-30T17:42:11Z",
"payload": { "github_run_id": "987654321", "github_sha": "abc123" }
}'
Response — 201 Created returns the stored event including the reconciliation result:
{
"id": "extev_01jzx...",
"source": "github",
"source_event_id": "workflow-run-987654321",
"governed_evaluation_id": "eval_01jzx..."
}
governed_evaluation_id is null when no governed evaluation matches.
Deduplication — when source_event_id is supplied, ingest is at-most-once on (organisation_id, source, source_event_id). A retry from an upstream webhook (GitHub, GCP, Azure) returns 409 AUDIT_EVENT_DUPLICATE instead of inserting a second row. Callers that want to ingest fan-out events without dedup must omit source_event_id.
Reconciliation rules
- Match
source_event_idagainstaction_results.external_idfor the org. The strongest signal — the BlackLake-issued action result already named the same external ID. - Otherwise, look at
payload.github_run_id,payload.github_sha, andpayload.external_idand try to find a recent governed evaluation whoserequest_contextcarries the same value.
The matcher is intentionally conservative — false positives would mask uncovered activity, which is the metric the operator cares about.
GET /v1/audit/events#
List ingested external events for this org. Supports a source filter plus standard limit / offset / sort=received_at pagination.
GET /v1/audit/uncovered#
List ingested events whose reconciliation found no governed evaluation. Same pagination shape as /events. This is the workspace's coverage gap — every row is a production action that happened without a BlackLake receipt.
GET /v1/audit/stream#
Server-Sent Events (SSE) stream of new policy evaluations as they're written. Each event is event: evaluation with the full evaluation row as the data payload. Use this for live tailing in dashboards or for piping into an external SIEM that prefers push over poll.
Resumption. The stream is resumable via the standard SSE Last-Event-ID header (set automatically by EventSource on reconnect) — every event's id field is the evaluation_id. As an alternative, pass ?since=<ISO timestamp> for a one-shot backfill from that point. Without either, the stream starts from "now" with no backfill.
A : keepalive comment line is emitted every 15 seconds so proxies and clients keep the connection open. Implementation is poll-and-stream (2-second poll interval), so latency for new events is on the order of 1-2 seconds.
GET /v1/audit/export#
Stream the org audit ledger as newline-delimited JSON. Each line is {"type":"evaluation"|"approval"|"action_result","data":{...}}. Suitable for piping into jq, BigQuery, S3, an SIEM, or a customer-assurance evidence pack.
Query parameters
| Parameter | Type | Description |
|---|---|---|
from | ISO timestamp | Start of window. Default: now - 30d. |
to | ISO timestamp | End of window. Default: now. |
kinds | comma-separated | Subset of evaluation, approval, action_result. Default: all three. |
The window is capped at 365 days. For longer ranges, call repeatedly with adjacent windows.
Response — 200 OK (application/x-ndjson)
{"type":"evaluation","data":{"id":"eval_01jzx...","decision":"allow",...}}
{"type":"approval","data":{"id":"approval_01jzx...","status":"approved",...}}
{"type":"action_result","data":{"id":"ares_01jzx...","status":"succeeded",...}}
The response uses Content-Disposition: attachment with a filename that encodes the date range, so a browser fetch with download or a curl -O writes the file with a sensible default name.
SDK
const ndjson = await bl.audit.export({
from: new Date('2026-04-01'),
to: new Date('2026-04-30'),
kinds: ['evaluation', 'action_result'],
});
Admin (privileged-action log + access review)#
Two read-only surfaces for enterprise auditors. They sit alongside /v1/audit (AI-action evidence): /v1/audit/* answers what did the AI do?, /v1/admin/* answers what did the operators do, and who has the keys to do it?
Every mutation through the control plane (create/update/delete on policies, agents, tools, webhooks, MCP upstreams, members, API keys, budgets…) is recorded into the privileged-action log via recordAdminAction. The log is independent of the AI-action audit ledger and uses its own retention.
Auth: org-scoped API key (bl_…) or session cookie.
GET /v1/admin/audit/events#
Privileged-action log. Each row is one operator action against the control plane.
Query parameters
| Parameter | Type | Description |
|---|---|---|
action | string | Filter by action name (e.g. policy.deleted, webhook.created, member.invited). |
actor_user_id | string | Filter to actions by a single user. |
actor_api_key_id | string | Filter to actions by a single API key. |
from | ISO timestamp | Only rows recorded after from. |
to | ISO timestamp | Only rows recorded before to. |
limit | integer | Default 100, max 500. |
offset | integer | Default 0. |
Response — 200 OK
{
"data": [
{
"id": "aae_…",
"organisation_id": "org_…",
"actor_user_id": null,
"actor_api_key_id": "key_…",
"action": "policy.deleted",
"target": { "id": "pol_…", "kind": "policy", "name": "block-prod-opus", "priority": 42 },
"reason": null,
"metadata": { "ip": "203.0.113.42", "request_id": "req_…", "user_agent": "curl/8.7.1" },
"recorded_at": "2026-05-25T14:53:16Z"
}
],
"total": 1,
"limit": 100,
"offset": 0
}
metadata carries free-form context the route recorded at action time — IP, request id, user agent, and any action-specific fields (the deleted policy's outcome, the rotated webhook's old/new secret suffix, etc.).
GET /v1/admin/access-review#
Point-in-time snapshot of every active actor and credential in the workspace — the set an auditor needs to attest "yes, every one of these has a current need-to-have".
Response — 200 OK
{
"organisation": { "id": "org_…", "name": "Acme Corp" },
"generated_at": "2026-05-25T15:13:27Z",
"members": [
{ "id": "mem_…", "user_id": "usr_…", "role": "admin", "email": "ops@acme.example", "email_verified_at": "…", "created_at": "…" }
],
"api_keys": [
{ "id": "key_…", "name": "production", "key_suffix": "af7d", "user_id": null, "created_at": "…", "revoked_at": null }
],
"webhooks": [
{ "id": "wh_…", "url": "https://…", "events": ["evaluation.created"], "enabled": true, "created_at": "…" }
],
"mcp_upstreams": [
{ "id": "ups_…", "name": "atlassian", "url": "https://…", "auth_type": "oauth2", "enabled": true, "last_pinged_at": "…", "last_health_status": "healthy" }
],
"github_installations": [
{ "id": "gh_…", "account_login": "acme", "account_type": "Organization", "verified_at": "…", "installed_at": "…" }
]
}
API keys are listed by suffix only — the raw key material is never returned.
Approvals#
Approval records represent pending human-in-the-loop decisions. When a govern() call matches a policy with outcome: approval_required, an approval is created automatically and referenced in the govern response.
Auth: org-scoped API key (bl_…) or session cookie.
GET /v1/approvals#
List approvals for the workspace. Supports filtering by status and actor.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | pending | approved | rejected | expired | Filter by approval status. |
agent_id | string | Filter to one agent. |
tool_id | string | Filter to one tool. |
decision_category | string | Filter by category (e.g. break_glass_override). |
exclude_archived_actors | boolean | Default true — hides approvals whose agent or tool is soft-deleted. Pass false for the full historical view. |
limit | integer | 1–200. Default 50. |
offset | integer | Pagination offset. |
sort | created_at | expires_at | decided_at | status | Default created_at. |
order | asc | desc | Default desc. |
Response — 200 OK — standard list envelope { data, total, limit, offset, sort, order }.
GET /v1/approvals/:id#
Return a single approval. Lazily expires the record if it is past its deadline — the row's status is upgraded from pending to expired on this GET if expires_at < now(). Note that GET /v1/approvals?status=pending does not apply the lazy expiry, so list responses may return rows whose effective status is "expired but not yet observed." Poll the detail endpoint (or GET /v1/approvals/:id/status) to settle the status of an individual approval.
Response — 200 OK
{
"id": "approval_01jv...",
"organisation_id": "org_01jv...",
"evaluation_id": "eval_01jv...",
"agent_id": "agent_01jv...",
"tool_id": "tool_01jv...",
"policy_id": "pol_01jv...",
"action_payload": { "amount": 4200, "vendor": "Acme Corp" },
"request_context": { "ip": "203.0.113.5", "user_agent": "curl/8.7.1" },
"status": "pending",
"requires_two_person": false,
"break_glass": false,
"decisions": null,
"decided_by": null,
"decision_reason": null,
"decision_category": null,
"decision_channel": null,
"decided_at": null,
"created_at": "2026-04-08T12:34:56.789Z",
"expires_at": "2026-04-09T12:34:56.789Z",
"approve_scope_until_iso": null,
"approve_scope_max_calls": null,
"approve_scope_budget_usd": null,
"approve_scope_consumed_calls": 0,
"approve_scope_consumed_usd": 0
}
Approval-scope fields are the persistent state for "scoped approvals" — when an approver decides with approve_scope_* set, the approval keeps applying to subsequent govern() calls until the time / call-count / budget limit is reached. consumed_* track how much of the scope has been used; the auto-grant fires while consumed_calls < max_calls and consumed_usd < budget_usd and now() < until_iso.
decisions is null until the first approver acts. For two-person approvals it becomes an array [{ decided_by, action, reason, decided_at }, ...] with one entry per approver — the row's status flips to approved only when the array has two distinct approvers, both with action: 'approve'.
GET /v1/approvals/:id/status#
Lightweight polling endpoint — returns only { status, decided_at, expires_at }. Use this instead of the full GET when polling every few seconds for a decision.
POST /v1/approvals/:id/approve#
Approve a pending approval.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decided_by | string | Yes | Identity of the approver (email, username, or any unique identifier). Required for every approve call — the audit trail records this verbatim. |
reason | string | No | Free-form reason recorded in the audit trail. |
decision_category | string | No | Caller-supplied category for analytics (e.g. routine_review). |
decision_channel | string | No | One of email, push, console, api. |
approve_scope | object | No | Scoped approval — see note. |
approve_scope fields: max_calls (integer), until_iso (ISO 8601 string), budget_usd (number). Only max_calls and budget_usd are persisted; until_iso is accepted but not yet enforced — do not rely on time-bounded approval scopes in production.
Two-person approval flow. When the policy has requires_two_person: true, the first approve call returns the approval still pending (one decision recorded). A second approve call from a different decided_by identity closes the approval. The same identity approving twice returns 409 DUPLICATE_APPROVER.
Response — 200 OK — the updated approval object.
Errors
| Code | Status | Cause |
|---|---|---|
APPROVAL_EXPIRED | 422 | The approval has passed its deadline. |
APPROVAL_ALREADY_DECIDED | 422 | The approval was already approved, rejected, or expired. |
DUPLICATE_APPROVER | 409 | Two-person: same identity attempted to approve twice. |
ROLE_REQUIRED | 403 | The decided_by identity does not hold a required approver_roles role. |
CONCURRENT_APPROVAL_UPDATE | 409 | Race condition — another approver wrote simultaneously. Re-fetch and retry. |
POST /v1/approvals/:id/reject#
Reject a pending approval. Uses the same body shape as /approve (minus approve_scope). A single reject closes the approval immediately even when requires_two_person is set.
Note: the reject verb is /reject, not /deny. Calling /deny returns 404.
Response — 200 OK — the updated approval object.
POST /v1/approvals/:id/break-glass#
Emergency override. Force-approves the approval regardless of requires_two_person. Sets break_glass: true and decision_category: break_glass_override permanently on the approval as an audit marker.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decided_by | string | Yes | Identity of the actor invoking break-glass. |
reason | string | Yes | Audit justification. Minimum 40 characters. The friction is intentional — a short reason is not adequate for an emergency override audit trail. |
decision_category | string | No | Defaults to break_glass_override. |
decision_channel | string | No | Channel used. |
SDK: bl.approvals.breakGlass(approvalId, { decidedBy, reason }) — camelCase method, kebab HTTP path (/break-glass).
Response — 200 OK — the updated approval object with break_glass: true and decision_category: "break_glass_override".
Errors
| Code | Status | Cause |
|---|---|---|
APPROVAL_EXPIRED | 422 | The approval has passed its deadline. |
APPROVAL_ALREADY_DECIDED | 422 | The approval was already closed. |
VALIDATION_ERROR | 400 | reason is shorter than 40 characters. |
Evaluations#
Evaluation records are the immutable audit ledger of every govern() call. Records are never removed when an agent or tool is archived — list queries default to showing the full ledger. Pass ?exclude_archived_actors=true for the "clean recent" view the console uses.
Auth: org-scoped API key or session cookie.
GET /v1/evaluations#
List evaluations. Supports two pagination modes.
Query parameters
| Parameter | Type | Description |
|---|---|---|
decision | allow | deny | approval_required | default_deny | Filter by decision. (outcome is a legacy alias.) |
agent_id | string | Filter to one agent. |
tool_id | string | Filter to one tool. |
exclude_archived_actors | boolean | Default false — returns the full audit ledger. Pass true to hide evaluations whose agent or tool is soft-deleted. |
mode | offset | cursor | Pagination mode. Default offset. |
limit | integer | 1–200. Default 50. |
offset | integer | Offset mode only. |
sort | evaluated_at | outcome | Default evaluated_at. |
order | asc | desc | Default desc. |
after | string | Cursor mode only — opaque cursor from next_cursor. |
Cursor mode (?mode=cursor) returns { data, next_cursor } and is immune to mid-iteration inserts. Use it for SIEM export and audit workloads. Pass next_cursor as ?after=<cursor> to advance. When next_cursor is null, iteration is complete.
Response — 200 OK — offset mode: { data, total, limit, offset, sort, order } — cursor mode: { data, next_cursor }.
Each evaluation carries a decision field (preferred) aliased from the internal outcome column for backward compatibility.
GET /v1/evaluations/:id#
Return a single evaluation by id.
POST /v1/evaluations/:id/results#
Attach an action result (execution evidence) to a governed decision. Call this after the governed tool runs to close the receipt loop.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
status | succeeded | failed | skipped | unknown | Yes | Outcome of the actual tool execution. |
external_system | string | No | System that ran the action (e.g. github-actions). |
external_id | string | No | Source system identifier (run ID, job ID). Used for audit reconciliation. |
external_url | string | No | Link to the run in the external system. |
duration_ms | integer | No | Execution time. |
exit_code | integer | No | Process exit code if applicable. |
output_digest | string | No | SHA-256 of the output for tamper detection. |
error | string | No | Error message on failure. |
metadata | object | No | Arbitrary key-value evidence. |
Response — 201 Created — the persisted action result with id prefixed ares_.
GET /v1/evaluations/:id/results#
List all action results attached to an evaluation.
Response — 200 OK — { data: [...] }.
Webhooks#
Webhooks deliver signed event notifications to a URL of your choice. The signing secret is returned once at creation and never again — store it immediately. Verify deliveries with HMAC-SHA256: sha256(secret, "<timestamp_ms>.<raw_body>") and compare to the X-BlackLake-Signature: sha256=<hex> header.
Auth: org-scoped API key or session cookie.
GET /v1/webhooks#
List webhooks. Supports soft-delete filters: ?include_deleted=true or ?only_deleted=true.
Standard list envelope. Sort fields: created_at, url, enabled.
POST /v1/webhooks#
Register a new webhook.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS delivery URL. Must be a public address (no private IPs). |
events | string[] | Yes | Event types to subscribe to. See Enums for the full list. |
enabled | boolean | No | Default true. |
Response — 201 Created
{
"id": "wh_01jv...",
"url": "https://hooks.example.com/blacklake",
"secret": "whsec_<64 hex chars>",
"secret_suffix": "a1b2",
"events": ["approval.created", "approval.approved"],
"enabled": true,
"created_at": "2026-05-17T10:00:00.000Z",
"warning": "Save this signing secret — it will not be shown again."
}
The secret field is shown exactly once. Store it for HMAC verification.
GET /v1/webhooks/:id#
Return a single webhook (no secret — only secret_suffix).
PATCH /v1/webhooks/:id#
Update url, events, or enabled. Body fields are all optional.
DELETE /v1/webhooks/:id#
Hard-delete the webhook and its delivery log. Returns 204 No Content.
POST /v1/webhooks/:id/test#
Fire a synthetic webhook.test delivery to the endpoint. Returns the delivery result inline:
{
"delivery_id": "whd_01jv...",
"status_code": 200,
"response_body": "ok",
"error": null,
"duration_ms": 83,
"delivered_at": "2026-05-17T10:00:00.000Z"
}
POST /v1/webhooks/:id/rotate-secret#
Issue a new signing secret. Returns the raw secret once (same shape as create). The previous secret is invalidated atomically — update your receiver before rotating in production.
GET /v1/webhooks/:id/deliveries#
Recent delivery log. Standard list envelope. Sort fields: delivered_at, status_code, duration_ms. Each row carries status (success, failed, dead), payload, status_code, response_body, error, signed_timestamp_ms, duration_ms.
POST /v1/webhooks/:id/deliveries/:deliveryId/resend#
Re-fire a single delivery with a fresh timestamp and signature. Returns { delivery_id, replay_of }.
POST /v1/webhooks/:id/deliveries/resend-failed#
Bulk-replay up to 100 failed deliveries (status_code null or ≥ 400, or error non-null). Returns 422 NO_FAILED_DELIVERIES when there is nothing to replay.
GET /v1/webhooks/:id/health#
Delivery health for the last 100 deliveries: { webhook_id, window_size, success_rate, last_success_at, dead_letter_count }.
Billing#
Billing endpoints expose the workspace's Stripe subscription state and initiate Checkout / Portal flows.
Auth: Session cookie or user-scoped API key (bl_usr_…) required for write operations (checkout, portal). Org-scoped bl_… keys can read /billing and /billing/nudges but cannot mint Stripe sessions — gating Checkout and Portal on user sessions prevents a leaked CI key from opening live billing flows.
GET /v1/billing#
Current subscription summary.
Response — 200 OK
{
"tier": "free",
"status": "no_subscription",
"quantity": 0,
"stripe_customer_id": null,
"stripe_subscription_id": null,
"stripe_price_id": null,
"current_period_start": null,
"current_period_end": null,
"cancel_at_period_end": false,
"canceled_at": null,
"trial_end": null,
"usage": {
"used": 142,
"limit": 10000,
"remaining": 9858,
"period_key": "2026-05"
}
}
usage.limit is null for unconstrained tiers (Team, Enterprise). tier is free, team, or enterprise.
POST /v1/billing/checkout#
Start a Stripe Checkout session for the Team tier. Session or user-key auth required.
Request body (all optional)
| Field | Type | Description |
|---|---|---|
plan | "team" | Currently the only self-serve tier. |
quantity | integer | Seat count. Default 1. |
success_url | string | Redirect after successful payment. |
cancel_url | string | Redirect on cancellation. |
Response — 200 OK
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_…", "session_id": "cs_live_…" }
Errors
| Code | Status | Cause |
|---|---|---|
ALREADY_SUBSCRIBED | 409 | Workspace already has an active or trialing subscription. Use Portal to modify. |
BILLING_NOT_CONFIGURED | 503 | Stripe is not configured on this deployment. |
POST /v1/billing/portal#
Open a Stripe Customer Portal session. Session or user-key auth required. Allows the user to update payment method, change quantity, and cancel.
Request body (optional)
{ "return_url": "https://console.blacklake.systems/settings/billing" }
Response — 200 OK
{ "portal_url": "https://billing.stripe.com/p/session/live_…", "session_id": "…" }
Errors
| Code | Status | Cause |
|---|---|---|
NO_CUSTOMER | 404 | No Stripe Customer exists yet — start a Checkout flow first. |
PORTAL_NOT_CONFIGURED | 503 | Portal not configured in the Stripe Dashboard. |
BILLING_NOT_CONFIGURED | 503 | Stripe not configured on this deployment. |
GET /v1/billing/nudges#
Value-moment upgrade prompts for the current workspace. Returns nudges that are relevant to the workspace's current tier and usage level, minus any the user has dismissed.
Response — 200 OK
{
"nudges": [
{
"id": "value_first_100_actions",
"title": "You've governed 100 actions",
"body": "…",
"cta_text": "Upgrade to Team",
"cta_kind": "upgrade"
}
],
"context": { "tier": "free", "governed_actions": 142, "decided_approvals": 3 }
}
POST /v1/billing/nudges/:id/dismiss#
Dismiss a nudge so it stops appearing. Idempotent.
Demo#
Demo endpoints seed and clear representative data so a fresh workspace renders a populated UI without a real integration.
Auth: org-scoped API key or session cookie. /demo/seed and /demo/clear return 403 DEMO_DISABLED when BLACKLAKE_DEMO_ENABLED=false (regulated deployments). /demo/status is always open so the console can conditionally render the seed button.
GET /v1/demo/status#
{ "has_demo_data": true }
POST /v1/demo/seed#
Create demo agents, tools, policies, bindings, evaluations, cost records, and budgets. Idempotent — returns { status: "already_seeded" } if demo data already exists.
Response — 200 OK
{
"status": "seeded",
"agents": 3,
"tools": 4,
"policies": 3,
"bindings": 12,
"evaluations": 8,
"cost_records": 8,
"budgets": 2
}
POST /v1/demo/clear#
Remove all demo-tagged resources. Safe to call when no demo data exists. Returns counts of what was removed.
API Key Admin#
GET /v1/api-keys#
List API keys for the workspace. Returns metadata only — the raw key is never stored and cannot be retrieved. Includes both org-scoped (bl_…) and user-scoped (bl_usr_…) keys, distinguished by user_id (non-null for user-scoped).
Auth: org-scoped API key or session cookie.
Response — 200 OK — standard list envelope. Each row: { id, name, user_id, key_suffix, created_at, revoked_at }.
Sort fields: created_at, name, revoked_at.
Organisation#
GET /v1/organisation#
Return the current workspace's organisation record including name, contact email, creation date, and Stripe customer ID.
Auth: org-scoped API key or session cookie.
POST /v1/organisation/delete#
Soft-delete the workspace. All API keys for the org are immediately rejected. Data is preserved for 30 days; restore via POST /v1/organisation/restore before the grace window closes.
Request body
{ "confirmation": "Acme Corp", "reason": "optional free-form reason" }
confirmation must exactly match the organisation name (422 CONFIRMATION_MISMATCH otherwise).
Response — 200 OK
{
"deleted_at": "2026-05-17T10:00:00.000Z",
"restore_until": "2026-06-16T10:00:00.000Z",
"grace_days": 30,
"note": "…"
}
POST /v1/organisation/reset#
Wipe all operational data (agents, tools, policies, evaluations, approvals, cost records, webhooks, audit events) while preserving identity (users, API keys, memberships, sessions). Requires admin role. Confirmation must match the workspace name. Rate-limited at 3 per hour per workspace.
Request body — same shape as /delete: { confirmation, reason }.
Response — 200 OK — counts of rows removed by table, plus list of preserved tables.
Insights (additional)#
POST /v1/insights/anomalies/recompute#
Trigger anomaly detection across the workspace immediately. Returns the count of new anomaly observations written. Use after ingesting a batch of historical cost records or after a major workload change.
Legacy#
POST /v1/decide#
The original magic-link approval decision endpoint. Used by email and push approval links — not intended for direct API calls. Accepts ?token=<magic-link-token> plus { action: "approve" | "reject", reason?, channel? } in the body. Verifies the signed token, records the decision, and returns { decision, status, decided_by, decided_at }.
For programmatic approval from a CI script or SDK, use POST /v1/approvals/:id/approve instead.
Receipt lifecycle — v1 and v2 decision tokens#
Every govern() call returns a decision_token in the form bldt_v1:<base64url-hmac>. This is a v1 token: an HMAC-SHA256 binding (evaluation_id, decision). It proves the decision was produced by the BlackLake API and has not been tampered with.
A v2 token (bldt_v2:…) additionally binds the cost summary — (evaluation_id, decision, cost_summary). It is derived when a cost_records row is attached to the evaluation.
Lifecycle:
POST /v1/govern→ returnsdecision_token: "bldt_v1:…"in the response.- Agent executes the tool; then calls
POST /v1/cost/recordwithevaluation_idset to the evaluation's id. GET /v1/cost/by-evaluation/:id→ response includesdecision_token_v2: "bldt_v2:…".- Verify either token via
POST /v1/decisions/verify:
curl -X POST https://api.blacklake.systems/v1/decisions/verify \
-H "Content-Type: application/json" \
-d '{ "evaluation_id": "eval_01jv...", "decision_token": "bldt_v2:..." }'
A successful v2 verify response includes token_version: "v2" and cost_signed: "Cost is cryptographically bound to this decision via the v2 token." A v1 verify succeeds without cost binding.
When to use v1 vs v2: Use v1 for governance-only receipts (was this action permitted?). Use v2 when you need to prove both permission and spend (compliance, audit, finance sign-off).
Failure reason codes: invalid_token_format, malformed, signature_mismatch, evaluation_not_found.
Audit-to-govern correlation#
When you ingest a cloud audit event via POST /v1/audit/ingest, BlackLake attempts to correlate it with an existing governed evaluation. The governed_evaluation_id field on the ingested event row reflects the result.
Current matching rules:
source_event_idmatched againstaction_results.external_idfor the org.payload.github_run_id,payload.github_sha, orpayload.external_idmatched against recent evaluationrequest_context.
Auto-correlation is not guaranteed. If the govern call and the audit event do not share a common identifier, governed_evaluation_id stays null. The uncovered event appears on GET /v1/audit/uncovered.
Manual linking: To ensure correlation, pass governed_evaluation_id explicitly in the audit ingest payload, or pass external_id in the POST /v1/evaluations/:id/results call that follows the governed action and then use the same value as source_event_id on the audit event.
Insights#
Top-level risk signals across the workspace. Backs the Risk dashboard. Window: last 30 days.
Response — 200 OK
{
"window_days": 30,
"decision_breakdown": { "allow": 1500, "deny": 80, "approval_required": 200, "default_deny": 12 },
"decision_total": 1792,
"approval_rate": { "pending": 4, "approved": 180, "rejected": 14, "expired": 2 },
"approval_total": 200,
"top_actors_by_deny": [
{
"agent_id": "agent_01jzx...",
"agent_name": "deploy-bot",
"environment": "production",
"risk_classification": "high",
"deny_count": 23
}
],
"top_high_risk_tools": [
{
"id": "tool_01jzx...",
"name": "gcloud.run.deploy",
"risk_classification": "high",
"owner": "platform-team@acme.example",
"last_seen_at": "2026-04-29T08:21:00Z"
}
],
"recent_denies": [
{
"id": "eval_01jzx...",
"decision": "default_deny",
"agent_id": "agent_01jzx...",
"tool_id": "tool_01jzx...",
"policy_name": null,
"evaluated_at": "2026-04-29T08:33:12Z"
}
]
}
top_high_risk_tools is filtered to risk_classification of high or critical. top_actors_by_deny and recent_denies group deny and default_deny together.
GET /v1/insights/coverage#
Aggregate inventory + recent activity counts. Backs the Coverage dashboard. Window is fixed at the last 30 days.
Response — 200 OK
{
"window_days": 30,
"actors": {
"total": 24,
"active_recent": 18,
"stale": 5,
"never_seen": 1,
"by_source": { "manual": 4, "mcp": 16, "sdk": 3, "ci": 1 }
},
"tools": {
"total": 142,
"active_recent": 89,
"stale": 50,
"never_seen": 3,
"by_source": { "manual": 0, "mcp": 138, "sdk": 4, "ci": 0 }
},
"evaluations_recent": {
"total": 4218,
"by_source": { "manual": 0, "mcp": 4001, "sdk": 200, "ci": 17 }
}
}
| Field | Description |
|---|---|
actors.total / tools.total | Total registered agents / tools in the workspace. |
actors.active_recent | Count with last_seen_at inside the window. |
actors.stale | Count whose last govern() was outside the window (or never). |
actors.never_seen | Subset of stale that have never been governed. |
*.by_source | Buckets by registration path — manual, mcp, sdk, ci. |
evaluations_recent.by_source | govern() counts joined to the agent's source within the window. |
API Proxy#
The API proxy forwards requests to upstream LLM providers using your own API keys, and records usage metadata from the response. No request or response body content is stored.
Authentication: The proxy is fronted by BlackLake's auth middleware. You must supply a valid BlackLake key (Authorization: Bearer bl_… or x-api-key: bl_…) in addition to the provider-specific header. The BlackLake key gates access and attributes cost to your workspace; the provider key authenticates to the upstream. Sending only a provider key — without a BlackLake key — returns 401 from BlackLake's edge before the request reaches the upstream.
Headers, summarised:
| Provider | BlackLake auth (required) | Provider auth (forwarded upstream) |
|---|---|---|
| Anthropic | Authorization: Bearer bl_… | x-api-key: sk-ant-… + anthropic-version: … |
| OpenAI | Authorization: Bearer bl_… | Authorization: Bearer sk-… (use a second Authorization header — see example) |
| Ollama | Authorization: Bearer bl_… | none (local; no provider key) |
OpenAI note. Because both BlackLake and OpenAI use
Authorization, send BlackLake's key first and OpenAI's key asx-openai-api-key. The proxy mapsx-openai-api-key→Authorization: Bearer …on the outbound request to the OpenAI API.
The proxy is transparent pass-through: the provider headers go to the provider, BlackLake observes wire metadata (tokens, model, latency) and writes a cost_records row. If your provider key is invalid the provider's own 401 comes back. If your BlackLake key is invalid, BlackLake's edge returns 401 before forwarding upstream.
The proxy strips Accept-Encoding on the outbound request so the upstream returns an uncompressed body — fetch() was decoding it before we forwarded the response anyway, and the original encoding headers on the now-decoded body made compliant clients (Node fetch, Anthropic/OpenAI SDKs, curl --compressed) crash with Z_DATA_ERROR.
/proxy/anthropic/*#
Forwards to https://api.anthropic.com. Replace your Anthropic SDK's baseURL with http://localhost:3100/proxy/anthropic (local) or https://api.blacklake.systems/proxy/anthropic (cloud).
# Cloud mode — both BlackLake auth and Anthropic key required
curl https://api.blacklake.systems/proxy/anthropic/v1/messages \
-H "Authorization: Bearer $BLACKLAKE_API_KEY" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [{ "role": "user", "content": "Hello" }]
}'
In local mode the BlackLake auth is unenforced — any non-empty value passes — but the header shape is the same.
/proxy/openai/*#
Forwards to https://api.openai.com. Replace your OpenAI SDK's baseURL with http://localhost:3100/proxy/openai.
curl https://api.blacklake.systems/proxy/openai/v1/chat/completions \
-H "Authorization: Bearer $BLACKLAKE_API_KEY" \
-H "x-openai-api-key: $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [{ "role": "user", "content": "Hello" }]
}'
/proxy/ollama/*#
Forwards to http://localhost:11434. Local mode only. In cloud mode the route returns 502 from Cloudflare because localhost:11434 is not reachable from BlackLake's cloud infrastructure — Ollama is a local-only model runtime.
# Local mode only
curl http://localhost:3100/proxy/ollama/api/chat \
-H "Authorization: Bearer local" \
-H "Content-Type: application/json" \
-d '{
"model": "llama3",
"messages": [{ "role": "user", "content": "Hello" }]
}'
Agents#
POST /v1/agents#
Create a new agent.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Agent name. Must be case-insensitively unique within the organisation (the server normalises to lower-case for the uniqueness check). Max 100 chars; cannot contain < > or invisible / bidi-control Unicode codepoints. |
description | string | No | Optional description. Max 500 chars. |
environment | development | staging | production | Yes | Deployment environment. |
risk_classification | low | medium | high | critical | Yes | Risk level of the agent. |
approval_mode | auto_approve | require_approval | block | No | Default: auto_approve. Agent-level fallback when no matching policy exists. |
owner | string | No | Free-text owner attribution (email, team, ticket reference). Stored on the row for audit; not validated against any directory. Max 200 chars. |
source | enum | No | Integration path that registered the agent: manual, mcp, sdk, ci, shell, cloud_audit, existing_workflow_engine, depth. Defaults to manual when called via console / direct API key. |
Response — 201 Created
{
"id": "agent_01jv2k8tq3e4f5g6h7j8k9l0m",
"organisation_id": "org_01jv...",
"name": "customer-support-agent",
"description": "Handles tier-1 customer queries",
"environment": "production",
"risk_classification": "medium",
"status": "active",
"approval_mode": "auto_approve",
"owner": null,
"source": "manual",
"last_seen_at": null,
"created_at": "2026-04-06T09:00:00.000Z",
"updated_at": "2026-04-06T09:00:00.000Z",
"deleted_at": null
}
A 409 AGENT_NAME_CONFLICT is returned if another agent already holds the name (case-insensitive). last_seen_at is null until the first govern() call resolves the agent — it then tracks the last-seen timestamp for staleness signals on the Coverage page.
curl
curl -X POST https://api.blacklake.systems/v1/agents \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "customer-support-agent",
"description": "Handles tier-1 customer queries",
"environment": "production",
"risk_classification": "medium"
}'
GET /v1/agents#
List agents in the organisation. Returns the standard list envelope; honours ?limit=, ?offset=, ?sort=created_at|updated_at|name|environment|risk_classification|status, and ?order=asc|desc.
Soft-deleted agents are hidden by default. Pass ?include_deleted=true to include archived rows or ?only_deleted=true to fetch just the archive.
Query parameters
| Parameter | Type | Description |
|---|---|---|
environment | string | Filter by environment. |
status | string | Filter by status. |
include_deleted | boolean | Include soft-deleted agents. Default false. |
only_deleted | boolean | Return only soft-deleted agents. Default false. |
Response — 200 OK (list envelope; agents in data).
curl
curl 'https://api.blacklake.systems/v1/agents?environment=production' \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/agents/:id#
Retrieve a single agent by ID.
Response — 200 OK or 404 if not found.
curl
curl https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m \
-H "x-api-key: $BLACKLAKE_API_KEY"
PATCH /v1/agents/:id#
Update agent fields. Only provided fields are modified.
Request body — all fields optional
| Field | Type | Description |
|---|---|---|
name | string | New name. |
description | string | New description. |
environment | string | New environment. |
risk_classification | string | New risk classification. |
status | string | New status. |
approval_mode | string | New approval mode. |
Response — 200 OK with the updated agent.
curl
curl -X PATCH https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "risk_classification": "high" }'
POST /v1/agents/:id/suspend#
Set the agent status to suspended. All subsequent governance requests for this agent will return deny until the agent is reactivated. No request body required.
Response — 200 OK with the updated agent.
curl
curl -X POST https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m/suspend \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/agents/:id/activate#
Set the agent status back to active. No request body required.
Response — 200 OK with the updated agent.
curl
curl -X POST https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m/activate \
-H "x-api-key: $BLACKLAKE_API_KEY"
DELETE /v1/agents/:id#
Soft-delete (archive) the agent. The record stays in the database — bindings and the audit trail are preserved — but POST /v1/govern will no longer resolve it by name and GET /v1/agents hides it by default. The freed name can immediately be reused for a brand-new agent.
PATCH /v1/agents/:id returns 409 AGENT_DELETED against an archived record; restore the agent first or replace it with a fresh registration.
Reversible via POST /v1/agents/:id/restore. Idempotent: re-deleting an archived record returns the same row without error.
Response — 200 OK (archived agent, with deleted_at set).
curl
curl -X DELETE https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/agents/:id/restore#
Un-archive a soft-deleted agent. Returns 409 AGENT_NAME_CONFLICT if another active agent has since claimed the name; rename or delete that agent first, then retry.
Response — 200 OK (restored agent, with deleted_at: null).
curl
curl -X POST https://api.blacklake.systems/v1/agents/agent_01jv2k8tq3e4f5g6h7j8k9l0m/restore \
-H "x-api-key: $BLACKLAKE_API_KEY"
Agent-Tool Bindings#
POST /v1/agents/:id/tools#
Bind a tool to an agent. Returns 409 BINDING_EXISTS if the (agent_id, tool_id) pair already has a binding.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
tool_id | string | Yes | ID of the tool to bind. |
Response — 201 Created
{
"id": "bind_01jv...",
"agent_id": "agent_01jv...",
"tool_id": "tool_01jv...",
"created_at": "2026-04-06T09:01:00.000Z"
}
curl
curl -X POST https://api.blacklake.systems/v1/agents/agent_01jv.../tools \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "tool_id": "tool_01jv..." }'
GET /v1/agents/:id/tools#
List tools bound to an agent. Returns the standard list envelope; honours ?limit=, ?offset=, ?sort=created_at|name|risk_classification, and ?order=asc|desc. Each item joins binding metadata with the full tool object.
Response — 200 OK
{
"data": [
{
"binding_id": "bind_01jv...",
"binding_created_at": "2026-04-06T09:01:00.000Z",
"tool": {
"id": "tool_01jv...",
"organisation_id": "org_01jv...",
"name": "send-email",
"description": "Sends an email to a customer",
"risk_classification": "medium",
"created_at": "2026-04-06T09:00:30.000Z"
}
}
],
"total": 1,
"limit": 50,
"offset": 0,
"sort": "created_at",
"order": "desc"
}
curl
curl https://api.blacklake.systems/v1/agents/agent_01jv.../tools \
-H "x-api-key: $BLACKLAKE_API_KEY"
DELETE /v1/agents/:id/tools/:tool_id#
Remove a binding between an agent and a tool. After this call, governance requests from this agent for this tool will be denied.
Idempotent — deleting a binding that does not exist still returns 204, so cleanup scripts can run without first checking for the binding.
Response — 204 No Content
curl
curl -X DELETE https://api.blacklake.systems/v1/agents/agent_01jv.../tools/tool_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
Tools#
POST /v1/tools#
Register a new tool.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Tool name. Must be unique within the organisation. |
description | string | No | Optional description. |
risk_classification | low | medium | high | critical | Yes | Risk level of the tool. |
Response — 201 Created
{
"id": "tool_01jv...",
"organisation_id": "org_01jv...",
"name": "send-email",
"description": "Sends an email to a customer",
"risk_classification": "medium",
"owner": null,
"source": "manual",
"last_seen_at": null,
"created_at": "2026-04-06T09:00:30.000Z",
"deleted_at": null
}
curl
curl -X POST https://api.blacklake.systems/v1/tools \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "send-email",
"description": "Sends an email to a customer",
"risk_classification": "medium"
}'
GET /v1/tools#
List tools in the organisation. Returns the standard list envelope; honours ?limit=, ?offset=, ?sort=created_at|name|risk_classification, and ?order=asc|desc.
Soft-deleted tools are hidden by default. Pass ?include_deleted=true to include archived rows or ?only_deleted=true to fetch just the archive.
Response — 200 OK
{
"data": [
{
"id": "tool_01jv...",
"name": "send-email",
"description": "Sends an email to a customer",
"risk_classification": "medium",
"owner": null,
"source": "manual",
"last_seen_at": null,
"created_at": "2026-04-06T09:00:30.000Z",
"deleted_at": null
}
],
"total": 1,
"limit": 50,
"offset": 0,
"sort": "created_at",
"order": "desc"
}
curl
curl https://api.blacklake.systems/v1/tools \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/tools/:id#
Retrieve a single tool by ID.
Response — 200 OK or 404 if not found.
curl
curl https://api.blacklake.systems/v1/tools/tool_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
PATCH /v1/tools/:id#
Update tool inventory metadata. Only name, description, risk_classification, and owner can be patched — source and last_seen_at are system-owned. Returns 409 TOOL_DELETED if the tool has been archived; restore it first.
Response — 200 OK (updated tool).
curl
curl -X PATCH https://api.blacklake.systems/v1/tools/tool_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "owner": "platform-team@acme.example" }'
DELETE /v1/tools/:id#
Soft-delete (archive) the tool. The record stays in the database — bindings and the audit trail are preserved — but governance treats the tool as not-bound and the tool disappears from GET /v1/tools by default. Reversible via POST /v1/tools/:id/restore. Idempotent: re-deleting an archived record returns the same row without error.
Response — 200 OK (archived tool, with deleted_at set).
curl
curl -X DELETE https://api.blacklake.systems/v1/tools/tool_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/tools/:id/restore#
Un-archive a soft-deleted tool. Idempotent — restoring an already-active record returns the same row.
Response — 200 OK (restored tool, with deleted_at: null).
curl
curl -X POST https://api.blacklake.systems/v1/tools/tool_01jv.../restore \
-H "x-api-key: $BLACKLAKE_API_KEY"
Policies#
POST /v1/policies#
Create a new policy.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Descriptive policy name. Max 200 chars. |
priority | integer | Yes | Evaluation order. Lower = evaluated first; the first matching policy wins. Range: 0–10000 inclusive. |
agent_selector | object | No | Key-value pairs to match against the agent. Default: {} (matches all). |
tool_selector | object | No | Key-value pairs to match against the tool. Default: {} (matches all). |
outcome | allow | deny | approval_required | Yes | Decision to return when this policy matches. |
enabled | boolean | No | Default: true. Disabled policies are skipped entirely. |
requires_two_person | boolean | No | When outcome=approval_required, gate the approval on two distinct approvers. Default: false. |
approver_roles | string[] | No | When non-empty, a session-authed approver must hold at least one matching role on their org membership. Roles are alphanumeric (with - / _), max 50 chars each, max 20 entries. |
cost_conditions | object | No | Cost-aware DSL conditions evaluated alongside selectors. When present, all conditions must evaluate true for the policy to fire. Same shape as the cost_conditions block on policy update; see §Cost-aware conditions. |
mode | enforce | monitor | No | Defaults to enforce. monitor records a match without applying the outcome — useful for shadow-testing a policy before flipping it live. |
Response — 201 Created
{
"id": "pol_01jv...",
"organisation_id": "org_01jv...",
"name": "block-high-risk-tools-in-prod",
"priority": 1,
"agent_selector": { "environment": "production" },
"tool_selector": { "risk_classification": "high" },
"outcome": "deny",
"enabled": true,
"requires_two_person": false,
"approver_roles": null,
"cost_conditions": null,
"mode": "enforce",
"created_at": "2026-04-06T09:02:00.000Z",
"updated_at": "2026-04-06T09:02:00.000Z",
"created_by": "api_key:key_01jv...",
"updated_by": "api_key:key_01jv...",
"deleted_at": null
}
The created_by and updated_by strings carry the actor that wrote the row: api_key:<id> for org-scoped keys, user:<id> for session-authed admins, or system for migrations.
curl
curl -X POST https://api.blacklake.systems/v1/policies \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "block-high-risk-tools-in-prod",
"priority": 1,
"agent_selector": { "environment": "production" },
"tool_selector": { "risk_classification": "high" },
"outcome": "deny"
}'
GET /v1/policies#
List all policies, default sort priority ascending. Returns the standard list envelope with ?limit=, ?offset=, ?sort=, ?order=.
Response — 200 OK
{
"data": [/* policy objects */],
"total": 42,
"limit": 50,
"offset": 0,
"sort": "priority",
"order": "asc"
}
curl
curl https://api.blacklake.systems/v1/policies \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/policies/:id#
Retrieve a single policy by ID.
Response — 200 OK or 404 if not found.
curl
curl https://api.blacklake.systems/v1/policies/pol_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
PATCH /v1/policies/:id#
Update policy fields. Only provided fields are modified. The policy cache is invalidated immediately.
Request body — all fields optional. Same fields as POST /v1/policies.
Response — 200 OK with the updated policy.
curl
curl -X PATCH https://api.blacklake.systems/v1/policies/pol_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'
DELETE /v1/policies/:id#
Permanently delete a policy. The policy cache is invalidated immediately.
Response — 204 No Content
curl
curl -X DELETE https://api.blacklake.systems/v1/policies/pol_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
Governance#
POST /v1/govern#
Evaluate whether an agent is permitted to invoke a tool. This is the core endpoint of Surface.
The request identifies the agent and tool by name. The engine resolves them to records, checks agent status, verifies the binding, evaluates policies in priority order, and records the decision.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
agent | string | Yes | Agent name (not ID). Resolved to an agent record by name within the org. Max 100 chars. |
tool | string | Yes | Tool name (not ID). Resolved to a tool record by name within the org. Max 200 chars. |
action | object | No | Arbitrary key-value payload describing the action. Stored in the evaluation record and shown in approval notifications. |
context | object | No | Caller-supplied context (ip, user_agent, engine block for workflow lineage, free-form kv pairs). Stored as the evaluation's request_context column verbatim — the API only overwrites ip and user_agent with the server-observed values. For audit reconciliation: put external_id, github_sha, or github_run_id at the top level of context, not nested under a request_context key (the engine does not re-flatten — a nested key lands at depth 2 and the reconciler does not match it). |
estimate | object | No | Pre-call cost estimate. When provided, cost-aware policies and budget pre-checks evaluate against projected spend so a denial can fire before the LLM call leaves the network. Same shape as the cost_estimate returned by POST /v1/cost/estimate. |
session_id | string | No | Caller-supplied session identifier grouping related govern() calls within one agent run. Used by per-session cost signals. Max 120 chars. |
task_id | string | No | Caller-supplied task identifier for the user-facing unit of work within a session. Used by per_task budget period and per-task cost ceilings. Max 120 chars. |
user_id | string | No | Caller-supplied user identifier for per-user budget enforcement and cost attribution. Not validated against any user directory. Max 120 chars. |
Response — 200 OK
| Field | Type | Description |
|---|---|---|
decision | string | allow, deny, approval_required, or default_deny. |
evaluation_id | string | ID of the recorded evaluation. |
evaluation_url | string | Console deep link to the evaluation row. Always present. |
policy_id | string | null | ID of the matching policy, or null (binding_missing / agent_suspended / budget / default_deny paths). |
reason | string | Human-readable explanation. |
evaluated_at | string | ISO 8601 UTC timestamp. |
approval_id | string | Present only when decision is approval_required. ID of the created approval record. |
approval_url | string | Console deep link to the approval. Present whenever approval_id is set. Requires console auth — magic-link tokens are delivered via email/push. |
decision_token | string | HMAC-signed receipt binding the evaluation ID to the decision. Verify with POST /v1/decisions/verify. |
matched_policy | object | null | When a policy matched: { id, name, priority, outcome, matched_selectors: { agent, tool } }. null on the binding_missing / agent_suspended / budget-deny / default_deny paths. |
denial_reason | string | null | Discriminator on deny / default_deny / approval_required paths: 'policy', 'budget', 'binding_missing', 'agent_suspended', or 'default_deny'. null on allow. |
cost_signals | object | null | Cost-condition values that tipped the matched policy (BL-DIF-7 explainability). null when the matched policy carried no cost conditions, or when no policy matched. |
budget_headroom_usd | number | USD remaining in the tightest enforceable budget covering this (agent, tool) pair. 0 when a hard limit denied; the caller's signal for "how much more can I spend before the next denial." |
estimated_cost_usd | number | Echo of request.estimate.estimated_cost_usd when the caller passed one. Absent otherwise. |
{
"decision": "allow",
"evaluation_id": "eval_01jv...",
"evaluation_url": "https://console.blacklake.systems/evaluations/eval_01jv...",
"policy_id": "pol_01jv...",
"reason": "Matched policy: allow-support-agent-email",
"evaluated_at": "2026-04-06T09:05:00.000Z",
"decision_token": "bldt_v1:...",
"matched_policy": {
"id": "pol_01jv...",
"name": "allow-support-agent-email",
"priority": 10,
"outcome": "allow",
"matched_selectors": { "agent": { "environment": "production" }, "tool": {} }
},
"denial_reason": null,
"cost_signals": null,
"budget_headroom_usd": 47.32
}
POST /v1/govern/simulate#
What would govern() return for this request? Runs the full live evaluation
pipeline against current policies + bindings without recording an evaluation,
creating an approval, or firing webhooks. Use it to debug "why is this denied?"
and to validate policy reach before changing client code.
This is not the same as POST /v1/policies/simulate. The policy simulator
replays a draft policy against historical evaluations to estimate impact; the
govern simulator answers a single hypothetical "what would happen now?"
question.
Request body — same shape as POST /v1/govern (agent, tool, optional
action and context), plus two cost-aware extras:
| Field | Type | Required | Description |
|---|---|---|---|
estimated_cost_usd | number | No | When supplied, the simulator runs the same budget pre-check live govern() does and returns per-budget snapshots in the response. Lets you predict whether a real call would be denied by a budget even when the policy says allow. |
task_id | string | No | Group identifier for budget pre-checks scoped to a per-task window. |
Response — 200 OK
| Field | Type | Description |
|---|---|---|
decision | string | allow, deny, approval_required, default_deny, or unknown (the agent or tool isn't registered). |
reason | string | Human-readable explanation. |
evaluated_at | string | ISO 8601 UTC timestamp. |
agent_resolved | boolean | True if the agent name resolves to a record. |
tool_resolved | boolean | True if the tool name resolves to a record. |
binding_present | boolean | True if there is a binding linking the agent and tool. |
matched_policy | object | null | { id, name, priority, outcome } of the policy that would match, or null. |
next_steps | string[] | Concrete suggestions to make the decision change (register a missing record, write a policy, bind a tool, etc.). |
budgets | object | Per-budget snapshot for every enforcing budget in scope. See below. |
budgets is always present and has shape { estimated_cost_usd, blocking_budget_id, snapshots: [...] }. Each snapshot is { id, name, scope_type, period, spend_usd, soft_limit_usd, hard_limit_usd, headroom_usd, estimated_projected_usd, blocks_call }. When estimated_cost_usd was passed in the request and any budget's projected spend would exceed its hard limit, blocking_budget_id names the first budget that would deny — useful when the policy says allow but a budget would override.
curl
curl -X POST https://api.blacklake.systems/v1/govern \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent": "customer-support-agent",
"tool": "send-email",
"action": { "to": "user@example.com" },
"context": { "ticket_id": "T-1234" }
}'
Denial reasons
| Scenario | decision | reason |
|---|---|---|
| Agent is suspended | deny | Agent is suspended |
| Agent is disabled | deny | Agent is disabled |
| No binding for this agent+tool | deny | Tool is not bound to agent |
| Policy matched with deny outcome | deny | Matched policy: <policy name> |
| No policy matched | default_deny | No matching policy found |
Evaluations#
GET /v1/evaluations#
List evaluation records. Results are ordered by evaluated_at descending (most recent first).
Query parameters
| Parameter | Type | Description |
|---|---|---|
agent_id | string | Filter by agent ID. |
tool_id | string | Filter by tool ID. |
outcome | string | Filter by outcome: allow, deny, approval_required, or default_deny. |
limit | integer | Number of records to return. Default: 50. |
offset | integer | Pagination offset. Default: 0. |
Response — 200 OK
{
"data": [
{
"id": "eval_01jv...",
"organisation_id": "org_01jv...",
"agent_id": "agent_01jv...",
"tool_id": "tool_01jv...",
"policy_id": "pol_01jv...",
"policy_name": "allow-support-agent-email",
"policy_priority": 10,
"policy_snapshot": {
"id": "pol_01jv...",
"name": "allow-support-agent-email",
"priority": 10,
"agent_selector": { "name": "customer-support-agent" },
"tool_selector": { "name": "send-email" },
"outcome": "allow",
"enabled": true
},
"action_payload": { "to": "user@example.com" },
"outcome": "allow",
"decision": "allow",
"evaluated_at": "2026-04-06T09:05:00.000Z",
"request_context": { "ticket_id": "T-1234" }
}
],
"total": 142
}
curl
curl "https://api.blacklake.systems/v1/evaluations?outcome=deny&limit=20" \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/evaluations/:id#
Retrieve a single evaluation by ID.
Response — 200 OK or 404 if not found.
curl
curl https://api.blacklake.systems/v1/evaluations/eval_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/evaluations/:id/results#
List execution or delivery result evidence attached to an evaluation. Use this to show what happened after the governance decision: whether the command, CI job, API call, or external workflow succeeded, where it ran, and what durable identifier another system assigned to it.
Response — 200 OK or 404 EVALUATION_NOT_FOUND
{
"data": [
{
"id": "ares_01jv...",
"organisation_id": "org_01jv...",
"evaluation_id": "eval_01jv...",
"status": "succeeded",
"external_system": "github-actions",
"external_id": "run_1234567890",
"external_url": "https://github.com/acme/app/actions/runs/1234567890",
"duration_ms": 18420,
"exit_code": 0,
"output_digest": "sha256:1f2d...",
"error": null,
"metadata": { "workflow": "deploy" },
"recorded_at": "2026-04-06T09:05:19.000Z"
}
]
}
curl
curl https://api.blacklake.systems/v1/evaluations/eval_01jv.../results \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/evaluations/:id/results#
Attach execution or delivery result evidence to an evaluation. The result record is intentionally compact: BlackLake stores status, external references, timing, exit/error details, and an optional digest or metadata object, not full command output.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | succeeded, failed, skipped, or unknown. |
external_system | string | No | System that executed or recorded the action, for example bash, github-actions, cloud-run, or jira. |
external_id | string | No | Durable ID assigned by the external system. |
external_url | string | No | URL to the run, job, issue, log, or receipt in the external system. |
duration_ms | integer | No | Execution duration in milliseconds. |
exit_code | integer | No | Process exit code when applicable. |
output_digest | string | No | Digest of output or artifact content, for example sha256:<hex>. |
error | string | No | Short error message when the result failed. |
metadata | object | No | Integration-specific structured metadata. |
Response — 201 Created
{
"id": "ares_01jv...",
"organisation_id": "org_01jv...",
"evaluation_id": "eval_01jv...",
"status": "failed",
"external_system": "bash",
"external_id": "local-shell-1723",
"external_url": null,
"duration_ms": 842,
"exit_code": 1,
"output_digest": "sha256:b4c9...",
"error": "permission denied",
"metadata": { "command": "gcloud run deploy" },
"recorded_at": "2026-04-06T09:05:19.000Z"
}
curl
curl -X POST https://api.blacklake.systems/v1/evaluations/eval_01jv.../results \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "succeeded",
"external_system": "github-actions",
"external_id": "run_1234567890",
"external_url": "https://github.com/acme/app/actions/runs/1234567890",
"duration_ms": 18420,
"exit_code": 0,
"output_digest": "sha256:1f2d...",
"metadata": { "workflow": "deploy" }
}'
Decision Receipts#
POST /v1/decisions/verify#
Verify that a (evaluation_id, decision_token) pair was issued by BlackLake. This is the audit-grade receipt path: a downstream agent, CLI wrapper, CI job, or human operator can quote a decision token, and another party can confirm the decision, matched policy snapshot, and any attached result evidence.
There is a UI for this in the console at /decisions/verify — paste an evaluation ID and a token, get back the verified decision, the policy snapshot at the time, and any attached execution evidence. The same endpoint backs both the API call here and the console page.
The endpoint returns 200 OK for valid and invalid tokens. Read the valid field in the body.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
evaluation_id | string | Yes | Evaluation ID returned by POST /v1/govern. |
decision_token | string | Yes | Signed decision token returned by POST /v1/govern. |
Valid response — authenticated owner of the evaluation
{
"valid": true,
"token_version": "v2",
"evaluation_id": "eval_01jv...",
"decision": "allow",
"agent_id": "agent_01jv...",
"tool_id": "tool_01jv...",
"policy_id": "pol_01jv...",
"policy_name": "allow-support-agent-email",
"policy_priority": 10,
"policy_snapshot": {
"id": "pol_01jv...",
"name": "allow-support-agent-email",
"priority": 10,
"agent_selector": { "name": "customer-support-agent" },
"tool_selector": { "name": "send-email" },
"outcome": "allow",
"enabled": true
},
"action_results": [
{
"id": "ares_01jv...",
"status": "succeeded",
"external_system": "github-actions",
"external_id": "run_1234567890",
"external_url": "https://github.com/acme/app/actions/runs/1234567890",
"duration_ms": 18420,
"exit_code": 0,
"output_digest": "sha256:1f2d...",
"error": null,
"metadata": { "workflow": "deploy" },
"recorded_at": "2026-04-06T09:05:19.000Z"
}
],
"evaluated_at": "2026-04-06T09:05:00.000Z",
"receipt_version": 2,
"cost_summary": {
"total_usd": 0.0142,
"input_tokens": 1200,
"output_tokens": 450,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"thinking_tokens": 0,
"pricing_version": "2026-05",
"record_count": 1
},
"cost_signed": "Cost is cryptographically bound to this decision via the v2 token."
}
token_version is "v1" or "v2" — matches which version was passed in. receipt_version is the numeric receipt version stored with the evaluation (independent of which token was verified — a v2-eligible receipt can still be verified with the original v1 token). cost_summary is null for receipts written before any cost was recorded. cost_signed is the human-readable confirmation that the v2 HMAC binds the cost figure; null for v1 verifications.
Valid response — anonymous / different-org caller (redacted)
Auditors verifying a quoted receipt don't need to belong to the workspace. Anonymous calls return only the fields the token cryptographically binds:
{
"valid": true,
"token_version": "v2",
"evaluation_id": "eval_01jv...",
"decision": "allow",
"evaluated_at": "2026-04-06T09:05:00.000Z",
"receipt_version": 2,
"cost_summary": { /* same shape as above */ },
"cost_signed": "Cost is cryptographically bound to this decision via the v2 token.",
"redacted": true,
"note": "Anonymous verification — only the fields signed by the decision token are returned. Authenticate with an x-api-key for the owning workspace to see the full view."
}
Invalid response
{
"valid": false,
"reason": "signature_mismatch",
"token_version": "v1"
}
token_version on the invalid response is the parsed prefix of the supplied token ("v1" or "v2") when one was recognisable; absent for invalid_token_format.
reason is one of:
| Value | Meaning |
|---|---|
invalid_token_format | Token doesn't start with bldt_v1: or bldt_v2:. Statically wrong before we touch the DB. |
malformed | Right prefix, but the body bytes don't decode as a valid token. |
evaluation_not_found | The token's evaluation_id refers to an evaluation that doesn't exist (or doesn't belong to this org). |
signature_mismatch | Token format is right; HMAC doesn't match what we'd compute over (evaluation_id, decision[, cost_summary]). |
Token versions:
| Prefix | Signed payload | When it's issued |
|---|---|---|
bldt_v1: | (evaluation_id, decision) | Every POST /v1/govern returns this — the durable receipt for the decision itself. |
bldt_v2: | (evaluation_id, decision, cost_summary) | Derived after one or more POST /v1/cost/record calls bind cost to the evaluation. Fetch via GET /v1/cost/by-evaluation/:id → decision_token_v2. |
A v1 token can be verified at any time. A v2 token requires the same cost_summary that was signed; we look it up by evaluation_id on verify, so callers only need to pass the evaluation_id + token pair.
curl
curl -X POST https://api.blacklake.systems/v1/decisions/verify \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"evaluation_id": "eval_01jv...",
"decision_token": "bldt_v1:..."
}'
The /v1/decisions/verify endpoint is also reachable anonymously (no x-api-key) — auditors verifying a quoted receipt don't need to belong to the workspace. Anonymous callers see a redacted view (only the fields the token cryptographically binds — decision, evaluated_at, cost_summary); authenticated callers from the owning workspace see the full view above.
Approvals#
GET /v1/approvals#
List approval records. Results are ordered by created_at descending.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: pending, approved, rejected, or expired. |
agent_id | string | Filter by agent ID. |
tool_id | string | Filter by tool ID. |
limit | integer | Number of records to return. Default: 50. |
offset | integer | Pagination offset. Default: 0. |
Response — 200 OK
{
"data": [
{
"id": "approval_01jv...",
"organisation_id": "org_01jv...",
"evaluation_id": "eval_01jv...",
"agent_id": "agent_01jv...",
"tool_id": "tool_01jv...",
"policy_id": "pol_01jv...",
"action_payload": { "amount": 4200, "vendor": "Acme Corp" },
"request_context": null,
"status": "pending",
"decided_by": null,
"decision_reason": null,
"decided_at": null,
"created_at": "2026-04-08T12:34:56.789Z",
"expires_at": "2026-04-09T12:34:56.789Z"
}
],
"total": 3
}
curl
curl "https://api.blacklake.systems/v1/approvals?status=pending" \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/approvals/:id#
Retrieve a single approval by ID.
Response — 200 OK or 404 APPROVAL_NOT_FOUND.
curl
curl https://api.blacklake.systems/v1/approvals/approval_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
GET /v1/approvals/:id/status#
Fetch only the status fields for an approval. Cheaper than a full fetch when polling.
Response — 200 OK
{
"status": "pending",
"decided_at": null,
"expires_at": "2026-04-09T12:34:56.789Z"
}
curl
curl https://api.blacklake.systems/v1/approvals/approval_01jv.../status \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/approvals/:id/approve#
Approve a pending approval.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decided_by | string | Yes | Identifier of the person or system making the decision. |
reason | string | Yes | Substantive explanation. 1–2000 characters. The audit ledger stores it verbatim. |
Response — 200 OK — the updated Approval object with status: "approved".
Errors
| Code | Status | Cause |
|---|---|---|
APPROVAL_NOT_FOUND | 404 | No approval with this ID in the organisation. |
APPROVAL_ALREADY_DECIDED | 422 | The approval was already approved or rejected. |
APPROVAL_EXPIRED | 422 | The approval has passed its 24-hour expiry. |
curl
curl -X POST https://api.blacklake.systems/v1/approvals/approval_01jv.../approve \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "decided_by": "ops-team", "reason": "Verified vendor and amount" }'
POST /v1/approvals/:id/break-glass#
Emergency override. Forces an approval through regardless of the policy's requires_two_person setting. Recorded with action: 'break-glass' on the decisions ledger and a top-level break_glass: true marker so the audit trail makes the override obvious.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decided_by | string | Yes | Audit identifier (email, handle, on-call rotation). |
reason | string | Yes | Substantive justification. Minimum 40 characters. |
Response — 200 OK returns the resolved Approval. The break_glass field is true, status is approved, and the decisions array contains the override entry.
The reason length minimum is the deliberate friction: an emergency override should leave a clear written record. If you find your team using break-glass routinely, the underlying policy probably needs adjusting.
Break-glass also bypasses the approver_roles check (see Approver roles), since the override is its own audit-flagged emergency path.
Approver roles#
A policy can list approver_roles — when set, an approve or reject requires the caller to hold at least one matching role on their workspace membership. This is enforced for session callers and user-scoped API keys (the SDK exposes user keys as bl_usr_…). Org-scoped API keys carry no user identity and therefore can't satisfy a role-gated policy — they receive 403 ROLE_REQUIRED immediately, regardless of who's holding the key. Break-glass overrides bypass the role check entirely; that's the audit-flagged emergency path.
A caller without a required role gets:
{
"error": {
"code": "ROLE_REQUIRED",
"message": "You do not hold a role required to decide this policy. Ask an admin to grant you one of the required approver roles."
}
}
with status 403. Roles are normalised to lowercase on write, deduplicated, and limited to alphanumeric plus - / _.
Manage member roles via PATCH /v1/organisation/members/:user_id/roles (admin only):
{ "roles": ["security", "platform"] }
Returns { "user_id": "...", "roles": [...] } on success.
POST /v1/approvals/:id/reject#
Reject a pending approval.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decided_by | string | Yes | Identifier of the person or system making the decision. |
reason | string | Yes | Substantive explanation. 1–2000 characters. Reviewers downstream rely on this to understand why a request was rejected. |
Response — 200 OK — the updated Approval object with status: "rejected".
Errors
| Code | Status | Cause |
|---|---|---|
APPROVAL_NOT_FOUND | 404 | No approval with this ID in the organisation. |
APPROVAL_ALREADY_DECIDED | 422 | The approval was already approved or rejected. |
APPROVAL_EXPIRED | 422 | The approval has passed its 24-hour expiry. |
curl
curl -X POST https://api.blacklake.systems/v1/approvals/approval_01jv.../reject \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "decided_by": "ops-team", "reason": "Amount exceeds policy limit" }'
Magic-link approval decisions#
These two endpoints are public — no x-api-key required. The signed token in the URL is the authentication. They power the email + push notification "Approve / Reject" flow that lands on the mobile-friendly /decide page in the console.
The token is single-use (used_at flips on first successful decision) and TTL-capped at min(24h, approval.expires_at - now).
GET /v1/decide/preview?token=<>#
Resolve a magic-link token and return enough context for the decide page to render the approval card. Does NOT mark the token used.
Response — 200 OK
{
"approval_id": "approval_01jv...",
"agent_name": "billing-operations-agent",
"agent_environment": "production",
"agent_risk": "high",
"tool_name": "stripe.refund",
"tool_risk": "critical",
"policy_name": "ask-before-refunds-over-50",
"requires_two_person": false,
"action_payload": { "customer_id": "cus_xyz", "amount_cents": 4200 },
"status": "pending",
"expires_at": "2026-05-03T10:00:00.000Z",
"organisation_name": "Acme Corp",
"session": {
"tool_client": "codex",
"tool_client_version": "0.7.2",
"user": "james",
"machine": "macbook-pro-2",
"repo": "github.com/acme/billing",
"branch": "main"
}
}
session is the structured block from approval.request_context.session if the original govern() caller followed the session-actor convention. Wider request_context is intentionally NOT exposed here — it could leak headers / IPs that aren't relevant to a phone approver.
Error responses
400 INVALID_TOKEN— token unknown or already used.400 ALREADY_USED— same as above with a friendlier label.410 EXPIRED— token TTL expired or approval window passed.
POST /v1/decide?token=<>#
Record an approve / reject decision via the magic-link token. Atomically marks the token used → calls the same decideApproval core as the authenticated route → returns the result.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
action | approve | reject | Yes | The decision. |
reason | string | No | Free text up to 2000 chars. Defaults to "Approved via <channel>" / "Rejected via <channel>". |
channel | email | push | console | api | No | Hint about how the link reached the user. Surfaces in decision_reason for an honest audit trail. |
Response — 200 OK
{
"decision": "approve",
"status": "approved",
"decided_by": "approver@acme.example",
"decided_at": "2026-05-02T17:42:11.000Z"
}
decided_by is set to the email of the user the token belongs to. For two-person approvals, the first decision returns status: "pending" (the approval is still waiting for a second distinct approver).
Error responses
400 INVALID_TOKEN/400 ALREADY_USED/410 EXPIRED— same as preview.409 ALREADY_DECIDED— approval was decided by another request between preview and decide. Body includes the currentstatus.
Webhooks#
Webhook payload format#
All webhook deliveries share a common envelope:
{
"id": "whd_01jv...",
"event": "approval.created",
"created_at": "2026-04-08T12:34:56.789Z",
"data": {
"approval": { }
}
}
The full event catalogue:
| Event | data contents |
|---|---|
approval.created | { "approval": <Approval> } — approval is pending. |
approval.approved | { "approval": <Approval> } — approval is approved. |
approval.rejected | { "approval": <Approval> } — approval is rejected. |
evaluation.created | { "evaluation_id", "decision", "agent_id", "tool_id", "evaluation_url" } — fires for every governed action (firehose). |
evaluation.denied | Same shape as evaluation.created, plus reason. Filtered subset for "page someone on every deny". |
evaluation.approval_required | Same shape as evaluation.created, plus approval_id + approval_url. Filtered subset for "notify reviewers when approval is needed". |
budget.threshold_crossed | { "budget", "threshold" ("soft_50" | "soft_80" | "soft_100"), "spend_usd", "soft_limit_usd", "hard_limit_usd", "period_key" } |
budget.limit_exceeded | Same as threshold_crossed with threshold = "hard_100". Routed separately so PagerDuty / paging endpoints can differentiate "approaching" from "over". |
cost.recorded | { "cost_record": <CostRecord> } — fires whenever a cost_records row is captured (proxy / SDK / MCP / CI / manual). |
Signature verification#
Every delivery includes four headers:
X-BlackLake-Signature: sha256=<hex-digest>
X-BlackLake-Timestamp: <unix-ms>
X-BlackLake-Event: approval.created
X-BlackLake-Delivery-Id: whd_01jv...
Signed content: HMAC-SHA256(raw_secret, timestamp + "." + rawBody)
Verification example (Node.js):
Use the built-in verifyWebhook from the blacklake package — it takes care of header lookup, constant-time comparison, and the sha256= prefix.
import { verifyWebhook } from 'blacklake';
// Throws BlackLakeError (code: WEBHOOK_SIGNATURE_INVALID) on mismatch.
await verifyWebhook({
secret: process.env.WEBHOOK_SECRET!,
rawBody, // raw bytes/string, BEFORE JSON.parse
headers: req.headers, // includes x-blacklake-signature + x-blacklake-timestamp
});
The full signed-content recipe is HMAC-SHA256(secret, timestamp + "." + rawBody) if you prefer to verify in a language without a BlackLake SDK.
Subscribable events: evaluation.created, evaluation.denied, evaluation.approval_required, approval.created, approval.approved, approval.rejected, budget.threshold_crossed, budget.limit_exceeded, cost.recorded, upstream.unhealthy, upstream.recovered. (Pass events: ['*'] to receive all.) The string evaluation.recorded appears only in audit NDJSON exports; it is not a webhook event name.
GET /v1/webhooks#
List all webhooks registered for the organisation. Returns the standard list envelope with ?limit=, ?offset=, ?sort=, ?order=.
Response — 200 OK
{
"data": [
{
"id": "wh_01jv...",
"organisation_id": "org_01jv...",
"url": "https://myapp.example/webhooks/blacklake",
"secret_suffix": "a3f9",
"events": ["approval.created", "approval.approved", "approval.rejected"],
"enabled": true,
"created_at": "2026-04-08T10:00:00.000Z"
}
],
"total": 3,
"limit": 50,
"offset": 0,
"sort": "created_at",
"order": "desc"
}
curl
curl https://api.blacklake.systems/v1/webhooks \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/webhooks#
Register a new webhook. The secret field in the response is the raw signing secret — shown once and cannot be retrieved again.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS URL to deliver events to. |
events | array | Yes | One or more of approval.created, approval.approved, approval.rejected, budget.threshold_crossed, budget.limit_exceeded, evaluation.created, evaluation.denied, evaluation.approval_required, cost.recorded. |
enabled | boolean | No | Default: true. |
Response — 201 Created
{
"id": "wh_01jv...",
"url": "https://myapp.example/webhooks/blacklake",
"secret": "whsec_8c17dd680f3059175a80a7c75b565cc0480c464a0d39e6d31674341173ed09f5",
"secret_suffix": "a3f9",
"events": ["approval.created", "approval.approved", "approval.rejected"],
"enabled": true,
"created_at": "2026-04-08T10:00:00.000Z",
"warning": "Save this secret — it will not be shown again."
}
curl
curl -X POST https://api.blacklake.systems/v1/webhooks \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://myapp.example/webhooks/blacklake",
"events": ["approval.created", "approval.approved", "approval.rejected"]
}'
GET /v1/webhooks/:id#
Retrieve a single webhook by ID. Does not return the signing secret.
Response — 200 OK or 404 WEBHOOK_NOT_FOUND.
curl
curl https://api.blacklake.systems/v1/webhooks/wh_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
PATCH /v1/webhooks/:id#
Update webhook fields. Only provided fields are modified.
Request body — all fields optional
| Field | Type | Description |
|---|---|---|
url | string | New receiver URL. |
events | array | Replacement event list. |
enabled | boolean | Enable or disable the webhook. |
Response — 200 OK with the updated Webhook object.
curl
curl -X PATCH https://api.blacklake.systems/v1/webhooks/wh_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'
DELETE /v1/webhooks/:id#
Permanently delete a webhook.
Response — 204 No Content
curl
curl -X DELETE https://api.blacklake.systems/v1/webhooks/wh_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/webhooks/:id/test#
Fire a synthetic delivery so you can confirm the receiver is wired correctly without having to trigger a real approval or budget alert. The body uses the special event webhook.test, signed with the same HMAC scheme as production deliveries — your verification code path runs end-to-end.
Response — 200 OK
{
"delivery_id": "whd_01jv...",
"status_code": 200,
"response_body": "ok",
"error": null,
"duration_ms": 142,
"delivered_at": "2026-05-02T10:00:00.000Z"
}
status_code is the HTTP status the receiver returned. error is non-null when the delivery never completed (network failure, TLS handshake error, 5-second timeout). The test delivery is also persisted to webhook_deliveries so it shows up in /v1/webhooks/:id/deliveries for audit.
Receivers should accept
webhook.testand respond200 OK— most webhook frameworks ignore unknown events, but if your receiver hard-fails on the event name you'll see astatus_code: 4xxhere. Filterevent === 'webhook.test'in your verification code if you want to ack-and-skip.
Returns 404 WEBHOOK_NOT_FOUND if the webhook does not exist or belongs to a different organisation.
GET /v1/webhooks/:id/deliveries#
List delivery attempts for a webhook. Results are ordered by delivered_at descending.
Query parameters
| Parameter | Type | Description |
|---|---|---|
limit | integer | Number of records to return. Default: 50. |
offset | integer | Pagination offset. Default: 0. |
Response — 200 OK
{
"data": [
{
"id": "whd_01jv...",
"webhook_id": "wh_01jv...",
"event": "approval.created",
"payload": { "id": "whd_01jv...", "event": "approval.created", "created_at": "...", "data": { } },
"status_code": 200,
"response_body": "ok",
"error": null,
"response_headers": { "content-type": "text/plain" },
"signed_timestamp_ms": "1777730637789",
"delivered_at": "2026-04-08T12:34:57.100Z",
"duration_ms": 312
}
],
"total": 14
}
response_headers and signed_timestamp_ms give the console Webhook delivery debugger enough state to render the exact HMAC base string a consumer needs to reproduce. Reproduce with HMAC-SHA256(secret, signed_timestamp_ms + "." + JSON.stringify(payload)) and compare to the X-BlackLake-Signature digest the upstream received.
curl
curl "https://api.blacklake.systems/v1/webhooks/wh_01jv.../deliveries?limit=10" \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/webhooks/:id/deliveries/:deliveryId/resend#
Re-fire one previously-attempted delivery. Re-uses the original payload but re-signs with the current secret and a fresh timestamp — rotated-secret cases work, and the receiver's replay-window check doesn't reject the redelivery as stale. Inserts a new webhook_deliveries row whose payload carries replay_of pointing back to the original.
Response — 200 OK
{ "delivery_id": "whd_01jw...", "replay_of": "whd_01jv..." }
POST /v1/webhooks/:id/deliveries/resend-failed#
Bulk replay of failed deliveries on this webhook. The query: error IS NOT NULL OR status_code IS NULL OR status_code >= 400. Capped at 100 to defend against accidentally re-firing thousands of stale rows. Each redelivery follows the same re-sign / fresh-timestamp rules as the single-delivery resend.
Response — 200 OK
{
"resent": 3,
"deliveries": [
{ "original_id": "whd_01jv...", "new_id": "whd_01jw..." },
{ "original_id": "whd_01jv...", "new_id": "whd_01jw..." },
{ "original_id": "whd_01jv...", "new_id": "whd_01jw..." }
]
}
Returns 422 NO_FAILED_DELIVERIES when there's nothing to resend.
Organisation#
GET /v1/organisation#
Return the current organisation. The organisation is derived from the API key.
Response — 200 OK
{
"id": "org_01jv...",
"name": "Acme Corp",
"contact_email": "ops@acme.example",
"created_at": "2026-04-06T09:00:00.000Z"
}
curl
curl https://api.blacklake.systems/v1/organisation \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/organisation/delete#
Soft-delete the organisation. All API keys for the workspace are immediately rejected by the auth middleware, and all agents, tools, policies, and other workspace data become inaccessible. The data is preserved for a 30-day grace window, during which the workspace can be recovered via POST /v1/organisation/restore using the same API key that was valid at the time of deletion. After the grace window lapses, a nightly process permanently removes the workspace and all its data.
The caller must pass the organisation's exact name as confirmation — any other value is rejected with 422 CONFIRMATION_MISMATCH.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
confirmation | string | yes | The organisation's exact name. |
reason | string | no | Optional free-text reason for leaving. Not stored permanently. |
Response — 200 OK
{
"deleted_at": "2026-04-28T10:00:00.000Z",
"restore_until": "2026-05-28T10:00:00.000Z",
"grace_days": 30,
"note": "Workspace marked for deletion. Restore via POST /v1/organisation/restore with your API key and \"Acme Corp\" as the confirmation, any time before 2026-05-28T10:00:00.000Z. After that the workspace and all its data are permanently removed."
}
curl
curl -X POST https://api.blacklake.systems/v1/organisation/delete \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "confirmation": "Acme Corp" }'
POST /v1/organisation/reset#
Wipe operational data for the workspace (agents, tools, bindings, policies, evaluations, approvals, MCP upstreams, webhooks, audit ingest events, cost logs) without deleting the workspace itself. Built so an admin can rerun cloud onboarding on a clean slate without restarting the 30-day soft-delete dance.
Preserved: the organisations row, users, API keys, memberships, sessions, push subscriptions, invitations, auth tokens, and the GitHub installation handle. Existing API keys and sessions continue working.
Wiped: every operational table, in FK-safe order. The recent-decision audit ledger goes with it — this is destructive and intentional.
Auth: admin role required. API keys count as admin-equivalent. The auth middleware refuses keys for soft-deleted workspaces, so the endpoint can't be invoked on a workspace already in the delete grace window.
Rate limits: 3 / hour per IP and 3 / hour per API key. Resets are rare; the limit makes runaway scripts noisy.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
confirmation | string | yes | The organisation's exact name. Same shape as /delete. |
reason | string | no | Optional free-text. Logged for audit. |
Unknown fields return 400 VALIDATION_ERROR — strict body schema.
Response — 200 OK
{
"reset_at": "2026-05-01T12:34:56.000Z",
"organisation_id": "org_01jv...",
"total_rows": 142,
"counts": {
"agents": 10,
"tools": 16,
"agent_tool_bindings": 22,
"policies": 4,
"policy_evaluations": 72,
"approvals": 3,
"action_results": 5,
"mcp_upstream_servers": 2,
"webhooks": 1,
"webhook_deliveries": 0,
"external_events": 7,
"github_events": 0,
"api_call_logs": 0,
"mcp_upstream_user_credentials": 0,
"mcp_oauth_authorization_states": 0,
"mcp_upstream_oauth_clients": 0
},
"preserved": [
"organisations",
"users",
"api_keys",
"org_memberships",
"sessions",
"push_subscriptions",
"invitations",
"auth_tokens",
"github_installations"
],
"note": "Operational data wiped; identity and integration handles preserved. API keys and sessions still work. To delete the workspace itself, use POST /v1/organisation/delete."
}
Errors
| Code | Status | Cause |
|---|---|---|
CONFIRMATION_MISMATCH | 422 | confirmation doesn't match the organisation name. |
VALIDATION_ERROR | 400 | Unknown body field, or non-string confirmation/reason. |
FORBIDDEN | 403 | Session caller without admin role. |
RATE_LIMITED | 429 | More than 3 reset attempts per hour from this IP or API key. |
curl
curl -X POST https://api.blacklake.systems/v1/organisation/reset \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "confirmation": "Acme Corp", "reason": "Re-testing onboarding flow" }'
POST /v1/organisation/restore#
Lift the soft-delete on an organisation within the 30-day grace window. Because the normal auth middleware refuses API keys for soft-deleted organisations, this endpoint accepts the API key in the request body rather than the header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
api_key | string | yes | A valid API key for the soft-deleted organisation. |
confirmation | string | yes | The organisation's exact name. |
Response — 200 OK
{
"restored": true,
"organisation": { "id": "org_01jv...", "name": "Acme Corp", "contact_email": "ops@acme.example" }
}
Errors
| Code | Status | Cause |
|---|---|---|
UNAUTHORIZED | 401 | API key is invalid or does not match any organisation. |
CONFIRMATION_MISMATCH | 422 | confirmation does not exactly match the organisation name. |
NOT_DELETED | 422 | The organisation is not currently soft-deleted. |
RESTORE_WINDOW_EXPIRED | 410 | The 30-day grace window has lapsed; the workspace cannot be recovered. |
curl
curl -X POST https://api.blacklake.systems/v1/organisation/restore \
-H "Content-Type: application/json" \
-d '{ "api_key": "bl_8c17dd680f...", "confirmation": "Acme Corp" }'
API Keys#
GET /v1/api-keys#
List all API keys for the current organisation. The raw key is never returned — only the last four characters as key_suffix for identification.
Returns the standard list envelope; honours ?limit=, ?offset=, ?sort=created_at|name|revoked_at, and ?order=asc|desc.
Response — 200 OK
{
"data": [
{
"id": "key_01jv...",
"name": "production",
"user_id": null,
"key_suffix": "af7d",
"created_at": "2026-04-06T09:00:00.000Z",
"revoked_at": null
}
],
"total": 1,
"limit": 50,
"offset": 0,
"sort": "created_at",
"order": "desc"
}
user_id is null for org-scoped keys created via POST /v1/api-keys and is populated with the creating user's ID for personal keys created via POST /v1/api-keys/user.
curl
curl https://api.blacklake.systems/v1/api-keys \
-H "x-api-key: $BLACKLAKE_API_KEY"
POST /v1/api-keys#
Generate a new API key. The raw bl_-prefixed key is returned once in the key field. Store it securely on receipt.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | A label for the key (1–100 chars). |
Response — 201 Created
{
"id": "key_01jv...",
"name": "production",
"key": "bl_8c17dd…",
"created_at": "2026-04-06T09:00:00.000Z",
"warning": "Save this key — it will not be shown again."
}
curl
curl -X POST https://api.blacklake.systems/v1/api-keys \
-H "x-api-key: $BLACKLAKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "production" }'
DELETE /v1/api-keys/:id#
Revoke an API key by ID. Sets revoked_at to the current timestamp; the row is preserved for audit purposes. Subsequent requests using the revoked key return 401 UNAUTHORIZED.
The API rejects attempts to revoke the key currently being used to make the request, returning 400 CANNOT_REVOKE_SELF. Idempotent: revoking an already-revoked key returns 204 without changes.
Response — 204 No Content
curl
curl -X DELETE https://api.blacklake.systems/v1/api-keys/key_01jv... \
-H "x-api-key: $BLACKLAKE_API_KEY"
Signup#
POST /v1/signup#
Create a new organisation and its initial API key. Public endpoint — does not require authentication. Rate-limited to 5 requests per hour per IP address.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
organisation_name | string | yes | Display name for the new organisation (2–100 chars). |
email | string | yes | Contact email (also used to detect duplicate organisations). |
accept_terms | true | yes | Must be the literal boolean true. |
turnstile_token | string | no | Cloudflare Turnstile token if Turnstile is enabled on the deployment. |
Response — 201 Created
{
"organisation": {
"id": "org_01jv...",
"name": "Acme Corp",
"contact_email": "ops@acme.example"
},
"api_key": "bl_8c17dd…",
"api_key_id": "key_01jv...",
"email_sent": true,
"warning": "This is the only time your API key will be shown. Save it now."
}
The api_key is shown once. A welcome email is sent to the contact address with the same key (best-effort — email_sent: false if delivery failed).
Errors
| Code | Status | Cause |
|---|---|---|
VALIDATION_ERROR | 400 | Missing or malformed fields. |
EMAIL_EXISTS | 409 | Another organisation already uses this email. |
CAPTCHA_FAILED | 400 | Turnstile token verification failed. |
RATE_LIMITED | 429 | More than 5 signup attempts from this IP in the last hour. |
curl
curl -X POST https://api.blacklake.systems/v1/signup \
-H "Content-Type: application/json" \
-d '{
"organisation_name": "Acme Corp",
"email": "ops@acme.example",
"accept_terms": true
}'
GitHub Action#
blacklake-systems/control-plane/packages/govern-action is a wrapper around POST /v1/govern and POST /v1/evaluations/:id/results. It is not a separate API surface — it is documented here because it is the primary integration point for governed CI.
The action gates a workflow step behind a policy decision and records the job outcome as evidence on the same evaluation when the step completes.
Inputs
| Name | Default | Description |
|---|---|---|
api-key | (required) | BlackLake API key — store as a GitHub secret. |
agent | (required) | Agent name registered in BlackLake. |
tool | (required) | Tool name registered in BlackLake. |
api-url | https://api.blacklake.systems | API base URL. |
console-url | https://console.blacklake.systems | Console base URL — used to build receipt links. |
action-payload | {} | JSON object describing the action. |
context | {} | JSON merged with auto-collected GitHub context. |
wait-for-approval | true | When the decision is approval_required, block until decided. |
approval-timeout-seconds | 600 | Max wait time when blocking on approval. |
fail-on-deny | true | Fail the step when the decision is deny / default_deny / rejected / expired. |
record-result | true | Record job outcome as evidence after the step completes. |
Outputs
| Name | Description |
|---|---|
decision | Final decision: allow / deny / default_deny / approval_required / approved / rejected / expired. |
evaluation-id | The BlackLake evaluation ID for this step. |
decision-token | HMAC receipt binding (evaluation_id, decision). |
approval-id | Set when the decision was approval_required. |
receipt-url | Console URL for this evaluation. |
Auto-collected context
The action augments the context object with the following GitHub fields, which appear on the evaluation's request_context:
{
"github_run_id": "...",
"github_run_attempt": "...",
"github_run_url": "https://github.com/acme/app/actions/runs/...",
"github_repository": "acme/app",
"github_workflow": "Deploy",
"github_actor": "alice",
"github_sha": "...",
"github_ref": "refs/heads/main",
"github_event_name": "push",
"github_job": "deploy"
}
Workflow example
- uses: blacklake-systems/control-plane/packages/govern-action@main
with:
api-key: ${{ secrets.BLACKLAKE_API_KEY }}
agent: github-actions
tool: gcloud.run.deploy
action-payload: '{"environment":"production","service":"control-plane-api"}'
- name: Deploy to Cloud Run
run: ./deploy.sh
When the gated step finishes — success or failure — a post-run hook records an action result evidence row on the evaluation, including the GitHub run URL and the job status. POST /v1/decisions/verify will then return both the policy snapshot at the time of decision and the actual deploy outcome.
Billing — Stripe checkout, portal, plan + usage#
GET /v1/billing returns the workspace's current tier, subscription metadata, and the in-period usage meter. Free tier is the default — orgs that have never gone through Checkout still get a populated envelope (no null-branching required in the console).
{
"tier": "free",
"status": "no_subscription",
"quantity": 0,
"stripe_customer_id": null,
"stripe_subscription_id": null,
"stripe_price_id": null,
"current_period_start": null,
"current_period_end": null,
"cancel_at_period_end": false,
"canceled_at": null,
"trial_end": null,
"usage": {
"used": 91,
"limit": 10000,
"remaining": 9909,
"period_key": "2026-05"
}
}
stripe_customer_id populates as soon as the org has a Stripe customer on file — either created during checkout, or attached by the webhook on the first subscription event. The same value is echoed by GET /v1/organisation.stripe_customer_id.
usage.limit is null for unconstrained tiers (Team, Enterprise) — the console renders "Unlimited" rather than computing a percentage against infinity.
POST /v1/billing/checkout#
Start a Stripe Checkout flow for the Team tier. Returns a hosted Stripe URL the caller redirects to.
Body fields:
| Field | Type | Default |
|---|---|---|
plan | 'team' | 'team' (only self-serve tier today; Production/Enterprise are sales-led) |
quantity | int 1-1000 | 1 |
success_url | URL | console workspace home |
cancel_url | URL | console pricing page |
Returns { checkout_url, session_id }. The checkout_url resolves to a real https://checkout.stripe.com/c/pay/cs_… URL.
POST /v1/billing/portal#
Mints a Stripe Customer Portal session URL so the workspace admin can update payment method, change seat count, view invoices, or cancel. Body: { return_url?: string }. Returns { portal_url, session_id }.
Requires that the org already has a stripe_customer_id. If not, returns 404 NO_CUSTOMER — direct the user to checkout first.
POST /v1/billing/stripe-webhook and POST /integrations/stripe/webhook#
Both URLs receive Stripe webhook events. The canonical Stripe Dashboard webhook endpoint is /integrations/stripe/webhook; the /v1/billing/stripe-webhook alias is provided for tooling that scans /v1/* for receivers. Both routes are unauthenticated for the workspace key — Stripe-Signature HMAC is the only authentication.
Bad signature → HTTP 400 SIGNATURE_INVALID with a request_id, matching the /integrations/github/webhook contract.
Manifest — bulk apply#
POST /v1/manifest/apply accepts a desired-state document and either dry-runs the diff or actually creates/updates the entities. Idempotent: applying the same manifest twice produces no changes the second time.
Top-level body:
{
"mode": "dry_run" | "apply",
"agents": [ { "name": "...", "environment": "...", "risk_classification": "..." }, ... ],
"tools": [ { "name": "...", "risk_classification": "..." }, ... ],
"policies": [ { "name": "...", "priority": 1500, "outcome": "deny", ... }, ... ],
"bindings": [ { "agent_name": "...", "tool_name": "..." }, ... ],
"webhooks": [ { "url": "https://...", "events": ["evaluation.created"] }, ... ]
}
Mode can also be passed via query string: POST /v1/manifest/apply?dry_run=true is honoured the same as body: { mode: "dry_run" }. The query string wins when set explicitly; if absent, the body's mode field decides.
Returns { mode, counts: { created, updated, unchanged, skipped }, changes: [...] }. The changes array carries per-entity diffs so the console can render a preview.
Demo promote#
POST /v1/demo/promote graduates the workspace's demo agents/tools/policies into the working workspace in-place. Returns { status: "promoted", agents_promoted, tools_promoted, policies_renamed, rename }.
POST /v1/demo/promote-to-workspace clones the demo state into a different workspace. Body: { target_org_id: string, ... }. The caller must hold a session that has admin membership in target_org_id.
GET /v1/me vs GET /v1/users/me#
Two parallel identity endpoints, intentionally separate:
GET /v1/me works for both API key and session authentication. Returns the canonical "who am I right now" envelope:
{
"auth_mode": "api_key" | "session",
"organisation": { "id": "...", "name": "..." },
"session": null | { ... },
"api_key": null | { "id": "...", "name": "...", "key_suffix": "..." }
}
GET /v1/users/me is session-only and narrower in scope. Returns user-account state including memberships across organisations:
{
"user": { ... },
"memberships": [{ "organisation_id", "organisation_name", "role", "joined_at" }, ...],
"active_organisation_id": "..."
}
API-key callers hitting /v1/users/me get HTTP 403 SESSION_REQUIRED. Use /v1/me if you need an auth-mode-agnostic identity probe.