# dmswift facts

- dmswift automates Instagram through Meta's official API: comment-to-DM campaigns, DM keyword replies, story replies, inbox, contacts, publishing and scheduling, and insights.
- Instagram is live. Facebook Pages are coming soon for new accounts, until Meta approves Page messaging.
- An MCP server lets AI agents (Claude, ChatGPT, Grok, Cursor and others) run the account: https://app.dmswift.com/mcp
- If list_pages returns an empty list, no channel is connected yet. Ask the user to open https://app.dmswift.com/app and press Connect Instagram (an Instagram Business or Creator account is needed).
- Sign-up is with Google only. The free trial is 7 days, no card.
- Plans and prices: https://dmswift.com/pricing . Refunds: 14 days, first payment only (https://dmswift.com/refund).
- Contacts are never metered; sends are.
- Full marketing facts for agents: https://dmswift.com/llms.txt

---

# dmswift - API & MCP

Run an Instagram professional account from your AI agent or your own code (Facebook Pages are
coming soon and open only to testers for now):
publish and schedule posts on both, run comment campaigns that reply publicly and by private
message, read and moderate comments, read insights and the inbox - plus a customer inbox on Meta's
official APIs that only replies to people who wrote to you first.

This document is written to be handed, as-is, to an LLM agent or a developer. Everything an
agent needs to operate the system is here.

- Base URL: `https://app.dmswift.com`
- MCP endpoint: `POST https://app.dmswift.com/mcp` - add it as a custom connector; sign-in is OAuth 2.1 (§2)
- Machine-readable spec: `GET https://app.dmswift.com/openapi.json` ("dmswift API") - reports API version **3.1.0**
- This document: `GET https://app.dmswift.com/llms.txt`
- MCP registry manifest: `server.json` in the repository, name `com.dmswift/dmswift`

The MCP server identifies itself as `dmswift` version **3.1.0**.

Use of this API is subject to https://dmswift.com/acceptable-use. Never message people who did not contact the business first.

---

## 1. Concepts

**Channel** - a connected account, addressed by its Meta id. Two kinds exist:

- a **Facebook Page** (`platform: "facebook"`), connected by OAuth;
- an **Instagram professional account** (`platform: "instagram"`), connected directly with
  Instagram Login, no Facebook Page needed. It has its own id, its own campaigns and its own
  activity log. One connected through a linked Facebook Page instead holds no credential of its
  own - reconnecting the parent Page re-authorizes it.

Facebook Pages are coming soon for new accounts and open only to testers for now, because Meta has
not yet approved messaging for Pages.

Everything below says "Page" where the API parameter is literally `page`, because that is the
parameter name and it takes either kind of id. Where a capability is Facebook-only, it says so.

**Post** - content managed by this system, on a Facebook Page or an Instagram account. A post has a lifecycle:

```
draft ──────► scheduled ──────► published
  │                                 ▲
  └────────── publish now ──────────┘
                                  error / canceled
```

Creating a post never publishes it. Publishing is a separate, explicit step. On an Instagram channel
the same routes take `ig_type` (`feed`, `carousel`, `reel`, `story`), a caption in `message`, and media
in `media_ids` / `media_urls` - one item for feed, reel and story, 2-20 for a carousel - plus
`cover_url` and `share_to_feed` (default true) for reels and an optional `first_comment`. Scheduling,
publish, status and duplicate behave as they do on Facebook; Instagram schedules are run by
dmswift's own worker. Instagram publishing counts against the plan's post allowance like Facebook.
One difference: Meta's API cannot delete Instagram media, so deleting a published Instagram post
removes it from dmswift only (see §3, Instagram publishing).

**Campaign** - an automation rule attached to a channel. When its trigger fires, the campaign
matches the incoming text against its keywords and responds: optionally a public reply, and a
private message to the person. A campaign either applies to one post (`post_id` set) or to every
post on the channel (`post_id` empty).

**Trigger** - what starts a campaign. One of:

| `trigger` | Fires on | Platforms |
|---|---|---|
| `comment` | a comment on a post or reel - the original behaviour, and the default | Facebook + Instagram |
| `dm` | an inbound direct message matching the keywords | Facebook + Instagram |
| `story_reply` | a reply to one of the account's stories | Instagram |
| `mention` | the account was @mentioned in a story | Instagram |

Campaigns written before triggers existed carry `''` and are treated as `comment`. A campaign whose
trigger is not `comment` may not set `public_reply` or `post_id` - there is no comment to answer in
public and no post to bind to - and the API rejects both rather than silently ignoring them.

Quiet hours, daily caps, once-per-user, variants and personalization work identically for all four,
because they are one table rather than four features. Comment campaigns are matched by the webhook's
comment path; the other three are matched when an inbound message is recorded in the inbox.

The two combine: create a post with a campaign attached, and the moment the post goes live the
campaign starts answering its comments.

**Account and plan** - every channel belongs to one account, and every account has a plan that
caps how many channels, campaigns, monthly private messages, monthly published posts, monthly AI
replies, monthly broadcast runs and team seats it gets, and whether AI and API access are included.
Exceeding a cap is a `402` with `code: "plan_limit"` (see Errors) - never a transient failure.

**Delegated access, and what it means for a key.** An account owner can invite other people, who
then act on the owner's channels while every page, contact, post and quota stays with the owner. A
key resolves to the human who created it, and it inherits that human's delegated access - so a key
created by an invited member can name the inviting account's channels with `?page=`, and
`GET /api/pages` returns them. If that is not what you want, pin the key (§2). Team management
itself is never reachable with a key.

**Meta app** - the Facebook app the automation runs through. An account can plug in its own
(Settings → "Your Meta app"), which is how a business operates its own Pages under Meta's
Standard Access without waiting on dmswift's App Review. Accounts that don't bring one use the
platform app. Either way the webhook URL is the same; the per-account verify token is what
tells them apart.

---

## 2. Authentication

Every request carries an API key as a bearer token:

```
Authorization: Bearer fbcb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Keys are created in the dashboard on the **AI agents** page. A key is shown once, at creation, and
cannot be recovered afterwards. MCP clients that support sign-in never need one - see OAuth below.

A key is deliberately weaker than the human account that created it:

- it holds only the scopes it was granted;
- it may be pinned to specific Pages, and is then refused on every other Page - and a pinned key
  must *name* a Page with `?page=`, because inheriting "the account's first Page" was a way past
  the pin (`code: "page_required"`);
- it can never reach account administration, the Meta app credentials, or key management -
  a key cannot create another key;
- it carries daily caps per kind: **15 publishes** (a schedule counts), **30 comments** (a first
  comment and a comment reply count) and **100 private messages** by default, per UTC day;
- every write it makes counts as an **agent action** against the plan's monthly allowance; reads are free;
- it can be revoked without disturbing the owner's dashboard sessions.

`GET /api/keys/self` tells a key where it stands - its scopes, pinned channels, approval mode, what
is left of each daily cap, the plan, and the monthly agent actions used - and is open to every key:

```json
{
  "key": { "id": 12, "name": "Claude (OAuth)", "scopes": ["pages:read", "posts:read"], "page_ids": [], "approval": "auto", "oauth": true, "expires_at": "2026-10-20T13:00:00.000Z" },
  "caps": { "publish": { "cap": 15, "used": 2, "remaining": 13, "resets_at": "2026-10-21T00:00:00.000Z" }, "comment": { "cap": 30, "used": 0, "remaining": 30, "resets_at": "…" }, "dm": { "cap": 100, "used": 0, "remaining": 100, "resets_at": "…" } },
  "plan": { "code": "pro", "name": "Pro", "api": true },
  "agent_actions": { "used": 41, "limit": 2000, "remaining": 1959 }
}
```

`GET /api/billing/status` (any scope) adds the subscription view: plan, status, trial end, limits and usage.

### OAuth 2.1 (MCP clients)

dmswift is its own OAuth 2.1 authorization server, following the MCP authorization specification,
so Claude (web, desktop, mobile), Claude Code, ChatGPT custom MCP servers, Cursor and any
other compliant client connect with nothing but the URL `https://app.dmswift.com/mcp`:

1. `POST /mcp` without a token answers `401` with
   `WWW-Authenticate: Bearer resource_metadata="https://app.dmswift.com/.well-known/oauth-protected-resource"`.
2. `GET /.well-known/oauth-protected-resource` (also at `…/oauth-protected-resource/mcp`, RFC 9728) names
   the resource `https://app.dmswift.com/mcp`, the authorization server and `scopes_supported`.
3. `GET /.well-known/oauth-authorization-server` (RFC 8414) lists the endpoints. `code_challenge_methods_supported`
   is `["S256"]`, `token_endpoint_auth_methods_supported` is `["none"]` (public clients), and
   `authorization_response_iss_parameter_supported` is `true` - every authorization response carries `iss` (RFC 9207).
4. `POST /oauth/register` (RFC 7591, JSON) registers a public client. Redirect URIs must be `https`, or
   `http` on `localhost` / `127.0.0.1` (any port is accepted at authorization time for loopback, RFC 8252).
   Other URIs in the request are dropped; none acceptable is `400 invalid_redirect_uri`.
   **Or skip registration** with a Client ID Metadata Document (the metadata says
   `client_id_metadata_document_supported: true`; this is what Claude's default "Use Claude's published
   identity" sends): the `client_id` is the `https` URL of a JSON document the client publishes. dmswift
   fetches it when the authorization request arrives - `https` on port 443 only, with a path; public
   addresses only (private, loopback, link-local/metadata, CGNAT, ULA and IPv4-mapped forms are refused,
   and the connection is pinned to the address that was checked); no redirects; 5 s; at most 64 KB;
   served as `application/json`. The document's `client_id` must equal the URL exactly, it must be a
   public client (`token_endpoint_auth_method` `none` or absent, no secret), and its `redirect_uris`
   follow the registration rule above (unacceptable ones dropped, none left is refused); the request's
   `redirect_uri` must be one of them. A valid document is cached for its `Cache-Control: max-age`
   (5 minutes to 24 hours, 1 hour if absent), a failure for a minute, and fetches are rate-limited per
   host. The consent screen names such a client together with its host - *Claude · claude.ai* - so a
   look-alike name cannot pass for another app. `POST /oauth/token` checks the document again (from the
   cache): one withdrawn since is `401 invalid_client`.
5. `GET /oauth/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`,
   `code_challenge_method=S256`, `state`, optional `scope` (space-separated scopes from §2; unknown names
   such as `offline_access` are ignored, none means all) and `resource` (must be this server's `/mcp`
   URL, else `invalid_target`). A malformed request (no S256 challenge, wrong `response_type`, foreign
   `resource`, unknown client or redirect) is shown as an error page and never redirected - open
   registration must not turn this origin into a redirector. The person signs in to the dashboard if needed and sees a consent screen
   in their language (one mobile-first screen, details collapsed) with the client's name, where it returns to, and
   the same limits an API key has: **channels** (all, the default, or any subset of Facebook Pages and
   Instagram accounts), **what it may do** (a preset - *Read only*, *Read + draft*, *Full*, the default -
   or single scopes under *Advanced*; always inside what the client asked for), **"Ask me before anything
   is published"** (off by default: the connection acts on its own within the daily caps; on, it is an
   approval-mode key - see §6, 202) and the **daily caps** (default 15 publishes, 30 comment replies,
   100 messages). See *Connections and limits* below.
6. `POST /oauth/token` (`application/x-www-form-urlencoded`):
   - `grant_type=authorization_code` with `code`, `code_verifier`, `client_id` (and optionally
     `redirect_uri`, `resource`). Codes live 5 minutes and are single-use; a replayed code ends the
     connection it produced.
   - `grant_type=refresh_token` with `refresh_token`, `client_id`. **Refresh tokens rotate**: each use
     returns a new pair and the old refresh token stops working; presenting a rotated-away token again
     later ends the connection.
   - The answer: `{ "access_token": "fbcb_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "fbrt_…", "scope": "…" }`.
     Errors are RFC 6749 shaped: `invalid_grant`, `invalid_target`, `unsupported_grant_type`.
7. `POST /oauth/revoke` (RFC 7009) with `token` (either token) ends the connection; always `200`.

**The access token is an API key** with the granted scopes, channel pin and approval mode, valid for an
hour, so everything in this document applies to it unchanged - scopes, pinning, caps, the plan gate.
Its secret rotates on refresh but the key itself stays the same, so caps and the agent-action count
carry across refreshes. Connected apps are listed on the dashboard's **AI agents** page, where the owner
can disconnect any of them at once.

A key pinned to channels lists only those in `GET /api/pages` (and `list_pages`); naming any other
channel is refused (`403 page_forbidden`), and a page-scoped route called without a channel is
`400 page_required`.

### Connections and limits

A connection made by sign-in is exactly as controllable as a pasted key, before and after it is made.

**Presets** (computed over the scopes on offer - every scope for a key, the client's request for a
connection):

| Preset | Scopes | Approval |
|---|---|---|
| `read` | every `*:read` scope | unchanged |
| `draft` | `*:read` + `posts:write`, `posts:publish`, `campaigns:write`, `contacts:write` (no `kb:write`: a knowledge-base entry feeds the live AI reply at once) | forced `ask` - a publish parks for approval (202); scheduling, public replies, switching a campaign on, hiding or deleting comments, deleting a published post from the platform and every knowledge-base write (`approval_required_for_kb`, whatever scopes the key holds) are refused |
| `full` | everything on offer | unchanged |

