
# Subscribe a webhook to the changelog

`POST https://api.keenauth.com/v1/accounts/{account_id}/changelog-subscriptions` · account owner or admin

Registers an HTTPS endpoint of your own that receives a `changelog.entry_published` webhook whenever a [changelog](/docs/changelog) entry in one of the chosen categories is published. The request is made with the credentials of `keenauth login`: the account must be the one you signed in to, and you must be its owner or admin. An account can have at most 5 enabled subscriptions.

Each subscription has its own signing secret, returned once in the response. Deliveries follow Standard Webhooks: every request carries the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and the signature (`v1,<base64>`) is an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. The HMAC key is the 32 bytes that the 43 characters after the `idp_whsec_` prefix encode (base62, big-endian); the last 6 characters are a checksum and are not part of the key. Verify the signature and reject a timestamp that is too far from the current time (the Standard Webhooks libraries allow 5 minutes) before you trust the body.

Do not put personal data, credentials or tokens in the URL: it is stored with the subscription and shown to every owner and admin of the account.

## Request

Headers:

| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | yes | `DPoP <access token>`: the access token the CLI received when you signed in. |
| `DPoP` | yes | A DPoP proof for this request (`htm` `POST`, `htu` the URL of this request), signed with the key the access token is bound to; it carries the hash of the access token and the server nonce. |
| `Content-Type` | yes | `application/json`. |
| `Idempotency-Key` | no | 16 to 64 characters of `A-Z`, `a-z`, `0-9`, `_` and `-`. Repeating the request with the same key and body within 24 hours returns the same subscription without the secret, with `secret_redacted: true` and the `Idempotent-Replayed: true` header. |

Body:

| Field | Required | Description |
| --- | --- | --- |
| `url` | yes | The endpoint: `https`, port 443, a host name (not an IP address or a local-only name such as `.localhost`, `.local` or `.internal`), no user information or fragment, at most 2048 bytes. |
| `categories` | no | Any of `api`, `sdk`, `deprecation`, `security` and `incident`, each at most once. Omit the field to receive every category; an empty array is refused. |

## Response

`201 Created` with `Cache-Control: no-store`:

| Field | Description |
| --- | --- |
| `id` | The subscription (`chlg_…`). |
| `account_id` | The account (`acct_…`). |
| `url` | The endpoint. |
| `categories` | The categories received, in the order `api`, `sdk`, `deprecation`, `security`, `incident`. |
| `status` | `enabled`, or `disabled` when deliveries kept failing. |
| `created_at` | When the subscription was created (RFC 3339, UTC). |
| `signing_secret` | The signing secret (`idp_whsec_…`). Returned only in this response; store it in your secret manager. |
| `secret_redacted` | `true` on an idempotent replay, which carries no `signing_secret`. |

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `malformed_request` | The body is not JSON, or the `Idempotency-Key` header is repeated, empty or malformed. |
| 400 | `unknown_field` | The body has a field other than `url` and `categories`. |
| 401 | `unauthenticated` | No access token was sent. |
| 401 | `invalid_token` | The access token is invalid, expired or not the CLI's; sign in again with `keenauth login`. |
| 401 | `invalid_dpop_proof` | The DPoP proof is missing, repeated or does not match the request or the token's key. |
| 401 | `use_dpop_nonce` | Retry with a proof that carries the nonce of the `DPoP-Nonce` response header. |
| 401 | `insufficient_user_authentication` | Multi-factor authentication (or, for owners and admins, a passkey or security key) is required; sign in again. |
| 403 | `insufficient_permissions` | You are not an owner or admin of the account. |
| 403 | `account_restricted` | The account's status does not allow changes. |
| 403 | `quota_exceeded` | The account already has 5 enabled subscriptions (`errors[].code` `changelog_subscription_limit_exceeded`). |
| 404 | `not_found` | The account does not exist, is not the account you signed in to, or you are not its member. |
| 413 | `payload_too_large` | The body is larger than 8 KiB. |
| 415 | `unsupported_media_type` | The body is not `application/json`. |
| 422 | `validation_failed` | `url` is missing or invalid, a category is unknown or repeated, `categories` is empty, or a field has the wrong type. |
| 422 | `idempotency_key_reuse` | The `Idempotency-Key` was used with another body within 24 hours. |
| 429 | `rate_limited` | More than 10 subscriptions were created for the account in the last hour; wait for `Retry-After`. |

