Tools / REST API
REST API
Two endpoints: one creates a secret, one reveals and destroys it. JSON in, JSON out, callable from any language. Encryption happens entirely on your side — the API only ever handles ciphertext.
Base URL
https://secretonce.dev
All requests are POST with a JSON body and Content-Type: application/json. All responses are JSON. TLS is required.
Authentication
Authentication is optional. Anonymous requests work and are subject to a per-IP daily quota. Authenticated requests use your plan's quota and attribute the link to your account, so it appears on your dashboard with view notifications and the ability to revoke.
Authorization: Bearer so_live_…
API keys are a PRO feature; create and revoke them from your dashboard. A key is shown once, at creation, and only its hash is retained — a lost key cannot be recovered, only replaced. Sending a key that is unknown, revoked, or not on a PRO plan returns 401.
Never put an API key in client-side JavaScript. Anything a browser downloads is public. Call the API anonymously from a browser, or proxy through your own backend.
Encryption — your responsibility
This is the part that makes the service zero-knowledge, and the API cannot do it for you. The server stores exactly the bytes you send it. If you send plaintext, we store plaintext, and the guarantee is gone.
Before calling /api/secret, encrypt the secret client-side and keep the key. The format is fixed:
| Element | Value |
|---|---|
| Cipher | AES-256-GCM |
| Key | 256 bits, generated with a CSPRNG, exported raw and encoded base64url |
| IV | 12 random bytes, unique per secret |
ciphertext field | base64url of IV ‖ GCM output (the 16-byte auth tag is appended by GCM) |
The key never goes into a request. It belongs in the URL fragment of the link you hand to the recipient — https://secretonce.dev/s/<id>#<key> — because browsers never transmit the fragment to a server. This format is frozen and will not change; links already in the wild must keep working.
Rather not implement this yourself? The npm package does exactly the above in a few lines and is the reference implementation.
Create a secret
Stores a ciphertext and returns the id to build a link from. Counts against your daily creation quota.
Body parameters
| Parameter | Type | Description |
|---|---|---|
ciphertext required | string | base64url of IV ‖ AES-256-GCM output. Size is capped by your plan. |
ttl required | integer | Lifetime in seconds. Must be one of the accepted values below, and within your plan's maximum. The API has no implicit default — the npm package supplies 86400 when you omit it. |
Accepted ttl values
| Seconds | Duration | Available on |
|---|---|---|
3600 | 1 hour | Anonymous and up |
28800 | 8 hours | Anonymous and up |
86400 | 24 hours (default) | Anonymous and up |
259200 | 3 days | Free and up |
604800 | 7 days | Free and up |
2592000 | 30 days | PRO |
7776000 | 90 days | PRO |
Request
curl -X POST https://secretonce.dev/api/secret \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SECRETONCE_API_KEY" \
-d '{"ciphertext":"HfTzB1n…","ttl":3600}'
Response 200
{ "id": "abc123" }
Build the link yourself by appending the key as a fragment:
https://secretonce.dev/s/abc123#<your base64url key>
Reveal a secret
This call is destructive. It returns the ciphertext and deletes the record in the same atomic operation, so exactly one caller ever receives a given secret — a retry after a dropped connection gets 404, and the secret is gone. Buffer the response before you do anything that might fail.
No authentication is needed or accepted: possession of the id is what grants the read, and possession of the key is what makes it readable.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id required | string | The id returned by POST /api/secret — the path segment after /s/ in a link. |
Request
curl -X POST https://secretonce.dev/api/secret/abc123/reveal
Response 200
{ "ciphertext": "HfTzB1n…" }
Decrypt it with the key from the link's fragment. If decryption fails, the secret is unrecoverable — it has already been destroyed, and a wrong or truncated key cannot be corrected after the fact.
Response 404
{ "error": "gone" }
The secret was already read, revoked, or expired. These are deliberately indistinguishable: the API will not confirm whether an id ever existed.
Errors
Failures return a JSON body of the shape { "error": "…" }; some also carry a limit field. Match on the status code — the message is for humans and may change.
| Status | Meaning |
|---|---|
400 | Bad input — missing or malformed ciphertext, unsupported ttl. |
401 | The API key is unknown, revoked, or not on a PRO plan. |
403 | Over a plan limit. limit is "expiry" or "size". |
404 | Already read, revoked, or expired. |
429 | Daily creation quota exhausted. Resets at midnight UTC. |
Quota is counted per UTC day at creation time. Revoking a link or letting it expire does not give the day's allowance back.
CORS
Both endpoints on this page answer cross-origin browser requests with Access-Control-Allow-Origin: * and no credentials, so a front-end on your own domain can create and reveal secrets directly — the plaintext and the key stay in your user's browser, which is the whole point. Every other route is same-origin only.
Because credentials are not accepted cross-origin, browser calls are anonymous. Do not work around this by embedding an API key in the page.
Writing JavaScript?
The npm package handles the encryption, the link format, and typed errors for you.