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.
curl -sS https://drop.kapable.ai/healthYou should see a healthy response from the Drops host.
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 + returnedEndpoints
| Method | Path | Auth | SDK (client.drops) |
|---|---|---|---|
| POST | /v1/drops | required | create |
| POST | /v1/drops/{id}/claim | public | claim |
| GET | /v1/drops/{id}/status | creator + org | status |
| GET | /d/{id} | public page | — |
| GET | /health | public | — |
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
- Burn on explicit claim, never on GET. Opening
/d/{id}(or a link-preview prefetching it) never consumes the drop — onlyPOST …/claimdoes. Safe to paste into a chat that renders previews. - One-time by default.
max_claimsdefaults to 1 (cap 10). On the final claim the ciphertext is NULLed; a tombstone row is kept ~30 days for receipts, then reaped. - Short-lived.
ttl_secsdefaults to 72h, capped at 7 days. An hourly reaper destroys the ciphertext of any expired-but-unclaimed drop.
Error strings are actionable
Errors use {error:{code,message,details}}. The message is the human line.
| Code | What you see | What to do |
|---|---|---|
410 | already claimed at 2026-07-18T15:45:18Z — all 1 claim(s) used | Ask the sender to mint a new drop. |
410 | expired 2026-07-21T15:45:18Z — drops live at most 7 days | Ask the sender to mint a new drop. |
404 | no drop with that id | Ask the sender to mint a new drop. |
422 | ttl_secs 691200 exceeds the cap of 604800 (7 days) | Use a TTL of 604800 or less. |
422 | max_claims 11 exceeds the cap of 10 | Use 10 or fewer claims. |
429 | too many claim attempts from your address | Wait 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.