Skip to content

Developers

Build with VaultDrop

A versioned REST API where encryption stays on your side. Create one-time links from scripts, pipelines and apps.

Quickstart

VaultDrop never accepts plaintext. Your client encrypts the secret, sends the ciphertext, and builds the share link by appending the key as a URL fragment. The quickest route is the CLI; the API is fully documented in the API reference and as an OpenAPI 3.1 spec.

quickstart.shShell
# 1. Encrypt locally (the CLI or an SDK does this for you), then create:
curl -sS https://vaultdrop.example/api/v1/secrets \
  -H "Authorization: Bearer $VAULTDROP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deploy-4821" \
  -d @sealed.json
# → { "id": "Xk2p…", "expiresAt": "…", "receiptToken": "…",
#     "shareUrlBase": "https://vaultdrop.example/s/Xk2p…" }
# Share <shareUrlBase>#<linkKey> — the link key never leaves your machine.

# 2. Check status (does not consume)
curl -sS https://vaultdrop.example/api/v1/secrets/Xk2p…/status

# 3. Destroy before it is read
curl -sS -X POST https://vaultdrop.example/api/v1/secrets/Xk2p…/burn \
  -H "Authorization: Bearer $VAULTDROP_API_KEY"

Authentication

Anonymous requests can create secrets with anonymous limits. For higher limits, labels, listing and webhooks, create an API key in Dashboard → API keys (Pro plan and above) and send Authorization: Bearer vd_live_…. Keys are shown once and stored only as SHA-256 hashes. Scopes: secrets:create, secrets:read, secrets:burn, webhooks:manage. Test keys (vd_test_…) create secrets tagged environment: test and only list test secrets.

Envelope format (v1)

linkKey32 random bytes, base64url — goes after # in the link
pwKeyArgon2id(NFKC(passphrase), salt16, t=2, m=19456 KiB, p=1, 32 bytes) — only with a passphrase
ikmlinkKey ‖ pwKey (or linkKey alone)
encKey / proofHKDF-SHA256(ikm, salt="vaultdrop/v1/hkdf-salt", info="vaultdrop/v1/enc" | "vaultdrop/v1/auth", 32 bytes)
plaintextuint32 big-endian length ‖ UTF-8 bytes ‖ zero padding to a multiple of 256 bytes
ctAES-256-GCM(encKey, iv=12 random bytes, aad="vaultdrop/v1"), tag appended
verifierbase64url(SHA-256(proof)) — sent at creation

To open a secret: GET /secrets/:id/status (returns KDF params), derive keys, POST /secrets/:id/redeem with { proof }, then decrypt the returned envelope. Redemption consumes a view only if the proof is correct.

CLI

terminalShell
# Install (Node 20.19+ or Bun), or run without installing via npx / bunx
npm install -g vaultdrop

export VAULTDROP_API_KEY=vd_live_…   # optional; anonymous limits apply without it
# export VAULTDROP_SERVER=https://…  # only for self-hosted servers

echo "$DB_PASSWORD" | vaultdrop --expires 1h
vaultdrop --passphrase --views 3 --label "staging creds" < creds.txt
vaultdrop open "https://vaultdrop.example/s/Xk2p…#M3f…"

SDK examples

Each example encrypts locally and creates a secret over the REST API. TypeScript mirrors the CLI and is covered by interoperability tests; Python and Go are reference examples.

vaultdrop.tsTypeScript
// Bun, Node 20+ or any modern browser. Mirrors cli/vaultdrop.mjs,
// which is covered by VaultDrop's interoperability tests.
import { argon2idAsync } from "@noble/hashes/argon2.js";

const subtle = crypto.subtle;
const te = new TextEncoder();
const b64u = (b: Uint8Array) => Buffer.from(b).toString("base64url");
const SALT = te.encode("vaultdrop/v1/hkdf-salt");
const AAD = te.encode("vaultdrop/v1");
const ARGON2 = { t: 2, m: 19456, p: 1 };

