diff --git a/CHANGELOG.md b/CHANGELOG.md
index 563c936..ff7c013 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## Unreleased
+
+- **Webhooks** — `webhooks:event-types` / `list` / `get` / `create` / `update` /
+ `enable` / `disable` / `delete` / `rotate-secret` / `deliveries`. Webhooks are
+ owned by the API key's user, so every workspace lists the same ones; the
+ workspace only decides whose API credits the call uses. The signing secret is
+ printed once, by `create` and `rotate-secret` only (the old secret keeps
+ signing for 24h after a rotate). `webhooks:deliveries` is the paginated
+ delivery log for diagnosing why events stopped arriving.
+- **Brand Knowledge** — `brand:get` / `section` / `create` / `update` / `delete` /
+ `source-add` / `source-delete` / `sync` / `post-settings` / `post-settings-update`,
+ backed by `/workspaces/{workspace_id}/brand`. `create`, `source-add` and `sync` run
+ the app's AI brand analysis synchronously (up to ~2 minutes), so they wait 180s
+ (`--timeout`) and are not auto-retried.
+
## 1.6.0 — content categories, approval workflows, planner share links, AI video (2026-09-17)
The public API gained five surfaces; the CLI mirrors all of them.
diff --git a/README.md b/README.md
index 6ccd626..be846e8 100644
--- a/README.md
+++ b/README.md
@@ -1407,6 +1407,24 @@ The CLI wraps these endpoints from the ContentStudio v1 public API (plus the wor
| DELETE | `/workspaces/{w}/share-links/{id}` | `planner-share-links:delete` |
| POST | `/workspaces/{w}/share-links/{id}/send-invitations` | `planner-share-links:send-invitations` |
| GET | `/workspaces/{w}/share-links/{id}/activity` | `planner-share-links:activity` |
+| GET | `/workspaces/{w}/webhooks/event-types` | `webhooks:event-types` |
+| GET | `/workspaces/{w}/webhooks` | `webhooks:list` |
+| POST | `/workspaces/{w}/webhooks` | `webhooks:create` |
+| GET | `/workspaces/{w}/webhooks/{id}` | `webhooks:get` |
+| PUT | `/workspaces/{w}/webhooks/{id}` | `webhooks:update` / `webhooks:enable` / `webhooks:disable` |
+| DELETE | `/workspaces/{w}/webhooks/{id}` | `webhooks:delete` |
+| POST | `/workspaces/{w}/webhooks/{id}/rotate-secret` | `webhooks:rotate-secret` |
+| GET | `/workspaces/{w}/webhooks/{id}/deliveries` | `webhooks:deliveries` |
+| GET | `/workspaces/{w}/brand` | `brand:get` |
+| POST | `/workspaces/{w}/brand` | `brand:create` |
+| PATCH | `/workspaces/{w}/brand` | `brand:update` |
+| DELETE | `/workspaces/{w}/brand` | `brand:delete` |
+| GET | `/workspaces/{w}/brand/{section}` | `brand:section` |
+| POST | `/workspaces/{w}/brand/sources` | `brand:source-add` |
+| DELETE | `/workspaces/{w}/brand/sources/{id}` | `brand:source-delete` |
+| POST | `/workspaces/{w}/brand/sync` | `brand:sync` |
+| GET | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings` |
+| PATCH | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings-update` |
| GET | `/workspaces/{w}/labels` | `labels:list` |
| GET | `/workspaces/{w}/team-members` | `team:list` |
| GET | `/workspaces/{w}/media` | `media:list` |
@@ -1530,6 +1548,19 @@ contentstudio --json planner-share-links:create --name "Client Review" --plan
--email a@b.c --approval-option anyone
contentstudio --json planner-share-links:activity [--type comment] # Client comments/actions
+# Webhooks (owned by your user — same list in every workspace; the workspace only pays the API credit)
+contentstudio --json webhooks:event-types
+contentstudio --json webhooks:create --url https://example.com/hook --event-type post.published --event-type post.failed # prints the secret ONCE
+contentstudio --json webhooks:deliveries --status failed # why did events stop?
+contentstudio --json webhooks:rotate-secret # old secret signs for 24h more
+
+# Brand Knowledge (one brand per workspace; create/source-add/sync run a ~2 min AI analysis)
+contentstudio --json brand:get
+contentstudio --json brand:create --website-url https://acme.coffee --social-account
+contentstudio --json brand:update --brand-voice '{"tone":["Warm","Confident"]}' # a list sent replaces that list
+contentstudio --json brand:source-add --file https://acme.coffee/brand-guide.pdf
+contentstudio --json brand:post-settings-update --social-platform linkedin --number-of-posts 5
+
# Posts
contentstudio --json posts:list [--status draft] [--date-from] [--date-to] # List posts
contentstudio --json posts:get # Read one post
diff --git a/SKILL.md b/SKILL.md
index 0d04473..8cf47fd 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -91,7 +91,7 @@ contentstudio workspaces:use
The CLI silently defaults to the active workspace (whatever was set by `workspaces:use`). That default is fine for **read-only** calls (`workspaces:list`, `accounts:list`, `posts:list`, `media:list`, etc.) — just use the active workspace.
-But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate.
+But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, `webhooks:create`, `webhooks:update`, `webhooks:enable`, `webhooks:disable`, `webhooks:delete`, `webhooks:rotate-secret`, `brand:create`, `brand:update`, `brand:delete`, `brand:source-add`, `brand:source-delete`, `brand:sync`, `brand:post-settings-update`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate.
> **AI video generation costs credits.** `ai-video:generate` and `ai-video:run-tool` submit a real (billed) job the moment they're called without `--dry-run` — run `ai-video:estimate` first when the flags support it, show the estimate/cost to the user, and `--dry-run` the actual call before running it for real. `ai-video:cancel-job` may also charge for partial work already consumed — don't cancel a job on the user's behalf without confirming.
@@ -180,9 +180,9 @@ Did the user say "all" / "every" / "complete list" / "every single"?
### Endpoints that paginate
All `*:list` commands paginate:
-`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `ai-video:jobs`.
+`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `webhooks:deliveries`, `ai-video:jobs`.
-`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`.
+`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. `webhooks:list` is likewise unpaginated (a user has at most 5 webhooks) and answers `{used, limit, data[]}`.
Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, etc.) never include `pagination` in their envelope.
@@ -453,7 +453,7 @@ Errors: 422 for unknown accounts or a workspace with no connected accounts; 502
- **`model_used` is not one of the `images:models` values** — it comes back provider-prefixed (`fal-ai/nano-banana-pro`, `pixelcut/background-removal`) and names the model that actually ran after any fallback. Report it; never compare it for equality with `--model`.
- **`images:tools` `controls` describe the underlying tool, not the public payload.** Take `--resolution` / `--aspect-ratio` values from there, but a control with no matching flag cannot be sent at all — `upscale` lists `model` and `upscale_factor`, and neither is in the API's tool payload. Likewise `accepts_instructions: true` on `headshot` and `face-swap` is not reachable: only `images:product-image` has `--instructions`. Sending an unsupported field is dropped in silence, so it will look like it worked.
- **`--dimensions`** is `square`, `square_hd`, `portrait_4_5` or `landscape_16_9`, text→image only. Exact pixels are the model's choice — read `width`/`height` back. Anything else is rejected by the CLI before the call.
-- **Brand knowledge is a boolean, read-only.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is ever accepted or returned. `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge.
+- **Brand knowledge is a boolean here.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is accepted or returned by the image commands (read or edit the brand itself with the `brand:*` commands — see **Brand Knowledge**). `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge.
- **`--dry-run` on every generating command** prints the endpoint and body and calls nothing. Use it to show the user the prompt before spending a credit. The three discovery commands are reads and need no `--dry-run`.
- **Rate limit: 30 requests/minute**, shared with the ContentStudio app's own AI usage on the same account. A `RateLimitError` here needs the full minute.
- Video tools (`image-to-video`, `motion-control`, `lip-sync`, `talking-avatar`) are **not** on this API; asking for one is `NotFoundError` (`TOOL_NOT_FOUND`), same as an unknown key.
@@ -621,6 +621,123 @@ the scheduled **posts**, optionally letting them comment or approve.
- Prefer `planner-share-links:update --disabled` over `:delete` when a client engagement is only pausing — disabling is reversible, deleting is not.
- Errors: `SHARE_LINK_NOT_FOUND` (404), `SHARE_LINK_TOKEN_REVOKE_FAILED` (422).
+### Webhooks
+
+Outbound webhooks push events (`post.published`, `post.failed`, `inbox.message.received`,
+`automation.bulk.completed`, …) to an https endpoint you own.
+
+**A webhook belongs to the API key's USER, not to a workspace.** Every workspace the user
+is a member of lists the same webhooks; a webhook created under workspace A is readable
+and editable under workspace B. The workspace (`--workspace` / active) only decides
+**whose API credits the call uses** — one credit per call. A workspace the user is not a
+member of answers 403. Each user can have **5 webhooks** (active and paused both count).
+
+| Command | Purpose |
+|---------|---------|
+| `webhooks:event-types` | Events you can subscribe to: `{value, label, group, description, payload_schema}`. Pass `value` to `--event-type` |
+| `webhooks:list` | Your webhooks, newest first, plus `used` / `limit` (5) |
+| `webhooks:get ` | Read one webhook |
+| `webhooks:create --url --event-type … [--name ] [--secret whsec_…] [--custom-headers '']` | Create — prints the signing secret ONCE |
+| `webhooks:update [--url] [--event-type …] [--name] [--custom-headers ''] [--status enabled\|disabled]` | Partial update |
+| `webhooks:disable ` / `webhooks:enable ` | Shorthand for `update --status disabled` / `--status enabled` |
+| `webhooks:delete ` | Delete a webhook |
+| `webhooks:rotate-secret ` | New signing secret, printed ONCE; the old one keeps signing for 24h |
+| `webhooks:deliveries [--status all\|successful\|failed] [--event-type] [--from] [--to] [--search] [--page] [--per-page]` | Delivery log — the "why did events stop arriving" diagnostic |
+
+- **The secret is shown only by `create` and `rotate-secret`.** `list` / `get` / `update` return `has_secret` and nothing more. When you run either command, show the user `data.secret` immediately and tell them to store it now — it cannot be fetched again. Never paste it into anything else unprompted.
+- `--secret` is optional (`whsec_`); omit it and the backend generates one. `--dry-run` redacts a supplied secret.
+- **Create pings the URL first.** The URL must be https and publicly reachable: internal addresses are refused (`WEBHOOK_URL_NOT_ALLOWED`), and the endpoint must answer the ping with a 2xx within 5s (`WEBHOOK_PREFLIGHT_FAILED`). Make sure the receiver is live before creating. A new `--url` on update goes through the same check.
+- `--event-type` is repeatable → `event_types[]` (≥1 on create). On update it **replaces** the whole list — pass every event you want to keep.
+- `--custom-headers` is a JSON object of string values sent with every delivery, e.g. `'{"X-Tenant":"acme"}'`. It cannot override the signing or system headers.
+- `status` is `enabled`, `disabled` (paused by you) or `disabled_by_system` (ContentStudio paused it after 15 consecutive failed deliveries and emailed the owner). Resume either paused state with `webhooks:enable` — that also resets the failure count. You cannot set `disabled_by_system` yourself.
+- Prefer `webhooks:disable` over `webhooks:delete` when the pause is temporary — disabling is reversible, deleting is not.
+- **Rotation has a 24h grace window**: `previous_secret_expires_at` (ISO, or null if the webhook had no secret) is when the old secret stops signing. Tell the user to deploy the new secret to their receiver before then.
+- **"My webhook stopped receiving events"** → `webhooks:get` (check `status` and `last_delivery_status`), then `webhooks:deliveries --status failed`. Each row is one event's latest attempt: `{event_id, event_type, outcome, response_status, duration_ms, is_test, created_at}`. `outcome` is `delivered` / `failed` / `retrying` / `skipped_out_of_credits` (not sent — the account's API credit pool ran out; successful deliveries cost one credit each, tests/pings are free). `response_status: null` means the endpoint was unreachable (DNS, TLS, refused, timeout). `--search` takes an exact event ID or part of an event type; `--from` / `--to` are ISO 8601 (no offset = UTC). Deliveries are kept 30 days.
+- Not available through the API: sending a test event, opening a single delivery's request/response bodies, and CSV export — those are in Settings → Webhooks in the web app.
+- Errors: `WEBHOOK_NOT_FOUND` (404 — includes webhooks owned by someone else), `VALIDATION_ERROR` (422), `WEBHOOK_LIMIT_REACHED` (422 — already 5), `WEBHOOK_URL_NOT_ALLOWED` (422), `WEBHOOK_PREFLIGHT_FAILED` (422), `WEBHOOK_REQUEST_FAILED` (500), `API_ACCESS_NOT_ALLOWED` / `API_CREDIT_LIMIT_EXCEEDED` (403), `RATE_LIMIT_EXCEEDED` (429).
+
+### Brand Knowledge
+
+The workspace's **brand** — the app's Brand Knowledge editor (Brand Style, Brand Profile,
+Brand Voice, Source Materials, Media Assets). ContentStudio's AI (composer, inbox, RSS,
+Evergreen, AI images with `--use-brand`) resolves it server-side; nothing here passes brand
+content into a generation request. **One brand per workspace.** Any workspace member can
+read and edit it, and edits show in the app immediately.
+
+| Command | Purpose |
+|---------|---------|
+| `brand:get` | The brand object. No brand → `is_set_up: false`, empty sections, null timestamps (a 200, not an error) |
+| `brand:section