Programmatic upload

Three endpoints exist for uploading content from outside the dashboard: the standard /pages endpoint (covered on the Pages page), an API-token-only mirror at /api/pages, and a short-lived signed URL at /upload/{token}. This page covers the latter two and explains how files are stored.

POST /api/pages

Create a page with an API token. Identical semantics to POST /pages but:

  • Authenticates only via API token (the op_… prefix).
  • Returns a slimmer JSON body that includes a url field.
  • Defaults visibility to "public".
  • Doesn't accept passcodes / allowed_emails (set them later with PUT /pages/{id} if you need them).

Form fields

FieldTypeRequiredNotes
nametextyesDisplay name.
visibilitytextno Default public. One of private/shared/public.
filefileone of ZIP archive (≤ 50 MB) or single .html file.
source_urltextone of HTTP(S) URL; the page and same-domain assets are fetched.

Examples

ZIP upload (the most common case)
curl -X POST https://outpost.click/api/pages \
  -H "Authorization: Bearer op_YOUR_TOKEN" \
  -F "name=Hello World" \
  -F "visibility=public" \
  -F "file=@./site.zip"
From a build directory
(cd ./dist && zip -r /tmp/site.zip .) \
  && curl -X POST https://outpost.click/api/pages \
       -H "Authorization: Bearer op_YOUR_TOKEN" \
       -F "name=My App" \
       -F "file=@/tmp/site.zip"
From a single HTML file
curl -X POST https://outpost.click/api/pages \
  -H "Authorization: Bearer op_YOUR_TOKEN" \
  -F "name=Notes" \
  -F "file=@./notes.html"
From a URL (mirror a public page)
curl -X POST https://outpost.click/api/pages \
  -H "Authorization: Bearer op_YOUR_TOKEN" \
  -F "name=Mirror" \
  -F "source_url=https://example.com"

Response 200

{
  "id": "Ab12Cd34",
  "name": "Hello World",
  "visibility": "public",
  "default_file": "index.html",
  "url": "https://outpost.click/p/Ab12Cd34",
  "created_at": "2026-05-03T17:42:11"
}

Errors

  • 400 Invalid visibility
  • 400 Either file or source_url must be provided
  • 400 Provide either file or source_url, not both
  • 400 any storage error (size limits, bad zip, non-HTML URL).
  • 401 Authorization header required / Invalid API token

POST /api/pages/{page_id}/files

Replace the files served by an existing page. The page id and URL stay the same; only the contents are swapped. Useful for deploys.

Form fields

FieldNotes
fileZIP or HTML, exactly as for create.
source_urlHTTP(S) URL to fetch.

Examples

Push a new build
curl -X POST https://outpost.click/api/pages/Ab12Cd34/files \
  -H "Authorization: Bearer op_YOUR_TOKEN" \
  -F "file=@./dist.zip"
Re-mirror from the source URL
curl -X POST https://outpost.click/api/pages/Ab12Cd34/files \
  -H "Authorization: Bearer op_YOUR_TOKEN" \
  -F "source_url=https://example.com"

Response 200

{
  "id": "Ab12Cd34",
  "name": "Hello World",
  "visibility": "public",
  "default_file": "index.html",
  "url": "https://outpost.click/p/Ab12Cd34",
  "updated_at": "2026-05-03T18:01:55",
  "message": "Page files updated successfully"
}

