In short
One key, one base URL — https://miragechat.io/api/ext/v1. Upload a file, create a post for any of your Instagram, Facebook and TikTok accounts, and get its result by a signed webhook. The platforms’ rules are kept by the API, not left to you: what an account cannot do is refused with a code that says why.
Authentication
Every request carries a key in the Authorization header. The owner or an admin of the workspace creates it under Security › Developer › API Keys, chooses the Publishing API, and confirms: «Posts sent with this key are published on the selected accounts in my name.» The key is shown once; we keep only its hash. Keep it on your server — never in a browser or an app.
Send your own User-Agent header (for example LuisVentureBot/1.0). Requests with a library’s default User-Agent, such as Python-urllib, can be refused by our CDN with 403 (error 1010).
curl https://miragechat.io/api/ext/v1/capabilities \ -H "Authorization: Bearer mck_live_…"
Keys and scopes
| Scope | What it allows |
|---|---|
publish | Everything below: upload, post, cancel, change hashtags — and every read. |
publish:read | Reads only: capabilities, posts and their statuses, insights, music, hashtags. |
read · write | The messaging API. A messaging key never publishes, and a publishing key never reads conversations. |
IP addresses. A key can be limited to your servers’ addresses (single addresses or ranges such as 203.0.113.0/24). A request from anywhere else is refused with ip_not_allowed and recorded.
Accounts. A key can be limited to chosen accounts; one left out answers account_not_allowed. With no list, the key can use every publishing account of the workspace, including ones connected later.
Rotation. Rotating gives a new key at once and keeps the old one working for 24 hours, so you can switch your servers without a gap. Revoking stops a key at once.
Limits
| Who | Limit |
|---|---|
| 50–100 API posts in 24 hours per account — Meta’s own figure varies, so we read the account’s content_publishing_limit. A carousel counts as one. | |
| Reels: 30 in 24 hours per Page. Feed posts and stories: Meta’s call-rate limits. | |
| TikTok | About 15 posts a day per creator, shared with every app they use. Drafts: 5 waiting in 24 hours. |
| MirageCHAT | No daily cap of ours. 60 requests a minute per key, so a loop in your code cannot block your accounts. |
Read what is left before you post: GET /capabilities returns, per account, the posts left today and, for TikTok, the places left on each route. A post that would pass a platform’s limit is refused up front with platform_limit and resets_at when the platform says it — never sent to fail there.
Endpoints
Base URL https://miragechat.io/api/ext/v1. Ids are strings: a prefix and digits — pst_1204 a post, itm_88213 one item of it (one account and format), med_4821 a file. Account ids are the numbers GET /capabilities lists. Times are ISO 8601, UTC.
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /capabilities | publish:read | The accounts this key can post to: formats, music, what is left today, missing permissions, TikTok routes. |
POST | /media | publish | Upload a file (multipart) or give a public https URL. Returns a media_id and what each platform accepts. |
GET | /media/{id} | publish | One uploaded file and its checks. |
POST | /posts | publish | Create a post on one or more accounts — now or at schedule_at. Send an Idempotency-Key. |
GET | /posts | publish:read | Your posts, newest first. Filters: since, status, limit. |
GET | /posts/{id} | publish:read | One post and the status of each item (one item per account and format). |
DELETE | /posts/{id} | publish | Cancel what has not been published yet. |
GET | /posts/{id}/insights | publish:read | The numbers of each published item, per platform. |
GET | /music | publish:read | Sounds you can use, per platform: trending, genre, duration, commercial_ok. |
GET | /accounts/{id}/hashtags | publish:read | The account’s own hashtags (the «Own» list Instant Post puts first). |
PUT | /accounts/{id}/hashtags | publish | Replace the account’s own hashtags, and/or whether it also takes its group’s. |
GET /capabilities
For each account the key may use: id, platform, name, handle, formats[], music, limits{ remaining_today, … }, missing_permissions[], and for TikTok modes{ approve_in_app, drafts } with the places left. music says where music can go — { "reel": true } on Instagram, { "video": true } on TikTok — or is false; each of missing_permissions is { code, message }: what stops the account from publishing now, and what to do.
curl https://miragechat.io/api/ext/v1/capabilities \ -H "Authorization: Bearer mck_live_…"
POST /media · GET /media/{id}
A file as multipart/form-data (field file, up to 500 MB) or a public https URL we fetch (no private or local addresses; size and time limited; the bytes are checked). Returns { media_id, kind, width, height, duration_s, per_channel: { instagram: { ok, reasons[] }, … } } — what each platform will accept, before you post. A new file answers 201; the same file again answers 200 with its media and duplicate: true. A JSON body over 256 KB is refused with payload_too_large (413); while our media storage is nearly full, POST /media answers service_unavailable (503).
curl -X POST https://miragechat.io/api/ext/v1/media \
-H "Authorization: Bearer mck_live_…" \
-F "file=@reel.mp4"
curl -X POST https://miragechat.io/api/ext/v1/media \
-H "Authorization: Bearer mck_live_…" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/media/reel.mp4"}'
curl https://miragechat.io/api/ext/v1/media/med_4821 \
-H "Authorization: Bearer mck_live_…"POST /posts
One call posts to every account in account_ids, in one format: reel · video · photo · carousel · story. captions overrides caption per platform; link adds a clickable link to a Facebook feed post; hashtags.own adds the account’s own hashtags, extra more, and max caps them per platform; sound picks a sound per platform (see GET /music); also_story posts the same media as a story too; tiktok.mode picks the TikTok route for this post (the key’s default otherwise); schedule_at posts later.
curl -X POST https://miragechat.io/api/ext/v1/posts \
-H "Authorization: Bearer mck_live_…" \
-H "Idempotency-Key: 2026-10-01-reel-11" \
-H "Content-Type: application/json" \
-d '{
"account_ids": [12, 45, 97],
"format": "reel",
"media": ["med_4821"],
"caption": "Weekend deal",
"captions": { "facebook": "Weekend deal → example.com/go/881" },
"link": { "facebook": "https://example.com/go/881" },
"hashtags": { "own": true, "extra": ["travel"], "max": { "instagram": 5 } },
"sound": { "instagram": "ig_audio_…", "tiktok": "cml_…" },
"also_story": ["instagram", "facebook"],
"tiktok": { "mode": "approve_in_app" },
"schedule_at": "2026-10-01T15:00:00Z"
}'201 Created
{
"id": "pst_1204",
"items": [
{ "id": "itm_88213", "account_id": 12, "platform": "instagram", "format": "reel", "status": "queued", "scheduled_at": "2026-10-01T15:00:00Z" },
{ "id": "itm_88214", "account_id": 12, "platform": "instagram", "format": "story", "status": "queued", "scheduled_at": "2026-10-01T15:00:00Z" },
{ "id": "itm_88215", "account_id": 97, "platform": "tiktok", "format": "reel", "status": "awaiting_approval", "scheduled_at": "2026-10-01T15:00:00Z" }
]
}GET /posts · GET /posts/{id} · DELETE /posts/{id}
The list takes since, status and limit. One post returns its items with their statuses; a published item carries platform_id, permalink and published_at, a failed one reason: { code, message }. DELETE cancels every item not yet published.
curl "https://miragechat.io/api/ext/v1/posts?since=2026-10-01T00:00:00Z&status=failed&limit=50" \ -H "Authorization: Bearer mck_live_…" curl https://miragechat.io/api/ext/v1/posts/pst_1204 \ -H "Authorization: Bearer mck_live_…" curl -X DELETE https://miragechat.io/api/ext/v1/posts/pst_1204 \ -H "Authorization: Bearer mck_live_…"
GET /posts/{id}/insights
The numbers of each published item, read from the platform. Every metric of the platform is always named; a number the platform did not give is null — never 0. An item not yet published has all metrics null and says so in unavailable. Numbers are refreshed at most every five minutes.
| Platform | Metrics |
|---|---|
views, reach, likes, comments, shares, saved | |
views, reactions, comments, shares, clicks | |
| TikTok | views, likes, comments, shares |
curl https://miragechat.io/api/ext/v1/posts/pst_1204/insights \
-H "Authorization: Bearer mck_live_…"
{
"post_id": "pst_1204",
"read_at": "2026-10-02T09:12:00Z",
"items": [
{ "item_id": "itm_88213", "account_id": 12, "platform": "instagram", "format": "reel", "status": "published",
"permalink": "https://www.instagram.com/reel/…", "published_at": "2026-10-01T15:00:07Z",
"metrics": { "views": 494, "reach": 367, "likes": 7, "comments": 0, "shares": 1, "saved": null }, "unavailable": null }
]
}GET /music
?platform=instagram|tiktok&trending=true&genre=…&q=… → [{ id, platform, title, artist, duration_s, genre, trending, commercial_ok }]. A sound that cannot go to an account is refused when you post.
curl "https://miragechat.io/api/ext/v1/music?platform=tiktok&trending=true" \ -H "Authorization: Bearer mck_live_…"
GET · PUT /accounts/{id}/hashtags
The account’s own hashtags — at most 30; words that are not hashtags are left out, repeats removed, a missing # added. use_group says whether the account also takes its group’s hashtags. What you do not send is not changed; [] clears the list.
curl https://miragechat.io/api/ext/v1/accounts/12/hashtags \
-H "Authorization: Bearer mck_live_…"
# → { "account_id": 12, "platform": "instagram", "hashtags": ["#travel", "#summer"], "use_group": true, "max": 30 }
curl -X PUT https://miragechat.io/api/ext/v1/accounts/12/hashtags \
-H "Authorization: Bearer mck_live_…" \
-H "Content-Type: application/json" \
-d '{"hashtags": ["#travel", "#weekend"], "use_group": false}'Post statuses
| Status | Meaning |
|---|---|
queued | Accepted; waits for schedule_at (or goes out now). |
awaiting_approval | TikTok, approve_in_app: waits for the account owner in MirageCHAT. |
sent_to_drafts | TikTok, drafts: in the creator’s TikTok inbox, to finish there. |
publishing | The platform is receiving it. |
published | Live: platform_id, permalink and published_at are set. |
failed | Not published: reason.code and reason.message say why. |
cancelled | Cancelled before it was published. |
TikTok: three routes
TikTok’s rules require the account owner to know and control every post: their consent for each one, the privacy level chosen by them with no preset value, and a preview. So a post reaches TikTok only with those choices made. You choose the route per key (a default) and per post with "tiktok": { "mode": … }:
| Mode | What happens |
|---|---|
approve_in_app | The item waits as awaiting_approval under Instant Post › Waiting for you, on the web and the phone app. The owner sees the creator’s name, picks who can see it, sets comments, Duet and Stitch, declares branded content, confirms the music and taps Post or Schedule. It goes out at schedule_at. |
drafts | The video is sent to the creator’s TikTok inbox (sent_to_drafts); they finish and post it in the TikTok app. TikTok allows 5 waiting drafts in 24 hours. |
direct | Only with a key whose owner ticked TikTok direct under Security › Developer › API Keys and confirmed: «TikTok posts sent with this key are published directly on the chosen TikTok accounts, with the privacy and settings in each request, without approval in MirageCHAT.» The request carries the same choices the owner makes in MirageCHAT — checked the same way against the creator’s TikTok settings of that moment — and the item goes queued → publishing → published at schedule_at. |
Direct. Send privacy_level (one of the levels GET /capabilities lists for the account under tiktok.privacy_level_options) and music_confirmed: true — both required, never a default. disable_comment, disable_duet and disable_stitch are true when left out; one the creator turned off in TikTok cannot be turned on, and a photo post takes comments only. commercial with brand_organic_toggle (your brand) or brand_content_toggle (branded content, never with SELF_ONLY) declares commercial content. A key without TikTok direct answers tiktok_direct_not_enabled; a missing field validation_failed; what TikTok does not allow this creator unsupported_by_platform.
"tiktok": { "mode": "direct", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": false,
"disable_duet": true, "disable_stitch": true, "music_confirmed": true }Instagram and Facebook publish on their own at schedule_at; TikTok waits for a person unless the key posts directly.
What platforms do not allow
| Platform | Not possible through their API | What the API answers |
|---|---|---|
| Stickers on stories — link, poll, location, any. A clickable link in a caption. | unsupported_by_platform | |
| Music outside reels, or on an account not connected through its Facebook Page. | unsupported_by_platform | |
| Music of any kind; stickers or links on stories. | unsupported_by_platform | |
| TikTok | Stories; posting without the owner’s choices (see above). | unsupported_by_platform |
A link is clickable only in a Facebook feed post (link.facebook). On a story, put the address on the image itself.
Webhooks
Add an https endpoint under Security › Developer › Outbound Webhooks and subscribe it to the events below; its signing secret is shown once. Send test delivers a test.ping event.
| Event | When |
|---|---|
post.queued | The item is accepted and waits for its time. |
post.awaiting_approval | TikTok, approve_in_app: it waits for the account owner in MirageCHAT. |
post.sent_to_drafts | TikTok, drafts: the video is in the creator’s TikTok inbox. |
post.published | Live on the platform: platform_id, permalink, published_at. |
post.failed | Not published: reason.code and reason.message. |
post.cancelled | Cancelled with DELETE /posts/{id}, or declined by the owner. |
Each delivery is a POST with X-Mirage-Event and X-Mirage-Signature: t=…,v1=… — an HMAC-SHA256 of t + "." + raw body with your secret. Answer with any 2xx within 8 seconds. A failed delivery is tried 3 times; after 20 failures in a row the endpoint is switched off until you switch it back on. The same event can arrive twice: use id to ignore repeats. post_id is null for a post made on the web or the phone; item_id always names the item.
POST /mirage/post-events
X-Mirage-Event: post.published
X-Mirage-Signature: t=1790870400,v1=5f2c…
{
"id": "evt_9f1c…",
"type": "post.published",
"created": 1790870407,
"tenant_id": 42,
"data": {
"event": "post.published",
"post_id": "pst_1204",
"item_id": "itm_88213",
"idempotency_key": "2026-10-01-reel-11",
"account_id": 12,
"platform": "instagram",
"format": "reel",
"status": "published",
"platform_id": "17984…",
"permalink": "https://www.instagram.com/reel/…",
"published_at": "2026-10-01T15:00:07Z"
}
}Verify the signature — Node.js
const crypto = require('crypto');
// X-Mirage-Signature: t=1790870400,v1=5f2c… — HMAC-SHA256 of `${t}.${rawBody}` with your endpoint's secret
function verifyMirage(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('=')));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false; // refuse replays
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: read the RAW body — a parsed and re-serialised body no longer matches the signature.
app.post('/mirage/post-events', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyMirage(req.body.toString('utf8'), req.headers['x-mirage-signature'], process.env.MIRAGE_WHSEC)) {
return res.status(401).send('invalid signature');
}
const evt = JSON.parse(req.body); // { id, type, created, data: { event, post_id, item_id, … } }
res.sendStatus(200); // answer fast; work after
});Verify the signature — PHP
<?php
// X-Mirage-Signature: t=1790870400,v1=5f2c… — HMAC-SHA256 of "{t}.{rawBody}" with your endpoint's secret
function verify_mirage(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $p) { [$k, $v] = array_pad(explode('=', $p, 2), 2, ''); $parts[$k] = $v; }
if (empty($parts['t']) || empty($parts['v1'])) return false;
if (abs(time() - (int) $parts['t']) > $tolerance) return false; // refuse replays
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input'); // the RAW body, not a re-encoded one
if (!verify_mirage($raw, $_SERVER['HTTP_X_MIRAGE_SIGNATURE'] ?? '', getenv('MIRAGE_WHSEC'))) {
http_response_code(401); exit('invalid signature');
}
$evt = json_decode($raw, true); // ['type' => 'post.published', 'data' => [...]]
http_response_code(200);Idempotency
Send an Idempotency-Key header (any unique string up to 255 characters, such as your own post id) with POST /posts. If the request is repeated with the same key and the same body — a timeout, a retry — you get the post created the first time instead of a second post. The same key with a different body is refused with idempotency_conflict. The key is also in every webhook of that post.
Errors
Every error has the same flat body: { "error": "<code>", "message": "<what to do, in English>", … }. Some add details: problems: [{ field, message }], platform and feature, account_id, resets_at, and Retry-After on 429.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
invalid_api_key | 401 | The key is missing, wrong, revoked or expired. | Send Authorization: Bearer mck_live_… with an active key. |
insufficient_scope | 403 | The key cannot do this (a read-only key tried to publish, or a messaging key was used). | Use a key with the publish scope. |
ip_not_allowed | 403 | The request came from an address the key does not allow. | Add the address to the key, or call from an allowed one. |
account_not_allowed | 403 | The key may not touch this account. | Add the account to the key under Security › Developer. |
consent_required | 403 | Nobody of the workspace has confirmed that posts sent with this key go out in their name, or that person has left the team. | Create a new key under Security › Developer and confirm it. |
tiktok_direct_not_enabled | 403 | tiktok.mode direct was sent with a key that does not have «TikTok direct» on. | Turn on «TikTok direct» for the key under Security › Developer, or send approve_in_app or drafts. |
module_required | 402 | The workspace’s package does not include the Publishing API. | Change the package under Billing; the body names the package. |
rate_limited | 429 | More than 60 requests in a minute with this key. | Wait for Retry-After seconds, then continue. |
validation_failed | 400 | The body is not valid. problems lists each field and what is wrong. | Fix the fields in problems and send again. |
unsupported_by_platform | 422 | The platform does not allow this (for example a link sticker on a story). platform and feature say which. | Drop the feature for that platform. |
platform_limit | 429 | The account reached the platform’s own limit. platform, account_id and resets_at (when known) say which and until when. | Post after resets_at, or to another account. |
idempotency_conflict | 409 | This Idempotency-Key was already used with a different body. | Use a new key for a different post. |
not_found | 404 | No such post, file or account for this key. | Check the id; ids are a prefix and digits, like pst_1204, itm_88213, med_4821. |
payload_too_large | 413 | The request body is larger than 256 KB. | Send files as multipart to POST /media; keep JSON bodies under 256 KB. |
internal_error | 500 | Something failed on our side. | Try again in a minute; the same Idempotency-Key makes a retry safe. |
service_unavailable | 503 | Our media storage is nearly full, so POST /media takes no new media right now. Media you already uploaded is safe. | Try again later. |
{
"error": "validation_failed",
"message": "The post could not be created. Fix the fields listed in problems.",
"problems": [ { "field": "schedule_at", "message": "Use a time in the future, in ISO 8601." } ]
}OpenAPI
The same contract, machine-readable: openapi.json (OpenAPI 3.1) — import it into Postman, Insomnia or a code generator. For messaging, contacts and the rest of the API, see the developer overview.