Drops API

Zero-knowledge, burn-after-read secret sharing. Hand a credential, key, or sensitive string to a person or an agent once — without it living in a chat transcript, a log, or even the drop server's own database.

First call
curl -sS https://drop.kapable.ai/health

You should see a healthy response from the Drops host.

Base URL is drop.kapable.ai, not api.kapable.ai

Drops run on their own host, https://drop.kapable.ai — the same host serves the API, the human claim page (/d/{id}), and the returned claim links. The SDK's client.drops targets it automatically; a raw caller uses that host directly.

The zero-knowledge model

The creator encrypts the secret in their own process with AES-256-GCM under a random 32-byte key, and uploads only the ciphertext. The key never reaches the server. It rides the claim URL's #fragment:

https://drop.kapable.ai/d/<id>#<key-b64url>
                                        └────────┬────────┘
                    the fragment is never sent to the server — browsers and
                    HTTP clients keep everything after # local to the client

Decryption happens on the claimer's side: in the browser via WebCrypto on the claim page, or via a bun -e one-liner for agents. The server stores ciphertext it genuinely cannot read, and a claim returns that same ciphertext for the claimer to decrypt with the fragment key.

Payload format

The wire contract, shared by the SDK, the claim page, and the raw recipe:

fragment key = base64url(32 raw key bytes)              // in the URL #hash
ct_b64       = base64( iv[12] || ciphertext || gcm_tag[16] )   // uploaded + returned

Endpoints

MethodPathAuthSDK (client.drops)
POST/v1/dropsrequiredcreate
POST/v1/drops/{id}/claimpublicclaim
GET/v1/drops/{id}/statuscreator + orgstatus
GET/d/{id}public page—
GET/healthpublic—

Create a drop

Authenticated (any platform credential — a session token, or an sk_org_ key). The body carries only ciphertext; the caller does the encryption. The response never contains the key.

# The SDK does the encryption for you; this is the raw wire shape.
curl -X POST https://drop.kapable.ai/v1/drops \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "ct_b64": "<base64(iv||ct||tag)>",
        "label": "prod db url",
        "ttl_secs": 259200,
        "max_claims": 1
      }'

# 200 OK
{
  "id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "claim_url": "https://drop.kapable.ai/d/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "expires_at": "2026-07-21T15:44:42Z",
  "max_claims": 1
}

Append #<key-b64url> to claim_url and share the whole link. Defaults: ttl_secs 259200 (72h, cap 604800 / 7 days), max_claims 1 (cap 10). Ciphertext is capped at 64 KiB — drops carry secrets, not files.

Claim a drop

Public — no auth. Possession of the URL (id + fragment key) is the credential. The claim is atomic and burns the drop: on the final claim the ciphertext is destroyed in the same transaction. Rate-limited to 10/min per IP.

curl -X POST https://drop.kapable.ai/v1/drops/<id>/claim

# 200 OK — decrypt ct_b64 client-side with the fragment key
{ "ct_b64": "<base64(iv||ct||tag)>", "label": "prod db url", "claims": 1, "max_claims": 1 }

Check status (creator only)

Authenticated, and scoped to the creating org. Returns state + receipts, never the ciphertext.

curl -H "Authorization: Bearer $TOKEN" \
  https://drop.kapable.ai/v1/drops/<id>/status

# 200 OK
{
  "state": "burned",            // unclaimed | claimed | expired | burned
  "claims": 1, "max_claims": 1,
  "created_at": "2026-07-18T15:45:18Z",
  "expires_at": "2026-07-21T15:45:18Z",
  "last_claimed_at": "2026-07-18T15:45:18Z",
  "claim_hint": "203.0.113.7"   // claimer IP hint (receipt only)
}

Burn & expiry semantics

Error strings are actionable

Errors use {error:{code,message,details}}. The message is the human line.

