# apublished

> Publish to social platforms on a schedule. Connect accounts once, then compose
> content once and publish it to N platforms at chosen times, with per-platform
> validation before send and a durable record of what went out.

Agents and humans use the same API. Everything the web app can do is a documented
endpoint.

## Start here

- [Full API reference](https://apublished.com/llms-full.txt): the whole API as one flat file.
- [OpenAPI 3.1](https://apublished.com/v1/openapi.json): plain JSON, no auth, no JavaScript.
- [Recipes](https://apublished.com/docs/agents/recipes.md): connect a channel, schedule a video,
  handle a reauth, recover missed webhooks, batch a week.
- [Errors](https://apublished.com/docs/agents/errors.md): every code, its cause, its remediation.

## Per-platform briefs

- [Simulator](https://apublished.com/docs/agents/simulator.md): limits, required settings, what people get wrong.

## MCP

Streamable HTTP at `https://apublished.com/mcp`, the same
operations as tools. Authenticate with the same key.

## The three things worth knowing before you write any code

1. **Validate before you commit.** `POST /v1/posts:validate` has no side effects and
   returns the exact violations with actual and allowed values. Iterating there is
   free; a rejected post is not.
2. **Send an Idempotency-Key on every mutation, and reuse it when you retry.** The
   same key with the same body returns the original response rather than posting
   twice.
3. **Publishing is asynchronous everywhere.** `t_dispatch` is when the platform
   accepted the call — that is what we guarantee, within ±30 seconds. `t_live` is
   when it becomes visible, which is the platform's business and can take minutes.


---

# Recipes

Every example uses `Authorization: Bearer ap_live_…`. Base URL `https://apublished.com`.

## Connect a channel

You cannot complete OAuth yourself. Start a session, hand the URL to a human, poll.

```bash
curl -X POST https://apublished.com/v1/channels:connect \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"provider":"simulator"}'
```

```jsonc
{ "id": "…", "state": "pending", "url": "https://…", "pollUrl": "https://apublished.com/v1/connect-sessions/…" }
```

Poll `pollUrl` until `state` is not `pending`:

- `completed` — done, `channels` lists what was connected.
- `awaiting_subaccount` — the human must choose which Page or organization.
  Post the ids from `subAccountChoices` to `/v1/connect-sessions/{id}:select`.
- `abandoned` — they walked away. Sessions expire after 15 minutes.
- `failed` — they declined, or the platform refused. `error` says which.

## Schedule a post

Validate first. It costs nothing and it is the difference between an agent that
iterates and one that guesses.

```bash
curl -X POST https://apublished.com/v1/posts:validate \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "body": { "items": [{ "text": "Hello" }] },
    "targets": [{ "channelId": "…", "settings": { } }]
  }'
```

Then create, with an idempotency key you can reuse on retry:

```bash
curl -X POST https://apublished.com/v1/posts \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "body": { "items": [{ "text": "Hello" }] },
    "targets": [{ "channelId": "…", "settings": { } }],
    "scheduleMode": "AT",
    "scheduledAt": "2026-09-01T09:00:00Z"
  }'
```

## Post a video

Upload first, so the file is validated and probed before it matters. Videos are
never transcoded — a file the platform will not take is refused with a reason
rather than silently re-encoded.

```bash
# 1. get a presigned URL
curl -X POST https://apublished.com/v1/media -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"contentType":"video/mp4"}'

# 2. PUT the bytes straight to the returned uploadUrl (not through this API)
curl -X PUT "$UPLOAD_URL" --data-binary @video.mp4 -H "Content-Type: video/mp4"

# 3. tell us it is there
curl -X POST https://apublished.com/v1/media/$ASSET_ID:complete -H "Authorization: Bearer $KEY"

# 4. poll until status is READY, then reference assetId in a post
curl https://apublished.com/v1/media/$ASSET_ID -H "Authorization: Bearer $KEY"
```

Or hand us the URL and skip all of it:
`POST /v1/media:from-url` with `{"url":"https://…"}`.

## Handle a channel that needs reconnecting

This is routine, not exceptional. Do not treat it as an error state to retry.

```bash
curl https://apublished.com/v1/channels?status=NEEDS_REAUTH -H "Authorization: Bearer $KEY"
curl -X POST https://apublished.com/v1/channels/$ID:reauth -H "Authorization: Bearer $KEY"
```

Give the returned `url` to a human. The channel keeps its id, its schedule and its
queue when they finish.

## Recover after downtime

Webhooks get missed and agents restart. Do not walk every post — read the change
feed from the last cursor you saw:

```bash
curl "https://apublished.com/v1/changes?since=$CURSOR" -H "Authorization: Bearer $KEY"
```

Cursors are monotonic and retained for 30 days.

## Batch a week

Ask for free slots rather than inventing times: they respect the channel's own
schedule and skip slots that are already taken.

```bash
curl "https://apublished.com/v1/channels/$ID/slots?n=7" -H "Authorization: Bearer $KEY"
```

Or let create do it in one call with `"scheduleMode": "NEXT_SLOT"`. Add
`"randomizeQueueTime": true` for a ±10 minute jitter so the cadence does not look
automated.

## Draft for a human to approve

Use a key with `posts:write` but NOT `posts:publish`. Then `scheduleMode: "NOW"`
is refused, and everything the agent creates waits for a person. That is the whole
approval workflow — no extra API.


---

# Errors

Every error is `application/problem+json` with a stable machine `code`. Branch on
`code`, never on `title` or on the HTTP status alone — several codes share a status
and mean entirely different things.

```jsonc
{
  "type": "https://apublished.com/docs/agents/errors#validation_failed",
  "title": "The post is not valid for one or more channels",
  "status": 422,
  "code": "validation_failed",
  "detail": "…",
  "violations": [ /* per-channel, with actual and allowed values */ ],
  "remediation": "…",          // what to do, machine-readable
  "context": { /* facts specific to this error */ }
}
```

### `unauthorized`

**HTTP 401** — Authentication required

Send `Authorization: Bearer ap_live_…`. Keys are created in the dashboard or via `POST /v1/keys`.

### `invalid_key`

**HTTP 401** — API key is invalid or revoked

The key does not exist or was revoked. Do not retry — get a new key.

### `key_expired`

**HTTP 401** — API key has expired

The key passed its `expiresAt`. Do not retry — mint a new one.

### `insufficient_scope`

**HTTP 403** — The key lacks the required scope

The key lacks a scope this call needs; `detail` names which. Note that `posts:write` without `posts:publish` can create scheduled posts and drafts but cannot publish immediately — that is the approval workflow, not a bug.

### `channel_not_permitted`

**HTTP 403** — The key is not scoped to this channel

The key is scoped to specific channels and this is not one of them. Use a broader key, or a channel it covers.

### `channel_reauth_required`

**HTTP 409** — The channel must be reconnected

The channel needs a human to reconnect it. Call `get_reauth_url` (MCP) or `POST /v1/channels/{id}:reauth` and give the URL to a person. This is routine — LinkedIn tokens expire every 60 days and cannot be refreshed programmatically.

### `channel_disabled`

**HTTP 409** — The channel is disabled

The channel was disconnected. Reconnect it before scheduling to it.

### `channel_paused`

**HTTP 409** — The channel is paused

The channel is paused. Posts to it are held rather than published. Resume it to continue.

### `channel.has_queue`

**HTTP 409** — The channel has scheduled posts

Disconnecting would cancel scheduled posts. `context` carries the count and the next due time. Re-send with `queue: "cancel_queued"` to accept that, or leave the channel connected and pause it instead.

### `validation_failed`

**HTTP 422** — The post is not valid for one or more channels

One or more channels rejected the post, and NOTHING was created. Each violation carries `actual`, `limit` and often `remediation`. Fix and resubmit — or call `:validate` first next time, which has no side effects.

### `settings.unknown_field`

**HTTP 422** — Unknown provider setting

A provider setting does not exist. Check `didYouMean` — it is usually a near-miss — and `schemaUrl` for the full settings schema.

### `content_rejected_by_platform`

**HTTP 422** — The platform rejected this content

The platform refused the content itself. This is terminal: retrying sends the same thing again.

### `media_rejected`

**HTTP 422** — The platform rejected this media

The platform refused the media. Check the size, duration and aspect-ratio limits for that provider.

### `post.locked`

**HTTP 409** — The post is already staged with the platform

The post is already staged with the platform — media uploaded, container created — so its content can no longer change. `context.allowed` lists what you can still do; normally that is cancel and recreate. Check `locksAt` on a target BEFORE attempting an edit rather than discovering this.

### `not_found`

**HTTP 404** — No such endpoint

Nothing is served at that URL. Check the path against `GET /v1/openapi.json`; this is not the same as a post or channel that does not exist, which have their own codes.

### `post_not_found`

**HTTP 404** — No such post

No such post in this tenant. Another tenant's id looks identical to a missing one, deliberately.

### `channel_not_found`

**HTTP 404** — No such channel

No such channel in this tenant.

### `request_too_large`

**HTTP 413** — The request body is too large

The body is larger than the API accepts. Media never travels through this endpoint — upload it with `POST /v1/media`, which presigns a direct upload, and reference the asset id instead.

### `rate_limited`

**HTTP 429** — Too many requests

Too many requests. Wait for `retryAfter` seconds. Do not retry sooner — it makes the wait longer.

### `quota_exhausted`

**HTTP 429** — The platform quota for today is exhausted

The platform quota for today is committed. Quotas are reserved at SCHEDULE time, so this means the day is genuinely full. Schedule for tomorrow, or check `GET /v1/channels/{id}/limits` first.

### `plan.limit_reached`

**HTTP 402** — The plan limit has been reached

The subscription limit was reached. `context` carries the current count and the ceiling.

### `idempotency_key_reused`

**HTTP 409** — This Idempotency-Key was used with a different body

The same Idempotency-Key was used with a DIFFERENT body. That is a bug in the caller: either reuse the key with the identical body to replay, or use a new key.

### `bad_request`

**HTTP 400** — The request could not be parsed

The request did not match the schema. `violations` names the fields.

### `internal`

**HTTP 500** — Something went wrong on our side

Something failed on our side. Retry with backoff; the Idempotency-Key makes that safe.



---

# Simulator

Everything below is generated from the live capability manifest, so the numbers are
the ones the validator actually enforces. Machine-readable version:
[`GET /v1/capabilities/simulator`](https://apublished.com/v1/capabilities/simulator).

## What you can post

| | |
|---|---|
| Text | up to **2200** characters (counted as graphemes, so an emoji is 1) |
| Threads | yes, up to 10 items |
| First comment | yes |
| Links | detected automatically |
| Images | up to 10, 8MB each, image/jpeg / image/webp — **JPEG required**, we convert PNG for you |
| Video | 1 per post, up to 4GB, 0m01s to **10 minutes** |
| Carousels | 2–10 items, images and video cannot be mixed |

## Settings

| Field | Values | | |
|---|---|---|---|
| `visibility` | `PUBLIC`, `FOLLOWERS`, `SELF_ONLY` | **required, no default** — you must choose | Who can see the post once it is live. |
| `allowComments` | `boolean` | optional |  |
| `scheduledNote` | `string` | optional |  |

Full JSON Schema: [`GET /v1/capabilities/simulator`](https://apublished.com/v1/capabilities/simulator).
Send an unknown field and you get a **422 with `didYouMean`** rather than a silent drop —
so a typo is something you can learn from rather than a setting that quietly did nothing.

## Timing

Publishing here is **asynchronous: we get a job id and poll it**.

Two different moments, and the difference matters:

- **`dispatchedAt`** — the platform accepted our call. We guarantee this within
  **±30 seconds** of the time you asked for.
- **`liveAt`** — the platform says it is visible. Typically text 0m02s, image 0m05s, carousel 0m08s, video 1 minutes. This is the platform's business, not ours, and we report it rather
  than promise it.

Schedule a video for 18:00 and we start uploading before then, so it is *live* at
18:00 rather than starting to upload at 18:00.

## Limits

- **6 per 1 minutes** per account (`publish`)
- **10000 per 1440 minutes** per application (shared across all customers) (`publish`)

- **100 per 1440 minutes** per account (`published`, hard)
- **10000 per 1440 minutes** per application (`units`, hard) — Mirrors YouTube: verification reads draw from this, uploads do not.

Hard quotas are **reserved when you schedule**, not when we publish — so you find out immediately rather than tomorrow morning. Check `GET /v1/channels/{id}/limits` before a batch.

## Authentication and how it expires

Access tokens last **1 days**. The refresh token **rotates**: each refresh invalidates the previous one. We handle this under a per-account lock, which is why two concurrent publishes cannot orphan the account.

## After publishing

You can change: **text, visibility** via `POST /v1/posts/{id}:update-published`.
The post can be deleted via `POST /v1/posts/{id}:delete-published`; we keep the local record with `remoteDeletedAt` set.

## Compliance

- The simulator never contacts a real network. Posts exist only as rows in sim_remote_posts.
- Free-tier `ap_test_` keys may publish here without limit; they may not reach a real provider.


---
