API docs

One endpoint. Every platform.

A JSON REST API and an MCP server over the same account. Send one request; we fan it out to X, Instagram, TikTok, LinkedIn, and YouTube, on your schedule. Everything below is copy-pasteable.

01 — Authentication

Every request carries a bearer key

Create a key on your dashboard — it starts with pn_ and is shown once, so store it somewhere safe. Each key is scoped to a single organization: it can only see and act on that organization's profiles, media, and posts. Rotate or revoke a key at any time and it stops working immediately.

Authorization header
curl -X POST https://publishnow.app/v1/posts \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"content": "Hello!", "profiles": ["my-x-handle"]}'

Send the key over HTTPS only, from your server. Never ship it in a browser bundle or a mobile app.

02 — Publish a post

POST /v1/posts

One call creates one post per profile you name. content and profiles are required; everything else is optional. Omit schedule_at to publish right away, or pass an ISO 8601 timestamp to queue it.

request
curl -X POST https://publishnow.app/v1/posts \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from Publish Now!",
    "profiles": ["my-x-handle", "my-ig-handle"],
    "schedule_at": "2026-08-01T09:00:00Z",
    "media_ids": ["a1b2c3d4-..."],
    "post_types": { "instagram": "reel" },
    "title": "Optional YouTube title"
  }'

Request fields

  • content — the post body. Required.
  • profiles — array of profile slugs. Required.
  • schedule_at — ISO 8601 datetime. Publishes now when omitted.
  • media_ids — array of confirmed media IDs.
  • post_types — per-platform overrides, e.g. reel or story on Instagram.
  • title — YouTube title, 100 characters max.

Response fields

  • posts[].id — the post ID to track it by.
  • posts[].profile — the profile slug it was created for.
  • posts[].connector — the destination platform.
  • posts[].status — where it is in the pipeline.
  • posts[].schedule_at — when it goes out.
  • posts[].created_at — when we accepted it.
201 Created
HTTP/1.1 201 Created
X-Credits-Remaining: 480

{
  "posts": [
    {
      "id": "01J...",
      "profile": "my-x-handle",
      "connector": "x",
      "content": "Hello from Publish Now!",
      "status": "scheduled",
      "schedule_at": "2026-08-01T09:00:00Z",
      "created_at": "2026-07-26T11:04:22.104Z",
      "media_ids": ["a1b2c3d4-..."],
      "post_type": "tweet"
    }
  ]
}

03 — Upload media

Presign, upload, confirm

Big files never pass through the API. You ask for a short-lived upload URL, push the bytes straight into object storage, then confirm. Pass a content_hash (SHA-256, lowercase hex) and we deduplicate: if the same bytes are already in your library you get back duplicate: true with the existing media_id and no upload to do.

POST /v1/media/presign
# 1. Ask for an upload URL
curl -X POST https://publishnow.app/v1/media/presign \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "launch.jpg",
    "mime_type": "image/jpeg",
    "size_bytes": 184320,
    "content_hash": "9f86d081...64 hex chars"
  }'

# 201 Created
{ "media_id": "a1b2c3d4-...", "upload_url": "https://...", "expires_in": 3600 }
PUT the file, then POST /v1/media/{id}/confirm
# 2. PUT the bytes straight at the returned URL
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @launch.jpg

# 3. Confirm — the file is only usable after this
curl -X POST https://publishnow.app/v1/media/a1b2c3d4-.../confirm \
  -H "Authorization: Bearer pn_your_key_here"

# 200 OK
{
  "media_id": "a1b2c3d4-...",
  "type": "image",
  "mime_type": "image/jpeg",
  "size_bytes": 184320,
  "name": "launch.jpg",
  "description": "",
  "tags": "",
  "created_at": "2026-07-26T11:02:00.000Z"
}

Legacy multipart upload

Posting a multipart/form-data body to POST /v1/media still works and still returns a media_id. It is kept for backward compatibility only — it is slower and caps out on file size, so use the presign flow for anything new.

Limits on the presign flow: 50 MB per image, 50 MB per GIF, 1 GB per video. The legacy multipart endpoint above accepts the same images and GIFs but caps video at 100 MB. Upload URLs expire after one hour. A media file is only usable in media_ids once it has been confirmed.

04 — Source images