Any other combination reads back as `custom`.

```http
GET    /api/keys                 → { keys: [Key], scopes: [..every scope], presets: { read: [..], draft: [..], full: [..] } }
GET    /api/keys/oauth-apps      → { apps: [App] }
PATCH  /api/keys/:id             { preset? | scopes?, page_ids?, approval?, daily_cap?, comment_cap?, dm_cap? } → { ok, key: KeyLimits }
DELETE /api/keys/oauth-apps/:id  → { ok }   (ends the connection: refresh token dead, access token revoked)
DELETE /api/keys/:id             → { ok, purged: false }   (?purge=1 removes the row: { ok, purged: true })
```

Both lists show **live credentials only**: a deleted key (its row is kept, revoked, so what it did keeps
its attribution) and the token of a connection that was disconnected or whose refresh token lapsed are
gone from `GET /api/keys` and `GET /api/keys/oauth-apps`; the audit log keeps their history. Deleting
an already-deleted key answers `{ ok: true, purged: false, already_revoked: true }` (no second audit
entry); PATCH of one is `404 err.key_not_found`. Only live keys and live connections count toward the
plan's `max_keys`.

- `Key`: `{ id, name, prefix, scopes, page_ids, approval, daily_cap, comment_cap, dm_cap, active, expires_at,
  last_used_at, created_at, oauth, oauth_app_id, client_name, allowed_scopes, preset }` - `oauth` is true
  for the access token of a connection (`oauth_app_id` is its id in `/api/keys/oauth-apps`).
- `App`: `{ id, key_id, name, client_name, redirect_host, scopes, allowed_scopes, preset, page_ids, approval,
  daily_cap, comment_cap, dm_cap, created_at, connected_at, last_used_at }` - `key_id` is what PATCH takes.
- `KeyLimits`: `{ id, scopes, page_ids, approval, daily_cap, comment_cap, dm_cap, oauth, allowed_scopes, preset }`.

PATCH sends only what changes. `preset` and `scopes` are mutually exclusive (`err.key_preset_invalid`);
every scope must exist (`err.key_scope_unknown`) and, for a connection, be one its client asked for
(`err.key_scope_not_requested`); at least one (`err.api_key_scope_required`). `page_ids` is an array of
channels the account can reach, own or by delegation (`err.page_not_in_account`); `[]` means every
channel. `approval` is `auto` or `ask` (`err.key_approval_invalid`). Caps are whole numbers 0…the plan's
monthly agent actions, 1000 on an unlimited plan (`err.key_cap_out_of_range`; 0 = no daily cap). An
empty body is `err.key_cap_required`. Session only (`403` with a key). Changes apply on the key's
**next request** - nothing is cached. A refresh never widens a connection: it rotates only the secret,
and a `scope` in the refresh request is ignored.

### Scopes

| Scope | Grants |
|---|---|
| `pages:read` | List channels; check what a Page's token can do |
| `posts:read` | Read posts and their status |
| `posts:write` | Create, edit and delete posts; upload media |
| `posts:publish` | Put a post live (or schedule it) on Facebook or Instagram; comment as the Page on Facebook |
| `campaigns:read` | Read campaigns; dry-run comment matching |
| `campaigns:write` | Create and edit campaigns |
| `analytics:read` | Statistics, the activity log and the analytics reports |
| `contacts:read` | Read the contact list, its tags and its stats |
| `contacts:write` | Tag contacts |
| `kb:read` | Read and search the business knowledge base |
| `kb:write` | Add knowledge base entries |
| `inbox:read` | Read inbox conversations |
| `inbox:write` | Reply in the inbox - inside Meta's 24-hour window, never while a human took over, never for approval-mode keys |
| `comments:read` | Read comments on live posts |
| `comments:write` | Reply to comments (counts as a comment) and like them (Facebook only) |
| `comments:moderate` | Hide, unhide and delete comments |
| `insights:read` | Meta insights for channels and posts |

`posts:write` and `posts:publish` are separate on purpose: drafting content and putting it in
front of an audience are different levels of trust. A key that may draft but not publish is a
safe default for a new agent.

The read/write split is deliberately uneven on contacts and the knowledge base: a key can *add*
knowledge and *tag* a contact, but cannot edit or delete either. Destructive operations on a
customer's own records stay with a human. See §7 for the full picture.

---

## 3. MCP

The MCP server speaks Streamable HTTP at `POST /mcp` and authenticates with an OAuth access token
or an API key, both as a bearer token. It is stateless - each request is independent, no session id
is needed.

### Supported AI clients

MCP URL `https://app.dmswift.com/mcp`. Sign-in is OAuth 2.1 (with dynamic client registration);
the alternative is an API key sent as `Authorization: Bearer <key>`. MCP is included from the
$19/month Starter plan.

| Client | How |
|---|---|
| Claude (web, desktop, mobile) | Customize → Connectors → + Add → Add custom connector → name it, the URL, keep the recommended option (Use Claude's published identity) → Add → Connect, then sign in (OAuth). Added once on the web or desktop, it also works in the mobile apps |
| Claude Code | `claude mcp add --transport http --scope user dmswift https://app.dmswift.com/mcp`, then `/mcp` → Authenticate (or add `--header "Authorization: Bearer <key>"`) |
| ChatGPT (and dots) | chatgpt.com/plugins (in a browser) → + → Add custom MCP server → name it, the URL under Connection, authentication OAuth → tick I understand and want to continue → Create as a plugin. In a chat, type `@` and pick it. ChatGPT has no API-key option. dots use the plugins you added |
| Grok | grok.com/connectors → New Connector → Custom → the URL, then complete the sign-in Grok asks for (xAI documents custom connectors for Business/Enterprise plans on grok.com). Grok Bot accepts "Add this MCP server: <url>" in chat |
| Gemini | gemini.google.com → Settings → Connected Apps → Custom apps → Add a custom app → the URL (web only; US personal accounts) |
| Perplexity | Plugins → Add custom → Connector → Add MCP connector → Remote → the URL; OAuth 2.0 or API Key |
| Poke (iMessage, SMS, WhatsApp, Telegram) | poke.com/integrations/new → Name, MCP Server URL, API Key → Create Integration. dmswift uses Streamable HTTP at `https://app.dmswift.com/mcp` (no SSE endpoint): choose Streamable HTTP if asked |
| Instinct | tested by dmswift (Instinct publishes no MCP docs): ask Instinct to connect to dmswift and send it the MCP link `https://app.dmswift.com/mcp` |
| OpenClaw (WhatsApp, iMessage, Telegram, …) | `openclaw mcp add dmswift --url https://app.dmswift.com/mcp --transport streamable-http`, then a `headers` bearer key in the config, or `auth: "oauth"` + `openclaw mcp login dmswift` |
| Cursor | the Add to Cursor button under AI agents in the dashboard (a `cursor://anysphere.cursor-deeplink/mcp/install` link), or Cursor's MCP settings / `~/.cursor/mcp.json`: `{ "mcpServers": { "dmswift": { "url": "https://app.dmswift.com/mcp" } } }`, then the sign-in prompt next to dmswift |
| VS Code (Copilot agent mode) | `.vscode/mcp.json`: `{ "servers": { "dmswift": { "type": "http", "url": "https://app.dmswift.com/mcp" } } }` |
| n8n, Make, Zapier | the MCP Client node / module / app with the URL and an API key as a Bearer token |
| Muse (Meta) | **via API, not MCP**: ask Muse to create a Custom Connector from `https://app.dmswift.com/openapi.json` and give it an API key |

Not supported: Meta AI (it cannot add outside tools).

```json
{
  "mcpServers": {
    "dmswift": {
      "type": "http",
      "url": "https://app.dmswift.com/mcp",
      "headers": { "Authorization": "Bearer fbcb_your_key_here" }
    }
  }
}
```

OpenClaw (an agent people talk to on WhatsApp), in its config under `mcp.servers`:
`"dmswift": { "url": "https://app.dmswift.com/mcp", "transport": "streamable-http", "headers": { "Authorization": "Bearer ${DMSWIFT_KEY}" } }`

**Only the tools your key's scopes allow are registered.** If a tool is missing from
`tools/list`, the key lacks its scope - the fix is a new key (or reconnecting), not a retry.

Every tool carries MCP **annotations** - `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`,
`openWorldHint` (it reaches Meta or people outside dmswift) - and the list is always in the same order.
Results are compact JSON text, and the same object as `structuredContent` when the result is an object.
A failed call is `isError: true` with the API's error body plus **`next_step`**: one sentence saying
what to do (stop and report a `daily_cap` until `resets_at`, tell the owner about a `plan_limit`,
call `list_pages` after `page_forbidden`, …).

Send one JSON-RPC message per POST. A batch of up to 5 messages is accepted for older clients and
counts as that many requests against the connection's limit; a longer one is refused with `-32600`
(`data.code` `err.mcp_batch_too_large`) and nothing in it runs.

The server also offers two **prompts** - `launch_comment_campaign_for_latest_post` and
`weekly_performance_report` - and two **resources**: `dmswift://docs/agent-guide` (how to use this
server) and `dmswift://pages` (the channels, with `pages:read`).

If the account's plan does not include the API, `/mcp` answers every call with a JSON-RPC error
`-32001` whose `data` is `{ "code": "plan_limit", "upgrade_url": "…" }` - readable by the model, so it can
tell the owner.

MCP is a thin adapter over this app's own REST API: every tool call goes back through `/api`
carrying the caller's key, so a tool can never quietly outgrow what the same key could do over
REST. A tool whose REST route is closed to keys therefore fails the same way REST does - see §7.

### Tools

| Tool | Scope | What it does |
|---|---|---|
| `get_account_status` | - | Permissions, channels, approval mode, caps left today (`GET /api/keys/self`) and, under `billing`, plan, trial, monthly limits, usage and `upgrade_url` (`GET /api/billing/status`, without the price list) |
| `list_pages` | `pages:read` | Channels this key may act on. **Call this first.** |
| `check_page_permissions` | `pages:read` | Whether this channel can publish (`instagram_content_publish` on Instagram) |
| `list_posts` | `posts:read` | Posts managed through this system, filterable by status (`draft`, `scheduled`, `published`, etc.); rows carry `platform` and the Instagram fields |
| `list_live_posts` | `posts:read` | Recent live posts/reels directly from Facebook or Instagram (pass `include_scheduled: true` to include Facebook scheduled posts) |
| `list_scheduled_posts` | `posts:read` | Scheduled posts both directly from Facebook (`/{page-id}/scheduled_posts`) and from dmswift (`local`); Instagram has only dmswift's queue |
| `get_post` | `posts:read` | One post in full, including permalink, errors, and attached `media` (`media_id`, `signed_path`) |
| `upload_media` | `posts:write` | Store an image or video by `url` (preferred: dmswift downloads it), `file` (a file the user attached in ChatGPT) or `data` base64 (tiny files, with `expected_sha256`), and get back `media_id` (`file`), `signed_url`, `size` and `sha256` |
| `render_text_card` | `posts:write` | dmswift draws a text-on-background JPEG (or up to 10 for a carousel) and returns `media_ids`. No image to make or upload; works from any sandbox |
| `get_brand_kit` / `set_brand_kit` | `posts:read` / `posts:write` | The channel's default card look: preset or gradient, text color, footer, logo |
| `create_post` | `posts:write` | Create a draft or a scheduled post on Facebook or Instagram: media (`media_ids` / `media_urls`), alt text, first comment, a colored background (Facebook), `ig_type` / `cover_url` / `share_to_feed` (Instagram), and an attached campaign |
| `update_post` | `posts:write` | Edit an unpublished post; omitted fields (Instagram ones included) stay as they are |
| `duplicate_post` | `posts:write` | Clone an existing post into a fresh draft (Instagram type and reel cover included) |
| `delete_post` | `posts:write` | Delete, optionally from Facebook too, which spends the key's publish cap (given back if Graph refuses). Instagram media cannot be deleted through the API: removed from dmswift only (`remote_deleted: false`) |
| `publish_post` | `posts:publish` | Put a post live on Facebook or Instagram. Supports `dry_run`. |
| `comment_on_post` | `posts:publish` | **Facebook only.** Comment as the Page on one of its own published posts (or reply to a comment thread on it), with text and/or an image (`media_id`, `media_data` base64, or `media_url`). On Instagram use `reply_to_comment`, or `first_comment` on `create_post` |
| `list_comments` | `comments:read` | Comments on the channel's live posts (Facebook and Instagram), newest first, paged by `next_cursor` |
| `reply_to_comment` | `comments:write` | Public reply under a comment, Facebook or Instagram (counts as a comment; refused for approval-mode keys) |
| `like_comment` | `comments:write` | Like a comment - Facebook only |
| `hide_comment` | `comments:moderate` | Hide or unhide a comment |
| `delete_comment` | `comments:moderate` | Delete a comment permanently |
| `list_conversations` | `inbox:read` | Inbox conversations, with `counts`; filter by `status` (`open` default, `done`, `snoozed`, `all`), `unread`, `q`; page with `offset` |
| `get_conversation` | `inbox:read` | One conversation's messages plus `window_open` and `takeover_active`; older messages with `before` |
| `reply_to_conversation` | `inbox:write` | Private reply inside Meta's 24-hour window, never while a human took over (counts as a dm) |
| `get_insights` | `insights:read` | Channel reach, views, followers and engagement from Meta, with daily series |
| `get_post_insights` | `insights:read` | Metrics for one live post or reel |
| `list_campaigns` | `campaigns:read` | Campaigns with hit counts |
| `get_campaign` | `campaigns:read` | One campaign in full |
| `create_campaign` | `campaigns:write` | New comment, DM, story reply or mention automation rule |
| `update_campaign` | `campaigns:write` | Partially update an existing campaign |
| `delete_campaign` | `campaigns:write` | Delete a campaign |
| `set_campaign_active` | `campaigns:write` | Pause or resume a campaign |
| `test_campaign_match` | `campaigns:read` | Dry-run a comment against the campaigns |
| `get_stats` | `analytics:read` | Totals for a channel |
| `get_activity_log` | `analytics:read` | What the bot recently did, and whether it worked |
| `get_analytics` | `analytics:read` | The funnel, headline metrics with period-over-period change, and `best_times` (peak hours/days to post) |
| `get_unanswered_questions` | `analytics:read` | Comments that matched no campaign, deduplicated and ranked by how often they were asked. **Each one is a campaign worth creating** - the single most actionable report the product produces |
| `list_contacts` | `contacts:read` | Search and filter the people who engaged: text, tags, platform, subscribed state, source, recency |
| `tag_contact` | `contacts:write` | Add tags to one contact. Tags are how segments are built |
| `search_knowledge` | `kb:read` | Retrieve what this business wrote about itself. Use before answering anything factual - prices, hours, policies - instead of guessing |
| `add_knowledge` | `kb:write` | Record a fact about the business. One fact per entry, with a title someone would search for. Refused for an approval-mode connection (`approval_required_for_kb`): what the knowledge base says, the AI says unreviewed |

`create_post`, `reply_to_comment` and `reply_to_conversation` accept an optional `idempotency_key`. For 24 hours a retry with the same key returns the first result and does nothing else. The same key on another tool or with other arguments answers `409 err.idempotency_key_reused`, a retry while the first call runs answers `409 err.idempotency_in_progress`, and a call that failed forgets its key. A call with an unknown outcome keeps its key: when Meta does not answer (a timeout or a dropped connection, `504 graph_timeout`) the write may have landed, so a retry answers `err.idempotency_in_progress` until you have checked. A DM in this state shows in `get_conversation` with `status: "pending"` and an `error` starting `outcome unknown`.

The comment, inbox and insights tools sit on the routes in §4 ("Comments, insights and the inbox").

Recommended workflow: `list_pages` → `list_live_posts` → `create_campaign` (bound with `post_id`) →
`test_campaign_match` → `get_stats`. An agent asked to "improve this page" should start with
`get_unanswered_questions`, then `search_knowledge` to check whether the answer already exists, then
`create_campaign`.

Other workflows the server's instructions teach:

- **Instagram post:** `create_post` with `ig_type` and the image's public URL in `media_urls` (or
  `media_ids` from `upload_media` / an upload link) → show the draft → `publish_post` with `dry_run`,
  then for real.
