Markest API
Markest exposes two HTTP interfaces:
- The paste API (
/api/v1) — authenticated with an API key. Create, list, read and delete your own pastes. - The read API (
/api/p) — no authentication. Read any paste you could open in a browser: its document list, a document's source, its rendered HTML, or the whole thing as a ZIP.
Everything is served over HTTPS from https://marke.st. All request and
response bodies are JSON unless stated otherwise, and all timestamps are
ISO 8601 (2026-09-04T10:30:00+00:00).
Authentication
The paste API accepts a key in either header:
Authorization: Bearer mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-API-Key: mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Create a key under Account → API keys. Keys look like mk_live_ followed by
48 hexadecimal characters. The full key is shown once, when it is created —
only a SHA-256 hash is stored, so it cannot be recovered afterwards. If you lose
it, revoke the key and issue another.
API access is a plan feature. If your plan does not include it, no key you create will work.
Permissions
Each key carries an explicit set of permissions. A new key gets
create_paste only; tick the others when you create it if you need them.
| Permission | Allows |
|---|---|
create_paste |
POST /api/v1/pastes, POST /api/v1/pastes/{id}/images |
list_own |
GET /api/v1/pastes |
read_own |
GET /api/v1/pastes/{id}, GET /api/v1/pastes/{id}/versions, GET /api/v1/pastes/{id}/images, GET /api/v1/pastes/{id}/images/{imageId} |
delete_own |
DELETE /api/v1/pastes/{id}, DELETE /api/v1/pastes/{id}/images/{imageId} |
A key may also carry an expiry date and can be deactivated. Calling with an
expired or inactive key returns 403.
Authentication errors
| Status | Body | Meaning |
|---|---|---|
401 |
Missing API key. Provide via Authorization: Bearer or X-API-Key header. |
No key supplied |
401 |
Invalid API key. |
Key not recognised |
403 |
API key is expired or inactive. |
Key exists but cannot be used |
403 |
API key does not have <permission> permission. |
Key lacks the permission for this endpoint |
Paste API
Create a paste
POST /api/v1/pastes
Requires create_paste. Accepts JSON or multipart/form-data.
Single document (JSON) — the shorthand form:
curl -X POST https://marke.st/api/v1/pastes \
-H "Authorization: Bearer $MARKEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Release notes",
"content": "# 1.4.0\n\nFixed the thing.",
"filename": "README.md",
"visibility": "unlisted",
"expires_in": 604800
}'
Several documents (JSON):
curl -X POST https://marke.st/api/v1/pastes \
-H "Authorization: Bearer $MARKEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Widget API",
"default_path": "README.md",
"documents": [
{ "path": "README.md", "content": "# Widget API\n\nSee [setup](docs/setup.md)." },
{ "path": "docs/setup.md", "content": "# Setup\n\n1. Get a key." }
]
}'
Files (multipart):
curl -X POST https://marke.st/api/v1/pastes \
-H "Authorization: Bearer $MARKEST_KEY" \
-F "title=Docs bundle" \
-F "visibility=unlisted" \
-F "files[]=@README.md" \
-F "files[]=@CHANGELOG.md"
Each file's original name becomes its document path.
Fields
| Field | Type | Default | Notes |
|---|---|---|---|
content |
string | — | Single-document shorthand. Provide this or documents. |
filename |
string | README.md |
Path for the content shorthand. |
documents |
array | — | [{ path, content, title?, content_type? }]. Order is preserved. |
title |
string | null |
Paste title. |
visibility |
string | your default, unlisted unless changed |
public, unlisted or private, subject to your plan. |
expires_in |
integer | 0 |
Seconds until expiry. 0 means never, if your plan allows it. |
default_path |
string | first document | Which document the paste opens on. |
burn_after_reading |
boolean | false |
Destroy the paste after its first view. Not allowed with HTML. |
track_versions |
boolean | your account setting | Keep a version history from the first save. See Version history below. |
proxy_images |
boolean | your account setting | Keep copies of the documents' external images on Markest, so readers never contact the sites they come from. false leaves them where they are, and readers are asked before any loads. |
folder |
string | key's default | Folder in your paste list. Organisational only; not part of the URL. |
content_type |
string | detected | Per document: markdown, html or code. Omit and Markest decides from the path and body; a name such as main.py or Makefile is code, highlighted in the language it names. |
Paths are normalised and validated: no .., no backslashes, no leading /,
at most 200 characters, and none of < > : " | ? * # or control characters
(they would break the viewer URL or the ZIP download).
Response — 201 Created
{
"id": "01K3XY4Z8QW2V7N0M5P9RT6ABC",
"title": "Widget API",
"visibility": "unlisted",
"folder": null,
"track_versions": false,
"proxy_images": true,
"url": "https://marke.st/p/01K3XY4Z8QW2V7N0M5P9RT6ABC",
"api_url": "https://marke.st/api/v1/pastes/01K3XY4Z8QW2V7N0M5P9RT6ABC",
"documents": [
{
"path": "README.md",
"title": "Readme",
"content_type": "markdown",
"raw_url": "https://marke.st/r/01K3XY4Z8QW2V7N0M5P9RT6ABC/README.md"
}
],
"created_at": "2026-09-04T10:30:00+00:00"
}
When visibility is public and the account's publishing confirmation is on —
it is by default — the paste is created unlisted instead and the response is
202 Accepted: the same body, with status set to approval_required and an
approval_url for the account holder to open. Nothing is public until they
approve it; see Visibility below.
List your pastes
GET /api/v1/pastes
Requires list_own. Returns every paste you own; document contents are not
included.
curl https://marke.st/api/v1/pastes -H "Authorization: Bearer $MARKEST_KEY"
{
"pastes": [
{
"id": "01K3XY4Z8QW2V7N0M5P9RT6ABC",
"title": "Widget API",
"visibility": "unlisted",
"folder": null,
"document_count": 2,
"created_at": "2026-09-04T10:30:00+00:00",
"updated_at": "2026-09-04T10:30:00+00:00",
"expires_at": null,
"url": "https://marke.st/p/01K3XY4Z8QW2V7N0M5P9RT6ABC"
}
],
"total": 1
}
Read one paste
GET /api/v1/pastes/{id}
Requires read_own. Returns the paste with the decrypted content of every
document. Only pastes you own are visible; anything else returns 404.
{
"id": "01K3XY4Z8QW2V7N0M5P9RT6ABC",
"title": "Widget API",
"visibility": "unlisted",
"folder": null,
"documents": [
{
"path": "README.md",
"title": "Readme",
"content": "# Widget API\n\nSee [setup](docs/setup.md).",
"content_type": "markdown",
"sort_order": 0
}
],
"created_at": "2026-09-04T10:30:00+00:00",
"updated_at": "2026-09-04T10:30:00+00:00",
"expires_at": null
}
Add documents to a paste
POST /api/v1/pastes/{id}/documents · requires create_paste
Appends to a paste you already own, leaving its other documents untouched. This is how a multi-document paste gets built up over several calls rather than submitted whole.
curl -X POST https://marke.st/api/v1/pastes/01J.../documents \
-H "Authorization: Bearer $MARKEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "path": "docs/setup.md", "content": "# Setup\n\nInstall it." }'
Several at once:
{
"documents": [
{ "path": "a.md", "content": "# A" },
{ "path": "b/c.md", "content": "# C" }
]
}
| Field | Type | Notes |
|---|---|---|
path |
string | Required. Folders are allowed; the path is normalised. |
content |
string | The document body. |
title |
string | Optional display title. |
content_type |
string | Optional; otherwise detected from the path and body. |
documents |
array | Use instead of path/content to add several at once. |
overwrite |
bool | Replace a document already at that path. Defaults to false. |
A path already in use returns 409 rather than silently replacing anything:
{ "error": "This paste already has a document at \"a.md\". Pass \"overwrite\": true to replace it, or PATCH to update it." }
The resulting document set is validated as a whole, so the same size, path and plan rules that guard creation also guard an addition.
Returns 201 with the paste's full document list.
Update a document
PATCH /api/v1/pastes/{id}/documents · requires create_paste
Changes one document in place. content replaces the body entirely — read it
first if you need to keep any of it.
curl -X PATCH https://marke.st/api/v1/pastes/01J.../documents \
-H "Authorization: Bearer $MARKEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "path": "docs/setup.md", "content": "# Setup\n\nRevised." }'
| Field | Type | Notes |
|---|---|---|
path |
string | Required. Names the document to change. |
content |
string | New body. |
new_path |
string | Move or rename the document. |
title |
string | New display title. |
content_type |
string | Override the detected type. |
sort_order |
int | Position within the paste. |
404 if the paste has no document at that path.
Delete a document
DELETE /api/v1/pastes/{id}/documents?path=docs/setup.md · requires delete_own
The path may also be given in the body. A paste must keep at least one document;
removing the last one returns 422. Delete the paste itself instead.
Each of these returns the paste's documents after the change:
{
"id": "01J...",
"url": "https://marke.st/p/01J...",
"document_count": 2,
"documents": [
{ "path": "README.md", "title": "README", "content_type": "markdown", "sort_order": 0,
"raw_url": "https://marke.st/api/p/01J.../raw/README.md" }
]
}
Delete a paste
DELETE /api/v1/pastes/{id}
Requires delete_own. Permanent, and it takes every document with it.
{ "deleted": true }
Upload an image
POST /api/v1/pastes/{pasteId}/images — needs create_paste.
Stores an image with a paste you own, so its documents can show it without relying on another site. Send it as a multipart image file, or as JSON:
{ "name": "chart.png", "data": "iVBORw0KGgo…" }
data is base64; a data: URL is accepted too. PNG, JPEG, GIF, WebP, AVIF, BMP and SVG are kept, under the plan's image limits. The response, 201 Created, gives the address to use:
{ "id": "01J…", "source": "upload", "name": "chart.png", "original_url": null, "content_type": "image/png", "size": 48213, "path": "/img/01J…/01J…", "url": "https://marke.st/img/01J…/01J…", "markdown": "", "created_at": "2026-09-15T12:00:00+00:00" }
Whoever can read the paste can see the image. A file that is no image is 422, one that is too large 413.
List, download and delete images
| Request | Needs | Does |
|---|---|---|
GET /api/v1/pastes/{pasteId}/images |
read_own |
Lists the paste's images: uploads, and the copies Markest made of images its documents show from other sites |
GET /api/v1/pastes/{pasteId}/images/{imageId} |
read_own |
The image itself, private pastes included |
DELETE /api/v1/pastes/{pasteId}/images/{imageId} |
delete_own |
Deletes an upload: 204 |
Each image is described as an upload's response describes it:
{ "paste_id": "01J…", "images": [ { "id": "01J…", "source": "upload", "name": "chart.png", "original_url": null, "content_type": "image/png", "size": 48213, "path": "/img/01J…/01J…", "url": "https://marke.st/img/01J…/01J…", "markdown": "", "created_at": "2026-09-15T12:00:00+00:00" } ] }
A copy ("source": "proxy") cannot be deleted — 409 — because it goes when no document shows it any more.
Read API
These endpoints need no key. They serve any paste you could open in a browser:
public and unlisted pastes, and private ones when you pass the signature
parameters from a signed link. A paste that is expired, password-locked, or
private without a valid signature answers 404 — the same answer as a paste
that does not exist, so the endpoint never reveals which.
Document list
GET /api/p/{id}/manifest
{
"id": "01K3XY4Z8QW2V7N0M5P9RT6ABC",
"title": "Widget API",
"defaultPath": "README.md",
"visibility": "unlisted",
"updatedAt": "2026-09-04T10:30:00+00:00",
"documents": [
{
"path": "README.md",
"title": "Readme",
"contentType": "markdown",
"sortOrder": 0,
"sha256": "9f86d081884c7d65…",
"updatedAt": "2026-09-04T10:30:00+00:00"
}
]
}
Document source
GET /api/p/{id}/doc?path=README.md
Returns the raw source, not JSON: text/markdown for a markdown document and
text/plain for an HTML one — HTML is never served as text/html here. The
response carries an ETag of the document's SHA-256, so conditional requests
work.
Rendered HTML
GET /api/p/{id}/rendered?path=README.md
Returns the document rendered to HTML as JSON. HTML documents have no rendered
form on this origin and answer 409 instead:
{
"error": "This document is HTML and is only shown sandboxed inside the Markest viewer.",
"contentType": "html",
"viewUrl": "https://marke.st/p/01K3XY4Z8QW2V7N0M5P9RT6ABC/report.html"
}
Download
GET /api/p/{id}/download.zip # the whole paste
GET /r/{id}/{path} # one document, inline
GET /r/{id}/{path}?download=1 # one document, as a file
/r/ serves markdown as text/markdown and HTML as inert text/plain. With
?download=1 it becomes an attachment under the document's own name, and an
HTML document is sent as application/octet-stream so nothing can render it.
Private pastes
A signed link carries two query parameters:
?exp=<unix-timestamp>&sig=<64 hex characters>
Pass both to any read-API endpoint to reach a private paste:
curl "https://marke.st/api/p/$ID/manifest?exp=1788000000&sig=$SIG"
The signature covers the paste id and the expiry, and is checked in constant time. It fails closed on a missing key, a malformed digest, a lapsed deadline, or a signature minted for a different paste. A signed link opens the whole paste — every document in it and the ZIP download — not a single file. Rotating the paste's signing key invalidates every link already issued.
Reading a paste as a script or agent
You do not need the API for this. Request a paste's normal URL as a non-browser client and Markest serves clean markdown instead of the HTML page, with an index of the paste's documents at the top:
curl https://marke.st/p/01K3XY4Z8QW2V7N0M5P9RT6ABC
curl -H "Accept: text/markdown" https://marke.st/p/$ID/docs/setup.md
curl "https://marke.st/p/$ID?raw=1"
A client is treated as non-browser when it sends ?raw=1, an Accept header
asking for text/markdown or text/plain without text/html, a known AI-agent
user agent, or no Sec-Fetch-Mode header at all. Password-protected and unread
burn-after-reading pastes refuse this path.
HTML documents
A document is either markdown or html. HTML documents are never rendered on
the marke.st origin. They are served only from /h/{id}/{path}, under a
Content-Security-Policy: sandbox without allow-same-origin, and only to the
viewer's own iframe — a direct navigation is redirected to the viewer and any
other context gets 403. Their scripts and forms work; none of it can read a
cookie or touch the surrounding page.
For API purposes this means an HTML document is readable as source
(/api/p/{id}/doc, /r/{id}/{path}) but has no rendered form you can fetch.
Limits and errors
Paste size limits are enforced for every write path, the API included:
| Limit | Value |
|---|---|
| Markdown document | 128 KB |
| HTML document | 1 MB |
| Code document | 1 MB |
| Paste total | 512 KB, or 2 MB when it contains HTML or code |
| Documents per paste | 50, or fewer on your plan |
Your plan additionally caps the number of pastes, documents per paste, storage,
and which visibilities and features you may use. Exceeding any of them returns
403 with a message naming the limit.
| Status | Meaning |
|---|---|
200 / 201 / 202 |
Success — 202 when a new public paste awaits confirmation |
400 |
Malformed request — invalid JSON, or neither content nor documents |
401 |
Missing or invalid API key |
403 |
Key lacks the permission, key unusable, or a plan quota was exceeded |
404 |
Paste or document not found, or not yours |
409 |
The document is HTML and has no rendered form |
422 |
Validation failed — bad path, oversized document, or an unusable combination |
429 |
Rate limit exceeded — see Rate limits |
Errors are always shaped the same way:
{ "error": "Document \"notes.md\" exceeds 128KB limit." }
Rate limits
Requests are limited per minute. API traffic is counted against your API key, so one key's usage never affects another's.
| Bucket | Applies to | Default |
|---|---|---|
api |
/api/v1/* and /mcp |
30/min, or your plan's allowance if higher |
paste_create |
Creating a paste | 10/min |
auth |
Sign-in and account recovery | 5/min |
global |
Everything else | 60/min |
Paid plans raise the API allowance — currently 120/min on Pro and 300/min on Business. The higher of the site default and your plan's allowance applies.
Every limited response carries your standing:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
Exceeding a limit returns 429 with a Retry-After header giving the seconds
to wait:
{ "error": "Rate limit exceeded: 120 requests per minute. Retry in 34 seconds." }
Limits are a sliding window, so the allowance refills continuously rather than resetting on the minute. OAuth discovery documents are never limited, so a client can always find its way to the authorization server.
Agents and MCP
Markest has a Model Context Protocol endpoint, so an AI agent can work with your pastes as tools rather than by calling this API directly. It is the way to connect ChatGPT, Claude, Codex or Cursor.
POST https://marke.st/mcp
Everything about it — the tool list, connecting each client, the OAuth flow and the publishing confirmation — is on the MCP guide.
Two things worth knowing from here:
- The MCP endpoint shares this API's rate-limit bucket.
- Agent access is a plan feature, separate from API access.
Visibility
POST /api/v1/pastes/visibility · requires create_paste
Set the visibility of one paste or many. The two directions behave differently on purpose.
curl -X POST https://marke.st/api/v1/pastes/visibility \
-H "Authorization: Bearer $MARKEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "paste_ids": ["01J...", "01K..."], "visibility": "private" }'
| Field | Type | Notes |
|---|---|---|
paste_ids |
array | The pastes to change. Up to 100 per call. |
paste_id |
string | Shorthand for a single paste. |
visibility |
string | public, unlisted or private. |
Restricting applies immediately
Setting pastes to unlisted or private takes effect at once, in batches,
subject only to your plan's allowed visibilities. Returns 200:
{
"status": "applied",
"changed": [ { "id": "01J...", "title": "Notes", "visibility": "private", "url": "https://marke.st/p/01J..." } ],
"unchanged": []
}
Publishing may need your confirmation
Making a paste public is the one visibility change that cannot be undone — once
something has been read, copied or indexed, setting it back to private does not
retract it. So by default an API caller cannot publish directly. The request
returns 202 with a link for the account holder to open:
{
"status": "approval_required",
"approval_url": "https://marke.st/app/approvals/31635a45fb0e5b8a67bd011cea321c04",
"visibility": "public",
"pending": [ { "id": "01J...", "title": "Notes", "visibility": "private", "url": "https://marke.st/p/01J..." } ],
"message": "Making these 2 pastes public needs your confirmation. Open the link to review and approve."
}
Nothing is public until that link is opened and approved. The confirmation screen covers every paste in the request at once, with a checkbox each, so a batch takes one visit and a subset can be approved without the rest. The link works once and expires after 24 hours.
202 means understood and stored, not done. Do not poll it and do not retry —
hand the URL to the person whose account it is.
Turn the gate off under Account → Publishing if you would rather publish directly from the API. Restricting a paste is never gated either way.
| Status | Meaning |
|---|---|
200 |
Applied |
202 |
Stored, awaiting the account holder's confirmation |
403 |
Key lacks create_paste, or the plan does not include API access |
422 |
Unknown visibility, a paste that is not yours, plan disallows it, or too many at once |
Over MCP
The markest_set_visibility tool takes the same arguments and follows the same
rules. When approval is required it returns the URL and says plainly that
nothing is published until the account holder confirms — an agent should pass
the link on rather than retry. Send every paste in one call so the person
confirms one screen instead of thirty.
Update a paste
PATCH /api/v1/pastes/{id} · requires create_paste
Change a paste's settings without touching its documents.
curl -X PATCH https://marke.st/api/v1/pastes/01J... \
-H "Authorization: Bearer $MARKEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Release notes", "folder": "Docs", "expires_in": 604800 }'
| Field | Type | Notes |
|---|---|---|
title |
string | null clears it. |
folder |
string | null or "" unfiles the paste. |
tags |
string | Comma-separated; replaces the current tags. |
default_path |
string | Which document opens first. Must already exist in the paste. |
expires_in |
int | Seconds from now. 0 means never, if your plan allows it. |
password |
string | Protects the paste. null or "" removes the password. |
burn_after_reading |
bool | Deletes the paste once read. |
track_versions |
bool | true records every later save; false stops recording and keeps the versions already recorded. |
proxy_images |
bool | true copies external images to Markest; false removes the copies already made and leaves images on their own sites, loaded only once the reader agrees. |
Send only the fields you want changed; anything omitted is left alone. An empty
body returns 400 rather than silently doing nothing.
Visibility is not settable here. It has one entry point — the visibility
endpoint above — because that is where the publishing confirmation lives.
Sending visibility to this endpoint returns 422 with a pointer, rather than
quietly giving you a second way to publish.
Password protection, burn-after-reading and no-expiry are plan features and
return 403 when your plan does not include them.
Returns the paste's settings after the change:
{
"id": "01J...",
"title": "Release notes",
"visibility": "unlisted",
"folder": "Docs",
"default_path": "README.md",
"burn_after_reading": false,
"password_protected": false,
"expires_at": "2026-09-11T08:00:00+00:00",
"document_count": 3,
"track_versions": false,
"proxy_images": true,
"url": "https://marke.st/p/01J...",
"updated_at": "2026-09-04T08:00:00+00:00"
}
Over MCP the same fields are available as markest_update_paste.
Version history
A paste can keep a version history: each save is recorded with what it changed
and the text of every document as it was. It is off unless you turn it on, per
paste with track_versions or for every new paste in your account settings.
Turning it off stops recording and keeps the versions already recorded.
History belongs to the paste's owner alone. These endpoints answer 404 for a
paste you do not own, even a public one. Restoring a version is done in the
editor.
List versions
GET /api/v1/pastes/{id}/versions · requires read_own
curl https://marke.st/api/v1/pastes/01J.../versions \
-H "Authorization: Bearer $MARKEST_API_KEY"
Versions come newest first. source says where each save came from: created,
editor, api, mcp, restore, enabled (the paste as it stood when history
was turned on) or system. changes compares a version with the one before it.
{
"paste_id": "01J...",
"track_versions": true,
"versions": [
{
"number": 2,
"source": "api",
"created_at": "2026-09-13T08:00:00+00:00",
"title": "Release notes",
"document_count": 2,
"changes": {
"title": null,
"default_document": null,
"added": ["CHANGELOG.md"],
"removed": [],
"modified": ["README.md"],
"renamed": [],
"retitled": [],
"reordered": false
}
},
{
"number": 1,
"source": "created",
"created_at": "2026-09-12T17:30:00+00:00",
"title": "Release notes",
"document_count": 1,
"changes": {
"title": [null, "Release notes"],
"default_document": null,
"added": ["README.md"],
"removed": [],
"modified": [],
"renamed": [],
"retitled": [],
"reordered": false
}
}
]
}
Read a version
GET /api/v1/pastes/{id}/versions/{number} · requires read_own
Returns the version and the documents it held. Add path to include that
document's text as the version kept it.
curl "https://marke.st/api/v1/pastes/01J.../versions/2?path=README.md" \
-H "Authorization: Bearer $MARKEST_API_KEY"
{
"paste_id": "01J...",
"number": 2,
"source": "api",
"created_at": "2026-09-13T08:00:00+00:00",
"title": "Release notes",
"document_count": 2,
"changes": {
"title": null,
"default_document": null,
"added": ["CHANGELOG.md"],
"removed": [],
"modified": ["README.md"],
"renamed": [],
"retitled": [],
"reordered": false
},
"default_path": "README.md",
"documents": [
{ "path": "README.md", "title": null, "content_type": "markdown" },
{ "path": "CHANGELOG.md", "title": null, "content_type": "markdown" }
],
"document": {
"path": "README.md",
"title": null,
"content_type": "markdown",
"content": "# Release notes\n\nFixed the export."
}
}
A version or path the paste does not have returns 404. Over MCP the same
history is read with markest_list_versions and markest_get_version.