Automating posts with a post-scoped key
Server-side publishing needs a narrower key than generation, plus its own rate limit. Scope a key to posting and pace it so a bug cannot generate.
A generate-scoped key in a posting job is not a shortcut. It is a bug with credentials. The publish call lives on /social/posts and needs the post scope. The generate call lives on /generate and needs generate. Those prefixes are owned by different scopes on purpose, so a loop that misfires captions cannot also fire video jobs.
Server-side publishing is a narrow integration: HTTPS media you already have, a caption, one or more connected account ids, optional schedule. Give it a key that can do that and nothing else, then pace it against the posting limiter rather than against your generate limiter. The rest of this post is that integration, not a tour of every platform's composer.
The post scope, and what it is not
Create it from a session JWT at POST /api/v1/auth/api-keys with a name, a scopes array, and an optional rate_limit_rpm. Empty scope arrays are rejected. "all" expands to the eight named scopes and is stored as that list, which is the opposite of what you want on a publisher.
For this job the array is ["post"]. Add read only if the worker has to look up library URLs before it posts. Do not add generate. Do not add manage_accounts. Do not add workflows.
Why the last two stay off:
manage_accountsowns/social/accountsand/social/auth-url. Connecting, refreshing, and disconnecting accounts is a setup task for a person. A publisher that can disconnect the destination it is supposed to post to is a recovery incident waiting for a bad deploy.workflowsowns saved recipes and runs. Auto-posting from a scheduled workflow is a different product path, with its ownauto_postflags, covered in scheduled workflows. A headless poster that only needsPOST /social/postsshould not be able to start a 28-scene render.
The raw key is returned once. Store it as a secret. Identify it later by key_prefix. Revoke with DELETE /api/v1/auth/api-keys/:keyId. Set expires_at if the worker is temporary.
Account connection itself is not this key's problem. A person connects Instagram, TikTok, YouTube, X, Facebook, LinkedIn, Pinterest, Bluesky, and Threads through the connect flow, one account at a time. The nine-platform publishing guide is the setup. This key only ever sees account ids that already exist.
The request you actually send
POST /api/v1/social/posts
Authorization: Bearer vsk_...
Content-Type: application/json
{
"caption": "Tuesday's matcha pour. Same kitchen, new mistake.",
"media_urls": ["https://cdn.example.com/matcha-take-04.mp4"],
"account_ids": ["3f2c1a8e-…"],
"scheduled_at": "2026-08-21T14:00:00.000Z"
}
Rules the handler will enforce:
captionis required.account_idsis a non-empty array of your account row ids, not platform usernames. Ownership is checked. An id that is not yours, or is inactive, does not post.scheduled_atomitted means send now. Present means hold until that timestamp.is_draft: truewrites a draft record and does not publish. A draft is not a publish, so it is not charged.tiktok_draft: truemarks the TikTok destination as an in-app draft for the TikTok accounts in the set.
media_urls is optional. Use publicly reachable HTTPS URLs; the destination platforms fetch them. Multiple image URLs are a carousel (Versely will send up to 20 to Instagram). A video is one URL.
The response carries the post id, the platforms it was aimed at, the schedule, and credits_charged. Status on the way out is processing for an immediate send, scheduled for a hold, draft for a draft. Terminal states later are posted, partial, or failed.
List what you have queued with GET /api/v1/social/posts?limit=20&offset=0. The list (and not the create payload) is the source of the Versely row id for GET /api/v1/social/posts/:postId and DELETE /api/v1/social/posts/:postId. The agent versions of those calls, if a person is driving instead of a worker, are post or schedule and manage scheduled posts.
A preview that does not send is POST /api/v1/social/preview. It shares the posting rate limit. Use it when you are wiring the client. Do not use it as a substitute for posting.
Pacing: ten per minute, two credits per destination
Two numbers govern this worker, and they are not the generate numbers.
Credits. Each destination account costs 2 credits, charged when the post is created, not per retry you invent on your side. Three account ids is 6 credits. A draft is 0. If the upstream publish fails, the charge is refunded. If you fan one video to nine platforms, you are paying nine times 2, not 2.
Rate limit. Posting and preview are 10 requests per minute per user. Account refresh is 5. Those are separate from the per-key RPM (default 60, clamp 1 to 1,000) and from the generate limiter (30 per minute). A 429 body includes retryAfter in seconds, plus X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Sleep that many seconds. Do not retry in a tight loop.
Practical pacing for a worker:
- Build the caption and the HTTPS URLs before you touch
/social/posts. Generation belongs to a different key, in a different process, already finished. - Post one account-set per call. If TikTok and LinkedIn need different captions, that is two calls, not one call with a caption that is wrong on one of them.
- Cap the key's
rate_limit_rpmwell under 10 if the worker is a cron that should never burst. The user-level posting limiter is the ceiling; a lower per-key RPM is how you make a bug slow instead of loud. - Treat
scheduled_atas the pacing tool for the calendar, and the 10/min limiter as the pacing tool for the process. Do not schedule 80 posts by firing 80 requests in a second and hoping the limiter "queues" them. It does not queue. It 429s.
There is no free posting allowance and no trial key. Credits come from the same balance as everything else; the pricing page is the source for plans and packs. A publisher with an empty balance fails at charge time, which is preferable to a publisher that generates a consolation clip because it also had generate.
What the worker must not do
Keep generation, account setup, and publishing as three credentials even if they share a codebase.
| Job | Scope | Prefix it may call |
|---|---|---|
| Render the asset | generate |
/generate, /movie, /features only if you also granted read |
| Connect a destination | manage_accounts |
/social/accounts, /social/auth-url |
| Publish or schedule | post |
/social/posts, /social/preview |
| Read performance | analytics |
/social-analytics |
A single "all" key collapses that table into one secret. Convenient for a laptop. Indefensible in a worker.
The media has to exist before the post call. That usually means a generate worker wrote an HTTPS URL into a queue, and the poster read it. If you are tempted to "just call the agent" from the poster so it can make a clip when the URL is missing, you are putting /agentic (a read prefix) on a publishing credential and giving a caption bug a generate path. Fail the job instead. Let the generate worker retry.
Platform mix is a content decision, not a key decision. One workflow blasting the same cut to TikTok and LinkedIn is how you get a vertical joke on a professional feed. The posting API will happily send it. Split captions and, when the cut itself has to change, split the generate job too. The key does not protect you from a bad brief. It only protects you from a brief that can also render.
FAQ
Can this key connect a new TikTok account when a token expires?
No, not if you minted it with post only. Token refresh and reconnect live under manage_accounts. Expired tokens are the common reason a scheduled post does not go out; they are a person-in-the-loop problem, not something to paper over by widening the publisher.
Does a scheduled post charge now or at send time?
At creation. credits_charged in the create response is the amount already taken. A draft does not charge. Deleting a still-scheduled post stops it from sending; the delete does not refund the credits charged when the post was created.
I got 403 API key lacks required scope: post. I passed a generate key on purpose. Why?
Because /social/posts is owned by post, matched by longest prefix. A generate key is working correctly when it refuses to publish. Mint a second key. Put only post on it. Point the worker at that one.
Can I post from MCP instead of this key?
Yes, as a person in a conversation. The agent tool post_to_social_media is the interactive version of the same call, and it still costs 2 credits per platform. For a cron, a queue consumer, or another product posting on someone's behalf, pin to POST /api/v1/social/posts with a post key. The developer page is the HTTP entry; the agent is the conversational one.