SocialCannon provides a REST API for publishing to 6 social platforms (Twitter, Facebook, Instagram, LinkedIn, TikTok, YouTube), analytics, engagement monitoring, A/B testing, AI-powered content repurposing, and 23 MCP tools for AI agents. OpenClaw ready.
curl -X POST https://socialcannon.app/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_SECRET" }'This text-only body suits X (paid plan), Facebook and LinkedIn. Instagram, TikTok and YouTube need mediaUrls, and a TikTok post also needs platformOptions.privacyLevel.
curl -X POST https://socialcannon.app/api/v1/posts \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"accountId": "ACCOUNT_ID",
"content": "Hello from SocialCannon!"
}'The mediaUrls field accepts any public URL — you can use an image already hosted elsewhere, or upload via the media endpoint first.
Option A: Use an external URL directly
curl -X POST https://socialcannon.app/api/v1/posts \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"accountId": "ACCOUNT_ID",
"content": "Check out this photo!",
"mediaUrls": ["https://example.com/photo.jpg"]
}'Option B: Upload via signed URL (direct-to-GCS)
This example reads the response with jq, so install it first if the host does not already have it.
# Step 1: Ask for a signed PUT URL and keep the response
set -o pipefail
UPLOAD_INIT=$(curl -sS -f -X POST https://socialcannon.app/api/v1/media/upload-init -H "Authorization: Bearer YOUR_JWT_TOKEN" -H "Content-Type: application/json" -d '{ "filename": "photo.jpg", "contentType": "image/jpeg", "size": 123456, "accountId": "ACCOUNT_ID", "postType": "feed" }') || exit 1
UPLOAD_URL=$(echo "$UPLOAD_INIT" | jq -r '.data.uploadUrl')
PUBLIC_URL=$(echo "$UPLOAD_INIT" | jq -r '.data.publicUrl')
CONTENT_TYPE=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["Content-Type"]')
GCS_ACL=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["x-goog-acl"]')
CONTENT_RANGE=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["x-goog-content-length-range"]')
GCS_IF_GENERATION_MATCH=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["x-goog-if-generation-match"]')
CLIENT_KEY=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["x-goog-meta-socialcannon-client-key"]')
UPLOAD_STATE=$(echo "$UPLOAD_INIT" | jq -r '.data.requiredHeaders["x-goog-meta-socialcannon-upload-state"]')
# Stop if the request failed or returned nothing usable, rather than PUT to
# an empty URL.
[ -n "$UPLOAD_URL" ] && [ "$UPLOAD_URL" != "null" ] || { echo "Upload-init failed." >&2; exit 1; }
[ -n "$PUBLIC_URL" ] && [ "$PUBLIC_URL" != "null" ] || { echo "Upload-init returned no public URL." >&2; exit 1; }
# Step 2: PUT the bytes directly to GCS (max size = data.limit.maxBytes)
# Send every value from requiredHeaders exactly as step 1 returned it. They are
# signed into the URL, so a substituted value fails the signature check.
# "quota-reserved" is an internal upload-state marker, not a monthly
# upload-count restriction; upload counts are unlimited.
curl -sS -f -X PUT "$UPLOAD_URL" -H "Content-Type: $CONTENT_TYPE" -H "x-goog-acl: $GCS_ACL" -H "x-goog-content-length-range: $CONTENT_RANGE" -H "x-goog-if-generation-match: $GCS_IF_GENERATION_MATCH" -H "x-goog-meta-socialcannon-client-key: $CLIENT_KEY" -H "x-goog-meta-socialcannon-upload-state: $UPLOAD_STATE" --data-binary "@photo.jpg" || exit 1
# Step 3: Finalize and start the lifecycle clock
curl -sS -f -X POST https://socialcannon.app/api/v1/media/upload-complete -H "Authorization: Bearer YOUR_JWT_TOKEN" -H "Content-Type: application/json" -d "{ \"publicUrl\": \"$PUBLIC_URL\" }" || exit 1
# Step 4: Use the publicUrl when creating the post
curl -sS -f -X POST https://socialcannon.app/api/v1/posts -H "Authorization: Bearer YOUR_JWT_TOKEN" -H "Content-Type: application/json" -d "{
\"accountId\": \"ACCOUNT_ID\",
\"content\": \"Check out this photo!\",
\"mediaUrls\": [\"$PUBLIC_URL\"]
}" || exit 1Bytes go straight to Google Cloud Storage — SocialCannon never touches them, so uploads are not buffered through our server. Each destination still has a per-file limit: pass its accountId in step 1 and the request is rejected before a signed upload URL is issued if the file cannot pass the pipeline. Upload count is unlimited on every plan. The signed x-goog-content-length-range carries that limit, so GCS also refuses an oversized PUT. Up to 4 images for Twitter, 10 for Instagram carousels.
The same media rules as step 2 apply. This example uses jq to generate a UTC time one day from now; install it first if needed.
SCHEDULED_AT=$(jq -nr 'now + 86400 | todateiso8601') || exit 1
curl -X POST https://socialcannon.app/api/v1/posts \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg scheduledAt "$SCHEDULED_AT" '{
"accountId": "ACCOUNT_ID",
"content": "Scheduled post!",
"scheduledAt": $scheduledAt
}')"SocialCannon uses OAuth2 client_credentials flow. Exchange yourclient_id andclient_secret for a JWT token that expires in 1 hour.
Include the token in all authenticated requests:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Available scopes:
Rate limiting:
Free: 30 requests/minute. Pro: 300 requests/minute. Returns 429 with Retry-After header when exceeded.
2 connected accounts
Unlimited posts and scheduling within the 30-day scheduling window
Facebook, Instagram, LinkedIn, TikTok, YouTube
Twitter/X requires a paid plan
No analytics, no replies
2-item threads, 5 tracked links
Unlimited media uploads
30 API requests/min
20 connected accounts, unlimited posts
50 Twitter/X posts per period
Full analytics with history
25-item threads, unlimited tracked links
Engagement replies, A/B testing (5×4)
Timing suggestions, 90-day calendar
300 API requests/min
Everything in Pro
50 connected accounts
250 Twitter/X posts per period
A/B testing (20 concurrent, 6 variants)
365-day calendar
Priority support
600 API requests/min
Everything in Agency
Unlimited connected accounts
750 Twitter/X posts per period
A/B testing (50 concurrent, 8 variants)
1200 API requests/min
SLA or custom contract? Contact sales
Prices are in US dollars. From 1 January 2027, if you're in the EU, VAT at your country's rate will be added at checkout and shown before you pay: Pro at $15 a month will cost $18.15 in Spain (21%), for example. Businesses with a valid EU VAT number will be able to enter it at checkout and pay no VAT (the reverse charge applies). Existing subscriptions will have VAT added from their first renewal on or after that date. Until then, you pay the listed price wherever you are, and outside the EU nothing changes.
{
"success": true,
"data": { ... }
}{
"success": false,
"error": "Description of the error",
"code": "LIMIT_EXCEEDED",
"details": { ... }
}HTTP 503 PLAN_LOOKUP_RETRYABLE with details.retryable:true means a linked plan could not be verified. For a keyed operation, keep the same idempotency key and retry later; do not create a replacement post. Repurpose also returns 503 if its initial plan lookup fails before work starts. In post mode, follow the shared recovery rule:
First follow the response code's remedy; ACCOUNT_REFRESH_RECONCILIATION_REQUIRED must be reconciled by support before any resend. Repurpose takes no Idempotency-Key, so the fix is judged against what this request already did: if an earlier attempt of the same request published or may have published some targets, send only the targets that are still missing, never the whole request. Read this response first to tell the two cases apart. As the whole response, a request-level refusal, so no target was processed in it, and this request can then be sent again, but only after confirming an earlier attempt of it published nothing; if it did or may have published some targets, send only the ones that are still missing. As one target's entry in data.results, other targets may already have published, so never resend the whole request; if that entry has providerPublished or reconciliationRequired the post may be live, so check it instead of retrying; otherwise, if the entry has a postId, a post was saved: read it first. If it is published or has a platformPostId, platformPostUrl or recovery object, keep it and do not send that target again. If it is failed with no live markers, either retry it with POST /api/v1/posts/{postId}/retry or delete it and retry only that target in a new request naming just it, never both. If it is a draft, it cannot be retried, so delete it and then send only that target again; the deletion releases its reserved quota. In any other state, or if the deletion fails, contact support. Reading needs the posts:read scope and deleting posts:delete; without them, or if the deletion fails, contact support instead of retrying the target. With no postId and neither providerPublished nor reconciliationRequired, a draft may still have been saved if its acknowledgement was lost. Before retrying that target, list that account's drafts with GET /api/v1/posts?status=draft&platform={platform}&accountId={accountId} (posts:read scope), identify any draft created by this request and delete it with DELETE /api/v1/posts/{id} (posts:delete scope). Only after that deletion succeeds, or after confirming this request saved no draft, send only that missing target again. If you cannot identify the draft, lack the scopes or cannot complete its deletion, contact support instead of retrying. If the owner chose this target's content for the account recovery check post, that content is now live, so do not send that target again. Before you publish this content again, check whether the owner already published it as the account recovery check post: if that content is now live, do not publish it again.
Every error code, where it happens, what it means and the safe next action are listed in the error reference.
Getting started — publish your first post1. Get your credentials from the dashboard Settings page (a client is auto-provisioned on signup): the client_id is always shown; the client_secret is revealed once — click "Generate API Secret" to issue a new one, which revokes the old secret 2. Get a token: POST /api/v1/auth/token with your credentials 3. Connect a social account: POST /api/v1/accounts/connect-token with the platform, then open the returned connectUrl in a browser (dashboard users click Connect and their session identifies the client; either path requires accounts:write on that client) 4. List connected accounts: GET /api/v1/accounts (use the account id from the response) 5. Publish a post: POST /api/v1/posts with accountId, content, and optional mediaUrls (media is required on Instagram, TikTok and YouTube; a text-only post works on X, Facebook and LinkedIn, and X needs a paid plan)
// Example: publish a text post to a connected account.
// This body suits X (paid plan), Facebook and LinkedIn. Instagram, TikTok and
// YouTube need mediaUrls, and TikTok also needs platformOptions.privacyLevel.
POST /api/v1/posts
Authorization: Bearer YOUR_TOKEN
{
"accountId": "your-account-id",
"content": "Hello from SocialCannon!"
}
// Response:
{
"success": true,
"data": {
"id": "post-id",
"status": "published",
"platformPostId": "...",
"platformPostUrl": "https://..."
}
}Platform capabilitiesEach platform supports different content types. Use the mediaType field in platformOptions to control how content is published.
Facebook:
• Feed post (text, images, or video)
• Story (single image or video, mediaType: "story")
• Reel (single video, mediaType: "reel")
Instagram:
• Feed post (images required, up to 10 for carousel)
• Story (single image or video, mediaType: "story")
• Reel (single video, mediaType: "reel")
Twitter/X:
• Tweet (text, optional images or video)
TikTok:
• Video post (single video required)
• Photo carousel (multiple images, up to 35)
• privacyLevel required on all posts (use get_tiktok_creator_info to get allowed values)
YouTube:
• Video upload (single video, mediaType: "feed")
• Short (single square or vertical video ≤180s, mediaType: "short"; classification is not guaranteed)
• Community publishing is unavailable through the YouTube Data API/api/v1/auth/tokenPublicExchange client credentials for a JWT access token (OAuth2 client_credentials flow).
{ "grant_type": "client_credentials", "client_id": "...", "client_secret": "sc_..." }{ "success": true, "data": { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scope": "posts:read posts:write ..." } }/api/v1/auth/identityAttest the active bearer token's own client ID and creation-time client name. Returns no secret, scopes, trust flag, or tenant metadata.
{ "success": true, "data": { "identityContractVersion": 1, "client_id": "...", "name": "My Client" } }/api/v1/auth/clientsGet the requesting client's own public info. The handler returns only the calling client, not every client on the account.
/api/v1/auth/clientsCreate a new API client. Returns the secret once.
{ "name": "My Client", "scopes": ["posts:read", "posts:write"] }/api/v1/postsList posts. Supports cursor-based pagination. status=awaiting_account lists pristine missed content saved during an account restriction or unresolved publication. Recovery leaves this content saved until the customer explicitly chooses a new schedule.
/api/v1/postsCreate a post. Omit scheduledAt to publish immediately. Media URLs must be publicly accessible. Maximum scheduling window: 30 days. An optional Idempotency-Key header (8-128 printable ASCII characters, no spaces) binds one canonical request to one durable post ID; exact replays never publish again and set Idempotency-Replayed: true. A different request under the same key returns 409 IDEMPOTENCY_CONFLICT. In-progress or provider-uncertain replays return 409 with the stable data.id and must be recovered, never compensated as a new post. A settled failed keyed create normally returns 409 with a recovery action; a settled platform-plan refusal instead returns 403 LIMIT_EXCEEDED with limit.type="platform" and details.action="upgrade_plan". Repeating its key cannot publish. For a settled platform-plan refusal with data.id, upgrade before retrying that failed post. With no existing or concurrent binding, a fresh request refused during preflight returns 403 LIMIT_EXCEEDED without data.id or details.action and creates no durable key binding; the idempotency lookup returns 404 until a binding exists. After upgrading, resend the unchanged request with the same unbound key to create and publish it. Files proven oversized before provider publication, including Facebook Reel or video Story and X GIF, image, or video uploads, return 413 MEDIA_TOO_LARGE for immediate creates; submit a new post with compliant media and a new Idempotency-Key. If a streaming source sends more bytes than its announced Content-Length, the transfer fails with a length mismatch; a Reel has not reached its publish step, while a video Story may need reconciliation after Meta starts its session. Do not automatically resend a Story whose provider outcome is uncertain. Set platformOptions.aiGenerated: true to request the platform's native AI-content label. TikTok and YouTube support it; Instagram requests fail validation unless the deployment has explicitly enabled verified native-disclosure access — see the platformOptions reference.
{
"accountId": "abc123",
"content": "Hello world!",
"mediaUrls": ["https://example.com/video.mp4"],
"scheduledAt": "FUTURE_ISO_8601_WITHIN_30_DAYS",
"platformOptions": {
"mediaType": "reel",
"aiGenerated": true
}
}// Success:
{ "success": true, "data": { "id": "...", "status": "published", "platformPostId": "...", "platformPostUrl": "..." } }
// Provider acknowledged, local persistence failed (HTTP 503):
{ "success": false, "error": "...", "data": { "id": "...", "platformPostId": "...", "platformPostUrl": "...", "providerPublished": true, "reconciliationRequired": true } }
// Exact keyed replay still in progress (HTTP 409; never delete/redispatch this ID):
{ "success": false, "error": "...", "code": "IDEMPOTENCY_IN_PROGRESS", "data": { "id": "...", "status": "draft" } }
// Facebook video rejected by the local size limit (HTTP 413).
// sizeSource="reported" means the source's Content-Length; correct that header if inaccurate.
{ "success": false, "code": "MEDIA_TOO_LARGE", "details": { "retryable": false, "action": "use_smaller_media", "maxBytes": 104857600, "sizeSource": "reported", "reportedBytes": 121797838 }, "data": { "id": "..." } }
// Settled failed keyed create (HTTP 409; same key will not publish again):
{ "success": false, "code": "POST_SETTLED_FAILED_RETRY_REQUIRED", "details": { "retryable": false, "postId": "...", "action": "retry_failed_post", "retryEndpoint": "/api/v1/posts/{id}/retry" }, "data": { "id": "...", "status": "failed" } }
// The action can instead be "wait_for_publishing_limit" for Instagram's rolling
// 24-hour quota, "review_account_restriction" for an account-level restriction,
// or "repair_permissions" for a publishing-permission denial.
// Follow that remedy before calling the same failed-post retry endpoint.
// Fresh preflight platform refusal (HTTP 403; no post or durable key binding):
{
"success": false, "code": "LIMIT_EXCEEDED",
"error": "Twitter/X requires a paid plan. Upgrade to the Pro plan or above.",
"limit": { "type": "platform", "current": 0, "max": 0, "tier": "free" }
}
// Upgrade, then resend the same unbound key. Lookup is 404 before a binding exists.
// Same-key replay of a settled platform-plan refusal (HTTP 403; will not publish again):
{ "success": false, "code": "LIMIT_EXCEEDED", "limit": { "type": "platform", "current": 0, "max": 0, "tier": "free" }, "details": { "retryable": false, "postId": "...", "action": "upgrade_plan" }, "data": { "id": "...", "status": "failed" } }
// The first settled response carries data:{id}; a same-key replay also carries status.
// Upgrade to a plan including the platform, then retry the same failed post.
// Settled permanent media/content rejection (HTTP 422; no post was published):
{ "success": false, "code": "POST_DEFINITIVELY_REJECTED", "details": { "retryable": false, "action": "edit_media_or_destination" }, "data": { "id": "..." } }
// An unreadable publishing credential uses action="contact_support" instead.
// If the failed-row settlement could not be saved, these typed 409/422/413
// remedies and the account refresh/reconnect codes are withheld: the response
// is 503 with data.id and no code. Inspect the post's state before retrying.
// Application failure bodies use 4xx or 503 (never 502/504), so they survive
// proxies that replace gateway-error pages./api/v1/posts/idempotencyRecover the public state of a keyed POST /api/v1/posts operation. Send the original Idempotency-Key in the request header, not the URL. A 404 means no durable binding exists at that read; another same-key request can still be in preflight. outcome is the settlement state at the time of this read: published (provider post identity recorded), scheduled (also a post held as awaiting_account until its owner reschedules it; either can still publish later), not_published (settled failure with no provider marker or identity, or with an exact operator-confirmed absence attestation for its retained attempt; safe to treat as nothing published), or unresolved (not settled: in flight, awaiting reconciliation, a thread that has published, or a post returned to draft; do not settle it yourself). scheduled changes when the scheduler publishes, and a not_published post can later be retried by its owner, so re-read instead of caching an old answer. If a linked plan cannot be verified, keyed write/setup requests return 503 PLAN_LOOKUP_RETRYABLE with details.retryable:true; retain the same key and retry later without creating a replacement post. Repurpose can return a request-level 503 before work starts; post mode retains HTTP200 for per-target results; retry only failed targets as described on that endpoint. This read endpoint returns state, not the original create HTTP status; a same-key POST replay of a settled platform-plan refusal returns 403 LIMIT_EXCEEDED with details.action="upgrade_plan".
// Found:
{ "success": true, "data": { "id": "...", "status": "failed", "outcome": "unresolved", "platformPostId": "...", "platformPostUrl": "...", "recovery": { "required": true, "code": "PROVIDER_RECONCILIATION_REQUIRED", "kind": "publish_outcome" } } }
// outcome: "published" | "scheduled" | "not_published" | "unresolved"
// Not found (HTTP 404):
{ "success": false, "error": "No post was found for this Idempotency-Key", "code": "IDEMPOTENCY_NOT_FOUND" }/api/v1/posts/:idGet a single post by ID.
/api/v1/posts/:id/retryRetry the same failed post after checking its state and resolving the indicated account or publishing-limit problem. Media cannot be replaced on an existing post: if its stored file is gone (MEDIA_INACCESSIBLE or SCHEDULED_MEDIA_UNAVAILABLE), upload it again and create a new post. A provider-confirmed post is repaired locally without another publication; an uncertain Story remains fenced for reconciliation. A durably settled transient no-post failure returns 409 POST_SETTLED_FAILED_RETRY_REQUIRED; a permanent media/content rejection returns 422 POST_DEFINITIVELY_REJECTED; known oversized media returns 413 MEDIA_TOO_LARGE. If local failure settlement is unconfirmed, inspect this post before retrying.
// Published or locally repaired:
{ "success": true, "data": { "postId": "...", "status": "published", "platformPostId": "..." } }
// No post was published; retry this failed row after the stated remedy (HTTP 409):
{ "success": false, "code": "POST_SETTLED_FAILED_RETRY_REQUIRED", "details": { "action": "wait_for_publishing_limit", "postId": "...", "retryEndpoint": "/api/v1/posts/{id}/retry" } }
// The action may instead be "retry_failed_post", "review_account_restriction", or "repair_permissions".
// Permanent rejection (HTTP 422): action="edit_media_or_destination" or "contact_support".
// Oversized media (HTTP 413): code="MEDIA_TOO_LARGE", action="use_smaller_media"./api/v1/posts/:idUpdate a draft or scheduled post, or reschedule an awaiting_account post after its account restriction and earlier uncertain operations are resolved. Held content requires an explicit future scheduledAt at least five minutes ahead and five minutes from another recovered post; the normal 30-day window and media availability checks still apply. Send scheduledAt: null to return unattempted held or scheduled content to draft and release only its exact unused quota reservation. Active create ownership and provider-attempt guards remain; a concurrent publish claim returns 409 PUBLISH_IN_PROGRESS. Recovery never publishes missed content automatically. Free cannot assign or reschedule Twitter/X posts (403 LIMIT_EXCEEDED, limit.type="platform"); content-only edits and scheduledAt:null remain allowed.
{ "content": "Updated text", "scheduledAt": "FUTURE_ISO_8601_WITHIN_30_DAYS" }/api/v1/posts/:idDelete a post. Every delete now fails closed when the record contains provider-publish evidence: SocialCannon must verify any required provider deletion before removing the local record. For rollback/reconciliation, send { strictCompensation: true, platformPostId? }; the optional provider ID must exactly match persisted evidence. Unsupported or failed provider deletion returns a non-2xx response and leaves the local record available for operator reconciliation. A post with an uncertain publish attempt and no provider ID cannot be deleted until it is reconciled.
{ "strictCompensation": true, "platformPostId?": "exact provider post id" }/api/v1/posts/threadCreate a multi-part thread (Twitter thread or Instagram carousel). Every item is validated against the account's platform, and one invalid item fails the whole request with 400; Instagram requires media on every item. Twitter/X publishing and scheduling require a paid plan; Free receives 403 LIMIT_EXCEEDED with limit.type="platform". Publishes immediately or schedules for later. Maximum scheduling window: 30 days. Accepts platformOptions, including aiGenerated: true. Instagram rejects that request before publishing unless verified native-disclosure access is enabled for the deployment. A durably settled immediate failure with no published item returns 409 POST_SETTLED_THREAD_NO_POST: resolve the cause, then create a new thread; unreadable credentials instead direct you to contact support. Oversized media returns 413 MEDIA_TOO_LARGE with details.action "use_smaller_media" and the byte limit; create a new thread with smaller media. A partial or uncertain thread requires reconciliation before another attempt.
{
"accountId": "abc123",
"items": [
{ "content": "Thread part 1...", "mediaUrls": ["https://..."] },
{ "content": "Thread part 2...", "mediaUrls": ["https://..."] }
],
"scheduledAt": "FUTURE_ISO_8601_WITHIN_30_DAYS"
}/api/v1/posts/auto-scheduleDistribute posts across optimal time slots for the next 7 days. Pro plan only. Requires a stable Idempotency-Key header (8-128 printable ASCII characters, no spaces); without it the request is rejected with 400 IDEMPOTENCY_KEY_REQUIRED before the body is read. Retrying with the same key reuses the same manifest and slot assignments instead of scheduling a second set of posts. Needs both posts:write and analytics:read, because it reads your engagement history to rank the slots. A completed operation returns pending: [] and summary.totalPending: 0; when items are still being worked on the response is a failure with code AUTO_SCHEDULE_RETRY_REQUIRED, so retry the same key rather than starting a new batch.
POST /api/v1/posts/auto-schedule
Header: Idempotency-Key: auto-schedule-week-20260420-e5f6g7h8
{
"accountId": "abc123",
"posts": [
{ "content": "Post 1 text" },
{ "content": "Post 2 text", "mediaUrls": ["https://..."], "platformOptions": { "aiGenerated": true } }
],
"timezone": "UTC-5"
}{ "operationId": "...", "operationStatus": "completed", "scheduled": [...], "unscheduled": [...], "pending": [], "summary": { "totalRequested": 2, "totalScheduled": 2, "totalUnscheduled": 0, "totalPending": 0 } }/api/v1/accountsList all connected social media accounts.
/api/v1/accounts/:idGet a single account. Tokens are stripped from the response. An active publishing incident exposes safe accessRecovery guidance with incidentId, account identity, remediation and recoveryUrl; ordinary token health remains separate.
/api/v1/accounts/:id/recoveryRecheck an owned account connection, or consent to a new immediate verification publication. Recheck is read-only provider evidence and returns publishingVerified:false; it never clears a publishing incident or an earlier uncertain operation. Verification requires incidentId from accessRecovery, confirmNewPublication:true, an Idempotency-Key header and posts:write as well as accounts:write. It uses the normal post/media/quota pipeline and consumes two rate-limit admissions (account write, then post write), while reserving publication quota once; accountId and scheduledAt cannot be supplied inside post. An unresolved provider fence blocks verification. Exact-key replay uses the normal post idempotency contract; recover a lost response with GET /api/v1/posts/idempotency using that same header. Only matching acknowledged publication evidence closes the incident. Facebook 368/4854002 and 190/459 direct the Page owner to the Facebook mobile identity/security check, then Restore access on the same SocialCannon account before verification. An incomplete recheck returns retryable 503 ACCOUNT_CONNECTION_CHECK_UNAVAILABLE; changed recovery or tenant deletion returns a non-retryable 409.
// Read-only connection check:
{ "action": "recheck_connection" }
// New verification publication; include Idempotency-Key in the request header:
{
"action": "verify_publishing",
"incidentId": "11111111-1111-4111-8111-111111111111",
"confirmNewPublication": true,
"post": { "content": "New content I consent to publish", "mediaUrls": ["https://example.com/image.jpg"] }
}{ "success": true, "data": { "connected": true, "publishingVerified": false, "account": { "id": "..." } } }/api/v1/accounts/:idDisconnect a social account.
/api/v1/accounts/:id/tiktok/creator-infoTikTok only. Returns the account's live posting capabilities for building a compliant post UI: allowed privacy levels (`privacyLevelOptions`), whether Comment/Duet/Stitch are disabled, the creator nickname/username, and `maxVideoPostDurationSec`.
{ "success": true, "data": { "creatorNickname": "...", "creatorUsername": "...", "creatorAvatarUrl": "...", "privacyLevelOptions": ["PUBLIC_TO_EVERYONE","SELF_ONLY"], "commentDisabled": false, "duetDisabled": false, "stitchDisabled": false, "maxVideoPostDurationSec": 600 } }/api/v1/media/upload-initStep 1 — initialize a direct-to-GCS upload. Returns a 15-minute V4 signed PUT URL plus the final public URL. Pass accountId (and optionally postType, default "feed") so the file is checked against that destination's real per-file limit; without accountId the pipeline-wide ceiling of 140 MiB applies. Files that cannot be published are rejected — 400 MEDIA_LIMIT_EXCEEDED over the size cap, 400 UNSUPPORTED_MEDIA for a platform/type/format this pipeline cannot publish, 404 for an unknown or foreign account, 409 for a disconnected one. Upload count is unlimited on every plan; file limits and tenant ownership still apply. Uploading media does not grant publishing access: files may be prepared for a connected X account on Free, but publishing or scheduling them to X requires a paid plan.
{ "filename": "clip.mp4", "contentType": "video/mp4", "size": 12345678, "accountId": "ACCOUNT_ID", "postType": "feed" }{ "success": true, "data": { "uploadUrl": "https://storage.googleapis.com/...", "publicUrl": "https://storage.googleapis.com/...", "requiredHeaders": { "Content-Type": "video/mp4", "x-goog-acl": "public-read", "x-goog-content-length-range": "0,104857600", "x-goog-if-generation-match": "0", "x-goog-meta-socialcannon-client-key": "...", "x-goog-meta-socialcannon-upload-state": "quota-reserved" }, "limit": { "maxBytes": 104857600, "maxBytesDisplay": "100 MiB", "platform": "tiktok", "postType": "feed" } } }{uploadUrl}Signed URLStep 2 — upload the file bytes directly to GCS using the signed URL from step 1. This is not an open endpoint: the V4 URL carries its own write authorization, an expiry and signed headers. It must include the headers from `requiredHeaders` exactly or the signature check fails; `x-goog-content-length-range` carries the destination's limit from step 1, so GCS itself rejects a file that is too large.
Raw binary file contents200 OK (no body)/api/v1/media/upload-completeStep 3 — finalize the upload: stamps the lifecycle retention timer (customTime) and verifies the finalized file; there is no monthly upload-count limit. Verifies the object exists in your client prefix before bookkeeping. Uploaded files are kept only while a post can still publish them: they are usually deleted within two hours after the post that uses them is published, about 12 hours after a failure is settled (a failure our support confirmed was not published keeps its file until you retry or delete the post, within the 45-day limit), and about a day after their last use if no post uses them. Upload the file again for a new post rather than reusing an old URL.
{ "publicUrl": "https://storage.googleapis.com/.../media/{clientId}/{uuid}.{ext}" }{ "success": true, "data": { "url": "...", "filename": "...", "contentType": "...", "size": 102400 } }/api/v1/calendarGet a content calendar view with posts, gap analysis, and summary stats for a date range (up to 7 days on Free, 90 on Pro, 365 on Agency and 365 on Enterprise).
{ "posts": [...], "summary": { "totalPosts", "postsByPlatform", "postsByDay", "gaps": [{ "date", "dayOfWeek" }] } }/api/v1/calendar/slotsFind available time slots not occupied by scheduled or published posts (max 14-day range).
{ "slots": [{ "start", "end", "available" }], "totalSlots", "availableSlots", "occupiedSlots" }/api/v1/posts/:id/analyticsFetch live metrics and return current + history. Instagram Stories expire after 24 hours: a saved snapshot is returned with liveRefresh.available=false, or 400 INSTAGRAM_STORY_EXPIRED if none was collected.
{ "current": { "metrics": { "likes", "comments", "shares", "impressions", ... }, "fetchedAt" }, "history": [...], "liveRefresh?": { "available": false, "code": "INSTAGRAM_STORY_EXPIRED", "reason": "..." } }/api/v1/analytics/summaryGet aggregate analytics across all posts for a date range.
{ "totalPosts", "totalLikes", "totalComments", "totalShares", "totalImpressions", "avgEngagementRate" }/api/v1/analytics/refreshBulk-refresh analytics for multiple posts (max 50 per request). Expired Instagram Stories are skipped with INSTAGRAM_STORY_EXPIRED, preserving saved snapshots; skipped posts do not count as refreshed or failed.
{ "postIds?": ["..."], "platform?": "twitter", "limit?": 20 }{ "refreshed": 15, "failed": 2, "skipped": 1, "results": [{ "postId", "success", "skipped?", "code?", "error?" }] }/api/v1/linksList tracked links with UTM parameters. Supports cursor-based pagination.
/api/v1/links/generateGenerate a UTM-tagged URL. Optionally save it for tracking.
{ "url": "https://example.com", "platform?": "twitter", "campaign?": "launch", "content?": "cta-btn", "postId?": "...", "save?": true }{ "originalUrl": "https://example.com", "trackedUrl": "https://example.com?utm_source=twitter&...", "linkId?": "..." }/api/v1/posts/:id/engagementsFetch comments and replies from the platform for a published post. Stores new engagements.
{ "engagements": [{ "type", "authorName", "content", "createdAt", ... }], "nextCursor?" }/api/v1/engagementsList all stored engagements across posts. Filter by read status for an inbox view.
/api/v1/engagements/:idMark an engagement as read.
/api/v1/engagements/:id/replyReply to a comment or mention directly on the social platform.
{ "content": "Thanks for the feedback!" }{ "platformReplyId": "...", "engagementId": "...", "repliedAt": "..." }/api/v1/posts/repurposeUse AI to adapt content for multiple platforms. Two modes: "preview" returns adapted variants for review, "post" adapts and publishes in one call. Content is humanized to remove AI writing patterns. Trusted clients bypass numeric tier limits. Linked users still need platform access: Free Twitter/X preview requests return 403 LIMIT_EXCEEDED before the model call; post mode retains per-target results for mixed requests. A request-level plan lookup failure returns HTTP 503 PLAN_LOOKUP_RETRYABLE with details.retryable=true before model or publishing work; retry that request later. In post mode, a per-target PLAN_LOOKUP_RETRYABLE (including thrown plan reads) carries details.retryScope="failed_target_only"; HTTP200 may include already-published successes. This endpoint has no Idempotency-Key: retry only failed targets after checking every result.
{
"sourceContent": "Your original text...",
"targetPlatforms": ["twitter", "facebook", "tiktok"],
"mode?": "preview",
"tone?": "professional",
"accountIds?": { "twitter": "acc_1", "facebook": "acc_2" },
"mediaUrls?": { "twitter": ["https://..."] },
"appendContent?": { "twitter": "Extra text for Twitter" },
"appendToAll?": "Appended to all platforms"
}preview: { "variants": [{ "platform", "content", "validation", "characterCount" }], "allValid" }
post: { "results": [{ "platform", "success": true, "postId", "postUrl?", "platformPostId?" } | { "platform", "success": false, "postId?", "platformPostId?", "providerPublished?", "reconciliationRequired?", "error" }] }
Every created post-mode result includes its SocialCannon postId, including failed sandbox dry runs. If providerPublished is true, retain platformPostId and use strict compensation or operator reconciliation.
If the AI adaptation itself fails: 503 { "success": false, "code": "REPURPOSE_UNAVAILABLE", "error": "...", "details": { "retryable": true } }. Nothing is drafted or posted, and the reserved repurpose quota is released (best-effort). retryable is true when retrying may help (timeouts, rate limits, provider outages) and false when it cannot until SocialCannon fixes its AI provider setup; the error text says which. If the model left out a requested platform, details.missingPlatforms names it, so you can retry without that target (when another failure occurs in the same request, that failure's answer is returned instead). If the AI provider declines to adapt your source text (a moderation or content-filter block, or a refusal, which can also be triggered by text that tries to instruct the AI): 422 { "success": false, "code": "REPURPOSE_CONTENT_REJECTED", "error": "...", "details": { "retryable": false } }; edit the text before trying again. A block in the humanizing pass, which rewrites an already adapted draft, is a retryable 503. Twitter/X, Instagram, TikTok, LinkedIn and YouTube variants over the platform's length limit (appended content included) get a best-effort shortening pass when at least 40 characters remain for the post itself; if it is skipped, fails, is blocked, or leaves the text too long, that variant's validation reports it./api/v1/ab-testsCreate an A/B test. Requires an Idempotency-Key header (8-128 printable ASCII characters, no spaces); reuse the exact key after a timeout or lost response. The test row, deterministic variant post rows, usage reservations, and active-test slot are provisioned atomically before scheduler or provider visibility. If scheduledAt is omitted, the pre-provisioned variants publish immediately through provider mutation fences; if scheduledAt is provided, all variants are scheduled atomically for that time (within 30 days). A new operation returns 201; an exact committed replay returns 200 with replayed: true; the same key with a different payload returns 409 IDEMPOTENCY_CONFLICT. Immediate uncertain outcomes retain the test in provisioning with exact post IDs and return 503 for reconciliation; an operator-confirmed absent outcome is consumed once on same-key replay before untouched variants resume. Definitive failed variants are excluded from comparison. Once all variants settle, at least two published variants activate the test; otherwise it becomes failed with terminalReason insufficient_eligible_variants and transactionally releases its active slot. Scheduled tests follow the same terminal rule. Test duration starts at the latest durable eligible-variant publish, and completion waits for analytics on every eligible variant. Per-variant mediaUrls is optional only on platforms that accept text-only posts; Instagram requires media on every variant and YouTube requires a video URL on every variant (for example an MP4); one invalid variant fails the whole request with 400. Not supported for TikTok accounts (A/B variants cannot set the privacy level TikTok requires) or platforms whose post analytics are unavailable. Set aiGenerated: true to request the native label on every variant. Instagram rejects that request before publishing unless verified native-disclosure access is enabled for the deployment.
Header: Idempotency-Key: ab-test-launch-2026-04-20
{
"accountId": "abc123",
"name": "CTA test",
"variants": [
{ "content": "Check out our new feature!", "mediaUrls": ["https://example.com/variant.mp4"] },
{ "content": "You won't believe this new feature...", "mediaUrls": ["https://example.com/variant.mp4"] }
],
"metric?": "engagementRate | likes | impressions | clicks",
"minDurationHours?": 24,
"scheduledAt?": "FUTURE_ISO_8601_WITHIN_30_DAYS",
"aiGenerated?": true
}201 new / 200 replay: { "success": true, "replayed?": true, "data": { "id": "...", "status": "provisioning | active | failed", "startedAt?": "...", "terminalReason?": "insufficient_eligible_variants", "variants": [{ "id", "postId", "content", "state", "platformPostId?" }], ... } }
409: { "success": false, "code": "IDEMPOTENCY_CONFLICT | SCHEDULE_EXPIRED", "error": "..." }
503 partial/reconciliation: { "success": false, "code?": "PARTIAL_FAILURE | PROVIDER_RECONCILIATION_REQUIRED | INSUFFICIENT_ELIGIBLE_VARIANTS", "error": "...", "data": { "id", "status": "provisioning | active | failed", "variants": [{ "id", "postId", "state", "platformPostId?" }] } }/api/v1/ab-testsList A/B tests. Filter by status.
/api/v1/ab-tests/:idGet test details with live per-variant analytics and current winner.
{ "variants", "variantResults": [{ "variantId", "metrics" }], "currentWinner", "confidence" }/api/v1/ab-tests/:id/completeForce-complete an active test only after at least two eligible variants are durably published and each has analytics; atomically releases its active-test slot.
/api/v1/ab-tests/:idCancel an active or provisioning test and atomically release its active-test slot. Variant posts remain as regular posts.
/api/v1/accounts/:id/timingAnalyze posting history and engagement to recommend optimal days and hours for posting.
{ "suggestions": [{ "day", "hour", "engagementRate", "sampleSize" }], "timezone" }/api/v1/platformsPublicList all supported platforms and their capabilities. Public endpoint, no auth required.
sandbox platformThe sandbox platform is a dry-run test platform — nothing is published anywhere. Create a post with accountId: "sandbox" and a sandbox account is auto-provisioned on first use (no OAuth, no tokens). Publish returns a simulated success (fake sandbox_* post ID and URL). Sandbox posts do not consume your monthly post quota and work on every plan, so you can try the full API — create, publish, schedule, MCP tools — before connecting a real account. Sandbox usage is bounded by a lifetime allowance of 100 dry runs per account; media attachments are accepted but discarded, and auto-UTM is skipped.
platformOptions referenceThe platformOptions field in POST /api/v1/posts accepts platform-specific options. Use mediaType to control how content is published.
mediaType values:
• "reel" — Facebook or Instagram Reel (single video, MP4/MOV)
• "story" — Facebook or Instagram Story (single media, 24h ephemeral)
• "short" — YouTube Short (single square or vertical video ≤180s)
• "community" — unsupported by the YouTube Data API; publishing is refused
Media rules:
• Stories, Reels, and Shorts accept only one media attachment
• Instagram feed supports image/video carousels up to 10 items; Facebook feed uses one media attachment
• TikTok photo carousel accepts up to 35 images
• TikTok video posts accept only one video
• YouTube videos/shorts accept only one video file
• All media URLs must be publicly accessible
YouTube-specific options:
• "title" — Video title (optional, max 100 chars). If omitted, takes the first content line before its first ".", "!" or "?", trims it and limits it to 100 characters; if empty, uses the first 100 characters of the full content. Supply platformOptions.title when exact wording matters ("Version 2.0 launch" otherwise becomes "Version 2"). Shorts append #Shorts when absent and cap the result at 100 characters.
• "tags" — Comma-separated video tags
• "categoryId" — YouTube category ID (default: "10" Music)
• "privacyStatus" — "public", "unlisted", or "private"
TikTok-specific options (call GET /api/v1/accounts/:id/tiktok/creator-info first):
• "privacyLevel" — REQUIRED. One of: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. Choose from privacyLevelOptions returned by creator-info. Omitting this field returns 400 TIKTOK_PRIVACY_REQUIRED.
• "disableComment" — boolean (default: false). Set true to disable comments. Must be true if commentDisabled is true on creator-info.
• "disableDuet" — boolean (default: false). Video only. Set true to disable duets. Must be true if duetDisabled is true on creator-info.
• "disableStitch" — boolean (default: false). Video only. Set true to disable stitches. Must be true if stitchDisabled is true on creator-info.
• "commercialContent" — boolean. Set true if the post promotes a brand, product, or service (enables commercial content disclosure).
• "brandOrganic" — boolean. Set true if promoting your own brand ("Your Brand" disclosure). Requires commercialContent: true.
• "brandedContent" — boolean. Set true for paid/third-party partnerships. Requires commercialContent: true. Cannot be combined with privacyLevel: SELF_ONLY.
AI-generated content disclosure (cross-platform):
• "aiGenerated" — boolean (default: false). Set true when the post's media is AI-generated. SocialCannon maps it to the platform's native AI-content label:
- Instagram: fails validation by default because native disclosure is not yet enabled for this deployment. When verified access is explicitly enabled, sends is_ai_generated on the media container (carousel parent only)
- TikTok: is_aigc ("AI-generated" label)
- YouTube: containsSyntheticMedia ("Altered content" disclosure)
Ignored on platforms without a disclosure field (Facebook, X, LinkedIn).
Also accepted by POST /api/v1/posts/thread (platformOptions), per-post in POST /api/v1/posts/auto-schedule (posts[].platformOptions), and as a test-level "aiGenerated" flag on POST /api/v1/ab-tests./api/v1/accounts/connect-tokenMint a short-lived (10 min) connect token that authorizes one OAuth connect flow for your client. Required scope: accounts:write. Returns a ready-to-open connectUrl — send the user there in a browser.
{ "platform": "twitter", "redirectTo": "/dashboard/accounts" }{ "success": true, "data": { "connectUrl": "https://…/api/connect/twitter?connect_token=…", "expiresIn": 600, "connectContractVersion": 3 } }/api/connect/:platformSession or connect tokenInitiate OAuth connection for a social platform. Redirects to the platform's authorization page. Accepted platform ids: twitter, facebook, instagram, linkedin, tiktok, youtube, reddit. Reddit is reachable through the API but is not offered on the public pricing page or in the guides. Caller must prove tenancy: either a signed-in dashboard session (the account is linked to the session's client) or a connect_token minted via POST /api/v1/accounts/connect-token. A bare client_id is not accepted. Either way the resolved client needs the accounts:write scope, or the connect flow is refused by redirecting back with an error.
/api/connect/:platform/callbackOAuth callbackOAuth callback handler. Exchanges authorization code for tokens and stores the connected account. Do not call directly — the platform redirects here after authorization.
Scope referenceScopes control what an API client can access. Assign scopes when creating a client via POST /api/v1/auth/clients.
posts:read — List and view posts
posts:write — Create, update, schedule posts
posts:delete — Delete posts
accounts:read — List connected accounts
accounts:write — Connect/disconnect social accounts
media:upload — Upload media files
analytics:read — View analytics and timing suggestions
engagements:read — View comments and replies
engagements:write — Reply to engagements, mark as read
links:read — View tracked links
links:write — Generate UTM-tagged links
clients:manage — Create API clients, and read the requesting client's own detailsUploads go straight to Google Cloud Storage, but every file is still fetched back by the publishing pipeline. Each destination therefore has a per-file limit, and upload-init rejects a file that cannot be published before it issues a signed URL — upload count is unlimited on every plan. The signed URL carries the limit in its x-goog-content-length-range header, so a PUT over the cap is refused by GCS as well.
Accepted formats: image/gif, image/jpeg, image/png, image/webp, video/mp4, video/quicktime, video/webm. A format that is not accepted for the destination, an unknown or another customer’s account ID, or a limit overrun returns HTTP 400/404/409 with a plain-English error and a machine-readable code (for example MEDIA_LIMIT_EXCEEDED).
| Platform | Post type | File kind | Max per file | Formats |
|---|---|---|---|---|
| Feed | Images | 10 MB | image/jpeg, image/png, image/webp, image/gif | |
| Feed | Video | 100 MiB | video/mp4, video/quicktime, video/webm | |
| Reels | Video | 100 MiB | video/mp4, video/quicktime | |
| Stories | Images | 10 MB | image/jpeg, image/png, image/webp, image/gif | |
| Stories | Video | 100 MiB | video/mp4, video/quicktime, video/webm | |
| Carousels | Images | 8 MB | image/jpeg | |
| Carousels | Video | 100 MiB | video/mp4, video/quicktime | |
| Feed | Images | 8 MB | image/jpeg | |
| Feed | Video | 100 MiB | video/mp4, video/quicktime | |
| Reels | Video | 100 MiB | video/mp4, video/quicktime | |
| Stories | Images | 8 MB | image/jpeg | |
| Stories | Video | 100 MB | video/mp4, video/quicktime | |
| Feed | Images | 20 MiB | image/jpeg, image/png, image/gif | |
| Threads | Images | 20 MiB | image/jpeg, image/png, image/gif | |
| TikTok | Carousels | Images | 20 MB | image/jpeg, image/webp |
| TikTok | Feed | Images | 20 MB | image/jpeg, image/webp |
| TikTok | Feed | Video | 100 MiB | video/mp4, video/quicktime, video/webm |
| X (Twitter) | Feed | GIFs | 15 MB | image/gif |
| X (Twitter) | Feed | Images | 5 MB | image/jpeg, image/png, image/webp |
| X (Twitter) | Feed | Video | 140 MiB | video/mp4, video/quicktime |
| X (Twitter) | Threads | GIFs | 15 MB | image/gif |
| X (Twitter) | Threads | Images | 5 MB | image/jpeg, image/png, image/webp |
| X (Twitter) | Threads | Video | 140 MiB | video/mp4, video/quicktime |
| YouTube | Feed | Video | 100 MiB | video/mp4, video/quicktime, video/webm |
| YouTube | Shorts | Video | 100 MiB | video/mp4, video/quicktime, video/webm |
Limits are the SocialCannon end-to-end maximum for one file — the smaller of what the destination network accepts and what our publishing pipeline fetches. Where a network documents a higher figure (TikTok video, YouTube video, Instagram Reels), the number above is SocialCannon’s cap, not the network’s. X GIFs use the full documented 15 MB category. Uploaded media is kept only while a post can still publish it: a scheduled post’s media is kept while the post is scheduled and a draft’s media until about 30 days after the file was last stamped (for example when the post using it was created or scheduled), but no file is kept more than 45 days after upload: schedule a draft within 30 days of uploading its media, and make sure the post publishes within 45 days of the upload (scheduling itself is limited to 30 days ahead). Media is deleted shortly after publishing (usually within two hours), about 12 hours after a failure is settled, or about a day after its last use if no post uses it. Only a post’s media fields keep a file: an upload URL pasted into a post’s text is not kept for it and follows the same schedule, so host any file you link to from post text yourself. Platform/post-type combinations that are not listed (LinkedIn video, TikTok Story, YouTube Community posts, and all Reddit media) cannot be uploaded. LinkedIn video and YouTube Community media cannot be published through SocialCannon at all; a Reddit link post can point at a file you host yourself (not a SocialCannon upload, which is deleted after publishing and would break the link) by passing its URL in mediaUrls.
Social accounts are connected via browser-based OAuth flows — this step requires a human to log in to the platform, so it cannot be completed by an unattended agent. Each connected account also carries a tokenHealth field; skip or reconnect any account whose token is expired before publishing. Redirect users to the connect endpoint for the desired platform:
The connect_token in each URL cannot be a bare client_id. A dashboard user is already signed in, so their session identifies the client for the connection; an API client has no session, so it mints a token with POST /api/v1/accounts/connect-token (scope accounts:write) and sends the human to the data.connectUrl it returns:
Both paths require the resolved API client to have accounts:write. If the flow reports a missing scope, enable it in Settings → API Keys and save the scopes before trying again.
curl -X POST https://socialcannon.app/api/v1/accounts/connect-token \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "platform": "facebook", "redirectTo": "/dashboard/accounts" }'The token is valid for 10 minutes. The redirect comes from the token itself, not from a query parameter on the connect URL, so set redirectTo on the connect-token request. The example returns to SocialCannon's Accounts page; omitting the field also returns the user to the SocialCannon dashboard.
redirectTo is validated: a relative path resolves against SocialCannon's own origin. Absolute HTTP(S) URLs are accepted only on indierocket.app, indierocket.firebaseapp.com, localhost, 127.0.0.1, and their subdomains. Other absolute redirects are silently dropped and the user returns to the SocialCannon dashboard. Integrations on other hosts should omit the field and have the agent list accounts after authorization.
| Platform | Connect URL | Notes |
|---|---|---|
| Twitter / X | /api/connect/twitter?connect_token=... | OAuth2 with PKCE |
/api/connect/facebook?connect_token=... | Page-level access | |
/api/connect/instagram?connect_token=... | Requires media (no text-only) | |
/api/connect/linkedin?connect_token=... | Post-only (no analytics/engagement) | |
| TikTok | /api/connect/tiktok?connect_token=... | Requires media and a privacy level |
| YouTube | /api/connect/youtube?connect_token=... | Video and Shorts |
After authorization, the user is redirected back with the account connected. Tokens are encrypted with AES-256-GCM before storage.
scheduledAt → immediate publishscheduledAt → the requested time is preserved without rounding (up to 30 days ahead)280 character limit
Up to 4 images per tweet
Threads via reply chains
v2 API for tweets, v1.1 for media upload
63,206 character limit
Page-level access tokens
Supports native scheduling via API
Analytics and engagement pending Meta review
2,200 character limit
Requires media (no text-only posts)
Max 10 carousel items, no API deletion
Threads use carousel container model
3,000 character limit
Text-only or single/multi-image posts
Threads combine items into one post
Post-only (no analytics or engagement)
SocialCannon includes an MCP server with 23 tools that expose all functionality for AI agents (Claude, GPT, etc.) via the @modelcontextprotocol/sdk over stdio transport.
Start the MCP server (repo):
npm run mcpOr run the published package directly (external clients):
npx -y @socialcannon/mcp@1.0.2Set SOCIALCANNON_CLIENT_ID and SOCIALCANNON_CLIENT_SECRET (from your dashboard) before running.
Configuration (preferred — auto-refreshes JWT):
MCP_CLIENT_ID=<your API client ID>
MCP_CLIENT_SECRET=<your API client secret>
SOCIALCANNON_API_URL=https://socialcannon.appAlternatively, set MCP_API_TOKEN with a pre-generated JWT (expires after 1 hour, no auto-refresh).
List all supported social media platforms and their capabilities.
Params: None
List all connected social media accounts.
Params: platform?
Fetch a TikTok account's posting capabilities — allowed privacy levels, disabled interactions, nickname, max video duration. Call before create_post for TikTok.
Params: accountId
Create and publish or schedule a social media post. For TikTok, privacyLevel is required (from get_tiktok_creator_info); also supports disableComment, disableDuet (video), disableStitch (video), commercialContent, brandOrganic, brandedContent. Set aiGenerated to request the native label on TikTok or YouTube. Instagram rejects that request unless verified native-disclosure access is enabled for the deployment.
Params: accountId, content, mediaUrls?, scheduledAt?, visibility?, privacyLevel?, disableComment?, disableDuet?, disableStitch?, commercialContent?, brandOrganic?, brandedContent?, aiGenerated?
List social media posts with optional filters.
Params: status?, platform?, accountId?, limit?
Get details of a specific post.
Params: postId
Update a draft or scheduled post.
Params: postId, content?, scheduledAt?
Delete a post with provider-safe compensation. Provider-bound and reconciliation rows require strictCompensation; platformPostId may be supplied only as an exact persisted-ID check.
Params: postId, strictCompensation?, platformPostId?
Repair or retry a failed single post. Provider-confirmed rows are repaired locally with no republish; uncertain rows reconcile or fail closed. Failed threads without a provider ID require operator reconciliation or creation of a new thread.
Params: postId
Get a content calendar view with posts, gaps, and summary stats.
Params: startDate, endDate, accountId?, platform?
Get engagement metrics (likes, comments, shares, impressions) for a post.
Params: postId
Get aggregate analytics across all posts for a date range.
Params: startDate, endDate, platform?, accountId?
Create a multi-part thread (Twitter thread or Instagram carousel). Instagram aiGenerated requests fail closed unless verified native-disclosure access is enabled.
Params: accountId, items[], scheduledAt?, aiGenerated?
Generate a UTM-tagged URL for tracking social media traffic.
Params: url, platform?, campaign?, content?, postId?, save?
List stored engagements (comments/replies) with optional filters.
Params: postId?, isRead?, limit?, cursor?
Reply to a comment/reply directly on the social platform.
Params: engagementId, content
List all unread engagements (inbox shortcut).
Params: limit?
Repurpose content for different platforms using AI and return the adapted variants for review (preview only; publish them with create_post). Content is humanized automatically.
Params: sourceContent, targetPlatforms[], tone?
Atomically create an idempotent A/B test with content variants for an account. Reuse the exact key after a timeout.
Params: idempotencyKey, accountId, name, variants[], metric?, minDurationHours?
Get A/B test results with per-variant analytics and winner.
Params: testId
Get recommended posting times based on historical engagement data.
Params: accountId, timezone?
Find the best available time slot for a new post based on calendar gaps and engagement data.
Params: accountId, startDate?, endDate?
Idempotently fill a week with optimally timed posts. A durable manifest preserves deterministic post IDs, slots, quota reservations, and partial outcomes across same-key retries. Each post can request a native AI-content label; Instagram requests fail closed unless verified native-disclosure access is enabled.
Params: idempotencyKey, accountId, posts[], timezone?
Hermes Agent (Nous Research) ships a first-class MCP client. Point it at the published @socialcannon/mcp package and all 23 tools load over npx — no repo clone.
mcp_servers:
socialcannon:
command: "npx"
args: ["-y", "@socialcannon/mcp@1.0.2"]
env:
SOCIALCANNON_CLIENT_ID: "${SOCIALCANNON_CLIENT_ID}"
SOCIALCANNON_CLIENT_SECRET: "${SOCIALCANNON_CLIENT_SECRET}"Export SOCIALCANNON_CLIENT_ID / SOCIALCANNON_CLIENT_SECRET from your dashboard in the environment that starts Hermes; it fills in the ${…} references, so the secret stays out of config.yaml. Run /reload-mcp in your Hermes session and the tools load automatically (Hermes also auto-reloads config edits within ~30s). Other MCP hosts use the same pinned command and args — Claude Desktop uses an equivalent JSON block (mcpServers) in claude_desktop_config.json. If a host needs the secret written into its config file, make that file readable only by you and never commit or sync it.
SocialCannon is available as an OpenClaw skill. Install it from ClawHub so your AI assistant can publish, schedule, analyze, and manage social media posts through natural conversation.
clawhub install socialcannonAdd your SocialCannon API credentials to your openclaw.json config:
{
"skills": {
"entries": {
"socialcannon": {
"enabled": true,
"env": {
"SOCIALCANNON_CLIENT_ID": "your-client-id",
"SOCIALCANNON_CLIENT_SECRET": "your-client-secret"
}
}
}
}
}A trimmed reference, not the full skill: it covers authentication and connecting an account. For the complete skill with every endpoint, the platform rules and the approval guidance, install it from ClawHub with clawhub install socialcannon. To install by hand, create this file at ~/.openclaw/workspace/skills/socialcannon/SKILL.md:
This copy carries the safety rules and the two steps you need to start, but it is a summary: it does not list every endpoint, the per-platform content requirements or the full request contracts. Prefer the ClawHub package when you want the whole skill, and treat the complete file at skills/socialcannon/SKILL.md in the repository as the canonical version.
---
name: socialcannon
description: >
Publish, schedule, and manage social media posts across
Twitter/X, Facebook, Instagram, LinkedIn, TikTok, and YouTube.
Includes content calendar, analytics, A/B testing,
engagement inbox, AI content repurposing, and timing suggestions.
version: 1.14.0
metadata:
openclaw:
requires:
env:
- SOCIALCANNON_CLIENT_ID
- SOCIALCANNON_CLIENT_SECRET
bins:
- curl
- jq
primaryEnv: SOCIALCANNON_CLIENT_ID
emoji: "\U0001F4E3"
homepage: https://socialcannon.app
---
# SocialCannon
Social media publishing API with 23 MCP tools.
## Safety: destructive & irreversible actions
Several operations act on **live social accounts** and cannot be undone. Get
**explicit human approval before** any of these:
- **Publish** or an immediate A/B test (a post with no `scheduledAt`) — goes live at once.
- **Reply to an engagement** — posts a public reply from the connected account.
- **Retry** a post — re-attempts a real publish.
- **Delete** a post — removes it here **and** attempts deletion on the platform.
- **Disconnect** an account — breaks the integration; reconnecting needs the OAuth flow again.
- **Repurpose** in `post` mode — adapts **and publishes** to the target platforms.
Prefer read-only listing and `draft`/`scheduled` posts, and use `preview`-mode
repurpose before `post` mode. Keep your Client Secret in an environment variable —
never paste it into a chat.
## Authentication
First, get a JWT token:
```bash
TOKEN_FILE="$HOME/.socialcannon-token-$SOCIALCANNON_CLIENT_ID"
rm -f "$TOKEN_FILE"
TOKEN=$(curl -sS -f -X POST https://socialcannon.app/api/v1/auth/token \
-H "Content-Type: application/json" \
-d "{\"grant_type\": \"client_credentials\", \"client_id\": \"$SOCIALCANNON_CLIENT_ID\", \"client_secret\": \"$SOCIALCANNON_CLIENT_SECRET\"}" \
| jq -r '.data.access_token') || exit 1
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "Could not get an access token." >&2
exit 1
fi
umask 077
printf '%s' "$TOKEN" > "$TOKEN_FILE" || exit 1
chmod 600 "$TOKEN_FILE" || exit 1
```
Use `response.data.access_token` (responses are wrapped in `{ success, data }`) as a Bearer token for all
subsequent requests. Tokens expire after 1 hour — request a new one when you get a 401.
## Connect an account
The connect routes do not accept a bare `client_id` as proof of tenant ownership. Mint a connect token with the
`TOKEN` from the step above, then send the human to the URL it returns. The token is valid for
10 minutes.
```bash
# Read the token saved above, so this works in a fresh shell too.
TOKEN_FILE="$HOME/.socialcannon-token-$SOCIALCANNON_CLIENT_ID"
TOKEN=$(cat "$TOKEN_FILE") || exit 1
[ -n "$TOKEN" ] || { echo "No access token found. Run the authentication step above." >&2; exit 1; }
curl -sS -f -X POST https://socialcannon.app/api/v1/accounts/connect-token -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"platform":"facebook"}'
```
The response carries `data.connectUrl`. Use `twitter`, `facebook`, `instagram`, `linkedin`, `tiktok` or `youtube` as the platform. This needs the `accounts:write` scope.
## Core Operations
- POST /api/v1/posts — Create/publish a post
- GET /api/v1/posts — List posts (cursor pagination)
- POST /api/v1/posts/thread — Create thread/carousel
- GET /api/v1/calendar — Content calendar with gaps
- GET /api/v1/posts/:id/analytics — Engagement metrics
- GET /api/v1/engagements — Comment inbox
- POST /api/v1/engagements/:id/reply — Reply to comment
- POST /api/v1/posts/repurpose — AI content adaptation
- POST /api/v1/ab-tests — Create A/B test (requires stable Idempotency-Key header)
- GET /api/v1/accounts/:id/timing — Best posting times
- POST /api/v1/links/generate — UTM-tagged URL
- POST /api/v1/media/upload-init — Start a direct upload (send accountId to apply that destination's per-file limit)Create your first API client:
npx tsx scripts/create-client.ts "My App"This writes directly to Firestore and prints the client_id andclient_secret. Save the secret — it's only shown once.
Or use the dashboard:
Sign in with Google or GitHub at /sign-in and an API client is automatically provisioned for your account.
Stuck on something? Reach out at support@socialcannon.app