Upload a file, get a link back. No account, no signup, and no credential required — the API is as anonymous as the site.
# upload one or more files
curl -F 'files[]=@photo.jpg' https://sec.prnt.li/api/upload
# upload from something that can only stream a body
curl --data-binary @movie.mp4 'https://sec.prnt.li/api/upload?filename=movie.mp4'
# upload a file that never expires
curl -F 'files[]=@photo.jpg' -F 'expires_at=never' https://sec.prnt.li/api/upload
# paste text that never expires
curl -H 'Content-Type: application/json' -d '{"text":"hello"}' https://sec.prnt.li/api/paste
# ... or the paste as a raw body
echo 'hello' | curl -H 'Content-Type: text/plain' --data-binary @- 'https://sec.prnt.li/api/paste?filename=hello.txt'
# what is this file?
curl https://sec.prnt.li/api/file/<token>
# delete it
curl -X POST https://sec.prnt.li/api/delete/<token>
| Route | What it does |
|---|---|
| POST /api/upload | Upload files. Multipart, or a raw body. |
| POST /api/paste | Store a text snippet. Same as a file, except it never expires. |
| GET /api/file/<token> | Metadata: name, size, type, expiry, view and download counts. |
| POST /api/delete/<token> | Delete permanently. |
| POST /api/unlock/<token> | Exchange a password for an unlock token. |
| GET /api/config | The limits currently in force. |
| GET /api/openapi.json | OpenAPI 3.1 — machine-readable, for generators. |
Multipart is the primary one, because that is what every HTTP client
library sends by default. A raw body is also accepted for clients
that would otherwise have to hand-assemble a multipart document to
move one file. They are told apart by Content-Type, and
both go through the same size, quota and storage checks.
A raw upload carries one file, so the per-upload file
count does not apply to it. The display name comes from the
X-Filename header, or
?filename= as a fallback — a header, because query
strings end up in access logs.
A paste is a file in every way except one: it never
expires. Same tokens, same /f/<token>/<name>
links, same per-IP quota and rate limits, same burn-after-view and
password options. The stored type is text/plain, which
the server renders inline — so the link opens as a readable page
rather than a download.
Three shapes, mirroring the upload route's philosophy of meeting the
client where it is. JSON takes
{"text": "..."} with optional name,
burn_after_view and password in the same
object; a form field named text carries the same
companions as fields; anything else with a
text/* content type is the paste, with
the display name from X-Filename or
?filename=.
There is deliberately no expiry parameter on a
paste — not because files cannot be permanent (they can:
expires_at=never stores a file with no expiry either),
but because a paste with an expiry would just be a file upload,
and POST /api/upload already serves that. The response
carries "expires_at": null to say so, and the metadata
route reports the same.
An API key is optional and buys exactly two things: the per-IP hourly ceiling rises tenfold, and downloads are exempt from the large-file throttle.
not configured here This server currently has no keys set, so every request takes the anonymous path.
What a key deliberately does not buy:
Send one as X-API-Key or
Authorization: Bearer <key>.
/pw/ hands out a cookie. A script cannot use a cookie,
so POST /api/unlock/<token> returns the same
signed value in a header:
UNLOCK=$(curl -s -d 'password=hunter2' https://sec.prnt.li/api/unlock/<token> \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["unlock"])')
curl -H "X-PRNT-Unlock: $UNLOCK" https://sec.prnt.li/f/<token>/secret.pdf
The signature is scoped to that one file. That is
load-bearing: the cookie version signs a bare 1 and
relies on the cookie's name to scope it, and a header has no
name — so an unscoped signature would unlock every
password-protected file on the host at once.
Errors are {"error": "..."} — a flat object, a single
string, safe to show a user.
| Code | Meaning |
|---|---|
| 400 | Bad request, or every file in the batch was rejected. |
| 401 | Wrong password. |
| 404 | No such file. |
| 413 | Larger than the per-file limit. |
| 429 | Hourly limit reached, or too many password attempts. |
| 503 | Too many uploads already in flight. |
| 507 | Out of storage or disk space. |
A batch can partially fail and still return 200. One
oversized file does not cost you the other nine, so an
errors array is present on success and must be checked
even then. Only a request where nothing was stored returns
400.
| Value | |
|---|---|
| per file | 1024 MiB |
| files per upload | 10 |
| per IP | 20/hour |
| expiry, or never — clamped to | 365 days |
Expiry is clamped rather than trusted: an anonymous client sending
9999-01-01 would otherwise have a way to disable it.
The one explicit opt-out is the literal string
never — anything absent or malformed falls back to
the default rather than guessing. Pastes are the exception in the
other direction: they carry no expiry at all, which is the whole
point of /api/paste.
An inbox.li project, hosted in Norway.