// REST API
built for pipelines.
Getting started
Five steps take you from an empty account to a webhook-driven integration. The SDKs do all encryption on your side; the server only ever receives ciphertext, IVs and wrapped keys.
// STEP 01
Issue an API key
Sign in and open the keys tab of the dashboard. API keys belong to your organization, so you need to be an organization Owner or Admin to create one (see Organizations and credentials). The full key is shown once, at creation, and never again — copy it into your secret manager. Keep it server-side: never in a browser bundle, never in a public environment variable, and never in these docs. Each key carries a daily limit set by its tier; both are shown next to the key.
// STEP 02
Install the SDK
npm install @burnedsecret/sdk
# Node 20 or newer (native globalThis.crypto.subtle)
pip install burnedsecret
# Python 3.10 or newer
// STEP 03
Create a secret
The SDK generates a random AES-256-GCM key, encrypts locally and uploads only the ciphertext and IV. The key is returned to you in the URL fragment, which browsers never send to a server.
import { BurnedSecret } from "@burnedsecret/sdk";
const bs = new BurnedSecret({ apiKey: process.env.BS_API_KEY });
const { url, id } = await bs.createSecret("hunter2", { ttl: 86400 });
// url: https://burnedsecret.com/s/<id>#k=<base64url AES key>
// Only ciphertext + IV were sent. The part after '#' stays with you.
const plaintext = await bs.readSecret(url); // burns the secret on read
import os
from burnedsecret import BurnedSecret
bs = BurnedSecret(api_key=os.environ["BS_API_KEY"])
url, secret_id = bs.create_secret("hunter2", ttl=86400)
# url: https://burnedsecret.com/s/<id>#k=<base64url AES key>
# Only ciphertext + IV were sent. The part after '#' stays with you.
plaintext = bs.read_secret(url) # burns the secret on read
// STEP 04
Create a request
A request asks someone else to send you a secret. The SDK generates an RSA-4096 keypair and a random root secret locally, wraps the private key under a key derived from that root, and uploads only the public key and the wrapped material. Creating a request needs your API key, and it returns two links:
- The submission link goes to the person answering. It lets them send a reply and cannot read one.
- The private view link stays with you. It is a bearer capability: anyone who holds it can read the reply, and there is one collective read for everyone who has a copy. If you lose it the reply is unrecoverable, because the server never sees the
#s=part. Save it in your secret store keyed by the request id, and never put it in logs, tickets or webhook URLs.
Reading needs no API key: the private view link is the credential. getRequestView checks status without consuming anything. readFulfillment checks status first and throws without consuming if the reply is not ready yet, then sends a single consume call and never retries it. If that call ends with an unknown outcome (network error, timeout, 5xx or an unreadable response), it throws RetrievalUncertainError. Do not retry then: the reply may already have been consumed.
import { BurnedSecret, RetrievalUncertainError } from "@burnedsecret/sdk";
const bs = new BurnedSecret({ apiKey: process.env.BS_API_KEY });
const { url, id, privateViewUrl } = await bs.createRequest({
prompt: "Send me your AWS root key",
webhookUrl: "https://acme.example/hooks/bs",
ttl: 86400,
});
// url -> https://burnedsecret.com/r/<id> (submission link: send it to the person answering)
// privateViewUrl -> https://burnedsecret.com/r/<id>/view#s=<root secret> (private view link: keep it)
await mySecretStore.put(`bs/req/${id}`, privateViewUrl);
// Later (any machine, no API key needed): a non-consuming status check...
const { status } = await bs.getRequestView(privateViewUrl);
if (status === "ready") {
try {
const answer = await bs.readFulfillment(privateViewUrl); // burn-on-read: exactly once
} catch (e) {
if (e instanceof RetrievalUncertainError) {
// The reply may already be consumed. Do not retry.
}
throw e;
}
}
from burnedsecret import BurnedSecret, RetrievalUncertainError
url, request_id, private_view_url = bs.create_request(
prompt="Send me your AWS root key",
webhook_url="https://acme.example/hooks/bs",
ttl=86400,
)
# url -> https://burnedsecret.com/r/<id> (submission link: send it to the person answering)
# private_view_url -> https://burnedsecret.com/r/<id>/view#s=<root secret> (private view link: keep it)
my_secret_store.put(f"bs/req/{request_id}", private_view_url)
# Later (any machine, no API key needed): a non-consuming status check...
view = bs.get_request_view(private_view_url)
if view["status"] == "ready":
try:
answer = bs.read_fulfillment(private_view_url) # burn-on-read: exactly once
except RetrievalUncertainError:
# The reply may already be consumed. Do not retry.
raise
From a shell, bs request create prints both links with distinct labels (the private-link warning goes to stderr) and writes nothing to disk. bs request status and bs request read take the private view link, or - to read it from stdin, and need no login. Pass the link on stdin so it stays out of your shell history and process list:
bs request create --prompt "Send me your AWS root key" --ttl 1d
# Submission link: https://burnedsecret.com/r/<id>
# Private view link: https://burnedsecret.com/r/<id>/view#s=<root secret>
read -rs LINK # paste the private view link (not echoed)
printf '%s\n' "$LINK" | bs request status - # non-consuming
printf '%s\n' "$LINK" | bs request read - # burn-on-read: exactly once
unset LINK
Portable web request links
Every new request, whether it comes from the web app, the SDKs, the CLI or the browser extension, has two different links. The submission link is safe to send to the person answering the request; it contains no read capability. The private view link is for the requester or a trusted colleague and has the form /r/{id}/view#s={root}. Possession of that full link allows one answer to be revealed without signing in, so protect it like the answer itself.
The #s= fragment contains a random 32-byte root secret S. The requester's client derives two independent 32-byte values with HKDF-SHA-256: wrapping key K and retrieval token T. S and K never leave the client. Creation always uploads the complete portable pair: the canonical client-generated request_id and all four portable fields, which are the AES-256-GCM-wrapped PKCS8 private key, its 12-byte IV, version 1, and SHA-256(T). A create without the complete pair is rejected with 400 and nothing is stored.
The official JavaScript and Python SDKs, the bs CLI and the browser extension all use this protocol. There is no authenticated owner read: an API key or account session can create requests but never reads a reply. A request record stored without portable material is unavailable and is never consumed.
Opening the private link calls GET /v1/requests/{id}/view with T in the Authorization: Bearer header. This status call returns waiting or ready, the public key, and exactly the three public wrapping fields (version, wrapped_private_key, iv). It never returns the stored token hash or the encrypted answer, and it never consumes the answer. After the client has successfully unwrapped and imported the private key, an explicit Reveal action (or readFulfillment / read_fulfillment / bs request read) sends an empty POST /v1/requests/{id}/view/consume. The first authorized consume wins.
The browser may cache S locally as a convenience. The full private link remains the portable recovery material: losing both that link and the browser cache makes the answer unrecoverable. Webhook payloads and notification emails contain only a fragmentless navigation URL or ids; readers must use their saved private link or local cache. There is no account, passphrase, or passkey conversion that can recreate S.
T is a narrow bearer capability. This means token theft can burn without decrypting: T can authorize one ciphertext consume, but it cannot derive S or K and cannot unwrap the private key. After consumption the server erases the verifier and encrypted material and retains only a generic tombstone until expiry. Because a successful response lost in transit cannot be recovered, the client never retries the consuming POST automatically.
The exact salt, purpose labels, AAD byte encoding, field bounds, and lifecycle rules are the canonical portable request-view contract. Implementations should verify byte-for-byte behavior against the synthetic public fixture at /api/v1/test-vectors.json. Never copy its fixed test keys into production.
// STEP 05
Handle the fulfillment webhook
When a request is fulfilled (or a secret is burned) we POST a flat JSON event to your webhook URL, signed with HMAC-SHA256 over the exact body bytes in the X-Signature header. The payload carries only ids and timestamps — never the answer, a key, or a link with a key fragment. Webhook payloads and notification emails are fragmentless and never contain the private view link, so your handler looks up the private link it saved at creation by the request id. Verify against the raw body, treat X-Webhook-ID as an idempotency key, and expect deliveries minutes rather than seconds after fulfillment.
import express from "express";
import { BurnedSecret, verifyWebhook, WebhookSignatureError } from "@burnedsecret/sdk";
const bs = new BurnedSecret(); // private-link reads need no API key
app.post(
"/webhooks/burnedsecret",
express.raw({ type: "application/json" }), // RAW body — never re-serialised JSON
async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.header("X-Signature"), process.env.BS_WEBHOOK_SECRET);
} catch (e) {
if (e instanceof WebhookSignatureError) return res.status(401).end();
throw e;
}
if (event.event === "request.fulfilled") {
// Look up the private view link you saved when you created the request.
const privateViewUrl = await mySecretStore.get(`bs/req/${event.request_id}`);
const answer = await bs.readFulfillment(privateViewUrl); // one read, never retried
}
res.status(204).end();
},
);
import os
from flask import Flask, request
from burnedsecret import BurnedSecret, WebhookSignatureError, verify_webhook
app = Flask(__name__)
bs = BurnedSecret() # private-link reads need no API key
@app.post("/webhooks/burnedsecret")
def burnedsecret_hook():
try:
event = verify_webhook(
request.get_data(), # RAW bytes — never re-serialised JSON
request.headers.get("X-Signature"), # "sha256=<hex>"
os.environ["BS_WEBHOOK_SECRET"],
)
except WebhookSignatureError:
return "", 401
if event["event"] == "request.fulfilled":
# Look up the private view link you saved when you created the request.
request_id = event["request_id"]
private_view_url = my_secret_store.get(f"bs/req/{request_id}")
answer = bs.read_fulfillment(private_view_url) # one read, never retried
return "", 204
$ NPM @burnedsecret/sdk $ PYPI burnedsecret
↳ Building your own client? Start from the spec at /security and the interop vectors at /api/v1/test-vectors.json.
Organizations and credentials
The API accepts two kinds of bearer credential in the same Authorization: Bearer header. Both start with bs_, so the SDKs accept either one without any change.
- Organization service keys (
bs_...). API keys belong to an organization, not to a person, and are what servers and integrations should use. Organization Owners and Admins create and revoke them in the dashboard; a one-person organization is fine. Daily caps are per key and set by the key's tier. - Per-device sessions (
bs_sess_...).bs auth loginand the Chrome extension no longer mint API keys. Each device gets its own session that acts as you, under your personal daily limit. You can see and revoke your devices in the dashboard's Security page, andbs auth logoutor signing out of the extension revokes that device's session on the server. - The CLI requires an organization. A CLI session only works while you are a member of an organization. Without one,
bs auth loginstops with a message pointing you to the dashboard, and API calls return403 ORG_REQUIRED. The extension needs only an account. - Sign-in policy. If your organization later restricts sign-in methods (for example to SSO only), sessions created another way stop working with
401 SIGN_IN_METHOD_NOT_ALLOWED; sign in again with an allowed method. - Existing keys were migrated automatically. Keys created before organizations moved into your personal organization, and existing CLI and extension logins became sessions with the same value, so nothing needs to be reissued.
Reference
↳ rendered live from /api/v1/openapi.json
↳ Spec not loading? Open /api/v1/openapi.json directly.