> ## 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 partner integrations

> Connect a confidential partner backend and provision one protected gateway for each external key.

Use this guide to implement experiences such as **Protect with AIRCTRL**. The partner
registers one App, then each customer connects it once to the AIRCTRL organization they
choose. Every isolated customer installation can protect any number of external keys without receiving
a different Client ID or secret.

The contract is provider-neutral and applies to every provider registered and enabled in
AIRCTRL.

> [!WARNING]
>
> This is a confidential-backend integration. Never request an AIRCTRL PAT or Service Account token,
> and never send the OAuth client secret, provider key, access token, refresh token, or runtime gateway
> token to browser storage, URLs, analytics, or logs.

## What the integration creates

The first successful protection creates one internal Service Account and one dedicated project for the
OAuth installation. The user who approved the connection is added to that project as `ProjectAdmin`.
Every protected external key then receives its own:

* encrypted provider credential;
* independently configured gateway and model bindings; and
* runtime gateway token limited to the data-plane `run` scope.

Disconnecting revokes OAuth/runtime access and disables the internal Service Account. It does not
delete projects, credentials, gateways, settings, request logs, or audit history.

## Before you start

An organization member with `oauth-clients:create` creates an OAuth App under
**Settings → Integrations → My OAuth Apps**. Organization owners receive that permission by default.
Enable **Allow other AIRCTRL organizations to install this app** for a public App. The App definition,
Client ID, secret, redirects, and publisher controls remain in the creator's organization; every
installation and resource created with its tokens belongs to the customer organization selected at
consent. Leave it off for a private App that can connect only to its creator organization.
The creator provides the partner developer with:

* the Client ID;
* the one-time Client secret; and
* the exact HTTPS redirect URI registered for the partner backend.

Register these scopes:

* **`protections:provision`** — Resolve providers, validate a provider key, discover models,
  run preflight, and provision resources atomically.
* **`protections:read`** — Read only protections created by this OAuth connection.
* **`protections:update`** — Update only protections created by this OAuth connection.
* **`protections:manage`** — Revoke, reactivate, or rotate credentials only for protections created by
  this OAuth connection.
* **`protections:read-logs`** — Read request metadata only for protection runtime tokens created by
  this OAuth installation. Request and response bodies are never exposed through OAuth.
* **`protections:read-spend`** — Read token usage and monetary spend only for protections created by
  this OAuth installation.
* **`offline_access`** — Renew access automatically with rotating refresh tokens. The integration must be
  authorized again after 90 days without a successful refresh.

Enable all seven scopes for the complete protection lifecycle and observability. A deliberately limited integration may
enable a subset, but its authorization request must match that subset exactly. OAuth rejects the
complete authorization request when it contains even one permission the App does not allow.
`protections:provision` is the required minimum and the UI keeps it enabled; `offline_access` cannot
replace it.

Permission changes are least-privilege and fail closed. Removing a permission narrows every existing
installation immediately and revokes current access tokens. Removing `offline_access` also revokes
refresh-token families. Adding a permission never upgrades an existing installation silently: the user
must authorize the connection again.

Redirect URIs use exact HTTPS matching. Wildcards, fragments, and runtime-selected return URLs are not
accepted.

The redirect URI is owned by the partner backend, not by AIRCTRL. Production integrations
normally use a route such as `https://partner.example.com/oauth/callback`. A local reference app may
register an HTTPS tunnel ending in `/callback`, provided the authorization request uses that exact same
value.

## OAuth endpoints