CodeWhat you seeWhat to do
410already claimed at 2026-07-18T15:45:18Z — all 1 claim(s) usedAsk the sender to mint a new drop.
410expired 2026-07-21T15:45:18Z — drops live at most 7 daysAsk the sender to mint a new drop.
404no drop with that idAsk the sender to mint a new drop.
422ttl_secs 691200 exceeds the cap of 604800 (7 days)Use a TTL of 604800 or less.
422max_claims 11 exceeds the cap of 10Use 10 or fewer claims.
429too many claim attempts from your addressWait 60 seconds and try again.

Claim without Kapable tooling

Any box with curl + bun can claim a drop — no SDK, no login. This exact recipe is printed in the claim page footer:

URL='https://drop.kapable.ai/d/<id>#<key>' bun -e 'const u=new URL(process.env.URL),id=u.pathname.split("/").pop();let b=u.hash.slice(1).replace(/-/g,"+").replace(/_/g,"/");while(b.length%4)b+="=";const r=await fetch(u.origin+"/v1/drops/"+id+"/claim",{method:"POST"});if(!r.ok){console.error(await r.text());process.exit(1)}const{ct_b64}=await r.json();const raw=Uint8Array.from(atob(ct_b64),c=>c.charCodeAt(0)),iv=raw.slice(0,12),data=raw.slice(12),kr=Uint8Array.from(atob(b),c=>c.charCodeAt(0));const k=await crypto.subtle.importKey("raw",kr,"AES-GCM",false,["decrypt"]);const pt=await crypto.subtle.decrypt({name:"AES-GCM",iv},k,data);await Bun.write("secret.out",new Uint8Array(pt));console.error("wrote secret.out — chmod 600 secret.out")'

The plaintext lands in secret.out (never on your screen), and the drop is burned.

Security posture — what a server compromise leaks

Because encryption is client-side and the key never touches the server, the drop server is a deliberately low-value target:

An attacker who fully owns the drop server / its DB / its logs gets…
Ciphertext (AES-256-GCM)❌ useless without the key
The decryption key✅ never stored, never logged, never in a request — not leaked
The plaintext secret✅ not leaked
Label, claim counts, timestamps, claimer IP hint⚠️ metadata — leaked (keep labels non-sensitive)

The confidentiality boundary is the client-side key plus an unguessable 128-bit id — not tenant row-scoping, which is why the service deliberately runs without RLS and the claim route is public by design. The residual exposure is metadata: don't put the secret in the label. If a full link (including the #fragment) ever lands in a transcript or log, treat the secret as compromised and rotate it.

SDK Examples

The SDK owns the crypto so callers can't get it wrong: create encrypts and returns the full claim URL with the fragment key already appended; claim fetches and decrypts to bytes.

// Create — the SDK encrypts client-side and returns the shareable URL (incl. #key)
const { id, claimUrl } = await client.drops.create({
  secret: 'sk_org_live_…the-credential…',
  label: 'onboarding key for Hardy',
  ttlSecs: 86_400,   // optional (default 72h, cap 7d)
  maxClaims: 1,      // optional (default 1, cap 10)
});
console.log(claimUrl); // https://drop.kapable.ai/d/# — share this once

// Claim — fetch + decrypt in one call (burns the drop)
const secret = await client.drops.claimText(claimUrl); // string
// or claim() for raw bytes: Uint8Array

// Status (creator only) — receipts, never the ciphertext
const s = await client.drops.status(id);
console.log(s.state); // 'burned'
// Create — encrypts client-side, returns the URL with the fragment key
let created = client.drops().create(&CreateDropRequest {
    secret: b"sk_org_live_…the-credential…".to_vec(),
    label: Some("onboarding key for Hardy".into()),
    ttl_secs: Some(86_400),
    max_claims: Some(1),
}).await?;
println!("{}", created.claim_url); // share once

// Claim — fetch + decrypt (burns the drop)
let bytes = client.drops().claim(&created.claim_url).await?;

// Status (creator only)
let s = client.drops().status(&created.id).await?;
assert_eq!(s.state, "burned");

See also the Secrets API (secrets at rest) and the Skills page for the /drop Claude skill.

Next

Getting Started · Authentication