API Reference
The sharedrop REST API lets you upload, manage, and share HTML pages programmatically.
Base URL
https://sharedrop.cloud/api/v1
Authentication
All requests require a Bearer token in the Authorization header:
curl -H "Authorization: Bearer sd_your_api_key_here" \
https://sharedrop.cloud/api/v1/pages
See Authentication for details on obtaining and managing API keys.
Rate Limits
All API requests are rate-limited per API key based on your plan tier. Rate limit information is included in response headers.
Response Headers
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Limits by Tier
| Endpoint | Free | Pro | Team |
|---|---|---|---|
| Upload | 20/min | 100/min | 200/min |
| List / Read / Update | 60/min | 300/min | 600/min |
| Delete / Share | 30/min | 150/min | 300/min |
Invitation emails sent when you share a page by email, or make a disappearing link for specific people, have their own daily limit per account: 10 a day on Free, 500 on Pro and 2000 on Team. Going over it never blocks the share; see Daily email limit.
Page URLs and custom domains
Every page, reservation and share response carries two address fields: url, the path under the
owner's handle, and full_url, the absolute address to give a recipient.
full_url is the branded address when the page's owner has a live
custom domain and the page is eligible for it, for example
https://share.yourcompany.com/yourhandle/abc123. Otherwise it is the standard
https://sharedrop.cloud/yourhandle/abc123. A page in a Team workspace uses the workspace's
domain, not a member's personal one. Always send recipients full_url as returned rather
than building an address from url and a hostname of your own.
Only public and shared pages are eligible. Private pages and archives always return the standard
address. API requests themselves always go to https://sharedrop.cloud; your custom domain carries
recipient traffic only.
Custom domains need exactly two records: one CNAME for the share subdomain and one CNAME for the
view subdomain. See Custom domains for setup details.
Response Format
Success
{
"data": { ... }
}
List (Paginated)
{
"data": [ ... ],
"pagination": {
"next_cursor": "uuid-or-null",
"has_more": true
}
}
Error
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description"
}
}
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Invalid or missing API key |
FORBIDDEN | 403 | API key lacks the required scope (e.g. a pages:read key used to write) |
RATE_LIMIT_EXCEEDED | 429 | Too many requests. Check X-RateLimit-* headers. |
PAGE_NOT_FOUND | 404 | Page does not exist or you don't have access |
VALIDATION_ERROR | 400 | Invalid request body or parameters |
TIER_LIMIT | 402 | A new page would go over your plan's page limit, or the file kind or feature is not on your plan |
FILE_SIZE_EXCEEDED | 413 | File exceeds the size limit for your plan, the 10 MB limit for HTML, other text files and SVG images, or a bundle exceeds its total limit |
STORAGE_LIMIT | 402 | The operation would exceed the resolved storage cap (see Trash and storage accounting) |
PAYMENT_REQUIRED | 402 | The account is read only after a failed payment. Update the card to write again. |
Retired endpoints return 410 Gone (see Upload a Page). Scoped keys that lack write access are rejected with 403 FORBIDDEN before any work is done.
Billing lock (402 PAYMENT_REQUIRED)
When a subscription payment fails and is still unpaid 21 days later, the account goes read only.
Listing, getting and deleting keep working, and so do fetching and downloading documents. The lock
also drops the plan to Free, which starts the 14 day clock for images, video and archives: from 14
days after the lock, fetching or downloading them returns 404 until the plan is restored, and 28
days after the lock they are deleted. Every write below returns 402 with this body:
{
"error": {
"code": "PAYMENT_REQUIRED",
"message": "Your account is read only because a payment failed. Update your card at sharedrop.cloud/dashboard/settings/billing to start uploading again.",
"currentTier": "free",
"upgradeUrl": "https://sharedrop.cloud/dashboard/settings/billing",
"pricing": { ... }
}
}
upgradeUrl is the page that fixes the card, not an upsell. For a Team workspace the message
names the workspace instead: a workspace owner is pointed at that workspace's billing page, and a
member is told to ask an owner. Relay error.message to the human as it arrives.
Every write endpoint that returns it, including the ones without their own section below:
| Endpoint | What it does |
|---|---|
POST /api/upload/sign | Step 1 of a file upload |
POST /api/upload/bundle/sign | Step 1 of a folder bundle upload |
POST /api/archives/create | Start an archive upload |
PATCH /api/v1/pages/:id | Update a page |
PUT /api/pages/:id | Update a page (dashboard route) |
POST /api/pages/:id/move | Move a page into or out of a folder |
POST /api/pages/:id/access | Grant access to a page by email |
POST /api/v1/pages/:id/share | Share a page by email |
POST /api/pages/:id/ephemeral-links | Create a disappearing link |
POST /api/v1/pages/:id/ephemeral-links | Create a disappearing link |
POST /api/pages/:id/versions | Restore an earlier version |
POST /api/trash/:id/restore | Restore a page or folder from trash |
Nothing else is gated. Reading, downloading, deleting, emptying the trash, revoking a share, revoking a disappearing link, and the reservation and folder endpoints all keep working. The lock lifts by itself on the next successful payment. See Failed payments.
Endpoints
Identity & Entitlements
GET /api/v1/me
Resolve the account, tier, usage, and per-tier entitlements for the current API key. Call this first: it tells you the username, tier, remaining quota, and which capabilities (visibilities, upload kinds, folders, fetch) your plan allows, so an agent can decide what it may do before attempting it. For a workspace-scoped key, the tier, usage, and entitlements reflect the workspace; username and email stay personal.
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/me
Response 200:
{
"data": {
"username": "you",
"email": "you@example.com",
"tier": "pro",
"pages_used": 8,
"pages_limit": 1000,
"storage_used": 5242880,
"entitlements": {
"maxFileSizeBytes": 104857600,
"maxTextFileSizeBytes": 10485760,
"maxArchiveBytes": 10737418240,
"allowedVisibilities": ["private", "shared", "public"],
"allowedKinds": ["document", "image", "video"],
"maxVersionRetention": 25,
"fetch": true,
"folders": true
},
"storage": { "usedGb": 0.1, "capGb": 25, "addonGb": 0 }
}
}
entitlements.maxFileSizeBytes is the plan's per-file limit. entitlements.maxTextFileSizeBytes is the lower
limit for HTML, other text files (slide decks, MHTML, Markdown, source code, plain text, JSON, JSONL, skills,
Word documents, spreadsheets) and SVG images: 10 MB on every plan. Check a file against it before you sign.
entitlements.maxArchiveBytes is the separate per-file ceiling for archives (POST /api/archives/create), which can
be larger than maxFileSizeBytes. It is 0 on plans that cannot upload archives.
storage.addonGb is the add-on storage on the subscription. It is part of capGb only while the
subscription is paid (active, trialing, or past due during the retry window).
Error Cases:
401 UNAUTHORIZED: Missing or invalid API key
Upload a Page (streamed)
The legacy inline create endpoint has been retired and now returns
410 Gone. Upload via the streamed flow below, the CLI, or MCP.
Uploading is a streamed, three-step flow. The file bytes are PUT directly to object storage out-of-band, so there is no request-body size cap on the function.
Storing a large or opaque file you want handed back rather than rendered (a database dump, a build tarball, a
.zip, or anything over 30 MB)? Use an archive instead. Archives (Pro and Team) stream in resumable parts up to 10 GB and are private download-only. See the Archives guide for the/api/archives/*lifecycle.
Supported file types: HTML (.html, .htm), MHTML web archives (.mhtml, .mht), Markdown (.md, .markdown), PDF (.pdf), JSON (.json), JSONL (.jsonl), source code and plain text (.js, .ts, .py, .css, .sql, .sh, .txt, and similar), Word documents (.docx), spreadsheets (.csv, .xlsx), images (.png, .jpg, .jpeg, .webp, .gif, .avif, .bmp, .ico, .apng, .svg, .heic, .heif, .tif, .tiff), and video (.mp4, .webm, .mov, .m4v). Documents (everything except images and video) upload on every tier; images and video require Pro.
Size limits: each file can be up to your plan's per-file limit (Free 10 MB, Pro and Team 100 MB). HTML and other text files (HTML, slide decks, MHTML, Markdown, source code, plain text, JSON, JSONL, skills, Word documents, spreadsheets) and SVG images are capped at 10 MB on every plan. PDF, other images and video keep the plan limit. A folder bundle is capped at your plan's per-file limit across all its files together (Free 10 MB, Pro and Team 100 MB). Sign refuses an oversized file or bundle with FILE_SIZE_EXCEEDED before any bytes are uploaded; the error names the limit (limitBytes) and your size (requestedBytes). For larger HTML or text content, split it, or upload it as a zip: it is stored as a download-only archive (Pro and Team).
Step 1: sign
POST /api/upload/sign
Reserve an object key, run the tier and storage gate, and mint a short-lived upload token.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Filename including extension (e.g. report.pdf) |
content_type | string | Yes | MIME type (e.g. application/pdf) |
size_bytes | number | Yes | Exact body size in bytes. Must match the Content-Length of the PUT |
workspace | string | No | Workspace ID to upload to. Note the name: sign reads workspace, while finalize and lint read workspace_id. Send the same workspace to both, or finalize refuses with workspace_mismatch |
curl -X POST \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"filename": "report.pdf", "content_type": "application/pdf", "size_bytes": 482190}' \
https://sharedrop.cloud/api/upload/sign
The response includes an upload_url, a 5-minute upload_token, a finalize_url, and the object_key.
Step 2: PUT the bytes
Stream the file bytes directly to the upload_url returned by step 1, authenticating with the upload_token:
curl -X PUT "$UPLOAD_URL" \
-H "Authorization: Bearer $UPLOAD_TOKEN" \
-H "Content-Type: application/pdf" \
-H "Content-Length: $(stat -f%z report.pdf)" \
--data-binary @report.pdf
Step 3: finalize
POST /api/upload/finalize
Validate the token, sanitise the uploaded bytes, publish the page, and create the page row.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
object_key | string | Yes | The object_key returned by step 1 |
upload_token | string | Yes | The upload_token returned by step 1 |
title | string | No | Page title. Defaults to the document title (HTML/PDF) or filename stem |
visibility | string | No | private (default), shared, or public |
mode | string | No | HTML only: static or interactive (default: your account's default upload mode, initially interactive; configure in Settings). Ignored for non-HTML kinds |
workspace_id | string | No | Upload to a specific workspace |
page_id | string | No | Existing page ID for re-upload (URL stays stable, version recorded) |
slides | boolean | No | HTML only: publish as a slide deck (kind: slides) with fullscreen Present mode. Equivalent to a <meta name="sharedrop:kind" content="slides"> marker in the file. Ignored for non-HTML kinds, and ignored if sent to /sign instead of here |
curl -X POST \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"object_key": "...", "upload_token": "...", "title": "Q4 Report", "visibility": "public"}' \
https://sharedrop.cloud/api/upload/finalize
Response 200 (new page or re-upload): the body is a flat JSON object, not wrapped in data.
{
"url": "/scottoau/ab12cd34ef",
"full_url": "https://sharedrop.cloud/scottoau/ab12cd34ef",
"page_id": "3f0c2b4e-8f7a-4c1d-9e2b-1a2b3c4d5e6f",
"slug": "ab12cd34ef",
"title": "Q3 report",
"mode": "interactive",
"visibility": "private",
"kind": "html",
"contentType": "text/html",
"was_reupload": false,
"version": 1,
"scripts_will_run": false,
"external_resource_hosts": ["cdn.jsdelivr.net"],
"warnings": [
{ "code": "images_extracted", "detail": "img", "count": 2, "message": "2 inline images were moved to hosted storage." },
{ "code": "external_refs_block_scripts", "detail": "cdn.jsdelivr.net", "count": 1, "message": "Scripts will not run because the page loads resources from 1 external host. Turn on external network for this page or vendor the files." }
],
"same_title_pages": [
{ "id": "7d1e0a9c-2b3f-4e5d-8c7b-6a5f4e3d2c1b", "full_url": "https://sharedrop.cloud/scottoau/zz98yy76xx", "updated_at": "2026-10-06T22:14:03.000Z" }
]
}
| Field | Meaning |
|---|---|
mode | The mode the page got: mode from the body, else (new page) your account's default upload mode, else (re-upload) the page's current mode |
was_reupload | true when page_id replaced an existing page |
version | Per-page counter: 1 for a new page, up by one on every re-upload or restore. A re-upload returns the same page_id and url with a higher version |
scripts_will_run | Whether the page's scripts run with its current settings: mode is interactive, the kind is html or slides, scripts are not blocked, and either external network is on or external_resource_hosts is empty. Owner changes later can change it. Always false for other kinds |
external_resource_hosts | Hosts the stored root HTML loads resources from (empty for non-HTML kinds) |
warnings | Always present, possibly empty. See the warning codes below |
same_title_pages | New page only (never on a re-upload): up to 5 of the owner's live pages with exactly the same title (case-insensitive), in the same owner scope (your personal pages, or that workspace), newest first, excluding the new page. A hint: send page_id next time if the upload was meant to revise one. Always [] when the caller is a reservation claim token (sdr_) or an API key without the pages:read scope |
sanitiser_warnings | Unchanged for older clients: only the removed_tag and removed_attribute warnings, present only when non-empty. New clients should read warnings |
A re-upload returns the same fields with "was_reupload": true, the next version, and no
same_title_pages key. POST /api/upload/bundle/finalize returns the same fields plus assets.
Warning codes (every item in warnings has code, detail, count and message):
code | detail | count |
|---|---|---|
removed_tag | The removed tag name | Number of removals |
removed_attribute | The removed attribute name | Number of removals |
images_extracted | img | Inline images moved to hosted storage |
external_refs_block_scripts | The external hosts, joined by , (first 10) | Number of hosts |
external_refs_block_scripts is sent only when the page is interactive, its kind is html or
slides, external network is off, and the stored HTML references external hosts. Branch on
code, not on message.
Error Cases:
Plan and quota limits are checked at sign, so most refusals arrive before any bytes move. Sign returns:
400with a plainerrorstring: an invalid body (Invalid request body, withdetails),reservation_claim_conflictwhen bothreservation_idandpage_idare sent, orreservation_id_mismatchwhen a reservation claim token sends areservation_idother than the one it was issued for401with{ "error": "Unauthorized" }: missing or rejected API key402 PAYMENT_REQUIRED: the account is read only after a failed payment (see Billing lock)402 TIER_LIMIT: the file kind is not on your plan (for example an image on Free), or a new page would go over your page limit402 FILE_SIZE_EXCEEDED: the file is over the size limit for your plan, or the 10 MB limit for HTML, other text files and SVG images402 STORAGE_LIMIT: live plus recoverable trash plus uploads still in progress plus the new file would exceed the storage cap403 FORBIDDEN: the API key lacks thepages:writescope. A reservation claim token is never refused this way at sign; its plain{ "error": "Claim tokens cannot read existing content" }403 comes only from read routes such as archive download404: the workspace or page does not exist or is not yours413with"code": "USE_MULTIPART": an archive over 30 MB, which must use/api/archives/create429: rate limited
Finalize re-checks some of these and returns:
400with a plainerrorstring: an invalid body (Invalid request body, withdetails),workspace_mismatch,page_id_mismatch,reservation_mismatchorobject_key_mismatch(the body does not match what the token was signed for),unsupported_file_type, a re-upload onto an archive page, or a file that could not be processed401with{ "error": "Invalid token", "code": "upload_token_invalid" }: the upload token from step 1 is invalid or past its 5-minute window. Your API key is fine: sign and PUT again. Branch oncode. A rejected API key is a401with{ "error": "Unauthorized" }and nocode. Both shapes also apply toPOST /api/upload/bundle/finalizeand to lint402 TIER_LIMIT: the kind is not on your plan, or a new page would go over your page limit403 FORBIDDEN: the API key lacks thepages:writescope404: the page, workspace or uploaded bytes were not found (the PUT did not finish, or the quarantined copy expired after 24 hours)413 FILE_SIZE_EXCEEDED: the uploaded bytes are larger than sign allowed, or over the per-kind size limit429: rate limited500,502or503: a server or storage fault. Retry only when the body hasretryable: true409withretryable: trueand aRetry-Afterheader (seconds): the page is busy, so wait that long and send the same finalize request again (no need to upload the bytes again). The body'sreasonsays why:page_mutation_in_progress(another replacement of this page is still finishing) ortarget_page_pending(another finalize with the same upload token is still running). The same applies toPOST /api/upload/bundle/finalize. A409withoutretryable: trueis not worth retrying
{
"error": "Another replacement of this page is still in progress. Please try again.",
"reason": "page_mutation_in_progress",
"retryable": true
}
Retrying: finalizing a new page (single file or folder bundle, but not a reserved-address claim) is safe to retry with the same upload tokens. If the first attempt already finished, the retry returns the same page and URL and creates nothing new, even when the first response never arrived.
Pre-check without publishing (lint)
POST /api/upload/lint
POST /api/upload/bundle/lint
Run the real finalize checks on uploaded bytes without publishing them. Sign and PUT exactly as
above, then call lint instead of finalize, with the same body you would send to finalize
(object_key and upload_token, plus optional mode, slides, page_id and title; for a
bundle, the bundle finalize body). Lint runs the sanitiser, inline image extraction, slides
detection, the size limit and the external-host check, then deletes the quarantined object(s). It
writes no page, version or stored page data and uses no page slot. It has its own rate limit
bucket, with the same per-plan limits as finalize. The CLI's sharedrop check uses it.
Because a pre-check signs first, it needs what an upload needs at sign: a free page slot (unless
the sign sends page_id) and storage headroom for the file. At the page or storage cap, sign
refuses with the same 402 TIER_LIMIT or 402 STORAGE_LIMIT an upload gets, and lint is never
reached. A pre-check still never uses a page slot and never publishes.
Response 200:
{
"kind": "html",
"mode_effective": "interactive",
"detected_slides": false,
"size_ok": true,
"size_bytes": 48211,
"size_limit_bytes": 26214400,
"title": "Q3 report",
"warnings": [ { "code": "removed_tag", "detail": "iframe", "count": 1, "message": "Removed 1 <iframe> element." } ],
"images_extracted": 2,
"external_resource_hosts": [],
"scripts_would_run": true,
"same_title_pages": [],
"would_change": true
}
| Field | Meaning |
|---|---|
mode_effective | mode from the body, else the mode of the page in page_id, else your account's default upload mode |
would_change | true when size_ok is false, or any warning is removed_tag, removed_attribute or external_refs_block_scripts. images_extracted alone is not a change |
scripts_would_run | The same rule as scripts_will_run on finalize, for these bytes and settings |
same_title_pages | As on finalize, for a new page. Always [] when page_id is sent, when the caller is a reservation claim token (sdr_), or when the API key lacks the pages:read scope |
files | Bundle lint only: the number of files in the bundle |
Lint uses the finalize error cases.
List Pages
GET /api/v1/pages
List your pages with cursor-based pagination.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (max 100) |
cursor | string | None | Pagination cursor from previous response |
workspace_id | string | None | Filter to a specific workspace |
curl -H "Authorization: Bearer sd_..." \
"https://sharedrop.cloud/api/v1/pages?limit=10"
Response 200:
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "abc123",
"title": "My Report",
"mode": "static",
"file_size": 2048,
"visibility": "private",
"url": "/username/abc123",
"full_url": "https://sharedrop.cloud/username/abc123",
"created_at": "2026-04-07T00:00:00.000Z",
"updated_at": "2026-04-07T00:00:00.000Z"
}
],
"pagination": {
"next_cursor": "660e8400-e29b-41d4-a716-446655440001",
"has_more": true
}
}
To fetch the next page, pass cursor from pagination.next_cursor:
curl -H "Authorization: Bearer sd_..." \
"https://sharedrop.cloud/api/v1/pages?limit=10&cursor=660e8400-e29b-41d4-a716-446655440001"
Get Page
GET /api/v1/pages/:id
Get metadata for a specific page.
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000
Response 200:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "abc123",
"title": "My Report",
"mode": "interactive",
"file_size": 2048,
"visibility": "private",
"url": "/username/abc123",
"full_url": "https://sharedrop.cloud/username/abc123",
"external_network_enabled": false,
"version": 3,
"scripts_blocked": false,
"scripts_will_run": true,
"external_resource_hosts": [],
"created_at": "2026-04-07T00:00:00.000Z",
"updated_at": "2026-04-07T00:00:00.000Z"
}
}
| Field | Meaning |
|---|---|
version | The page's version counter (1 for a new page, up by one on every re-upload or restore) |
scripts_blocked | The owner has turned scripts off for this interactive page (reversible, no re-upload needed) |
scripts_will_run | Computed from the page's current state: mode is interactive, the kind is html or slides, scripts_blocked is false, and either external_network_enabled is true or external_resource_hosts is empty. mode: interactive alone does not mean scripts run |
external_resource_hosts | Hosts the stored HTML loads resources from |
Error Cases:
404 PAGE_NOT_FOUND: Page does not exist or you don't have access
Fetch Page (raw content)
GET /api/v1/pages/:id/fetch
Mint a short-lived signed URL that returns a page's raw stored bytes, for an agent pulling a page's content into its context. This is the agent-native counterpart to a human download: it returns the original file with its real content type, never a zip and never the sandboxed viewer wrapper. Available on every tier (advertised as entitlements.fetch in the GET /api/v1/me response).
The mint response does not contain the bytes. It returns a fetch_url on the viewer origin that you then GET to stream the content. This mirrors the upload flow in reverse (mint → GET, like create_upload → PUT), so large pages never inflate the JSON response.
Access matches the rest of the API: the owner always may; a public page may be fetched by any authenticated caller; a shared/private page requires an active access grant matching one of your verified emails. Every denial returns 404.
Image and video pages have two extra rules. While a new upload is still in its safety check, only the owner can fetch it. Once the plan that pays for the page has lapsed for more than 14 days, nobody can fetch it, the owner included, until the plan is restored. Both return 404. Archives follow the same plan rule: a locked archive returns 404 instead of ARCHIVE_DOWNLOAD_REQUIRED.
# 1. Mint the fetch URL
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000/fetch
Response 200:
{
"data": {
"fetch_url": "https://view.sharedrop.cloud/api/fetch/550e8400-e29b-41d4-a716-446655440000?token=...",
"expires_at": "2026-04-07T00:05:00.000Z",
"content_type": "text/html; charset=utf-8",
"mode": "static",
"size": 2048
}
}
# 2. Pull the raw bytes -- no auth header, the URL token is the credential
curl "https://view.sharedrop.cloud/api/fetch/550e8400-...?token=..." -o page.html
The pull endpoint streams the bytes with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and Content-Security-Policy: sandbox so the content can never execute or make network requests. The token is single-purpose and expires after ~5 minutes; an invalid, expired, or wrong-page token returns 404.
Error Cases:
401 UNAUTHORIZED: Missing or invalid API key (mint endpoint)404 PAGE_NOT_FOUND: Page does not exist or you don't have access; or an invalid/expired fetch token (pull endpoint)409 ARCHIVE_DOWNLOAD_REQUIRED: The page is an archive. Archives have nofetch_url; the error carriesdownload_url(GET /api/archives/:id/download, same Bearer key), which redirects to the file
{
"error": {
"code": "ARCHIVE_DOWNLOAD_REQUIRED",
"message": "This page is an archive, so its raw content cannot be fetched. ...",
"download_url": "https://sharedrop.cloud/api/archives/550e8400-e29b-41d4-a716-446655440000/download"
}
}
Download Page (zip)
GET /api/v1/pages/:id/download
Stream a page's complete artefact as a zip: the root document plus every asset (images, CSS, JS) beneath it, with their original relative paths preserved. This is the human-facing counterpart to fetch: download returns the whole bundle, fetch returns just the raw root document. The response body is the zip bytes (Content-Type: application/zip), not a JSON envelope.
Access matches the rest of the API: you can download a page you own, or one shared to you with zip download enabled (the sharer ticks "allow zip download" on your access grant, matched against your account's primary verified email). Every denial (unauthenticated, no page, not authorised, or an empty page) returns 404 PAGE_NOT_FOUND, so existence is never leaked.
Image and video pages follow the same two extra rules as fetch: only the owner while the upload is in its safety check, and nobody once the paying plan has lapsed for more than 14 days. Archives follow the plan rule too, on this route and on the archive download route.
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000/download \
-o report.zip
Error Cases:
401 UNAUTHORIZED: Missing or invalid API key404 PAGE_NOT_FOUND: Page does not exist, you can't download it, or it has no downloadable content409 ARCHIVE_DOWNLOAD_REQUIRED: The page is an archive; download it from the returneddownload_urlinstead (see Fetch)
Update Page
PATCH /api/v1/pages/:id
Update a page's title, visibility, custom address, or serving options.
Request Body (JSON):
| Field | Type | Description |
|---|---|---|
title | string | New page title |
visibility | string | public, private, or shared |
slug | string | Custom address for a public page. Requires Pro or higher |
webview_enabled | boolean | Serve the page as a standalone website without the viewer frame. Not available for archives |
external_network_enabled | boolean | Let an interactive page reach outside Sharedrop. Interactive pages only |
All fields are optional. Include only the fields you want to change. The page object reports the two serving options as webview_enabled and external_network_enabled.
For slug, use 3 to 60 lowercase letters, numbers, and hyphens. It cannot start or end with a hyphen, contain consecutive hyphens, be a reserved word, or look like a generated address. Custom addresses work only on public pages and a page can be renamed up to 10 times. Its previous address is kept forever and permanently sends a 301 redirect to the current address. A retired address cannot be reused by any page. Changing the page to private or shared returns it to a generated random address and the custom address stops resolving.
curl -X PATCH \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title", "visibility": "public"}' \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000
Response 200:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "abc123",
"title": "Updated Title",
"mode": "static",
"file_size": 2048,
"visibility": "public",
"url": "/username/abc123",
"full_url": "https://sharedrop.cloud/username/abc123",
"created_at": "2026-04-07T00:00:00.000Z",
"updated_at": "2026-04-07T00:00:00.000Z"
}
}
Error Cases:
400 VALIDATION_ERROR: Invalid JSON body or field values403 VALIDATION_ERROR: Visibility not available on your plan (e.g., private on Free tier)402 TIER_LIMIT:slugrequires a Pro plan or higher400 VALIDATION_ERROR:external_network_enabledon a page that is not interactive, orwebview_enabled: trueon an archive400 VALIDATION_ERROR: A custom address can be set only on a public page409 VALIDATION_ERROR: The requested custom address is already in use or retired402 PAYMENT_REQUIRED: The account is read only after a failed payment (see Billing lock)404 PAGE_NOT_FOUND: Page does not exist or you don't have access
Delete Page
DELETE /api/v1/pages/:id
Move a page and its versions to recoverable trash. Its stored bytes continue to count toward the storage cap until the item expires or you permanently clear it. Archives stay in trash for 7 days. Other items, including folders, stay for 30 days.
curl -X DELETE \
-H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000
Response 200:
{
"data": {
"success": true
}
}
Error Cases:
404 PAGE_NOT_FOUND: Page does not exist or you don't have access
Trash and storage accounting
Deleting a page does not immediately release its stored bytes. New byte-creating operations use this comparison:
live bytes + trash bytes + in-progress bytes + candidate bytes <= storage cap
This rule applies to single-file sign, bundle sign, archive create, and archive
completion. Equality is allowed. When the comparison fails, Sharedrop returns
HTTP 402 with a nested STORAGE_LIMIT billing envelope. currentUsageGb
is live plus trash plus in-progress bytes before the candidate, and optional
trashedGb identifies the trash-only part.
In-progress bytes are uploads that were signed but not finalized yet. A sign holds its bytes until finalize succeeds or 10 minutes pass, so uploads started in parallel cannot each use the same free space. An abandoned upload frees its space on its own after those 10 minutes.
The storage cap is the plan's storage plus any storage add-on, but the add-on
counts only while the subscription is paid (active, trialing, or past due
during the retry window). If you remove an add-on, or the subscription becomes
unpaid, the cap shrinks at once. Files already stored stay where they are and
stay viewable, but every upload surface refuses new bytes with
STORAGE_LIMIT until usage is back under the cap.
Restore uses a different ceiling because the selected item is already inside trash:
live bytes + selected subtree bytes <= storage cap
Unrelated trash is not added to the restore comparison. Clearing unrelated
trash therefore does not make a blocked restore fit. The response still
includes trashedGb as a diagnostic.
Archives stay in trash for 7 days. Other items, including folder rows and non-archive pages, stay in trash for 30 days. Expiry and Empty trash permanently remove the rows first, then attempt storage cleanup.
List trash
GET /api/trash
Requires pages:read. Returns the authenticated caller's trashed rows.
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/trash
Response 200:
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Q4 Report",
"nodeType": "page",
"parentId": null,
"path": "/550e8400-e29b-41d4-a716-446655440000",
"deletedAt": "2026-07-27T00:00:00.000Z",
"kind": "pdf",
"fileSize": 1048576
}
]
}
Empty trash
DELETE /api/trash
Requires pages:write. This operation is irreversible. It permanently deletes
the authenticated caller's eligible trash rows and returns stable counters even
when trash is already empty.
curl -X DELETE \
-H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/trash
Response 200:
{
"success": true,
"purged": 3,
"freedBytes": 1048576
}
purged is the number of database rows removed. freedBytes is the trash byte
total claimed by that authenticated owner scope before deletion. Storage cleanup
is best effort after the guarded database delete.
Workspace storage meters and new-write gates charge all trash in the workspace. Empty trash removes only rows owned by the authenticated caller, even when that caller is a workspace member or owner. A workspace-bound key narrows the same caller-owned delete to its bound workspace. Workspace-owner-wide Empty trash is tracked separately and is not implemented.
Restore a trashed item or folder subtree
POST /api/trash/:id/restore
Requires pages:write. The id must name a trashed row owned by the authenticated
caller. If its original parent is unavailable, the restored root returns to the
top level.
Response 200:
{
"success": true,
"reparentedToRoot": false
}
If live bytes + selected subtree bytes exceeds the cap, restore returns HTTP
402 and preserves the full billing response:
{
"error": {
"code": "STORAGE_LIMIT",
"message": "Storage cap reached.",
"currentTier": "pro",
"currentUsageGb": 20,
"trashedGb": 4,
"capGb": 25,
"recommendedAddonGb": 25,
"upgradeUrl": "https://sharedrop.cloud/dashboard/settings/billing#storage",
"pricing": {
"pro": { "monthly": 9, "storageGb": 25 },
"team": {
"bundle": { "seats": 3, "monthly": 29 },
"additionalPerSeat": 7,
"storagePerSeatGb": 50
},
"storageAddons": [
{ "blockGb": 25, "monthly": 5 },
{ "blockGb": 250, "monthly": 20 },
{ "blockGb": 1024, "monthly": 60 }
],
"currency": "AUD"
}
}
}
Error Cases:
401 UNAUTHORIZED: Missing or invalid credentials402 STORAGE_LIMIT: Live usage plus the selected subtree would exceed the cap402 PAYMENT_REQUIRED: The account is read only after a failed payment (see Billing lock)404: The row does not exist, is not owned by the caller, or is not trashed429: Rate limit exceeded; respectRetry-After
Share Page
POST /api/v1/pages/:id/share
Share a page with someone by email address. Creates an access grant that allows the recipient to view the page.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address of the person to share with |
curl -X POST \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"email": "colleague@example.com"}' \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000/share
Response 201:
{
"data": {
"id": "grant-uuid",
"email": "colleague@example.com",
"status": "pending",
"created_at": "2026-04-07T00:00:00.000Z",
"visibility_promoted": null,
"email_warning": null
}
}
The grant starts as pending. When the recipient signs in to sharedrop with a matching email, the grant automatically activates and they can view the page.
Notifications (best-effort): A successful share always creates the access grant. On top of that, sharedrop makes two best-effort attempts that are not part of the synchronous response and are not guaranteed to be delivered:
- It fires an
access.grantedwebhook to the page owner's configured webhook and Telegram destinations (see the Webhooks guide). - It sends a gated invitation email to the recipient with a one-click unsubscribe link. This email is gated by the owner's notification preference, the recipient's suppression status (a prior unsubscribe, bounce, or complaint), a 24-hour per-recipient cooldown, and a daily limit, so it may not be sent for a given share. The subject is always
<username> shared "<page title>" with you on Sharedrop, with the title cut to 60 characters and any web address or domain removed from it (if nothing is left, the subject reads<username> shared a page with you on Sharedrop). The page title in the body of the email has any web address or domain removed in the same way (it shows as "Untitled page" if nothing is left).
Daily email limit
Each account can send 10 invitation emails a day on Free, 500 on Pro and 2000 on Team (a workspace page uses the workspace's plan). The limit is shared with the emails for disappearing links made for specific people. If our rate-limit service is down, the limit is still enforced, but each of our servers counts on its own until the service is back, so for that time the total across servers can go over it.
Going over the limit never fails the share: the response is still 201 and the grant works. Instead email_warning is set, so you can tell the user to send the link themselves:
"email_warning": {
"code": "SHARE_EMAIL_LIMIT_REACHED",
"limit": 10,
"message": "No email was sent because you have reached your daily limit of 10 share emails. Send the link yourself, or try again tomorrow."
}
The limit is checked when you share, so email_warning is exact even for several shares in quick succession: null means the email counted against the limit and will be sent (the other rules above still apply), and a set email_warning means it will not be sent. Its code is SHARE_EMAIL_LIMIT_REACHED when the daily limit stopped the email, or SHARE_EMAIL_NOT_SENT (with limit set to null) when a problem on our side meant the email could not be checked against the limit, so it was not sent either. For a disappearing link made for several people, the message says how many were not emailed, for example 2 of 5 people were not emailed because you have reached your daily limit of 10 share emails. Send the link to them yourself, or try again tomorrow. The dashboard shows the same message, the MCP tools return the same field, and the CLI prints it.
Error Cases:
400 VALIDATION_ERROR: Invalid email address402 PAYMENT_REQUIRED: The account is read only after a failed payment (see Billing lock)404 PAGE_NOT_FOUND: Page does not exist or you don't have access
List Shares
GET /api/v1/pages/:id/shares
List the active access grants for a page (revoked grants are excluded). Owner-only.
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000/shares
Response 200:
{
"data": [
{
"id": "grant-uuid",
"email": "colleague@example.com",
"status": "active",
"created_at": "2026-04-07T00:00:00.000Z",
"updated_at": "2026-04-07T00:00:00.000Z"
}
]
}
Error Cases:
404 PAGE_NOT_FOUND: Page does not exist or you don't have access
Disappearing Links
POST /api/v1/pages/:id/ephemeral-links
GET /api/v1/pages/:id/ephemeral-links
PATCH /api/v1/pages/:id/ephemeral-links/:linkId
DELETE /api/v1/pages/:id/ephemeral-links/:linkId
Create, list, change the people on, and revoke separate links that stop working after a time limit or a number of views (Pro and Team). A link is for anyone holding it ("audience": "anyone", the default) or only for named people who sign in ("audience": "people" with "emails"). It never changes the page's own visibility or share list. The people on a "people" link are emailed the link unless you pass "notify": false. Fields, responses and errors are in the Disappearing links guide.
Reservations
Reserve a placeholder /{username}/{slug} address before any content exists, hand out the final URL up front, and let the first upload claim it into a live page. See the Reserved addresses guide for the end-to-end walkthrough and the placeholder/expiry behavior.
Reads (GET) require a pages:read scope; mutations (POST, PATCH, DELETE) require pages:write. Every reservation carries a lifecycle status:
| Status | Meaning |
|---|---|
reserved | Waiting for its first upload |
claimed | Already claimed into a live page (claimed_page_id is set) |
revoked | Manually revoked; the claim token is dead |
expired | Passed its expires_at unclaimed; the slug is freed and the claim token is dead |
Revoked and expired rows stay in the list for history.
Create a Reservation
POST /api/v1/reservations
Reserve an address and mint a one-time claim credential. Returns 201 with the serialized reservation (including the final url) plus a sibling claim_token (an sdr_ credential). The token is shown here once, never appears in any later response, and is never logged: store it now and hand it to the agent over a secure channel.
Request Body (JSON): every field is optional.
| Field | Type | Description |
|---|---|---|
title | string | Page title shown once the address is claimed (default Untitled) |
description | string | Optional description for your own reference |
intended_agent_name | string | Name of the agent this address is held for (shown in the dashboard) |
visibility | string | private (default), shared, or public. The claimed page inherits it |
slug | string | Optional custom address for a public reservation. Requires Pro or higher |
mode | string | HTML render mode the claimed page takes: static or interactive |
watermark_enabled | boolean | Whether the claimed page is watermarked (default false) |
folder_id | string | Pro folder to file the claimed page into |
expires_at | string | ISO 8601 datetime after which an unclaimed reservation expires. Omit to keep it until claimed or revoked |
curl -X POST \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"title": "Q3 Metrics", "intended_agent_name": "reporting-bot", "visibility": "public"}' \
https://sharedrop.cloud/api/v1/reservations
Response 201:
{
"data": {
"reservation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "abc123",
"title": "Q3 Metrics",
"description": null,
"intended_agent_name": "reporting-bot",
"visibility": "public",
"mode": "static",
"watermark_enabled": false,
"status": "reserved",
"claimed_page_id": null,
"url": "/username/abc123",
"full_url": "https://sharedrop.cloud/username/abc123",
"expires_at": null,
"created_at": "2026-07-19T00:00:00.000Z",
"updated_at": "2026-07-19T00:00:00.000Z"
},
"claim_token": "sdr_example_shown_once_store_now"
}
}
The agent claims the address by streaming its first upload with this reservation, either by passing reservation_id to the MCP create_upload tool or the CLI --to flag, or by presenting the sdr_ token as the Bearer credential to the sign/finalize endpoints. The finished upload becomes a new page at the reserved URL, inheriting the visibility, mode, and folder chosen here.
For slug, use 3 to 60 lowercase letters, numbers, and hyphens. It cannot start or end with a hyphen, contain consecutive hyphens, be a reserved word, or look like a generated address. A custom reservation must be public and requires Pro or higher. When a live public page is renamed, its previous address is retained permanently as a 301 redirect to the current address; retired addresses cannot be reused.
Error Cases:
400 VALIDATION_ERROR: Invalid body or an out-of-range field402 TIER_LIMIT: Over your plan's active-reservation cap, or a visibility your plan does not allow. Free allows 2 active reservations, Pro allows 25, Team is unlimited. The402carries a billing envelope with an upgrade URL402 TIER_LIMIT:slugrequires a Pro plan or higher400 VALIDATION_ERROR: A custom address can be reserved only for a public page409 VALIDATION_ERROR: The requested custom address is already in use or retired
List Reservations
GET /api/v1/reservations
List your reservations with cursor-based pagination. Includes reserved, claimed, revoked, and expired rows.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (max 100) |
cursor | string | None | Pagination cursor from the previous response |
curl -H "Authorization: Bearer sd_..." \
"https://sharedrop.cloud/api/v1/reservations?limit=20"
The response is the standard paginated envelope: data is an array of serialized reservations (the same shape as create, without the claim_token), and pagination carries next_cursor and has_more.
Get a Reservation
GET /api/v1/reservations/:id
Fetch a single reservation's metadata. A missing or non-owned id returns 404 (existence is never leaked).
curl -H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/reservations/550e8400-e29b-41d4-a716-446655440000
Error Cases:
404 NOT_FOUND: Reservation does not exist or you don't have access
Update a Reservation
PATCH /api/v1/reservations/:id
Update reservation metadata. The slug (the address itself) is immutable and can never be changed: that stability is the whole point of a reservation. Only title, description, intended_agent_name, visibility, mode, and watermark_enabled can be set. Include only the fields you want to change.
curl -X PATCH \
-H "Authorization: Bearer sd_..." \
-H "Content-Type: application/json" \
-d '{"title": "Q3 Metrics (final)", "visibility": "shared"}' \
https://sharedrop.cloud/api/v1/reservations/550e8400-e29b-41d4-a716-446655440000
Error Cases:
400 VALIDATION_ERROR: Invalid JSON body or field values402 TIER_LIMIT: A visibility your plan does not allow (carries a billing envelope)404 NOT_FOUND: Reservation does not exist or you don't have access
Revoke a Reservation
POST /api/v1/reservations/:id/revoke
Revoke a reservation. The row is kept (status flips to revoked) and the claim token is permanently invalidated in the same operation, so the sdr_ credential dies the instant this returns 200. Use revoke as the manual equivalent of expiry.
curl -X POST \
-H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/reservations/550e8400-e29b-41d4-a716-446655440000/revoke
Returns 200 with the serialized reservation (now revoked).
Error Cases:
404 NOT_FOUND: Reservation does not exist, is already terminal, or you don't have access
Delete a Reservation
DELETE /api/v1/reservations/:id
Remove a reservation row entirely. Unlike revoke, this deletes the record rather than keeping it for history.
curl -X DELETE \
-H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/reservations/550e8400-e29b-41d4-a716-446655440000
Returns 200 with { "data": { "success": true } }.
Error Cases:
404 NOT_FOUND: Reservation does not exist or you don't have access
Revoke Share
DELETE /api/v1/pages/:id/shares/:grantId
Revoke an access grant by its id. Idempotent: revoking an already-revoked or unknown grant still returns success.
curl -X DELETE \
-H "Authorization: Bearer sd_..." \
https://sharedrop.cloud/api/v1/pages/550e8400-e29b-41d4-a716-446655440000/shares/grant-uuid
Response 200:
{
"data": {
"success": true
}
}
Error Cases:
404 PAGE_NOT_FOUND: Page does not exist or you don't have access