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

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the current window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets

Limits by Tier

EndpointFreeProTeam
Upload20/min100/min200/min
List / Read / Update60/min300/min600/min
Delete / Share30/min150/min300/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

CodeHTTP StatusDescription
UNAUTHORIZED401Invalid or missing API key
FORBIDDEN403API key lacks the required scope (e.g. a pages:read key used to write)
RATE_LIMIT_EXCEEDED429Too many requests. Check X-RateLimit-* headers.
PAGE_NOT_FOUND404Page does not exist or you don't have access
VALIDATION_ERROR400Invalid request body or parameters
TIER_LIMIT402A new page would go over your plan's page limit, or the file kind or feature is not on your plan
FILE_SIZE_EXCEEDED413File 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_LIMIT402The operation would exceed the resolved storage cap (see Trash and storage accounting)
PAYMENT_REQUIRED402The 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:

EndpointWhat it does
POST /api/upload/signStep 1 of a file upload
POST /api/upload/bundle/signStep 1 of a folder bundle upload
POST /api/archives/createStart an archive upload
PATCH /api/v1/pages/:idUpdate a page
PUT /api/pages/:idUpdate a page (dashboard route)
POST /api/pages/:id/moveMove a page into or out of a folder
POST /api/pages/:id/accessGrant access to a page by email
POST /api/v1/pages/:id/shareShare a page by email
POST /api/pages/:id/ephemeral-linksCreate a disappearing link
POST /api/v1/pages/:id/ephemeral-linksCreate a disappearing link
POST /api/pages/:id/versionsRestore an earlier version
POST /api/trash/:id/restoreRestore 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):

FieldTypeRequiredDescription
filenamestringYesFilename including extension (e.g. report.pdf)
content_typestringYesMIME type (e.g. application/pdf)
size_bytesnumberYesExact body size in bytes. Must match the Content-Length of the PUT
workspacestringNoWorkspace 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):

FieldTypeRequiredDescription
object_keystringYesThe object_key returned by step 1
upload_tokenstringYesThe upload_token returned by step 1
titlestringNoPage title. Defaults to the document title (HTML/PDF) or filename stem
visibilitystringNoprivate (default), shared, or public
modestringNoHTML only: static or interactive (default: your account's default upload mode, initially interactive; configure in Settings). Ignored for non-HTML kinds
workspace_idstringNoUpload to a specific workspace
page_idstringNoExisting page ID for re-upload (URL stays stable, version recorded)
slidesbooleanNoHTML 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" }
  ]
}
FieldMeaning
modeThe 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_reuploadtrue when page_id replaced an existing page
versionPer-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_runWhether 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_hostsHosts the stored root HTML loads resources from (empty for non-HTML kinds)
warningsAlways present, possibly empty. See the warning codes below
same_title_pagesNew 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_warningsUnchanged 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):

codedetailcount
removed_tagThe removed tag nameNumber of removals
removed_attributeThe removed attribute nameNumber of removals
images_extractedimgInline images moved to hosted storage
external_refs_block_scriptsThe 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:

  • 400 with a plain error string: an invalid body (Invalid request body, with details), reservation_claim_conflict when both reservation_id and page_id are sent, or reservation_id_mismatch when a reservation claim token sends a reservation_id other than the one it was issued for
  • 401 with { "error": "Unauthorized" }: missing or rejected API key
  • 402 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 limit
  • 402 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 images
  • 402 STORAGE_LIMIT: live plus recoverable trash plus uploads still in progress plus the new file would exceed the storage cap
  • 403 FORBIDDEN: the API key lacks the pages:write scope. 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 download
  • 404: the workspace or page does not exist or is not yours
  • 413 with "code": "USE_MULTIPART": an archive over 30 MB, which must use /api/archives/create
  • 429: rate limited

