Skip to content

Developer guide

Send posts to Amaposta from your own content pipeline: upload media, create a post for every channel, schedule it and hear back when it's live.

Authentication

Create an API key in the dashboard under Settings, API and webhooks. Keys start with ss_live_, belong to one workspace and can be limited to certain projects. Send the key as a bearer token on every request:

Check a key
curl https://www.amaposta.com/api/v1/me \
  -H "Authorization: Bearer ss_live_…"

The response names the key, its scopes and the workspace. Each key has one or more scopes:

ScopeAllows
content:readRead projects, channels, posts and media.
content:writeUpload media and create or edit posts.
posts:publishSchedule and publish without review. Without it, posts go to the review inbox in projects that use approvals, and stay drafts everywhere else.

Each key can make 120 requests a minute. Past that you get 429 with the code rate_limited; wait a moment and try again.

Projects and channels

Posts live in a project. List the projects your key can see, then the channels in one of them. You'll need a channel's id to target it or to override its text.

List projects and channels
curl https://www.amaposta.com/api/v1/projects \
  -H "Authorization: Bearer $AMAPOSTA_KEY"

curl https://www.amaposta.com/api/v1/projects/$PROJECT_ID/channels \
  -H "Authorization: Bearer $AMAPOSTA_KEY"

A channel has a provider (youtube, instagram, tiktok, x, facebook, custom or sandbox), a status (active, expiring, needs_reconnect, error or disabled) and the post kinds it supports.

Upload media

Images and videos are uploaded once per project and can be used in any number of posts. There are three ways in.

Direct upload

Send the file as multipart form data in a field named file. Best for files up to a few hundred MB.

POST /api/v1/projects/{project_id}/media
curl https://www.amaposta.com/api/v1/projects/$PROJECT_ID/media \
  -H "Authorization: Bearer $AMAPOSTA_KEY" \
  -F "[email protected]"

Presigned upload for large files

Ask for an upload URL, PUT the bytes straight to storage, then tell Amaposta you're done.

Three requests
# 1. Ask for an upload URL. The response is {"media": { … }, "upload_url": "…"}
curl https://www.amaposta.com/api/v1/projects/$PROJECT_ID/media/uploads \
  -H "Authorization: Bearer $AMAPOSTA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename": "batch-142.mp4", "content_type": "video/mp4", "size_bytes": 48213844}'

# 2. Upload the bytes to upload_url
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  --upload-file batch-142.mp4

# 3. Finish the upload
curl -X POST https://www.amaposta.com/api/v1/media/$MEDIA_ID/complete \
  -H "Authorization: Bearer $AMAPOSTA_KEY"

Import from a URL

Give Amaposta a public URL and it downloads the file in the background. You can also skip this step and pass media_url when you create the post.

POST /api/v1/projects/{project_id}/media/import
curl https://www.amaposta.com/api/v1/projects/$PROJECT_ID/media/import \
  -H "Authorization: Bearer $AMAPOSTA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://cdn.example.com/batch-142.mp4"}'

Every upload returns a media object. Its status moves from pending to processing to ready (or failed, with an error). Once processed it carries width, height, duration_ms and a thumbnail. Poll GET /api/v1/media/{id} or listen for the media.ready webhook. The url it returns is signed and valid for 12 hours.

Create a post

POST /api/v1/projects/{project_id}/posts creates one post with a delivery for each channel. Amaposta adapts it per platform: titles go only where there's a title field, hashtags become tags on YouTube and inline text elsewhere.

Create a video post and add it to the queue
curl https://www.amaposta.com/api/v1/projects/$PROJECT_ID/posts \
  -H "Authorization: Bearer $AMAPOSTA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: episode-142" \
  -d @- <<'JSON'
{
  "kind": "video",
  "title": "How we roast a batch in 12 minutes",
  "description": "From green beans to first crack. We filmed one batch start to finish.",
  "hashtags": ["coffee", "behindthescenes"],
  "media_url": "https://cdn.example.com/batch-142.mp4",
  "cover_url": "https://cdn.example.com/batch-142-cover.jpg",
  "overrides": {
    "<x-channel-id>": {
      "description": "Batch 142 is out: green beans to first crack in 12 minutes."
    },
    "<youtube-channel-id>": {
      "options": { "privacy": "unlisted" }
    }
  },
  "schedule": { "mode": "queue" },
  "external_ref": "episode-142",
  "metadata": {
    "pipeline": { "name": "nightly-cut", "run": "2026-10-17" }
  }
}
JSON

