Ampli API & MCP Reference

Everything a user can do in Ampli, from code or from an AI agent: generate posts on viral templates, bring your own media, schedule and publish to eight networks, keep an ideas board, answer comments and messages, and read analytics.

Two surfaces, one behaviour:

Surface For Where
REST API (v1) Scripts, backends, automation platforms (n8n, Make, Zapier) https://www.useampli.com/api/v1
MCP server AI agents: Claude, ChatGPT, Cursor, Codex, Grok, OpenClaw, Hermes, any MCP client https://www.useampli.com/api/mcp

MCP tools use the v1 endpoints, with the same ownership checks and publishing validation. Some tools add defaults, combine calls, or return a smaller response; see How MCP differs from REST. The machine-readable spec is GET /api/v1/openapi (OpenAPI 3.1). Its operation names usually match MCP tools; the tool table gives the exact mapping. Some REST operations have no MCP tool.

ViralFarm and Ampli are the same product under two names. Everything here applies to both; only the domain differs (www.myviral.farm or www.useampli.com). Use the one your account was created on.

This reference is published at https://www.useampli.com/docs (and on the Ampli domain); the source is docs/API.md in the repo, rendered by npm run build:api-docs. It was last brought in line with the code on 2026-10-19.


Contents#

The sections below, in order; on a wide screen the same list stays beside the text.

  1. Changes

Quick start#

REST, in 60 seconds#

  1. In the app, open Settings → API Keys, click Create key, copy the vf_… value. It is shown once.
  2. Call the API:
export VF_KEY=vf_your_key

# Who am I, and how many credits do I have?
curl -s https://www.useampli.com/api/v1/me -H "Authorization: Bearer $VF_KEY"

# Which social accounts are connected? (their ids are what posting takes)
curl -s https://www.useampli.com/api/v1/accounts -H "Authorization: Bearer $VF_KEY"

# Schedule a text post to one account
curl -s -X POST https://www.useampli.com/api/v1/posts \
  -H "Authorization: Bearer $VF_KEY" -H "Content-Type: application/json" \
  -d '{"socialAccountIds":["123"],"caption":"Hello from the API","scheduledAt":"2026-11-01T15:00:00Z","timezone":"America/Los_Angeles"}'

MCP, in 60 seconds#

Claude Code:

claude mcp add --transport http ampli https://www.useampli.com/api/mcp \
  --header "Authorization: Bearer vf_your_key"

Claude.ai or Claude Desktop: Settings → Connectors → Add custom connector, URL https://www.useampli.com/api/mcp, then sign in and click Authorize. No key needed; OAuth handles it.

Any stdio-only client (Cursor's JSON config, for example):

{
  "mcpServers": {
    "ampli": {
      "command": "npx",
      "args": ["-y", "viralfarm-mcp@latest"],
      "env": { "VIRALFARM_API_KEY": "vf_your_key" }
    }
  }
}

The in-app setup page at /mcp has copy-and-paste instructions for every client, and a one-prompt setup you can paste into any agent.


Three journeys#

The endpoint reference below is organised by resource. These are the three complete flows most integrations are, in order, with the calls each one makes.

Publish existing media.

  1. GET /accounts — the ids to post to.
  2. GET /accounts/{id}/publishing-options for each — limits, the fields that platform takes, the TikTok privacy options the account allows; GET /accounts/{id}/boards for Pinterest.
  3. POST /uploads (presigned PUT) or POST /uploads/direct (base64) — a hosted mediaUrl.
  4. POST /posts with mediaUrls, socialAccountIds, scheduledAt or publishNow: true, platformOverrides from step 2, and an Idempotency-Key.
  5. Read the outcome: publishNow.outcome now, or GET /posts/{id} → status + outcome + results later. Retry a failed or partial post with POST /posts/{id}/retry.

Generate content.

  1. GET /templates (optional) — a look the user likes.
  2. POST /generations with input (a URL, topic or text), format, optional templates / collection, and an Idempotency-Key → a job id.
  3. GET /generations/{id} every ~15 s until completed; take a post id from job.result.posts, then GET /generated-posts/{id} for its editor viewUrl.
  4. POST /posts with generatedPostId (media is rendered for you), then step 5 above.

Manage content.

  • GET /posts with status / limit / offset; GET /posts/{id} for the full record.
  • PATCH /posts/{id} to edit or re-time; DELETE /posts/{id} to cancel; POST /posts/{id}/retry for a failed one.
  • GET /ideas … DELETE /ideas/{id} for the ideas board; /comments, /messages, /analytics where enabled.

Authentication#

API keys#

The v1 content endpoints and the MCP server accept a personal API key, created in Settings → API Keys. Only a SHA-256 hash is stored; the key itself is shown once. /keys requires a browser session or session JWT; /openapi is public.

Send it in a header. MCP also supports a legacy query-string credential:

How Example Use when
Bearer header (preferred) Authorization: Bearer vf_… Everything
Custom header x-api-key: vf_… Clients that reserve Authorization
Query string (MCP only) ?key=vf_… MCP clients that cannot set headers at all. REST does not accept this; URLs can end up in logs

A key acts as its owner: it can do exactly what that user can do in the app, on that user's own accounts.

How many keys a plan allows (each connected AI agent holds one):

Plan Active keys
Free 1
Starter, and a Starter trial 3
Pro, and a Pro trial 10
Premium, and a Premium trial Unlimited (10 through Settings)

Accounts on the legacy per-account Pro plan have a 3-key allowance.

Revoking a key frees its slot. A revoked key fails immediately on the next request.

OAuth (MCP clients)#

MCP clients that support OAuth for remote servers (Claude.ai, Claude Desktop, ChatGPT, Grok, Hermes and others) connect with no key at all. The server publishes standard discovery documents:

  • /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource
  • Authorization at /oauth/authorize, token exchange at /api/oauth/token, dynamic client registration at /api/oauth/register

Completing the flow mints an API key for that connection behind the scenes (named after the client, for example "Claude (OAuth)"), which counts toward the plan's key allowance and appears under Settings → API Keys, where it can be revoked like any other.

Sessions#

A signed-in browser session (the Supabase JWT) is also accepted as the Bearer token on every v1 endpoint. This is how the app's own AI assistant will call the same endpoints without minting a key. Key management (/v1/keys) accepts session auth only: a key cannot create or revoke keys.


Conventions#

Base URL. https://www.useampli.com/api/v1. The paths below are relative to that.

Format. JSON in, JSON out. Send Content-Type: application/json on requests with a body. Content responses carry success: true | false; /openapi returns the specification itself. Examples labelled json are valid JSON you can send as-is; examples labelled jsonc are annotated illustrations (comments, alternatives, request → response arrows) and need editing first.

success is not "published". success: true means the request was accepted and the object exists. Whether a post actually went live is a separate field: publishNow.outcome on the create response and outcome on every post (see Post status). Never report a post as posted from success alone.

Errors. A failure is { "success": false, "error": "<what went wrong, in plain words>" }, with field on validation errors that identify a specific input ("title", "mediaUrls[2]", "Idempotency-Key"), code where a machine-readable reason exists, and sometimes issues: string[] (one line per platform rule). These extra fields are optional; always read error as well.

Status Meaning
400 Invalid input. Posting and generation validators reject overlong titles, invalid media URLs and out-of-range counts. Platform validation may include issues; some wrappers return the rules in error. Template-list limits are clamped to 1–50, and unsupported analytics periods fall back to 30 days.
401 No credentials, a revoked key, or a key that does not resolve.
403 Plan limit: the monthly post cap, the X link cap, or a feature the plan lacks.
404 Not found, or not yours. Also returned with code: "feature_off" by comments, messages and analytics on deployments where those features are not yet enabled (see Core concepts).
409 The post is publishing right now, or already published, and cannot be changed. Also: a create with an Idempotency-Key that is still running (code: "idempotency_in_progress").
422 An Idempotency-Key reused with a different request body (code: "idempotency_key_reused").
413 A base64 upload over 3 MiB.
500 Server failure. Retry reads. The three creates supporting Idempotency-Key can be retried with that key; other creates may need a read to check whether they succeeded first.
502 A platform did not answer (Pinterest boards, for example); error says which and why.
503 Required API storage is not set up on this deployment. Missing idempotency storage returns code: "idempotency_unavailable".

Ids. Social account ids are numeric strings ("123"); numbers are accepted too. Post, idea and generation ids are UUID strings. Comment and conversation ids are opaque strings from their list endpoints. Never construct an id.

Times. Timestamps are ISO-8601. Send scheduledAt with an explicit offset (2026-11-01T15:00:00Z, or -07:00) and pass timezone (an IANA name) so the app shows the time the way the user thinks of it. Scheduled times must be at least a few minutes in the future.

Pagination. These lists page: GET /posts, /ideas, /generated-posts, /generations, /comments and /messages. They take limit (max 100; default 25, or 50 for ideas) and offset, and return count (items in this page), limit, offset, hasMore, nextOffset (null on the last page) and, when the whole matching set has been read, total. count is never the total. Invalid limit/offset values on these paged endpoints are 400s. The remaining lists — /accounts, /templates (which has its own limit and total), /media-collections, /keys, boards — return everything in one response.

Idempotency. Send an Idempotency-Key header (1–255 characters, unique per request, for example a UUID) on POST /posts, POST /generations and POST /ideas. If the request times out, send it again with the same key and body: you get the original response (status and body, with Idempotent-Replayed: true) instead of a second post or generation. Keys are scoped to your account and the endpoint, across projects, and are permanent: the created object keeps its key, so a key is never reused for something else. The same key with a different body is 422; the same key while the first request is still running is 409 (idempotency_in_progress) — retry with the same key a few seconds later, or read the object with GET. The key is written into the created object by the same database insert that creates it (and is unique per account), so whatever fails afterwards — a lost response, a publish-now step that times out, a process that dies — the retry recovers that same object by its key and returns it with idempotentRecovery: true; a second post is never created, and the database itself refuses a second row with the same key. On a recovered post, publishNow reports the row's real state (not_started for a draft whose publish never ran: publish it with POST /posts/{id}/retry; publishing, retrying, or the finished outcome). A request that failed before the create was dispatched releases the key so the retry runs; one that failed after dispatch with nothing to recover answers 500 idempotency_unresolved and keeps the key until a retry finds the object or six minutes pass. A generation job that was inserted but never started (its create lost the response, or the worker's first claim failed) is started by the next replay with its key, by the recovery, or by the next GET /generations/{id} poll — never twice, the worker claims it atomically — so polling it works as usual. Without the header, creates are not deduplicated. For a failed post use retry, never a new copy. Keep the same project header on a retry. The header is not part of the request fingerprint: reusing a key with the same body in another project replays the original object. Use a new key for a separate create, even in another project. Repeated edits and cancels do not create copies, but may return an error if the object's state changed.

