API
Free, keyless, and rate limited by IP. Encrypt on your side and we will hold the bytes.
Base URL
https://api.openonce.link/api/v1
/secretsCreate a secret
Returns the identifier and the expiry timestamp.
Request
{
"content": "ciphertext-or-plaintext",
"ttl": "1d",
"ttlSeconds": 3600,
"views": 1,
"password": null,
"notifyChannel": "webhook",
"notifyTo": "https://hooks.slack.com/services/...",
"label": "prod db password",
"kind": "text",
"fileMeta": "{\"name\":\"config.env\",\"size\":2048,\"type\":\"text/plain\"}"
}Response
{
"id": "8f3kQ2mVxL",
"expiresAt": "2026-08-08T12:04:11.000Z",
"views": 1,
"hasPassword": false,
"notifyOnRead": true
}/secrets/:id/metaCheck a secret
Tells you whether a secret is still alive and how many views remain. This never burns a view.
Response
{
"id": "8f3kQ2mVxL",
"alive": true,
"viewsLeft": 1,
"viewsTotal": 2,
"expiresAt": "2026-08-08T12:04:11.000Z",
"hasPassword": false,
"kind": "text"
}/secrets/:id/revealReveal a secret
Consumes one view and returns the stored content. This is destructive.
Request
{
"password": null
}Response
{
"content": "ciphertext-or-plaintext",
"viewsLeft": 0,
"kind": "file",
"fileMeta": "{\"name\":\"config.env\",\"size\":2048,\"type\":\"text/plain\"}"
}/secrets/:idDestroy a secret
Removes a secret before anyone reads it.
Response
{
"destroyed": true
}Sending files
Set kind to "file" and pass fileMeta as a JSON string with name, size and type. The bytes go in content like any other payload — encrypt them yourself. Note that fileMeta is stored as you send it, so keep secrets out of the file name.
Burn notifications
Pass notifyChannel and notifyTo when creating a secret and we will call your endpoint the moment it is read. The payload never contains the secret itself.
POST https://your-app.com/hook
X-Openonce-Event: secret.revealed
X-Openonce-Timestamp: 1786412651
X-Openonce-Signature: sha256=9f86d081884c7d65...
{
"event": "secret.revealed",
"secretId": "8f3kQ2mVxL",
"occurredAt": "2026-08-07T17:45:47.797Z",
"viewsLeft": 0,
"label": "prod db password"
}Every request carries an HMAC-SHA256 signature over timestamp.rawBody. Compare it in constant time before trusting the payload.
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature.replace('sha256=', '')),
Buffer.from(expected),
);Command line
Pipe anything into a link without leaving your terminal.
npx openonce "my api key" --ttl 1h --views 2
Rate limits
Limits apply per IP address. Exceeding one returns 429 until the window resets.
10 per 60s30 per 60sErrors
Errors use standard status codes with a JSON body containing an error code and a human-readable message.
{
"statusCode": 404,
"error": "Not Found",
"code": "SECRET_NOT_FOUND",
"message": "This secret does not exist or has already been destroyed."
}CONTENT_TOO_LARGEINVALID_NOTIFICATION_TARGETPASSWORD_REQUIREDWRONG_PASSWORDSECRET_NOT_FOUNDThrottlerException