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.
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 and in the 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 for how keys expire and how to revoke one.
Post an image in three steps
- Start an upload. Send the file's type and size:
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.
- Upload the bytes with the same
Content-Typeand noAuthorizationheader:
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg
- Create the post. List the account ids from
GET https://wavegrain.com/api/v1/accounts:
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.
captionfor every network, andcaptionsfor one network's own text. Each network's own length and hashtag limits apply.optionsper network: Instagram and Threads takealtTextsandfirstComment, Threads takestopicTag, Facebook takesfirstComment, Pinterest needsboardIdandtitle, TikTok takestitle.aiGenerated: truewhen 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.
- Start the upload with the exact size in bytes:
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.
- PUT each part's bytes to its
url, with noAuthorizationheader. Partnis the bytes from(n - 1) x partSize, and it must be exactly itsbyteslong: the address signs that size.
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.
-
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 saysreadywith what it read (video: length, size, frame rate, codecs) and, innetworks, 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 answers409withdetailsnaming it; the upload stays open. A file no network takes isrejectedand deleted. -
Create the post with the video's id as the one item of
media:
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 |
| 3 seconds to 15 minutes | 300 MB | at most 1920 px wide, AAC audio, the index (moov) at the start of the file | |
| 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:
statusisawaiting_confirmation,confirmed,cancelledorexpired.targetslists every account with itsstate:awaiting_confirmation,refused,cancelled,expired, ordeliveredwith itsdeliveryId.deliverieslists each account's post after the confirm, with itsstatus, thelabelWavegrain shows, areasonwhen it failed, and itslink.
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.
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.
{ "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.
Something wrong or missing? Write to support@wavegrain.com.