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:
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:
| Scope | Allows |
|---|---|
content:read | Read projects, channels, posts and media. |
content:write | Upload media and create or edit posts. |
posts:publish | Schedule 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.
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.
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.
# 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.
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.
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
| Field | What it does |
|---|---|
kind | text, image or video. Required. |
title | Used by platforms with a title field: YouTube, TikTok photo posts, Facebook videos. |
description | The caption, description or post text. Also accepted as body, caption or text. |
hashtags | A list, with or without #. |
media_id, media_url | An uploaded media id, or a public URL Amaposta downloads for you. Needed for image and video posts. |
cover_media_id, cover_url | A cover image for videos, where the platform supports one. |
channels | Channel ids to post to. Leave it out to use every active channel in the project that supports this kind of post. |
overrides | Per-channel changes keyed by channel id: title, description, hashtags and platform options, such as YouTube's privacy. |
schedule | mode is at (with an ISO 8601 at), now or queue for the next free posting slot. Leave it out to create a draft. |
external_ref | Your own reference, echoed back on the post and in Custom API deliveries. |
metadata | Free-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.
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}.
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": [
{
"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.
{
"error": {
"code": "validation",
"message": "Please fix the highlighted fields.",
"fields": {
"kind": "Use text, image or video."
}
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | The request can't be done as asked, such as scheduling a post that fails its checks. |
| 401 | unauthorized | The API key is missing, wrong or revoked. |
| 402 | plan_limit | The workspace's plan doesn't allow this; limit says which limit. |
| 403 | forbidden | The key lacks a scope or access to the project. |
| 404 | not_found | No such project, post, media or endpoint. |
| 409 | conflict | The change clashes with the current state, such as editing a post that's already publishing. |
| 422 | validation | One or more fields need fixing; see fields. |
| 429 | rate_limited | More than 120 requests a minute on this key. |
| 502 | provider_error | A 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.
| Event | Sent when |
|---|---|
post.created | A post was created (dashboard or API) |
post.scheduled | A post was scheduled |
post.approved | A post was approved |
post.published | A post went live on a channel |
post.failed | A post failed to publish on a channel |
channel.connected | A channel was connected or reconnected |
channel.disconnected | A channel lost its connection |
media.ready | An upload finished processing |
media.failed | An upload couldn't be processed |
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.
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);
}
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:
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" }
}
Authorization- Your API key. The header name and the prefix (
Bearerby default) are set on the channel. Idempotency-Key- The delivery id. A retry of the same delivery sends the same key, so you can skip work you've already done.
X-Amaposta-Delivery- The delivery id again, for your logs.
X-Amaposta-Signature- Signed like webhooks, with the channel's own signing secret.
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.
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.failedhandling. - 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. | |||||
| 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. | |||||
| 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. | |||||