Find a photo. Get a media_id.

Two calls and no files. Search licensed photos, keep the ones you want, and get back the same media_id that POST /v1/posts already takes. Nothing lands on your disk, nothing is uploaded from it, and the alt text and any required credit come back with the image.

Searching is free and creates nothing, so look at as many photos as you like. Only the second call spends anything — and only for the images that actually land.

POST /v1/media/stock/search
# 1. Search. Creates nothing, saves nothing, costs nothing.
curl -X POST https://publishnow.app/v1/media/stock/search \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "query": "morning coffee shop", "preset": "square", "count": 6 }'

# 200 OK
{
  "provider": "unsplash",
  "preset": "square",
  "query": "morning coffee shop",
  "results": [
    {
      "source_ref": "Qw3s7dK1a",
      "provider": "unsplash",
      "preview_url": "https://...",
      "mime_type": "image/jpeg",
      "width": 1440,
      "height": 1440,
      "alt_text": "Steam rising from a cup on a wooden counter",
      "attribution": null
    }
  ]
}
POST /v1/media/source
# 2. Keep the ones you want. This is the call that creates and charges.
curl -X POST https://publishnow.app/v1/media/source \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "source_refs": ["Qw3s7dK1a", "Lm9x2ptRb"],
    "preset": "square",
    "destination": "instagram-api",
    "query": "morning coffee shop"
  }'

# 200 OK
{
  "kind": "media",
  "run_id": "7f4e9c22-...",
  "status": "ready",
  "requested_count": 2,
  "delivered_count": 2,
  "media_ids": ["a1b2c3d4-...", "e5f6a7c8-..."],
  "media": [
    {
      "media_id": "a1b2c3d4-...",
      "alt_text": "Steam rising from a cup on a wooden counter",
      "provider": "unsplash",
      "source_ref": "Qw3s7dK1a",
      "attribution": null
    }
  ],
  "failures": []
}
POST /v1/posts
# 3. Post them. The same media_ids field you already use.
curl -X POST https://publishnow.app/v1/posts \
  -H "Authorization: Bearer pn_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "New beans landed this morning.",
    "profiles": ["my-ig-handle"],
    "media_ids": ["a1b2c3d4-...", "e5f6a7c8-..."]
  }'

# 201 Created — no file ever touched your disk.

Sizes

  • square — 1:1. The default, and publishable everywhere.
  • portrait — 4:5. The tallest a feed post can be.
  • landscape — 1.91:1. A header, not a scene.
  • story — 9:16. X only; Instagram's feed will not take it.

Request fields

  • query — what the photo should show. Required on search.
  • source_refs — the picks you are keeping. One to ten per call.
  • providerunsplash or pexels. Defaults to unsplash.
  • preset — one of the sizes on the left.
  • destination — where you are headed. Checked before anything is charged.

Name the destination and we check it first

Pass destination and a size that cannot reach it is refused before a single photo is fetched or billed. The refusal names the destinations that size does reach, so you can pick another one and move on.

400 Bad Request
{
  "error": "\"Story\" is 9:16 (0.56), outside Instagram's feed aspect window of 4:5 to 1.91:1 (0.80-1.91). The \"story\" size publishes to: x.",
  "code": "PRESET_DESTINATION",
  "valid_for": ["x"]
}

Credits and credit lines

You are charged per image delivered, never per image requested. A photo that fails to save drops out of the set, the response lists it under failures, and you are not billed for it. Unsplash asks for no credit; Pexels requires the photographer's name and a link, and both arrive in attribution so you can render them.

Read kind to know which answer you got. "media" means the images exist now and media_ids is populated — every photo search answers this way. "run" means they are still being made and run_id is the handle to poll.

05 — Credits and errors

Read the header. Trust the code.

Every successful POST /v1/posts comes back with an X-Credits-Remaining header, so your client always knows where it stands without a second call. Drop below the low-credit threshold and the response body also carries a warning.

low-credit warning
{
  "posts": [ ... ],
  "warning": { "code": "low_credits", "remaining": 12 }
}

Hard freeze at zero

Credits are consumed when a post actually goes out. Hit zero and publishing stops — queued posts are marked failed with insufficient_credits instead of silently degrading or posting a truncated version. Top up and re-send them. If the organization behind the key has no active plan at all, every call to /v1 returns 402 before anything is created.

