---
title: "Upload media"
method: POST
path: "/media"
---

# Upload media

`POST /media`

## Request

- Base URL: `https://xpost.to/api/v1`
- URL: `https://xpost.to/api/v1/media`
- Auth: HTTP bearer

Attach an image, video or PDF for a post. Three shapes: JSON `{url}` (the server fetches a public https URL — also how an MCP host's attachment arrives, through its temporary download url), JSON `{ticket}` (redeems an upload slot from `POST /media/ticket`), or multipart form-data with a `file` field. ≤100 MB. Optional: `thumbnail_timestamp_ms` picks a video's cover frame; `alt_text` (≤1000 chars) sets an image's accessibility text (applied natively on X and Bluesky); `people_tags` tags people in a picture (see the field — Instagram only, and every handle is checked before the file is stored). Returns `{id, kind}` — pass `id` in create_post `media_ids`. A PDF (`kind: "document"`) is a LinkedIn document post: one per post, attached on its own, and only to a LinkedIn account whose connection supports documents — create_post refuses anything else and says why.

## Request body

- object
  - `url` string, uri
  - `ticket` string: An upload ticket from POST /media/ticket, once the file has been posted to the slot. Give either this or `url`.
  - `thumbnail_timestamp_ms` integer
  - `alt_text` string
  - `people_tags` object[]: People tagged IN the picture. Instagram only, and only on an account connected through bundle.social with Facebook Login — check `can.people_tags` on GET /social-accounts before sending any. Stored on the MEDIA row, not the post: reuse the photo and the tags come with it, like alt text. Every handle is verified against Instagram business discovery BEFORE the file is stored; one it cannot see (a private or personal account) is refused with 422 and an `unknown` list, because Instagram does not drop a bad tag and publish anyway — it refuses the whole post. On a feed image these render as photo tags; on a story the same field renders as a MENTION sticker.
    - `id` string, required: Instagram username. A leading @ is stripped.
    - `x` number, required: Where the tag sits, as a fraction from the LEFT edge. Required — a tag is a position, not just a name.
    - `y` number, required: …and from the TOP edge. Required.
    - `platform` 'instagram': Optional; Instagram is the only accepted value. Facebook tags cannot publish and are refused.

## Response `200`

Stored

- object
  - `id` string, uuid
  - `kind` 'image' | 'video' | 'document': `document` is a PDF — it publishes on LinkedIn only, alone
  - `people_tags` object[]: Echoed back only when tags were sent and stored — the cleaned copy (@ stripped, coordinates clamped) that will actually go out.
  - `media_ids` string[]: Only when redeeming a ticket whose browser door (`attach_url`) was used: every file it received, in the order they were added. With more than one file this is the whole answer — `id` and `kind` are absent and `count` says how many.
  - `already_uploaded` boolean: True when the file arrived through the ticket's browser door rather than the upload slot. Nothing to retry — use the ids.

## Other responses

- `400`: No file/url/ticket, unfetchable URL, unsupported type, an expired ticket, or a malformed people_tags (including tags sent with a video — the response carries the stored `id`)
- `401`: Missing or invalid API key
- `403`: Missing posts:write scope
- `404`: No file has reached that ticket yet — post the file to the upload slot first, then redeem the SAME ticket. A ticket whose browser door was used answers 200 with `media_ids` and `already_uploaded`, not 404
- `409`: people_tags were sent but the handle check isn't configured (bundle.social key missing)
- `413`: Larger than 100 MB
- `422`: A people_tags handle Instagram can't confirm — the body's `unknown` array names them; retry without those
- `429`: Rate limited

---

[API](https://skmtc.dev/xpost/apis/xpost-api.md) · [All operations](https://skmtc.dev/xpost/apis/xpost-api/llms.txt) · [OpenAPI document](https://skmtc.dev/xpost/apis/xpost-api/revisions/9f570fcfed6d?raw)
