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 |
Every MCP tool calls a v1 endpoint with the same validation and the same
permissions as the app, so whatever you read here about an endpoint is true
of its tool. The machine-readable spec is GET /api/v1/openapi (OpenAPI 3.1);
its operationIds are the MCP tool names.
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 viralfarm 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": {
"viralfarm": {
"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; itspostscarry aviewUrlthe user can review and edit in.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#
Every v1 endpoint 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.
Send it one of three ways:
| How | Example | Use when |
|---|---|---|
| Bearer header (preferred) | Authorization: Bearer vf_… |
Everything |
| Custom header | x-api-key: vf_… |
Clients that reserve Authorization |
| Query string | ?key=vf_… |
MCP clients that cannot set headers at all. Avoid elsewhere; URLs 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) |
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. Every response carries success: true | false.
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 naming the offending input on a 400 ("title", "mediaUrls[2]",
"Idempotency-Key") and code where a machine-readable reason exists,
plus, sometimes, issues: string[] (validation, one line per problem) and
code (a stable machine tag). The error string is written to be shown to
a person as-is.
| Status | Meaning |
|---|---|
| 400 | Invalid input. field names the input and the message the rule. Nothing is truncated, clamped or dropped silently: a title over 100 characters, a bad URL in mediaUrls, a count out of range, a JSON body that is not an object are all 400s. Platform validation failures (caption too long, wrong media count, TikTok direct post without privacy_level) are 400 with issues. |
| 401 | No credentials, a revoked key, or a key that does not resolve. |
| 402 | Not enough credits for a generation. |
| 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 | Our fault. Retry reads; retry creates with the same Idempotency-Key (see below). |
| 502 | A platform did not answer (Pinterest boards, for example); error says which and why. |
| 503 | Idempotency-Key sent on a deployment whose idempotency storage is not set up yet (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 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 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; edits and cancels are safe to
repeat.
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.
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. Everything else is available everywhere.
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" }] } }
products are the things the user told the app they promote; the first is
what a topic generation's call to action recommends.
Connected accounts#
GET /accounts#
{ "success": true, "accounts": [
{ "id": "123", "platform": "instagram", "username": "annicorn", "display_name": "Annie", "profile_picture_url": "https://…" }
] }
The id values are what socialAccountIds takes everywhere. If the platform
a user wants is missing, they connect it at /home in the app.
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}/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 |
Required for a useful answer |
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 |
{ "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 } }.
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, newest first, paged: { jobs: [...], count, limit, offset, hasMore, nextOffset }.
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).
{ "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.
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.
With sizeBytes, the PUT lives inside an upload session so you can verify it
with GET /uploads/sessions/{id}.
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}#
For upload clients that implement resumable transfers themselves: returns
the storage's TUS endpoint and a path-scoped x-signature token in
resumable, 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
"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.
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" },
"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 (the paging fields ride alongside
totals; total is the number of posts matching).
{ "success": true, "syncedAt": "…", "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.
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/{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.
{ "success": true, "range": 30, "since": "…", "lastSyncedAt": "…",
"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.
API keys#
Session auth only; an API key cannot manage keys.
| Call | Does |
|---|---|
GET /keys |
Lists the user's keys: name, prefix, created, last used. Never the secret. |
POST /keys { "name": "My agent" } |
Mints a key. Returns token once. Up to 10 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 viralfarm (version 0.1.0 in initialize).
How MCP differs from REST. Every tool calls the same v1 endpoint, with
the same validation and the same outcomes, so the REST reference above is
the contract for both. The MCP layer adds three things on top: the server
instructions that tell an agent how to ask the user and how to report
outcomes; extra calls some tools make for presentation (schedule_post also
fetches the account list to name the destinations; generate_posts resolves
template and photo-pack names); and the optional cards. It adds no
defaults of its own.
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#
Annotations follow the MCP spec: read-only tools are safe to call freely; every tool that creates, edits, posts or sends is marked destructive and should be confirmed with the user; open-world tools reach a third-party platform.
| Tool | Endpoint | Kind | Notes |
|---|---|---|---|
get_me |
GET /me |
read | Account, credits, products |
list_accounts |
GET /accounts |
read | Ids for posting |
list_boards |
GET/POST /accounts/{id}/boards |
read (creates a board only when asked) | Pinterest board ids; createBoard makes one by name |
get_publishing_options |
GET /accounts/{id}/publishing-options |
read | Limits, override fields, the TikTok privacy options the account allows |
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 |
reply_to_comment |
POST /comments/{id}/reply |
write, open-world | Confirm wording first |
hide_comment |
POST /comments/{id}/hide |
write, open-world | |
list_messages |
GET /messages |
read | Where enabled |
send_message |
POST /messages/{id}/send |
write, open-world | Confirm wording first |
get_analytics |
GET /analytics |
read | Where enabled; compact by default |
The six comment, message and analytics tools are listed only on deployments where those features are enabled.
Results#
Every tool returns readable text in content and the same data in
structuredContent, so text-only clients lose nothing and automation
clients (n8n, for example) can read fields directly, such as
structuredContent.job.id after generate_posts. 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 (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. Invalid input is a 400 withfieldeverywhere (no silent truncation or dropped URLs; anullbody is a 400, not a crash). Every list pages the same way (count,hasMore,nextOffset,total), 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 declares every error response, the paging parameters and the 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).
