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:

PermissionLets the key
readList accounts and posts
writeUpload images and videos
postCreate 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

  1. 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.

  1. Upload the bytes with the same Content-Type and no Authorization header:
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg
  1. 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.
  • 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:
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.

  1. 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.
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.

  1. 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.

  2. 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:

NetworkLengthFileAlso
TikTokup to 10 minutes4 GB23 to 60 frames a second, 360 to 4096 px a side; sent as a draft to the creator's TikTok inbox
Instagram3 seconds to 15 minutes300 MBat most 1920 px wide, AAC audio, the index (moov) at the start of the file
Facebook3 to 90 seconds1 GBat least 540 x 960, between 9:16 and 16:9, 24 to 60 frames a second, AAC audio
Threadsup to 5 minutes1 GBturned off on Wavegrain for now
YouTubeup to 15 minutes2 GBoptions.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.

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.