---
title: "Manage media through the API and MCP"
description: "Upload media inline or straight to storage with a SHA-256 checksum, import it from a URL, replace a media's file in place, reuse media the workspace already holds on MEDIA_ALREADY_EXISTS, and handle the delete rules, over REST and the MCP server."
canonical: "https://linkbreakers.com/help/article/manage-media-through-the-api-and-mcp"
last-updated: 2026-10-18
---

# Manage media through the API and MCP

> Upload media inline or straight to storage with a SHA-256 checksum, import it from a URL, replace a media's file in place, reuse media the workspace already holds on MEDIA_ALREADY_EXISTS, and handle the delete rules, over REST and the MCP server.

## Short answer

The media endpoints live under `/v1/media`. Send a file of up to 3 MB inline with `POST /v1/media`, or upload anything up to the limit for its kind straight to storage with `POST /v1/media/uploads`, a `PUT` to the returned URL and `POST /v1/media/{id}:complete`. A file that is already online is fetched server side with `POST /v1/media:import`. To put a new file in place of an existing media without touching what uses it, use the replace routes. A workspace holds each file once: uploading bytes it already has fails with `409 MEDIA_ALREADY_EXISTS`, and the error names the existing media so you can use it. Every route has a matching MCP tool, named `media_*`.

## Quick summary

- Kinds and limits: images (PNG, JPEG, WebP, SVG) up to 10 MB, PDFs up to 25 MB, MP4 videos up to 50 MB; inline uploads up to 3 MB
- Writes need the `media:write` scope; reads need `media:read`
- Two-step uploads take an optional `sha256`: duplicates are then refused before any upload, and storage checks the bytes against it
- `MEDIA_ALREADY_EXISTS` carries `existingMediaId` and `existingFileName` in its metadata: use that media instead of uploading again
- Replacing keeps the media id, so Open media steps and page theme images follow; QR code designs keep their logo and are detached
- `MEDIA_REPLACE_NOT_ALLOWED` lists each use that refuses the new file in `refusals`
- Deleting is refused with `MEDIA_IN_USE` while an Open media step or a page theme uses the media; QR code logos never block it

## Routes and MCP tools

| What | REST | MCP tool |
|---|---|---|
| Upload inline (up to 3 MB) | `POST /v1/media` | `media_create` |
| Start a direct upload | `POST /v1/media/uploads` | `media_upload_create` |
| Complete a direct upload | `POST /v1/media/{id}:complete` | `media_upload_complete` |
| Import from an https URL | `POST /v1/media:import` | `media_import` |
| Start a replacement | `POST /v1/media/{id}/replace-uploads` | `media_replace_upload_create` |
| Complete a replacement | `POST /v1/media/{id}/replace-uploads/{replacement_id}:complete` | `media_replace_upload_complete` |
| Replace from an https URL | `POST /v1/media/{id}/replace:import` | `media_replace_import` |
| Get one media, with where it is used | `GET /v1/media/{id}` | `media_get` |
| List media | `GET /v1/media` | `media_list` |
| Delete | `DELETE /v1/media/{id}` | `media_delete` |

The examples below use `curl` against `https://api.linkbreakers.com` with a secret key in `$LINKBREAKERS_TOKEN`. See [how to use the API](/help/article/how-to-use-the-linkbreakers-api) for authentication and [API token scopes](/help/article/api-token-scopes) for scoped tokens. To use the tools from Claude, Cursor or another AI agent, connect the [MCP server](/help/article/mcp-server-setup).

## Upload media

### Inline, up to 3 MB

Send the file base64 encoded in `fileData`. The kind is read from the bytes, never from the name.

```bash
curl -X POST https://api.linkbreakers.com/v1/media \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"fileName\": \"logo.png\",
    \"fileData\": \"$(base64 < logo.png | tr -d '\n')\"
  }"
```

The response holds the media, with its `id`, `kind`, `size`, `sha256` and a short-lived `signedUrl`. A larger file fails with `MEDIA_TOO_LARGE_FOR_INLINE_UPLOAD`: use a direct upload.

### Direct upload, with a checksum

Large files go straight to storage and never pass through the API. Declare the file first, with its exact size and, ideally, its SHA-256 as hex:

```bash
SIZE=$(wc -c < specs.pdf | tr -d ' ')
SHA=$(shasum -a 256 specs.pdf | cut -d' ' -f1)

curl -X POST https://api.linkbreakers.com/v1/media/uploads \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"fileName\": \"specs.pdf\",
    \"mimeType\": \"application/pdf\",
    \"size\": \"$SIZE\",
    \"sha256\": \"$SHA\"
  }"
```