Fields

FieldWhat it does
kindtext, image or video. Required.
titleUsed by platforms with a title field: YouTube, TikTok photo posts, Facebook videos.
descriptionThe caption, description or post text. Also accepted as body, caption or text.
hashtagsA list, with or without #.
media_id, media_urlAn uploaded media id, or a public URL Amaposta downloads for you. Needed for image and video posts.
cover_media_id, cover_urlA cover image for videos, where the platform supports one.
channelsChannel ids to post to. Leave it out to use every active channel in the project that supports this kind of post.
overridesPer-channel changes keyed by channel id: title, description, hashtags and platform options, such as YouTube's privacy.
schedulemode is at (with an ISO 8601 at), now or queue for the next free posting slot. Leave it out to create a draft.
external_refYour own reference, echoed back on the post and in Custom API deliveries.
metadataFree-form JSON stored with the post. Include "pipeline" to mark generated content; the dashboard shows it and webhooks report the origin as pipeline.

The response is the post, including one entry in deliveries per channel.

Safe retries

Networks drop requests. Send an Idempotency-Key header when you create a post, and a retry with the same key returns the post from the first attempt instead of creating a second one. Use something stable from your side, such as a job or episode id. Keys are cut to 200 characters.

Schedule and publish

Schedule a draft, publish it now, add it to the queue or take it off the calendar with POST /api/v1/posts/{post_id}/schedule.

Schedule for a time
curl https://www.amaposta.com/api/v1/posts/$POST_ID/schedule \
  -H "Authorization: Bearer $AMAPOSTA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "at", "at": "2026-10-20T07:00:00Z"}'
  • atPublishes at the given time, up to two years ahead.
  • nowPublishes right away.
  • queueTakes the next free posting slot for the post's channels in the next 12 weeks.
  • unscheduleMoves the post back to drafts.

Before anything is scheduled, the same checks as in the composer run. If one fails, the request returns 400 with a message such as Fix this before scheduling: YouTube needs a title. Scheduling needs the posts:publish scope, or a project with approvals turned on, in which case the post waits in the review inbox.

Read posts and deliveries

List a project's posts, filtered by status (comma-separated statuses, or inbox, queue or history), a from/to window, limit and offset. The response is {"items": […], "total": n}.

What's coming up, and one post in detail
curl "https://www.amaposta.com/api/v1/projects/$PROJECT_ID/posts?status=queue" \
  -H "Authorization: Bearer $AMAPOSTA_KEY"

curl https://www.amaposta.com/api/v1/posts/$POST_ID \
  -H "Authorization: Bearer $AMAPOSTA_KEY"

A post has an overall status (draft, in_review, changes_requested, ready, scheduled, publishing, published, partial or failed) and one delivery per channel:

deliveries[]
"deliveries": [
  {
    "id": "0192b7d4-…",
    "channel_id": "0192b7a1-…",
    "status": "published",
    "url": "https://www.youtube.com/watch?v=…",
    "error": null,
    "published_at": "2026-10-20T07:00:41Z"
  },
  {
    "id": "0192b7d4-…",
    "channel_id": "0192b7a9-…",
    "status": "failed",
    "url": null,
    "error": {
      "code": "endpoint_unavailable",
      "message": "Your endpoint returned 503 Service Unavailable",
      "hint": "Amaposta will retry with backoff."
    },
    "published_at": null
  }
]

Delivery statuses are pending, scheduled, publishing, processing, published, failed and cancelled. DELETE /api/v1/posts/{post_id} removes a post.

Errors

Every error has the same shape: an error object with a stable code, a message you can show to people, and sometimes fields (per-field messages) and a hint.

