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_idin 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
| Route | Status | Purpose |
|---|---|---|
POST /v1/permits | Canonical public | Evaluate a permit request and create the permit record. |
GET /v1/permits | Official public | List permit audit records. |
GET /v1/permits/{permit_id} | Official public | Fetch one permit audit record. |
GET /v1/permits/export | Official public | Export a project audit bundle. |
POST /v1/permits/{permit_id}/usage | Official public | Report final verified usage for permit-first callers. |
POST /v1/projects/{project_id}/permits | Compatibility | Older 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
| Code | Meaning |
|---|---|
200 | Permit created. Both allow and deny decisions return 200 — branch on decision, not status code. |
400 | Malformed payload or validation failure (invalid subject, action, or resource structure). |
401 | Missing or invalid API key. |
403 | API key scope insufficient, or project_id in the body does not match the key’s project. |
409 | Idempotency key reused with a different request payload. |
500 | Unhandled 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 headerIdempotency-Keyinstead. 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/permitsstill requiresproject_idin 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
| Field | Required? | Current meaning |
|---|---|---|
project_id | Yes | Project that owns the permit request and resulting permit record. You can find your project_id in the Keel dashboard under Project Settings. |
idempotency_key | No | Client-supplied replay key for safe retries. |
subject | Yes | Actor the decision applies to. |
action | Yes | Requested governed action. |
resource | Yes | Request-shaped resource and execution metadata under evaluation. See resource fields below. |
context | No | Optional request context such as timestamp, IP, or user agent. This public top-level field is permit-only. |
resource fields
| Field | Required? | Current meaning |
|---|---|---|
resource.type | Yes | Caller-supplied label for the resource kind. Not validated against an enum — any non-empty string is accepted. Common values: "request", "ai.chat.completion". |
resource.id | Yes | Caller-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.attributes | Yes | Execution metadata. See fields below. |
resource.attributes fields
| Field | Required? | Current meaning |
|---|---|---|
provider, model | Yes | Requested provider and model identity. |
operation | Yes | Canonical governed operation such as generate.text, generate.image, or transcribe.audio. |
modality | No | Execution context label used by routing, capability metadata, and audit records. |
execution_mode | No | sync, async, or realtime. |
estimated_input_tokens, estimated_output_tokens | No | Current permit-time estimate fields. |
max_output_tokens_requested | No | Requested upper bound for generated output. |
inputs | No | Declared inputs or assets. |
asset_summary | No | Optional asset metadata summary. |
routing | No | Optional routing hints. On POST /v1/permits these are recorded, not executed. |
callback_url | No | Optional callback target relevant to async execution. |
routing and context on permits
- On permits,
resource.attributes.routingis governance intent and recorded decision context. It can influence policy evaluation and audit records, butPOST /v1/permitsdoes not dispatch provider work or execute fallback. contextexists on permits as a public top-level request field. Execution routes do not expose a public top-levelcontextfield.
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
providerormodel, 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/verifyThe 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 identifierdecision—"allow"or"deny"actions— array of action objects your application should processmetadata.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; usereason_codeinstead.
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.idIf 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 ?? 0Step 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.