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

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 s3Keyscarousel.
  • Exactly one key ending in .jpg, .jpeg, .png, .gif, .webp, .heic or .heifimage.
  • 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.

PlatformVideoImageCarouselStoryText
instagramReelsyes2–10 itemsyes
facebookyesyesyes
xyesyesup to 4 imagesyes
linkedinyes1 imageyes
tiktokyes
youtubeyes

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

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. Always send timezone with it (see “Scheduling”).

timezonestring

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

addToQueueboolean

Ignore 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 scheduledAt and addToQueue — publishes immediately.
  • Send scheduledAt together with timezone — publishes at that wall-clock time in that IANA zone. An unparseable value returns 400 Invalid datetime.
  • Send addToQueue: true — uses the next free slot in the schedule you configured for that profile, ignoring scheduledAt.

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

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: "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" } }
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. status moves through queuedprocessing → 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 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 new secret is embedded in the registered URL. 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 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_AUTHignored

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