Workflows

    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.

    Versely Team8 min read

    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_accounts owns /social/accounts and /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.
    • workflows owns saved recipes and runs. Auto-posting from a scheduled workflow is a different product path, with its own auto_post flags, covered in scheduled workflows. A headless poster that only needs POST /social/posts should 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:

    • caption is required.
    • account_ids is 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_at omitted means send now. Present means hold until that timestamp.
    • is_draft: true writes a draft record and does not publish. A draft is not a publish, so it is not charged.
    • tiktok_draft: true marks 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:

    1. Build the caption and the HTTPS URLs before you touch /social/posts. Generation belongs to a different key, in a different process, already finished.
    2. 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.
    3. Cap the key's rate_limit_rpm well 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.
    4. Treat scheduled_at as 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.