Errors

  • 400 storage error.
  • 404 Page not found (also when you don't own the page).

POST /upload/{token}

Upload using a short-lived signed token. No Authorization header is required because the token itself encodes user, page, and expiry. Tokens are HMAC-signed by the server's jwt_secret and expire after 5 minutes.

Signed tokens are issued internally by the MCP outpost_upload_url tool and by frontend flows. There is no public endpoint that creates one for you — callers that hold an API token should prefer /api/pages and /api/pages/{id}/files, which don't expire.

Token format

<base64url(json_payload)>.<hex(hmac_sha256(secret, payload))[:16]>

Payload fields

FieldNotes
user_idOwner of the resulting page.
namePage name (used only when creating).
visibilityprivate/shared/public.
expUnix seconds; default issuance is +5 min.
page_idOptional. Present → update existing page; absent → create new.

Form fields

Same as /api/pages: provide either file or source_url.

Example: create

# Server returns a token like:
#   eyJ1c2VyX2lkIjogNDIsICJuYW1lIjogIk15IFNpdGUiLCAidmlzaWJpbGl0eSI6...JSON.4f9b1234abcd5678

curl -X POST "https://outpost.click/upload/$TOKEN" \
  -F "file=@./site.zip"

Example: update

curl -X POST "https://outpost.click/upload/$TOKEN" \
  -F "source_url=https://example.com"

Response 200 (create)

{
  "id": "Ab12Cd34",
  "name": "My Site",
  "visibility": "public",
  "default_file": "index.html",
  "url": "https://outpost.click/p/Ab12Cd34",
  "created_at": "2026-05-03T17:42:11"
}

Response 200 (update)

{
  "id": "Ab12Cd34",
  "name": "My Site",
  "visibility": "public",
  "default_file": "index.html",
  "url": "https://outpost.click/p/Ab12Cd34",
  "updated_at": "2026-05-03T18:01:55",
  "message": "Page updated successfully"
}

Errors

  • 401 Invalid or expired upload token
  • 401 User not found (token signed for a deleted user)
  • 400 any storage error
  • 404 Page not found (update token, page no longer exists)

Default file detection

The "default file" is the entry point served when a visitor hits /p/<id> with no path. Detection logic (storage.py:save_upload):

Upload typeDefault file
ZIP containing index.html at any level index.html
ZIP with HTML files at the root only (no index.html) Alphabetically first root-level .html.
ZIP with HTML only in subdirectories Alphabetically first .html path.
ZIP with no HTML at all Falls back to index.html (the URL will 404 until you upload again).
Single uploaded .html file Stored under its original filename (or index.html if Outpost can't determine one).
source_url Always index.html. Outpost rewrites in-page references to local copies of same-domain assets.

Size limits

Defined in app/storage.py:

ConstantValueEffect
MAX_UPLOAD_SIZE50 MBReject the entire upload before extraction.
MAX_FILE_SIZE25 MBReject any single file inside a ZIP, or any single fetched asset.
MAX_PAGE_SIZE100 MBReject the upload once the cumulative size of files exceeds this.

URL import behavior

When you provide source_url:

  1. The page is fetched with a 30-second timeout, following redirects.
  2. The response must have text/html in Content-Type — otherwise the upload is rejected.
  3. Asset URLs are scraped from the HTML — <link href>, <script src>, <img src>, srcset, favicons, and inline url(...) in style attributes.
  4. Same-domain assets are fetched and saved alongside the HTML; URLs are rewritten to point at the local copies.
  5. CSS files are recursed once: url(...) references inside CSS are also pulled in (still same-domain only).
  6. Off-domain assets (cdn.example.org/script.js) are kept as absolute URLs in the HTML — they continue to load from the original host.
  7. Assets that exceed MAX_FILE_SIZE, push the page over MAX_PAGE_SIZE, or fail to fetch are silently skipped.

Path safety

When extracting a ZIP:

  • Leading slashes are stripped from every entry.
  • If any file path contains .., the upload is rejected with 400 Invalid file path in archive.
  • Empty directories are ignored.

On read (GET /p/{id}/{path}) the same .. guard applies.

Recipe: deploy script

#!/usr/bin/env bash
set -euo pipefail
: "${OUTPOST_TOKEN:?Set OUTPOST_TOKEN}"
PAGE_ID=${PAGE_ID:-Ab12Cd34}
DIST=${DIST:-./dist}
TMPZIP=$(mktemp --suffix=.zip)
trap 'rm -f "$TMPZIP"' EXIT
( cd "$DIST" && zip -qr "$TMPZIP" . )
curl -fsS -X POST "https://outpost.click/api/pages/$PAGE_ID/files" \
  -H "Authorization: Bearer $OUTPOST_TOKEN" \
  -F "file=@$TMPZIP"
echo "Deployed to https://outpost.click/p/$PAGE_ID"