Webhooks
Register an endpoint with POST /v1/webhook-endpoints to be notified instead of polling. Events:
post.published A Post Target was publishedpost.failed A Post Target failedaccount.disconnected A Social Account was disconnected
Each body is {"id": "evt_…", "type": "…", "created_at": "…", "data": {…}}.
Verifying deliveries
Every delivery is a `POST` with a JSON body, signed with the endpoint's secret. To verify it: take the `Simple-Socials-Timestamp` header (Unix seconds) and the raw request body, compute HMAC-SHA256 with the secret (the whole `whsec_…` string as the key) over `timestamp + "." + body`, hex-encode it, and compare it in constant time with the `v1=` value in `Simple-Socials-Signature`. Reject deliveries whose timestamp is more than 5 minutes from your clock, to stop replays. Answer with any 2xx status within 10 seconds; anything else (including redirects and timeouts) is retried with exponential backoff for about 24 hours. Delivery is at least once and unordered: deduplicate by event `id`.
// Node.js
const crypto = require("crypto");
function verify(secret, headers, rawBody) {
const ts = headers["simple-socials-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
return headers["simple-socials-signature"].split(",").some((part) => {
const got = part.trim().replace(/^v1=/, "");
return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
});
}
Endpoints
GET /v1/workspace
Get the current Workspace
Returns the Workspace that owns the API Key used to authenticate the request.
| Status | Meaning |
200 | The Workspace. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/webhook-endpoints/{id}/rotate-secret
Rotate a Webhook Endpoint's secret
Replaces the signing secret immediately and returns the new one (shown only here). Every delivery attempt from now on, including retries of earlier events, is signed with the new secret.
| Status | Meaning |
200 | The endpoint with its new `secret`. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/webhook-endpoints/{id}/deliveries
List an endpoint's deliveries
Every event sent (or being sent) to the endpoint, with each HTTP attempt.
| Status | Meaning |
200 | A page of deliveries. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/webhook-endpoints/{id}
Get a Webhook Endpoint
| Status | Meaning |
200 | The endpoint (without its secret). |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
PATCH /v1/webhook-endpoints/{id}
Update a Webhook Endpoint
Changes the fields present in the body; omitted fields stay as they are. `description: null` clears the description.
| Status | Meaning |
200 | The updated endpoint. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
DELETE /v1/webhook-endpoints/{id}
Delete a Webhook Endpoint
Removes the endpoint and its delivery history; pending retries stop.
| Status | Meaning |
204 | Deleted. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/webhook-endpoints
List Webhook Endpoints
| Status | Meaning |
200 | A page of Webhook Endpoints. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/webhook-endpoints
Register a Webhook Endpoint
Registers a URL to receive signed events. The response includes the endpoint's signing `secret`, shown only here and when rotated; store it to verify deliveries. A Workspace can have at most 10 endpoints.
| Status | Meaning |
201 | Registered; includes `secret`. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/social-accounts/{id}
Get a Social Account
| Status | Meaning |
200 | The Social Account. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
DELETE /v1/social-accounts/{id}
Disconnect a Social Account
Destroys the account's stored token and removes it from the Workspace. Connecting the same account again later restores it with the same ID.
| Status | Meaning |
204 | Disconnected. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/social-accounts
List Social Accounts
Lists the Workspace's connected Social Accounts. Paginate with `limit` and the opaque `cursor` from the previous page's `next_cursor`; `next_cursor` is null on the last page.
| Status | Meaning |
200 | A page of Social Accounts. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/posts/{id}/cancel
Cancel a Post
Cancels the Post's unpublished targets (`scheduled`, or `publishing` while queued or waiting to retry); cancelled targets never publish. Targets that already published or failed are left as they are. Send `target_ids` to cancel only some targets; omit the body (or the field) to cancel all. A target whose publish request is already in flight at the platform may still publish, and then reports `published`. Returns the Post in its new state.
| Status | Meaning |
200 | The Post. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/posts/{id}
Get a Post
Returns the Post with each target's status, platform post ID, permalink and error.
| Status | Meaning |
200 | The Post. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/posts
List Posts
Lists your Workspace's Posts, newest first (by `created_at`), with their targets. Filters combine with AND; date ranges include `*_after` and exclude `*_before`, and take RFC 3339 date-times with an offset. An invalid filter fails with `validation_failed`, `details.field` naming it.
| Status | Meaning |
200 | A page of Posts. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/posts
Create a Post
Creates a Post and fans it out into one Post Target per Social Account. The call returns at once; each target is published by a background job. Poll `GET /v1/posts/{id}` for the outcome. Set `publish_at` to schedule the Post: its targets stay `scheduled` until then; without it (or with a time in the past) they publish as soon as possible. Cancel unpublished targets with `POST /v1/posts/{id}/cancel`. Content is validated against every target's platform first, with that platform's `overrides` applied (Facebook: a caption, a link, or both; or one to 10 photos (JPEG, PNG or GIF, at most 10 MB each; several become one multi-photo post); or one video (MP4 or MOV, at most 1 GB and 20 minutes); captions up to 63,206 characters, optional with Media; photos and videos can't be mixed, and posts with Media can't have a `link` (put it in the caption). Instagram: one JPEG image, one MP4/MOV video published as a Reel, or a carousel of 2–20 images and videos; images JPEG, at most 8 MB, aspect ratio 4:5 to 1.91:1; videos 3 s to 15 min, at most 300 MB; captions up to 2,200 characters, 30 hashtags and 20 @mentions; text-only posts are rejected, and so are Posts with a `link` unless `overrides.instagram.link` removes it (`""` or `null`), naming the target). Every Media in `media_ids` must exist in your Workspace and be `ready`; Media that cleanup has deleted fails with 422 `media_deleted` (`details.field` names it), so upload it again. A problem fails the whole request with `validation_failed`, where `details.field` names the field and `details.social_account_id` the offending target. Targeting a Social Account that is `needs_reauth` fails with 422 `account_needs_reauth` (same details); reconnect it first.
Send an `Idempotency-Key` header to make retries safe: repeating the request with the same key and body (in the same Workspace) returns the original Post with `Idempotent-Replayed: true` instead of creating another. Reusing a key with a different body (including `media_ids` and `overrides`) returns 422 `idempotency_key_reused`. Keys are kept for at least 24 hours.
Facebook publishes a video that fits its Reels specs (portrait, about 9:16, at least 540×960, 3–90 seconds) as a Reel, and other videos as Page videos; set `overrides.facebook.video_type` to `reel` or `video` to choose (`auto` is the default). Facebook fetches the video from its Media URL; the target stays `publishing` while Facebook processes it (checked every 30 seconds, giving up after 2 hours). Facebook allows 30 API-published Reels per Page per rolling 24 hours. Creating a Post checks this first: when the Page already has 30 Reels published in the 24 hours before the Post's publish time, or pending (scheduled or in progress) and due to publish within 24 hours either side of it (an overdue one counts as publishing now), the request fails with 422 `publishing_limit_reached` naming the target (`details.field`, `details.social_account_id`, plus `cap`, `limit`, `window_seconds`, `count`). The check is repeated just before publishing (Reels in progress count): a Reel over the limit then fails its target with `publishing_limit_reached` (same details).
Instagram publishes in the background through Instagram's media containers: the job creates the containers, checks once a minute until Instagram has processed them (giving up after 30 minutes), then publishes. Before creating containers it checks the account's 24-hour publishing limit (`content_publishing_limit`, currently 50 posts; a carousel counts as one), counting the account's other Instagram posts still being published through Simple Socials on top of Instagram's own count; when it is used up the target fails with `publishing_limit_reached`.
| Status | Meaning |
201 | The Post (or, for a replayed Idempotency-Key, the original Post in its current state). |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | Invalid request (`validation_failed`), an Idempotency-Key reused with a different body (`idempotency_key_reused`), a target that `needs_reauth` (`account_needs_reauth`), deleted Media (`media_deleted`), or a target already at a platform publishing cap (`publishing_limit_reached`; `details.social_account_id` names it). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/media/{id}/complete
Complete a direct upload
Checks the uploaded file and marks the Media `ready`, recording its detected type, size and, where detectable, dimensions and duration. If the file is larger than the Workspace's limit, or its bytes are not the declared type, it is deleted, the Media becomes `failed` and the error is returned. Completing `ready` Media again returns it unchanged.
| Status | Meaning |
200 | The ready Media. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
409 | Nothing has been uploaded yet, or the Media is not a pending upload. Codes: `media_not_uploaded`, `media_not_pending`. |
422 | The uploaded file was rejected; the Media is now `failed`. Codes: `media_too_large`, `unsupported_media_type`. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /v1/media/{id}
Get Media
Returns the Media's status, type, size and, once `ready`, its public URL.
| Status | Meaning |
200 | The Media. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
404 | No such resource in your Workspace (code `not_found`). |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/media/uploads
Start a direct upload
Creates `pending` Media and returns a presigned URL. Upload the file with an HTTP `PUT` to `upload.url`, sending exactly the headers in `upload.headers` (the Content-Type is part of the signature), before `upload.expires_at` (1 hour). The bytes go straight to storage, not through the API. Then call `POST /v1/media/{id}/complete`. Supported types: `image/jpeg`, `image/png`, `image/gif`, `video/mp4`, `video/quicktime` (each platform accepts a subset when you post). `size` must not exceed the Workspace's max file size (250 MB by default); the actual upload is checked again on complete.
| Status | Meaning |
201 | Pending Media with upload instructions. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid. Codes: `validation_failed`, `unsupported_media_type`, `media_too_large`. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/media
Create Media from a URL
Creates `pending` Media and copies the file at `url` into storage in the background. Poll `GET /v1/media/{id}` until it is `ready` (or `failed`, with an `error`). The URL must be publicly reachable over http(s); URLs pointing at private, loopback or link-local addresses (including through DNS or redirects) are refused with `source_url_not_allowed`. The file's type is detected from its bytes, not the server's Content-Type. Failure codes: `source_fetch_failed` (HTTP error or unreachable after retries), `source_url_not_allowed`, `media_too_large`, `unsupported_media_type`, `storage_failed`.
| Status | Meaning |
202 | Pending Media; the copy runs in the background. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The URL is malformed or not allowed. Codes: `validation_failed`, `source_url_not_allowed`. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
POST /v1/connect-links
Create a Connect Link
Creates a single-use link to a hosted page where the owner of a social account signs in with the platform and chooses which accounts to attach to your Workspace. Send the returned `url` to them. The link expires after one hour and can complete once. When `redirect_url` is given, the person is sent back to it afterwards with `status=connected`, `connect_link_id` and `social_account_ids` (comma-separated), or with `status=error`, `connect_link_id` and `error` (one of `access_denied`, `missing_permissions`, `no_accounts`, `personal_account`, `nothing_selected`, `account_mismatch`, `platform_error`, `link_expired`, `link_used`, `invalid_state`, `account_limit_reached`, `workspace_disabled`). Your Workspace's Social Account limit (`limits.max_social_accounts`) is enforced when the person confirms their selection: accounts that are `active` or `needs_reauth` count, `disconnected` and deleted ones don't. Reconnecting an account that already counts is always allowed; if the new accounts would go over the limit, none are attached, the page answers 422 and the redirect carries `error=account_limit_reached` (the link stays usable, so they can choose fewer).
**Reconnecting:** pass `social_account_id` to repair an existing Social Account (for example one that is `needs_reauth` or `disconnected`) in place. `provider` may then be omitted; if given it must match the account's. The person must sign in as that same platform account: the account picker offers only it, and signing in as anyone else fails with `error=account_mismatch` without changing anything (the link stays usable). On success the account is `active` again with the same ID and a new token.
| Status | Meaning |
201 | The Connect Link. |
401 | The API Key is missing, invalid or revoked (code `unauthorized`). |
403 | The operator disabled this Workspace (code `workspace_disabled`). |
422 | The request is invalid (code `validation_failed`); `details.field` names the offending field when there is one. |
429 | The Workspace's requests-per-minute limit was reached (code `rate_limited`). Wait `Retry-After` seconds; `details.limit` is the limit and `details.retry_after_seconds` the wait. |
GET /healthz no auth
Health check
Returns 200 when the service and its database are reachable, 503 otherwise. No authentication.
| Status | Meaning |
200 | Healthy. |
503 | The database is unreachable. |