- **Text post (no photo):** `render_text_card` (`slides` for a carousel) → `create_post` with its
  `media_ids` → show the draft → `publish_post`. Only text crosses the connection.
- **Comments:** `list_comments` → `reply_to_comment` for real questions (after `search_knowledge`),
  `hide_comment` for spam (reversible; preferred), `delete_comment` only when asked, `like_comment` on
  Facebook.
- **Inbox:** `list_conversations` (`unread: true`) → `get_conversation` → `reply_to_conversation` while
  `window_open` and not `takeover_active`.
- **Results:** `get_stats` and `get_activity_log` for campaigns; `get_insights` and `get_post_insights`
  for Meta's reach and views.

Error results carry `next_step` for the cases an agent meets here too: `permission_missing` (reconnect
the channel), `human_takeover`, `err.inbox_window_closed`, `err.ig_publish_limit`,
`err.publish_interrupted` (never retry) and `err.posts_facebook_only`.

### Worked example

Publishing a post that answers its own comments:

1. `list_pages` → take the `id` of the channel you want.
2. `create_post` with the Page id, the text, optional `first_comment` or `bg_preset`, and a `campaign`:

```json
{
  "page": "1247943345074815",
  "message": "We just launched a new course! Want details - comment 'price' below",
  "first_comment": "Full syllabus and registration: https://example.com/course",
  "campaign": {
    "name": "Price inquiries",
    "keywords": "price,cost,details",
    "public_reply": "We sent you a DM 🙂",
    "dm_text": "Hi {name}, here are the course details...",
    "button_title": "Course syllabus",
    "button_url": "https://example.com/course",
    "like_comment": true
  }
}
```

3. `test_campaign_match` with `text: "how much does it cost?"` → confirm the campaign matches
   before anyone sees the post.
4. `publish_post` with `dry_run: true` → confirm it is publishable.
5. `publish_post` → live. The campaign now answers comments on it automatically, and `first_comment` is posted under the story.

### Colored backgrounds, first comment & commenting with images

- `bg_preset`: Facebook colored-background preset ID (`text_format_preset_id`), supported on text-only posts up to 130 characters (no link or media). Valid preset IDs:
  `1881421442117417` (black), `106018623298955` (purple), `1903718606535395` (red), `219266485227663` (magenta), `1289741387813798` (dark red), `249307305544279` (red-blue gradient), `200521337465306` (fire), `1679248482160767` (blue-white gradient), `127541261450947` (soccer green), `143093446467972` (blue sky), `931584293685988` (aqua gradient), `303063890126415` (orange-purple gradient).
- `first_comment`, `first_comment_media_id`, `first_comment_media_data` & `first_comment_media_url`: Up to 8,000 characters and/or an image posted by the Page as the first comment right after the post goes live (ideal for placing external URLs or infographics in the comments without reducing organic feed reach).
- `comment_on_post`: Post an additional comment as the Page on any published post at any time - pass `post_id` (or `fb_object_id` for a live Facebook post or comment thread), `message`, and/or an image via:
  - `media_id`: the `file` / `media_id` of an image already uploaded to dmswift (e.g. `"a1b2c3d4...png"` or `"/api/uploads/a1b2c3d4...png"` from `upload_media`, `POST /api/uploads`, or `get_post`),
  - `media_data`: base64-encoded image bytes (or `data:image/png;base64,...`) + optional `media_mime`,
  - `media_url`: a public URL, a signed dmswift upload URL (`/api/uploads/<file>?sig=...`), or an `/api/uploads/<file>` URL (which is resolved directly from disk without requiring a second download).

### Merge tokens

These are replaced in `dm_text`, `public_reply` and `postback_text` before sending. Longer tokens
are substituted first, so a short token never partially consumes a longer one.

| Token | Also written as | Becomes |
|---|---|---|
| `{name}` | `{first_name}`, `{שם}` | the person's first name |
| `{last_name}` | `{שם_משפחה}` | the surname (empty when only one name is known) |
| `{full_name}` | `{שם_מלא}` | the whole name as the platform reports it |

The Hebrew spellings exist because tenants writing Hebrew campaign copy already type them; they are
template syntax, not translated UI copy. On Facebook, Messenger exposes only a first name without
extra permissions. On Instagram the profile gives a username where Messenger gives a name, so
`{name}` falls back to the username. A missing name never fails a send - it yields an
un-personalized but delivered message.

Newline-separated `public_reply` variants keep repeated replies from looking automated. The variant
is chosen by hashing the comment id, not at random: different commenters get different lines, and a
retry of the same comment picks the same line it would have sent the first time. Set
`dm_variants: 1` to do the same with `dm_text`.

### Scheduling

Pass `schedule_at` to `create_post` as a unix timestamp at least 10 minutes in the future.
On Facebook, scheduling is handed to Facebook itself, so the post goes out at the appointed time even if
this server is down. Instagram has no native scheduler: its schedules are run by dmswift's own worker.

### Media

Pass `media_urls` - a list of image or video URLs (and optional `media_alts` for image accessibility alt text). The server downloads and attaches them.
Allowed: JPG, PNG, GIF, WEBP, MP4, MOV; photos up to 25MB each, videos up to 300MB (`VIDEO_MAX_MB`); 10 per post
on Facebook (Instagram: see below). A link preview and attached media are mutually exclusive; media wins.

URLs must be publicly reachable, and this is the way in for an agent whose sandbox cannot reach
dmswift (the URL an image generator returned goes straight in). Up to 3 redirects are followed, each
hop checked again (an address inside a private network is refused at any hop; a loop is
`err.media_redirect_loop`, a fourth redirect `err.media_too_many_redirects`). A photo URL gets 20 s for
all its hops and a video URL 120 s (`VIDEO_FETCH_TIMEOUT_S`); a post's URLs are fetched three at a time
within 60 s together (135 s for videos), and files fetched for a post that is then refused are deleted
again. Downloads stream to disk and stop at the cap; a partial file is removed. A generic content type (`application/octet-stream`, none at all) is judged by the bytes; a
web page is refused (`err.media_url_not_media`). The files fetched for one `create_post` /
`update_post` count as that one agent action.

### Instagram publishing

`create_post`, `update_post`, `publish_post`, `duplicate_post` and the list tools take an Instagram
channel's id exactly as they take a Facebook Page's. What changes is the content rules, enforced on
create and on every edit:

| `ig_type` | Media | Notes |
|---|---|---|
| `feed` (default) | exactly 1 photo | a video is refused (`err.ig_feed_video`) - publish it as a `reel` |
| `carousel` | 2-20 photos and/or videos | in `media_ids` / `media_urls` order |
| `reel` | 1 video (MP4/MOV) | optional `cover_url` (an uploaded image, converted to JPEG if needed, or a public https URL of a JPEG, which Meta fetches as is) and `share_to_feed` (default true) |
| `story` | 1 photo or video | no first comment |

- Photos are published as **JPEG**, up to 8 MB (`err.ig_image_too_large`). A PNG, WEBP or still GIF
  attached to an Instagram post (or as a Reel cover) is copied to a new JPEG upload: white behind
  transparency, long edge at most 1440 px (1920 for a story or a cover). The original upload stays as
  it was, so the same file can still go to Facebook. An animated GIF, PNG or WEBP is
  `err.ig_animated_image`: send it as a video.
- The caption is `message`: up to 2,200 characters, 30 hashtags, 20 @mentions.
- No `link` (`err.ig_no_link` - put it in the caption or the first comment) and no `bg_preset`.
  `first_comment` is text only, up to 2,200 characters.
- An edit that omits `ig_type`, `cover_url` or `share_to_feed` keeps the stored values.
- Publishing: dmswift checks Meta's 24-hour API publishing quota first (`400 err.ig_publish_limit`,
  nothing is created), creates the media containers, waits for Meta to process them (a reel can take
  minutes), publishes, reads the permalink and posts the first comment. A container Meta rejects fails
  the post; `err.publish_interrupted` means the outcome is unknown and is **never** retried - check
  `get_post` or `list_live_posts`. A scheduled post that meets a full quota is pushed back an hour.
- Deleting: Meta's API cannot delete Instagram media. `delete_post` with `also_remote` removes the post
  from dmswift and answers `{ ok: true, remote_deleted: false, note, note_code }`; it stays on
  Instagram until the owner deletes it in the app.
- Comments under Instagram posts are read and answered with the comment tools (`list_comments`,
  `reply_to_comment`, `hide_comment`, `delete_comment`); `comment_on_post` and `like_comment` are
  Facebook-only.

An Instagram reel through MCP:

```json
{ "page": "17840000000000000", "ig_type": "reel", "message": "New drop. Comment LINK for the shop",
  "media_ids": ["a1b2c3d4e5f6a1b2c3d4e5f6.mp4"], "share_to_feed": true,
  "campaign": { "name": "Reel link", "keywords": "link", "dm_text": "Here you go {name}", "button_title": "Shop", "button_url": "https://example.com/shop" } }
```

---

## 4. REST API

Everything the working MCP tools do is available directly. All page-scoped endpoints take the
channel id as `?page=<id>` (`?page_id=<id>` is read the same way), and write endpoints also expect
`page_id` in the body.

### Pages

```http
GET /api/pages
GET /api/pages?include=disconnected
GET /api/page-scopes?page=<id>
```

`GET /api/pages` returns each channel's `id`, `name`, `platform`, `username`, `avatar`,
`parent_page_id` and `connected_at`. Tokens never leave the server. With `include=disconnected` the
list also carries channels that were disconnected (kept so they can be reconnected), each with
`disconnected: true`; connected channels never carry the field. A pinned key sees only its pages
either way.

### Posts