async function derive(ikm: Uint8Array) {
  const base = await subtle.importKey("raw", ikm, "HKDF", false, [
    "deriveKey",
    "deriveBits",
  ]);
  const hkdf = (info: string) => ({
    name: "HKDF", hash: "SHA-256", salt: SALT, info: te.encode(info),
  });
  const encKey = await subtle.deriveKey(
    hkdf("vaultdrop/v1/enc"), base,
    { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
  );
  const proof = new Uint8Array(
    await subtle.deriveBits(hkdf("vaultdrop/v1/auth"), base, 256),
  );
  return { encKey, proof };
}

function pad(text: string) {
  const body = te.encode(text);
  const size = Math.max(Math.ceil((body.length + 4) / 256) * 256, 256);
  const out = new Uint8Array(size);
  new DataView(out.buffer).setUint32(0, body.length);
  out.set(body, 4);
  return out;
}

export async function createSecret(
  server: string, apiKey: string, text: string, passphrase?: string,
) {
  const linkKey = crypto.getRandomValues(new Uint8Array(32));
  let ikm = linkKey;
  let kdf = null;
  if (passphrase) {
    const salt = crypto.getRandomValues(new Uint8Array(16));
    kdf = { alg: "argon2id", salt: b64u(salt), ...ARGON2 };
    const pw = await argon2idAsync(
      te.encode(passphrase.normalize("NFKC")), salt, { ...ARGON2, dkLen: 32 },
    );
    ikm = new Uint8Array([...linkKey, ...pw]);
  }

  const { encKey, proof } = await derive(ikm);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await subtle.encrypt(
    { name: "AES-GCM", iv, additionalData: AAD }, encKey, pad(text),
  ));
  const verifier = b64u(new Uint8Array(await subtle.digest("SHA-256", proof)));

  const res = await fetch(`${server}/api/v1/secrets`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      envelope: { v: 1, iv: b64u(iv), ct: b64u(ct) },
      kdf,
      verifier,
      expiresIn: 3600,
    }),
  });
  if (!res.ok) throw new Error((await res.json()).error.message);
  const { shareUrlBase } = await res.json();
  return `${shareUrlBase}#${b64u(linkKey)}`;
}

Errors & limits

Errors share one shape: { "error": { "code", "message", "details" }, "requestId" }. Include the requestId (also in the X-Request-Id header) when contacting support.

StatusCodeMeaning
401unauthorizedMissing or invalid API key / session
403forbidden, plan_restricted, invalid_proofScope missing, plan limit, or wrong passphrase/link (secret not consumed)
404not_foundUnknown secret, or not yours
410secret_unavailableConsumed, expired or revoked — see details.state
413payload_too_largeCiphertext over your plan's size limit
422validation_failed, idempotency_mismatchInvalid body, or reused idempotency key
429rate_limited, quota_exceededSlow down; honour Retry-After

Default limits: anonymous creation 20 per 10 minutes per client; redemption 30 per minute per client and 10 per minute per secret; API keys per plan (e.g. Pro 120/min).

Idempotency

Send Idempotency-Key: <8–128 chars> with authenticated POST /secrets. Retrying with the same key and body within 24 hours returns the original response (header Idempotent-Replayed: true) instead of creating a duplicate. Reusing a key with a different body returns 422.

Webhooks

Events: secret.created, secret.viewed, secret.consumed, secret.revoked, secret.expired. Payloads contain metadata only. Each delivery is signed with your endpoint's secret; failed deliveries (non-2xx or timeout after 10s) are retried after 1m, 5m, 30m, 2h, 6h, 12h and 24h. Endpoints must be public HTTPS URLs.

secret.consumed.jsonJSON
{
  "id": "evt_Qm9vYmFy…",
  "type": "secret.consumed",
  "createdAt": "2026-10-28T09:12:44.120Z",
  "data": {
    "secretId": "Xk2pR7…",
    "label": "staging DB for Sam",
    "state": "consumed",
    "viewCount": 1,
    "maxViews": 1,
    "expiresAt": "2026-10-29T09:10:02.000Z",
    "environment": "live"
  }
}
verify.tsTypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

// Header: "VaultDrop-Signature: t=1761650000,v1=5f1c…"
export function verifyVaultDropSignature(
  secret: string,
  header: string,
  rawBody: string,
): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  // Reject stale timestamps (5-minute replay window).
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const given = String(parts.v1 ?? "");
  return (
    given.length === expected.length &&
    timingSafeEqual(Buffer.from(given), Buffer.from(expected))
  );
}

// Also de-duplicate on the VaultDrop-Event-Id header:
// deliveries are retried and may arrive more than once.

CI/CD

Use a test key in staging pipelines and a live key in production. Never print the link to public logs.

.github/workflows/share-credentials.ymlYAML
name: Share generated credentials
on: workflow_dispatch

jobs:
  share:
    runs-on: ubuntu-latest
    steps:
      - name: Share generated DB password with on-call
        env:
          VAULTDROP_API_KEY: ${{ secrets.VAULTDROP_API_KEY }}
        run: |
          LINK=$(printf '%s' "$GENERATED_PASSWORD" \
            | npx -y vaultdrop --expires 2h --label "rotation $GITHUB_RUN_ID")
          echo "::add-mask::$LINK"
          # Post $LINK to your incident channel; it opens exactly once.