422 Unprocessable Entity
{
  "error": {
    "code": "validation",
    "message": "Please fix the highlighted fields.",
    "fields": {
      "kind": "Use text, image or video."
    }
  }
}
StatusCodeMeaning
400bad_requestThe request can't be done as asked, such as scheduling a post that fails its checks.
401unauthorizedThe API key is missing, wrong or revoked.
402plan_limitThe workspace's plan doesn't allow this; limit says which limit.
403forbiddenThe key lacks a scope or access to the project.
404not_foundNo such project, post, media or endpoint.
409conflictThe change clashes with the current state, such as editing a post that's already publishing.
422validationOne or more fields need fixing; see fields.
429rate_limitedMore than 120 requests a minute on this key.
502provider_errorA platform refused or failed; hint says what to do.

Webhooks

Add an endpoint in Settings, API and webhooks, pick the events you want and copy its signing secret. Amaposta POSTs JSON to your URL for each event. Anything other than a 2xx response, or no answer within 15 seconds, is retried with backoff, and endpoints that keep failing are switched off with a notice to admins.

EventSent when
post.createdA post was created (dashboard or API)
post.scheduledA post was scheduled
post.approvedA post was approved
post.publishedA post went live on a channel
post.failedA post failed to publish on a channel
channel.connectedA channel was connected or reconnected
channel.disconnectedA channel lost its connection
media.readyAn upload finished processing
media.failedAn upload couldn't be processed
post.published
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Amaposta-Webhooks/1.0
X-Amaposta-Event: post.published
X-Amaposta-Event-Id: 0192b7e0-…
X-Amaposta-Signature: t=1792479641,v1=5f2b0c…

{
  "id": "0192b7e0-…",
  "type": "post.published",
  "created_at": "2026-10-20T07:00:41Z",
  "project_id": "0192a3f1-…",
  "data": {
    "post_id": "0192b7c8-…",
    "delivery_id": "0192b7d4-…",
    "channel_id": "0192b7a1-…",
    "provider": "youtube",
    "external_id": "…",
    "url": "https://www.youtube.com/watch?v=…"
  }
}

Verify the signature

X-Amaposta-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC_SHA256(secret, t + "." + body) over the raw request body. Compute it yourself, compare in constant time, and reject old timestamps to stop replays.

Node.js
import crypto from "node:crypto";

// rawBody: the request body exactly as received (string or Buffer).
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Python
import hashlib
import hmac
import time


def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Custom API channels

A Custom API channel publishes to an HTTPS endpoint you run: your CMS, a newsletter tool, a platform Amaposta doesn't cover yet. It sits on the calendar, gets checks, approvals and retries like any other channel. When a post goes out, Amaposta sends one POST per delivery:

What your endpoint receives
POST /amaposta/publish HTTP/1.1
Authorization: Bearer <your API key>
Content-Type: application/json
Idempotency-Key: 0192b7d4-…
X-Amaposta-Delivery: 0192b7d4-…
X-Amaposta-Signature: t=1792479600,v1=9ac41e…

{
  "id": "0192b7d4-…",
  "type": "post.publish",
  "created_at": "2026-10-20T07:00:00Z",
  "content": {
    "kind": "video",
    "title": "How we roast a batch in 12 minutes",
    "text": "From green beans to first crack. We filmed one batch start to finish.",
    "hashtags": ["coffee", "behindthescenes"],
    "media": {
      "url": "https://…",
      "content_type": "video/mp4",
      "size_bytes": 48213844,
      "width": 1080,
      "height": 1920,
      "duration_ms": 58000,
      "filename": "batch-142.mp4"
    },
    "cover": null,
    "options": {}
  },
  "post": {
    "id": "0192b7c8-…",
    "external_ref": "episode-142",
    "scheduled_at": "2026-10-20T07:00:00Z",
    "metadata": {
      "pipeline": { "name": "nightly-cut" }
    }
  },
  "project": { "id": "0192a3f1-…", "name": "Spring launch" },
  "channel": { "id": "0192b7a9-…", "name": "api.example.com" }
}

How to respond

  • 2xxPublished. Optionally return {"id": "…", "url": "…"} and the link shows up on the post.
  • 401, 403The key was rejected. The channel is marked for reconnect and admins are told. Don't use these for a bad signature.
  • 5xx, 408, 429Temporary. Amaposta retries with backoff.
  • Other 4xxThe post is marked failed with your message, and someone can fix it and press Retry.
