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
urlfield. - Defaults
visibilityto"public". - Doesn't accept
passcodes/allowed_emails(set them later withPUT /pages/{id}if you need them).
Form fields
| Field | Type | Required | Notes |
|---|---|---|---|
name | text | yes | Display name. |
visibility | text | no | Default public. One of private/shared/public. |
file | file | one of | ZIP archive (≤ 50 MB) or single .html file. |
source_url | text | one 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 visibility400 Either file or source_url must be provided400 Provide either file or source_url, not both400any 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
| Field | Notes |
|---|---|
file | ZIP or HTML, exactly as for create. |
source_url | HTTP(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
400storage 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.
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
| Field | Notes |
|---|---|
user_id | Owner of the resulting page. |
name | Page name (used only when creating). |
visibility | private/shared/public. |
exp | Unix seconds; default issuance is +5 min. |
page_id | Optional. 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 token401 User not found(token signed for a deleted user)400any storage error404 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 type | Default 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:
| Constant | Value | Effect |
|---|---|---|
MAX_UPLOAD_SIZE | 50 MB | Reject the entire upload before extraction. |
MAX_FILE_SIZE | 25 MB | Reject any single file inside a ZIP, or any single fetched asset. |
MAX_PAGE_SIZE | 100 MB | Reject the upload once the cumulative size of files exceeds this. |
URL import behavior
When you provide source_url:
- The page is fetched with a 30-second timeout, following redirects.
- The response must have
text/htmlinContent-Type— otherwise the upload is rejected. - Asset URLs are scraped from the HTML —
<link href>,<script src>,<img src>,srcset, favicons, and inlineurl(...)in style attributes. - Same-domain assets are fetched and saved alongside the HTML; URLs are rewritten to point at the local copies.
- CSS files are recursed once:
url(...)references inside CSS are also pulled in (still same-domain only). - Off-domain assets (
cdn.example.org/script.js) are kept as absolute URLs in the HTML — they continue to load from the original host. - Assets that exceed
MAX_FILE_SIZE, push the page overMAX_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 with400 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"