Rate limits. There is no hard limit today. Poll generations about every 15 seconds, never faster, and do not poll a post to "confirm" a schedule: the create response is the confirmation.

Timeouts. Requests that render media (scheduling a generated post for the first time, generation polling) can take tens of seconds. Allow at least 120 seconds client-side.

Links. Objects carry a viewUrl that opens them in the app (the editor for a generated post, the queue, the idea page). Share those with users instead of raw media.


Core concepts#

Platforms and connected accounts#

A user connects social accounts in the app. The API never connects or disconnects accounts (that needs the platform's own browser login); it lists them. Platforms: tiktok, instagram, pinterest, linkedin, threads, facebook, youtube, x.

Projects#

A user's work is split into projects. Each project has its own connected accounts, posts, ideas, generated assets, products and private media collections; the plan, channel limit and credits are shared across them. When projects are enabled, the first is created as "My Project". How many projects a user can have depends on the plan: 1 on Free, 5 on Pro, 10 on Premium and unlimited on Agency. Creating one past the limit returns 403 with code: "project_limit" and a message naming the plan that allows more; projects a user already has are never removed.

Lists of accounts, posts, ideas, generated posts and private collections, and the comments/messages/analytics summaries, use the current project. New posts, ideas, generations and private collections are created there. An API key defaults to the project it was created in; GET /me identifies the resolved project. Send x-vf-project-id with an id from GET /projects to select another one. MCP tools accept it as projectId.

An explicit header takes precedence over the app's vf-project cookie, then the key's project (or the user's oldest project for session callers). An id that is not owned by the user falls back to their oldest project. Clients should use an id returned by /projects and check /me when needed.

Projects are an organizational filter, not separate API permissions: keys act as the user. Operations targeting an existing object by id check its owner and can reach that user's objects in other projects. Uploads, the template catalog, generation job history (GET /generations), and key listing are also not filtered by project. Public photo packs are shared. If projects are not enabled, /projects returns enabled: false and these project filters are inactive.

Generated posts vs. scheduled posts#

Two different objects:

  • A generated post is a designed asset: slides built on a template, with a caption. It lives in the user's library (/generated-posts). Its media renders on demand and is cached.
  • A scheduled post is an entry in the queue: media URLs (or a generated post reference), a caption, target accounts, a time, and a status. Posting always goes through the queue, even "publish now".

Post status#

Status Meaning What to do
draft Saved, not queued. No time required. Edit it, or queue it with status: "scheduled" + scheduledAt.
scheduled In the queue for scheduledAt. Nothing.
retrying A temporary error; the queue retries automatically at nextAttemptAt. Nothing. Do not schedule it again; that posts it twice.
publishing Handed to a platform that has not confirmed yet (TikTok processing, for example). Check again later with GET /posts/{id}.
published The attempt is over and at least one destination took the post. Read outcome: it may be live everywhere, partly posted, or only in the TikTok inbox. Check outcome and results.
failed The attempt is over and nothing was confirmed posted. error says why; outcome says whether a platform might still have it. Fix the cause, then POST /posts/{id}/retry. Never create a copy.
cancelled Removed from the queue. Nothing. Deletes are soft app-wide: the row stays, cancelled.

retrying and publishing are derived states; the stored status underneath is scheduled or published.

Outcome. Every post carries outcome, null until an attempt has finished, then one of:

outcome Meaning
published Live on every destination.
sent_to_inbox Nothing failed, but at least one TikTok destination received the post as an inbox notification; it is not live until the user finishes it in TikTok.
partial At least one destination succeeded and at least one failed. results says which; POST /posts/{id}/retry re-sends only the failed ones.
unconfirmed Nothing succeeded and at least one attempt's result is unknown (the platform may have received it). Check the account before posting again; it is not retried automatically.
failed Nothing was posted anywhere.

results lists each destination with delivery: live, inbox, processing, failed or unconfirmed, plus postUrl and error. The note on a finished post is a sentence you can show a user. The same vocabulary comes back as publishNow.outcome / retry.outcome on an immediate attempt, with four more values for attempts that are not over: publishing, retrying, unknown (the request was dispatched but its result could not be read — check GET /posts/{id}) and not_started (refused before any attempt).

Credits#

Generation costs credits per post: a slideshow 4, a pin 1 (reels are app-only for now). Scheduling, publishing, uploads, ideas, comments and messages cost nothing. GET /me shows the balance; running out fails the generation job with an Insufficient credits error.

Ideas#

An idea is a note on the Ideas board (the app's home page): a title, a body, optional images, a status (todo, in_progress, done) and details such as a target date, priority, tags, campaign and the accounts it is meant for. Ideas are never posted. They are stored as queue drafts with an idea marker, which is why they are kept out of GET /posts and have their own endpoints.

Availability of comments, messages and analytics#

Reading comments, replying, hiding, reading and sending direct messages, and analytics depend on platform permissions that are approved per deployment. Where they are not enabled yet, those endpoints answer 404 { code: "feature_off" }, and the MCP server leaves their tools out of tools/list entirely. Projects and other API features also require their database migrations to be installed.


Endpoint reference#

Account#

GET /me#

The account behind the credentials.

{
  "success": true,
  "user": { "id": "uuid", "email": "me@example.com", "credits": 396, "products": [{ "id": "...", "name": "My course" }] },
  "project": { "id": "uuid", "name": "My Project" },
  "projects": [{ "id": "uuid", "name": "My Project" }]
}

products are the current project's products, newest first (up to 10); the first is what a topic generation's call to action recommends. project is the project this call resolves to (see Projects); it is null when no project is available. The response also includes a gettingStarted list of suggested API calls.

Projects#

Call Does
GET /projects The user's projects, each with current: true on the one this call works in.
POST /projects { "name": "Client A" } Creates an empty project. Nothing moves into it; connect channels and create posts there with the header below. 403 project_limit once the plan's project limit is reached.
PATCH /projects/{id} { "name": "..." } Renames a project.
GET /api/v1/accounts
Authorization: Bearer vf_...
x-vf-project-id: 7b1e…   ← optional: another of the user's projects

The header selects the project for the scoped operations described above; it does not restrict access to an existing object addressed by id. GET /projects returns { success, enabled, currentId, projects }, with { id, name, createdAt, current } for each project. Create and rename return HTTP 200 with { success, project }; create also returns a note explaining the header. Names are trimmed, whitespace is collapsed, and the result must be 1–80 characters. Create/rename return 400 if projects are not enabled.

Project deletion and switching the app's current project are done in the app, not through the API. A connected channel can belong to multiple projects. In Settings → Channels, choose Add to project and confirm, or use Add a connected channel to reuse an existing connection without signing in again. Connecting or reconnecting through OAuth adds the channel to the current project while preserving its other project links. Each connection counts once toward your account limit and billing.

New posts can target channels linked to the selected project. Existing drafts, ideas, and scheduled posts stay in their original project. Remove from project hides a channel there without disconnecting it; existing scheduled posts keep publishing. Disconnect everywhere stops publishing to that channel across all projects and requires separate confirmation. Reconnecting refreshes the shared credential for every linked project.

Connected accounts#

GET /accounts#

{ "success": true, "accounts": [
  { "id": "123", "platform": "instagram", "username": "annicorn", "display_name": "Annie", "is_active": true, "updated_at": "…", "instagramMusic": false }
] }

The id values are what socialAccountIds takes everywhere. If the platform a user wants is missing, they connect it at /home in the app. Instagram accounts carry instagramMusic: true when the account is connected through Facebook, so a single-video Reel can carry music (see GET /accounts/{id}/instagram-audio).

GET /accounts/{id}/publishing-options#

Everything POST /posts accepts for this one account, so an integration can discover every required value: caption and media limits, the platformOverrides fields with their allowed values, and for TikTok the live creator settings.

{ "success": true, "account": { "id": "123", "platform": "tiktok", "username": "annicorn", "isActive": true },
  "options": {
    "platform": "tiktok", "label": "TikTok",
    "caption": { "max": 2200, "maxVideo": 2200, "maxPhotos": 4000, "hashtagsMax": null, "mentionsMax": null },
    "media": { "single-image": { "min": 1, "max": 1 }, "multi-image": { "min": 1, "max": 35 }, "video": { "min": 1, "max": 1 } },
    "overrides": { "key": "tiktok", "fields": [
      { "field": "post_mode", "type": "enum", "required": true, "values": ["direct", "inbox"], "description": "…" },
      { "field": "privacy_level", "type": "enum", "required": "post_mode = direct", "values": ["PUBLIC_TO_EVERYONE", "FOLLOWER_OF_CREATOR", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"], "description": "…" }
    ] },
    "tiktok": { "available": true, "nickname": "Annie", "username": "annicorn", "privacyLevelOptions": ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"],
                "commentDisabled": false, "duetDisabled": false, "stitchDisabled": false, "maxVideoPostDurationSec": 600, "postingCapReached": false } } }

media holds the allowed item counts per post format; a format that is missing is not allowed on that platform. For TikTok, tiktok.privacyLevelOptions are the only privacy_level values this account may use (TikTok decides, per account), the three *Disabled flags say which interaction toggles the account cannot change, and postingCapReached: true means stop and tell the user. When TikTok cannot be reached, tiktok.available is false and tiktok.error says why (usually: reconnect the account) — the static limits are still returned. For Pinterest, boards come from the endpoint below.

GET /accounts/{id}/instagram-audio#

Search Instagram's authorized music catalog for an active Instagram account connected through Facebook Login (instagramMusic: true in GET /accounts). Requires a valid Facebook user token; Instagram Login connections must reconnect through Facebook to use music. The account must be in the key's project. The MCP equivalent is search_instagram_audio.

Parameter Meaning
audio_type music (default) or original_sound
search_query Optional keyword search, up to 200 characters. Omit for trending audio.
audio_id Optional audio ID string. Returns that track's metadata instead of searching.

Search returns { "success": true, "audio": [{ "audio_id": "…", "title": "…" }] }. Lookup returns a single object under audio. Other metadata fields come from Meta and can be absent. Preview URLs are temporary and must not be persisted as the track identity. Use the stable audio_id in platformOverrides.instagram.audio_configuration when creating a single Reel video. Optional audio_volume and video_volume must be integers 1–100. Music is unsupported for images, carousels, and Stories. No separate music permission exists: Facebook-login instagram_basic and instagram_content_publish cover the Audio API (plus login dependencies).

GET /accounts/{id}/boards#

The Pinterest account's boards; platformOverrides.pinterest.board_id must be one of these ids.

{ "success": true, "account": { "id": "456", "platform": "pinterest", "username": "annie" },
  "boards": [{ "id": "1021", "name": "Spring looks", "description": "", "pinCount": 42, "privacy": "PUBLIC" }], "count": 1 }

POST /accounts/{id}/boards#

{ "name": "Spring looks" } creates a public board (or returns the existing one with that name) → 201 { board: { id, name } }. Both board endpoints answer 502 with the reason when Pinterest does not (an expired sign-in means the user reconnects Pinterest in the app).

Templates and photo packs#

GET /templates#

The template catalog, described in words, for agents that cannot show thumbnails.

Query Values Notes
format slideshow, pin, reel Optional filter; reel templates can be browsed, but API generation only accepts slideshow/pin
platform tiktok, instagram Templates modeled on that platform
query free text "dark moody", "notes app", "tweet thread"; ranks by relevance, then by likes of the post the template was modeled on
limit 1–50 default 8
include preview Adds first-slide previewHtml where available
{ "success": true, "templates": [
  { "id": "…", "name": "Dark Editorial", "look": "dark magazine editorial", "details": "…", "format": "slideshow", "category": "…", "platform": "TikTok", "likes": 4536, "aspectRatio": "9:16", "viewUrl": "https://www.useampli.com/create?template=…" }
], "total": 42, "browseUrl": "https://www.useampli.com/create" }

Pass a template's name (or id) in POST /generations templates. Show people names and looks, never ids.

GET /media-collections#

The photo packs posts draw images from: Ampli's packs (each with a one-line vibe) plus the user's own uploaded collections. ?kind=photos|yours narrows it.

{ "success": true, "collections": [
  { "id": "…", "name": "Old Money", "kind": "photos", "source": "viralfarm", "vibe": "quiet luxury, linen and marble", "imageCount": 120, "isDefault": true }
], "manageUrl": "https://www.useampli.com/media" }

Pass a pack's name (or id) in POST /generations collection.

POST /media-collections#

Create one of the user's own collections. Body { "name": "Spring launch" } → 201 { collection: { id, name, mediaSelectionId } }. The name is trimmed, whitespace is collapsed, and names longer than 60 characters are shortened.

POST /media-collections/images#

Add hosted image URLs to one of the user's own collections.

{ "collection": "Spring launch", "mediaUrls": ["https://…/a.jpg", "https://…/b.jpg"] }
→ { "success": true, "collection": { … }, "added": 2, "total": 14 }

Host files first with an upload and pass the returned mediaUrls; any https image URL is accepted. Ampli's own packs are read-only. Duplicates are skipped.

Generation#

POST /generations#

Turn a URL, a topic or pasted text into finished posts. Asynchronous: the call returns a job immediately; poll it.

{
  "input": "https://example.com/blog/5-mistakes",   // required: URL, topic, or pasted text
  "format": "slideshow",                            // required: slideshow | pin
  "count": 3,                                       // 1–20, default 1
  "slidesPerPost": 6,                               // slideshows: 2–12 slides including the cover
  "hookStyle": "numbered",                          // cover-headline style, see below
  "customHook": "The 5 mistakes I made…",           // verbatim headline; overrides hookStyle
  "voice": "casual and funny",                      // tone
  "templates": ["Dark Editorial"],                  // template name(s) from GET /templates
  "collection": "Old Money",                        // photo pack name from GET /media-collections
  "firstSlidePack": "…",                            // a pack just for the cover
  "options": { "ctaKind": "comment", "ctaCommentWord": "GUIDE" }
}
→ 202 { "success": true, "job": { "id": "uuid", "status": "queued" } }

Three layers, on purpose: input and format are all an agent should confirm with the user; count, slidesPerPost, hookStyle / customHook, voice, templates and collection are quick dials; everything else lives under options and should only be set when asked. Every default is the app's own automatic pick.

hookStyle: ai (default), article (reuse the linked page's headline; URL input only), numbered, how-to, quick-easy, personal, headline, motivational, curiosity-gap, contrarian, before-after, real-numbers, who-its-for, best-of, gift-idea, shop-the-look, named-style, free-download, cooked-in, craveable, outfit-formula, seasonal-capsule.

options (also accepted flat at the top level): mediaMode (auto | url-images | collection | pexels | pexels-video | web-search), mediaSelectionId, mediaQuery, ctaKind (comment | product | link-bio), ctaCommentWord, ctaProductName, ctaMode (auto | none | caption | last-slide | body), promoLevel (none | light | balanced | heavy), keywords, customPrompt, includeCaption, includeTitle, aspectRatio ("W:H"), templateIds, palette ({ text, main, accent1, accent2 } as hex), stockImageUrls.

Media when you pick nothing: a link uses the page's own photos; a topic uses the Old Money pack for slideshows and pins. Never random stock.

templates and collection resolve forgivingly ("dark editorial", "the notes one"). An unknown or ambiguous name is a 400 whose message lists the closest matches, so an agent can ask one precise question. A template of the wrong format for the run is also a 400.

Costs credits per post (slideshow 4, pin 1). Running out surfaces as a failed job, not a 402 on this call.

GET /generations/{id}#

Poll a job. Every 15 seconds is plenty; a batch typically takes one to three minutes. status is queued | processing | completed | failed.

{ "success": true, "job": { "id": "uuid", "status": "completed", "request": { "format": "slideshow" }, "error": null,
  "result": { "posts": [
    { "id": "<generatedPostId>", "caption": "…", "pinTitle": null, "templateName": "dark-editorial", "slideCount": 6, "aspectRatio": "9:16",
      "template": { "id": "…", "name": "Dark Editorial", "look": "dark magazine editorial", "platform": "TikTok", "likes": 4536, "viewUrl": "…" } }
  ], "errors": [] } } }

A completed job with no posts is a failure; errors says why. template is the recap worth giving a user: which template was used and how the post it was modeled on performed. A job still queued twenty seconds after it was created was never started by its create request (a lost response, a failed worker claim); the poll starts it itself, once, so keep polling.

GET /generations#

The caller's jobs across all projects, newest first, paged: { jobs: [...], count, limit, offset, hasMore, nextOffset }. x-vf-project-id currently does not filter this history.

Generated posts#

GET /generated-posts#

The library, newest first. ?format=slideshow|pin|reel, ?limit=1..100, ?offset; the paging fields come back with the rows. List rows are previews (captionPreview is cut at 200 characters), alongside the full caption. ?include=slides adds htmlSlides and preview metadata.

{ "success": true, "posts": [
  { "id": "uuid", "format": "slideshow", "captionPreview": "…", "pinTitle": null, "topic": "…", "slideCount": 6, "viewUrl": "https://www.useampli.com/editor?id=…", "createdAt": "…" }
] }

GET /generated-posts/{id}#

The full record: untruncated caption, title and topic, plus viewUrl and any fresh cached media. ?include=slides adds the slide HTML.

GET /generated-posts/{id}/media#

Renders the post's media, or returns the cached render.

{ "success": true, "kind": "images", "mediaUrls": ["https://…/slide-1.jpg", "…"], "thumbnailUrl": "…", "previewImageUrl": "…", "viewUrl": "…" }

Slideshows and pins give one hosted image per slide; reels give one MP4 and a first-frame thumbnail. previewImageUrl is a single contact sheet of the whole post. You rarely need this: POST /posts with generatedPostId renders automatically.

Uploads#

Three ways to bring your own image or video, by what the caller can do:

Caller can… Use Size
Read the file and make an HTTP PUT (a terminal, a backend) POST /uploads → PUT the bytes Any, within storage limits
Only hand the user a link (a chat client) POST /uploads/sessions → user uploads in the browser Up to 1 GiB per file, resumable
Nothing but JSON (a sandbox with no outbound HTTP) POST /uploads/direct with base64 3 MiB decoded

Accepted types: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/quicktime. The stored extension is forced to match the content type, because scheduling infers the post format from the URL. Uploading never schedules or publishes anything.

POST /uploads#

{ "filename": "launch.mp4", "contentType": "video/mp4", "sizeBytes": 48213110 }
→ { "success": true,
    "upload": { "method": "PUT", "url": "https://upload.myviral.farm/storage/v1/object/upload/sign/…", "headers": { "Content-Type": "video/mp4" }, "expiresAt": "…", "fallbackUrl": "…" },
    "mediaUrl": "https://www.useampli.com/api/storage/media/<user>/<ts>_launch.mp4", "publicUrl": "…", "path": "…" }

Then stream the raw bytes:

curl --fail-with-body -X PUT -H "Content-Type: video/mp4" --upload-file ./launch.mp4 "<upload.url>"

The signed URL is valid for two hours and single use; nothing is stored until the PUT lands. Check the PUT's HTTP result before scheduling mediaUrl. upload.fallbackUrl targets the same object through the raw storage host if upload.myviral.farm is unreachable. Treat both URLs as opaque. Never pass the API key to the upload URL; it has its own signature.

sizeBytes must be positive when supplied. A value above 3 MiB selects the raw storage host for the PUT; it does not create a session or verify the file size. For a tracked transfer, create an upload session, then prepare the file with POST /uploads/sessions/{id} and poll that session.

POST /uploads/sessions#

{} → { "success": true, "sessionId": "…", "uploadPageUrl": "https://www.useampli.com/upload/…", "expiresAt": "…", "maxFileBytes": 1073741824 }

Share uploadPageUrl with the user. They pick files in the browser; bytes go straight to storage with resumable 6 MiB chunks, up to 20 files at a time. The link is scoped to this session and expires in two hours. Pass an existing sessionId to reopen a session after a failed transfer.

GET /uploads/sessions/{id}#

{ "success": true, "sessionId": "…", "state": "uploaded", "media": [{ "mediaUrl": "…", "filename": "…", "contentType": "video/mp4", "sizeBytes": 48213110 }] }

waiting means nothing has finished yet. Only the mediaUrls returned here are completed uploads; a link that was issued is not an upload.

POST /uploads/sessions/{id}#

Send { filename, contentType, sizeBytes, lastModified? }, where sizeBytes is a positive integer up to the returned maxFileBytes (at most 1 GiB), and lastModified is an optional nonnegative integer (default 0). Returns the storage's TUS endpoint and a path-scoped x-signature token in resumable, plus a signed PUT target in direct, or alreadyUploaded: true for a completed retry. Send 6 MiB chunks with the metadata bucketName, objectName (the returned path), contentType and cacheControl; do not set x-upsert. The signature is scoped to one object and expires with the session. Poll GET /uploads/sessions/{id} to confirm completion; a finished chunked transfer is not an upload until it shows there.

POST /uploads/direct#

{ "filename": "launch.png", "contentType": "image/png", "data": "<base64, a data: URL prefix is fine>" }
→ { "success": true, "mediaUrl": "…", "publicUrl": "…", "path": "…", "sizeBytes": 123456 }

Over 3 MiB decoded is a 413. Never put a large file through this path.

Posts: schedule, publish, edit#

POST /posts#

Schedule a post, publish it now, or save a draft.

{
  "socialAccountIds": ["123", "456"],      // required: from GET /accounts
  "generatedPostId": "uuid",               // a generated post (its media and caption are used) …
  "mediaUrls": ["https://…"],              // … or your own hosted media …
  "caption": "Text post, or an override",  // … or just text
  "title": "≤100 chars",                   // TikTok photo posts, Pinterest, YouTube
  "scheduledAt": "2026-11-01T15:00:00Z",   // required unless publishNow or draft
  "timezone": "America/Los_Angeles",
  "publishNow": false,                     // post immediately
  "draft": false,                          // save to the queue as a draft instead
  "format": "multi-image",                // optional: text | single-image | multi-image | video; inferred when omitted
  "platformOverrides": { "tiktok": { "post_mode": "inbox" } }
}

One of generatedPostId, mediaUrls or a non-empty caption is required. With generatedPostId, the caption defaults to the generated one and the media is rendered (and cached) automatically.

Responses

Scheduled: { post: { id, status: "scheduled", outcome: null, scheduledAt }, mediaUrls }.

Draft: { post: { id, status: "draft", outcome: null, scheduledAt: null }, mediaUrls }. Drafts skip platform validation and quotas; those run when the draft is queued. A draft can keep a supplied scheduledAt; the null example above omits it. If both publishNow and draft are true, publishing takes precedence.

Publish now: the call waits for the attempt and returns the real outcome.

{ "success": true, "post": { "id": "uuid", "status": "published", "results": [{ "accountId": "123", "platform": "instagram", "status": "published", "postUrl": "https://www.instagram.com/p/…" }] },
  "publishNow": { "outcome": "published" }, "mediaUrls": ["…"] }

publishNow.outcome is the truth of the attempt — published (live on every destination), sent_to_inbox (TikTok inbox, not live), partial (some destinations failed; post.results says which), publishing (the platform has not confirmed yet), retrying (temporary error, retried automatically at nextAttemptAt; do not send again), unconfirmed or unknown (the result could not be confirmed; check GET /posts/{id}, do not re-send), failed (publishNow.error) or not_started (refused before any attempt). Report that outcome; never assume success, and never read it from success. See Post status for the vocabulary.

Send an Idempotency-Key header so a timed-out create can be retried safely (see Conventions).

Validation. Input shape is checked first and comes back as 400 with field: a non-object body, an unknown account id, a title over 100 characters, a mediaUrls entry that is not an http(s) URL, a bad timezone, a format outside the four values. Platform rules then run at schedule time and come back as 400 with issues: caption limits, media counts (X takes at most 4 images; YouTube is video-only; Pinterest needs a board and one image; TikTok photo posts need a title), format conflicts, and a time that is too soon. Plan quotas (monthly posts, X links) are 403.

TikTok has two destinations. post_mode: "inbox" sends the post to the creator's TikTok inbox as a notification; the user opens it in TikTok to finish and publish, and nothing appears in their Profile → Drafts. The response includes tiktokDelivery explaining that. post_mode: "direct" publishes live and takes the full set of options below; it requires privacy_level, chosen by the user from the values GET /accounts/{id}/publishing-options returns for that account (TikTok's Content Posting API terms: no assumed privacy, and the interaction toggles under the user's control). A direct post without it is a 400 issues.

platformOverrides, by platform (values are internal; agents should present plain labels and map them):

{
  "tiktok":    { "post_mode": "direct" | "inbox", "title": "…", "privacy_level": "PUBLIC_TO_EVERYONE" | "FOLLOWER_OF_CREATOR" | "MUTUAL_FOLLOW_FRIENDS" | "SELF_ONLY",
                 "disable_comment": false, "auto_add_music": true, "disable_duet": false, "disable_stitch": false,
                 "commercial_disclosure": false, "brand_organic": false, "branded_content": false },
  "pinterest": { "board_id": "…", "title": "…", "link": "https://…" },
  "instagram": { "type": "FEED" | "REELS" | "STORIES", "trial_reel": true, "trial_graduation": "MANUAL" | "SS_PERFORMANCE", "audio_configuration": { "audio_id": "587784541076604", "audio_volume": 80, "video_volume": 50 } },
  "youtube":   { "title": "…" }
}

For a live TikTok post nothing is assumed on either surface: who can see it must be chosen by the user from the account's allowed options, and comments, duet, stitch, music and the commercial disclosure are the user's choices too. (Until 2026-10-19 the MCP tool filled in defaults here; it no longer does.)

GET /posts#

The queue and history.

Query Values
status comma-separated: draft, scheduled, retrying, publishing, published, failed, cancelled
limit / offset 1–100 (default 25) / 0+
order asc (default; by scheduled time) or desc. Published-only lists default to desc
{ "success": true, "posts": [
  { "id": "uuid", "status": "scheduled", "captionPreview": "…", "mediaCount": 3, "thumbnailUrl": "…", "scheduledAt": "…", "socialAccountIds": ["123"], "generatedPostId": null, "error": null }
], "count": 7, "limit": 25, "offset": 0, "order": "asc", "hasMore": false, "nextOffset": null, "total": 7 }

List rows are previews. "Last 5 published posts" is ?status=published&limit=5&order=desc. Ideas never appear here.

GET /posts/{id}#

The full post: untruncated caption, mediaUrls, per-account results with their delivery, the outcome once an attempt has finished, and a note that says what to do (every finished attempt and every retrying post has one).

PATCH /posts/{id}#

Edit a scheduled post or draft. Send only what changes; unsent fields keep their value.

{ "caption": "…", "scheduledAt": "…", "timezone": "…", "mediaUrls": ["…"], "socialAccountIds": ["…"], "title": "…",
  "status": "scheduled" | "draft", "platformOverrides": { … }, "format": "…" }
→ { "success": true, "post": { …full post… } }

status: "scheduled" with a scheduledAt queues a draft; status: "draft" unschedules. The editor's validation and quotas run on the merged result. Published or publishing posts are 409. Ideas are 400 here; edit them at /ideas/{id}. An empty body is 400.

POST /posts/{id}/retry#

Re-run a failed post now with the same media; accounts that already published are skipped. Waits for the attempt and returns retry.outcome (the same values as publishNow.outcome) and retry.error, like publish now. A partial post retries only its failed destinations.

DELETE /posts/{id}#

Cancel a scheduled post or draft (status becomes cancelled). Published posts cannot be cancelled (400); a post that is publishing right now is 409.

Ideas#

An idea object:

{ "id": "uuid", "title": "Spring launch teaser", "status": "todo", "body": "Notes, markdown is fine…", "mediaUrls": [],
  "properties": { "category": ["Launch"], "contentType": ["Reel"], "priority": "high", "publicationDate": "2026-11-01", "tags": ["#spring"], "campaign": "Spring 26", "accountIds": ["123"] },
  "createdAt": "…", "updatedAt": "…", "viewUrl": "https://www.useampli.com/new-idea?draftId=…" }

GET /ideas#

Newest-edited first. ?status=todo,in_progress,done, ?limit=1..100 (default 50), ?offset. → { ideas: [...], count, limit, offset, hasMore, nextOffset, total } — paged like every list, and no idea is skipped however many drafts the account has.

POST /ideas#

title is required (or a non-empty body); everything else is optional. status defaults to todo. Tags may be sent with or without #. → { idea }.

GET /ideas/{id} · PATCH /ideas/{id} · DELETE /ideas/{id}#

Read, edit (any field; unsent fields and unsent properties keep their value), and delete. A deleted idea leaves the board and reads as 404 afterwards.

Comments#

Comments on the account's Instagram, Threads and Facebook posts, including posts published outside Ampli. See availability.

GET /comments#

?days=7|30|90 (default 30), ?accountIds=1,2, ?needsReply=1, and ?limit/?offset over the posts, newest comment first (the paging fields ride alongside totals; total is the number of posts matching, while totals always covers every comment in the period). If post captions, thumbnails or links cannot be read, the comments still return with those fields empty and warnings says so.

{ "success": true, "syncedAt": "…", "warnings": [], "totals": { "comments": 41, "needsReply": 6, "hidden": 1 },
  "accounts": [{ "id": "123", "platform": "instagram", "username": "…", "error": null }],
  "posts": [
    { "scheduledPostId": "uuid or null", "platform": "instagram", "accountId": "123", "username": "…", "platformPostId": "…", "postUrl": "…", "caption": "…", "thumbnailUrl": "…", "publishedAt": "…", "needsReply": 2,
      "comments": [
        { "id": "987", "platformCommentId": "…", "parentPlatformCommentId": null, "authorName": "Sam", "text": "Where did you get this?", "postedAt": "…", "likeCount": 3, "hidden": false, "isOwner": false, "needsReply": true }
      ] }
  ] }

needsReply on a comment means a top-level comment with no reply from the account yet. Comments are synced on a schedule; syncedAt says how fresh they are, and an account's error explains why it is not syncing (for example a missing permission).

POST /comments/sync#

Refresh comments from the platforms now instead of waiting for the schedule. Optional { "accountId": "123" } or { "accountIds": ["123", "456"] }; the default is every account in the key's project. Each account refreshes at most every two minutes and respects platform permission and rate-limit pauses.

Omit the body or send {} to refresh all project accounts. An explicit accountIds: [] refreshes none. Provide only one account filter; malformed JSON, empty IDs, non-string IDs, and simultaneous accountId/accountIds return HTTP 400. Accounts outside the project return 404.

{ "success": false, "accounts": 1, "commentsSeen": 14, "errors": {},
  "skipped": { "456": "Recently checked; wait two minutes before refreshing again" },
  "error": "Recently checked; wait two minutes before refreshing again", "cached": false }

accounts counts attempted accounts, including failed or partially updated accounts. success is true only when at least one account refreshed and no requested account reported an issue; skipped and failed accounts are listed by id with the reason, still with HTTP 200. Then read GET /comments again. POST /messages/sync and POST /analytics/sync work the same way. The MCP equivalent is refresh_data. When you name accounts, any that are disconnected, on a platform without this feature, or connected from another deployment are reported as skipped. The default all-accounts refresh leaves those out quietly, so it succeeds when every eligible account refreshed. Storage/control failures return HTTP 503. MCP returns success, attempted, refreshed (attempts without reported errors), errors and skipped.

Both list_comments and list_messages accept limit (1–100, default 25) and offset and return hasMore/nextOffset. Keep the same filters when requesting another page. DM reads fill only the requested page, even if the database caps each response below limit. Comments read grouping metadata for the period, then text and post details for the selected page. totals still counts the whole inbox. DM history contains up to the latest 50 stored messages per conversation. Platform import windows still apply, so stored history is not necessarily complete platform history.

POST /comments/{id}/reply#

{ "text": "…" } (up to 2,200 characters). Posts the reply on the platform as the account that owns the post, immediately. → { replyId }. Confirm the wording with the user before calling this from an agent.

POST /comments/{id}/hide#

{ "hidden": true } (default) hides the comment on the platform; it stays visible to its author only. { "hidden": false } unhides.

Messages#

Instagram and Facebook direct messages. See availability.

GET /messages#

?accountIds=1,2, ?needsReply=1, and ?limit/?offset over the conversations.

{ "success": true, "syncedAt": "…", "totals": { "conversations": 12, "needsReply": 3 },
  "accounts": [ … ],
  "conversations": [
    { "id": "555", "platform": "instagram", "accountId": "123", "accountUsername": "…", "platformConversationId": "…",
      "participant": { "id": "…", "name": "Sam", "username": "sam_k", "pictureUrl": "…" },
      "lastMessageAt": "…", "lastMessageText": "…", "needsReply": true, "lastInboundAt": "…",
      "messages": [{ "id": "…", "fromName": "Sam", "text": "Hi! Do you ship to Canada?", "attachments": [], "sentAt": "…", "isOwner": false }] }
  ] }

Each conversation carries its latest messages; older history stays on the platform.

POST /messages/sync#

Refresh direct messages now; same body, limits and response as POST /comments/sync.

POST /messages/{id}/send#

{ "text": "…" } (up to 1,000 characters). Sends as the account, immediately. Meta only allows a reply within 24 hours of the other party's last message (lastInboundAt); outside that window the platform refuses and the error says so.

Analytics#

See availability.

GET /analytics#

?days=7|30|90|180 (default 30), ?accountIds=1,2, ?compact=1. Other days values fall back to 30 days; range says which period was used and warnings explains the fallback. Stored metric, audience and daily-activity reads are paginated internally so database row caps cannot silently drop recent snapshots. If totals or posts cannot be read the request fails; if an extra section (posting heatmap, follower history, daily activity, demographics or Stories) cannot be read, it comes back empty and warnings names it, so an empty section is never mistaken for no activity. Warnings do not imply that the remaining data is fresh; use lastSyncedAt and per-account errors to assess freshness.

{ "success": true, "range": 30, "since": "…", "lastSyncedAt": "…", "warnings": [],
  "totals": { "posts": 24, "postsWithData": 21, "views": 48210, "likes": 1930, "comments": 142, "shares": 88, "saves": 310, "impressions": 0, "reach": 39000, "clicks": 0, "linkClicks": 0, "follows": 57 },
  "previousTotals": { "views": 31005, "likes": 1211, "…": "…" },
  "byPlatform": [{ "platform": "tiktok", "posts": 9, "views": 30100, "likes": 1200, "…": "…" }],
  "accounts": [ … ],
  "posts": [{ "scheduledPostId": "uuid or null", "platform": "tiktok", "accountId": "…", "username": "…", "platformPostId": "…", "postUrl": "…", "caption": "…", "thumbnailUrl": "…", "publishedAt": "…", "views": 12000, "likes": 540, "comments": 31, "shares": 22, "saves": 90 }],
  "series": [{ "date": "2026-10-01", "views": 1500, "likes": 60, "…": "…" }],
  "postsPerDay": { "2026-10-01": 2 },
  "engagement": { … }, "audienceSeries": [ … ], "accountDaily": [ … ], "demographics": [ … ] }

compact=1 drops series, postsPerDay, audienceSeries and accountDaily, which is what an agent wants. Metrics a platform does not report are null, and a metric no platform in view reports is 0 in the totals. Covers every post on the connected accounts, not only ones published through Ampli.

POST /analytics/sync#

Refresh analytics now; same body, limits and response as POST /comments/sync. Analytics otherwise refresh every six hours, with older posts checked less often.

API keys#

Session auth only; an API key cannot manage keys.

Call Does
GET /keys Lists the user's keys across all projects, including revoked keys: name, prefix, timestamps, projectId, projectName. Never the secret.
POST /keys { "name": "My agent" } Mints a key in the selected project. Name is optional (default API key, up to 80 characters). Returns token once. Up to 10 active keys this way, within the plan allowance.
DELETE /keys/{id} Revokes a key. Takes effect immediately.

MCP server#

Endpoint and transport#

https://www.useampli.com/api/mcp, MCP Streamable HTTP, stateless: every request is an independent POST with the JSON-RPC envelope and an application/json reply. There is no standalone GET stream, which is why a GET returns 405. Supported protocol versions: 2024-11-05 through 2025-11-25. The legacy SSE transport is not offered.

The server is ampli (version 0.1.0 in initialize).

How MCP differs from REST. Tools use the REST handlers for data and publishing, then add agent instructions, readable summaries and optional cards. They are not a one-to-one REST wrapper:

  • list_posts defaults to 10 items (REST: 25). get_analytics requests compact data and returns at most 50 posts, plus totals and platform/account summaries.
  • generate_posts asks the agent to choose a photo pack for topic input before dispatching when no media source was supplied. REST can select its default pack. Template and collection names are resolved by REST.
  • create_upload_url defaults to a handoff to create_upload_session. With transferMode: "direct" and sizeBytes, it creates/prepares a tracked session; without a size it uses POST /uploads.
  • get_generation adds editor links and rendered previews. It also accepts a generated-post id as a fallback. REST polling returns the stored job.

Publishing outcomes and required TikTok privacy choices are the same on both surfaces.

Credentials#

Pasted key, in any of the three forms from Authentication, or OAuth. A request without valid credentials gets a JSON-RPC error whose message tells the user where to create a key.

Tool-list changes#

At initialize the server issues an Mcp-Session-Id that encodes a fingerprint of the tool manifest. If a later request arrives on a session minted under an older manifest (a deploy changed the tools), the reply is an SSE stream carrying notifications/tools/list_changed before the JSON-RPC response, plus a refreshed session id. Clients that only accept application/json pick the new tools up when they next initialize. Start a new conversation after a tool change if your client caches tool lists.

Tools#

Tools carry read-only, destructive and open-world annotations to help clients decide when confirmation is needed. list_boards is marked as a write-capable, open-world tool because createBoard can create a Pinterest board; without that argument it only lists boards.

Tool Endpoint Kind Notes
get_me GET /me read Account, credits, products, current project
list_projects GET /projects read The user's projects; every tool takes an optional projectId
create_project POST /projects write An empty project
list_accounts GET /accounts read Ids for posting
list_boards GET/POST /accounts/{id}/boards write-capable, open-world Lists by default; createBoard creates a board by name
get_publishing_options GET /accounts/{id}/publishing-options read Limits, override fields, the TikTok privacy options the account allows
search_instagram_audio GET /accounts/{id}/instagram-audio read, open-world Music for a single-video Reel, on accounts with instagramMusic: true
list_templates GET /templates read Only when the user asks about looks
list_media_collections GET /media-collections read Only when the user asks about photos
create_media_collection POST /media-collections write
add_media_to_collection POST /media-collections/images write
generate_posts POST /generations write, open-world Spends credits
get_generation GET /generations/{id} read Renders media on first call
list_generated_posts GET /generated-posts read The library
create_upload_url POST /uploads write Terminal PUT; transferMode: "direct"
create_upload_session POST /uploads/sessions write Opens the file picker in clients with cards; call once, then wait
prepare_upload POST /uploads/sessions/{id} write Used by the upload card
get_upload_status GET /uploads/sessions/{id} read The only proof of a completed upload
upload_media POST /uploads/direct write ≤ 3 MiB
schedule_post POST /posts write, open-world Also draft: true and publishNow
update_post PATCH /posts/{id} write
list_posts GET /posts read Call once per request, never to confirm
get_post_status GET /posts/{id} read Status questions
retry_post POST /posts/{id}/retry write, open-world For failed posts
cancel_post DELETE /posts/{id} write
list_ideas GET /ideas read
create_idea POST /ideas write A note, never a post
update_idea PATCH /ideas/{id} write
delete_idea DELETE /ideas/{id} write
list_comments GET /comments read Where enabled; shows last sync and per-account problems
reply_to_comment POST /comments/{id}/reply write, open-world Confirm wording first; idempotencyKey; a failure says whether retrying is safe
hide_comment POST /comments/{id}/hide write, open-world
list_messages GET /messages read Where enabled; shows last sync and per-account problems
send_message POST /messages/{id}/send write, open-world Confirm wording first; idempotencyKey; a failure says whether retrying is safe
get_analytics GET /analytics read Where enabled; compact by default
refresh_data POST /comments/sync, /messages/sync, /analytics/sync write, open-world Refresh now; two-minute cooldown per account, skipped accounts named
get_api_usage GET /analytics/usage read Meta calls by feature and endpoint, last seven days
list_engagement_webhooks GET /webhooks/engagement read Subscriptions with receiver health, no secrets
register_engagement_webhook POST /webhooks/engagement write, open-world Returns the signing secret once
delete_engagement_webhook DELETE /webhooks/engagement write

The eleven comment, message, analytics, refresh, usage and webhook tools are listed only on deployments where those features are enabled.

Results#

Most tools return readable text in content and fields in structuredContent, such as structuredContent.job.id after generate_posts. This is a tool-specific summary, not the complete REST response. Upload preparation keeps transfer details in _meta.upload for the card. Failures are tool results with isError: true and the API's error message; the MCP client is not disconnected.

Cards#

tools/list carries _meta.ui for clients that render MCP Apps. A client that declares the io.modelcontextprotocol/ui capability at initialize gets interactive cards for post status, the queue, the library, templates, photo packs and the upload picker. Other clients get the same text and links.

How agents are told to behave#

The server's instructions (returned at initialize) set the rules an agent follows. The ones that matter to a developer watching the traffic:

  • Confirm only the input and format before generating; templates, hook style, tone and CTA are for when the user asks.
  • The photo pack is the agent's choice; it never asks the user about it.
  • A schedule_post or retry_post result is the confirmation. The agent does not call list_posts to confirm, and polls a known post only with get_post_status.
  • publishNow returns a real outcome; the agent reports that outcome, never an assumed success. publishing means the platform has not confirmed yet; sent_to_inbox is not live; partial names the destinations that failed.
  • For TikTok, the agent asks live vs. drafts first, then calls get_publishing_options and offers only the privacy levels that account allows; comments, duet, stitch, music and disclosure are the user's calls. Nothing is filled in.
  • For Pinterest, list_boards gives the user their boards by name; the agent sends the id.
  • Users are shown plain labels, never API values or ids.
  • Media: a terminal streams the raw file with a PUT; a chat client opens the picker once and waits; upload_media is a small-file fallback.

Stdio bridge#

viralfarm-mcp on npm (Node 20.3+) forwards a stdio client to the hosted server, so tools, instructions and cards are identical. Configure with the JSON in Quick start; VIRALFARM_API_KEY carries the key.

Automation platforms#

n8n has a community node (n8n-nodes-ampli, install it under Settings → Community nodes) and also works with its built-in MCP Client node against this server; the n8n section of the setup page has the exact node settings. Make and Zapier can call the REST API directly; the OpenAPI spec doubles as a ChatGPT Custom GPT Action (auth: API key → Bearer).


Changes#

  • 2026-10-19 (reference audit) Corrected project-filter exceptions, /me response fields, REST versus MCP uploads/defaults, and query-key authentication. OpenAPI now includes key management, draft/format inputs, body-only ideas, and OpenAPI 3.1 null types. Project writes reject non-object JSON bodies. MCP account lookups and card actions retain projectId; list_boards declares its optional write capability.
  • 2026-10-19 (projects) A user's work is split into projects, each with its own connected accounts, posts, ideas, generated assets and media collections. Scoped lists and creates use the resolved project; by-id operations and generation job history still check ownership across projects. GET /me returns project and projects; GET/POST /projects and PATCH /projects/{id} list, create and rename them. MCP: list_projects, create_project, and an optional projectId argument on every tool. Existing keys keep working unchanged inside the user's first project.
  • 2026-10-19 (contract review) Posts carry outcome and per-destination delivery (partial, sent_to_inbox and unconfirmed are no longer reported as plain published/failed). GET /accounts/{id}/publishing-options and GET/POST /accounts/{id}/boards make every required value discoverable. The MCP tool no longer fills in TikTok privacy or interaction defaults; both surfaces require the user's choice. Posting and generation inputs have field-level validation. Paged lists use count, hasMore, nextOffset and optional total, and ideas are no longer capped at the first 250 drafts. Idempotency-Key on POST /posts, /generations and /ideas, with the key stamped on the created row so a retry after a lost response recovers the same object. The OpenAPI spec describes the common error responses, paging parameters and idempotency header.
  • 2026-10-19 Post edits (PATCH /posts/{id}), drafts (draft: true), ideas, comments, messages and analytics endpoints, with matching MCP tools. Trials get their plan's API-key allowance (they were capped at one key). The MCP tool list is now 26 tools everywhere, 32 where the engagement features are enabled.
  • 2026-10-16 n8n community node published (n8n-nodes-ampli).
  • 2026-10-13 /uploads/sessions browser uploads up to 1 GiB, resumable.
  • 2026-09-22 MCP Apps cards (_meta.ui), tool annotations on every tool.
  • 2026-09-16 Publish-now and retry return the real outcome; retrying and publishing statuses.
  • 2026-09-15 Own-media uploads (/uploads, /uploads/direct), title on posts.
  • 2026-09-04 Tool-manifest change notifications (Mcp-Session-Id).

Engagement events and API usage#

Agents can receive new comments and messages instead of repeatedly refreshing social accounts. Register a public HTTPS receiver with POST /api/v1/webhooks/engagement:

{"url":"https://your-agent.example/events","types":["comment.received","message.received"]}

The response returns the subscription id and signing secret once. Supported events are comment.received, comment.updated, comment.deleted, and message.received. GET /api/v1/webhooks/engagement lists subscriptions without secrets; DELETE /api/v1/webhooks/engagement?id=<id> disables one and returns 404 if no subscription in the current project matches. Subscriptions and deliveries respect the current project's account access.

Event bodies contain id, type, accountId, platform, createdAt, and data. data.commentId is the local comment ID for the public reply endpoint; data.conversationId is the local conversation ID for sending a DM. Verify HMAC-SHA256(secret, timestamp + "." + rawBody) against X-Webhook-Signature: sha256=<hex>, using X-Webhook-Timestamp, and reject stale signatures. Persist and deduplicate body id before processing. Deliveries are at least once; retries retain the same event ID.

Send Idempotency-Key with comment replies and message sends. Reuse it for retries of the same intended action, e.g. event ID plus automation rule ID. Successful requests replay their result; a changed payload using a retained key returns HTTP 409. Pending/unknown send outcomes also return 409. Definitive refusals return retrySafe: true and release the key, so the same intended action can be retried after correcting the problem. A send with an unknown outcome retains its key and is not automatically retried. Without a key, existing clients remain compatible but cannot deduplicate retries. MCP reply_to_comment and send_message accept the optional idempotencyKey.

GET /api/v1/analytics/usage shows the current project's last seven days of instrumented Meta sync/engagement calls, including errors and usage headers (up to 1,000 aggregate rows). It excludes publishing, OAuth and music calls. MCP tools: get_api_usage, list_engagement_webhooks, register_engagement_webhook, and delete_engagement_webhook.

The inbox and analytics APIs read stored data. UI refreshes are feature-specific; refreshing Messages no longer refreshes analytics or comments. Existing Meta permissions, approvals and messaging windows still apply. This does not add a private-reply-to-comment endpoint.

Unsent Instagram messages are removed locally and do not trigger a new message.received notification. Pending deliveries for them are suppressed; deliveries already in flight or accepted cannot be recalled. Queue payloads expire after 90 days, while minimal duplicate/deletion receipts and send idempotency records remain to prevent replay.

Webhook subscription listings also expose consecutive_failures, first_failure_at, last_attempt_at, last_success_at, last_status, last_error, disabled_at, and next_attempt_at. A receiver has shared exponential retry pacing. HTTP 410 disables it immediately; other failures disable it after at least 10 consecutive failures spanning 24 hours. Repair the receiver and register a replacement subscription. Queued deliveries for the disabled subscription are not automatically replayed.