Finalize re-checks some of these and returns:

  • 400 with a plain error string: an invalid body (Invalid request body, with details), workspace_mismatch, page_id_mismatch, reservation_mismatch or object_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 processed
  • 401 with { "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 on code. A rejected API key is a 401 with { "error": "Unauthorized" } and no code. Both shapes also apply to POST /api/upload/bundle/finalize and to lint
  • 402 TIER_LIMIT: the kind is not on your plan, or a new page would go over your page limit
  • 403 FORBIDDEN: the API key lacks the pages:write scope
  • 404: 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 limit
  • 429: rate limited
  • 500, 502 or 503: a server or storage fault. Retry only when the body has retryable: true
  • 409 with retryable: true and a Retry-After header (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's reason says why: page_mutation_in_progress (another replacement of this page is still finishing) or target_page_pending (another finalize with the same upload token is still running). The same applies to POST /api/upload/bundle/finalize. A 409 without retryable: true is 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
}
FieldMeaning
mode_effectivemode from the body, else the mode of the page in page_id, else your account's default upload mode
would_changetrue 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_runThe same rule as scripts_will_run on finalize, for these bytes and settings
same_title_pagesAs 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
filesBundle 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:

ParameterTypeDefaultDescription
limitnumber50Results per page (max 100)
cursorstringNonePagination cursor from previous response
workspace_idstringNoneFilter 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"
  }
}
FieldMeaning
versionThe page's version counter (1 for a new page, up by one on every re-upload or restore)
scripts_blockedThe owner has turned scripts off for this interactive page (reversible, no re-upload needed)
scripts_will_runComputed 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_hostsHosts 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 no fetch_url; the error carries download_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 key
  • 404 PAGE_NOT_FOUND: Page does not exist, you can't download it, or it has no downloadable content
  • 409 ARCHIVE_DOWNLOAD_REQUIRED: The page is an archive; download it from the returned download_url instead (see Fetch)

Update Page

PATCH /api/v1/pages/:id

Update a page's title, visibility, custom address, or serving options.

Request Body (JSON):

FieldTypeDescription
titlestringNew page title
visibilitystringpublic, private, or shared
slugstringCustom address for a public page. Requires Pro or higher
webview_enabledbooleanServe the page as a standalone website without the viewer frame. Not available for archives
external_network_enabledbooleanLet 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 values
  • 403 VALIDATION_ERROR: Visibility not available on your plan (e.g., private on Free tier)
  • 402 TIER_LIMIT: slug requires a Pro plan or higher
  • 400 VALIDATION_ERROR: external_network_enabled on a page that is not interactive, or webview_enabled: true on an archive
  • 400 VALIDATION_ERROR: A custom address can be set only on a public page
  • 409 VALIDATION_ERROR: The requested custom address is already in use or retired
  • 402 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 credentials
  • 402 STORAGE_LIMIT: Live usage plus the selected subtree would exceed the cap
  • 402 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 trashed
  • 429: Rate limit exceeded; respect Retry-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):

FieldTypeRequiredDescription
emailstringYesEmail 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.granted webhook 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 address
  • 402 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

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:

StatusMeaning
reservedWaiting for its first upload
claimedAlready claimed into a live page (claimed_page_id is set)
revokedManually revoked; the claim token is dead
expiredPassed 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.

FieldTypeDescription
titlestringPage title shown once the address is claimed (default Untitled)
descriptionstringOptional description for your own reference
intended_agent_namestringName of the agent this address is held for (shown in the dashboard)
visibilitystringprivate (default), shared, or public. The claimed page inherits it
slugstringOptional custom address for a public reservation. Requires Pro or higher
modestringHTML render mode the claimed page takes: static or interactive
watermark_enabledbooleanWhether the claimed page is watermarked (default false)
folder_idstringPro folder to file the claimed page into
expires_atstringISO 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 field
  • 402 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. The 402 carries a billing envelope with an upgrade URL
  • 402 TIER_LIMIT: slug requires a Pro plan or higher
  • 400 VALIDATION_ERROR: A custom address can be reserved only for a public page
  • 409 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:

ParameterTypeDefaultDescription
limitnumber50Results per page (max 100)
cursorstringNonePagination 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 values
  • 402 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