Skip to Content
APIMulti-image MarkUps

Create a MarkUp from uploaded files

A MarkUp board can hold many images. Because a multipart request to Create Markup from file is capped at 20MB for the whole request, files are uploaded one at a time straight to storage, and the board is created from the uploaded files afterwards. This is also the only route for a PDF or video above 20MB.

What can go in one MarkUp

FilesResult
One or more images a browser can display (png, jpeg, gif, bmp, webp, svg)A single board, one page per image, in fileIds order
One PDFA PDF MarkUp
One video (mp4, mov, webm, avi, wmv, mkv, mxf)A video MarkUp, reviewable once the transcode finishes
One image needing conversion (tiff, psd, eps)An image MarkUp, reviewable once the conversion finishes

Only displayable images combine. A PDF, a video, or an image that needs converting is backed by a single file, so send exactly one fileId and do not mix it with others — a mixed set, or several of those types together, is rejected with a 400 explaining which rule it broke.

For anything that needs converting, the MarkUp is created immediately but isReady is false until the job finishes; images/video fill in at that point.

The flow is four steps, repeated per file for the first three:

  1. Ask for an upload policy — POST /api/v2/uploads/upload-policy returns a fileId and a presigned form to upload with.
  2. Upload the bytes — POST the file to the presigned URL. Nothing touches the MarkUp API here.
  3. Confirm the upload — POST /api/v2/uploads/upload-complete with the fileId and the ETag storage returned.
  4. Create the board — POST /api/v2/markups/from-files with all the fileIds, in the order the images should appear.

There is no fixed size limit on a file: what you can upload is whatever your plan’s storage still has room for. The declared filesize is checked against that allowance before a policy is issued, so a file too large for the plan is refused at step 1 with a 402, not part-way through the upload. A single request may combine up to 50 images.

Before you start: pick a folder

Step 1 needs a resourceId, which for markup_asset uploads is the folder the images are destined for. Use the workspace root folder unless you are filing the board somewhere specific — GET /api/v2/workspace returns its id.

GET /api/v2/workspace
curl "https://api.markup.io/api/v2/workspace" \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22"
200 OK
{ "data": { "id": "123e4567-e89b-12d3-a456-426655440000", "name": "Acme", "rootFolderId": "6a6360e6-fb79-416a-b765-6667f380b98e", "archiveFolderId": "b8f1c2d3-4e5a-6b7c-8d9e-0f1a2b3c4d5e" } }

Step 1 — Ask for an upload policy

Do this once per image. filesize must be the exact byte length of the file you are about to send, and contentType its real mime type: both are signed into the policy, and storage rejects an upload that does not match. The declared size is also what is charged against your storage allowance, so an oversized file is refused here rather than after the bytes have been sent.

POST /api/v2/uploads/upload-policy
curl "https://api.markup.io/api/v2/uploads/upload-policy" \ -X POST \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22" \ -H "Content-Type: application/json" \ --data '{ "resourceType": "markup_asset", "resourceId": "6a6360e6-fb79-416a-b765-6667f380b98e", "filename": "page-1.png", "contentType": "image/png", "filesize": 204813 }'
200 OK
{ "data": { "fileId": "3f1b9d2e-1c4a-4b7e-9a1d-2c5e8f0a7b31", "key": "public-api/123e4567-.../markup_asset/6a6360e6-.../9b339cd0-page-1.png", "bucket": "media.markup.io", "acl": "public-read", "policy": { "url": "https://media.markup.io.s3.amazonaws.com", "fields": { "key": "public-api/123e4567-.../markup_asset/6a6360e6-.../9b339cd0-page-1.png", "acl": "public-read", "Content-Type": "image/png", "bucket": "media.markup.io", "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": "...", "X-Amz-Date": "...", "Policy": "...", "X-Amz-Signature": "..." } } } }

Keep the fileId — step 3 and step 4 both need it.

The credentials in the response are an alternative route for clients that already use an AWS SDK: they are scoped to this one object, and let you PutObject directly instead of posting the form below. The presigned form needs no SDK, so it is what the rest of this page uses.

Step 2 — Upload the bytes

policy.fields is a complete, ready-to-submit form. Send every entry exactly as returned — including acl and Content-Type, which the policy is signed against — then the file last (storage ignores anything after the file part). Read the ETag from the response headers.

Dropping any field, or altering its value, fails the whole upload with 403 Invalid according to Policy: Policy Condition failed naming the field that did not match.

POST to policy.url
curl "https://media.markup.io.s3.amazonaws.com" \ -X POST \ -i \ -F "key=public-api/123e4567-.../markup_asset/6a6360e6-.../9b339cd0-page-1.png" \ -F "acl=public-read" \ -F "Content-Type=image/png" \ -F "bucket=media.markup.io" \ -F "X-Amz-Algorithm=AWS4-HMAC-SHA256" \ -F "X-Amz-Credential=..." \ -F "X-Amz-Date=..." \ -F "Policy=..." \ -F "X-Amz-Signature=..." \ -F "file=@/path/to/page-1.png"
204 No Content
HTTP/1.1 204 No Content ETag: "9a0364b9e99bb480dd25e1f0284c8555"

