Skip to Content
Permits

Permits

Use this API when you want Keel to evaluate a request, return a decision record, and let your application decide what to do next.

Permit creation requires project_id in the body.

When to use permits vs execution routes

  • You control the provider call — use POST /v1/permits. Keel evaluates policy and returns a decision. Your app calls the provider and reports usage back.
  • You want Keel to call the provider with a portable, provider-neutral request — use POST /v1/executions.
  • You have provider-shaped payloads but want normalized output — use POST /v1/execute.
  • You want provider-native input and output — use POST /v1/proxy/*.

All execution routes apply the same governance boundary before provider dispatch. The difference is whether you or Keel makes the provider call.

Route summary

RouteStatusPurpose
POST /v1/permitsCanonical publicEvaluate a permit request and create the permit record.
GET /v1/permitsOfficial publicList permit audit records.
GET /v1/permits/{permit_id}Official publicFetch one permit audit record.
GET /v1/permits/exportOfficial publicExport a project audit bundle.
POST /v1/permits/{permit_id}/usageOfficial publicReport final verified usage for permit-first callers.
POST /v1/projects/{project_id}/permitsCompatibilityOlder path-bound permit create flow.

Authentication

Authorization: Bearer <project_api_key>

X-API-Key remains accepted for compatibility, but Bearer auth is the documented contract.

POST /v1/permits/{permit_id}/usage is narrower than the rest of the permit surface:

  • it requires an admin-scope project API key
  • public closeout requires verification material that proves execution (a provider receipt or a signed callback)

HTTP status codes

CodeMeaning
200Permit created. Both allow and deny decisions return 200 — branch on decision, not status code.
400Malformed payload or validation failure (invalid subject, action, or resource structure).
401Missing or invalid API key.
403API key scope insufficient, or project_id in the body does not match the key’s project.
409Idempotency key reused with a different request payload.
500Unhandled server error.

Idempotency

POST /v1/permits uses the body field idempotency_key (not an HTTP header).

Execution routes (/v1/executions, /v1/execute, /v1/proxy/*) use the HTTP header Idempotency-Key instead. Do not mix the two — body keys are ignored on execution routes, and headers are ignored on permits.

  • same key plus the same semantic permit payload replays the prior permit
  • same key plus a different semantic payload returns 409 conflict
  • if the client omits the field, Keel generates a server-side idempotency key

That default makes onboarding ergonomic, but retries without a client-supplied key do not get a stable client-controlled replay contract.

Permit replay is also stateful: active or terminal permit states replay, while stale allow permits with inactive reservations can revalidate or return expired state instead of replaying the original allow body.

Request shape

The bearer token is project-scoped, but POST /v1/permits still requires project_id in the request body so the decision is attached to the correct project record.

Start here. Fields marked optional can be omitted for a first call — add them when your policy needs estimates, routing hints, or request context.

{ "project_id": "<project_uuid>", "idempotency_key": "permit-demo-001", "subject": { "type": "user", "id": "usr_123" }, "action": { "name": "ai.generate.summary" }, "resource": { "type": "request", "id": "req_123", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "modality": "text", // optional "execution_mode": "sync", // optional "estimated_input_tokens": 200, // optional "estimated_output_tokens": 250, // optional "max_output_tokens_requested": 300, // optional "routing": {} // optional — routing hints } }, "context": { // optional, permit-only "timestamp": "2026-03-09T00:00:00Z", "ip": "127.0.0.1", "user_agent": "curl" } }

Top-level fields

FieldRequired?Current meaning
project_idYesProject that owns the permit request and resulting permit record. You can find your project_id in the Keel dashboard under Project Settings.
idempotency_keyNoClient-supplied replay key for safe retries.
subjectYesActor the decision applies to.
actionYesRequested governed action.
resourceYesRequest-shaped resource and execution metadata under evaluation. See resource fields below.
contextNoOptional request context such as timestamp, IP, or user agent. This public top-level field is permit-only.

resource fields

FieldRequired?Current meaning
resource.typeYesCaller-supplied label for the resource kind. Not validated against an enum — any non-empty string is accepted. Common values: "request", "ai.chat.completion".
resource.idYesCaller-generated identifier for this resource instance. Not validated beyond being a non-empty string — no UUID or format requirement. Use this to correlate the permit with your internal request tracking.
resource.attributesYesExecution metadata. See fields below.

resource.attributes fields

FieldRequired?Current meaning
provider, modelYesRequested provider and model identity.
operationYesCanonical governed operation such as generate.text, generate.image, or transcribe.audio.
modalityNoExecution context label used by routing, capability metadata, and audit records.
execution_modeNosync, async, or realtime.
estimated_input_tokens, estimated_output_tokensNoCurrent permit-time estimate fields.
max_output_tokens_requestedNoRequested upper bound for generated output.
inputsNoDeclared inputs or assets.
asset_summaryNoOptional asset metadata summary.
routingNoOptional routing hints. On POST /v1/permits these are recorded, not executed.
callback_urlNoOptional callback target relevant to async execution.

routing and context on permits

  • On permits, resource.attributes.routing is governance intent and recorded decision context. It can influence policy evaluation and audit records, but POST /v1/permits does not dispatch provider work or execute fallback.
  • context exists on permits as a public top-level request field. Execution routes do not expose a public top-level context field.

Compatibility note:

  • canonical docs prefer resource.attributes.*
  • new integrations should use the documented resource.attributes.* fields rather than relying on older compatibility shapes

What POST /v1/permits does not do

  • it does not execute provider work
  • it does not proxy the provider request on your behalf
  • it does not by itself prove later provider execution
  • it does not replace POST /v1/executions, POST /v1/execute, or the provider-specific proxy routes

API example

JavaScript

const response = await fetch('https://api.keelapi.com/v1/permits', { method: 'POST', headers: { Authorization: 'Bearer keel_sk_your_key_here', 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: '<project_uuid>', idempotency_key: 'permit-demo-001', subject: { type: 'user', id: 'usr_123' }, action: { name: 'ai.generate.summary' }, resource: { type: 'request', id: 'req_123', attributes: { provider: 'openai', model: 'gpt-4o-mini', operation: 'generate.text', modality: 'text', execution_mode: 'sync', estimated_input_tokens: 200, estimated_output_tokens: 250, max_output_tokens_requested: 300 } } }) }) const data = await response.json() console.log(data)

Python

import requests response = requests.post( "https://api.keelapi.com/v1/permits", headers={ "Authorization": "Bearer keel_sk_your_key_here", "Content-Type": "application/json", }, json={ "project_id": "<project_uuid>", "idempotency_key": "permit-demo-001", "subject": { "type": "user", "id": "usr_123", }, "action": { "name": "ai.generate.summary", }, "resource": { "type": "request", "id": "req_123", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "modality": "text", "execution_mode": "sync", "estimated_input_tokens": 200, "estimated_output_tokens": 250, "max_output_tokens_requested": 300, }, }, }, ) print(response.json())

Response examples

Both allow and deny return HTTP 200. Permit denials are decisions, not errors. Branch on decision, not on HTTP status code. This differs from execution routes, which return HTTP 403 for denials.

Allow response (HTTP 200)

{ "id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z", "decision": "allow", "actions": [ { "type": "allow", "message": "Allowed by base policy." } ], "metadata": { "evaluated_at": "2026-03-26T18:42:11Z" } }

Allow decisions do not carry reason_code.

Deny response (HTTP 200)

{ "id": "permit_01jyf4p6c3m7n9q2t5v8w1x4y", "decision": "deny", "reason_code": "policy.model_not_allowed", "reason_detail": { "category": "policy", "kind": "model_not_allowed", "outcome": "deny" }, "message": "The requested model is not allowed for this project.", "actions": [ { "type": "deny", "message": "The requested model is not allowed for this project." } ], "metadata": { "evaluated_at": "2026-03-26T18:43:02Z" } }

A deny with a budget code looks the same but carries richer reason_detail. For example, a budget.daily_cap_exceeded deny includes cap_usd_micros, current_spend_usd_micros, and projected_spend_usd_micros inside reason_detail.

actions array

The actions array tells your application what to do next. Iterate it — do not ignore it.

Each action object has type (string) and message (string). Both fields are always present. A single response can contain multiple actions.

The primary action types are allow and deny. Additional action types may appear — always check the type field and handle unknown types gracefully.

Report usage

After an allowed permit completes, close it out on the permit record:

POST /v1/permits/{permit_id}/usage

This route is intentionally narrower than permit creation:

  • it requires an admin-scope project API key
  • completed public closeout requires verification material that proves execution (a provider receipt or a signed callback)
  • positive billed cost is still required for completed public closeout
  • if you send provider or model, they must match the issued permit

For the broader permit-first observation boundary, see Scope and Limits § Permit-first observation boundary.

curl -sS -X POST https://api.keelapi.com/v1/permits/permit_01jyf4m3n8q2r6t9v1w5x7y0z/usage \ -H "Authorization: Bearer keel_sk_admin_project_key_here" \ -H "Content-Type: application/json" \ -d '{ "provider": "openai", "model": "gpt-4o-mini", "actual_input_tokens": 182, "actual_output_tokens": 247, "actual_total_tokens": 429, "cost_usd_micros": 820, "usage_idempotency_key": "usage-demo-001", "verification": { "method": "provider_receipt", "provider_request_id": "req_123", "receipt_json": { "request_id": "req_123" } } }'
{ "permit_id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z", "project_id": "<project_uuid>", "usage_reported_at": "2026-04-10T18:42:11Z", "actual_input_tokens": 182, "actual_output_tokens": 247, "actual_total_tokens": 429, "actual_cost_usd_micros": 820, "usage_source": "caller_report", "usage_verification": { "method": "provider_receipt", "status": "pending", "updated_at": "2026-04-10T18:42:11Z" }, "status": "completed" }

Permit-first closeout truth

  • permit-first closeout is still not execution-bound
  • Keel does not directly observe the provider call on this path
  • public verification material narrows the trust gap, but it does not turn permit-first closeout into direct execution proof
  • when an allowed permit is never closed out, Keel can mark it as missing usage report
  • a later valid closeout can still transition that permit to completed, but billing-period request counting remains anchored to permit issuance

Verification track

After a permit-first request closes out via POST /v1/permits/{permit_id}/usage, callers can attach stronger receipt evidence — provider receipt material or signed-callback material — through:

POST /v1/permits/{permit_id}/usage/verify

The verification track records receipt evidence separately from permit completion state and progresses through explicit verification states (unverified, pending, verified, rejected, reconciled). Multiple verification calls can refine the evidence over time.

The full verification surface — methods, state semantics, the accounting_disposition interaction, and a worked example — is documented as part of the Reconciliation pillar.

Response shape

Permit responses always include:

  • id — the permit record identifier
  • decision"allow" or "deny"
  • actions — array of action objects your application should process
  • metadata.evaluated_at — evaluation timestamp

Deny decisions additionally carry:

  • reason_code — machine-readable reason code (see Errors › Permit reason codes). Filter on this in error handlers and audit queries.
  • reason_detail — structured evidence: category, kind, outcome, and dimension-specific fields such as spend amounts, cap values, or rate counters.
  • message — human-readable explanation. Do not parse this field programmatically; use reason_code instead.

Allow decisions do not carry reason_code.

Additional fields may appear depending on the decision outcome. Treat any fields not listed above as optional.

Constraints

Some permits can carry constraint details, such as an output-token limit.

Public boundary:

  • on Keel-managed execution routes, supported constraints are enforced before dispatch
  • on permit-first flows, your application is responsible for honoring any constraints carried by the permit decision
  • treat constraint fields as decision output, not as a promise about internal enforcement mechanics

Budget snapshot

When budget caps or guardrails are configured, permits can carry an optional budget snapshot. Each section is optional and appears only when the relevant cap or guardrail is active. All monetary values are in usd_micros. Example:

{ "request": { "estimated_cost": 120000, "cap": 150000, "remaining": 30000 }, "daily": { "current_spend": 2200000, "projected_spend": 2320000, "cap": 3000000, "remaining": 800000 }, "monthly": { "current_spend": 7800000, "projected_spend": 7920000, "cap": 10000000, "remaining": 2080000 } }

End-to-end permit flow

Use this when your app controls the provider call and wants decision-first governance.

Step 1 — Create the permit

curl -sS -X POST https://api.keelapi.com/v1/permits \ -H "Authorization: Bearer keel_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "project_id": "<project_uuid>", "idempotency_key": "flow-001", "subject": {"type": "user", "id": "usr_123"}, "action": {"name": "ai.generate.summary"}, "resource": { "type": "request", "id": "req_flow_001", "attributes": { "provider": "openai", "model": "gpt-4o-mini", "operation": "generate.text", "estimated_input_tokens": 120, "estimated_output_tokens": 80, "max_output_tokens_requested": 120 } } }'

Allow response (HTTP 200):

{ "id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z", "decision": "allow", "actions": [ {"type": "allow", "message": "Allowed by base policy."} ] }

Step 2 — Check the decision

const permit = await response.json() if (permit.decision !== 'allow') { throw new Error(`blocked: ${permit.reason_code}`) } const permitId = permit.id

If decision is "deny", do not call the provider. Read reason_code for the machine-readable denial code and message for a human-readable explanation. Always iterate the actions array and handle each action type your integration supports.

Step 3 — Call the provider directly

Your app uses its own provider credential for this step. Keel does not proxy the call in permit-only mode.

const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY!}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Summarize this document in one sentence.' }], max_tokens: 120 }) }) const completion = await openaiResponse.json() const inputTokens = completion.usage?.prompt_tokens ?? 0 const outputTokens = completion.usage?.completion_tokens ?? 0

Step 4 — Report usage

Close the permit with caller-reported actual usage and verification material.

await fetch(`https://api.keelapi.com/v1/permits/${permitId}/usage`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.KEEL_ADMIN_API_KEY!}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'openai', model: 'gpt-4o-mini', actual_input_tokens: inputTokens, actual_output_tokens: outputTokens, actual_total_tokens: inputTokens + outputTokens, cost_usd_micros: 820, usage_idempotency_key: 'usage-demo-001', verification: { method: 'provider_receipt', provider_request_id: completion.id, receipt_json: { request_id: completion.id } } }) })

Response:

{ "permit_id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z", "status": "completed", "usage_verification": { "method": "provider_receipt", "status": "pending" } }

If you skip usage reporting, the permit remains a decision record only. Keel can later mark its accounting disposition as missing_usage_report, and exact downstream tokens and cost can remain unknown.

Last updated on Edit this page on GitHub