docs
pipeout is one endpoint that takes a file and gives back a URL. There is no SDK to install and nothing to configure — if it can make an HTTP request, it can publish.
Quick start
Mint a token at pipeout.sh/app/tokens, put it in your environment, and send a file:
export PIPEOUT_TOKEN=po_9f3c1a7… # the real one is 43 characters
curl -T report.html -u :$PIPEOUT_TOKEN https://pipeout.sh/a/
https://pipeout.page/r/1f4c9a2b7e05d8613ac2f094
report.html v1 · 7 KB · this version: https://pipeout.page/r/8be31d07f2a45c9018ee6b23
The first line is the artifact URL. Push the same filename again and that URL updates in place.
| Host | Purpose |
|---|---|
pipeout.sh | App and API — you push here |
pipeout.page | Content — artifacts are served here, never on the app domain |
Authentication
Every request carries a token. Two forms, both accepted everywhere:
Authorization: Bearer po_...
curl -u :$PIPEOUT_TOKEN # HTTP Basic, empty username
Tokens carry scopes. push is required to publish;
read is required for the JSON API below. Reading an artifact by
its URL needs no token at all — the URL is the credential.
Only a SHA-256 of the secret is stored, so a lost token cannot be recovered, only revoked and replaced from the dashboard.
When creating a token, choose an expiration: 24 hours, 7 days, 31 days (the default), 90 days, or no expiration. Expired tokens cannot publish or use the JSON API. Reports already published stay available. Existing tokens without an expiration keep working until revoked.
Push a file
The body is the file. With curl -T report.html the filename is
appended for you, which is why the example above ends in a bare slash.
Filenames are identity
An artifact is identified by its filename on your account. Pushing
report.html twice updates one artifact and one URL — it does not
create a second. Each push also mints a permanent version URL frozen at those
exact bytes.
Filenames are 1–200 characters of A-Za-z0-9._- with no
slashes. Keep the real extension: it decides how the file is rendered.
Identical content keeps the same version
Pushes are compared by SHA-256 of the decompressed body. Re-pushing unchanged bytes returns the existing version and refreshes its retention clock without storing another version. Reviving an expired artifact must fit within your current storage quota.
Compression
Send Content-Encoding: gzip and pipeout decompresses before
storing. File limits apply to the decompressed size: 10 MB on Free and
100 MB on Pro. Gzip reduces transferred bytes, but does not raise these limits:
gzip -c big.log | curl -T - -H 'Content-Encoding: gzip' \
-u :$PIPEOUT_TOKEN https://pipeout.sh/a/big.log
Publish it private
Add a header and the URL asks for a sign-in instead of opening for anyone who has it. Only the account that owns the artifact can read it — there is no passcode to share and nothing to leak.
curl -T report.html -u :$PIPEOUT_TOKEN \
-H 'X-Pipeout-Access: private' https://pipeout.sh/a/
| Value | Effect |
|---|---|
private | Only the owning account can open the URL. Pro only. |
unlisted | Back to the default: anyone with the link can read it. |
| header omitted | Leaves the policy exactly as it is |
Omitting the header never changes anything, so a run that forgets it cannot reopen a private report. Access belongs to the artifact, not to a version: every past version URL is covered too, and re-pushing keeps the policy. You can also flip it either way per artifact in your dashboard.
Response
Plain text by default. Send Accept: application/json for:
{
"slug": "report.html",
"version": 3,
"deduplicated": false,
"size_bytes": 7183,
"sha256": "9f2c...",
"url": "https://pipeout.page/r/1f4c9a2b7e05d8613ac2f094",
"version_url": "https://pipeout.page/r/8be31d07f2a45c9018ee6b23",
"access": "unlisted"
}
An unlisted artifact URL can be read by anyone holding it. Do not push secrets, credentials, or other people's personal data — see the privacy policy.
Read an artifact
The hash is 24 hex characters, 96 bits, derived per account. There is no
listing and no index; pages are served noindex with
no-referrer.
What each content type does
| Type | Behaviour |
|---|---|
| HTML, images, PDF | Served byte-exact — the browser renders them natively |
| Markdown | Rendered to a styled page |
| Text, logs, JSON, CSV | Numbered viewer; above 1 MB it shows the head and says so |
| Anything else | A download page |
Content type is taken from the request when you set it, otherwise inferred from the extension. A NUL byte in the first kilobyte marks a file binary no matter what the extension claims.
Interactive report compatibility
HTML bytes are served unchanged, including your scripts and styles. Reports
run in an isolated browser context: service workers, shared browser storage
(such as localStorage and IndexedDB), and embedded plugins are not
supported. A report cannot read another artifact using same-origin access.
Inline scripts, forms, downloads, and links opening new tabs remain available.
External APIs used by your scripts must allow cross-origin requests from an
opaque origin (sent as Origin: null). Bundle data into your report
when the API does not support this access pattern.
Parameters and methods
| What | Effect |
|---|---|
?dl=1 | Forces a download instead of rendering |
HEAD | Content type and length of the stored bytes, no body |
Version URLs keep the same bytes, but browsers revalidate their responses so updated security headers can take effect. The artifact URL is never cached, so it always serves the newest version. Crawlers and unfurlers get metadata only — the blob is never opened for them, and those requests are not counted as views.
List and inspect
{
"artifacts": [
{ "slug": "report.html", "title": "Nightly audit",
"url": "https://pipeout.page/r/1f4c9a2b7e05d8613ac2f094",
"version": 3, "size_bytes": 7183, "views": 12, "expired": false }
]
}
expired marks an artifact whose URL has aged out of the free
window. It stays in the listing — it is still yours, just not reachable.
The same object plus the versions your plan can reach:
{
"slug": "report.html", "version": 3, "views": 12,
"versions": [
{ "version": 3, "url": "https://pipeout.page/r/8be31d07f2a45c9018ee6b23",
"size_bytes": 7183, "sha256": "9f2c..." }
]
}
Report abuse. Body is {"hash": "...", "reason": "..."} where
the hash is the 24-character id from an artifact URL. Limited to 5 per minute
per IP.
Errors
Plain text as error (code): message, or JSON as
{"error": "...", "message": "..."} when you send
Accept: application/json.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_slug | Filename outside the allowed character set or length |
| 400 | bad_body | Body unreadable — usually a gzip header that is not gzip |
| 401 | invalid_token | Token missing, unknown, expired, or revoked |
| 403 | wrong_scope | Token lacks the scope this call needs |
| 403 | account_suspended | Account suspended |
| 404 | not_found | No artifact by that name |
| 413 | file_too_large | Over the per-file cap for your plan |
| 429 | rate_limited | Too many requests; honour Retry-After |
| 507 | storage_quota_exceeded | Account storage full — delete something or upgrade |
Reading a hash that never existed or has been deleted gives 404. Content removed for abuse gives 410, and so does a link that has expired under free-tier retention — the expired page says so, and the artifact comes back on upgrade.
Limits
| Free | Pro | |
|---|---|---|
| Storage | 250 MB | 5 GB |
| Per file | 10 MB | 100 MB |
| Versions reachable | Newest 5 | Every version |
| Links stay live | 30 days from last push | Indefinitely |
| Pushes | 30 / min per token | 120 / min per token |
Caps are measured on the decompressed file. Cloudflare independently limits request bodies to 100 MB on any plan.
A second limit of 240 pushes per minute applies to the calling IP address
rather than to the token, so that a rejected credential is metered too. It sits
above both per-token limits and a normal client never meets it; both answer
429 with Retry-After.
Retention deletes nothing. On a free account an artifact stops
resolving 30 days after its last push, and version URLs older than the newest
five stop resolving immediately — but the rows and the blobs are kept, and
upgrading to Pro brings every one of them back straight away. The clock runs
from the last push, so a file your agent rewrites on a schedule never expires.
An expired URL answers 410.
Storage is charged on what your plan can actually reach, so history that has aged out does not count against the quota and cannot fill your account.