Drop API
Upload, manage, and share encrypted files programmatically using the Drop API.
The Drop API enables secure file sharing through a developer-friendly REST interface. All files are encrypted client-side using AES-256-GCM before upload, ensuring true end-to-end encryption where our servers never see your unencrypted data.
Authentication
The Drop API supports two authentication modes depending on the endpoint and use case:
- API Key (recommended for programmatic use) - pass
Authorization: Bearer ak_your_api_key_here. Generate keys in your Dashboard. - Browser session - automatic via logged-in session cookie. 2FA must be verified if enabled on the account.
Downloading a drop's files is public for anyone with the drop ID and the required decryption material.
API-key requests count against your monthly Drop API quota. Browser-session dashboard requests use separate abuse-prevention limits and do not consume monthly API quota.
# API Key authentication
Authorization: Bearer ak_your_api_key_here
Base URL
https://anon.li/api/v1
End-to-End Encryption
Client-Side Encryption: Unlike server-side encryption, the Drop API uses client-side E2EE. Your CLI tool or application must encrypt files locally before uploading. The encryption key is embedded in the share URL fragment and never sent to our servers unless you explicitly store a vault-wrapped owner key for dashboard recovery.
How It Works
- Generate key - Create an AES-256-GCM key and IV locally
- Encrypt - Encrypt your file and filename on your device
- Upload - Send encrypted data to our API
- Share - Build URL with key in fragment:
https://anon.li/d/dropId#base64Key - Download - Recipients decrypt locally using the key from URL fragment
This approach ensures:
- True zero-knowledge encryption
- Our servers cannot read your files
- The key fragment (
#...) is never sent to servers - Optional owner-key recovery stores only an AES-KW wrapped key tied to the caller's vault
- Files auto-expire and are permanently deleted
Vault Unlock for API Clients
CLI clients that need dashboard key recovery can unlock the account vault without using browser session cookies.
Call /api/vault/bootstrap with the account email to get authSalt, derive auth_secret locally with the vault password and the published Argon2id parameters, then call this endpoint with API-key authentication.
Request
{
"auth_secret": "base64url_argon2id_output"
}
Response
{
"data": {
"vault_id": "cuid...",
"vault_generation": 1,
"vault_salt": "base64url_salt",
"password_wrapped_vault_key": "base64url_aes_kw_wrapped_key",
"kdf_version": 1
},
"meta": {
"request_id": "req_1234567890abcdef"
}
}
The vault password and unwrapped vault key must remain local to the client. Use the unwrapped vault key only to AES-KW wrap the Drop key sent as ownerKey.wrappedKey.
Endpoints
List Drops
Retrieve all your uploaded drops.
Query Parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | number | No | Max items to return (default: 50) |
offset | number | No | Pagination offset (default: 0) |
Example Request
curl https://anon.li/api/v1/drop \
-H "Authorization: Bearer ak_..."
Response
{
"data": [
{
"id": "abc123xyz",
"encryptedTitle": "base64...",
"iv": "base64_iv...",
"downloads": 3,
"maxDownloads": 10,
"expires_at": "2026-01-15T00:00:00.000Z",
"created_at": "2026-01-08T12:00:00.000Z",
"fileCount": 2,
"totalSize": "1048576"
}
],
"meta": {
"request_id": "req_1234567890abcdef",
"total": 1,
"limit": 50,
"offset": 0,
"storage": {
"used": "5242880",
"limit": "5368709120"
}
}
}
Create Drop
Create a new drop container for uploading encrypted files.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
iv | string | Yes | Base64-encoded initialization vector (12 bytes) |
encryptedTitle | string | No | Encrypted title (base64) |
encryptedMessage | string | No | Encrypted message (base64) |
fileCount | number | Yes | Number of files to upload |
expiry | number | No | Days until expiration |
maxDownloads | number | No | Max download count |
ownerKey | object | No | Wrapped owner key metadata for dashboard key recovery. Include wrappedKey, vaultId, and vaultGeneration from the caller's current vault. |
Example Request
curl -X POST https://anon.li/api/v1/drop \
-H "Authorization: Bearer ak_..." \
-H "Content-Type: application/json" \
-d '{
"iv": "base64_iv_here",
"fileCount": 1,
"expiry": 7,
"maxDownloads": 10,
"ownerKey": {
"wrappedKey": "vault_wrapped_owner_key",
"vaultId": "vault_id_from_unlock_materials",
"vaultGeneration": 1
}
}'
Response
{
"data": {
"drop_id": "abc123xyz",
"expires_at": "2026-01-15T00:00:00.000Z",
"owner_key_stored": true
},
"meta": {
"request_id": "req_1234567890abcdef"
}
}
Add File to Drop
Add an encrypted file to an existing drop.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
size | number | Yes | File size in bytes |
encryptedName | string | Yes | Encrypted filename (base64) |
iv | string | Yes | File IV (base64, 12 bytes) |
mimeType | string | Yes | MIME type |
chunkCount | number | Yes | Number of chunks |
chunkSize | number | Yes | Size per chunk |
Response
{
"fileId": "file123",
"s3UploadId": "upload-id",
"uploadUrls": {
"1": "https://presigned-url-for-chunk-1...",
"2": "https://presigned-url-for-chunk-2..."
}
}
Get Drop
Get drop metadata and file info. No authentication is required - anyone with the drop ID can fetch encrypted metadata. Decryption requires the key from the share URL fragment.
Response
{
"id": "abc123xyz",
"encryptedTitle": "base64...",
"encryptedMessage": "base64...",
"iv": "base64_iv...",
"customKey": false,
"salt": null,
"customKeyData": null,
"customKeyIv": null,
"downloads": 4,
"maxDownloads": 10,
"expiresAt": "2026-01-15T00:00:00.000Z",
"hideBranding": false,
"createdAt": "2026-01-08T12:00:00.000Z",
"files": [
{
"id": "file123",
"encryptedName": "base64...",
"size": "1048576",
"mimeType": "application/pdf",
"iv": "base64...",
"chunkSize": 52428800,
"chunkCount": 1
}
]
}
Note: The GET endpoint returns encrypted metadata and file info but does not include download URLs. To get presigned download URLs, use the Record Download endpoint.
Record Download
Record a download event and get presigned download URLs for all files. This increments the download counter and returns time-limited URLs for fetching the encrypted file data.
No authentication or request body is required.
Response
{
"success": true,
"downloadUrls": {
"file123": "https://presigned-url-for-file-1...",
"file456": "https://presigned-url-for-file-2..."
}
}
The downloadUrls object maps each file ID to a presigned URL. These URLs are temporary and should be used immediately to fetch the encrypted file data.
File previews use the same full-file encrypted bytes as downloads. Preview requests therefore count against the drop's download limit.
Delete Drop
Delete a drop you own.
Response
{
"success": true
}
CLI Upload Workflow
For CLI tools, implement the following workflow:
import crypto from 'crypto';
// Helper: derive a unique IV for each chunk (or filename)
function deriveChunkIv(baseIv, chunkIndex) {
const iv = Buffer.alloc(12);
baseIv.copy(iv, 0, 0, 8); // First 8 bytes from base IV
iv.writeUInt32BE(chunkIndex, 8); // Chunk index as big-endian u32
return iv;
}
// 1. Generate encryption key, drop metadata IV, and per-file IV
const key = crypto.randomBytes(32); // 256-bit AES key
const dropIv = crypto.randomBytes(12); // 96-bit IV for title/message metadata
const fileIv = crypto.randomBytes(12); // 96-bit base IV for this file
// 2. Encrypt file data (chunk index 0 for single-chunk files)
const chunkIv = deriveChunkIv(fileIv, 0);
const cipher = crypto.createCipheriv('aes-256-gcm', key, chunkIv);
const encrypted = Buffer.concat([cipher.update(fileBuffer), cipher.final()]);
const authTag = cipher.getAuthTag();
const encryptedData = Buffer.concat([encrypted, authTag]);
// 3. Encrypt filename (reserved chunk index 0xFFFFFFFF)
const filenameIv = deriveChunkIv(fileIv, 0xFFFFFFFF);
const nameCipher = crypto.createCipheriv('aes-256-gcm', key, filenameIv);
const encryptedName = Buffer.concat([
nameCipher.update(filename, 'utf8'),
nameCipher.final(),
nameCipher.getAuthTag()
]).toString('base64url');
// 4. Create drop
const createResponse = await fetch('https://anon.li/api/v1/drop', {
method: 'POST',
headers: {
'Authorization': 'Bearer ak_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
iv: dropIv.toString('base64url'),
fileCount: 1,
expiry: 7,
// ownerKey: {
// wrappedKey: vaultWrappedOwnerKey,
// vaultId,
// vaultGeneration
// }
})
}).then(r => r.json());
const drop = createResponse.data;
// 5. Add file and get presigned upload URLs
const file = await fetch(`https://anon.li/api/v1/drop/${drop.drop_id}/file`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ak_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
size: encryptedData.length,
encryptedName,
iv: fileIv.toString('base64url'),
mimeType: 'application/octet-stream',
chunkCount: 1,
chunkSize: encryptedData.length
})
}).then(r => r.json());
// 6. Upload encrypted data to presigned URL
const uploadResponse = await fetch(file.uploadUrls[1], {
method: 'PUT',
body: encryptedData
});
const etag = uploadResponse.headers.get('ETag');
if (!etag) throw new Error('Storage did not return an ETag');
// 7. Finish the upload by recording chunk ETags and marking the drop ready
await fetch(`https://anon.li/api/v1/drop/${drop.drop_id}?action=finish`, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ak_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
files: [{
fileId: file.fileId,
chunks: [{ chunkIndex: 0, etag }]
}]
})
});
// 8. Build share URL with key in fragment
const shareUrl = `https://anon.li/d/${drop.drop_id}#${key.toString('base64url')}`;
console.log('Share URL:', shareUrl);
Download & Decrypt Workflow
Recipients can programmatically download and decrypt shared files using just the share URL. The encryption key is embedded in the URL fragment (#...), which is never sent to the server.
import crypto from 'crypto';
// Helper: derive a unique IV for each chunk (or filename)
function deriveChunkIv(baseIv, chunkIndex) {
const iv = Buffer.alloc(12);
baseIv.copy(iv, 0, 0, 8); // First 8 bytes from base IV
iv.writeUInt32BE(chunkIndex, 8); // Chunk index as big-endian u32
return iv;
}
// 1. Parse the share URL
const shareUrl = 'https://anon.li/d/abc123xyz#U2FsdGVkX1...';
const url = new URL(shareUrl);
const dropId = url.pathname.split('/d/')[1];
const keyBase64 = url.hash.slice(1); // Remove the '#'
// 2. Import the encryption key
const keyBuffer = Buffer.from(keyBase64, 'base64url');
// 3. Fetch drop metadata (no auth required)
const drop = await fetch(`https://anon.li/api/v1/drop/${dropId}`)
.then(r => r.json());
// 4. Get presigned download URLs (increments download counter)
const { downloadUrls } = await fetch(
`https://anon.li/api/v1/drop/${dropId}/download`,
{ method: 'POST' }
).then(r => r.json());
// 5. Decrypt title and message if present
const dropIv = Buffer.from(drop.iv, 'base64url');
if (drop.encryptedTitle) {
const titleIv = deriveChunkIv(dropIv, 0xFFFFFFFF);
const encTitle = Buffer.from(drop.encryptedTitle, 'base64url');
const titleDec = crypto.createDecipheriv('aes-256-gcm', keyBuffer, titleIv);
titleDec.setAuthTag(encTitle.slice(-16));
const title = Buffer.concat([
titleDec.update(encTitle.slice(0, -16)),
titleDec.final()
]).toString('utf8');
console.log('Title:', title);
}
if (drop.encryptedMessage) {
const msgIv = deriveChunkIv(dropIv, 0xFFFFFFFF);
const encMsg = Buffer.from(drop.encryptedMessage, 'base64url');
const msgDec = crypto.createDecipheriv('aes-256-gcm', keyBuffer, msgIv);
msgDec.setAuthTag(encMsg.slice(-16));
const message = Buffer.concat([
msgDec.update(encMsg.slice(0, -16)),
msgDec.final()
]).toString('utf8');
console.log('Message:', message);
}
// 6. Download and decrypt each file
for (const file of drop.files) {
const fileIv = Buffer.from(file.iv, 'base64url');
// Decrypt filename (reserved chunk index 0xFFFFFFFF)
const filenameIv = deriveChunkIv(fileIv, 0xFFFFFFFF);
const encryptedName = Buffer.from(file.encryptedName, 'base64url');
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, filenameIv);
decipher.setAuthTag(encryptedName.slice(-16));
const filename = Buffer.concat([
decipher.update(encryptedName.slice(0, -16)),
decipher.final()
]).toString('utf8');
// Fetch the encrypted file using the presigned URL
const downloadUrl = downloadUrls[file.id];
const encryptedData = await fetch(downloadUrl)
.then(r => r.arrayBuffer())
.then(ab => Buffer.from(ab));
// Decrypt each chunk
// Each encrypted chunk = ciphertext + 16-byte GCM auth tag
const chunkSize = file.chunkSize || (50 * 1024 * 1024); // from API or 50 MB default
const encryptedChunkSize = chunkSize + 16;
const chunkCount = file.chunkCount || Math.ceil(encryptedData.length / encryptedChunkSize);
const decryptedChunks = [];
for (let i = 0; i < chunkCount; i++) {
const start = i * encryptedChunkSize;
const end = Math.min(start + encryptedChunkSize, encryptedData.length);
const chunk = encryptedData.slice(start, end);
const chunkIv = deriveChunkIv(fileIv, i);
const dec = crypto.createDecipheriv('aes-256-gcm', keyBuffer, chunkIv);
dec.setAuthTag(chunk.slice(-16));
decryptedChunks.push(Buffer.concat([
dec.update(chunk.slice(0, -16)),
dec.final()
]));
}
const decryptedFile = Buffer.concat(decryptedChunks);
// Save to disk, process, etc.
console.log(`Decrypted: ${filename} (${decryptedFile.length} bytes)`);
}
Key Concepts
- URL fragment is the key - The
#base64Keyportion of the share URL contains the AES-256 key. It is never sent to the server (per HTTP spec). - Two-step download - First
GET /drop/:idfor encrypted metadata, thenPOST /drop/:id/downloadto record the download and get presigned file URLs. - Chunk IVs - Each chunk derives a unique 12-byte IV: first 8 bytes from the file's base IV, last 4 bytes are the chunk index (big-endian). The filename uses the reserved index
0xFFFFFFFF. - GCM auth tags - Each encrypted chunk includes a 16-byte authentication tag appended after the ciphertext. This provides per-chunk integrity verification.
- No authentication required - Anyone with the drop ID can fetch metadata and download files. Decryption requires the key from the URL fragment.
Encrypted Metadata (Zero-Knowledge Custom Data)
The encryptedMessage field on a drop lets you attach arbitrary metadata - descriptions, tags, structured JSON, or any other data - encrypted with the same AES-256-GCM key. The server stores only the ciphertext and cannot read it.
This is useful for attaching context to shared files while maintaining zero-knowledge guarantees.
Upload with Encrypted Metadata
import crypto from 'crypto';
function deriveChunkIv(baseIv, chunkIndex) {
const iv = Buffer.alloc(12);
baseIv.copy(iv, 0, 0, 8);
iv.writeUInt32BE(chunkIndex, 8);
return iv;
}
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);
// Your custom metadata - can be any structure
const metadata = JSON.stringify({
description: 'Q1 financial report',
tags: ['finance', 'confidential'],
author: 'jane@example.com',
version: 2
});
// Encrypt the metadata using the same IV derivation as filenames
// (reserved chunk index 0xFFFFFFFF)
const metaIv = deriveChunkIv(iv, 0xFFFFFFFF);
const metaCipher = crypto.createCipheriv('aes-256-gcm', key, metaIv);
const encryptedMessage = Buffer.concat([
metaCipher.update(metadata, 'utf8'),
metaCipher.final(),
metaCipher.getAuthTag()
]).toString('base64url');
// Optionally encrypt a human-readable title too
const titleIv = deriveChunkIv(iv, 0xFFFFFFFF);
const titleCipher = crypto.createCipheriv('aes-256-gcm', key, titleIv);
const encryptedTitle = Buffer.concat([
titleCipher.update('Q1 Report', 'utf8'),
titleCipher.final(),
titleCipher.getAuthTag()
]).toString('base64url');
// Create drop with encrypted metadata
const drop = await fetch('https://anon.li/api/v1/drop', {
method: 'POST',
headers: {
'Authorization': 'Bearer ak_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
iv: iv.toString('base64url'),
encryptedMessage,
encryptedTitle,
fileCount: 1,
expiry: 7
})
}).then(r => r.json());
Decrypt Metadata on Download
// After fetching the drop (see Download & Decrypt Workflow above)
const baseIv = Buffer.from(drop.iv, 'base64url');
const metaIv = deriveChunkIv(baseIv, 0xFFFFFFFF);
const encMsg = Buffer.from(drop.encryptedMessage, 'base64url');
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, metaIv);
decipher.setAuthTag(encMsg.slice(-16));
const metadataJson = Buffer.concat([
decipher.update(encMsg.slice(0, -16)),
decipher.final()
]).toString('utf8');
const metadata = JSON.parse(metadataJson);
// { description: 'Q1 financial report', tags: ['finance', 'confidential'], ... }
Why This Is Zero-Knowledge
- The
encryptedMessagefield is encrypted client-side with the same key used for files - The encryption key exists only in the URL fragment, which is never sent to the server
- The server stores and returns the ciphertext unchanged - it cannot read your metadata
- You can store any structure: JSON, plain text, binary data (base64-encoded)
Error Responses
| Status | Description |
|---|---|
400 | Bad Request - Missing required fields or invalid parameters |
401 | Unauthorized - Invalid or missing API key |
403 | Forbidden - Feature not available on your plan |
404 | Not Found - Drop doesn't exist |
410 | Gone - Drop expired or download limit reached |
429 | Too Many Requests - Rate limit exceeded |
500 | Internal Server Error |
Rate Limits
| Operation | Limit |
|---|---|
| Drop creation | 60 requests/hour |
| Drop list | 60 requests/minute |
| Drop operations | 100 requests/hour |
| Upload abort / cleanup | 300 requests/hour |
Monthly Drop API quotas for API-key requests are 500 on Free, 25,000 on Plus, and 100,000 on Pro.
Plan Limits
| Feature | Free | Plus | Pro |
|---|---|---|---|
| Bandwidth | 5 GB | 50 GB | 250 GB |
| Max file size | 5 GB | 50 GB | 250 GB |
| Max expiry | 3 days | 7 days | 30 days |
Security Notes
- Client-side encryption - Files are encrypted on your device before upload
- Zero-knowledge - We never see your unencrypted data or encryption keys
- URL fragments are private - The
#keypart of share URLs is never sent to servers - AES-256-GCM - Authenticated encryption with 256-bit keys
- Files auto-expire - All files are automatically deleted after their expiry date