Step 3 — Confirm the upload

This checks the object really landed and matches the policy, and marks the file ready to use.

POST /api/v2/uploads/upload-complete
curl "https://api.markup.io/api/v2/uploads/upload-complete" \ -X POST \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22" \ -H "Content-Type: application/json" \ --data '{ "fileId": "3f1b9d2e-1c4a-4b7e-9a1d-2c5e8f0a7b31", "etag": "\"9a0364b9e99bb480dd25e1f0284c8555\"" }'
200 OK
{ "data": { "id": "3f1b9d2e-1c4a-4b7e-9a1d-2c5e8f0a7b31", "key": "public-api/123e4567-.../markup_asset/6a6360e6-.../9b339cd0-page-1.png", "mimeType": "image/png", "filesize": 204813, "originalFilename": "page-1.png" } }

Repeat steps 1–3 for every image before moving on.

Step 4 — Create the board

fileIds order is the page order of the board. parentFolderId is optional and defaults to the workspace root; if you set it, it should be the folder you used as resourceId above. To create a PDF or video MarkUp instead, send the single fileId for that upload — the request is otherwise identical.

POST /api/v2/markups/from-files
curl "https://api.markup.io/api/v2/markups/from-files" \ -X POST \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22" \ -H "Content-Type: application/json" \ --data '{ "workspaceId": "123e4567-e89b-12d3-a456-426655440000", "fileIds": [ "3f1b9d2e-1c4a-4b7e-9a1d-2c5e8f0a7b31", "8c2a4e6f-5d3b-4a1c-8e9f-0b7d6c3a2e14" ], "name": "Homepage review" }'
200 OK
{ "data": { "id": "ce10f176-6a68-42eb-9ce2-718aae4ba191", "type": "image", "name": "Homepage review", "markupUrl": "https://app.markup.io/markup/ce10f176-6a68-42eb-9ce2-718aae4ba191", "thumbnailUrl": "https://media.markup.io/thumbnails/markup/ce10f176-6a68-42eb-9ce2-718aae4ba191", "isReady": true, "images": [ { "url": "https://media.markup.io/project-images/da82fc8c-.../ce10f176-.../3f1b9d2e-page-1.png", "mimeType": "image/png", "filesize": 204813 }, { "url": "https://media.markup.io/project-images/da82fc8c-.../ce10f176-.../8c2a4e6f-page-2.png", "mimeType": "image/png", "filesize": 198022 } ] } }

Reading the images back

images[] is returned by POST /markups/from-files and by Get Markup, in display order. Each url serves the file exactly as uploaded — no scaling or re-encoding — over a plain unauthenticated GET, which makes it directly usable as input to other tooling.

The same array is included on the markup of markup webhook events, so a listener can pick the images up without a follow-up read.

Listing endpoints return metadata only by default, because a page of boards can carry thousands of images. Ask for the files explicitly with include:

GET /api/v2/markups?include=media
curl "https://api.markup.io/api/v2/markups?workspaceId=123e4567-e89b-12d3-a456-426655440000&include=media" \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22"
includeExpands
mediaEvery MarkUp that has a file — the same as images,videos,documents
imagesimages[] on image MarkUps
videosvideo on video MarkUps
documentsdocument on PDF MarkUps

Values combine (include=images,videos), and each expands only its own type: asking for images leaves a PDF MarkUp in the same page without its document. The files for a whole page are read in one query, so the option costs one query rather than one per MarkUp — and a value that matches nothing on the page costs none at all.

isReady is reported either way, so a MarkUp whose files are still being prepared is always distinguishable from one that has none. An unrecognised include value is rejected with a 400 rather than quietly returning no files.

Both GET /api/v2/markups and GET /api/v2/markups/search accept it. A single-MarkUp read (GET /api/v2/markups/{id}) always includes the files and needs no include.

GET /api/v2/markups/{id}
curl "https://api.markup.io/api/v2/markups/ce10f176-6a68-42eb-9ce2-718aae4ba191" \ -H "Authorization: Bearer <API-KEY-SECRET>" \ -H "Markup-API-Version: 2023-02-22"

Rules and errors

ConditionResponse
fileIds empty, or more than 50 entries400
Same fileId listed twice in one request400
fileId unknown, or the upload was never confirmed400
fileId already used by another markup400 — upload the file again to reuse it
Set mixes images with a PDF, video or convertible image400 — create the images as one MarkUp and send the other file on its own
Several PDFs, videos, or convertible images in one request400 — those types hold a single file each
fileId uploaded into a different workspace403
workspaceId is not the API key’s workspace403
Storage quota or monthly MarkUp limit reached402 — the whole set counts as one MarkUp, and its combined size against storage
A single file larger than the plan’s remaining storage402 at step 1, before any bytes are sent

A file is consumed by the markup that uses it, so a retry after a successful create needs fresh uploads.