Overview
#How it works
The Publishing API is the programmatic counterpart of the planner in the Social Analyze panel. Everything you do there — choosing accounts, uploading media, per-platform suitability checks, scheduling, editing, cancelling — exists here and goes through the same validation. Posts created through the API show up in the panel with an “API” badge and can be edited there too.
- Create a keyPanel → Brand settings → API. Pick platforms and permissions; the secret is shown only once.
- Prepare mediaImport the video/image from a URL or upload it in chunks; you get a media id when it is ready.
- Check suitabilityFind out which accounts the content suits, or let targets:"auto" send it to the suitable ones.
- ScheduleGive a time in the brand's timezone. Get the result by webhook or poll the status.
Scope of the APIThe API currently publishes to Facebook, Instagram, YouTube, LinkedIn and TikTok. X is connected to Social Analyze for statistics only and is not publishable. A key can only reach the accounts of a single brand.
Getting started
#Quick start
The flow below schedules a video, at a time in the brand's timezone, to every allowed account the content suits. Keep the key and secret in environment variables; never put them in client-side code.
export SA_KEY="sak_…" # from the panel
export SA_SECRET="sas_…"
# 1) Verify the connection
curl https://app.socialanalyze.org/api/v1/me -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
# 2) Import the video from a URL → 202 + media id
curl -X POST https://app.socialanalyze.org/api/v1/media/import -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" \
-H "Content-Type: application/json" \
-d '{"url":"https://cdn.example.com/launch.mp4"}'
# 3) Wait until it is ready (status: "ready")
curl https://app.socialanalyze.org/api/v1/media/MEDIA_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
# 4) Schedule to the suitable accounts
curl -X POST https://app.socialanalyze.org/api/v1/publications -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-2026-10-10" \
-d '{
"text": "The new release is live! 🚀",
"media": ["MEDIA_ID"],
"targets": "auto",
"local_time": "2026-10-10T09:00",
"external_ref": "cms-post-4821"
}'
Successful response (201):
{
"data": {
"id": "7ed788c0-878c-4c8e-a46e-a83621a55521",
"status": "scheduled",
"text": "The new release is live! 🚀",
"media": [{ "id": "588ff7a4-…", "kind": "video", "mime": "video/mp4", "size": 18349211, "name": "launch.mp4", "url": "https://app.socialanalyze.org/api/social/media/94e3….mp4" }],
"scheduled_at": "2026-10-10T06:00:00.000Z",
"published_at": null,
"external_ref": "cms-post-4821",
"source": "api",
"targets": [
{ "account_id": "fb17aa48-…", "platform": "instagram", "text": null, "options": {}, "result": null },
{ "account_id": "4f030b35-…", "platform": "tiktok", "text": null, "options": {}, "result": null }
]
},
"skipped": [
{ "account_id": "9c1d…", "platform": "youtube", "reason": "YouTube: video başlığı gerekli." }
]
}
Getting started
#Authentication
Every request carries two headers: the public key identifier (sak_…) and the secret (sas_…). The secret must never appear in URLs, logs or client-side code. All requests use HTTPS.
curl https://app.socialanalyze.org/api/v1/me \
-H "X-API-Key: sak_5a2ebf67ddb00876af15202e" \
-H "X-API-Secret: sas_••••••••••••••••••••••••••••••••"
# Alternative: HTTP Basic (key:secret)
curl -u "$SA_KEY:$SA_SECRET" https://app.socialanalyze.org/api/v1/me
- You see the secret only at creation/rotation; we store only its SHA-256 hash, so rotate it if lost.
- A wrong key and a wrong secret return the same response (401 invalid_credentials); key existence is not leaked.
- Rotating invalidates the old secret immediately. For a zero-downtime switch, create a second key, move your system over, then revoke the old one.
- If you set an IP allowlist, only requests from those IPs/CIDRs are accepted (403 ip_not_allowed).
Getting started
#Keys & permissions
Keys are managed in the panel under Brand settings → API (account owner only). A brand can have up to 10 active keys, each with its own platforms, permissions and limits.
| Permission (scope) | What it allows |
|---|
accounts:read | List allowed accounts; query suitability via /compatibility. |
media:write | Upload, import, list and delete media. |
publications:read | Read publications created through the API and their results. |
publications:write | Create drafts/schedules, edit and cancel. |
publications:publish | Publish immediately (mode:"publish" and /publish). |
Permission presetsThe panel offers three presets: Scheduling (no immediate publishing — ideal for review workflows), Full access and Read-only (for reporting dashboards). A key without the publish permission can never publish anything immediately.
Platform access is per key: if a key is allowed only Instagram and TikTok, targeting any other platform returns 403 platform_not_allowed. If a new account is later connected to the brand, the key sees it automatically if its platform is allowed.
Concepts
#Conventions
| Topic | Rule |
|---|
| Base URL | https://app.socialanalyze.org/api/v1 |
| Format | Requests and responses are JSON, field names snake_case, dates ISO 8601 (UTC). |
| Success | { "data": …, "meta"?: …, "skipped"?: … } |
| Error | { "error": { "code", "message", "details"? } } |
| Rate limit | Per-key per-minute limit (default 60, 1–1200 in the panel). Every response carries X-RateLimit-Limit / -Remaining / -Reset; exceeding returns 429 + Retry-After. |
| Pagination | Lists take limit (≤100) and offset; meta.total returns the total. |
| Versioning | The path is versioned with /v1; backwards-compatible additions (new fields/endpoints) do not bump the version, so ignore unknown fields. |
| Body size | JSON bodies up to 1 MB; media goes through dedicated endpoints. |
| Unknown fields | Unknown fields in requests return 400 (to catch typos). |
Concepts
#Publication lifecycle
draft ──schedule──▶ scheduled ──(time reached)──▶ publishing ──▶ published
▲ │ ▲ │ └──▶ partial (some targets published)
└────unschedule────────┘ └─────── edit ──────────────┘ └──▶ failed (none published)
failed / partial ──retry (publish / reschedule)──▶ publishing| Status | Meaning | Via API |
|---|
draft | Draft; no time set. | Edit, schedule, publish, delete |
scheduled | Waiting for its time. | Edit, reschedule, unschedule, publish, cancel |
publishing | Being sent to targets (video uploads can take minutes). | Read-only |
published | Published on all targets (or processing on the platform). | Read-only; cannot be deleted |
partial | Some targets published, others failed. | Retry failed ones via /publish |
failed | Could not be published anywhere. | Edit and reschedule / publish, delete |
Per-target results are always in targets[].result: status (published | processing | failed), external_id, permalink and the error message. A target that is already published is never sent again on retry.
Concepts
#Targets & compatibility
The same content does not suit every platform: Instagram and TikTok need media, YouTube accepts only video and needs a title, and text limits differ. The API applies these rules for you and answers “which platforms does it suit?” in three ways:
| targets | Behaviour |
|---|
"auto" | Default. Every allowed account is tried; unsuitable ones are skipped and returned in skipped with the reason. If none suit, 422 no_eligible_targets. |
["instagram","tiktok"] | Platform name → all connected accounts on it. If unsuitable, 422 validation_failed (skipped with skip_invalid:true). |
[{"account_id":"…","text":"…","options":{…}}] | Per account: target-specific text and platform options. |
To ask in advance use POST /compatibility — you can even ask before uploading, with just the kinds (media_kinds).
Concepts
#Time & timezones
You can give the time in two ways; do not send both:
| Field | Example | Note |
|---|
scheduled_at | 2026-10-10T09:00:00+03:00 | ISO 8601 with offset (Z or ±hh:mm) required. |
local_time | 2026-10-10T09:00 | Local time in the brand's timezone (GET /me → brand.timezone). We handle DST transitions for you. |
- The time must be at least 1 minute and at most 1 year ahead.
- The scheduler runs every minute; a post goes out in its scheduled minute.
- scheduled_at: null (in PATCH) removes the schedule and returns the post to draft.
Concepts
#Idempotency & external_ref
To safely retry after a network error, add a unique Idempotency-Key header to POST /publications. When the same key + Idempotency-Key arrives again no new post is created; the original is returned with 200 and an Idempotent-Replayed: true header.
Store your own system's identifier in external_ref (≤120 chars) and find it again with GET /publications?external_ref=…. metadata is a free-form JSON object and comes back unchanged in webhooks.
Reference
#Endpoints
Key & brand info
Returns the key's permissions, allowed platforms, limits and the brand's timezone. Ideal to verify connectivity.
{
"data": {
"key": {
"key_id": "sak_5a2e…",
"name": "CMS otomasyonu",
"scopes": ["accounts:read", "media:write", "publications:read", "publications:write"],
"platforms": ["instagram", "tiktok"],
"expires_at": null,
"rate_limit_per_min": 60,
"daily_publication_limit": 100,
"ip_restricted": false,
"webhook_configured": true,
"webhook_events": [],
"created_at": "2026-10-02T11:10:09.784Z"
},
"brand": { "id": "b193a53c-…", "name": "Fonoloji", "timezone": "Europe/Istanbul", "local_date": "2026-10-02" }
}
}
GET/accountsaccounts:readList allowed accounts
Only connected, publishable accounts on the key's allowed platforms. status: active | error | expired — an expired account cannot publish and must be reconnected in the panel.
{
"data": [
{
"id": "fb17aa48-a440-470b-af3d-873044f93d54",
"platform": "instagram",
"name": "Fonoloji",
"username": "fonoloji",
"avatar_url": "https://…",
"profile_url": "https://www.instagram.com/fonoloji/",
"status": "active",
"capabilities": {
"publish": true,
"publish_kinds": ["image", "video", "carousel"],
"requires_media": true,
"max_text_length": 2200,
"max_media": 10
}
}
]
}
POST/compatibilityaccounts:readWhich platforms does the content suit?
Without creating anything, returns each allowed account's suitability for text + media and, if unsuitable, why.
| Field | Type | Req. | Description |
|---|
text | string | ○ | Post text (≤70,000). |
media | uuid[] | ○ | Ready media ids. |
media_kinds | ("image"|"video")[] | ○ | To query by kind before uploading (when media is omitted). |
options | object | ○ | Platform name → flat options, e.g. {"youtube":{"title":"…"}}. |
curl -X POST https://app.socialanalyze.org/api/v1/compatibility -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" \
-H "Content-Type: application/json" \
-d '{"text":"New video","media_kinds":["video"]}'
Videos and images are first brought into Social Analyze, then attached to a post by media id. There are two ways: (1) import from a public URL (easiest), (2) upload in 5 MB chunks. Supported: JPG, PNG, WEBP (≤30 MB), GIF (≤15 MB), MP4, MOV, WEBM (≤1 GB). File contents are verified against the declared type; mismatches are rejected.
POST/media/importmedia:writeImport from a URL
Downloads in the background (202). The source must be a public address returning Content-Length; internal and private IPs are blocked. Track progress with GET /media/{id}: uploading → ready (or failed + error).
| Field | Type | Req. | Description |
|---|
url | string (url) | ● | http(s) address to download (up to 3 redirects followed). |
name | string | ○ | File name; derived from the URL if omitted. |
curl -X POST https://app.socialanalyze.org/api/v1/media/import -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" \
-d '{"url":"https://cdn.example.com/launch.mp4"}'
POST/media/uploadsmedia:writeChunked upload
Start the upload, PUT each 5 MB chunk in order, then complete it. A chunk that arrives again (retry) is silently ignored.
| Field | Type | Req. | Description |
|---|
name | string | ● | File name. |
mime | string | ● | image/jpeg · image/png · image/webp · image/gif · video/mp4 · video/quicktime · video/webm |
size | integer | ● | Total bytes. |
# 1) Start
curl -X POST https://app.socialanalyze.org/api/v1/media/uploads -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" \
-d '{"name":"launch.mp4","mime":"video/mp4","size":18349211}'
# → { "data": { "id": "…", "chunk_size": 5242880, "chunk_count": 4, "status": "uploading" } }
# 2) Each chunk (index starts at 0)
curl -X PUT "https://app.socialanalyze.org/api/v1/media/uploads/MEDIA_ID/chunk?index=0" -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" \
-H "Content-Type: application/octet-stream" --data-binary @chunk0.bin
# 3) Complete
curl -X POST https://app.socialanalyze.org/api/v1/media/uploads/MEDIA_ID/complete -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
Storage quota is 5 GB per account and at most 20 uploads may be open at once. Abandoned uploads are cleaned up automatically.
GET/media · /media/{id} · DELETE /media/{id}media:writeMedia status, list, delete
GET /media (status=ready|uploading, limit ≤100) lists recent media of the key's brand. GET /media/{id} returns one item's status; if a URL import failed it returns status: "failed" and an error. DELETE /media/{id} removes the file unless it is used by a draft/scheduled post (otherwise 409 media_in_use).
POST/publicationspublications:writeCreate a publication (draft / schedule / publish)
If mode is omitted: schedule when a time is given, otherwise draft. mode:"publish" publishes at once and requires publications:publish; it cannot be combined with a time.
| Field | Type | Req. | Description |
|---|
text | string | ○ | Shared text (≤70,000). Required if there is no media. |
media | uuid[] | ○ | Ready media ids (max 35, order kept). |
targets | "auto" | (string|object)[] | ○ | Default "auto". See Targets & compatibility. |
platform_options | object | ○ | Platform name → flat options; default for all targets on that platform. |
scheduled_at / local_time | string | ○ | Time. See Time & timezones. |
mode | "draft"|"schedule"|"publish" | ○ | Kind of action. |
skip_invalid | boolean | ○ | true: skip unsuitable targets instead of failing (already the case for auto). |
validate_only | boolean | ○ | true: validate without creating anything (200). |
external_ref | string | ○ | Your own system's identifier (≤120). |
metadata | object | ○ | Free-form JSON; returned in webhooks. |
Example 1 — schedule a video to every suitable platform:
{
"text": "The new release is live! 🚀",
"media": ["588ff7a4-0c36-44bf-b0b9-7f942ed9f76e"],
"targets": "auto",
"local_time": "2026-10-10T09:00",
"external_ref": "cms-post-4821",
"metadata": { "campaign": "launch" }
}
Example 2 — per-platform text and options:
{
"text": "Compare funds with Fonoloji",
"media": ["588ff7a4-…"],
"targets": [
{ "platform": "youtube", "text": "Fund comparison guide — description text…", "options": { "title": "How do I compare funds?", "privacy": "public", "tags": ["fon", "yatırım"] } },
{ "platform": "instagram", "options": { "type": "reel", "first_comment": "Link in bio" } },
{ "platform": "tiktok", "options": { "title": "Compare funds", "privacy_level": "SELF_ONLY", "disable_duet": true } }
],
"scheduled_at": "2026-10-10T09:00:00+03:00"
}
Example 3 — validate only, do not create:
curl -X POST https://app.socialanalyze.org/api/v1/publications -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" \
-d '{"text":"deneme","media":["MEDIA_ID"],"targets":"auto","local_time":"2027-03-01T10:00","validate_only":true}'
# → { "data": { "valid": true, "mode": "schedule", "scheduled_at": "2027-03-01T07:00:00.000Z",
# "targets": [ { "account_id": "…", "platform": "instagram" }, … ] }, "skipped": [] }
Response: 201 (created) or on an idempotent replay / validate_only 200. Possible errors: 400 invalid request, 403 permission/plan, 422 validation (details.targets gives per-account reasons), 429 daily limit.
GET/publicationspublications:readList / get publications
Only publications created through the API (posts created by hand in the panel are not reachable via the API). GET /publications/{id} returns a single publication with per-target results.
| Field | Type | Req. | Description |
|---|
status | string | ○ | Comma separated: draft,scheduled,publishing,published,partial,failed |
platform | string | ○ | Having this platform among targets. |
external_ref | string | ○ | Exact match. |
from / to | ISO date | ○ | scheduled_at range. |
sort | "created_at"|"scheduled_at" | ○ | Default created_at (newest first); scheduled_at ascending. |
limit / offset | integer | ○ | limit defaults to 25, max 100. |
# Everything scheduled next week
curl "https://app.socialanalyze.org/api/v1/publications?status=scheduled&from=2026-10-12T00:00:00Z&to=2026-10-19T00:00:00Z&sort=scheduled_at" -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
PATCH/publications/{id}publications:writeEdit a scheduled publication
Only draft, scheduled and failed publications can be edited (otherwise 409 not_editable). Omitted fields are kept. Send only what changes:
# Change the time and text
curl -X PATCH https://app.socialanalyze.org/api/v1/publications/PUB_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" \
-d '{"text":"Updated text","local_time":"2026-10-11T18:30"}'
# Remove the schedule (back to draft)
curl -X PATCH https://app.socialanalyze.org/api/v1/publications/PUB_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" -d '{"scheduled_at":null}'
# Change targets: Instagram only
curl -X PATCH https://app.socialanalyze.org/api/v1/publications/PUB_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" -d '{"targets":["instagram"]}'
# Retry a failed post with a new time
curl -X PATCH https://app.socialanalyze.org/api/v1/publications/PUB_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET" -H "Content-Type: application/json" -d '{"mode":"schedule","local_time":"2026-10-12T09:00"}'
Fields are the same as POST. After an edit the publication fires a publication.updated webhook event.
POST/publications/{id}/publishpublications:publishPublish now / retry
Immediately publishes a draft, scheduled, failed or partial post (the schedule is cleared). Targets already published are not sent again. If it is already publishing/published, 409 already_published.
curl -X POST https://app.socialanalyze.org/api/v1/publications/PUB_ID/publish -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
DELETE/publications/{id}publications:writeCancel / delete
Deletes a draft, scheduled or failed post (a publication.cancelled event is sent). Published (published/partial) posts cannot be deleted (409 already_published) and the post on the platform is untouched; a publishing post returns 409 publishing.
curl -X DELETE https://app.socialanalyze.org/api/v1/publications/PUB_ID -H "X-API-Key: $SA_KEY" -H "X-API-Secret: $SA_SECRET"
# → { "data": { "id": "…", "deleted": true } }
Reference
#Webhooks
Instead of polling, let us tell you the result. When you set an https webhook URL on a key, a signed POST is sent whenever the status of a publication created with that key changes. The URL must be public (internal/private IPs are blocked).
| Event | When |
|---|
publication.published | Published on every target. |
publication.partial | Some published, others failed. |
publication.failed | Could not be published anywhere. |
publication.updated | Edited from the panel or the API. |
publication.cancelled | Cancelled / deleted. |
Request headers and body:
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SocialAnalyze-Webhooks/1.0
X-SocialAnalyze-Event: publication.published
X-SocialAnalyze-Delivery: 2b4f9e6e-…
X-SocialAnalyze-Timestamp: 1790939393
X-SocialAnalyze-Signature: sha256=38877139…
{
"id": "evt_2b4f9e6e…",
"type": "publication.published",
"created_at": "2026-10-10T06:00:07.214Z",
"data": { …publication object (same as GET /publications/{id})… }
}
Verifying the signature
Signature = HMAC-SHA256(webhook_secret, `${timestamp}.${raw_body}`) (hex) with a “sha256=” prefix. Use the raw body (before parsing), check the timestamp is no older than 5 minutes and compare in constant time. You can view the webhook secret by editing the key in the panel.
import crypto from "node:crypto";
// Express: app.post("/hook", express.raw({ type: "application/json" }), handler)
function verify(req, secret) {
const ts = req.header("X-SocialAnalyze-Timestamp");
const sig = req.header("X-SocialAnalyze-Signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay koruması
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.${req.body}`).digest("hex");
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Delivery & retries
- Return 2xx for success. Respond within 10 seconds; push heavy work to the background.
- A failed delivery is retried after 1 min, 5 min, 30 min, 2 h and 6 h (6 attempts in total), then marked failed. You can retry manually from the key's log in the panel.
- The same event may arrive more than once (at-least-once): dedupe using the id field.
- Order is not guaranteed; rely on data.status and updated_at in the event.
- Use “Send test” in the panel to deliver a ping event.
Reference
#Error codes
Every error has the same shape. Branch on the stable error.code, not on the HTTP status or message.
{
"error": {
"code": "validation_failed",
"message": "Instagram: Reels için tek video seçin.",
"details": { "targets": { "fb17aa48-a440-470b-af3d-873044f93d54": "Instagram: Reels için tek video seçin." } }
}
}
| HTTP | code | Meaning / fix |
|---|
| 401 | missing_credentials | X-API-Key / X-API-Secret headers missing. |
| 401 | invalid_credentials | Wrong key or secret. |
| 401 | key_revoked · key_expired | Key revoked / expired; create a new key in the panel. |
| 403 | ip_not_allowed | Request came from outside the IP allowlist. |
| 403 | feature_disabled · account_disabled | The API is not in your account's plan / account disabled. |
| 403 | insufficient_scope | The key lacks permission for this action (details.required). |
| 403 | platform_not_allowed · account_not_allowed | Platform/account is outside this key's permissions. |
| 403 | plan_limit | Your plan's monthly post quota is used up (details.max/used). |
| 400 | invalid_request · invalid_options · invalid_target | Body/field error; details.issues says which field. |
| 400 | unknown_platform · schedule_required · publish_with_schedule · ambiguous_schedule · invalid_local_time | Bad target or time parameter. |
| 404 | publication_not_found · media_not_found · not_found | Record missing or belongs to another brand/source. |
| 409 | not_editable · publishing · already_published | The status does not allow this action (see lifecycle). |
| 409 | media_in_use · chunk_in_progress | Media in use / previous chunk still writing. |
| 422 | validation_failed | Content unsuitable for a target (details.targets per-account reason). |
| 422 | no_eligible_targets · no_accounts · no_account_for_platform | No suitable/connected target (see skipped). |
| 422 | media_not_ready | Media missing or not ready; wait for status: ready. |
| 422 | source_unreachable · content_length_required · unsupported_type | Import source unreachable / no Content-Length / unsupported type. |
| 400/413/429 | media_error · unsafe_url | Media rule (type, size, quota) or unsafe URL. |
| 429 | rate_limited · daily_limit_reached | Per-minute / daily limit. Wait Retry-After. |
| 429 | api_quota_exceeded | Your plan's monthly API post/video allowance is used up (details.limit = apiPublications | apiVideos). It resets on the 1st. |
| 413 · 415 | payload_too_large · unsupported_media_type | Body too large / wrong Content-Type. |
| 500 | internal_error | Our side; retry safely with an Idempotency-Key. |
Reference
#Limits
| Item | Limit |
|---|
| Request rate | 60/min per key (default; 1–1200 in the panel) |
| Daily posts | 100 in the last 24 h per key (default; 1–5000) — within your plan quota |
| Monthly API allowance | N posts and M videos a month depending on your plan (table below); exceeding returns 429 api_quota_exceeded. GET /me shows your allowance and usage this month. |
| Monthly plan quota | The monthly scheduled/published post allowance of your workspace plan, shared with the panel (API posts count too) |
| Scheduling window | Now +1 minute … +1 year |
| Targets per post | Up to 30 |
| Media per post | Up to 35 (platform limits also apply) |
| File size | Image ≤30 MB (GIF ≤15 MB), video ≤1 GB |
| Storage | 0.5–500 GB depending on plan (workspace total), 20 open uploads at once. Media uploaded through the API is removed automatically 14 days after its post is published. |
| Keys per brand | 2–20 active keys per brand depending on plan |
| Log retention | Request logs and webhook deliveries kept 30 days |
Reference
#Plans & API allowance
The external API is metered like a separate service: it has its own monthly post and video allowance, key count and request rate. It is included from Studio up; on a lower plan it can be added as a separate service with an allowance sized to your needs.
| Plan | Posts / mo | Videos / mo | Keys / brand | Rate | Storage | Webhook |
|---|
| Spark | — | — | — | — | 0.5 GB | — |
| Pulse | — | — | — | — | 2 GB | — |
| Studio | 150 | 30 | 2 | 30/min | 10 GB | ✓ |
| Atlas | 1,000 | 250 | 5 | 120/min | 40 GB | ✓ |
| Summit | 5,000 | 1,500 | 10 | 300/min | 150 GB | ✓ |
| Zenith | 25,000 | 6,000 | 20 | 600/min | 500 GB | ✓ |
How are “posts” and “videos” counted?Every non-draft API post counts as one, no matter how many platforms it goes to. Videos are the video files in those posts. Drafts and validate_only requests do not count. We limit volume, not price: pricing is set by quote.
Reference
#Best practices
- Put an Idempotency-Key on every POST. Derive it from your content id (e.g. cms-post-4821-v3); retry with the same key on 429/5xx or timeouts.
- Honour Retry-After on 429 and back off exponentially. Spread bulk imports below the per-minute limit.
- Get results by webhook. As a safety net, reconcile occasionally with GET /publications?status=publishing,partial,failed.
- Validate first. In approval flows show users where a post will go (and why not) using validate_only:true.
- For big videos: media first, post second. Finish import/upload well before the scheduled time; only ready media can be attached.
- Surface platform errors. On partial/failed, targets[].result.error carries the platform's real error; fix and retry via PATCH + /publish.
- Grant least privilege. Use the “Scheduling” preset (no immediate publishing) for review workflows, a separate key per integration and an IP allowlist where possible.
- One key set per brand. A key reaches a single brand; in multi-brand systems keep a brand → key mapping on your side.
Reference
#Changelog
| Date | Version | Change |
|---|
| 2026-10-02 | v1.0 | First release: per-brand key + secret, platform and action permissions, accounts/compatibility, media (chunked upload + URL import), create/edit/cancel/publish, Idempotency-Key, signed webhooks, OpenAPI 3.1. |
Need help?If you get stuck during integration or miss an endpoint, write to us; sharing one row of your request log (time + path + status) is enough. Never share your API key or secret.
Contact