```http
GET    /api/posts?page=<id>
GET    /api/posts?page=<id>&scheduled=1
GET    /api/posts?page=<id>&include_scheduled=1
GET    /api/page-posts?page=<id>&status=<status>&limit=<n>
GET    /api/page-posts/<post_id>?page=<id>
POST   /api/page-posts?page=<id>
PUT    /api/page-posts/<post_id>?page=<id>
DELETE /api/page-posts/<post_id>?page=<id>&remote=1
POST   /api/page-posts/<post_id>/publish?page=<id>
POST   /api/page-posts/<post_id>/comments?page=<id>
POST   /api/page-posts/<post_id>/duplicate?page=<id>
POST   /api/page-posts/<post_id>/status?page=<id>
```

`GET /api/posts` (`posts:read`) is different from the rest: it reads **live content from Meta**
rather than this system's own post rows - recent Page posts on Facebook, recent media and reels on
Instagram, normalized into the same `{ id, message, permalink_url, created_time }` shape, plus
`type` (`photo`, `video`, `reel`, `album`, `link` or `text`) and `comments_count` when Meta reports
them (either may be absent; Instagram rows also keep `media_type` and `thumbnail`). It is how
you find the id of an existing post to bind a campaign to. `GET /api/page-posts` reads posts this
system manages.

A row with `has_thumb: true` has a picture at `GET /api/posts/<post_id>/thumb?page=<id>`
(`posts:read`): the image bytes, fetched by the server from Meta's CDN (`*.fbcdn.net`,
`*.cdninstagram.com` over https only, every redirect re-checked, 2 MB cap, raster types only) and
sent with `Cache-Control: private, max-age=3600`. The post must be in that page's recent list;
anything else is `404`, and a picture the CDN will not hand over is `502 err.thumb_unavailable`.

Create body:

```json
{
  "page_id": "1247943345074815",
  "message": "text",
  "link": "https://example.com",
  "media": [{ "file": "...", "mime": "image/png", "name": "a.png", "size": 1234 }],
  "scheduled_at": 1785000000,
  "fb_schedule": true,
  "auto_rule_template": { "name": "...", "keywords": "...", "dm_text": "..." }
}
```

`status` is derived from `scheduled_at`, never accepted from the client. Instead of `media`, a body may
carry `media_ids` (uploaded files) and/or `media_urls` (fetched first). On an Instagram channel the body
also takes `ig_type`, `cover_url`, `share_to_feed` and `first_comment` under the rules in §3
("Instagram publishing"); there `link` is refused and `bg_preset` / `fb_schedule` are ignored.

### Media upload

```http
POST /api/uploads
{ "url": "https://example.com/image.jpg", "page_id": "<id>" }                     # by URL
{ "data": "<base64>", "mime": "image/png", "name": "a.png", "page_id": "<id>",
  "expected_sha256": "<hex>" }                                                    # by base64

GET /api/uploads/<file>
GET /api/uploads/<file>?sig=<signature>&exp=<unix>
```

Returns `{ ok: true, media: { file, media_id, name, mime, size, sha256, signed_path, signed_url } }`; `size` and `sha256` describe the decoded bytes that were stored. Pass `media_id` (or `media`) into `comment_on_post`, `create_post`, or `POST /api/page-posts/<post_id>/comments`. `signed_url` can also be fetched directly without an `Authorization` header. It lasts 7 days: keep the `media_id`, not the link.

**Integrity of base64 uploads.** A model that sends a file as base64 emits it token by token, and one
dropped character shifts every byte after it. Compute the SHA-256 (hex) of the file before encoding and
send it as `expected_sha256`: if the decoded bytes hash differently the upload is refused with
`400 err.checksum_mismatch` and nothing is stored (the error text carries the size and hash that
arrived). A malformed `expected_sha256` is `400 err.invalid_checksum`. Independently of that, a JPEG
without its end-of-image marker, or a PNG with a bad chunk CRC or no `IEND`, is refused with
`400 err.invalid_image`. `expected_sha256` applies to `data` only, not to `url`.

**The file type is read from the bytes.** `mime` is optional: a JPEG sent as `image/png`, or with no
`mime`, is stored as a JPEG (Instagram needs JPEG). A `mime` outside the allowed list is refused, and so
are bytes with no recognisable JPEG, PNG, GIF, WEBP, MP4 or MOV signature, whatever `mime` says
(`400 err.unsupported_file_type`). HEIC/AVIF photos are refused with that reason; an upload link page
converts them to JPEG. A phone "motion photo" (a JPEG with a video appended) is accepted.

**Videos are checked when they arrive and again when attached.** The MP4/MOV header is read (no
transcoding): a cut-off file (`err.video_truncated`), one with no video track (`err.video_no_video_track`)
or an unreadable one (`err.video_unreadable`) is refused on upload. The answer describes the video
(`video: { container, duration_seconds, video_codec, audio_codec, width, height, fps, fast_start }`)
and lists `warnings` for what would stop it as an Instagram Reel. On Instagram, `create_post` /
`update_post` refuse a video Meta would refuse at publish time: a codec other than H.264/HEVC
(`err.ig_video_codec`), sound other than AAC (`err.ig_audio_codec`; any enabled AAC track counts, so an iPhone's spatial-audio track ahead of
it is fine), a Reel over 15:00 (`err.ig_reel_too_long`), a story or carousel video over 60 s
(`err.ig_video_too_long`), under 3 s (`err.ig_video_too_short`), or a Reel over 300MB or a story or
carousel video over 100MB (`err.ig_video_too_large`). Dolby Vision (iPhone HDR) is warned, not
refused (`warn.video_dolby_vision`). A file whose index is over 16MB or too complex to read (more
than 16 tracks or 50,000 boxes) is stored with `warn.video_unverifiable` and refused on Instagram
with `err.ig_video_unverifiable`. Each error carries `next_step` and `params`. A video whose index (moov box) is at the end of the file is accepted with
`warn.video_moov_end`: Meta downloads the whole file before processing, so it usually publishes; if
Meta does refuse it, the publish error says to export again with "Fast start".

### Upload links (the user's own photos)

No public URL, repo or file hosting. An autonomous agent that made an image POSTs its bytes to the
link's `upload_url`; an agent in a chat app, which sees a photo the person sent but cannot pass the
file on, gives the person the link's page instead. Both end in ordinary media ids.

```http
POST /api/upload-links  { "page_id": "<id>", "max_files": 10 }     # posts:write
GET  /api/upload-links/<token>                                       # posts:write
```

`POST` returns `{ token, url, upload_url, expires_at, max_files }`: `url` is `https://app.dmswift.com/u/<token>`,
valid for 30 minutes and up to `max_files` files (default 10, at most 20). The person opens it on a
phone or computer and picks files; photos that are not JPEG, or are over 8 MB, are converted to JPEG
on the page so they are Instagram-ready. `GET` returns `{ status: "waiting" | "uploaded" | "expired",
count, media: [{ media_id, mime, size, name }] }`, in the order the files were picked; use each
`media_id` as with `POST /api/uploads`. MCP: `request_upload_link` and `get_uploaded_media`.

**Autonomous agents** (an image the agent made itself) skip the page: POST the raw bytes to
`upload_url`, one file per request, with the file's own `Content-Type` and optionally `X-File-Name`.
No `Authorization` header; the token is the credential. This needs the agent's own network to reach
the dmswift app host: many cloud sandboxes block hosts outside an allow-list and answer
`403` / `CONNECT tunnel failed` themselves. Then either the owner adds the domain to the agent's
allowed domains, or the agent passes a public URL (`media_urls`) or gives the person the link's page.

```bash
curl -X POST --data-binary @image.jpg -H "Content-Type: image/jpeg" https://app.dmswift.com/u/<token>/file
# { "ok": true, "count": 1, "left": 9, "media": { "media_id": "...", "mime": "image/jpeg", "size": 81234, "name": "image.jpg" } }
```

The body is streamed to disk, so a filmed video goes up here too: photos up to 25MB, videos up to
`VIDEO_MAX_MB` (300MB). A `Content-Length` over the cap, or over the account's 2GB of storage, is
refused before the body is read (`413`); a body that passes the cap is cut off with `413` and nothing
kept. A sender that goes quiet for 30 s (`UPLOAD_IDLE_TIMEOUT_S`) or takes over 290 s in all
(`VIDEO_UPLOAD_TIMEOUT_S`) gets `408 err.upload_timeout`, and one slower than 8KB/s over a
30 s window (`UPLOAD_MIN_KBPS`, `UPLOAD_RATE_WINDOW_S`) gets `408 err.upload_too_slow`. At most 3
transfers per account (12 in all) run at once, upload-link bodies and URL downloads together
(`429 err.too_many_uploads`), and bytes still arriving count against the quota. A client that hangs
up mid-upload gets `err.upload_interrupted`. The dashboard composer sends videos this way, from its
own allowance of links (an API key keeps its 30 an hour).

Every upload path refuses with `err.server_storage_low` when the server's disk is under 10% free, and a
plan with its own storage limit refuses with `err.storage_quota_plan`. `POST /api/uploads` answers `400`
for both; an upload link (`/u/:token/file`) answers `507` and `413`. `POST /api/uploads` bodies over
2MB count as a transfer, so they can answer `429 err.too_many_uploads` too.

### Text cards (images dmswift draws)

An agent that can reach dmswift only through MCP (a cloud sandbox, a scheduled routine, a chat app
without code execution) can send text but not files. For the most common picture on Instagram, words on
a background, it does not need to: dmswift renders the JPEG on the server.

```http
POST /api/text-cards       { "page_id": "<id>", "text": "...", "format": "portrait", "preset": "red-blue", "footer": "@you" }
POST /api/text-cards       { "page_id": "<id>", "slides": ["1/3 ...", "2/3 ...", "3/3 ..."] }        # a carousel
GET  /api/text-cards/brand?page=<id>                                                               # posts:read
PUT  /api/text-cards/brand?page=<id>  { "preset": "midnight", "footer": "@you", "logo": "<media_id>" }  # posts:write
PUT  /api/text-cards/brand?page=<id>  { "reset": true }
```

- **Text:** `text` (one card) or `slides` (1 to 10 cards, carousel order), up to 600 characters each.
  Line breaks are kept and an empty line is a gap. The text is centered, bold, and sized to the largest
  that fits; each paragraph is balanced so it does not end on one stray word. Hebrew and Arabic run
  right to left, mixed with Latin where it appears; emoji are drawn in color. Control characters and
  bidi override marks are removed.
- **Format:** `portrait` 1080x1350 (default), `square` 1080x1080, `story` 1080x1920. A story card is
  for `ig_type: "story"` only: feed and carousel posts refuse it (`err.ig_aspect_ratio`).
- **Style:** `preset` (`red-blue`, `orange-purple`, `aqua`, `purple`, `green`, `fire`, `midnight`,
  `paper`; paper is light with dark text) or `background: { from, to, angle }` (hex colors; CSS-style
  angle, default 135 = top-left to bottom-right), `text_color`, `footer` (up to 40 characters, `""`
  for none), and `logo: false` to leave the kit's logo off. Whatever is not sent comes from the
  channel's brand kit, then from the `red-blue` preset.
- **Brand kit:** one per channel. `PUT` replaces the look (what is not sent is cleared) but keeps the
  logo unless `logo` is sent: a `media_id` replaces it, `null` removes it. The logo must be a JPEG, PNG
  or WEBP already in the account, up to 5 MB and 4096 px per side (checked from the file header before
  anything is decoded); it is scaled to fit 360 px and kept as its own PNG copy, so the original upload
  being cleaned up later does not break the kit.
- **Response:** `{ ok, format, width, height, media_ids: [...], media: [{ media_id, mime, size, sha256,
  width, height, signed_url }] }`. Each `media_id` works in `create_post` / `update_post` like any
  upload, on Instagram and on Facebook. Rendering publishes nothing and is not a publish against the
  daily caps; an unused card is cleaned up after 24 hours like any unattached upload.
- **Limits:** 120 cards per account per hour (`429 err.too_many_attempts`); renders run one at a time
  and a full queue answers `429 err.render_busy` (retry in a few seconds). A text that cannot fit is
  `400 err.text_card_too_long` and nothing is stored, not even the other slides.
- **Safety:** the input is plain text and fixed options. No markup, SVG or URL is accepted, so nothing
  is fetched and nothing is parsed; the only image ever decoded is the account's own logo, once, after
  its bytes and header are checked. Each word is measured once and lines are wrapped by arithmetic, so
  no text can stall the server.

### Campaigns

```http
GET    /api/rules?page=<id>
GET    /api/rules/<rule_id>?page=<id>
POST   /api/rules?page=<id>
PUT    /api/rules/<rule_id>?page=<id>
PATCH  /api/rules/<rule_id>?page=<id>   { <any subset of the fields below>, "version": <n> }
DELETE /api/rules/<rule_id>?page=<id>
POST   /api/test-match?page=<id>     { "text": "...", "post_id": "" }
```

`GET /api/rules/<rule_id>` (`campaigns:read`) returns one campaign in the same shape as the list,
`hits` and `version` included; a campaign that is not on the named page is a `404`
(`err.campaign_not_found_on_page`). `PUT` with a full body replaces the whole rule - read it first
and send it back modified, or fields will be cleared.