## Webhook delivery

When an entry is published, KeenAuth sends one `POST` to the URL of every enabled subscription whose categories include one of the entry's categories. The same entry is never sent twice to one subscription.

### Request

| Header | Description |
| --- | --- |
| `Content-Type` | `application/json`. |
| `webhook-id` | The event `id` (`evt_…`). It is the same on every retry of the same entry, so use it to ignore duplicates. |
| `webhook-timestamp` | Unix time in seconds at which this attempt was signed. It is new on every attempt. |
| `webhook-signature` | `v1,<base64>`: the HMAC-SHA256 described above, over `<webhook-id>.<webhook-timestamp>.<body>`. |

The body is at most 16 KiB. KeenAuth does not follow redirects.

### Event

```json
{
  "id": "evt_01jb0000000000000000000000",
  "type": "changelog.entry_published",
  "schema_version": 1,
  "occurred_at": "2026-11-04T00:00:00Z",
  "recorded_at": "2026-11-04T08:30:12Z",
  "data": {
    "id": "2026-11-04-example",
    "url": "https://docs.keenauth.com/en/changelog/#2026-11-04-example",
    "title": "Example entry",
    "date_published": "2026-11-04T00:00:00Z",
    "tags": ["deprecation"],
    "categories": ["api", "deprecation"],
    "surfaces": [{"kind": "api", "name": "management-v1", "version": "v1"}],
    "breaking": false,
    "sunset_at": "2027-11-04"
  }
}
```

| Field | Description |
| --- | --- |
| `id` | The event (`evt_…`), equal to the `webhook-id` header. |
| `type` | Always `changelog.entry_published`. |
| `schema_version` | Always `1`. New optional fields may be added to `data` within version 1; ignore fields you do not know. |
| `occurred_at` | When the entry was published (RFC 3339). |
| `recorded_at` | When KeenAuth queued the delivery (RFC 3339). |
| `data.id`, `data.url`, `data.title`, `data.date_published` | The entry, as in the changelog feeds. |
| `data.tags` | One or more of `added`, `changed`, `fixed`, `removed`, `deprecation`, `security`, `breaking` and `incident`. |
| `data.categories` | The subscription categories the entry belongs to. |
| `data.surfaces` | The affected surfaces: `kind` (`api`, `sdk`, `cli`, `provider`, `mcp` or `docs`), `name` and, when known, `version`. |
| `data.breaking` | `true` when the change is breaking. |
| `data.sunset_at` | The removal date of a deprecation (`YYYY-MM-DD`), otherwise `null`. |

The machine-readable schema is `changelog.entry_published.v1`.

### Responses and retries

Answer with any `2xx` status within 15 seconds. The response body is ignored. Any other outcome is a failed attempt and is retried: a connection or TLS error, a timeout, a redirect, `429`, `5xx` and every other status except `410`. The first attempt is made at once; the following ones about 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours and 36 hours after the previous failure (each interval varies by up to 10 percent), 8 attempts in about 3 days in all.

If the eighth attempt fails, the delivery is abandoned and the subscription is disabled (`status` `disabled`). Deliveries already queued for it are cancelled.

### 410 Gone

Answer `410 Gone` to tell KeenAuth that you no longer want deliveries. The subscription is disabled at once, without further attempts, and its queued deliveries are cancelled. A disabled subscription is not enabled again and does not count towards the limit of 5; create a new one to receive entries again. Every disabling is recorded in the account audit log as `changelog.subscription_disabled`, with the reason `receiver_gone`, `permanent_failure` or `schedule_exhausted`.

## Example

```bash
curl -X POST https://api.keenauth.com/v1/accounts/acct_01jb0000000000000000000000/changelog-subscriptions \
  -H "Authorization: DPoP $ACCESS_TOKEN" \
  -H "DPoP: $DPOP_PROOF" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: changelog-hooks-2026-10" \
  -d '{"url": "https://hooks.example.com/keenauth/changelog", "categories": ["deprecation", "security"]}'
```