The response holds a pending `media` (its `id` is what you complete), an `uploadUrl`, the `uploadMethod` (always `PUT`), the `uploadHeaders` to send and `uploadExpiresAt`. With `sha256` declared, a file the workspace already holds is refused right here with `409 MEDIA_ALREADY_EXISTS`, before you upload anything.

`PUT` the bytes to `uploadUrl` with every header from `uploadHeaders`, exactly as given. When you declared `sha256`, that includes `x-amz-checksum-sha256`, and storage refuses bytes that do not match it:

```bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  -H "x-amz-checksum-sha256: $CHECKSUM_HEADER_FROM_RESPONSE" \
  --data-binary @specs.pdf
```

Then complete the upload:

```bash
curl -X POST "https://api.linkbreakers.com/v1/media/$MEDIA_ID:complete" \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Completion checks the size, the real format and the storage allowance, and returns the media as `STATUS_READY`. An upload not completed within an hour is discarded. Without `sha256`, the server hashes the file on completion, and a duplicate is caught there instead.

### Import from a URL

When the file is already online, let the server fetch it:

```bash
curl -X POST https://api.linkbreakers.com/v1/media:import \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/specs.pdf",
    "fileName": "specs.pdf"
  }'
```

The URL must be https on port 443 and resolve to a public address. Redirects are followed only to other such URLs, at most five. The same kind and size limits apply. Leave `fileName` out to take the last segment of the URL's path.

## Reuse media the workspace already has

A workspace holds each file once, recognised by the SHA-256 of its bytes. Any upload, import or replacement whose bytes match another media fails with `409`:

```json
{
  "code": 6,
  "codeType": "MEDIA_ALREADY_EXISTS",
  "message": "this file is already in the workspace's media library",
  "details": [],
  "metadata": {
    "existingMediaId": "5f1c2d3e-4b5a-6c7d-8e9f-0a1b2c3d4e5f",
    "existingFileName": "specs.pdf"
  }
}
```

Treat it as a success: use `existingMediaId` wherever you were going to use the new media, and do not retry the upload. Computing `sha256` before a direct upload turns this into a cheap check that costs no transfer.

## Replace a media's file

Replacing puts a new file in place of a media's current one and keeps its `id`, so everything using it follows:

- Open media steps hand out the new file from the next scan
- Page themes show it as their background cover, badge or footer logo from the next visit
- QR code designs keep their own copy of their logo: they keep their current logo and are detached from the media

Read the media first with `GET /v1/media/{id}`: its `references` list every use the replacement will change.

### From a local file

Start the replacement with the new file's name, MIME type, size and optional `sha256`:

```bash
curl -X POST "https://api.linkbreakers.com/v1/media/$MEDIA_ID/replace-uploads" \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"fileName\": \"specs-week-42.pdf\",
    \"mimeType\": \"application/pdf\",
    \"size\": \"$SIZE\",
    \"sha256\": \"$SHA\"
  }"
```

The response holds a `replacementId`, an `uploadUrl` and `uploadHeaders`. `PUT` the new bytes as for a direct upload, then complete:

```bash
curl -X POST "https://api.linkbreakers.com/v1/media/$MEDIA_ID/replace-uploads/$REPLACEMENT_ID:complete" \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

If the declared `sha256` is the media's own file, the first call returns `unchanged: true` and no `uploadUrl`: there is nothing to replace. Until completion the media keeps its current file, and a replacement not completed within an hour is discarded.

### From a URL

```bash
curl -X POST "https://api.linkbreakers.com/v1/media/$MEDIA_ID/replace:import" \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/specs-week-42.pdf"
  }'
```

### What the replacement returns

Both completions return the media (same `id`, now holding the new file and taking its name, MIME type, kind, size and `sha256`), `unchanged`, and `detachedReferences`: the QR code designs that used the media as their logo and were detached from it. The old file is deleted. Only the difference in size counts against the storage allowance.

### When a use refuses the new file

Every use rendered from the media must accept the new file. A page theme cover, badge or footer logo takes an image of at most 3 MB; an Open media step takes any media, so media used only by Open media steps may change kind. When a use refuses, the replacement fails with `MEDIA_REPLACE_NOT_ALLOWED`, the media keeps its file, and `metadata.refusals` is a JSON array, as a string, of each refusing use:

