Developer API — publish posts from your code

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_xxx

Base 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","threads"]}'

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","threads"]}'

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, .heic or .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.

Instagram and Facebook Stories

A Story requires exactly one image or video and can target only instagram, facebook, or both. TikTok does not expose Story publishing through its API.

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":"<uploaded-key.mp4>","postType":"story","platforms":["instagram","facebook"]}'

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.

PlatformVideoImageCarouselStoryText
instagramReelsyes2–10 itemsimage/video—
facebookyesyes—image/videoyes
xyesyesup to 4 images—yes
linkedinyes1 image——yes
tiktokyes————
youtubeyes————

Stories accept one image or video and target Instagram or Facebook only. TikTok and YouTube do not expose Story publishing through their APIs. Unsupported Story targets are rejected before a job is created. 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”.

s3Keystring

Single 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.

captionstring

Caption 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.

scheduledAtstring

ISO datetime to publish at, up to 31 days ahead. Always send timezone with it (see “Scheduling”).

timezonestring

IANA timezone (e.g. "Europe/Berlin") used to interpret scheduledAt.

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 two ways to time a post:

  • Omit scheduledAt. The post publishes immediately.
  • Send scheduledAt together with timezone. The post publishes at that wall-clock time in that IANA zone. An unparseable value returns 400 Invalid datetime.

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.

Times up to 15 minutes ahead go on SQS with a delay. Further times (up to 31 days) are stored as scheduled in Postgres. An in-process worker promotes due rows onto the queue. Past 31 days returns 400 with Scheduled publish cannot be more than 31 days from now. Cancel a waiting post from the calendar while its status is scheduled, queued, or retry_scheduled.

Endpoints

GET/accounts

List 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.

POST/media/upload-url

Get 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" }
POST/posts

Create (and optionally schedule) a post. Responds 202 with { success, jobId, status } where status is "queued" or "scheduled". A profileId your key does not own returns 404. See the field reference below.

{ "profileId": "...", "s3Keys": ["..."], "postType": "carousel", "caption": "Hello", "platforms": ["instagram","x"], "captionOverrides": { "x": "Short version" } }
GET/posts/:jobId

Get a post job status and per-platform results. Returns { success, job: { id, profile_id, status, results, platforms, scheduled_at, created_at } }.

GET/analytics/posts/:jobId

Canonical analytics endpoint. Returns { success, metrics }.

GET/posts/:jobId/analytics

Same 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. A far-future post starts as scheduled, then queued when the clock is due. From there status moves 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.

addToQueue is no longer supported

Recurring queue slots were removed. Send scheduledAt plus timezone to publish at a chosen time, or omit both to publish immediately.

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 mcp

POSTUNO_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.

  1. 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.
  2. Set UPLOAD_POST_WEBHOOK_BASE_URL to the backend's public origin if it is not the default https://api.post-uno.com.
  3. 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:webhook from backend/, or docker exec postuno-api node dist/cli/register-upload-post-webhook.js on EC2.
  4. Optionally set UPLOAD_POST_WEBHOOK_SECRET to rotate the shared secret independently of the API key, then re-run the registration so the provider uses the updated secret. Unauthenticated deliveries are always rejected with 401.
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 unconditionally

Environment 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.com

Public origin prefixed to the path when building the URL registered with Upload-Post.

UPLOAD_POST_WEBHOOK_SECRETstring, optional

Shared secret used to authenticate inbound deliveries. Changing it requires re-running the registration.

UPLOAD_POST_WEBHOOK_REQUIRE_AUTHignored

Legacy flag. Inbound deliveries require a valid webhook secret and unauthenticated calls are rejected with 401.

UPLOAD_POST_API_KEYstring (required)

Upload-Post account key used for provider API calls and webhook registration.

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. Configure Upload-Post to send it in an x-postuno-webhook-token header, and postuno compares it in constant time on delivery. 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.