Developer API
Publish and schedule posts programmatically. Available on the Creator and Pro plans. Prefer no-code? See the integration guides.
Authentication
Create an API key on the API Keys page. Pass it as a Bearer token. Keys look like pk_live_… and the full value is shown once — only a hash is stored, so it cannot be recovered later.
Authorization: Bearer pk_live_xxxBase URL: https://api.post-uno.com/v1. A missing or malformed header returns 401 with Missing API key; a revoked or unknown key returns 401 with Invalid or revoked API key.
Profile authorization
Every /v1 request that names a profileId — in the path, the query string or the JSON body — is checked against the profiles your API key's account owns. If any named id is not yours, the request stops before it reaches the endpoint. Sending an owned id in the query string and a different id in the body does not bypass the check.
404 { "error": "Profile not found" }The same 404 is returned for a profile id that does not exist at all, and for one that is not a valid UUID. All three cases are deliberately indistinguishable.
Why 404 and not 403? A 403 would confirm that the profile exists and merely belongs to someone else, which turns any valid API key into a profile-id enumeration oracle. From your key's point of view, a profile you do not own simply does not exist.
Endpoints keyed by jobId rather than profileId — GET /posts/:jobId, GET /posts/:jobId/analytics and GET /analytics/posts/:jobId — are scoped the same way and return 404 Job not found for a job your key does not own.
Use GET /accounts without a filter to list the profile ids you can actually address; the profile_id on each account is a valid value for profileId.
Rate limits
120 requests per minute per API key, as a fixed one-minute window. The limit is the same on every plan. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; exceeding it returns 429 with { "error": "Rate limit exceeded", "limit": 120 }.
Separately, your plan caps how many posts you may publish per month. When that cap is reached POST /posts returns 402 with the reason, limit and used count.
Quick start
Every post is two steps: upload the bytes to a presigned URL, then create the post from the returned s3Key.
# 1. Get an upload URL
curl -X POST https://api.post-uno.com/v1/media/upload-url \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fileName":"clip.mp4","contentType":"video/mp4"}'
# 2. PUT the file bytes to the returned uploadUrl
curl -X PUT "<uploadUrl>" --data-binary @clip.mp4 -H "Content-Type: video/mp4"
# 3. Create the post — 202 { "success": true, "jobId": "...", "status": "queued" }
curl -X POST https://api.post-uno.com/v1/posts \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"profileId":"<id>","s3Key":"<s3Key>","caption":"Hello","platforms":["x","linkedin"]}'Single image
Identical, but upload an image content type. The post type is inferred from the file extension, so no postType is needed — though sending it explicitly is always safe.
curl -X POST https://api.post-uno.com/v1/media/upload-url \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fileName":"photo.jpg","contentType":"image/jpeg"}'
curl -X PUT "<uploadUrl>" --data-binary @photo.jpg -H "Content-Type: image/jpeg"
curl -X POST https://api.post-uno.com/v1/posts \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"profileId":"<id>","s3Key":"<s3Key>","postType":"image","caption":"Hello","platforms":["instagram","x"]}'Carousel (multiple images)
Upload each file separately, collect the keys, and send them as s3Keys — plural. More than one key infers carousel.
curl -X POST https://api.post-uno.com/v1/posts \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"profileId":"<id>",
"s3Keys":["<key1>","<key2>","<key3>"],
"postType":"carousel",
"caption":"Three shots from the shoot",
"platforms":["instagram"]
}'Text only
No media required — postType must be set explicitly, since there is no file to infer from.
curl -X POST https://api.post-uno.com/v1/posts \
-H "Authorization: Bearer $POSTUNO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"profileId":"<id>","postType":"text","caption":"Shipping today.","platforms":["x","linkedin","facebook"]}'Post types
postType accepts video, image, carousel, story and text. When you omit it, the API infers it from the media you sent:
- More than one key in
s3Keys→carousel. - Exactly one key ending in
.jpg,.jpeg,.png,.gif,.webp,.heicor.heif→image. - Anything else →
video.
Inference is extension-based, not content-based, so a key without a recognised image extension is treated as a video and will be rejected by the platform. Set postType explicitly for story and text, which are never inferred, and any time your key has an unusual extension.
Platform support
Not every platform accepts every post type. Targeting an unsupported combination does not fail the whole job — that platform alone comes back with an error while the others publish.
Threads is supported in managed mode (Upload-Post provider). Its exact post-type behavior is provider-dependent and may differ from the native platform matrix below.
| Platform | Video | Image | Carousel | Story | Text |
|---|---|---|---|---|---|
| Reels | yes | 2–10 items | yes | — | |
| yes | yes | — | — | yes | |
| x | yes | yes | up to 4 images | — | yes |
| yes | 1 image | — | — | yes | |
| tiktok | yes | — | — | — | — |
| youtube | yes | — | — | — | — |
TikTok and YouTube are video-only. Sending them an image, carousel, story or text post returns This platform does not support post type 'image' for that platform. Instagram has no text-only path, LinkedIn takes a single image rather than a carousel, and X caps a post at 4 images — extra images beyond the cap are dropped.
POST /posts fields
profileIdstring (required)Profile that owns the connected accounts you are posting to. Must belong to your account — see “Profile authorization”.
s3KeystringSingle media key from /media/upload-url. Use for one video or one image.
s3Keysstring[]Multiple media keys, for carousels. Takes precedence over s3Key when non-empty.
postType'video' | 'image' | 'carousel' | 'story' | 'text'Optional. Inferred from your media when omitted — see “Post types” below.
captionstringCaption used for every platform. Defaults to an empty string.
captionOverridesRecord<platform, string>Per-platform caption map, e.g. { "x": "Short version" }. Platforms not listed fall back to caption.
platformsstring[]Targets: facebook, instagram, tiktok, threads, youtube, x, linkedin. Defaults to ["facebook"] if omitted or not an array.
scheduledAtstringISO datetime to publish at. Always send timezone with it (see “Scheduling”).
timezonestringIANA timezone (e.g. "Europe/Berlin") used to interpret scheduledAt.
addToQueuebooleanIgnore scheduledAt and use the profile's next free schedule slot. Returns 400 “No schedule slots configured for this profile” if the profile has none.
Media is required unless postType is text; otherwise you get 400 with profileId is required, and media is required unless postType is text.
Scheduling
There are three ways to time a post:
- Omit
scheduledAtandaddToQueue— publishes immediately. - Send
scheduledAttogether withtimezone— publishes at that wall-clock time in that IANA zone. An unparseable value returns400 Invalid datetime. - Send
addToQueue: true— uses the next free slot in the schedule you configured for that profile, ignoringscheduledAt.
Always send timezone alongside scheduledAt. Without it the value is parsed loosely and a zone-suffixed timestamp such as 2026-07-01T12:00:00Z will not be understood, causing the post to go out immediately instead of at the time you asked for.
Scheduling is currently capped at 15 minutes ahead. Anything further returns 400 with Scheduled publish is currently limited to 15 minutes with SQS delays. For a real content calendar, use addToQueue or the dashboard scheduler instead.
Endpoints
/accountsList connected social accounts (optionally ?profileId=). Returns { success, accounts: [{ id, profile_id, platform, platform_account_id, platform_account_name }] }. A profileId your key does not own returns 404, not an empty list.
/media/upload-urlGet a presigned S3 URL. PUT your file bytes to uploadUrl, then reference the returned s3Key. Returns { success, s3Key, uploadUrl }.
{ "fileName": "photo.jpg", "contentType": "image/jpeg" }/postsCreate (and optionally schedule) a post. Responds 202 with { success, jobId, status: "queued" }, or 404 if the profileId is not one your key owns. See the field reference below.
{ "profileId": "...", "s3Keys": ["..."], "postType": "carousel", "caption": "Hello", "platforms": ["instagram","x"], "captionOverrides": { "x": "Short version" } }/posts/:jobIdGet a post job status and per-platform results. Returns { success, job: { id, profile_id, status, results, platforms, scheduled_at, created_at } }.
/analytics/posts/:jobIdCanonical analytics endpoint. Returns { success, metrics }.
/posts/:jobId/analyticsSame data, alternate path kept for the MCP tooling. Returns { success, jobId, analytics } — note the different envelope key.
Job status and results
POST /posts returns immediately with a jobId; publishing happens in the background. Poll GET /posts/:jobId for the outcome. status moves through queued → processing → one of done (every platform succeeded), partial (some succeeded) or failed (none succeeded). A transient error may park the job in retry_scheduled before it is retried.
results is a map keyed by platform — or by platform:accountName when one platform has several connected accounts. Each value is a string starting with success: or error:, and failures carry the real upstream response so you can act on it:
{
"x": "success: 1234567890123456789",
"instagram": "error: HTTP 400 on POST https://graph.facebook.com/v21.0/123/media: {\"error\":{...}}",
"tiktok": "error: This platform does not support post type 'image'"
}Troubleshooting
Job stuck in processing
The worker claimed the job but has not finished. Large videos are transcoded and uploaded upstream, so a few minutes is normal. If it has not moved after that, check results for platforms that already reported, and retry with a fresh job — job ids are not resumable.
404 Profile not found
The profileId you sent is not owned by this API key's account — or does not exist, or is not a UUID. These are reported identically on purpose. Call GET /accounts with no filter and use a profile_id from the response.
GET /accounts returns an empty list
The key authenticated and the profile is yours, but it has no connected accounts — connect the platform in the dashboard first. Note that a profile you do not own no longer comes back empty; it returns 404 Profile not found instead.
Platform not connected
A platform named in platforms that has no connected account for the profile is simply skipped or reported as an error in results. Call GET /accounts and post only to the platforms it returns.
This platform does not support post type '…'
You targeted a platform with a post type it has no code path for — most often an image or carousel sent to TikTok or YouTube. Check the platform support table and split the request into one call per group of compatible platforms.
No schedule slots configured for this profile
You sent addToQueue: true for a profile with an empty posting schedule. Configure slots for that profile, or send an explicit scheduledAt plus timezone.
402 on POST /posts
Your plan's monthly post allowance is used up. The response includes limit and used.
MCP server (for AI agents)
postuno ships a Model Context Protocol server so agents can publish on your behalf. It wraps the same /v1 API described here and currently covers listing connected accounts, creating presigned upload URLs, creating and scheduling posts, and reading post status and analytics. The tool set is expanding, so query your MCP client for the live list rather than hard-coding it.
The server ships in the postuno backend and speaks stdio. Point your MCP client at it with your API key:
POSTUNO_API_KEY=pk_live_xxx \
POSTUNO_API_URL=https://api.post-uno.com \
npm run mcpPOSTUNO_API_URL defaults to https://api.post-uno.com. Set it explicitly when targeting a different environment (for example local development). Tool errors surface the API's JSON response, so the troubleshooting notes above apply unchanged.
Webhooks (postuno → your endpoint)
Register an endpoint under Webhooks to receive upload_completed, upload_failed and upload_partial events. Deliveries carry the event name in X-Postuno-Event and an HMAC-SHA256 digest of the raw request body in X-Postuno-Signature — recompute it with your endpoint secret (whsec_…) over the raw bytes, before any JSON parsing, and compare.
{
"event": "upload_partial",
"data": {
"jobId": "…",
"profileId": "…",
"status": "partial",
"platforms": ["instagram", "tiktok"],
"results": { "instagram": "success: …", "tiktok": "error: …" }
},
"timestamp": "2026-08-12T12:34:56.789Z"
}This is postuno's outbound webhook to you, and it is the only one with a signature. It is unrelated to the inbound provider callback described under “Deployment setup”, which is an operator concern and never reaches your endpoint.
Deployment setup (self-hosting)
Only relevant if you run your own postuno backend. Managed publishing goes out through Upload-Post, which reports results back over an inbound webhook. This is a different mechanism from the outbound webhooks above — it carries no X-Postuno-Signature, and it is delivered by the provider, not by postuno.
Registration is account-level, not per post
Upload-Post stores exactly one webhook_url per account. There is no per-request callback parameter: correlation happens through the job_id / request_id postuno sends on dispatch and the provider echoes back. Registration is therefore a deploy-time step, run once per environment.
Setup order
The order matters, because the inbound route does not exist until it is configured.
- Set
UPLOAD_POST_WEBHOOK_PATH(suggested:/webhooks/upload-post). It defaults to an empty string, and while it is empty the inbound route is not mounted at all — the endpoint 404s because it does not exist. - Set
UPLOAD_POST_WEBHOOK_BASE_URLto the backend's public origin if it is not the defaulthttps://api.post-uno.com. - Backend deploy registers the URL automatically after the API health check (GitHub Action
Register Upload-Post webhook). It is idempotent — it reads the current settings and skips the write when they already match. Manual fallback:npm run register:webhookfrombackend/, ordocker exec postuno-api node dist/cli/register-upload-post-webhook.json EC2. - Optionally set
UPLOAD_POST_WEBHOOK_SECRETto rotate the shared secret independently of the API key, then re-run the registration so the new secret is embedded in the registered URL. Unauthenticated deliveries are always rejected with401.
cd backend
npm run register:webhook # register, or no-op if unchanged
npm run register:webhook -- --print # show the URL, call nothing
npm run register:webhook -- --force # write unconditionallyEnvironment variables
UPLOAD_POST_WEBHOOK_PATHstring, default ''Path the inbound route is mounted at. Empty means the route is not mounted at all, and registration refuses to run. Suggested: /webhooks/upload-post.
UPLOAD_POST_WEBHOOK_BASE_URLstring, default https://api.post-uno.comPublic origin prefixed to the path when building the URL registered with Upload-Post.
UPLOAD_POST_WEBHOOK_SECRETstring, optionalShared secret embedded in the registered URL. Defaults to a value derived from UPLOAD_POST_API_KEY, so the callback is never wide open. Changing it requires re-running the registration.
UPLOAD_POST_WEBHOOK_REQUIRE_AUTHignoredLegacy flag. Inbound deliveries always require a valid secret (or the Upload-Post API key). Unauthenticated calls are rejected with 401.
UPLOAD_POST_API_KEYstring (required)Upload-Post account key. Also the fallback source of the webhook secret, and accepted as an alternative credential on inbound deliveries.
Inbound authentication
Upload-Post documents no signature or HMAC scheme for its webhooks — do not go looking for one. The strongest check available is a shared secret, which postuno embeds in the URL it registers and compares in constant time on delivery. It is accepted either as the ?token= query parameter (where registration puts it) or as an x-postuno-webhook-token header. A mismatch returns 401 Unauthorized webhook.
One delivery per platform
A post to three platforms produces three separate deliveries. Each one is merged into the job's results map rather than replacing it, and the job's status is recomputed from the merged view — so a later failure never demotes an earlier success. A job with mixed outcomes ends as partial. Connection events (social_account_*) carry no job and are acknowledged and ignored.
Polling fallback
Webhooks are not the only path. When GET /v1/posts/:jobId reads a managed job that is still processing, postuno reconciles it against Upload-Post's status endpoint and returns the reconciled result. Job status is therefore truthful even if the webhook was never configured, delayed or dropped — registration makes it prompt, not merely correct.