```json
{
  "code": 9,
  "codeType": "MEDIA_REPLACE_NOT_ALLOWED",
  "message": "the new file cannot replace this media: a place using it does not accept it",
  "details": [],
  "metadata": {
    "refusals": "[{\"type\":\"PAGE_THEME_COVER\",\"reason\":\"KIND_NOT_ACCEPTED\",\"message\":\"A background cover must be an image; this is a PDF.\",\"linkId\":\"...\",\"linkName\":\"Spring open house\"}]"
  }
}
```

Each refusal names the use's `type`, a `reason`, a readable `message`, and the link (`linkId`, `linkName`) or page theme template (`pageThemeTemplateId`, `pageThemeTemplateName`) it belongs to. A file another media already holds fails with `MEDIA_ALREADY_EXISTS` instead.

## Delete media

```bash
curl -X DELETE "https://api.linkbreakers.com/v1/media/$MEDIA_ID" \
  -H "Authorization: Bearer $LINKBREAKERS_TOKEN"
```

Deleting frees the media's storage and cannot be undone. What decides it is where the media is used:

| Reference type | Effect on delete |
|---|---|
| `TYPE_OPEN_MEDIA_STEP` | Blocks it |
| `TYPE_PAGE_THEME_COVER`, `TYPE_PAGE_THEME_BADGE`, `TYPE_PAGE_THEME_FOOTER_LOGO` | Blocks it |
| `TYPE_QR_CODE_LOGO` | Does not block it: the QR code keeps its own copy, and its link to the media is cleared |

A blocked delete fails with `MEDIA_IN_USE`, whose metadata names what uses the media in `linkIds` and `pageThemeTemplateIds`. Point those Open media steps at other media, or remove the image from those themes, then delete.

## Find where media is used

`GET /v1/media/{id}` and `GET /v1/media` fill each media's `references`: one entry per use, with its `type` and the link or template it belongs to. An empty list means nothing uses the media. `GET /v1/media` takes `page_size` and `page_token`, and filters by `kind` (`KIND_IMAGE`, `KIND_DOCUMENT`, `KIND_VIDEO`).

```bash
curl -H "Authorization: Bearer $LINKBREAKERS_TOKEN" \
  "https://api.linkbreakers.com/v1/media?page_size=50&kind=KIND_DOCUMENT"
```

## Use the media

Pass a media's `id` wherever media is chosen:

- `POST /v1/links/open-media` (`links_create_open_media`) creates a link that opens it
- An `OPEN_MEDIA` workflow step takes it as its `media_id`
- A QR code design takes an image as `central_image_media_id`
- A page theme takes an image as its background cover, badge or footer logo

Media that does not fit a place, such as a PDF as a badge or an image over 3 MB as a QR code logo, returns `MEDIA_NOT_ALLOWED_HERE` with the reason.

## Frequently asked questions

### Which upload route should I use?

Inline (`POST /v1/media`) for files up to 3 MB when simplicity matters, a direct upload for anything larger, and an import when the file is already at a public https URL.

### Should I send sha256?

Yes, when you can. A duplicate is then refused before you transfer anything, and storage rejects a corrupted upload.

### Is MEDIA_ALREADY_EXISTS an error I should surface?

No. It means the workspace already has the file. Use `existingMediaId` and carry on.

### Does replacing a media change its id?

No. That is the point: every Open media step and page theme using it keeps pointing at the same id and shows the new file.

### Why did my QR code design lose its link to the media?

QR code designs keep their own copy of their logo, so a replacement never changes a printed QR code. They are detached and listed in `detachedReferences`. Set `central_image_media_id` again to use the new image.

### Can I delete media an Open media step uses?

No. Point the step at other media first. The `MEDIA_IN_USE` error lists the links that use it.

## Limits and caveats

- Images up to 10 MB, PDFs up to 25 MB, MP4 videos up to 50 MB; inline uploads up to 3 MB
- QR code logos and page theme images must be images of at most 3 MB
- Direct uploads and replacements expire after an hour if not completed
- Import URLs must be public https on port 443, with at most five redirects
- Every media counts toward the workspace's storage allowance; see [your media library](/help/article/your-media-library-kinds-where-its-used-and-storage) for the allowance per plan
- `uploaded_from` on uploads is informational only, and `usage` is deprecated

## Last reviewed

2026-10-18
