> ## Documentation Index
> Fetch the complete documentation index at: https://docs.airctrl.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth protection API

> Request and response contracts for provisioning and managing independent OAuth protections.

An OAuth connection belongs to one organization. A **protection** belongs to one external provider key
inside that connection. Two external keys create two `protectionId` values, credentials, gateways, and
runtime tokens.

```text theme={}
OAuth connection
├── protectionId A → external key A → credential A → gateway A → token A
└── protectionId B → external key B → credential B → gateway B → token B
```

The OAuth access token identifies the parent connection. Every child operation accepts only
`protectionId`; it never accepts the parent OAuth connection ID, gateway ID, credential ID, or token ID.
An ID from another connection returns `404` without revealing whether it exists.

## Authentication and scopes

Send the OAuth access token as `Authorization: Bearer`. Required scopes:

* `protections:provision` — provider discovery, preflight, model discovery, and creation.
* `protections:read` — list and read protections.
* `protections:update` — gateway name, models, settings, and entitled OTel configuration.
* `protections:manage` — revoke, reactivate, and rotate provider/runtime credentials.
* `protections:read-logs` — request metadata for runtime tokens owned by this installation.
* `protections:read-spend` — token usage and monetary spend for this installation.
* `offline_access` — rotating refresh tokens.

Private and public Apps use these same protection operations and scopes. The OAuth token's installation
binding—not the App distribution—selects the organization and resources that each request can access.

## Resolve providers

```http theme={}
GET /v1/oauth/protections/providers
Authorization: Bearer {access-token}
```

```json theme={}
{
  "ok": true,
  "data": [
    {
      "provider_id": "11111111-1111-4111-8111-111111111111",
      "slug": "example-ai",
      "display_name": "Example AI",
      "kind": "openai-compatible",
      "native_base_path": "/v1",
      "base_url_default": "https://api.provider.example/v1",
      "is_enabled": true
    }
  ]
}
```

Resolve by `slug`; never hard-code the UUID.