**Partial updates.** `PATCH` (`campaigns:write`) changes only the fields it names, and so does a
`PUT` whose body holds nothing but `active` (plus optionally `version` and `page_id`) - that is the
on/off switch. A partial update validates only what it **changes**: a problem the stored row already
had (for example a message saved before the 640-character limit existed) never blocks switching it
off or renaming it, while a problem the update introduces is refused as usual.

**Optimistic concurrency.** Every rule carries `version`, bumped by every write. Send the version
you loaded as `version` on `PUT`/`PATCH`; if someone saved in between, the write is refused with
`409` and `{ "code": "err.rule_conflict", "version": <current> }` - reload and re-apply. A write
without `version` keeps last-write-wins. A successful update answers `{ "ok": true, "version": <new> }`.
A `version` that is not a positive integer is `400 err.rule_version_invalid`.

**Limits** (refused with `400`, never cut silently at send time):

| Limit | Code |
|---|---|
| `name` at most 100 characters | `err.rule_name_too_long` |
| `button_url` a complete http(s) address with a domain (`https://shop.com/x`; not `https://` or `https://shop`) | `err.rule_button_url_scheme` (no http/https) or `err.rule_button_url_invalid` |
| `button_title` at most 20 characters; `postback_button_title` too, but only where it is sent (`button_url` set, `button_mode: "postback"`, `postback_link_style: "button"`) | `err.rule_button_title_too_long` |
| with a button, the message at most 640 characters | `err.rule_dm_640_limit` |
| without a button: Facebook 2000, Instagram 1000; on Instagram the link is part of the text, so with a link the cap is `1000 − link length − 1` (and 640) | `err.rule_dm_too_long` |
| `button_mode: "postback"` on an Instagram channel (Instagram has no such button) | `err.rule_postback_not_on_instagram` |
| `extra_buttons` must be a list of `{ title, url }` | `err.rule_extra_buttons_invalid` |
| `extra_buttons` need the first button (`button_url` set) | `err.rule_extra_buttons_need_first` |
| at most 3 buttons in all (the first + 2 extras) | `err.rule_buttons_max` |
| every extra button needs a `title`, at most 20 characters | `err.rule_extra_button_title_required` / `err.rule_button_title_too_long` |
| every extra button's `url` a complete http(s) address with a domain | `err.rule_extra_button_url_invalid` |
| `extra_buttons` on a `post_mention` campaign (it has no private message) | `err.rule_dm_not_for_trigger` |
| `capture_field` on a `post_mention` campaign (it has no private message) | `err.rule_capture_not_for_trigger` |
| `capture_gate: 1` needs a link to hold back (`button_url` set) | `err.rule_capture_gate_needs_link` |
| `capture_question` / `capture_thanks` at most 300 characters each | `err.rule_capture_text_too_long` |
| where the question travels inside the campaign message (a `comment` campaign, or `capture_gate: 1`), message + question within that message's limit; a gated message carries no button | `err.rule_capture_too_long` |
| turning `capture_field` on when the plan does not include it (402 `plan_limit`; Starter and up, trials included) | `err.plan_feature_capture` |

With `dm_variants: 1` the limits apply to the longest line, since one line is what a person gets.

A refused save answers `{ "error", "code" }` plus, when the problem belongs to one input, `field` - the body
field it is about (`name`, `keywords`, `dm_text`, `public_reply`, `post_id`, `button_title`, `button_url`, `postback_button_title`,
`postback_text`, `extra_buttons`, `button_mode`, `capture_field`, `capture_question`, `capture_thanks`, `capture_gate`).

`POST /api/test-match` answers the way the webhook would decide for a top-level comment arriving now:
`{ matched, keyword_match, skipped, postMatches }`. `matched` is the automation that would answer
right now (schedule applied), or `null`; `keyword_match` is the first automation whose keywords match
regardless of schedule; `skipped` lists automations that match but cannot answer now as
`[{ id, name, reason: "quiet_hours" }]`.

**Matching (`word` mode).** Keywords match whole words, case- and niqqud-insensitive. A Hebrew
keyword of three letters or more also matches when the comment glues a prefix onto it - ו, then
ש or כש, then one of ב ל כ מ, then ה (so `מחיר` matches `המחיר`, `והמחיר`, `כשהמחיר`). The word `תור` is the exception: it takes
no prefix, because `בתור` is a different word. In a phrase only
the first word may carry a prefix and the words stay adjacent (`כמה עולה` matches `בכמה עולה`, not
`בכמה זה עולה`). A keyword that is only an emoji matches when the comment contains it. `exact` and
`contains` modes are unchanged. `test/fixtures/match-vectors.json` is the full contract.

**When a matching automation cannot answer.** An automation outside its active hours, or one set to
top-level comments only receiving a reply to a comment, steps aside and the next matching automation
answers. If none can, the comment is logged with `dm_status` `skipped (quiet hours)` or
`skipped (top-level only)` and the first matching automation's `rule_id`.

In `postback` mode the button's payload is `SEND_LINK:<rule_id>:<comment_id>`, so the tap is logged
against the comment it answers; messages sent before this change carry `SEND_LINK:<rule_id>` and
keep working. When `postback_text` is empty the follow-up is a default in the account's language
(English, or Hebrew when the account's `locale` is `he`).

Campaign fields:

| Field | Meaning |
|---|---|
| `name` | required |
| `dm_text` | the private message. Required, except that a `comment` campaign needs `public_reply`, `dm_text`, or both. Buttons, variants, capture and follow-ups need it |
| `trigger` | `comment` (default), `dm`, `story_reply`, `mention` - all four are live |
| `keywords` | comma-separated; required unless `match_all` |
| `match_mode` | `word` (default, whole-word), `contains` (substring), `exact` (whole trimmed text equals a keyword) |
| `match_all` | `1` responds to every comment, ignoring keywords; exclusions still apply |
| `exclude_keywords` | comma-separated; any hit prevents a match. Uses whole-word matching in `word` mode and substring matching in `contains` **and `exact`** mode |
| `post_id` | empty = every post on the channel. `comment` trigger only. A post-bound campaign is always tried before an all-posts one |
| `public_reply` | newline-separated variants, one chosen per comment. `comment` trigger only |
| `dm_variants` | `1` treats `dm_text` lines as variants too |
| `button_title` / `button_url` | a link on the private message. With a button, `dm_text` is capped at 640 characters |
| `button_mode` | `link` (opens the URL) or `postback` (a tap opens the messaging window, then the link is sent) |
| `postback_text` / `postback_button_title` / `postback_link_style` | the follow-up after a postback tap; `postback_link_style` is `button` or `exposed` |
| `extra_buttons` | buttons 2-3 of the private message: a list of up to 2 `{ "title", "url" }`, always open-link, each link tracked per campaign like the first. Requires `button_url`. **Facebook only** - Instagram sends just the main link. The postback follow-up carries only the main link. `GET` returns the stored JSON string; `PATCH` without it keeps it, a full `PUT` without it clears it |
| `capture_field` | `""` (off), `email` or `phone`: after the private message, ask the person for it. A `comment` campaign puts the question in its one private reply (on Instagram after the link); a DM-started one sends it as its own message, with Instagram/Messenger quick replies (`user_email` / `user_phone_number`). For 24 hours the person's next messages are checked first: a valid value (in the text, or a quick reply's payload) is saved; "no" / "skip" closes the question; a message that looks like a try (an `@`, or six digits or more) but is not valid is asked again once, then let go. Anything else - "thanks", a question, another campaign's keyword, a sticker or photo, a button tap, a growth-link arrival - goes on to the campaigns and the AI as usual and the question stays open (a gated question re-asks any text once). A valid value is saved to the contact's `fields` (`email`, lowercased; `phone` as `+972…` for Israeli numbers, `+<digits>` when typed with a country code, else 10-12 digits as typed - a 9-digit run with no leading 0 is refused, it may be an ID number), tagged `email collected` / `phone collected`, and emitted as `contact.captured`. Opt-out and human takeover win. The question, a re-ask, the thank-you and a held-back link each count as a message against the plan's allowance and the campaign's `daily_cap`. In CSV exports a phone such as `+972…` is written as `'+972…` (the formula guard). Starter plan and up |
| `capture_question` / `capture_thanks` | the question and the thank-you, up to 300 characters each; empty = a default in the person's language (English or Hebrew) |
| `capture_gate` | `0` (default) sends the link and asks; `1` holds the link back until a valid answer (needs `button_url`; the message then goes without buttons) |
| `like_comment` | `1` auto-likes the triggering comment. **Facebook only** - resolves to a skip on Instagram rather than failing |
| `first_level_only` | `1` ignores replies-to-comments |
| `public_delay` / `dm_delay` | seconds before each send, `0`-`180`. The wait is stored, so a restart does not lose it |
| `public_delay_max` / `dm_delay_max` | above the matching delay, the wait is random between the two (e.g. `5` and `30`), drawn once per send. `0` (default) = fixed; below the delay is refused (`err.rule_delay_range`) |
| `typing_indicator` | `1` shows "typing" before a delayed answer to a DM and before a follow-up. Meta allows it only towards a person's id, so the first private reply to a comment never shows it |
| `followup` | one message later: `{ "enabled", "delay_minutes", "text", "only_if_replied", "only_if_not_clicked" }`. `delay_minutes` is `5`-`1380` (`delay_hours`, whole hours, still works) after their last tap or reply. Meta allows it only after a tap or reply and within 24 hours of it, so anyone who does neither gets nothing. `only_if_replied`: a written reply, a tap is not enough. `only_if_not_clicked`: needs `button_url` and `track_links`. With either condition, or when set by an API key, it ends for good if the campaign goes off or a human takes over, and counts against `daily_cap`. A key set to ask first cannot change it |
| `daily_cap` | max DMs per day for this campaign, `0` = unlimited, max `100000` |
| `ends_at` | when the campaign switches itself off. Send `YYYY-MM-DDTHH:MM` in the account's timezone (what the dashboard sends) or an ISO 8601 instant with `Z` or an offset; `null` / `""` clears it. Must be in the future on create, and whenever the campaign is switched on. `GET` returns it as ISO UTC (`2026-12-31T18:00:00Z`) plus `ends_at_local` on the account's clock. A comment that arrives at or after it is not answered (`dm_status` `skipped (campaign ended)`) |
| `send_limit` | switch the campaign off after this many successful private messages - public replies for a `post_mention` campaign, which sends none - (`1`-`1000000`), `null` = no limit. Counted from when the limit was set, not from creation (changing the number keeps the start; clearing it forgets it). `GET` returns `send_count` (counted so far) and `send_limit_since`. Comments past the limit get `skipped (send limit reached)` |
| `once_per_user` | `1` never messages the same person twice |
| `track_links` | `1` sends the campaign's links as tracked short links (`/l/<code>`) that count opens, then redirect; `0` (default) sends your URL exactly as typed. Links already sent keep redirecting either way |
| `active_start` / `active_end` | quiet hours, minutes past midnight `0`-`1439`, `-1` = off |
| `active_days` | CSV of weekday numbers `0`-`6`, empty = every day |

