Overview
API Overview
Every endpoint of Sofia API v1 with its parameters, scopes and examples, and a playground to try each one with your own API key.
Base URL https://api.sofia-post.com/api · OpenAPI document
The Sofia API gives your code access to your Sofia account: the establishments of your Workspaces, their connected networks, their media library, their posts and their statistics. It is a REST API over HTTPS; requests and responses are JSON. It lets you:
- Publish: now, at a date or as a draft, on one or several networks at once.
- Check before publishing: a dry run tells you whether each network would accept the post, without writing anything.
- Manage media: upload a file or import it from a URL into an establishment’s media library.
- Track posts: read each network’s state, its permanent link or the reason for the failure. A signed webhook can also notify you.
- Read statistics: the latest figures collected by Sofia for each connected account.
- Connect networks: create a hosted link that lets the owner connect a missing network.
SDKs and integrations
- `sofia` CLI: the CLI, published on npm as
sofia-post, to drive Sofia from a terminal. - MCP server: plug Sofia into your AI agents.
- Zapier: the Sofia integration connects with your personal key. It offers the “Post Published” and “Post Failed” triggers, the “Create Post” action (publish, schedule or save a draft) and the “Find Post” search. It has not yet been submitted to Zapier’s public directory.
Authentication
Every call carries a Bearer token in the Authorization header. Sofia re-reads the rights attached to it on every call: a person removed from a Workspace, or a revoked authorization, stops the token from the next request.
There are two ways to authenticate:
| Method | For | Token |
|---|---|---|
| Personal key | Automating your own account, scripts, agents | sofia_pk_live_…, valid until it expires |
| OAuth, authorization code + PKCE | A product that acts for its users, on the Workspace of their choice | sofia_live_at_…, one hour, renewable |
| OAuth, client credentials | One of your servers that automates a Workspace you own | sofia_live_at_…, one hour |
API key
If you automate your own Workspace, a personal key is enough. In the Sofia app, open Settings › API, Personal access tab, and create a key. Choose its name, its expiry and its permissions. The key is shown only once: copy it and keep it in a secrets vault, never in your code.
Create a key in Settings › API › Personal access. It acts for you on all your Workspaces: the establishment you name determines the Workspace, and your role in it decides what the key can do.
Send the key in the Authorization header, as a Bearer token:
curl https://api.sofia-post.com/api/v1/establishments \
-H "Authorization: Bearer sofia_pk_live_…"`GET /v1/me` tells you who the key acts for, its Workspaces and its permissions.
A call without the required permission (the scope) answers 403 insufficient_scope.
OAuth2 token
For a SaaS product that acts for its users on their own Workspaces, register an OAuth client: each person authorizes your application on the Workspace of their choice. OAuth access 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_…"Are you building an app that other Sofia accounts connect to? Its authorization code flow with PKCE, its client credentials and its scopes are described in OAuth2.
Base URL
Every endpoint lives under a single base URL:
https://api.sofia-post.com/api/v1Rate limits
The Sofia API counts requests over three windows: 15 minutes, 24 hours and 30 days.
| Token | 15 minutes | 24 hours | 30 days |
|---|---|---|---|
| Personal key (per account) | 150 | 1,000 | 10,000 |
| OAuth client | 100 | 500 | 15,000 |
| OAuth client, per authorizing person (up to 1,000) | +10 | +25 | +100 |
Headers
Every response tells you what is left, in the IETF format:
RateLimit: "15m";r=144;t=43, "24h";r=994;t=13543, "30d";r=9994;t=13543
RateLimit-Policy: "15m";q=150;w=900, "24h";q=1000;w=86400, "30d";q=10000;w=2592000r is the number of requests remaining, t the seconds before the window resets, q the quota and w the window in seconds.
When a window is full
The response is 429 rate_limit_exceeded, with a Retry-After header and the exhausted window in details. Wait that delay before trying again.
Using the API efficiently
- Register a webhook rather than polling a post in a loop.
- Use
limitup to 100 to read lists in fewer pages. - Cache what rarely changes: platforms, establishments, connected accounts.
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.
{
"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 | A live write (dry run included) needs an active subscription or trial. |
| 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 | 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 file is above the upload 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. |
Handling errors
- Base your logic on
code, never onmessage. - Retry a
429afterRetry-After, and a5xxwith exponential backoff. - Never retry a
4xxunchanged: fix the request first. - Log
requestIdon every failure.
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.
OAuth
/v1/oauth/authorizeStart an authorizationOpen it in the browser of the person. An unknown client or unregistered redirect_uri gives a 400; otherwise it redirects to Sofia consent screen.GET/.well-known/oauth-authorization-server/api/v1/oauthGet server metadataReturns the OAuth 2.0 authorization server metadata of the Sofia API (RFC 8414), so a client can discover the endpoints and the supported options.POST/v1/oauth/tokenIssue an access tokenExchanges client_credentials, an authorization_code with PKCE or a single-use refresh_token for an opaque token valid 3600 seconds for one Workspace.Account
Platforms
Establishments
/v1/establishmentsList establishmentsLists the establishments of the Workspace the token acts on. Use their identifiers as establishmentId when you publish, list media or read insights.GET/v1/establishments/{id}/accountsList connected accountsLists the networks connected to an establishment, with no token or network secret, only what you need to name an account and pick it as accountId.POST/v1/establishments/{id}/connection-linksCreate a connection linkReturns a hosted link where the person authorizes a network with Sofia, so no network token reaches your app. Valid 30 minutes; live app only.GET/v1/establishments/{id}/insightsGet establishment insightsReturns the latest valid figures Sofia collected for each connected account. An account not yet collected reads awaiting, never zero.Media
/v1/mediaList mediaLists the media library of an establishment, newest first, page by page, with URLs signed for the current window. Works in test and live.POST/v1/mediaImport or upload a mediaAdds a file (multipart) or an https URL that Sofia fetches to the media library of an establishment. Needs a live application and an active subscription.GET/v1/media/{id}Get a mediaReturns one media of the library with a signed URL valid for the current window, so you can reuse its identifier in a post.DELETE/v1/media/{id}Remove a mediaRemoves a media from the library of its establishment, as the app does. The file is not destroyed and a scheduled post keeps its visual.Posts
/v1/postsList postsLists the posts of the establishments in reach of the token, newest first, filtered by establishment, status, date window and author, page by page.POST/v1/postsPublish a postLive apps publish at once, schedule with a date or save a draft; test apps only simulate. With ?dry_run=true it checks everything and writes nothing.GET/v1/posts/{id}Get a postReturns a post with the state of each network, its link once published and its error if it failed. Live reads any post of the Workspace, test a simulated one.PATCH/v1/posts/{id}Edit a postBefore any network has started sending, replaces targets, sets scheduledAt or moves the post between draft and scheduled. Omitted fields are kept.DELETE/v1/posts/{id}Delete a postDeletes a post as the app does: nothing scheduled is sent and a published post is removed from its network. Test applications have nothing real to delete.Webhooks
/v1/webhooksList webhooksLists the webhooks of the application without their signing secret, which is only returned when the webhook is registered.POST/v1/webhooksRegister a webhookRegisters an https URL that Sofia calls with a signed JSON event, retried up to five times on failure. The secret is shown once; 10 webhooks at most.DELETE/v1/webhooks/{id}Delete a webhookDeletes a webhook. It receives nothing more, not even the deliveries that were still waiting for a retry, and the call returns no body.POST/v1/webhooks/{id}/testSend a test eventDelivers a signed webhook.test event like any other, without waiting for the receiver, so you can check your signature. At most five per minute.