HTTP API
The HTTP API of the Gone one-time secret sharing service. The machine-readable OpenAPI specification is openapi.yaml; this page summarizes the same API in prose. The encryption format, link fragment, and client rules are specified in Protocol specification. The command-line client is documented in The gone CLI.
Design Goals
- Minimal surface: Three secret operations (create, claim, acknowledge), two sender operations (status, revoke), secret requests (create, check, reply, status, claim, acknowledge, cancel), the operator policy, and health probes.
- Zero knowledge: The server only ever sees ciphertext. Encryption, decryption, and the key stay in the browser.
- Streaming-friendly: Ciphertext is sent and returned as raw
application/octet-stream, not wrapped in JSON. - Deterministic deletion: Retrieval is two-phase.
GETclaims the secret andDELETEacknowledges receipt, so a secret is deleted only after the recipient has it, or when the claim lease lapses. It is never served to a second party. - Explicit limits: The service enforces
MaxBytes(default 10 MiB, covering the message and all attachments combined), and TTL must fall within the configured[MinTTL, MaxTTL]. - Opaque IDs: 128-bit random, 32 lowercase hex characters; never guessable or sequential.
Endpoints Overview
| Method | Path | Purpose |
|---|---|---|
| POST | /api/secret | Create a secret (returns ID, expiry & manage token) |
| GET | /api/secret/{id} | Claim a secret, or re-fetch an existing claim (returns ciphertext + claim token) |
| DELETE | /api/secret/{id} | Acknowledge receipt; permanently deletes the secret |
| GET | /api/secret/{id}/status | Sender: is the secret still waiting? (X-Gone-Manage) |
| POST | /api/secret/{id}/revoke | Sender: delete the secret before it is opened (X-Gone-Manage) |
| POST | /api/request | Requester: open a secret request (X-Gone-TTL, no body; returns ID, expiry, manage token & fill token) |
| GET | /api/request/{id} | Reply page: can this request still be answered? (X-Gone-Fill) |
| PUT | /api/request/{id}/reply | Reply page: send the one reply, encrypted to the requester (X-Gone-Fill) |
| GET | /api/request/{id}/status | Requester: waiting or ready? (X-Gone-Manage) |
| GET | /api/request/{id}/reply | Requester: claim the reply (X-Gone-Manage, plus X-Gone-Claim on a retry) |
| DELETE | /api/request/{id}/reply | Requester: acknowledge the reply; permanently deletes it (X-Gone-Claim) |
| POST | /api/request/{id}/revoke | Requester: cancel the request, or delete an unopened reply (X-Gone-Manage) |
| GET | /api/policy | The operator policy clients honour before encrypting: {"passphrase_required": bool,. Never cached and not rate limited. See Policies. |
| GET | /brand/logo | The operator’s logo, when GONE_BRAND_LOGO is set (image/svg+xml or image/png; otherwise the HTML 404 page). An SVG carries its own sandboxed CSP. GET and HEAD only. See Branding your instance. |
| GET | /brand/accent.css | The operator’s accent colours as CSS custom properties, when GONE_BRAND_ACCENT is set (otherwise the HTML 404 page). GET and HEAD only. |
| GET | /healthz | Liveness check (200, text ok). GET and HEAD only. |
| GET | /i18n/{tag}.json | The web pages’ message catalog for one language (en, es, fr, de, pt-BR), used to switch language in place. With the current ?v= hash it may be cached for a year. |
| GET | /readyz | Readiness check: the database answers a ping and the blob directory is readable (200, text ready; otherwise 503 with JSON {"error":"not ready"}). GET and HEAD only. The result is cached for 2 seconds, and the blob directory check opens the directory and reads at most one entry. |
Other methods on /api/secret/{id} return 405 with Allow: GET, DELETE. The same applies to /status (Allow: GET) and /revoke (Allow: POST), to /api/request (Allow: POST), /api/request/{id} and its /status (Allow: GET) and /revoke (Allow: POST), and to /api/request/{id}/reply (Allow: GET, PUT, DELETE). /api/secret answers 405 with Allow: POST and /api/policy with Allow: GET, and /brand/logo, /brand/accent.css, /healthz and /readyz answer anything but GET and HEAD with 405 and Allow: GET, HEAD. Unknown /api/ paths return a JSON 404. A trailing slash is never part of a route: /api/secret/{id}/ and /api/request/{id}/reply/ are 404, not aliases. Page routes without an ID (/secret/, /manage/, /reply/, /request/) return the HTML 404 page.
Pages are rendered in the language chosen by the gone_lang cookie, else the best match for Accept-Language, else English. Page responses carry Content-Language and Vary: Cookie, Accept-Language. The API itself is not translated: error bodies are always English, and the pages map status codes to their own messages.
The branding files and static assets are cached for a year when requested with the ?v= fingerprint the pages use, and for 5 minutes otherwise.
Metrics are not served on the public listener. When both GONE_METRICS_ADDR and GONE_METRICS_TOKEN are set, a separate listener serves GET /metrics in the Prometheus text exposition format (version 0.0.4) and GET / as a JSON snapshot. Every request must carry Authorization: Bearer <token>: a missing or wrong token gets 401 with an empty body on any path, checked before routing. With the token, other paths return 404 and other methods 405. If either variable is missing, metrics are disabled. See Metrics.
Creation Workflow
The client builds the plaintext (see Payload Format) and encrypts it locally with AES-256-GCM, producing ciphertext, a version, and a nonce.
The client sends the ciphertext body with these headers:
X-Gone-Version(canonical decimal, no sign or leading zeros;1, or2for a passphrase-protected secret)X-Gone-Nonce(exactly 16 unpadded base64url characters encoding the 12-byte GCM IV)X-Gone-TTL(Go duration, e.g.15m)Content-Length(required; chunked uploads are rejected)
Each
X-Gone-*header must appear exactly once.The server validates size and TTL, rejects version
1with403when the operator requires a passphrase (GONE_REQUIRE_PASSPHRASE), issues an ID, and stores the ciphertext inline in SQLite (≤GONE_INLINE_MAX_BYTES) or as a filesystem blob.Response:
201with JSON{ "id": "<32-hex>", "expires_at": "RFC3339", "manage_token": "<43-char base64url>" }. The server keeps only a SHA-256 hash of the manage token.The client builds the share link
/secret/{id}#v1:<base64url-key>(or#v2:with a passphrase). The key lives only in the URL fragment, which browsers never send to the server. The passphrase never appears in the link.The client also builds the sender’s private manage link
/manage/{id}#<manage_token>. It cannot decrypt anything; it can only check on or revoke the secret.
Sender Workflow (Status + Revoke)
The manage page reads the token from the URL fragment and sends it only in the X-Gone-Manage header.
- Status.
GET /api/secret/{id}/statusreturns200with{ "state": "pending", "created_at", "expires_at" }while the secret is waiting, including while a recipient’s claim lease is active. Checking status never claims or deletes anything. - Revoke.
POST /api/secret/{id}/revokedeletes the secret and returns204. Revoke wins over an active claim: the recipient’s acknowledgement then fails. A recipient who already downloaded the ciphertext still holds it, so revoke is only reliable before the link is opened. - No tombstones. Once a secret is opened, revoked, or expired, nothing about it remains. Status and revoke return the same
404for all of these cases, for unknown IDs, and for a wrong token. The sender cannot tell “opened” from “expired” or “revoked”, and nobody else can learn anything.
Request Workflow
A secret request runs the other way: the person who needs a secret makes a link, and the person who has it answers. The cryptography is protocol v3 (Protocol specification §4.3, §8.6).
- Create. The requester’s browser makes an ECDH P-256 key pair and keeps the private key, non-extractable, in IndexedDB. It sends
POST /api/requestwithX-Gone-TTLand no body. Response:201with{ "id", "expires_at", "manage_token", "fill_token" }. The server stores only SHA-256 hashes of both tokens, and never sees the public key or the requester’s label. - Reply link. The browser builds
/reply/{id}#v3:<base64url public key>.<fill_token>for the requester to send to whoever has the secret. - Check. The reply page validates the fragment, then sends
GET /api/request/{id}withX-Gone-Fill.200 { "state": "open", "expires_at" }while it can be answered,404otherwise. The check is read-only. - Reply. The reply page encrypts the message and files to the public key and sends
PUT /api/request/{id}/replywithX-Gone-Version: 3,X-Gone-Nonce,X-Gone-FillandContent-Length. Response:201 { "expires_at" }. Each request takes exactly one reply; the reply is then kept for the request’s TTL again, counted from when it was sent. - Status. The requester’s page polls
GET /api/request/{id}/statuswithX-Gone-Manage:{ "state": "waiting" | "ready", "created_at", "expires_at" }. Status is read-only, so polling costs no writes. - Open.
GET /api/request/{id}/replywithX-Gone-Manageclaims the reply exactly like a secret claim (same headers and lease), andDELETE /api/request/{id}/replywithX-Gone-Claimdeletes it.GET /api/secret/{id}never returns a reply, so the ID alone can’t burn it. - Cancel.
POST /api/request/{id}/revokewithX-Gone-Managedeletes an open request or an unopened reply (204). - No tombstones. Expired, cancelled, answered, opened and unknown requests, and wrong tokens, all return the same
404.
Consumption Workflow (Claim + Acknowledge)
- Claim. The client sends
GET /api/secret/{id}without anX-Gone-Claimheader. If the secret exists, is unexpired, and is unclaimed, the server atomically marks it claimed and generates a random claim token. Only a SHA-256 hash of the token is stored. - Response:
200with the ciphertext body and headers:X-Gone-Version,X-Gone-NonceX-Gone-Claim: opaque claim token (43-char base64url)X-Gone-Claim-Expires: RFC 3339 UTC lease deadline (GONE_CLAIM_LEASE, default2m, max15m)Content-Length,Content-Type: application/octet-stream,Cache-Control: no-store
- Verify and decrypt. The client checks that it received exactly
Content-Lengthbytes, then decrypts. AES-GCM authentication detects any truncation or tampering. - Retry (optional). If the download was interrupted, the client repeats
GET /api/secret/{id}withX-Gone-Claim: <token>while the lease is still valid. Only the token holder can re-fetch. - Acknowledge. The client sends
DELETE /api/secret/{id}withX-Gone-Claim: <token>. The server permanently deletes the record and blob and returns204 No Content. - Lease expiry. If no acknowledgement arrives before the lease ends, the janitor deletes the secret (counted by
secrets_claims_expired_total). It is never re-served to anyone else. - Any later request, whether a fresh claim of a claimed or deleted secret or a wrong token, returns
404.
Payload Format
The server treats the body as opaque ciphertext. Inside the encryption:
- Text only: the plaintext is the raw UTF-8 message.
- With attachments (envelope v2):
"GONE2\0\0\0"magic (8 bytes), a big-endianu32header length, a JSON header{ "v": 2, "msg": <message byte length>, "files": [{ "name", "type", "size" }] }, then the message bytes, then each file’s bytes in order.
File names, types, and sizes are therefore encrypted along with the contents. Decoders sanitize file names and map MIME types onto an allowlist (see protocol.md §6). The web client allows up to 10 files of any type, or fewer when the operator sets GONE_MAX_FILES. The server cannot check that limit, since the file count is encrypted; see Policies. The message and all files share the MaxBytes limit, which includes the 16-byte GCM tag. Encryption uses AES-256-GCM with AAD gone:v1. With a passphrase (protocol v2), the AES key also depends on the passphrase through PBKDF2-SHA-256 and HKDF, and the body starts with a 21-byte authenticated header that the server stores without parsing (see protocol.md §4.2).
Error Mapping
All errors are JSON { "error": "<message>" }.
| Condition | Status | Body error |
|---|---|---|
X-Correlation-ID present but not a UUID (any route) | 400 | invalid correlation id |
| Invalid ID format | 400 | invalid id |
Malformed or repeated claim token (DELETE without/with bad X-Gone-Claim, or bad token on GET retry; the header must appear at most once) | 400 | invalid claim |
Missing, repeated, or malformed X-Gone-Manage on status/revoke | 400 | invalid manage token |
Missing, repeated, or malformed X-Gone-Fill on a request check or reply | 400 | invalid fill token |
Missing, empty, or repeated X-Gone-Version / X-Gone-Nonce / X-Gone-TTL | 400 | missing required headers |
Non-canonical or unsupported version (on a reply, anything but 3) | 400 | invalid version |
| Nonce not 16 base64url characters (12 bytes) | 400 | invalid nonce |
| Unparseable TTL | 400 | invalid ttl |
TTL outside [MinTTL, | 400 | ttl invalid |
Unparseable Content-Length | 400 | invalid content length |
Body ends before Content-Length bytes arrived (create or reply) | 400 | short body |
| Any other malformed create request | 400 | bad request |
Create (POST /api/secret or POST /api/request) without proof of sign-in when the operator set GONE_AUTH (sends WWW-Authenticate: Bearer realm="gone"). See Require sign-in. | 401 | sign in required |
Version 1 on create when the operator requires a passphrase (GONE_REQUIRE_PASSPHRASE; replies are exempt) | 403 | passphrase required |
| Not found / expired / claimed by someone else / wrong token / lease lapsed / opened or revoked (status, revoke) | 404 | not found |
| Method not allowed | 405 | method not allowed |
Missing Content-Length on create or on a reply (PUT /api/request/{id}/reply) | 411 | content length required |
Size > MaxBytes (create or reply) | 413 | size exceeded |
Per-client rate limit exceeded (sets Retry-After) | 429 | rate limited |
| Internal failure | 500 | internal |
Storage overloaded: no write turn within 5s (sets Retry-After; nothing changed, safe to retry) | 503 | busy |
If the client disconnects or times out while the server is working, the request is recorded as 499 and logged at debug level only; no client sees it, and it is never an error-level log line.
Secret replies are a different resource from secrets: DELETE /api/secret/{id}, /status and /revoke return 404 for a request’s reply, so a claim or manage token from the request side can only be used on /api/request/{id}/....
Rate Limiting
Each client address has two token buckets: one for creating secrets (POST /api/secret, GONE_RATE_CREATE) and one for reading them (GET and DELETE /api/secret/{id}, plus /status and /revoke, GONE_RATE_READ). Requests follow the same split: POST /api/request and PUT /api/request/{id}/reply (which uploads a body) use the create bucket, and every other /api/request/ call uses the read bucket. Both buckets hold up to GONE_RATE_BURST tokens and refill steadily at the configured rate. Health probes, pages, and static assets are not limited. /readyz is cheap by construction (cached for 2 seconds, one directory read); a language header is parsed only up to its first 16 entries.
- Over budget:
429withRetry-After: <seconds>and{ "error": "rate limited" }. The request body is never read. - Clients are keyed by IPv4
/32or IPv6/64. - The client address is the TCP peer.
X-Forwarded-Foris honored only when the peer is inGONE_TRUSTED_PROXIES; it is then walked right to left, skipping trusted hops, and the first untrusted address is used. - Counters are per process. With several replicas, the effective budget is multiplied by the replica count.
- Each IPv6
/48(or IPv4/24) may hold at most 256 tracked clients, and the table holds at most 100,000. Beyond either cap, clients share overflow buckets grouped by that parent network, so one large allocation cannot crowd out other users. - Rejections are counted in the
gone_rate_limited_create_totalandgone_rate_limited_read_totalmetrics (rate_limited_create_totalandrate_limited_read_totalin the JSON snapshot). - Request activity is counted in
requests_created_total,requests_filled_total,requests_opened_total,requests_cancelled_totalandrequests_expired_total. - Client addresses are never logged; only the correlation ID and scope are.
Security Headers
Every response carries:
X-Content-Type-Options: nosniffReferrer-Policy: no-referrerX-Robots-Tag: noindex: search engines don’t list any pageContent-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'X-Correlation-ID: the request’s value if it parses as a UUID (returned in canonical form), or a generated UUID v4. A value that isn’t a UUID is rejected with400on every route.
Responses default to Cache-Control: no-store and Pragma: no-cache. Static assets under /static/ override it: the pages link them with a content hash (?v=<hash>), and those URLs get Cache-Control: public, max-age=31536000, immutable; a bare URL gets public, max-age=300 so an upgrade is picked up quickly. Message catalogs under /i18n/ follow the same rule, keyed on the catalog’s own hash. The Pragma: no-cache header still appears on both.
Future Extensions (Non-Breaking)
- Optional JSON POST mode with metadata wrapper.
Non-Goals
- Secret re-use or updates.
- Listing secrets.
- Multi-consume semantics.
- Server-side encryption (handled entirely client-side).
The full list of features Gone has decided against, with the reasons and when to revisit them, is in Rejected features.
Implementation Notes
- The inline vs external threshold (
GONE_INLINE_MAX_BYTES, default 8 KiB) is not exposed via the API. X-Gone-Noncemust decode (base64url, unpadded) to exactly 12 bytes; anything else is400 invalid nonce.- The duration regex in the spec mirrors a subset of Go’s
time.ParseDuration.
Refer to openapi.yaml for the machine-readable schema.