`GET /api/rules/{id}` also returns `timing`: `pending` (sends waiting on a delay) and `followups` (each person's follow-up as `scheduled`, `sent` or `skipped`, with `reason`).
| `active` | `0` pauses the campaign |

**Campaigns that end by themselves.** When `ends_at` passes or `send_limit` is reached the campaign
switches itself off: `active` becomes `0` (the version is bumped, exactly like the owner's switch),
`auto_stopped_at` (ISO UTC, plus `auto_stopped_at_local`) and `auto_stop_reason` (`ended` |
`send_limit`) say when and why, and the audit trail gets a `campaign.auto_stopped` row. Every send
path refuses an ended campaign at once; a sweep once a minute flips the ones nobody wrote to.
Switching it on again is refused with `400 err.rule_ends_at_past` / `err.rule_send_limit_reached`
until `ends_at` is later or `send_limit` higher (or cleared) - send both in the same write. A
malformed value is `400 err.rule_ends_at_invalid` / `err.rule_send_limit_invalid`. `ends_at` and
`send_limit` are kept when a write does not name them (a full `PUT` included). Lowering `send_limit`
to what a running campaign has already sent is accepted, and the campaign stops at once (reason
`send_limit`).

What counts and what carries on: the limit counts the same successful sends as the campaign's Sent
figure (`hits`), including sends already on their way (a delay, a retry), so it is never overshot; a
campaign whose last places are all on their way steps aside for the next matching campaign. A comment
that arrived before `ends_at` is still answered when its delay or a retry carries the send past the
end. Button-tap follow-ups and follow-up sequences a campaign started are not counted, and carry on
after it stops.

### Analytics

```http
GET /api/stats?page=<id>
GET /api/stats/hourly?page=<id>&tz=<minutes>
GET /api/stats/daily?page=<id>&tz=<minutes>&days=<7|30>
GET /api/stats/top-rules?page=<id>
GET /api/log?page=<id>&limit=<n>&group=<0|1>&before=<row_id>&commenter=<id>&since=<date>&until=<date>&q=<text>
```

`GET /api/stats` returns `total`, `dm_ok`, `last24h`, `unique_people`, `clicks`, `dm_week`,
`dm_prev_week`, `recent_errors` and `last_error`. Button-tap follow-ups are reported as `clicks`
and excluded from the DM counters, so one recipient is never counted twice. Group comments are
excluded from every counter - see below.

`GET /api/log` returns at most 500 rows (default 100). `group=1` switches it to the opt-in view of
group comments: comments that arrived from a Facebook Group, which reach the webhook but which
Meta allows neither a reply nor a private message on. They are recorded but excluded from every
count, chart and default listing.

The other filters are optional and combine with each other and with `group`:

| Param | Meaning |
|---|---|
| `before` | rows with `id` below this - pass the last `id` you received to page backwards |
| `commenter` | one person's rows (`commenter_id`, exact) |
| `since` / `until` | inclusive UTC time range: ISO 8601 (`2026-10-01T08:00:00Z`) or `YYYY-MM-DD[ HH:MM:SS]`; a bare date as `until` means the end of that day |
| `q` | substring search over the commenter's name, the comment text and the campaign name; `%` and `_` match themselves |

A malformed value is refused with `400` and `code: "err.log_bad_filter"` rather than ignored. Rows
written by the retry worker (`comment_text: "(retry attempt)"`) carry the original `commenter_id`,
and a button tap from a new-format DM carries the `comment_id` it answers - and the commenter's id
only when the person who tapped is the one the private message was delivered to (a forwarded
message tapped by someone else stays under the tapper's own id).

Every row also carries two derived booleans, so a client never re-implements either rule:

- `is_group` - the server's group-comment test. A bare comment id (no `_`) marks a Facebook group
  comment **only on a Facebook channel**; Instagram comment ids are always bare and are never group
  rows. Rows with a `skipped (group comment…)` status are group rows on any channel.
- `delivered_later` - this row's private message failed, and a later retry of the same comment on
  the same channel delivered it. Such a row is not a "not delivered" failure.

Failure counts everywhere (`/api/stats` `recent_errors` and `last_error`, analytics `errors`,
`dms_failed`, the error breakdown) leave out failures that were delivered later.

The richer reports live under `/api/analytics/*` and are open to `analytics:read`:

```http
GET /api/analytics/overview?page=<id>&days=<7|14|30|90>
GET /api/analytics/funnel?page=<id>&days=<n>&rule=<rule_id>
GET /api/analytics/campaigns?page=<id>&days=<n>
GET /api/analytics/timeseries?page=<id>&days=<n>&metric=<dms|comments|clicks|errors|answered|link_opens|all>
GET /api/analytics/hourly?page=<id>&days=<n>
GET /api/analytics/weekday?page=<id>&days=<n>
GET /api/analytics/keywords?page=<id>&days=<n>&limit=<n>
GET /api/analytics/unanswered?page=<id>&days=<n>&limit=<n>
GET /api/analytics/people?page=<id>&days=<n>
GET /api/analytics/errors?page=<id>&days=<n>
GET /api/analytics/best-times?page=<id>&days=<n>&limit=<n>
```

Two measures appear in `overview` (`metrics` and `totals`) and in `timeseries`:

- `answered` - incoming comments where the public reply **or** the private message succeeded,
  including a private message the retry worker delivered later. Group comments never count.
- `link_opens` - opens of this channel's tracked links in the window, crawlers excluded. Not the
  same as `clicks`, which is the Messenger button tap that asks for the link. The link sent AFTER a
  tap is tracked too (so the person's opens are known), but its opens are left out of `link_opens`:
  the tap already is that person's click.

`campaigns` adds `link_clicks` per campaign: tracked-link opens of that campaign's links in the
window (follow-up links after a tap excluded, as above). `unique_people` counts people the
automation actually reached - a delivered private message or a public reply that landed - not
people who were skipped or whose message failed. New series and CSV columns are appended after the
existing ones.

**Tracked links and `?r=`.** A link in a private message carries `r=<recipient>.<signature>`. Only a
verified signature files an open under a person; anything else still redirects and counts as an
anonymous open. Links already in people's inboxes carry an unsigned `r=<recipient>`, which is
believed only when that recipient really received a message from the link's automation on that
channel.

`days` is an allowlist - **7, 14, 30 or 90**. Anything else silently becomes 30. There are no
arbitrary from/to dates and no sub-day granularity. The timezone comes from the account, never from
the query string: a caller who can move the day boundary can manufacture any number they like.

For an API key, an answer is kept for a minute per page and query, so a key may read numbers up to
60 seconds old; a signed-in dashboard session always reads live numbers.

Rate limit: 240 report requests per account per minute, answered with `429` and
`code: "err.analytics_rate_limited"` plus a `Retry-After` header.

```http
GET /api/analytics/export.csv?page=<id>&report=<name>&days=<n>
```

The CSV export **is** open to `analytics:read`. It is listed in the key route table as its own
literal entry rather than by widening the `/analytics/<name>` pattern to allow a dot - so adding a
new report can never accidentally expose a bulk download along with it. 20 exports per 5 minutes.

### Contacts

```http
GET  /api/contacts?page=<id>&search=&tags=&platform=&subscribed=&source=&seen_within_days=&messaged=&limit=&offset=
GET  /api/contacts/stats?page=<id>
GET  /api/contacts/tags?page=<id>
GET  /api/contacts/<contact_id>?page=<id>
GET  /api/contacts/<contact_id>/clicks?page=<id>
POST /api/contacts/<contact_id>/tags?page=<id>    { "page_id": "<id>", "tags": ["lead"] }
GET  /api/contacts/export.csv?page=<id>&<same filters as the list>
```

`messaged=1` keeps only people this channel has sent at least one message to (`messaged=0` the
rest); a message the retry worker delivered later counts. Every contact row carries `clicked`
(boolean) and `last_click_at` (or `null`): the person opened a tracked link, or tapped the button
that got them the link. `GET /api/contacts/<contact_id>/clicks` (`contacts:read`) returns
`{ clicks: [{ id, ts, link_id, code, title, target_url, rule_id }], taps: [{ id, ts, rule_id, rule_name, comment_id }] }`
- that person's tracked-link opens and button taps on this channel, newest first, at most 200 each,
crawlers excluded.

`GET /api/contacts/export.csv` returns the **People CSV** by default: one row per person with
headers in the account's language (English or Hebrew) - name, username, platform, automation,
comment, private message (yes/no), link click (yes/no), first contact, last contact, tags, ID, then
any custom fields. `format=raw` returns the original machine export (`external_id,platform,name,
username,tags,source,subscribed,first_seen,last_seen` + custom fields). Any cell that starts with
`=`, `+`, `-` or `@` - also after leading whitespace - is prefixed with `'` so spreadsheets do not
run it.

Tag filtering is **AND** - every named tag must be present. Up to 20 tags per query, 200 rows per
request. A contact can hold 50 tags, each up to 40 characters; tags are lowercased and stripped of
commas on the way in.

`source` accepts `comment`, `dm`, `story_reply`, `mention`, `referral`, `manual` or `import`.
`referral` is somebody who arrived through a growth link - a QR code, a bio link, an ad - which is
the acquisition story a customer most wants to segment on.

`GET /api/contacts/export.csv` **is** open to `contacts:read`, capped at 20,000 rows and 10 exports
per user per minute. Editing a contact, deleting one, opting someone in or out and managing segments
remain **session-only**: they change a customer's own records, which stays with a human.

### Knowledge base

```http
GET  /api/kb?page=<id>&q=&kind=&limit=&offset=
GET  /api/kb/summary?page=<id>
POST /api/kb/search?page=<id>       { "page_id": "<id>", "query": "what are your hours" }
POST /api/kb?page=<id>              { "page_id": "<id>", "title": "...", "body": "...", "kind": "faq" }
```

`kind` is one of `faq`, `note`, `policy`, `product`; anything else becomes `faq`. Title 200
characters, body **8,000 characters** - an over-long body is refused, not truncated, so split it.
500 entries per channel. Search returns 4 passages by default and 20 at most, drops anything scoring
below 20% of the top hit, and is limited to 60 searches per user per minute.

Retrieval is lexical (SQLite FTS5 with BM25, title weighted 10× body), **not embeddings**. A
question phrased with none of the words in the entry will not find it.

Editing, deleting and bulk import are session-only.

### Comments, insights and the inbox

All take the channel as `page_id` (query on reads, body on writes; `?page=` works too); a pinned key must name it.

| Method and path | Scope | Answer |
|---|---|---|
| `GET /api/comments?page_id=&post_id=&limit=25&cursor=&since=` | `comments:read` | `{ comments: [{ id, post_id, platform, from: { id, name }, text, created_time, like_count, reply_count, hidden, parent_id }], next_cursor }` |
| `POST /api/comments/:id/reply` `{ page_id, text }` | `comments:write` | `{ ok: true, id }` - counts as a comment; 8,000 characters on Facebook, 2,200 on Instagram; `403 approval_required_for_comment` for approval-mode keys |
| `POST /api/comments/:id/hide` `{ page_id, hidden }` | `comments:moderate` | `{ ok: true }` |
| `DELETE /api/comments/:id?page_id=` | `comments:moderate` | `{ ok: true }` - permanent, so a key pays for it from its comment cap (`429 daily_cap`, given back if Graph refuses); hide is free |
| `POST /api/comments/:id/like` `{ page_id }` | `comments:write` | `{ ok: true }`; Instagram → `400 unsupported_on_instagram` |
| `GET /api/insights?page_id=&period=day\|week\|days_28&since=&until=` | `insights:read` | `{ platform, period, metrics: { followers, new_followers, reach, impressions_or_views, engagements, … }, series: { metric: [{ date, value }] } }`. `followers` = current total; `new_followers` = follows gained in the window (Facebook `page_daily_follows_unique`, plus `unfollows`; Instagram `follower_count`, reported by Meta only for 100+ followers). If insights are refused but the follower count answers, the response carries `permission_missing` instead of failing |
| `GET /api/insights/posts/:postId?page_id=` | `insights:read` | `{ platform, post_id, metrics: { reach, views, likes, comments, shares, saves? } }`; Instagram adds `media_product_type` and `unavailable: [{ metric, field, reason }]`, and a reel adds `metrics.avg_watch_time_ms/_sec`, `total_watch_time_ms/_sec`, `skip_rate` (percent 0-100), `facebook_views?`, `crossposted_views?` plus `duration_ms`, `duration_sec` |
| `GET /api/inbox?page=&status=open\|done\|snoozed\|all&q=&unread=1&limit=30&offset=` | `inbox:read` | `{ rows, total, counts: { open, unread, done, snoozed } }`, newest first; each row carries the person's `name` / `username`, `last_preview`, `unread`, `window_open`, `takeover_active` |
| `GET /api/inbox/unread-count?page=` | `inbox:read` | `{ messages, conversations }` |
| `GET /api/inbox/:id?page=&before=&limit=` | `inbox:read` | `{ conversation, contact, messages, has_more, window_open, window_expires_at }` - messages oldest first; `before` is a message id |
| `POST /api/inbox/:id/reply?page=` `{ page_id, text }` | `inbox:write` | sends; checked in this order: `403 approval_required_for_dm` for approval-mode keys, `409 human_takeover` while a human took over, the daily `dm` cap (100/day), then `409 err.inbox_window_closed` outside Meta's 24-hour window and `409 err.inbox_opted_out`. A key sends at most 3 replies a minute to one conversation (`429 err.too_many_attempts`, with `retry_after`). Recorded as `sent_by: "ai"`; never takes the thread over |

Two behaviours worth knowing: without `post_id`, `GET /api/comments` reads the 10 most recent posts and
its `next_cursor` pages through **posts**, not comments; with `post_id`, Facebook returns comments and
replies as one flat list, newest first, and Instagram returns each top-level comment followed by its
replies. Graph refusals come back as `permission_missing` (403, with the `permission` to reconnect for),
`err.token_invalid` (409), `comment_not_found` (404), `graph_rate_limited` (429), `graph_timeout` (504)
or `graph_error` (400).

### Account (dashboard session only - closed to API keys)

```http
GET  /api/me                 → { id, email, name, role, tz_offset, tz, bot_paused, locale, ... }
GET  /api/billing            → { plan, usage, status, expired, at_limit, trial_end, plans, upgrade_request }
POST /api/billing/upgrade    { plan }        → records an upgrade request
POST /api/toggle-pause       { paused: true|false } → { ok, paused }
GET  /api/onboarding         → { steps[], complete }
GET  /api/meta-app           → the tenant's own Meta app + the URLs to paste at Meta
POST /api/meta-app           { app_id, app_secret }
```

`GET /api/me` is the one exception: it is in the key route table with no scope requirement, so any
key may ask who it is. The rest of this group is session-only.

Account security (session only):

```http
POST /api/me           { email, current_password? }  → { ok, user } + a re-issued session cookie
POST /api/me/password  { current, password, revoke_tokens? } → { ok, revoked? }
POST /api/logout       { revoke_tokens? }             → { ok, revoked? }   (signs out every session)
```

Changing the sign-in `email` needs a session signed in within the last 15 minutes or the correct
`current_password` (`403 err.email_change_reauth`, or `403 err.wrong_current_password`). The old
address is emailed a notice, every other session of the account ends (`session_epoch` + 1) and the
caller's session is re-issued. `revoke_tokens: true` on a password change or a sign-out also revokes
every API key and ends every connected app (OAuth grant), answering
`revoked: { api_keys, oauth_grants }`; a password reset through a reset link always does.

`tz` is the account's IANA time zone (`Asia/Jerusalem`), or `''` when none is set; when it is set,
`tz_offset` is that zone's offset in minutes right now, so daily caps, active hours and reports
follow daylight saving. Only zone names are accepted (raw offsets such as `+23:59` are
`400 err.tz_invalid`), and every offset is clamped to −720…840. `upgrade_request` is the pending
`{ plan, at }` request, or `null`. `POST /api/billing/upgrade` accepts only a plan listed in `plans`
(otherwise `400 err.plan_not_found`).

`trial_end` is `null` unless the account is in a trial that has not ended. Then it is
`{ ends_at: "YYYY-MM-DD", days_left, falls_back_to: { code, name, max_pages, max_campaigns, max_dms }, over_limits: [{ resource: "pages"|"campaigns"|"dms", used, limit }] }`
- the plan the account moves to the day after `ends_at`, and every current usage already over that
plan's limits, so the dashboard can warn before it happens. There is no free plan: `falls_back_to`
is the `frozen` plan ("Paused"), whose limits are all `0` - automations stop until a plan is chosen,
while sign-in, export and read-only API/MCP access keep working.

`POST /api/toggle-pause` requires an explicit boolean: `{ "paused": true }` pauses every automation
on the account, `{ "paused": false }` resumes. Anything else is `400 err.paused_boolean_required`
and changes nothing.

Purging a channel (or a commenter's data-deletion request) deletes its activity rows, but this
month's delivered messages stay counted in `usage.dms`: they are moved to the billing ledger first.

`plan.max_*` limits use `-1` for unlimited. The plan carries `max_pages`, `max_campaigns`,
`max_dms`, `max_posts`, `max_ai_replies`, `max_broadcasts` and `max_seats`. Usage counters are per
calendar month for `dms`, `posts`, `ai_replies` and `broadcasts`, and current totals for `pages` and
`campaigns`. Usage is derived by counting real rows rather than incremented into a counter, so it
stays correct across deletes, retries and backfills.

What each limit counts, exactly as the gates enforce it:

- `max_dms` - messages the platform sends automatically: campaign replies (comment or message
  trigger), broadcast and sequence sends, growth-link greetings and Instagram mention replies.
  Replies a person types in the inbox and button-tap follow-ups are not counted.
- `max_campaigns` - every saved campaign on every channel of the account, active or paused (a
  current total, not a monthly count). Deleting one frees its slot.
- `max_posts` - posts the platform publishes on Facebook or Instagram in the month (a reel, story or
  carousel is one post). Drafts, uploads and local schedules are not counted until they publish; a
  post handed to Facebook's native scheduler holds its slot from the moment it is scheduled. A
  scheduled post over the limit is deferred, not failed.
- `max_seats` - people invited besides the owner (members plus pending invites); the owner never
  takes a seat.

Monthly meters start on the 1st (UTC); during a trial they start on the trial's first day, so a trial
that crosses a month boundary gets its allowance once.

Two things about `usage` are worth reading closely, because they changed:

- **`dms` is the whole monthly message allowance, whatever sent the message.** Campaign replies,
  inbox automation, broadcast sends and sequence steps all count against the one number. The parts
  are still reported beside it as `broadcast_sends` and `sequence_sends` for a usage screen, but the
  gate is the total. Two ledgers, one of which the comment path could not see, is how a tenant used
  to exhaust their allowance through broadcasts and keep sending campaign replies.
- **`broadcasts` counts runs, not messages** - and a run that is merely *scheduled* is already
  counted. Counting only runs that had started meant six could be queued against a limit of two,
  because none of them had begun yet.

Button-tap follow-ups and a human's manual inbox reply are excluded from `dms`. Every limit above is
enforced today, including `max_ai_replies` (checked before the model is called, so no token is spent
to discover a refusal) and `max_broadcasts`.

---

## 5. Outbound webhooks

The generic way this product plugs into everything else: Zapier, Make, n8n, a CRM or your own
backend. You register an HTTPS endpoint per channel, subscribe it to events, and dmswift POSTs a
signed JSON envelope whenever one fires.

Endpoints are managed from the dashboard (**session-only**, see §7): `GET|POST /api/webhooks`,
`PUT|DELETE /api/webhooks/:id`, `POST /api/webhooks/:id/test`, `GET /api/webhooks/:id/deliveries`,
`POST /api/webhooks/:id/deliveries/:did/retry`. Up to **10 endpoints per channel**.

The signing secret is returned **once**, in the response to `POST /api/webhooks`. It is sealed at
rest afterwards and is never returned again, not even partially - recovery means rotation.

### Event catalog

| Event | Fires when | Emitted from |
|---|---|---|
| `comment.received` | a comment arrived on a connected channel, before any rule ran | the comment webhook |
| `comment.matched` | a campaign matched that comment | the comment webhook |
| `dm.sent` | a private message went out - a campaign reply, an AI answer, or a growth-link greeting | the comment webhook, the AI path, the referral handler |
| `dm.failed` | the send was refused, permanently or after retries | the same three |
| `link.clicked` | a tracked link was opened by a human (bot traffic never emits) | the `/l/:code` redirect |
| `contact.created` | first time this person has been seen on this channel | the contact upsert |
| `contact.tagged` | a tag was added to a contact | the tagging path |
| `contact.optout` | the person asked to stop hearing from this channel | the inbox opt-out path |
| `contact.captured` | a person answered a campaign's question with an email or phone number. Payload: `contact_id`, `external_id`, `platform`, `name`, `field` (`email`/`phone`), `value`, `campaign_id`, `campaign_name`, `conversation_id`, and `origin_contact_id` (the commenter's contact, when the answer came from a different Messenger id) | the inbox capture path |
| `message.received` | an inbound DM, story reply or mention from a person | the messaging webhook |
| `conversation.opened` | a new conversation thread started | the inbox |
| `broadcast.finished` | a broadcast run completed | the broadcast worker |
| `post.published` | a scheduled post went live | the post worker |
| `campaign.created` | a campaign was created, by a human or an API key | the campaign route |
| `quota.exhausted` | the account hit its monthly message limit and a reply was skipped | the comment webhook |

Every event in this table is subscribable and **has an emitter** (`contact.captured`, added with the in-conversation email/phone question, included). This table used to carry a
"not yet" column for nine of them; that column is gone because it is no longer true.

Two things it is still worth knowing rather than discovering:

- `quota.exhausted` currently fires for one resource - the monthly message allowance (`resource:
  "dms"`), from the comment path. Hitting the post, AI or broadcast limit is refused with a `402`
  but does not emit.
- `dm.sent` and `dm.failed` carry a `source` field (`growth`, `ai`) when the message did not come
  from the ordinary comment path. Branch on it if the distinction matters to you; its absence means
  a campaign reply to a comment.

`ping` is not in the catalog and cannot be subscribed to. It exists only for the "test this
endpoint" button, which sends it inline and reports the real response.

### Payload envelope

Every delivery is a `POST` with `Content-Type: application/json` and this body:

```json
{
  "event": "dm.sent",
  "page_id": "1247943345074815",
  "sent_at": "2026-08-11T09:14:22.481Z",
  "data": {
    "comment_id": "1234567890_0987654321",
    "rule_id": 12,
    "rule_name": "Price inquiries",
    "contact_id": 4471,
    "to": "6301234567890123"
  }
}
```

`data` is event-specific. If the whole envelope would exceed **64 KB** it is replaced with
`data: { "truncated": true, "bytes": <original size> }` - the event still arrives, the payload does
not.

Headers on every delivery:

| Header | Value |
|---|---|
| `User-Agent` | `dmswift-webhooks/1` |
| `X-Dmswift-Event` | the event name |
| `X-Dmswift-Delivery` | delivery id - stable across retries of the same delivery, use it to dedupe |
| `X-Dmswift-Timestamp` | unix **seconds** at send time |
| `X-Dmswift-Signature` | `sha256=<hex>` - see below |

### Signature

```
signed material = "<X-Dmswift-Timestamp>" + "." + "<raw request body, byte for byte>"
X-Dmswift-Signature = "sha256=" + hex(HMAC_SHA256(endpoint secret, signed material))
```

The timestamp is inside the signed material, not merely alongside it: signing the body alone would
make every captured request replayable forever, since the signature would stay valid. So a receiver
checks **two** things - that the HMAC matches, and that the timestamp is recent - and compares the
digests in constant time. A 5-minute tolerance is what dmswift's own verifier uses.

The body must be hashed as the **raw bytes that arrived**. Re-serializing parsed JSON will change
key order or whitespace and the signature will not match.

**`X-Dmswift-Delivery` is not covered by the signature.** The signed material is the timestamp and
the body, and the delivery id travels in an unsigned header. It is still the right key for
deduplication - it is stable across retries of the same delivery, which nothing in the body is - but
treat it as a hint from an authenticated sender rather than as signed data, and make your handler
idempotent on the event's own contents where you can.

### Verifying in Node

```js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
const SECRET = process.env.DMSWIFT_WEBHOOK_SECRET;
const TOLERANCE_S = 300;

// express.raw, not express.json: the signature covers the bytes on the wire.
app.post('/hooks/dmswift', express.raw({ type: 'application/json' }), (req, res) => {
  const ts  = req.get('X-Dmswift-Timestamp');
  const sig = req.get('X-Dmswift-Signature') || '';
  const raw = req.body.toString('utf8');

  // 1. the timestamp must be recent - this is what stops replay
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(age) || age > TOLERANCE_S) return res.sendStatus(400);

  // 2. the HMAC must match, compared in constant time
  const expected = 'sha256=' + createHmac('sha256', SECRET).update(`${ts}.${raw}`, 'utf8').digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(sig, 'utf8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);

  const { event, page_id, data } = JSON.parse(raw);

  // 3. answer fast. Anything slow belongs on your own queue -
  //    dmswift gives up on the connection after 10 seconds.
  res.sendStatus(200);
  handleAsync(event, page_id, data, req.get('X-Dmswift-Delivery'));
});
```

### Delivery, retries and failure

- **Success is any 2xx.** Anything else is a failure, including 3xx: redirects are never followed.
- **Timeout: 10 seconds.** Answer immediately and do your work afterwards.
- **6 attempts**, backing off roughly `0s, 30s, 2m, 10m, 1h, 6h` - about eight hours end to end.
  Long enough to ride out a deploy or a certificate renewal, short enough that a delivery is either
  done or reported the same day.
- **`410 Gone` is honoured immediately.** It is the one status that is a statement about the
  endpoint rather than about the request: the delivery fails at once and the endpoint is disabled
  with `disabled_reason: "gone"`.
- **20 consecutive failures disables the endpoint** (`disabled_reason: "failures"`). Any 2xx resets
  the run to zero.
- Deliveries are queued rows worked by a bounded tick - at most 25 per tick and 5 per endpoint -
  so one dead receiver cannot starve everyone else's.
- Delivery records are kept **30 days** when delivered and **90 days** when not. Failures are kept
  longer because they are the ones somebody investigates.

Retries are at-least-once. Use `X-Dmswift-Delivery` to make your handler idempotent.

### Destination restrictions

The destination is a URL you type, and this process sits in a private network, so:

- `http://` and `https://` only;
- no `user:password@` in the URL - use a header or a query parameter;
- URLs up to 500 characters;
- addresses that resolve inside a private network are refused, at save time **and again at connect
  time**, through a guarded DNS lookup - the address that is checked is the address that is
  dialled, so there is no rebinding window;
- `localhost`, `.local`, `.internal`, `.home.arpa` and bare single-label hostnames are refused by
  name as well as by address;
- redirects are never followed.

---

## 6. Errors

Errors are JSON with an `error` message, and a machine-readable `code` where the reason is
structural:

| HTTP | `code` | Meaning | What to do |
|---|---|---|---|
| 401 | - | Key or token missing, unknown, revoked or expired. `/mcp` adds `WWW-Authenticate: Bearer resource_metadata="…"` (and `error="invalid_token"` for a bad one) | An OAuth client refreshes or signs in again; a key holder stops. |
| 403 | `route_forbidden` | Endpoint is closed to API keys entirely | Stop. This is not scope-fixable. See §7. |
| 403 | `missing_scope` | Key lacks the scope in `required` | Stop; a broader key is needed. |
| 403 | `page_forbidden` | Key is pinned to other Pages | Use a Page from `list_pages`. |
| 400 | `page_required` | Key is pinned to specific Pages and the request named none | Add `?page=<id>`. |
| 402 | `plan_limit` | The account's plan does not allow this (quota used up, or a feature not included). Body: `{ error, code: "plan_limit", kind?, message_key, upgrade_url }` - `kind: "agent_actions"` when the monthly allowance of key writes is spent | Stop. Only an upgrade or the next month clears it; pass `upgrade_url` to the owner. |
| 429 | `daily_cap` | A daily cap of this key is spent. Body: `{ error, code: "daily_cap", kind: "publish"\|"comment"\|"dm", cap, used, remaining, resets_at }` | Stop that kind of action until `resets_at` (UTC midnight). |
| 429 | `err.big_body_limit` | A caller with no session or key sent more than 30 large JSON bodies (over 64 KB) in a minute from one address. Signed-in sessions, keys, webhooks and `/api/uploads` are not counted | Wait `retry_after`, then retry. |
| 429 | `err.approval_requests_limit` | An ask-mode key opened more than 20 new approval requests in an hour. Nothing was stored and nobody was messaged; asking about a request that is already open is not counted | Do not retry until the owner has answered, or `retry_after` has passed. |
| 429 | `err.rate_limited`, `err.analytics_rate_limited`, `err.too_many_attempts` | A per-action rate limit; `retry_after` / `Retry-After` says how long | Wait, then retry. **These are the only retryable 4xx.** |
| 409 | `err.rule_conflict` | The rule changed since the `version` you sent; the body carries the current `version` | Re-read the rule, re-apply your change, send again. |
| 415 | `err.unsupported_content_type` | A write under `/api` carried a body that is not `application/json` | Send JSON. |
| 403 | `err.cross_site_request` | A cookie-authenticated write that a browser marked `Sec-Fetch-Site: same-site` or `cross-site` | Not reachable from an API key (bearer requests are exempt). |
| 403 | `approval_required_for_campaign` | An approval-mode key ("ask me first") tried to create or switch on an active campaign | Save it with `active: false` and ask the owner to turn it on. |
| 403 | `approval_required_for_comment` | An approval-mode key tried to reply to a comment | Show the owner the text; they send it from the dashboard. |
| 403 | `approval_required_for_dm` | An approval-mode key tried to send a private reply | Same - hand the text to the owner. |
| 409 | `human_takeover` | A person on the team took the conversation over | Do not reply; leave it to them. Not retryable until they release it. |
| 404 | `post_not_found` | The `post_id` is not a post of this channel | Use a post from `list_posts` / `GET /api/posts` for the same `page`. |
| 404 | `comment_not_found` | Meta says the comment no longer exists (deleted or hidden by its author) | Skip it; refresh the thread. |
| 400 | `err.media_fetch_rate_limited` | More than 60 downloads by URL (`media_urls`, `/api/uploads` by URL, a comment's `media_url`) in an hour for this account | Wait an hour, or send the file as base64 (`media_data`) instead. |
| 400 | `err.ig_aspect_ratio` | An Instagram feed or carousel photo is outside 4:5 to 1.91:1 (judged as it shows, EXIF rotation applied) and could not be fitted. Photos you upload are normally fitted automatically instead (warning `warn.ig_photo_fitted`) | Post it as a story, or render/crop it to portrait or square. |
| 400 | `err.text_card_too_long` | A text card's text, or a footer, does not fit or is over its limit | Shorten it or split it into slides. |
| 400 | `err.text_card_text_required` / `err.text_card_too_many_slides` / `err.text_card_invalid_option` / `err.text_card_invalid_color` / `err.text_card_logo_invalid` | A text card request is malformed | Fix the field the message names. |
| 429 | `err.render_busy` | The text-card renderer's queue is full | Retry once in a few seconds. |
| 503 | `err.render_unavailable` | The text-card renderer is down on the server | Do not retry now; upload an image instead. |
| 400 | `err.checksum_mismatch` | A base64 upload's bytes do not hash to `expected_sha256`; nothing was stored | Encode and send it again; after two failures use `request_upload_link`. |
| 400 | `err.invalid_checksum` | `expected_sha256` is not 64 hex characters | Send the lowercase or uppercase hex digest. |
| 400 | `err.invalid_image` | A JPEG or PNG upload is cut off or corrupted; nothing was stored | Send the complete file again. |
| 400 | - | Validation failure; `error` explains in English | Fix the input and retry. |

Every `/api` response carries `Cache-Control: no-store`.

Apart from the 429s, none of the 4xx conditions above are transient. Retrying an identical request
will fail identically.

Some `code` values are dotted i18n keys (`err.page_not_found`, `err.no_page_connected`,
`err.rule_keywords_required`, …). Those are stable and safe to branch on, but the bare codes in the
table above are the ones the API contract guarantees.

`plan_limit` now also covers **team seats**: an invite that would exceed the account's `max_seats`
is a `402` with `code: "plan_limit"` and `message_key: "err.team_seat_limit"`, exactly like every
other quota refusal. That path is session-only (§7), so an API key will never see it - but the code
is shared, so a client that already branches on `plan_limit` needs no change.

The features added since this document was first written are all session-only, and their error
codes are therefore never returned to a key. They are listed here so a developer reading a dashboard
network log can look one up rather than guess:

| Surface | Codes |
|---|---|
| A/B tests | `err.abtest_not_found`, `err.abtest_exists`, `err.abtest_rule_not_found`, `err.abtest_too_few_variants`, `err.abtest_rotation_off`, `err.abtest_no_tracked_link`, `err.abtest_not_decided`, `err.abtest_winner_missing`, `err.abtest_bad_metric`, `err.abtest_bad_status`, `err.abtest_no_page`, `err.abtest_rate_limited` |
| Growth links & QR | `err.growth_slug_required`, `err.growth_slug_invalid`, `err.growth_slug_too_long`, `err.growth_slug_taken`, `err.growth_not_found`, `err.growth_rule_not_found`, `err.growth_no_page`, `err.growth_no_username`, `err.growth_limit_reached`, `err.growth_qr_empty`, `err.growth_qr_too_long`, `err.growth_qr_ecc`, `err.growth_qr_busy` |
| Team | `err.team_forbidden`, `err.team_rank_forbidden`, `err.team_role_invalid`, `err.team_role_too_high`, `err.team_seat_limit`, `err.team_email_invalid`, `err.team_already_member`, `err.team_owner_self`, `err.team_owner_inactive`, `err.team_invite_pending`, `err.team_invite_invalid`, `err.team_invite_used`, `err.team_invite_expired`, `err.team_invite_email_mismatch`, `err.team_invite_not_found`, `err.team_member_not_found`, `err.team_rate_limited`, `err.team_failed` |
| Alerts | `err.alerts_bad_config`, `err.alerts_unknown_kind`, `err.alerts_test_throttled` |

Status codes for that group: the `*_rate_limited` / `*_busy` / `*_throttled` codes are `429` and
carry `Retry-After`; `err.team_seat_limit` is a `402`; `err.team_forbidden` and
`err.team_rank_forbidden` are `403`; the team `_not_found` codes are `404`. On the A/B and growth
routes a `404` means the route resolved the id and found nothing on this channel, while the same
`_not_found` code raised from deeper in a write comes back as a `400` - so branch on the `code`,
never on the status alone. Everything else in the group is a `400`.

### 202 - pending approval

A key may be configured to require human approval (for an OAuth connection: "Ask me before anything
is published" on the consent screen). Such a key cannot schedule (`err.approval_required_no_schedule`),
comment in public (`err.approval_required_no_comment`, or `approval_required_for_comment` on a comment
reply) or send private messages (`approval_required_for_dm`). Its
`publish_post` calls do not publish; they return **HTTP 202**:

```json
{
  "ok": true,
  "status": "pending_approval",
  "code": "pending_approval",
  "approval_id": 7,
  "message": "Submitted for human approval..."
}
```

**This is a success, not a failure.** The request is queued and the owner has been notified;
the post publishes when they approve. Do not retry - a retry returns `duplicate: true` and
queues nothing. Do not treat the post as published: check `get_post` later, and consider the
job done once its status is `published`.

---

## 7. What API keys cannot reach, and why

Machine access is **fail-closed**. `src/server.js` holds an explicit table (`KEY_ROUTES`) of every
method-and-path an API key may use; anything absent from it answers `403 route_forbidden` no matter
what scopes the key carries. This means adding a route never silently widens machine access - but it
also means a route is unreachable until somebody adds it to the table.

Three groups are closed.

**Closed on purpose - administration.** No scope will ever open these:

- API key management (`/api/keys`, and the connected OAuth apps at `/api/keys/oauth-apps`) - a key must
  never mint another key or a stronger sibling. The one exception is `GET /api/keys/self`, which only
  describes the calling key.
- Account administration and user management (`/api/admin/*`).
- Meta app credentials (`/api/meta-app`), notification, LLM and content-agent settings.
- Billing and upgrades (`/api/billing*`), onboarding, page connect/disconnect.
- Approvals (`/api/approvals*`) - the whole point is that a human decides.
- Team management (`/api/team*`) - a machine credential that can invite people can quietly grant a
  human permanent access to the account. Those routes refuse an API key with a second, explicit
  check inside the module, so they stay closed even if somebody adds them to the route table by
  accident.

**Closed on purpose - destructive and outbound.** A key may read and add, but not destroy or
broadcast:

| Surface | Why |
|---|---|
| Editing/deleting contacts, opt-in/opt-out, segments | changing a customer's own records is a human decision. A key may tag, and that is all |
| Editing/deleting knowledge base entries, bulk import | a key may add a fact; unmaking one stays with a human |
| Broadcasts and sequences (`/api/broadcasts*`, `/api/sequences*`) | sending to a whole segment is the single action in the product that reaches the most people at once. It is not something an autonomous agent should be able to trigger |
| Inbox management (`/api/inbox/:id/status`, `takeover`, `note`, `tags`, canned replies) | the inbox exists for a human to take over. Reading it (`inbox:read`) and replying inside Meta's 24-hour window (`inbox:write`, never while a human took over, never for approval-mode keys) are open since contract C3 |
| Moderation rules (`/api/moderation*`) | automatic hide/delete rules act on other people's comments without anyone looking. One-off hide and delete are open as `comments:moderate` |
| Outbound webhook config (`/api/webhooks*`) | a key could otherwise redirect the account's event stream to itself. Deliveries themselves are unaffected |

**Closed by omission.** These are ordinary tenant features whose routes were simply never added to
`KEY_ROUTES`. They work in the dashboard and return `403 route_forbidden` to a key:

| Surface | Routes |
|---|---|
| Tracked links | `/api/links*` - the public redirect `/l/:code` needs no auth and is unaffected |
| Audit log | `/api/audit*` |
| AI configuration and drafting | `/api/ai/*` |
| A/B tests | `/api/abtests*` |
| Growth links and QR codes | `/api/growth*`, including `/api/growth/qr.svg` |
| Alerts | `/api/alerts*` |

The two CSV exports that used to sit in this table - `/api/analytics/export.csv` and
`/api/contacts/export.csv` - **have since been added to the route table** and are now reachable
with `analytics:read` and `contacts:read` respectively. They are listed as their own literal
entries, not by widening the pattern that matches the JSON reports.

If you need something from the table above, the fix is a server change, not a different key.

### Why the newest features are session-only

They were not overlooked. Each one is closed for a reason a scope could not fix:

- **A/B tests** (`/api/abtests*`) - the write side rewrites a campaign's `dm_text` down to a single
  line and throws the other variants away (`POST /api/abtests/:ruleId/apply`, or `auto_apply` on the
  test). That is a destructive edit of a customer's message copy, made on the strength of a
  statistic that is explicitly a decision aid rather than a proof. The **read** side is genuinely
  useful to an agent and could be opened later; it has not been, because opening half a module is
  how the other half gets opened by the next person who reads the table.
- **Growth links** (`/api/growth*`) - a growth link is a public identifier that gets printed. An
  agent that can mint, rename or delete slugs can invalidate a poster that is already on a wall,
  and `qr.svg` is an unbounded CPU cost (eight masked matrices, scored) behind a cheap request.
- **Team** (`/api/team*`) - see above: it hands out standing access to a business.
- **Alerts** (`/api/alerts*`) - `POST /api/alerts/test` sends real mail to the account's address.
  A key that can push that button is a spam cannon aimed at the owner.

None of these appear in `KEY_ROUTES`, so all of them answer `403 route_forbidden` regardless of
scope. There are no MCP tools for them either - MCP is a thin adapter over the same REST routes, so
a tool would fail exactly the same way.

---

## 8. Rules for agents

1. **Call `list_pages` first.** Every other operation needs a channel id, and guessing one fails.
   Check `platform` on the result - Instagram posts need media and an `ig_type`, and a campaign that
   likes comments will not like them there. Unsure what you may do today? `get_account_status`.
2. **Publishing is public and irreversible.** Never publish content a human has not seen.
   Draft, show it, then publish.
3. **Use `dry_run`** on `publish_post` when you are unsure - it validates without publishing.
4. **Verify campaigns with `test_campaign_match`** before the post is live. A campaign with a
   too-broad keyword will message everyone who comments.
5. **Do not retry 4xx**, except the 429s in the error table, which carry a `retry_after`.
6. **Respect the daily caps** (publishes, comments, messages). They exist to bound the damage of a
   loop, not to be worked around: on `daily_cap`, stop that kind of action until `resets_at`.
   Follow `next_step` on any MCP error.
7. **Check `get_activity_log`** after a campaign goes live to confirm messages are actually
   being delivered rather than silently failing.
8. **A 202 means a human is deciding.** Stop and report that; do not loop waiting for it.
9. **`route_forbidden` is final.** It is not a scope problem and not a transient one - the route is
   not open to keys at all. Report it and stop rather than trying variations.
