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
| Files | Result |
|---|---|
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 PDF | A 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:
- Ask for an upload policy —
POST /api/v2/uploads/upload-policyreturns afileIdand a presigned form to upload with. - Upload the bytes —
POSTthe file to the presigned URL. Nothing touches the MarkUp API here. - Confirm the upload —
POST /api/v2/uploads/upload-completewith thefileIdand theETagstorage returned. - Create the board —
POST /api/v2/markups/from-fileswith all thefileIds, 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.
curl "https://api.markup.io/api/v2/workspace" \
-H "Authorization: Bearer <API-KEY-SECRET>" \
-H "Markup-API-Version: 2023-02-22"{
"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.
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
}'{
"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.
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"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.
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\""
}'{
"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.
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"
}'{
"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:
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"include | Expands |
|---|---|
media | Every MarkUp that has a file — the same as images,videos,documents |
images | images[] on image MarkUps |
videos | video on video MarkUps |
documents | document 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.
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
| Condition | Response |
|---|---|
fileIds empty, or more than 50 entries | 400 |
Same fileId listed twice in one request | 400 |
fileId unknown, or the upload was never confirmed | 400 |
fileId already used by another markup | 400 — upload the file again to reuse it |
| Set mixes images with a PDF, video or convertible image | 400 — create the images as one MarkUp and send the other file on its own |
| Several PDFs, videos, or convertible images in one request | 400 — those types hold a single file each |
fileId uploaded into a different workspace | 403 |
workspaceId is not the API key’s workspace | 403 |
| Storage quota or monthly MarkUp limit reached | 402 — the whole set counts as one MarkUp, and its combined size against storage |
| A single file larger than the plan’s remaining storage | 402 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.