Quick start

Send some text, get two links back: one for the recipient and one for yourself. The base URL is onetime.fortion.cloud and every endpoint lives under /api/v1.

curl -s https://onetime.fortion.cloud/api/v1/secret \
  -H 'content-type: application/json' \
  -d '{"secret":"hunter2","ttl_days":7}'
The response contains the key exactly once — in secret_url, after the #. We never store it, so you have to.

Creating a link

POST /api/v1/secret

The body is JSON. Only secret is required; ttl_days is 1 to 30 (default 14) and passphrase is an optional extra password.

{
  "secret": "hunter2",
  "ttl_days": 14,
  "passphrase": ""
}

// 201 Created
{
  "secret_url": "https://onetime.fortion.cloud/s/7Kq2…#Xf9…",
  "receipt_url": "https://onetime.fortion.cloud/m/3Bd1…#Qa2…",
  "kind": "text",
  "size": 7,
  "has_passphrase": false,
  "expires_at": "2026-09-09T12:00:00Z",
  "receipt_expires_at": "2026-09-16T12:00:00Z",
  "ttl_days": 14
}

File upload

POST /api/v1/secret/file

Files go up as multipart/form-data. The file field must come last — the server reads the metadata before it starts streaming the body to disk, so that it never has to hold the whole file in memory.

curl -s https://onetime.fortion.cloud/api/v1/secret/file \
  -F 'ttl_days=7' \
  -F 'passphrase=' \
  -F 'file=@report.pdf'   # must be the last field

Password generation

POST /api/v1/generate

The server makes the password itself and turns it straight into a link. alphabet is letters, alphanumeric or symbols, length is 8 to 128. With return_value: false the password never appears in the response at all.

{
  "length": 24,
  "alphabet": "symbols",
  "ttl_days": 14,
  "return_value": false
}

// 201 Created — "value" stays null unless return_value is true
{ "secret_url": "…", "receipt_url": "…", "value": null }

Reading

POST /api/v1/peek

POST /api/v1/reveal

GET /api/v1/download

Start with peek: it reports the kind, the size and whether a password is needed, without burning anything. Only reveal with confirm: true hands out the content and deletes it. For files it returns a ticket good for five minutes against /api/v1/download.

// the key is the part of the link after the #
POST /api/v1/peek     { "key": "Xf9…" }
// 200 { "kind": "text", "state": "new", "has_passphrase": false, "size": 7 }

POST /api/v1/reveal   { "key": "Xf9…", "confirm": true }
// 200 { "kind": "text", "value": "hunter2", "revealed_at": "…" }

// files answer with a ticket instead of a body
// 200 { "download_url": "/api/v1/download", "download_ticket": "…", "ticket_expires_in": 300 }
GET /api/v1/download
X-Onetime-Ticket: "…"

Status

POST /api/v1/receipt

The receipt tells you what state the link is in and when things happened to it. It returns nothing about the recipient beyond timestamps.

{ "key": "Qa2…" }

// 200 — state is new | consumed | burned | destroyed | expired
{
  "state": "new",
  "kind": "text",
  "size": 7,
  "has_passphrase": false,
  "created_at": "2026-08-26T12:00:00Z",
  "secret_expires_at": "2026-09-09T12:00:00Z",
  "peeked_at": null,
  "consumed_at": null,
  "passphrase_failures": 0,
  "receipt_expires_at": "2026-09-16T12:00:00Z"
}

Deleting

POST /api/v1/receipt/burn

As long as nobody has read the link, you can destroy the content. There is no undo.

{ "key": "Qa2…", "confirm": true }

Errors

Errors are application/problem+json per RFC 9457. Branch on the code field, not on the text — the text changes and is translated.

// 410 Gone — content-type: application/problem+json
{
  "code": "already_revealed",
  "title": "Already revealed",
  "detail": "This link has already been used."
}
code HTTP When it happens
not_found404We don't know this link.
already_revealed409, 410The content has already been read and is deleted.
burned410The sender cancelled this link.
destroyed410The content was deleted after repeated wrong passwords.
passphrase_required401This link is password protected.
bad_passphrase403That password is wrong.
too_many_attempts429You have used up your attempts and the content is deleted.
confirmation_required400Confirm that you want to see the content.
payload_too_large413The content is too large.
empty400Add the content you want to send.
storage_full507We have run out of space. Try again shortly.
files_disabled403File sending is switched off right now.
read_only503Maintenance is in progress. New links cannot be created right now.
quota_exceeded429Too many links from your network. Try again shortly.
ticket_expired410The download link expired. Reload the page.
invalid_ttl400Validity has to be between 1 and 30 days.
rate_limited429One moment — a lot of requests are coming from your network. Try again in a minute.
internal500Something broke on our side. Please try again in a moment.

Limits

By default text up to 1 MB, files up to 50 MB and validity between 1 and 30 days; the form shows the exact figures. Five wrong password attempts destroy the content. Rate limiting is per IP and answers 429 with a Retry-After header.

For AI agents

When an agent has to hand a password to a human, use POST /api/v1/generate with return_value: false. The server makes the password and the agent only ever sees a link — so the password never enters the agent's context, its logs or the conversation history.

curl -s https://onetime.fortion.cloud/api/v1/generate \
  -H 'content-type: application/json' \
  -d '{"length":24,"alphabet":"symbols","return_value":false}' \
  | jq -r '.secret_url'
# the generated password never passes through the agent

A machine-readable description of the service lives at /llms.txt.

An honest note — Encryption happens on the server. The key is part of the link and we throw it away immediately — without the link, the content in our database is unreadable.