Open [Discover the authorization server](https://docs.airctrl.dev/api-reference/oauth/discover-the-airctrl-authorization-server.md)
in the API Reference. Its Mintlify base-URL selector switches the request URL and generated examples
between Production and QA from the OpenAPI `servers` contract. Local development values are listed in
[Environments](https://docs.airctrl.dev/reference/environments.md).

Never infer one environment from credentials issued by another.

AIRCTRL uses OAuth 2.0 Authorization Code with PKCE `S256` for confidential web clients. It is not an
OpenID Connect login provider and intentionally does not expose ID Tokens or UserInfo.

Use [OAuth protocol](https://docs.airctrl.dev/reference/oauth-protocol.md) for complete discovery, authorization, callback, token,
refresh, revocation, success, and error payloads for production, QA, and local environments.

## Choose an implementation path

The SDK is optional. Both paths use the same OAuth scopes, endpoints, payloads, idempotency contract,
and installation boundaries.

### Option A: Node or TypeScript SDK

Use `@airctrl/sdk` when the confidential partner backend runs Node or TypeScript. It provides typed
helpers for PKCE, token exchange and rotation, provider/model discovery, preflight, provisioning,
resource reads, updates, and revocation.

### Option B: Direct HTTP and OpenAPI

Use any standards-compliant OAuth and HTTP client in another language. No AIRCTRL package
is required. Read the machine contract from
`https://docs.airctrl.dev/api-reference/openapi.json` and the authorization-server endpoints from the
discovery URL above.

The examples below use the SDK for brevity. [Direct HTTP implementation](#direct-http-implementation)
maps the same flow to raw requests.

For every request and response payload, lifecycle endpoint, and multi-key mapping, see
[OAuth protection API](https://docs.airctrl.dev/reference/oauth-protections.md).

## Connect once

If you chose the SDK path, install `@airctrl/sdk` in the confidential partner backend and construct one
client:

```ts theme={}
import { OAuthPartnerClient, createPkce } from "@airctrl/sdk";

const oauth = new OAuthPartnerClient(
  process.env.AIRCTRL_CLIENT_ID!,
  process.env.AIRCTRL_CLIENT_SECRET!,
  {
    apiBaseUrl: process.env.AIRCTRL_API_ORIGIN!,
    webBaseUrl: process.env.AIRCTRL_WEB_URL!,
  },
);
```

### 1. Start authorization

Generate `state` and PKCE on the backend. Store `state` and the verifier in the user's short-lived
server session before redirecting the browser.

```ts theme={}
const { verifier, challenge } = await createPkce();
const state = crypto.randomUUID();

await sessions.save(sessionId, {
  state,
  codeVerifier: verifier,
  expiresAt: Date.now() + 10 * 60_000,
});

const url = oauth.authorizationUrl({
  redirectUri: process.env.AIRCTRL_REDIRECT_URI!,
  scopes: [
    "protections:provision",
    "protections:read",
    "protections:update",
    "protections:manage",
    "protections:read-logs",
    "protections:read-spend",
    "offline_access",
  ],
  state,
  codeChallenge: challenge,
});

return redirect(url);
```

For a public App, AIRCTRL lists only organizations where the approving person is an active
member, holds `oauth-installations:connect`, and has active product access. If more than one qualifies,
the consent screen requires an explicit organization selection. A private App shows only its creator
organization. Active subscription, trial, and payment-retry (`past_due`) states qualify.

The selected organization becomes the permanent tenant boundary for that installation. Its plan,
quotas, billing, project, Service Account, credentials, gateways, tokens, logs, and spend are used;
the publisher organization does not receive access to unrelated customer resources.

The confidential partner backend uses the same authorization, token, protection, logs, and spend
operations for private and public Apps. Distribution changes only which organization may be selected
during consent and therefore which organization becomes the installation boundary.

### 2. Handle the callback

Require the same `state`, exact issuer, a non-expired server session, and an authorization `code`. Then
exchange the code from the backend:

```ts theme={}
const pending = await sessions.require(sessionId);
if (query.state !== pending.state) throw new Error("oauth_state_mismatch");
if (query.iss !== process.env.AIRCTRL_API_ORIGIN) throw new Error("oauth_issuer_mismatch");

const tokens = await oauth.exchangeCode({
  code: query.code,
  redirectUri: process.env.AIRCTRL_REDIRECT_URI!,
  codeVerifier: pending.codeVerifier,
});

await encryptedTokenStore.save(airctrlOrganizationId, tokens);
```

The connection is now reusable. Do not repeat OAuth for each protected key.

## Protect one external key

The external platform already knows the selected key's stable ID, display name, and secret. These
values come from its backend—not from AIRCTRL UI fields.

### 1. Resolve the provider

Do not hard-code AIRCTRL provider UUIDs. Resolve the registered provider by slug:

```ts theme={}
const providers = await oauth.listProvisioningProviders(accessToken);
const provider = providers.find((candidate) => candidate.slug === selectedProviderSlug);
if (!provider) throw new Error("provider_unavailable");
```

### 2. Run preflight before sending the provider key

```ts theme={}
const preview = await oauth.preflightProtection(accessToken, {
  providerId: provider.provider_id,
  otel: false,
});

if (!preview.allowed) {
  return showAIRCTRLBlockers(preview.blockers, preview.quotas);
}
```

Preflight performs no writes and receives no provider key. Use it before provisioning so subscription,
entitlement, and quota failures appear before the partner sends a provider key.

### 3. Validate the key and load live models

Retrieve the selected key from the partner's server-side secret store and send it directly to AIRCTRL:

```ts theme={}
const providerKey = await partnerSecrets.require(selectedKeyId);
const catalog = await oauth.discoverProvisioningProviderModels(
  accessToken,
  provider.provider_id,
  providerKey,
);

if (catalog.invalidApiKey) throw new Error("provider_key_invalid");
```

AIRCTRL uses the OAuth-only `/v1/oauth/protections/providers/{providerId}/models` operation.
It uses the key only for upstream validation and live model discovery at this stage; it does not
persist it. The confidential partner backend chooses the gateway name and allowed model IDs that it
sends in the provisioning command. A partner may offer an optional review screen, but AIRCTRL
does not require another user step. Names, models, limits, logging, guardrails, and entitled OTel settings
can be changed later through the installation-bound update operation.

### 4. Provision atomically

`externalResourceId` must be the stable, non-secret key ID from the partner platform. Generate one
idempotency key per protection attempt and persist it until the final outcome is known.

```ts theme={}
const protection = await oauth.provisionProtection(
  accessToken,
  idempotencyKey,
  {
    externalResourceId: selectedKeyId,
    providerId: provider.provider_id,
    providerKey,
    credentialLabel: `${selectedKeyName} key`,
    gatewayName: selectedKeyName,
    models: selectedModelIds,
    settings: {
      loggingEnabled: true,
      logBodies: false,
      guardrailMode: "redact",
    },
  },
);
```

Provisioning is all-or-nothing. AIRCTRL validates the key and selected models again before writing,
encrypts the provider key at rest, and never returns it. A successful response includes the runtime
gateway token plaintext once:

```ts theme={}
await partnerSecrets.saveAIRCTRLRuntimeToken(
  selectedKeyId,
  protection.gatewayToken.plaintext!,
);
```

For a one-click partner experience initiated from **Protect with** AIRCTRL, keep that one stable external
key ID in the partner's server-side OAuth state. After consent, run provider resolution, preflight, model
discovery and this provisioning command only for that pending key. Store the resulting `protectionId`
beside it; the user does not repeat these inputs in AIRCTRL. A connection-only flow must stop
after token exchange and list existing protections—it must not call `POST /v1/oauth/protections` without
an explicit pending protection intent.

Retry an uncertain result with the same idempotency key and identical body. Reusing that key with a
different body returns `409`.

## Use and manage the protected gateway

Use the returned gateway slug and runtime token from the partner backend. Do not append the provider's
`native_base_path` to a gateway URL: that field describes the provider upstream and is not an
AIRCTRL data-plane route. Use the SDK's shared connection builder so the environment and
encoded gateway slug follow the same contract as the Web app and MCP:

```ts theme={}
import { buildGatewayConnectionConfig } from "@airctrl/sdk/gateway-connections";

const connection = buildGatewayConnectionConfig(
  process.env.AIRCTRL_API_ORIGIN!,
  protection.gateway.slug,
);

const compatBaseUrl = connection.compatBaseUrl;
```

Use `compatBaseUrl` for the compatibility surface. For a provider-native operation, select the typed
base returned by `buildGatewayConnectionConfig()` for that provider. See
[Gateway connection URLs](https://docs.airctrl.dev/sdk/overview.md#gateway-connection-urls) and [Connect an AI client](https://docs.airctrl.dev/guides/gateways.md#connect-an-ai-client).

The OAuth installation may later list its own protected resources or update the gateway name, model
bindings, and allowlisted settings:

```ts theme={}
const protections = await oauth.listProtections(accessToken);

const updatedProtection = await oauth.updateProtection(accessToken, protection.protectionId, {
  gatewayName: "Support AI v2",
  models: ["model-standard", "model-vision"],
  settings: { rateLimitPerMin: 240 },
});
```

Changing `gatewayName` also changes the derived gateway slug. Use `updatedProtection.gateway.slug` to
rebuild connection URLs before sending more traffic.

It cannot read or modify gateways created outside its own OAuth installation.

With the matching scopes, read request metadata and spend for one protection without OTel:

```ts theme={}
const logs = await oauth.listProtectionLogs(accessToken, protection.protectionId, {
  since: "2026-10-01T00:00:00.000Z",
  limit: 50,
});
const spend = await oauth.getProtectionSpend(accessToken, protection.protectionId, {
  since: "2026-10-01T00:00:00.000Z",
});
```

These endpoints include only traffic attributed to runtime tokens owned by this OAuth installation's
internal Service Account, including rotated replacements. Customer-created tokens on the same gateway
are excluded. Log cost is `null` unless the token also has `protections:read-spend`.
The behavior is identical for a private same-organization installation and a public installation bound
to the customer organization selected during consent.

## Choose internal logs or OpenTelemetry

AIRCTRL builds one canonical request-log event and exposes the same fields through its
internal logs and OpenTelemetry export; only the transport envelope differs. This prevents internal
storage and exported telemetry from disagreeing about identity, provider, model, operation, status,
errors, tokens, cost, bytes, latency, streaming, guardrails, trace ID, or span ID.

OAuth access tokens can poll protection-scoped request metadata with `protections:read-logs` and totals
with `protections:read-spend`. They cannot read stored request or response bodies. Authorized
AIRCTRL users retain the broader human observability surfaces.

OTel remains optional for real-time delivery to customer-controlled infrastructure. It is not required
to read the OAuth log or spend endpoints.

OTel is subject to the organization's effective feature entitlement. Call preflight with `otel=true`
before offering it. `otel_not_available` blocks only the requested OTel configuration, not a protection
that omits OTel. Request/response bodies can be exported only when gateway logging and body capture are
both enabled; disabling either automatically disables OTel body export.

## Direct HTTP implementation

The REST path follows the same order as the SDK path:

1. Read `/.well-known/oauth-authorization-server` and redirect the browser to its
   `authorization_endpoint` with `response_type=code`, exact `redirect_uri`, registered scopes,
   high-entropy `state`, and PKCE `S256`.
2. Exchange the callback code at `POST /v1/oauth/token` using HTTP Basic client authentication and a
   form-encoded body.
3. Call `GET /v1/oauth/protections/providers` with the OAuth bearer and resolve the provider
   by slug.
4. Call `GET /v1/oauth/protections/preflight?providerId={providerId}` before sending the key.
5. Call `POST /v1/oauth/protections/providers/{providerId}/models` with `{ "apiKey": ... }`
   from the confidential backend.
6. Call `POST /v1/oauth/protections` with the OAuth bearer, JSON body, and a stable
   `Idempotency-Key` header.
7. Store the returned `protectionId` next to the external key ID. Use `GET` and
   `PATCH /v1/oauth/protections/{protectionId}` only for that protection.
8. Use the protection lifecycle endpoints to revoke, reactivate, or rotate only that external key.
   Runtime-token rotation and reactivation require a stable `Idempotency-Key`.
9. Poll `GET /v1/oauth/protections/{protectionId}/logs` and
   `GET /v1/oauth/protections/{protectionId}/spend` only when the App and installation grant the
   corresponding scopes.
10. Rotate at `POST /v1/oauth/token` with `grant_type=refresh_token`; revoke through
    `POST /v1/oauth/revoke`.

Exchange the authorization code from Bash:

```bash theme={}
curl --request POST "$AIRCTRL_TOKEN_ENDPOINT" \
  --user "$AIRCTRL_CLIENT_ID:$AIRCTRL_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "code=$AIRCTRL_AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$AIRCTRL_REDIRECT_URI" \
  --data-urlencode "code_verifier=$AIRCTRL_CODE_VERIFIER"
```

Resolve providers and run preflight without transmitting the provider key:

```bash theme={}
curl --fail-with-body \
  --header "Authorization: Bearer $AIRCTRL_ACCESS_TOKEN" \
  "$AIRCTRL_API_URL/oauth/protections/providers"

curl --fail-with-body \
  --header "Authorization: Bearer $AIRCTRL_ACCESS_TOKEN" \
  "$AIRCTRL_API_URL/oauth/protections/preflight?providerId=$AIRCTRL_PROVIDER_ID"
```

For model discovery and provisioning, build JSON inside the backend from its secret store. The payload
shapes are:

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

{ "apiKey": "<server-side provider key>" }
```

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

{
  "externalResourceId": "partner-key-support",
  "providerId": "11111111-1111-4111-8111-111111111111",
  "providerKey": "<server-side provider key>",
  "credentialLabel": "Support AI key",
  "gatewayName": "Support AI",
  "models": ["model-standard"],
  "settings": {
    "loggingEnabled": true,
    "logBodies": false,
    "guardrailMode": "redact"
  }
}
```

Lifecycle requests use the returned `protectionId`:

```http theme={}
GET    /v1/oauth/protections
GET    /v1/oauth/protections/{protectionId}
PATCH  /v1/oauth/protections/{protectionId}
GET    /v1/oauth/protections/{protectionId}/logs
GET    /v1/oauth/protections/{protectionId}/spend
POST   /v1/oauth/protections/{protectionId}/rotate-provider-key
POST   /v1/oauth/protections/{protectionId}/rotate-runtime-token
POST   /v1/oauth/protections/{protectionId}/revoke
POST   /v1/oauth/protections/{protectionId}/reactivate
```

Runtime-token rotation and reactivation require `Idempotency-Key`. Request/response examples for every
operation are in [OAuth protection API](https://docs.airctrl.dev/reference/oauth-protections.md). Do not put the provider key or
OAuth bearer in a URL, browser form, shell history, or committed file.

## Refresh and disconnect

Access tokens expire after 15 minutes. With `offline_access`, use the latest stored refresh token and replace
it after every successful refresh. Each successful refresh renews the 90-day inactivity window; replaying an
older token revokes the token family.

Serialize refreshes per AIRCTRL installation. If two workers use the same refresh token concurrently,
AIRCTRL treats the second use as replay and revokes the complete token family. Use a distributed lock or
an atomic compare-and-swap in the encrypted token store. When refresh returns `invalid_grant`, stop retrying
and start authorization again.

```ts theme={}
const next = await refreshLock.run(airctrlOrganizationId, async () => {
  const current = await encryptedTokenStore.require(airctrlOrganizationId);
  const rotated = await oauth.refresh(current.refresh_token!);
  await encryptedTokenStore.compareAndSwap(current.refresh_token!, rotated);
  return rotated;
});
```

When the partner stops using its OAuth token, revoke the current access or refresh token and erase the
partner's encrypted token copy:

```ts theme={}
await oauth.revoke(current.refresh_token!);
await encryptedTokenStore.remove(airctrlOrganizationId);
```

RFC 7009 token revocation ends that OAuth token or refresh family; it does not delete AIRCTRL resources.
When an AIRCTRL user disconnects the installation from **Settings → Integrations → Connected apps**,
AIRCTRL additionally disables runtime gateway tokens and the installation's internal Service Account
without deleting resources or audit history. Reauthorizing the same OAuth App for the same AIRCTRL
organization reuses that installation, its dedicated project, Service Account, provider credentials,
gateways, and stable protection IDs. The partner should list the existing protections and call
`reactivate` only for those it wants to restore. Each reactivation rechecks current entitlements and
returns a new runtime gateway token once; it does not create replacement resources. Connect and
disconnect events remain available in the connection history.

Each connection-history event stores the exact scope snapshot granted at that moment. Reauthorization
replaces the installation's current scopes with the newly approved set; the historical snapshots remain
unchanged for audit comparison.

Protection list/read responses expose `state` as `active`, `connection_suspended`, or `revoked`.
After reauthorization, automate restoration only for `connection_suspended` protections selected by
the partner's current intent. Never automatically restore `revoked` protections. The same OAuth access
token may reactivate several existing protections and provision additional external keys; each command
is independently authorized and idempotent.

## Errors a partner must handle

* **`oauth_redirect_uri_invalid`** — The callback does not exactly match registration. Use the
  registered HTTPS URI verbatim.
* **`oauth_state_invalid` / `oauth_state_mismatch`** — State is missing, malformed, or different from
  the stored transaction. Abort and start a new authorization.
* **`oauth_authorization_request_expired`** — The pending consent request expired or was already
  decided. Start authorization again; do not reuse it.
* **`oauth_scope_insufficient`** — The token lacks the required delegated scope. Reconnect requesting
  only registered required scopes.
* **`oauth_access_not_allowed`** — The endpoint is not part of the OAuth surface. Do not substitute a
  PAT; use documented OAuth operations.
* **`subscription_inactive`** — The owning organization no longer has active product access. Active
  subscription, trial, and payment-retry (`past_due`) states qualify; other states must return to
  Billing before integration continues.
* **`provider_key_invalid`** — The provider rejected the supplied key. Ask the user to repair the key
  in the external platform.
* **`provider_model_not_available` / `provider_model_not_supported`** — A requested model is absent
  from the live catalog or AIRCTRL cannot safely route it. Refresh model discovery and let
  the user choose only supported IDs.
* **`provider_endpoint_not_configured` / `provider_validation_unavailable`** — Provider discovery or
  validation is temporarily unavailable. Do not persist the key; retry with bounded backoff.
* **Preflight blockers** — Subscription, entitlement, or quota prevents protection. Show blockers
  before sending the provider key.
* **`idempotency_key_invalid`** — The key is shorter than 16 characters or longer than 200. Generate a
  new stable operation key before sending the write.
* **`idempotency_key_reused` / `idempotency_operation_in_progress`** — The key was reused with a
  different body or the original transaction has not committed. Never change the body; reconcile or
  retry the identical request.
* **`idempotency_operation_superseded`** — A newer runtime-token operation already replaced the
  result of the retried lifecycle command. Keep the newest successful token and do not repeat the old
  operation with a new key.
* **`oauth_external_resource_already_protected`** — This connection already mapped the same stable
  external resource ID. Read the existing protections instead of creating a duplicate.
* **`oauth_gateway_protection_revoked` / `oauth_gateway_protection_connection_suspended`** — The
  protection cannot be updated or rotated in its current state. Reauthorize the connection when
  suspended, then call `reactivate` deliberately.
* **`oauth_project_unavailable` / `oauth_service_account_inactive` /
  `oauth_service_account_unrecoverable`** — The preserved internal project or Service Account cannot
  currently serve the connection. Stop automation and require an authorized AIRCTRL user
  to review the installation.
* **`otel_endpoint_required`** — The protection has no OTel configuration yet. Include `endpoint` the
  first time; later PATCH requests may omit it.
* **`otel_headers_invalid`** — The request exceeded 32 headers, used an invalid/forbidden name, or a
  value exceeded 2,048 characters. Correct the server-side OTel configuration.
* **`otel_not_available`** — Effective account entitlements do not enable OTel. Continue without OTel
  or let an authorized user review internal logs; do not mention internal entitlement grants.
* **`log_bodies_require_logging` / `otel_bodies_require_gateway_log_bodies`** — Enable gateway logging
  and body capture before requesting body export.
* **`429`, `500`, `502`, `503`** — Temporary capacity or upstream failure. Retry with bounded backoff
  and the same idempotency key when provisioning.

## Security checklist

* Keep all secrets and OAuth tokens in the confidential backend.
* Use exact HTTPS redirect URIs, PKCE `S256`, high-entropy state, and exact issuer validation.
* Resolve provider IDs from the registry instead of hard-coding them.
* Run preflight before transmitting a provider key.
* Treat `externalResourceId` and idempotency keys as stable per protection.
* Never log request authorization headers, provider keys, runtime gateway tokens, or OAuth token bodies.
* Revoke tokens on disconnect; do not delete AIRCTRL resources to simulate revocation.


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