`kind`, `native_base_path`, and `base_url_default` describe the provider and its upstream API. They do
not form an AIRCTRL `/gw/...` URL. Build data-plane URLs from the returned gateway slug with
`buildGatewayConnectionConfig()` or use the connection paths documented in
[Connect an AI client](https://docs.airctrl.dev/guides/gateways.md#connect-an-ai-client).
`kind` is one of `openai-compatible`, `anthropic`, `gemini`, or `arc`.

## Preflight

Preflight performs no writes and receives no provider key.

```http theme={}
GET /v1/oauth/protections/preflight?providerId={providerId}&otel=false
Authorization: Bearer {access-token}
```

```json theme={}
{
  "ok": true,
  "data": {
    "allowed": true,
    "blockers": [],
    "resources": {
      "project": 1,
      "serviceAccount": 1,
      "providerCredential": 1,
      "gateway": 1,
      "gatewayToken": 1
    },
    "quotas": {
      "project": { "limit": 10, "current": 1, "reached": false },
      "gateway": { "limit": 10, "current": 2, "reached": false },
      "providerKey": { "limit": 20, "current": 2, "reached": false }
    },
    "project": null
  }
}
```

After first-use provisioning, `project` and `serviceAccount` in `resources` become `0`, and `project`
contains the existing installation project.

## Discover models

```http theme={}
POST /v1/oauth/protections/providers/{providerId}/models
Authorization: Bearer {access-token}
Content-Type: application/json
```

```json theme={}
{
  "apiKey": "read from the partner backend secret manager"
}
```

```json theme={}
{
  "ok": true,
  "data": {
    "invalidApiKey": false,
    "models": [
      {
        "id": "model-standard",
        "name": "Standard Model",
        "canonicalId": "model-standard",
        "aliases": ["model-standard-latest"],
        "modes": ["chat"],
        "capabilities": ["chat", "streaming"],
        "supportedCapabilities": ["chat", "streaming"],
        "airctrlSupport": "supported",
        "lifecycle": {
          "status": "scheduled_shutdown",
          "shutdownDate": "2027-06-30"
        },
        "pricing": {
          "billingUnit": "per_1m_tokens",
          "inputPerMtok": 1,
          "outputPerMtok": 2,
          "cacheReadPerMtok": null,
          "cacheWritePerMtok": null,
          "unitPrice": null,
          "currency": "USD",
          "sourceName": "provider-public-catalog",
          "sourceGeneratedAt": "2026-10-26T12:00:00.000Z",
          "effectiveFrom": "2026-10-26T12:00:00.000Z"
        }
      }
    ]
  }
}
```

This operation validates but does not persist the key.

`canonicalId`, `aliases`, `modes`, `lifecycle`, and `pricing` are optional because upstream catalogs do
not all publish them. `capabilities` describes the provider claim;
`supportedCapabilities` and `airctrlSupport` describe what AIRCTRL can safely route.

Capability values are `chat`, `vision`, `tools`, `streaming`, `responses`, `embeddings`,
`image_generation`, `audio`, `realtime`, `moderation`, `ocr`, `completions`, `code_completion`, `video`,
`native_completion`, `code_generation`, `generative_ui`, `research`, `document_generation`, and
`browser`.

An upstream `401` or `403` is a typed, non-exceptional invalid-key result:

```json theme={}
{
  "ok": true,
  "data": {
    "invalidApiKey": true,
    "models": []
  }
}
```

## Create one protection

Use one stable idempotency key for one external key and request body. Reuse both unchanged after an
uncertain network result.

```http theme={}
POST /v1/oauth/protections
Authorization: Bearer {access-token}
Idempotency-Key: {stable-operation-id}
Content-Type: application/json
```

```json theme={}
{
  "externalResourceId": "partner-key-support",
  "providerId": "11111111-1111-4111-8111-111111111111",
  "providerKey": "read from the partner backend secret manager",
  "credentialLabel": "Support AI key",
  "gatewayName": "Support AI",
  "models": ["model-standard"],
  "settings": {
    "spendLimit": 100,
    "spendWindow": "monthly",
    "rateLimitPerMin": 240,
    "loggingEnabled": true,
    "logBodies": false,
    "guardrailMode": "redact"
  },
  "otel": {
    "enabled": false,
    "endpoint": "https://otel.example.com",
    "exportMetrics": true,
    "exportTraces": true,
    "exportBodies": false,
    "headers": {}
  }
}
```

Input validation follows the API contract:

| Field | Constraint |
| - | - |
| `Idempotency-Key` | Required; 16–200 characters. |
| `externalResourceId` | Required stable non-secret ID; 1–255 characters. |
| `providerId` | Enabled provider UUID returned by provider discovery. |
| `providerKey` | Required server-side secret; 1–2,000 characters. |
| `credentialLabel` | Optional; 1–100 characters when present. |
| `gatewayName` | Required; 3–100 characters. |
| `models` | At least one unique model ID; each ID is 1–200 characters. |
| `settings.spendLimit` | `null` or a non-negative number with at most two decimal places. |
| `settings.spendWindow` | `daily`, `weekly`, or `monthly`. |
| `settings.rateLimitPerMin` | `null` or an integer of at least `1`. |
| `otel.endpoint` on create | Required when `otel` is present; public HTTPS URL up to 2,048 characters. Private, loopback, and unsafe upstreams are rejected. |
| `otel.headers` | At most 32 valid HTTP token names; values up to 2,048 characters. `Host` and `Content-Length` are forbidden. |

Creation is atomic. The first protection lazily creates the connection's project and internal Service
Account, and adds the approving user to the project as `ProjectAdmin`. Every protection creates its own
credential, gateway, model bindings, and runtime token.
That gateway token has only the data-plane `run` scope; it cannot manage resources or read logs.

```json theme={}
{
  "ok": true,
  "data": {
    "protectionId": "22222222-2222-4222-8222-222222222222",
    "externalResourceId": "partner-key-support",
    "project": {
      "id": "33333333-3333-4333-8333-333333333333",
      "name": "Partner Platform AIRCTRL Integration"
    },
    "serviceAccount": {
      "id": "44444444-4444-4444-8444-444444444444",
      "name": "Partner Platform OAuth Integration",
      "active": true
    },
    "providerCredential": {
      "id": "55555555-5555-4555-8555-555555555555",
      "providerId": "11111111-1111-4111-8111-111111111111",
      "providerSlug": "example-ai",
      "label": "Support AI key"
    },
    "gateway": {
      "id": "66666666-6666-4666-8666-666666666666",
      "slug": "support-ai",
      "name": "Support AI",
      "models": ["model-standard"],
      "settings": {
        "spendLimit": 100,
        "spendWindow": "monthly",
        "rateLimitPerMin": 240,
        "loggingEnabled": true,
        "logBodies": false,
        "guardrailMode": "redact"
      }
    },
    "gatewayToken": {
      "id": "77777777-7777-4777-8777-777777777777",
      "prefix": "returned-prefix",
      "plaintext": "returned once; store without logging"
    },
    "state": "active",
    "accessRevokedAt": null,
    "connectionSuspendedAt": null,
    "createdAt": "2026-10-26T12:00:00.000Z",
    "replayed": false
  }
}
```

Persist `externalResourceId → protectionId`. The nested IDs are metadata; lifecycle operations require
only `protectionId`.

## List and read protections

```http theme={}
GET /v1/oauth/protections
GET /v1/oauth/protections/{protectionId}
Authorization: Bearer {access-token}
```

The list response uses `{ "ok": true, "data": [...] }`; the read response uses
`{ "ok": true, "data": { ... } }`. Runtime token plaintext is omitted from ordinary list/read
responses.

```json theme={}
{
  "ok": true,
  "data": [
    {
      "protectionId": "22222222-2222-4222-8222-222222222222",
      "externalResourceId": "partner-key-support",
      "project": {
        "id": "33333333-3333-4333-8333-333333333333",
        "name": "Partner Platform AIRCTRL Integration"
      },
      "serviceAccount": {
        "id": "44444444-4444-4444-8444-444444444444",
        "name": "Partner Platform OAuth Integration",
        "active": true
      },
      "providerCredential": {
        "id": "55555555-5555-4555-8555-555555555555",
        "providerId": "11111111-1111-4111-8111-111111111111",
        "providerSlug": "example-ai",
        "label": "Support AI key"
      },
      "gateway": {
        "id": "66666666-6666-4666-8666-666666666666",
        "slug": "support-ai",
        "name": "Support AI",
        "models": ["model-standard"],
        "settings": {
          "spendLimit": 100,
          "spendWindow": "monthly",
          "rateLimitPerMin": 240,
          "loggingEnabled": true,
          "logBodies": false,
          "guardrailMode": "redact"
        }
      },
      "gatewayToken": {
        "id": "77777777-7777-4777-8777-777777777777",
        "prefix": "returned-prefix"
      },
      "state": "active",
      "accessRevokedAt": null,
      "connectionSuspendedAt": null,
      "createdAt": "2026-10-26T12:00:00.000Z"
    }
  ]
}
```

Every protection returns one explicit lifecycle state:

* `active` — runtime access is enabled.
* `connection_suspended` — an installation disconnect disabled runtime access; this protection is
  eligible for automated restoration after reauthorization.
* `revoked` — this protection was explicitly revoked and must never be restored automatically.

After reconnecting, list protections and call `reactivate` for the selected
`connection_suspended` entries. A partner may reactivate several existing protections and provision
new external keys in the same OAuth session. Each operation remains independent and idempotent.

The AIRCTRL connection-details surface presents current permissions separately from Connection history.
Every Connect/Disconnect history event includes the scope snapshot effective for that event, so later
reauthorization never rewrites the evidence of earlier consent.

## Update a protection

```http theme={}
PATCH /v1/oauth/protections/{protectionId}
Authorization: Bearer {access-token}
Content-Type: application/json
```

```json theme={}
{
  "gatewayName": "Support AI v2",
  "models": ["model-standard", "model-vision"],
  "settings": {
    "rateLimitPerMin": 360,
    "loggingEnabled": true,
    "logBodies": true,
    "guardrailMode": "redact"
  },
  "otel": {
    "enabled": true,
    "endpoint": "https://otel.example.com",
    "exportMetrics": true,
    "exportTraces": true,
    "exportBodies": true,
    "headers": { "Authorization": "loaded from the partner secret manager" }
  }
}
```

The response is the updated protection object. AIRCTRL validates models again and encrypts
OTel headers. Updating one protection does not alter siblings.

Protection list/read/update responses do not return the OTel configuration or its headers. Keep the
desired non-secret OTel settings in the partner backend; header plaintext is write-only and is never
returned.

Changing `gatewayName` also derives and replaces that gateway's slug. Read the returned
`gateway.slug` and rebuild any stored connection URLs. A slug collision returns
`oauth_gateway_name_conflict`.

OTel updates are partial. Omit `otel.headers` to preserve the encrypted headers already stored by
AIRCTRL. Send `"headers": null` or an empty object to clear them. Send a non-empty object to
replace them.

Enabling OTel requires the organization's effective `otelExport` entitlement; disabling an existing
configuration does not. Run preflight with `otel=true` before offering enablement.
`exportBodies=true` additionally requires both `loggingEnabled=true` and `logBodies=true`. Turning off
either gateway setting automatically turns off OTel body export. OTel is optional: the OAuth log and
spend endpoints below work without it. OTel delivers the same canonical events in real time with an
OTLP envelope when the customer plan permits export.

## Read protection logs

```http theme={}
GET /v1/oauth/protections/{protectionId}/logs?since=2026-10-01T00:00:00.000Z&status=200&limit=50
Authorization: Bearer {access-token}
```

Requires `protections:read-logs`. Supported filters are `since`, `provider`, `model`, `status`,
`limit`, `offset`, and opaque `cursor`. Do not combine a cursor with a non-zero offset.
`limit` defaults to `50` and must be between `1` and `200`. `offset` defaults to `0` and cannot be
negative. When `cursor` is present, reuse the exact filters from the preceding page.

```json theme={}
{
  "ok": true,
  "data": {
    "rows": [
      {
        "log_id": "88888888-8888-4888-8888-888888888888",
        "request_id": "88888888-8888-4888-8888-888888888888",
        "gateway_id": "66666666-6666-4666-8666-666666666666",
        "provider_slug": "example-ai",
        "model": "model-standard",
        "operation_name": "chat",
        "status_code": 200,
        "streamed": false,
        "prompt_tokens": 120,
        "completion_tokens": 30,
        "total_tokens": 150,
        "cost": "0.0015000000",
        "cost_estimated": false,
        "latency_ms": 420,
        "error_code": null,
        "created_at": "2026-10-28T12:00:00.000Z"
      }
    ],
    "total": 1,
    "facets": {
      "providers": [{ "slug": "example-ai", "count": 1 }],
      "statusClasses": [{ "class": "2xx", "count": 1 }]
    },
    "can_read_spend": true,
    "can_read_bodies": false,
    "pagination": {
      "limit": 50,
      "has_more": false,
      "next_cursor": null
    }
  }
}
```

The query is restricted to the protection gateway and runtime tokens owned by this installation's
internal Service Account. Rotated replacement tokens remain included; customer-created tokens on the
same gateway are excluded. `cost` is `null` unless the access token also has
`protections:read-spend`. OAuth never exposes stored request or response bodies.

## Read protection spend

```http theme={}
GET /v1/oauth/protections/{protectionId}/spend?since=2026-10-01T00:00:00.000Z
Authorization: Bearer {access-token}
```

Requires `protections:read-spend`. `since` is optional and must be an ISO 8601 timestamp.

```json theme={}
{
  "ok": true,
  "data": {
    "currency": "USD",
    "since": "2026-10-01T00:00:00.000Z",
    "requests": 42,
    "promptTokens": 8000,
    "completionTokens": 2000,
    "totalTokens": 10000,
    "spendUsd": "0.1250000000"
  }
}
```

Totals are derived from persisted request logs for the same installation-owned runtime-token boundary
as the log endpoint. They do not include customer-created tokens or another protection.

## Rotate the provider key

```http theme={}
POST /v1/oauth/protections/{protectionId}/rotate-provider-key
Authorization: Bearer {access-token}
Content-Type: application/json
```

```json theme={}
{
  "providerKey": "replacement loaded from the partner secret manager"
}
```

AIRCTRL validates the replacement against every model currently bound to the gateway before
atomically replacing the encrypted credential. The key is never returned.

## Rotate the runtime token

```http theme={}
POST /v1/oauth/protections/{protectionId}/rotate-runtime-token
Authorization: Bearer {access-token}
Idempotency-Key: {stable-rotation-id}
```

The previous runtime token is revoked atomically. The response is the protection object and includes
the replacement `gatewayToken.plaintext` once.

## Revoke one protection

```http theme={}
POST /v1/oauth/protections/{protectionId}/revoke
Authorization: Bearer {access-token}
```

The operation is idempotent. It revokes that protection's runtime token, sets `state` to `revoked`,
and stamps `accessRevokedAt`.
It does not delete the gateway, credential, logs, settings, or audit history. Sibling protections remain
active.

## Reactivate one protection

```http theme={}
POST /v1/oauth/protections/{protectionId}/reactivate
Authorization: Bearer {access-token}
Idempotency-Key: {stable-reactivation-id}
```

AIRCTRL revalidates the stored provider credential and model bindings, then issues a new
runtime token. The response includes the new plaintext once. An already-active protection is an
idempotent no-op and does not mint another token. Retry with the same idempotency key to recover the
same one-time plaintext after an uncertain response. Successful reactivation clears both lifecycle
timestamps and returns `state: "active"`.

If a newer runtime-token operation already superseded that result, retrying the older idempotency key
returns `409` with `idempotency_operation_superseded`. Keep the newest successful runtime token and do
not retry the older operation with a new key.

## Successful response families

The complete protection object is the shape shown in [Create one protection](#create-one-protection).
Operations are grouped below because they deliberately reuse that same object:

| Operation | `data` shape | One-time runtime plaintext |
| - | - | - |
| List protections | Array of protection objects | Never included |
| Read, update, rotate provider key, revoke | One protection object | Never included |
| Create protection | One protection object | Included in `gatewayToken.plaintext` |
| Rotate runtime token | One protection object | Included in `gatewayToken.plaintext` |
| Reactivate | One protection object | Included only when a new token is issued or an idempotent replay recovers it |

Idempotent create, runtime rotation, and reactivation responses may also include `replayed: true`.
Store a returned plaintext before acknowledging the operation to the external platform.

## OAuth token lifecycle and full disconnect

* `POST /v1/oauth/token` exchanges an authorization code or rotates a refresh token.
* `POST /v1/oauth/revoke` revokes the submitted access token or refresh-token family.
* **Connected apps → Disconnect** revokes the entire OAuth connection, every access/refresh token,
  every active protection runtime token, and the internal Service Account. Protections that were active
  become `connection_suspended`; protections already marked `revoked` remain revoked. Resources and
  audit history remain.
* Reauthorizing the same App/organization reuses the installation and preserved resources but revokes
  every prior access/refresh token before the new authorization code is issued.

See [OAuth protocol](https://docs.airctrl.dev/reference/oauth-protocol.md) for complete token and revocation request/response
payloads.

## Errors and retries

Resource API failures use the standard AIRCTRL error envelope:

```json theme={}
{
  "ok": false,
  "error": {
    "code": "oauth_scope_insufficient",
    "message": "The OAuth token does not grant this operation."
  },
  "requestId": "request-correlation-id"
}
```

| Status | Meaning | Retry rule |
| - | - | - |
| `400` | Invalid body, provider key, model selection, OTel relationship, or idempotency key. | Correct input; do not retry unchanged. |
| `401` | Access token is missing, expired, revoked, or superseded. | Refresh once or start authorization again. |
| `403` | Scope, installation binding, subscription, entitlement, quota, or locked-resource denial. | Show the safe code; do not try another organization or ID. |
| `404` | The protection is absent or not bound to this installation. | Correct the stored mapping; do not enumerate IDs. |
| `409` | Idempotency body conflict, concurrent change, revoked state, or superseded runtime operation. | Reconcile current state before another write. |
| `429` | Rate limit exceeded. | Retry with bounded backoff and jitter. |
| `502` | Live provider validation/model discovery is unavailable. | Retry with bounded backoff; never skip validation. |
| `503` | AIRCTRL could not commit the operation. | Treat outcome as uncertain and replay the identical write with the same idempotency key. |

Stable codes include:

* `idempotency_key_invalid`, `idempotency_key_reused`, `idempotency_operation_in_progress`, and
  `idempotency_operation_superseded`;
* `oauth_external_resource_already_protected`, `oauth_gateway_installation_changed`,
  `oauth_gateway_installation_not_found`, and `oauth_gateway_name_conflict`;
* `oauth_gateway_protection_revoked` and `oauth_gateway_protection_connection_suspended`;
* `oauth_project_unavailable`, `oauth_service_account_inactive`, and
  `oauth_service_account_unrecoverable`;
* `provider_key_invalid`, `provider_endpoint_not_configured`, `provider_validation_unavailable`,
  `provider_model_not_available`, `provider_model_not_supported`, `model_required`, and
  `gateway_name_invalid`; and
* `otel_endpoint_required`, `otel_not_available`, `log_bodies_require_logging`,
  `otel_bodies_require_gateway_log_bodies`, and `otel_headers_invalid`.

## SDK mapping

* Preflight — `preflightProtection()`
* Create — `provisionProtection()`
* List / read — `listProtections()` / `getProtection()`
* Update — `updateProtection()`
* Request logs — `listProtectionLogs()`
* Spend — `getProtectionSpend()`
* Rotate provider key — `rotateProtectionProviderKey()`
* Rotate runtime token — `rotateProtectionRuntimeToken()`
* Revoke — `revokeProtection()`
* Reactivate — `reactivateProtection()`

CLI and MCP do not expose OAuth App credential modes or protection lifecycle commands. Keep OAuth
client secrets and refresh tokens out of their configuration. OAuth Apps are confidential-backend
integrations available through direct HTTP/OpenAPI and `OAuthPartnerClient`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.