Error envelope

Every failure returns the same two-field shape. Branch on code, which is stable; error is a human-readable message and may change.

400 Bad Request
{
  "error": "content is required",
  "code": "VALIDATION_ERROR"
}
Status Code When
400 VALIDATION_ERROR A required field is missing or malformed — content, profiles, schedule_at, title, filename, mime_type, size_bytes.
400 INVALID_MEDIA One or more media_ids do not exist in your library.
400 INVALID_MEDIA_COMBINATION The attachments break a platform rule (for example more than four images on X).
400 UNSUPPORTED_MEDIA_TYPE The mime_type sent to /v1/media/presign is not on the allowed list.
400 FILE_TOO_LARGE size_bytes exceeds the per-type limit: 50 MB images, 50 MB GIFs, 1 GB video (100 MB video on the legacy multipart endpoint).
400 FILE_NOT_UPLOADED You confirmed before the bytes finished landing in storage.
400 FILE_SIZE_MISMATCH The stored object size does not match the declared size_bytes.
400 UNKNOWN_PRESET The size named in a sourcing call is not one we offer. The message lists the ones that are.
400 UNKNOWN_PROVIDER The photo source named in a sourcing call does not exist.
400 PRESET_DESTINATION The size you chose cannot publish to the destination you named. The response carries valid_for — the destinations that size does reach.
400 STOCK_NOT_CONFIGURED That photo source is not switched on for this account. Use the other one.
401 UNAUTHORIZED Missing, malformed, or revoked key — or a key that does not start with pn_.
402 SUBSCRIPTION_REQUIRED The key's organization has no active or trialing plan.
402 INSUFFICIENT_CREDITS Not enough credits for the number of images asked for. Nothing was created and nothing was charged.
404 NOT_FOUND The media record does not exist, or it was already confirmed.
429 RATE_LIMITED The photo source limits how often it can be searched and we reached that limit. Try again in a few minutes.
502 PROVIDER_ERROR The photo source did not answer, or answered with something we could not use. Nothing was saved and nothing was charged.
502 SOURCE_FAILED Every chosen photo failed to save. Nothing was charged.

06 — MCP server

The same account, wired into your agent

We host a Model Context Protocol server at https://publishnow.app/mcp. Point Claude, Cursor, or any MCP client at it and authorize once in the browser — there is no API key to paste, and the client is granted only the scopes you approve (posts:read, posts:write, media:write, generate:read, generate:write).

Your agent already writes. What it cannot do on its own is write in your voice, avoid repeating what you published last week, and fit the real character limit of the account you are posting from. Those three things are what generate:read hands over — useful on their own, before you run a single generation.

claude_desktop_config.json
{
  "mcpServers": {
    "publish-now": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://publishnow.app/mcp"
      ]
    }
  }
}
or, from the terminal
claude mcp add --transport http publish-now https://publishnow.app/mcp

Tools it exposes

create_post

Create and schedule posts on X, Instagram, TikTok, or YouTube.

list_profiles

List the connected profiles on your account, optionally filtered by tag.

list_posts

List posts, optionally filtered by handle, platform, or status.

create_media

Request an upload URL for an image, GIF, or video.

confirm_media

Finalize an upload and get back a media_id for create_post.

search_stock

Search licensed photos. Creates nothing, saves nothing, costs nothing.

source_media

Turn the photos you picked into media_ids, alt text and credits included.

get_brand_voice

Read how your brand sounds — the voice, the approved examples, the phrases you never publish.

get_recent_posts

Read what you already published, so the next draft says something new.

get_destination_limits

Read each connected account's real character limit — and how that platform counts.

generate_*

One tool per writing tool in your Generate catalogue, each shaped by the context above.

get_generate_run

Collect a longer run's output when it did not finish inside the call.

07 — Machine-readable

Built for agents to read on their own

If you are an agent, skip the prose. Each of these is served as a plain document, cached, and always current with the endpoints above.

Public pages also advertise these targets in an RFC 8288 Link response header, so a crawler can find them without parsing HTML. Usage is governed by the API terms.

Get a key. Ship the first post.

No card. Cancel anytime. First posts in minutes.

Start free for 5 days