Files
palette/docs/api.md
fen 651bc0cb86
CI / test (push) Successful in 20s
CI / docker (push) Successful in 44s
Add API documentation (docs/api.md) (#305)
2026-10-16 20:46:45 +00:00

13 KiB

Palette API

Palette's REST API lives under /api on the same port that serves the web UI. All request and response bodies are JSON unless noted otherwise. This document describes the API as implemented in internal/api.

Overview

Base URL

The server listens on the address set by PALETTE_ADDR (default :8080). In production palette runs behind a reverse proxy, so API URLs are relative to the site root, for example:

https://paste.example.com/api/pastes

The examples below use http://localhost:8080 as a placeholder.

Authentication

There is no user account system. Access control works through three mechanisms:

  • Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly vwr cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as GET /api/mine and creator-only deletion.
  • Deletion token. Creating a paste returns a one-time deletion_token. Sending it in the Authorization header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
  • Admin key. The admin settings endpoints require an install-time key sent in the X-Admin-Key header. The key comes from PALETTE_ADMIN_KEY or is generated and persisted server-side at first start.

Password-protected pastes and cans are unlocked with the X-Paste-Password request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.

Rate limiting

Rate limits are token buckets keyed by client IP (the rightmost X-Forwarded-For entry, then X-Real-Ip, then the remote address). Current limits:

Scope Limit Refill
Paste create 5 burst (admin tunable) 60 per minute (admin tunable)
Language guess 5 burst 1 per second
Password unlock 5 burst, per IP and paste 5 per minute
Admin key check 5 burst, per IP 5 per minute

Exceeding a limit returns 429 with a Retry-After header and the error code rate_limited. Create and guess responses include X-RateLimit-Limit and X-RateLimit-Burst headers.

Size limits

  • Paste content: PALETTE_MAX_TEXT (default 5 MB), admin tunable. Larger content is rejected with 413.
  • Attached files: 25 MB per file, one file per paste.
  • Can items: PALETTE_MAX_ITEM (default 25 MB) per item.
  • The request body itself is capped server side; oversized bodies fail with 413 before handlers run.

Error format

Errors are JSON with a human-readable error message and, for create/validation failures, a machine-readable code:

{"error": "slug already taken", "code": "slug_taken"}

Known codes: content_empty, content_too_large, slug_invalid, slug_taken, slug_reserved, expiry_invalid, invalid_visibility, one_file_only, file_too_large, rate_limited, invalid_request. Unknown codes should be treated as a generic failure.

Endpoint reference

Pastes

POST /api/pastes

Create a paste. Rate limited.

Two body formats:

  1. application/json with these fields:
Field Type Notes
content string required; non-whitespace; up to the content cap
title string optional, trimmed, truncated to 200 chars
language string optional, max 40 chars, ^[a-zA-Z0-9+#-]{1,40}$
custom_slug string optional custom URL slug
password string optional; hash stored server side
expires_in string Go duration (90m, 6h, 168h); 1 minute to 1 year
burn_after_read bool convenience for a single-read budget
burn_after_reads int number of reads the paste survives (min 1)
visibility string public or unlisted; default public
public bool alias for visibility; takes precedence when both are sent
  1. multipart/form-data with the same fields as form values plus a file part. A paste is either text or one file; if both are sent the file wins and the text is ignored. The MIME type is sniffed from the file content, never from client headers.

If the admin has set a default expiry, pastes created without expires_in inherit it.

Response 201:

{
  "id": "ab12cd34",
  "deletion_token": "one-time token",
  "url": "/ab12cd34",
  "raw_url": "/raw/ab12cd34",
  "api_url": "/api/pastes/ab12cd34",
  "expires_at": 1735689600,
  "created_at": 1735603200,
  "rate_limit": {"create_per_sec": 1, "burst": 5}
}

With a multipart file, an extra attachment object is returned: id, filename, mime, size, sha256, url (/f/{aid}/{filename}).

The deletion token is also set as a short-lived tok_{id} cookie for the creating browser.

Errors: 400 invalid JSON or validation (codes content_empty, slug_invalid, slug_taken, slug_reserved, expiry_invalid, one_file_only), 413 (content_too_large, file_too_large), 429 rate limited.

GET /api/pastes/{id}

Fetch a paste's JSON representation. For password-protected pastes send X-Paste-Password; wrong or missing passwords return 401 and count against the unlock rate limit.

Response 200: id, content, content_type, language, title, created_at, view_count, visibility, and reads_remaining (number or null when no read budget is set).

Errors: 404 not found, expired, or burned out; 401 password required; 429 too many unlock attempts.

DELETE /api/pastes/{id}

Soft delete a paste. Requires either the deletion token in the Authorization header or the creating browser's vwr cookie.

Response 200: {"status": "soft-deleted"}.

Errors: 403 deletion token required, 404 not found.

DELETE /api/pastes/{id}/redeem

Hard delete immediately using the one-time deletion token in the Authorization header. The paste row is removed entirely instead of waiting for the sweeper.

Response 200: {"status": "deleted"}.

Errors: 400 token required, 403 invalid token, 404 not found.

GET /api/mine

List pastes (and cans) created from this browser, via the vwr cookie. Query parameters: limit (default 50, max 100), offset.

Response 200: {"total": n, "limit": l, "offset": o, "items": [...]} with each item carrying id, title, language, created_at, view_count, size, custom_slug, visibility, is_can.

GET /api/public

List public pastes. Query parameters: limit (default 25, max 100), offset. Same shape as /api/mine; items include is_can and custom_slug.

Files and raw content

GET /raw/{id}

Paste content served verbatim. Content types outside a safe fixed set (plain text, markdown, JSON, PDF, common images, octet-stream) are served as text/plain with nosniff. Raw reads count against a burn-after-read budget, with the same per-viewer dedupe window as page views (15 minutes by default, admin tunable). Password-protected pastes are not available raw (401).

Errors: 401 password required, 404 not found, expired, or burned.

GET /f/{aid}/{filename}

Download a file attachment. The filename path segment is decorative; lookups key on the attachment id. Files are served with the server-sniffed MIME type, nosniff, and a safe Content-Disposition; only images and PDFs render inline, everything else downloads. Attachments inherit the paste lifecycle: expired or burned pastes make their attachments disappear.

Errors: 404 attachment or paste not found.

Language detection

POST /api/guess-language

Body: {"content": "package main"}. Rate limited like create.

Response 200: {"language": "go"}. The result is a best-effort guess and may be "".

Errors: 400 invalid JSON body, 429 rate limited.

Cans

A can bundles multiple text items and files into one shareable page at /can/{id}. Cans appear in /api/mine and /api/public with is_can: true.

POST /api/pastes/can

Create a can. Multipart form only. Fields:

Field Type Notes
title string optional
description string optional
visibility string public or unlisted; default public
expires_in string Go duration, 1 minute to 1 year
password string optional
custom_slug string optional; becomes the can id
json_items string JSON array of {"title": "...", "content": "...", "language": "..."} text items
files parts one or more uploaded file parts

A can needs at least one item (file or json_items); otherwise the can is deleted and 400 (content_empty) is returned.

Response 201: {"id": "...", "url": "/can/{id}", "items": n}.

Errors: 400 (invalid_visibility, expiry_invalid, invalid json_items, content_empty), 409 slug errors (codes slug_taken, slug_invalid, slug_reserved), 413 item over the size cap.

GET /api/cans/{id}

Can metadata with an item list. Password-protected cans require X-Paste-Password.

Response 200: id, title, visibility, created_at, and items, where each item has id, title, content_type, size, and url (the item's fetch URL).

Errors: 404 not found or expired, 401 password required.

GET /api/cans/{id}/items/{item}

Fetch one can item's content. Items inherit the can's password: send X-Paste-Password, or the browser's pw_{can} unlock cookie. Served with the same safe content-type guard as /raw.

Errors: 404 item not found or not a can item, 401 password required.

DELETE /api/cans/{id}

Soft delete a can. Creator browser only: the vwr cookie must match the can's viewer. Cans carry no deletion token.

Response 200: {"status": "soft-deleted"}.

Errors: 403 deletion not authorized, 404 not found.

Admin

GET /admin/api/settings and POST /admin/api/settings

Read or update runtime settings. Requires the X-Admin-Key header; wrong keys return 401 and all admin key checks are rate limited to 5 per minute per IP. The admin key is never accepted as a query parameter.

Settings object (POST accepts the full object and persists it):

Field Default Meaning
rate_limit_burst 5 paste-create token bucket burst
rate_limit_per_minute 60 paste-create refill rate
max_content_bytes 5242880 paste content cap
default_expiry "" expiry applied to pastes created without one
custom_slug_reservation_days 30 how long a released slug stays reserved
burn_viewer_window_minutes 15 per-viewer dedupe window for burn reads

Errors: 400 invalid JSON or validation (all numeric settings must be positive, default_expiry must be a duration between 1 minute and 1 year or empty), 401 unauthorized, 429 rate limited.

Examples

Create a text paste:

curl -X POST http://localhost:8080/api/pastes \
  -H "Content-Type: application/json" \
  -d '{
    "content": "print(hello)",
    "title": "my snippet",
    "language": "python",
    "expires_in": "168h",
    "visibility": "public"
  }'

Response:

{
  "id": "ab12cd34",
  "deletion_token": "TOKEN",
  "url": "/ab12cd34",
  "raw_url": "/raw/ab12cd34",
  "api_url": "/api/pastes/ab12cd34",
  "expires_at": 1735689600,
  "created_at": 1735603200,
  "rate_limit": {"create_per_sec": 1, "burst": 5}
}

Save the deletion_token; it is shown once and authorizes deletion.

Create a file paste:

curl -X POST http://localhost:8080/api/pastes \
  -F "title=quarterly report" \
  -F "file=@report.pdf"

Fetch a paste:

curl http://localhost:8080/api/pastes/ab12cd34

# password-protected paste (header only; query parameters are not accepted):
curl -H "X-Paste-Password: secret" http://localhost:8080/api/pastes/ab12cd34

Fetch raw content:

curl http://localhost:8080/raw/ab12cd34

Soft delete (paste becomes unavailable but is recoverable during the grace period):

curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34

Hard delete immediately (row removed):

curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem

Create a can:

curl -X POST http://localhost:8080/api/pastes/can \
  -F "title=My bundle" \
  -F "expires_in=48h" \
  -F "custom_slug=my-bundle" \
  -F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
  -F "files=@screenshot.png"

Deletion and expiry behavior

  • Expired pastes are soft-deleted by a background sweeper that runs every minute.
  • Soft-deleted pastes are hard-deleted after a 7-day grace period.
  • The deletion token allows immediate hard delete via the redeem endpoint.
  • Burn-after-read pastes are soft-deleted once the read budget is exhausted. A read is counted once per unique viewer within the dedupe window (15 minutes by default); the same viewer returning inside the window does not consume another read.