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.
Quick start#
REST, in 60 seconds#
- In the app, open Settings → API Keys, click Create key, copy the
vf_…value. It is shown once. - 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.
GET /accounts— the ids to post to.GET /accounts/{id}/publishing-optionsfor each — limits, the fields that platform takes, the TikTok privacy options the account allows;GET /accounts/{id}/boardsfor Pinterest.POST /uploads(presigned PUT) orPOST /uploads/direct(base64) — a hostedmediaUrl.POST /postswithmediaUrls,socialAccountIds,scheduledAtorpublishNow: true,platformOverridesfrom step 2, and anIdempotency-Key.- Read the outcome:
publishNow.outcomenow, orGET /posts/{id}→status+outcome+resultslater. Retry afailedorpartialpost withPOST /posts/{id}/retry.
Generate content.
GET /templates(optional) — a look the user likes.POST /generationswithinput(a URL, topic or text),format, optionaltemplates/collection, and anIdempotency-Key→ a job id.GET /generations/{id}every ~15 s untilcompleted; take a post id fromjob.result.posts, thenGET /generated-posts/{id}for its editorviewUrl.POST /postswithgeneratedPostId(media is rendered for you), then step 5 above.
Manage content.
GET /postswithstatus/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}/retryfor a failed one.GET /ideas…DELETE /ideas/{id}for the ideas board;/comments,/messages,/analyticswhere 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-serverand/.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_postsdefaults to 10 items (REST: 25).get_analyticsrequests compact data and returns at most 50 posts, plus totals and platform/account summaries.generate_postsasks 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_urldefaults to a handoff tocreate_upload_session. WithtransferMode: "direct"andsizeBytes, it creates/prepares a tracked session; without a size it usesPOST /uploads.get_generationadds 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_postorretry_postresult is the confirmation. The agent does not calllist_poststo confirm, and polls a known post only withget_post_status. publishNowreturns a real outcome; the agent reports that outcome, never an assumed success.publishingmeans the platform has not confirmed yet;sent_to_inboxis not live;partialnames the destinations that failed.- For TikTok, the agent asks live vs. drafts first, then calls
get_publishing_optionsand 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_boardsgives 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_mediais 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,
/meresponse 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 retainprojectId;list_boardsdeclares 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 /mereturnsprojectandprojects;GET/POST /projectsandPATCH /projects/{id}list, create and rename them. MCP:list_projects,create_project, and an optionalprojectIdargument on every tool. Existing keys keep working unchanged inside the user's first project. - 2026-10-19 (contract review) Posts carry
outcomeand per-destinationdelivery(partial,sent_to_inboxandunconfirmedare no longer reported as plain published/failed).GET /accounts/{id}/publishing-optionsandGET/POST /accounts/{id}/boardsmake 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 usecount,hasMore,nextOffsetand optionaltotal, and ideas are no longer capped at the first 250 drafts.Idempotency-KeyonPOST /posts,/generationsand/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/sessionsbrowser 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;
retryingandpublishingstatuses. - 2026-09-15 Own-media uploads (
/uploads,/uploads/direct),titleon 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.
