BURNEDSECRET::VAULT v1.0.0
$ NEW $ DASH

// 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.

$ ISSUE KEY

// STEP 02

Install the SDK

npm install @burnedsecret/sdk
# Node 20 or newer (native globalThis.crypto.subtle)

// 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

// 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:

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 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

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();
  },
);

↳ 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.

Reference

↳ rendered live from /api/v1/openapi.json

↳ Spec not loading? Open /api/v1/openapi.json directly.