A receiver in Express
app.post("/amaposta/publish", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.get("X-Amaposta-Signature") ?? "";
  if (!verifySignature(req.body, signature, process.env.AMAPOSTA_SIGNING_SECRET)) {
    return res.status(400).json({ message: "Bad signature" });
  }
  const delivery = JSON.parse(req.body);
  const item = await publishToYourSystem(delivery.content, req.get("Idempotency-Key"));
  res.json({ id: item.id, url: item.url });
});

Your endpoint must be reachable from the internet; redirects aren't followed. When you connect the channel, Amaposta checks it with a GET to your health URL if you set one, or an OPTIONS request to the endpoint.

Sandbox channels

A sandbox channel behaves like a real one, but nothing leaves Amaposta. Use it to build and test your integration end to end: posts are scheduled, go on air, publish or fail, and fire the same webhooks. Sandbox channels are free on every plan and don't count toward channel limits.

Choose how it behaves when you add it:

  • Always succeedEvery post publishes.
  • Fail now and thenSee failures and retries without waiting for a real outage.
  • Always failTest your post.failed handling.
  • Expired connectionSee how reconnect alerts and links look when access lapses.

Platform rules

What each channel type accepts. The checks use the same limits, so most problems show up before a post is scheduled rather than when it publishes.

Platform Post Title Text Hashtags Media
YouTube Video TitleRequired, up to 100 characters DescriptionUp to 5,000 characters Sent as tags, up to 60 MP4, MOV, WebM, AVI, MKV; up to 256 GB; 1 s to 12 h
Accounts that haven't verified a phone number can upload up to 15 minutes and can't set custom thumbnails. Each upload costs API quota; the default Google quota allows about six uploads per day per app.
Instagram Image No title CaptionUp to 2,200 characters Added to the text, up to 30 JPEG; up to 8 MB
Video No title CaptionUp to 2,200 characters Added to the text, up to 30 MP4, MOV; up to 300 MB; 3 s to 15 min
Requires an Instagram Business or Creator account. Instagram allows 50 API-published posts per account in a rolling 24 hours. Text-only posts aren't possible on Instagram.
TikTok Image TitleUp to 90 characters DescriptionUp to 4,000 characters Added to the text JPEG, WebP; up to 20 MB
Video No title CaptionUp to 2,200 characters Added to the text MP4, MOV, WebM; up to 4 GB; 3 s to 10 min
Posts from apps that haven't completed TikTok's audit are visible only to the creator. TikTok picks the cover from a video frame; custom cover images aren't supported. Text-only posts aren't possible on TikTok.
X Text No title PostRequired, up to 280 (links count as 23, emoji as 2) Added to the text None
Image No title PostUp to 280 (links count as 23, emoji as 2) Added to the text JPEG, PNG, WebP, GIF; up to 5 MB
Video No title PostUp to 280 (links count as 23, emoji as 2) Added to the text MP4, MOV; up to 512 MB; 0.5 s to 2:20 min
Posting through the X API requires a paid API plan for your developer app. Hashtags count toward the character limit.
Facebook Text No title PostRequired, up to 63,206 characters Added to the text None
Image No title CaptionUp to 63,206 characters Added to the text JPEG, PNG, GIF, WebP; up to 10 MB
Video TitleUp to 255 characters DescriptionUp to 63,206 characters Added to the text MP4, MOV; up to 1 GB; 1 s to 20 min
Publishes to Pages only; personal profiles can't be posted to through the API.
Custom API Text TitleNo length limit DescriptionNo length limit Sent as tags None
Image TitleNo length limit DescriptionNo length limit Sent as tags JPEG, PNG, WebP, GIF
Video TitleNo length limit DescriptionNo length limit Sent as tags MP4, MOV, WebM
Each request is signed with an HMAC-SHA256 header so you can verify it came from Amaposta. Respond with 2xx and optional JSON {"id": "…", "url": "…"}. 408, 429 and 5xx responses are retried with backoff; 401/403 mark the channel for reconnecting; other 4xx fail the delivery so you can fix it and retry.
Sandbox Text TitleUp to 200 characters DescriptionUp to 5,000 characters Added to the text, up to 30 None
Image TitleUp to 200 characters DescriptionUp to 5,000 characters Added to the text, up to 30 JPEG, PNG, WebP, GIF
Video TitleUp to 200 characters DescriptionUp to 5,000 characters Added to the text, up to 30 MP4, MOV, WebM
Publishing takes a moment so you can watch the status change live.