The same reference as plain Markdown, for an agent: llms-full.txt. The OpenAPI spec: openapi.json.
Run a Facebook Page and an Instagram professional account from your AI agent or your own code: 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.jsonin the repository, namecom.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"), which is discovered through and operated with a connected Facebook Page's token. It has its own id, its own campaigns and its own activity log, but it holds no credential of its own - reconnecting the parent Page re-authorizes it.
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 | |
mention | the account was @mentioned in a story |
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:
{
"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 developer-mode connectors, Cursor and any other compliant client connect with nothing but the URL https://app.dmswift.com/mcp:
POST /mcpwithout a token answers401withWWW-Authenticate: Bearer resource_metadata="https://app.dmswift.com/.well-known/oauth-protected-resource".GET /.well-known/oauth-protected-resource(also at…/oauth-protected-resource/mcp, RFC 9728) names the resourcehttps://app.dmswift.com/mcp, the authorization server andscopes_supported.GET /.well-known/oauth-authorization-server(RFC 8414) lists the endpoints.code_challenge_methods_supportedis["S256"],token_endpoint_auth_methods_supportedis["none"](public clients), andauthorization_response_iss_parameter_supportedistrue- every authorization response carriesiss(RFC 9207).POST /oauth/register(RFC 7591, JSON) registers a public client. Redirect URIs must behttps, orhttponlocalhost/127.0.0.1(any port is accepted at authorization time for loopback, RFC 8252). Other URIs in the request are dropped; none acceptable is400 invalid_redirect_uri. Or skip registration with a Client ID Metadata Document (the metadata saysclient_id_metadata_document_supported: true; this is what Claude's default "Use Claude's published identity" sends): theclient_idis thehttpsURL of a JSON document the client publishes. dmswift fetches it when the authorization request arrives -httpson 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 asapplication/json. The document'sclient_idmust equal the URL exactly, it must be a public client (token_endpoint_auth_methodnoneor absent, no secret), and itsredirect_urisfollow the registration rule above (unacceptable ones dropped, none left is refused); the request'sredirect_urimust be one of them. A valid document is cached for itsCache-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/tokenchecks the document again (from the cache): one withdrawn since is401 invalid_client.GET /oauth/authorizewithresponse_type=code,client_id,redirect_uri,code_challenge,code_challenge_method=S256,state, optionalscope(space-separated scopes from §2; unknown names such asoffline_accessare ignored, none means all) andresource(must be this server's/mcpURL, elseinvalid_target). A malformed request (no S256 challenge, wrongresponse_type, foreignresource, 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.POST /oauth/token(application/x-www-form-urlencoded):grant_type=authorization_codewithcode,code_verifier,client_id(and optionallyredirect_uri,resource). Codes live 5 minutes and are single-use; a replayed code ends the connection it produced.grant_type=refresh_tokenwithrefresh_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.
POST /oauth/revoke(RFC 7009) withtoken(either token) ends the connection; always200.
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, kb:write | forced ask - a publish parks for approval (202), scheduling, public replies and switching a campaign on are refused |
full | everything on offer | unchanged |
Any other combination reads back as custom.
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 }-oauthis true for the access token of a connection (oauth_app_idis 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_idis 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 (people who wrote first) |
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 custom connector → the URL, then sign in (OAuth) |
| Claude Code | claude mcp add --transport http dmswift https://app.dmswift.com/mcp, then /mcp → Authenticate (or add --header "Authorization: Bearer <key>") |
| ChatGPT (and dots) | chatgpt.com/plugins → + → Add custom MCP server → the URL, authentication OAuth → Create as a plugin. 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 | ~/.cursor/mcp.json: { "mcpServers": { "dmswift": { "url": "https://app.dmswift.com/mcp" } } } |
| 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).
{
"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, …).
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 | Upload an image or video (via data base64 or url) and get back media_id (file), signed_url, size and sha256. Pass expected_sha256 with base64. Instagram photos must be JPEG |
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. 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 people started, 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 |
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:
upload_media(JPEG) →create_postwithig_typeandmedia_ids→ show the draft →publish_postwithdry_run, then for real. - Text post (no photo):
render_text_card(slidesfor a carousel) →create_postwith itsmedia_ids→ show the draft →publish_post. Only text crosses the connection. - Comments:
list_comments→reply_to_commentfor real questions (aftersearch_knowledge),hide_commentfor spam (reversible; preferred),delete_commentonly when asked,like_commenton Facebook. - Inbox:
list_conversations(unread: true) →get_conversation→reply_to_conversationwhilewindow_openand nottakeover_active. - Results:
get_statsandget_activity_logfor campaigns;get_insightsandget_post_insightsfor 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:
list_pages→ take theidof the channel you want.create_postwith the Page id, the text, optionalfirst_commentorbg_preset, and acampaign:
{
"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
}
}
test_campaign_matchwithtext: "how much does it cost?"→ confirm the campaign matches before anyone sees the post.publish_postwithdry_run: true→ confirm it is publishable.publish_post→ live. The campaign now answers comments on it automatically, andfirst_commentis 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 - passpost_id(orfb_object_idfor a live Facebook post or comment thread),message, and/or an image via:media_id: thefile/media_idof an image already uploaded to dmswift (e.g."a1b2c3d4...png"or"/api/uploads/a1b2c3d4...png"fromupload_media,POST /api/uploads, orget_post),media_data: base64-encoded image bytes (ordata:image/png;base64,...) + optionalmedia_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, up to 25MB each, 10 per post on Facebook (Instagram: see below). A link preview and attached media are mutually exclusive; media wins.
URLs must be publicly reachable. Addresses that resolve inside a private network are refused, and redirects are not followed.
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 JPEG or a public https URL) and share_to_feed (default true) |
story | 1 photo or video | no first comment |
- Photos must be JPEG, up to 8 MB (
err.ig_jpeg_only,err.ig_image_too_large). - 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 nobg_preset.first_commentis text only, up to 2,200 characters. - An edit that omits
ig_type,cover_urlorshare_to_feedkeeps 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_interruptedmeans the outcome is unknown and is never retried - checkget_postorlist_live_posts. A scheduled post that meets a full quota is pushed back an hour. - Deleting: Meta's API cannot delete Instagram media.
delete_postwithalso_remoteremoves 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_postandlike_commentare Facebook-only.
An Instagram reel through MCP:
{ "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
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
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:
{
"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
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>
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.
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.
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.
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.
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 }
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.
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) orslides(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:
portrait1080x1350 (default),square1080x1080,story1080x1920. A story card is forig_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) orbackground: { from, to, angle }(hex colors; CSS-style angle, default 135 = top-left to bottom-right),text_color,footer(up to 40 characters,""for none), andlogo: falseto leave the kit's logo off. Whatever is not sent comes from the channel's brand kit, then from thered-bluepreset. - Brand kit: one per channel.
PUTreplaces the look (what is not sent is cleared) but keeps the logo unlesslogois sent: amedia_idreplaces it,nullremoves 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 }] }. Eachmedia_idworks increate_post/update_postlike 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 answers429 err.render_busy(retry in a few seconds). A text that cannot fit is400 err.text_card_too_longand 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
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 |
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).
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 המחיר, והמחיר, כשהמחיר). 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 | required - the private message |
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 |
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 |
daily_cap | max DMs per day for this campaign, 0 = unlimited, max 100000 |
once_per_user | 1 never messages the same person twice |
active_start / active_end | quiet hours, minutes past midnight 0-1439, -1 = off |
active_days | CSV of weekday numbers 0-6, empty = every day |
active | 0 pauses the campaign |
Analytics
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 askipped (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:
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 asclicks, 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 oflink_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.
Rate limit: 240 report requests per account per minute, answered with 429 and code: "err.analytics_rate_limited" plus a Retry-After header.
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
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
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 } |
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? } } |
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. 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)
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.
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:
dmsis 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 asbroadcast_sendsandsequence_sendsfor 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.broadcastscounts 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 |
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 |
All fourteen are subscribable and all fourteen now have an emitter. 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.exhaustedcurrently fires for one resource - the monthly message allowance (resource: "dms"), from the comment path. Hitting the post, AI or broadcast limit is refused with a402but does not emit.dm.sentanddm.failedcarry asourcefield (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:
{
"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
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 Goneis 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 withdisabled_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://andhttps://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.arpaand 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.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 on JPEGs without EXIF rotation) | 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:
{
"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 isGET /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'sdm_textdown to a single line and throws the other variants away (POST /api/abtests/:ruleId/apply, orauto_applyon 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, andqr.svgis 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/testsends 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
- Call
list_pagesfirst. Every other operation needs a channel id, and guessing one fails. Checkplatformon the result - Instagram posts need media and anig_type, and a campaign that likes comments will not like them there. Unsure what you may do today?get_account_status. - Publishing is public and irreversible. Never publish content a human has not seen. Draft, show it, then publish.
- Use
dry_runonpublish_postwhen you are unsure - it validates without publishing. - Verify campaigns with
test_campaign_matchbefore the post is live. A campaign with a too-broad keyword will message everyone who comments. - Do not retry 4xx, except the 429s in the error table, which carry a
retry_after. - 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 untilresets_at. Follownext_stepon any MCP error. - Check
get_activity_logafter a campaign goes live to confirm messages are actually being delivered rather than silently failing. - A 202 means a human is deciding. Stop and report that; do not loop waiting for it.
route_forbiddenis 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.
