
# Plan a configuration change

`POST /v1/config-plans` · permission `config:read` and the read permission of each kind in the document

Compares a configuration document with the environment's current configuration and stores the result as a plan. Nothing is changed: a plan only shows what applying the document would do. Use it to review a promotion from `development` to `staging` or `production` before you apply it.

## Request

| Field | Description |
|---|---|
| `document` | Required. A configuration document in the format of [`GET /v1/config-export`](../config-export/get.md). It must match the JSON Schema `https://docs.keenauth.com/config/schema/v1.json`. |
| `prune` | Optional, default `false`. When `true`, resources of the document's kinds that are missing from the document are planned for deletion. |

- The document's kinds are `kinds` if given (every resource must then be of a listed kind), otherwise the kinds of its resources. Only these kinds are compared; other kinds are left as they are.
- A resource missing from the document is **not** deleted unless `prune` is `true`. The singleton kinds (`environment_settings`, `identity_settings`, `branding`) are never deleted.
- In the singleton kinds, a field missing from the document is left unchanged: the target's current value is kept. A document exported before a field existed therefore does not reset that field. A value given explicitly, including `null`, is still written. In the other kinds, a missing field takes its default value.
- The document must not contain secret values. A secret field holds only a `secret_ref` placeholder or `null`; a value is rejected and is never echoed back.
- `warnings` in the document are ignored; at most 15,000 are accepted (an export never contains more).
- The permissions are those of an export of the same kinds. With `prune`, the kinds that may refer to a deleted resource are read too, so their permissions are required as well.

## Response

`201 Created` with `Location: /v1/config-plans/{plan_id}` and `Cache-Control: no-store`.

| Field | Description |
|---|---|
| `id` | Plan id, `cfgp_…`. |
| `status` | `planned`; `expired` after `expires_at`. |
| `expires_at` | The plan is valid for 1 hour. It is deleted 7 days after it expires. |
| `summary` | Number of `create`, `update`, `delete` and `no_op` actions. |
| `changes` | One entry per resource, in kind order and then by `key`. |
| `warnings` | `secret_required`: the connection needs a client secret that the target does not have yet. |

Each change has `kind`, `key`, `action` (`create`, `update`, `delete` or `no_op`), `object_version` (the target resource's version at plan time; `null` for `create`), `requires_secret`, `fields` and, for `create` and `update`, the desired `spec`. Each entry in `fields` is a field difference: `pointer` (RFC 6901, for example `/spec/name`), `op` (`add`, `remove` or `replace`, as in RFC 6902), `before` and `after`. Lists are compared as whole values.

An exported document applied unchanged plans `no_op` for every resource, whatever the order of resources and set-like lists.

```json
{
  "id": "cfgp_01m4qpqtywfs7930tb6kqn5rc5",
  "status": "planned",
  "prune": false,
  "kinds": ["connection"],
  "expires_at": "2026-10-12T11:00:00Z",
  "summary": {"create": 1, "update": 1, "delete": 0, "no_op": 1},
  "changes": [
    {"kind": "connection", "key": "db", "action": "no_op", "object_version": 5, "requires_secret": false, "fields": []},
    {"kind": "connection", "key": "github", "action": "create", "object_version": null, "requires_secret": true, "fields": [], "spec": {"type": "social", "strategy": "github", "name": "GitHub", "display_name": null, "status": "active", "settings": {}, "client_secret": {"secret_ref": {"source": "env", "name": "KEENAUTH_CONNECTION_GITHUB_CLIENT_SECRET"}}}},
    {"kind": "connection", "key": "google", "action": "update", "object_version": 4, "requires_secret": false, "fields": [{"pointer": "/spec/name", "op": "replace", "before": "Google", "after": "Google Workspace"}], "spec": {"type": "social", "strategy": "google", "name": "Google Workspace", "display_name": null, "status": "active", "settings": {}, "client_secret": {"secret_ref": {"source": "env", "name": "KEENAUTH_CONNECTION_GOOGLE_CLIENT_SECRET"}}}}
  ],
  "warnings": [{"code": "secret_required", "kind": "connection", "key": "github", "pointer": "/spec/client_secret"}]
}
```

Every plan writes a `config_plan.created` entry to the environment's management log.

## Limits

- **Rate:** plans have their own per-minute limit per environment: 20 on Free, 60 on Startup and Business, 120 on Enterprise in `production`; one tenth (at least 2) in `development` and `staging`.
- **Quota per environment:** at most 100 plans that are neither applied nor expired, and at most 2,000 plans and 1 GiB of stored plans until they are deleted (7 days after expiry). Plans of other environments never count.
- **Capacity:** the server computes a limited number of large plans at the same time; one environment can use at most a quarter of that capacity.
- **Field limits** are those the configuration service enforces when it writes: the schema bounds lists and texts (for example `redirect_uris` 50, `allowed_web_origins` 20, `permissions` 1,000), connection `settings` is at most 32 KiB and 8 levels deep, application `jwks` and each permission's `consent_text_i18n` at most 16 KiB.

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `malformed_request`, `unknown_field` | The body is not JSON or has a field other than `document` and `prune`. |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks a required permission; `required_permissions` lists all of them. |
| 413 | `config_document_too_large`, `payload_too_large` | The document has more than 5,000 resources or 10 MiB; plan fewer kinds. |
| 422 | `validation_failed` | See below; `errors[].pointer` points into the request (`/document/...`). |
| 429 | `rate_limited` | The plan rate limit or the environment's plan quota was reached, or the environment is computing too many plans; retry after the `Retry-After` period (for the quota, when the oldest plan expires or is deleted). |
| 503 | `service_unavailable` | Plan computation is at capacity or a dependency is temporarily unavailable; retry after the `Retry-After` period. |

| `errors[].code` | Meaning |
|---|---|
| `required` | `document` is missing. |
| `invalid` | The document does not match the schema at this pointer. |
| `duplicate` | Two resources have the same `kind` and `key`. |
| `kind_not_listed` | The resource's kind is not in `kinds`. |
| `secret_value` | A secret value was found; use a `secret_ref` placeholder. |
| `unresolved_reference` | The `key` it refers to would not exist after the change. |
| `key_in_use` | The `key` belongs to an application that is not managed by configuration (organization-owned or self-registered), or to the system API. |
| `still_referenced` | `/prune`: a resource that stays refers to a resource the plan would delete. |
| `too_large` | A resource, a warning or a top-level field has more than 200,000 JSON values. |
| `too_long` | Connection `settings` exceeds 32 KiB, or application `jwks` or a permission's `consent_text_i18n` exceeds 16 KiB. |
| `too_deep` | Connection `settings` is nested deeper than 8 levels. |
| `reserved` | The API `identifier` is in the platform's `urn:idp:` namespace. |

## Example

```bash
curl -X POST "https://api.eu.keenauth.com/v1/config-plans" \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"document\": $(cat keenauth.json), \"prune\": false}"

keenauth config plan -f keenauth.yaml
```
