# Posting API

> Post your own images and videos to your connected accounts over plain HTTPS, read each post's state and live link, get webhooks, and cancel what has not started.

> Index of these docs: https://wavegrain.com/developers/llms.txt

The posting API lets a program post images and videos it made itself to the social accounts connected to your Wavegrain account.
It is plain HTTPS and JSON under `https://wavegrain.com/api/v1`, described in the OpenAPI document at [/developers/openapi.json](/developers/openapi.json) and in the [API reference](/developers/api-reference).
It uses the same access keys as the MCP server.

## A person confirms every post

Nothing is posted the moment your program asks, unless the owner trusted your key (below).
A post made with an access key waits until the account owner confirms it in Wavegrain.
The owner gets a notification with a link; the answer to your request also carries that link as `confirmUrl`.
The owner sees the images or the video, and for each account its caption and every setting that reaches the network (first comment, who can see it, alt text, the AI label and the rest), and the time.
Then they choose "Confirm and post" or "Do not post".
A post nobody confirms expires 7 days after its time (or 7 days after you made it, with no time), and is never sent.
At most 50 posts can wait at once per account; the 51st answers `429`.

### Trusted keys

The account owner can trust one key in Wavegrain, Settings, AI agents ("Let it post without asking").
A post made with that key is sent at once, with no confirm, and its answer has `"status": "confirmed"`.
Only the owner can turn this on, only for a key with the post permission, and only for their own connected accounts.
A trusted key sends at most 20 posts in any 24 hours; past that, its post waits for the owner like any other.
Every post it sends is recorded with the key.
Trust covers only this API: posts the same key prepares in an AI chat still wait for the person's Post button.

TikTok posts go to the creator's TikTok inbox as drafts for now.
The creator picks who can see the post and finishes it in the TikTok app.

## Authentication

Send an access key in the `Authorization` header: `Authorization: Bearer wg_pat_...`.
Make the key in Wavegrain, Settings, Connect an AI agent, and tick the permissions it needs:

| Permission | Lets the key |
| --- | --- |
| `read` | List accounts and posts |
| `write` | Upload images and videos |
| `post` | Create and cancel posts |

A key in the address is never read.
OAuth tokens (`wg_oat_`) work on the MCP server only.
See [Authentication](/developers/authentication) for how keys expire and how to revoke one.

## Post an image in three steps

1. Start an upload. Send the file's type and size:

```bash
curl -X POST https://wavegrain.com/api/v1/media \
  -H "Authorization: Bearer $WAVEGRAIN_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"image","mime":"image/jpeg","bytes":482113}'
```

The answer has an `id` and an `uploadUrl`, which works once, for 10 minutes.

2. Upload the bytes with the same `Content-Type` and no `Authorization` header:

```bash
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg
```

3. Create the post. List the account ids from `GET https://wavegrain.com/api/v1/accounts`:

```bash
curl -X POST https://wavegrain.com/api/v1/posts \
  -H "Authorization: Bearer $WAVEGRAIN_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2026-11-07-morning-post" \
  -d '{"media":["UPLOAD_ID"],"caption":"Five morning habits","captions":{"threads":"Five habits. Which one is yours?"},"accounts":["ACCOUNT_ID"],"at":"2026-11-08T09:00:00+01:00"}'
```

The answer is the post with `"status": "awaiting_confirmation"` and its `confirmUrl`.
Leave out `at` to post when the owner confirms.

## What a post takes

- Up to 35 images and 20 accounts, on Instagram, Facebook, Threads, LinkedIn, Pinterest, TikTok.
- JPEG, PNG or WebP, at most 15 MB each. Wavegrain checks the file by its bytes and keeps it at most 2400 px on its long side.
- `caption` for every network, and `captions` for one network's own text. Each network's own length and hashtag limits apply.
- `options` per network: Instagram and Threads take `altTexts` and `firstComment`, Threads takes `topicTag`, Facebook takes `firstComment`, Pinterest needs `boardId` and `title`, TikTok takes `title`.
- `aiGenerated: true` when the images were made with AI, so the networks that label it do.
- A time within the next 30 days.

Every account is checked the way the send will check it: the network's own settings, how many images it takes, and the image sizes it takes (Instagram takes 4:5 and 1:1 only).
A refusal answers `400` with `details` naming the account, for example `"accounts.acc_123": ["Instagram takes only 4:5 or 1:1 images. Crop them first."]`.
Daily limits, holds and reconnects are checked again when the owner confirms; an account the confirm cannot post to shows `"state": "refused"` with its reason.
YouTube takes one video, never images.

## Post a video

A video goes to storage in parts, so a large file never has to be sent in one go, and a dropped connection repeats one part.

1. Start the upload with the exact size in bytes:

```bash
curl -X POST https://wavegrain.com/api/v1/media \
  -H "Authorization: Bearer $WAVEGRAIN_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"video","mime":"video/mp4","bytes":157286400}'
```

The answer has an `id` that starts with `video_`, a `partSize`, and `upload.parts`: one `{ partNumber, bytes, url }` for each part.
Every part but the last has exactly `partSize` bytes (64 MiB); a file of up to 64 MiB is one part.

2. PUT each part's bytes to its `url`, with no `Authorization` header.
Part `n` is the bytes from `(n - 1) x partSize`, and it must be exactly its `bytes` long: the address signs that size.

```bash
dd if=video.mp4 bs=67108864 skip=0 count=1 2>/dev/null | curl -X PUT "$PART_1_URL" --data-binary @-
```

A part that fails is sent again on its own.
Each address works for an hour; `GET https://wavegrain.com/api/v1/media/{id}` with the same key lists the parts still missing, with fresh addresses.
An upload not completed within a day is dropped.

