Overview
API Overview
Getting started with the Sofia public API
SDKs and integrations
Authentication
There are two ways to authenticate with the Sofia API:
API key
Create a key in Settings › API, Personal access tab, then include it in the Authorization header:
curl https://api.sofia-post.com/api/v1/establishments \
-H "Authorization: Bearer sofia_pk_live_…"A personal key acts for you on all your Workspaces, with the rights your role gives you in each. It is shown only once: keep it in a secrets vault, never in your code. `GET /v1/me` tells you who it acts for and what it may do.
OAuth2 token
If you are building an app for other Sofia users, use OAuth2 to get tokens that act on their behalf. OAuth tokens start with sofia_live_at_, last one hour and are used the same way:
curl https://api.sofia-post.com/api/v1/establishments \
-H "Authorization: Bearer sofia_live_at_…"Base URL
Every endpoint lives under a single base URL:
https://api.sofia-post.com/api/v1The machine-readable contract of every endpoint is the OpenAPI document.
Rate limits
The rate limit does not depend on your subscription plan: plans cap establishments, connected networks and campaigns instead (see Limits). All your personal keys share the counters of your account, whatever the Workspace, and so does the Sofia CLI. Each OAuth client has its own, which grow with the people who authorized it, up to 1,000. Every caller has two buckets — its writes (POST, PATCH, DELETE) and its reads (GET) — over fixed windows:
| Token | Reads · 15 minutes | Writes · 1 hour | Writes · 24 hours |
|---|---|---|---|
| Personal key (per account) | 600 | 100 | 1,000 |
| OAuth client | 600 | 100 | 500 |
| OAuth client, per authorizing person (up to 1,000) | +20 | +10 | +25 |
Every response tells you what is left of the bucket it spent, in the IETF format — here, a write:
RateLimit: "1h";r=97;t=1843, "24h";r=994;t=13543
RateLimit-Policy: "1h";q=100;w=3600, "24h";q=1000;w=86400r is the number of requests remaining, t the seconds before the window resets, q the quota and w the window in seconds.
A full window answers 429 rate_limit_exceeded, with a Retry-After header and the full window in details: wait that long before trying again. To spend fewer requests, register a webhook rather than polling a post, and read lists with limit=100.
Errors
Every error answers with an HTTP status and the same JSON envelope. code is the contract your code reads; message is written for a human and may change.
| Status | Meaning |
|---|---|
400 Bad Request | The request body or a parameter is malformed: wrong shape, unknown value, missing required field. |
401 Unauthorized | The Authorization header is missing, or the token is unknown, expired or revoked. |
403 Forbidden | The token is valid but may not do this: a missing permission (scope), your role in the Workspace, your plan or test mode. |
404 Not Found | The endpoint does not exist, or the ID matches nothing the token can see. |
409 Conflict | The state of the resource does not allow it: a post being sent, an account already connected, an Idempotency-Key still in use. |
413 Payload Too Large | The request body or the uploaded file is above the size limit. |
422 Unprocessable Entity | The request is well formed but refused: a network refuses the post (post_refused), a media is refused, an Idempotency-Key is reused with another body. |
429 Too Many Requests | A rate-limit window is full: retry after Retry-After. |
5xx | Server error: retry with exponential backoff. |
This table covers the usual statuses: every code the API returns, with its status, is listed in [Error codes](#error-codes).
{
"error": {
"code": "invalid_request",
"message": "The request is invalid",
"details": [
{ "field": "targets[0].text", "code": "required", "message": "targets[0].text is required" }
],
"docUrl": "https://www.sofia-post.com/developers/errors#invalid_request",
"requestId": "2aab5943-6333-4caf-8478-25a85ebd4abf"
}
}detailssays more: the rejected field, a network’s reason, the exhausted rate-limit window.docUrlleads to the explanation of the code on this page.requestIdidentifies the request: give it to support.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body, the query or a parameter is invalid. One detail per refused field: field, code (required, is_string, max…) and message. |
| 400 | invalid_json | The body is not valid JSON. |
| 400 | invalid_cursor | The cursor was not returned by Sofia API. |
| 400 | webhook_url_invalid | The webhook address is not https, or leads to a private address. |
| 400 | webhook_event_invalid | Unknown webhook event. |
| 400 | application_name_invalid | Settings › API: the name of the client is missing or too long. |
| 400 | application_profile_invalid | Settings › API: a field of the client profile is invalid; the detail names it. |
| 400 | logo_invalid | Settings › API: the logo is refused (at least 180×180 px, PNG, JPEG or WebP, 2 MB at most). |
| 401 | invalid_token | The token is missing, unknown, expired or revoked, or the person behind it lost the rights it carries. See the WWW-Authenticate header. |
| 401 | unauthorized | Authentication is required. |
| 402 | subscription_required | No longer raised since the Free plan: an account without a subscription writes within the Free limits, and an action beyond them answers plan_limit_reached. |
| 403 | insufficient_scope | The token lacks the scope of the endpoint; the detail names it. |
| 403 | insufficient_role | Your role in this Workspace does not allow this action. |
| 403 | plan_limit_reached | The Free plan does not allow this action: the details name the limit reached (plan-publication-quota-reached, plan-campaign-not-included). |
| 403 | live_mode_required | A real write with a test token. |
| 403 | route_closed_to_applications | This endpoint is not open to applications. |
| 403 | forbidden | Access is refused. |
| 403 | session_required | Settings › API: this action needs a Sofia session, not a token. |
| 403 | impersonation_forbidden | Settings › API: this action is refused in a borrowed session. |
| 403 | workspace_owner_required | Only the owner of the Workspace can do this. |
| 403 | workspace_member_required | You are not a member of this Workspace. |
| 404 | route_not_found | No endpoint answers this method and path. |
| 404 | establishment_not_found | Unknown establishment, or out of reach of the token. |
| 404 | media_not_found | Unknown media, or out of reach of the token. |
| 404 | post_not_found | Unknown post, or out of reach of the token. |
| 404 | webhook_not_found | Unknown webhook, or one of another application. |
| 404 | not_found | The resource does not exist. |
| 404 | application_not_found | Settings › API: unknown client. |
| 404 | grant_not_found | Unknown authorization, or already revoked. |
| 404 | personal_key_not_found | Settings › API: unknown personal key. |
| 404 | consent_not_found | The authorization request expired or does not exist: start again from the authorization endpoint. |
| 405 | method_not_allowed | This method is not allowed on this endpoint. |
| 409 | post_in_flight | A network is sending the post: retry the deletion a moment later. |
| 409 | post_not_editable | A network already sent the post: it can no longer be edited. |
| 409 | account_already_connected | This network is already connected to the establishment. |
| 409 | webhook_limit_reached | The application already has 10 webhooks. |
| 409 | idempotency_in_progress | The first request with this Idempotency-Key is still running. |
| 409 | conflict | The request conflicts with the current state of the resource. |
| 409 | scope_removal_forbidden | Settings › API: a permission of a client can be added but not removed. |
| 409 | public_client_has_no_secret | Settings › API: a public client has no secret. |
| 413 | payload_too_large | The request body or the file is above the size limit. |
| 415 | unsupported_media_type | Unsupported content type. |
| 422 | post_refused | A network, the establishment or your plan would refuse the post. One detail per reason, each with its message. |
| 422 | media_import_refused | The media could not be imported from this URL; the detail gives the reason. |
| 422 | media_upload_refused | The uploaded file is not a supported image or video. |
| 422 | platform_not_offered | This network is not offered for connection. |
| 422 | idempotency_key_reused | This Idempotency-Key was used with a different body. |
| 422 | unprocessable_entity | The request is understood but cannot be processed. |
| 429 | rate_limit_exceeded | A rate limit window is full: wait for Retry-After. |
Reasons a post is refused
A post_refused lists each reason in details[].code:
| Code | Meaning |
|---|---|
unsupported_platform | This network is not supported. |
duplicate_platform | The same network is named twice. |
unsupported_format | This network does not offer this format. |
caption_too_long | The text is too long (max, actual). |
too_many_hashtags | Too many hashtags (max, actual). |
too_many_mentions | Too many mentions (max, actual). |
too_many_thread_posts | The thread is too long (max, actual). |
too_many_media | Too many media (max, actual). |
media_mix_refused | Photos and videos cannot be mixed on this network. |
media_required | This network needs a photo or a video. |
media_type_refused | This network does not accept this type of media. |
scheduled_in_past | scheduledAt is in the past. |
scheduled_at_invalid | A scheduled post needs a future scheduledAt. |
account_not_connected | No account connected on this network for this establishment. |
account_ambiguous | Several accounts are connected on this network: name one with accountId. |
media_not_found | The media is not in the library of this establishment. |
media_url_invalid | The media URL must be a public https address. |
platform_unavailable | This network is not open on Sofia yet. |
publication_quota_exceeded | The publication quota of your plan is reached. |
Terminology
Pagination
Lists (GET /v1/posts, GET /v1/media) are returned most recent first, one page at a time. limit sets the page size, from 1 to 100 (20 by default).
{
"data": [ … ],
"nextCursor": "WyIyMDI2LTEwLTI3VDE1OjExOjU5LjAxN1oiLCIx…"
}Pass nextCursor as cursor to read the next page. When nextCursor is absent, you have reached the end.
let cursor;
do {
const url = new URL('https://api.sofia-post.com/api/v1/posts');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await globalThis.fetch(url, { headers: { Authorization: `Bearer ${key}` } }).then((r) => r.json());
handle(page.data);
cursor = page.nextCursor;
} while (cursor);A cursor is opaque: never build or modify one. A cursor that Sofia did not return answers 400 invalid_cursor.
Idempotency
A network error can leave you unsure whether a post was created. Send an Idempotency-Key header with POST /v1/posts and POST /v1/media, and retry with the same key: Sofia answers what it answered the first time, and creates nothing twice.
curl -X POST https://api.sofia-post.com/api/v1/posts \
-H "Authorization: Bearer $SOFIA_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d @post.json- Use a new random key (a UUID) for each new post, and the same key for each retry of that post.
- The same key with a different body answers
422 idempotency_key_reused. - While the first request is still running, the same key answers
409 idempotency_in_progress: try again a moment later.
Test mode and dry run
Dry run. Add ?dry_run=true to POST /v1/posts: Sofia runs the checks of a real post (each network’s rules, the connected account, the media, the date and your plan) without saving or sending anything. The detail of the responses is on the Publish page.
Test mode. An application created in test mode (sofia_test_at_… tokens) reads your real Workspace but never writes to it: its posts are simulated and judged by the same rules, and any real write answers 403 live_mode_required. Keys and clients created in Settings › API are in live mode; use the dry run to check without publishing.
Supported platforms
Each target names its network in platform. format picks one of the network’s formats, and the default one when it is absent. GET /v1/platforms returns these rules live, with the accepted media types.
| Network | platform | Formats | Caption (max) | Media (max) |
|---|---|---|---|---|
instagram | postdefaultreelstorycarousel | 2200 | 10 | |
| TikTok | tiktok | photovideodefault | 2200 | 35 |
| X | x | postdefaultthread | 280 | 4 |
linkedin | postdefaultarticlecarouselvideo | 3000 | 20 | |
facebook | postdefaultreelstoryvideo | 63206 | 10 |
Quick examples
Schedule a post on Instagram
{
"establishmentId": "your-establishment-id",
"scheduledAt": "2026-12-14T10:00:00.000Z",
"targets": [
{
"platform": "instagram",
"text": "Tarte tatin du jour 🍎",
"media": [{ "id": "your-media-id" }]
}
]
}Post right away on LinkedIn
{
"establishmentId": "your-establishment-id",
"targets": [
{
"platform": "linkedin",
"text": "Nous recrutons un second de cuisine ! 🎉"
}
]
}Upload an image and post it on Instagram
# Step 1: upload the image
curl -X POST https://api.sofia-post.com/api/v1/media \
-H "Authorization: Bearer $SOFIA_API_KEY" \
-F "establishmentId=your-establishment-id" \
-F "file=@photo.jpg"
# Response: { "id": "your-media-id", "type": "image", "url": "https://…" }
# Step 2: create the post with the uploaded image
curl -X POST https://api.sofia-post.com/api/v1/posts \
-H "Authorization: Bearer $SOFIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"establishmentId": "your-establishment-id",
"targets": [{
"platform": "instagram",
"format": "post",
"text": "Coucher de soleil sur la terrasse 🌅",
"media": [{ "id": "your-media-id" }]
}]
}'Save a draft for several networks
{
"establishmentId": "your-establishment-id",
"status": "draft",
"targets": [
{
"platform": "facebook",
"text": "Menu de la semaine : tarte tatin et café"
},
{
"platform": "x",
"text": "Menu de la semaine ☕"
}
]
}