3. Finish it: `POST https://wavegrain.com/api/v1/media/{id}/complete`.
Wavegrain joins the parts and checks the file by its bytes: an MP4 or MOV with H.264 or HEVC video.
The answer says `ready` with what it read (`video`: length, size, frame rate, codecs) and, in `networks`, whether each network takes it and if not, why: for example `"facebook": { "accepted": false, "reason": "Facebook takes videos of at least 540 x 960, and this one is 360 x 640." }`.
A part that is missing or the wrong size answers `409` with `details` naming it; the upload stays open.
A file no network takes is `rejected` and deleted.

4. Create the post with the video's id as the one item of `media`:

```bash
curl -X POST https://wavegrain.com/api/v1/posts \
  -H "Authorization: Bearer $WAVEGRAIN_KEY" -H "Content-Type: application/json" \
  -d '{"media":["video_..."],"caption":"Morning routine","accounts":["ACCOUNT_ID"],"cover":{"atMs":1500},"options":{"youtube":{"title":"Morning routine","privacy":"public","madeForKids":false}}}'
```

What each network takes, read from its own docs on 2026-11-07:

| Network | Length | File | Also |
| --- | --- | --- | --- |
| TikTok | up to 10 minutes | 4 GB | 23 to 60 frames a second, 360 to 4096 px a side; sent as a draft to the creator's TikTok inbox |
| Instagram | 3 seconds to 15 minutes | 300 MB | at most 1920 px wide, AAC audio, the index (moov) at the start of the file |
| Facebook | 3 to 90 seconds | 1 GB | at least 540 x 960, between 9:16 and 16:9, 24 to 60 frames a second, AAC audio |
| Threads | up to 5 minutes | 1 GB | turned off on Wavegrain for now |
| YouTube | up to 15 minutes | 2 GB | `options.youtube` needs a `title`, a `privacy` and `madeForKids`, with no defaults |

A cover is a time in the video (`cover.atMs`, which Instagram uses) or an image upload id (`cover.image`, which Facebook, YouTube and Instagram use).
TikTok inbox drafts take no cover.
Videos count toward a daily video upload budget of 10 GB and the plan's storage.
A video is kept 7 days, and longer while a post that waits or has not been sent uses it.
`DELETE https://wavegrain.com/api/v1/media/{id}` stops an upload or deletes a video, unless a post still needs it.

## Read a post's state

`GET https://wavegrain.com/api/v1/posts/{id}` answers the post.
For a post made through the API:

- `status` is `awaiting_confirmation`, `confirmed`, `cancelled` or `expired`.
- `targets` lists every account with its `state`: `awaiting_confirmation`, `refused`, `cancelled`, `expired`, or `delivered` with its `deliveryId`.
- `deliveries` lists each account's post after the confirm, with its `status`, the `label` Wavegrain shows, a `reason` when it failed, and its `link`.

A delivery is `queued`, `preparing` or `sending` before the network has it, `processing` while the network works on it, `action_required` while it waits in the TikTok app, and `published`, `failed` or `cancelled` when it ends.
`outcome_unknown` means Wavegrain could not confirm it; check the account before you post again.

`link` is the post's address on the network once it is published.
On YouTube and LinkedIn it is built from the post's id, in the form each network documents.
On TikTok, Instagram, Facebook and Threads it is the address the network itself gives, which Wavegrain reads once a day, so it can be `null` for up to a day after the post.
It is `null` before the post is published, after it is deleted, and on a network that gives no address.

## Webhooks

Instead of reading a post again and again, add an https address in Wavegrain, Settings, Webhooks.
Wavegrain then sends it a signed event when a post on one account is published, fails, cannot be confirmed, or waits in the network's app.
See [Webhooks](/developers/webhooks).

`GET https://wavegrain.com/api/v1/posts` lists your posts, newest first, in a window of at most 100 days (`from`, `to`), 100 a page at most, with `nextCursor` for the next page.
It also lists the slideshows you posted from Wavegrain; their `source` is `slideshow`.
`status=awaiting_confirmation` lists the posts that wait for the owner.

## Cancel a post

`DELETE https://wavegrain.com/api/v1/posts/{id}` cancels a post made through the API.
A post that waits is never sent.
For a confirmed post, every delivery that has not started is cancelled, and `alreadyStarted` names the ones that had.

## Write captions

`POST https://wavegrain.com/api/v1/captions` writes one caption per network, from a `topic`, your own `text`, or both. It needs the `generate` permission.

```json
{ "topic": "A morning skincare routine", "networks": ["instagram", "tiktok"], "emoji": "auto" }
```

The answer is `{ "captions": { "instagram": "...", "tiktok": "..." }, "creditsCharged": 1 }`.
Each caption is written in your brand's voice (`brandId`, or your active brand) and fits that network's length limit, and Instagram's hashtag limit.
A request costs 1 credit and counts against your access key's daily credit limit; an account may make at most 200 requests in any 24 hours. A request that fails is refunded and answers `503`.
Nothing is posted: put the captions in `POST /posts`.

## Retry safely

Send an `Idempotency-Key` header with each `POST /posts`: 1 to 255 characters of your own.
A retry with the same key and body answers the first result for 24 hours, with `Idempotent-Replayed: true`, and makes no second post.
The same key with another body answers `409`.

## Limits and errors

Every answer carries `X-Request-Id` and `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`.
A refusal is JSON: `{ "error": { "code", "message", "details", "requestId" } }`.
Quote the `requestId` when you ask for help.
The limits, the codes and what to do about each are on [Limits and errors](/developers/limits-and-errors).
