Build on AnonDrop
Plain HTTP, no SDK anywhere. Storage speaks S3 so your existing tools work unchanged, and the same buckets are reachable over a small JSON REST API. Accounts and billing are a handful of endpoints. Object events arrive as signed webhooks you can verify offline.
Getting started
Your user key
AnonDrop has no sign-up form. Every browser holds a user key, and that key is the account. It lives in localStorage under userkey, and a GET /register issues a fresh one. This is exactly what the upload page does:
if (!localStorage.getItem('userkey')) {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/register');
xhr.onload = () => {
const m = xhr.responseText.match(/localStorage\.setItem\('userkey', '([0-9]+)'\)/);
if (m) localStorage.setItem('userkey', m[1]);
};
xhr.send();
}
Outside the browser, keep the key in an environment variable and pass it as ?key=. It is a bearer credential: anyone holding it holds the account, so treat it the way you would treat a password.
export ANONDROP_KEY=1234567890123456789
curl -s "https://anondrop.net/api/account?key=$ANONDROP_KEY"
Access keys for storage
The S3 API uses a different credential: an access key pair, minted from the user key and signed with AWS Signature Version 4. Key ids begin with AKAD, the secret is shown once, and each key can be scoped to read only or to a named list of buckets. Mint them on the account page or through POST /api/account/keys below. Storage is part of Pro and Business.
Conventions
- Both anondrop.net and anondrop.io serve the same account, the same buckets and the same endpoints.
- The same buckets are also reachable over a JSON REST API at
/api/v1, using the same access keys with no signing at all: send them asAuthorization: Bearer KEYID:SECRET, or as the header pairX-AnonDrop-KeyandX-AnonDrop-Secret. - Request bodies are JSON with
Content-Type: application/json. Responses are JSON. - Byte counts are bytes, timestamps are Unix seconds, and money is USD while the amount you send is SOL.
- Errors come back as
{"error": {"code": "...", "message": "..."}}with a matching HTTP status.401means the user key was missing or not recognised,402means the plan does not include that feature.
GET /api/v1/ping is the quickest way to confirm an access key works:
curl -s "https://anondrop.net/api/v1/ping" \
-H "Authorization: Bearer AKADIVMECTKQJRCUCQ2DJ5KU4VCJIQXGK6DBNVYGYZI:wJalrXUtnFEMI-K7MDENG-bPxRfiCYEXAMPLEKEY"
{
"ok": true,
"plan": "pro",
"plan_name": "Pro",
"key": "AKADIVMECTKQJRCUCQ2DJ5KU4VCJIQXGK6DBNVYGYZI",
"time": 1771286400
}
Account API
/api/plans and /api/rate are public. Everything else takes your user key as ?key=.
The full catalogue, the comparison table used on the pricing page, and the SOL rate the checkout will quote. No parameters.
Request
curl -s "https://anondrop.net/api/plans"
Response
{
"plans": [
{
"id": "pro",
"name": "Pro",
"tagline": "S3-compatible storage for builders, at a fraction of the price.",
"price": {"monthly": 4.99, "yearly": 49.0, "lifetime": 149.0},
"storage": 2199023255552,
"storage_label": "2 TB",
"egress": 5497558138880,
"egress_label": "5 TB",
"max_object_label": "5 TB",
"speed_label": "Uncapped",
"api_keys": "10",
"buckets": 25,
"seats": 1,
"rate_limit": 1200,
"features": {
"s3": true, "rest_api": true, "private_objects": true,
"signed_urls": true, "passwords": true, "expiry": true,
"download_limits": true, "webhooks": true, "analytics": true,
"priority": true, "bulk": true, "support": "email"
},
"highlights": ["2 TB storage and 5 TB transfer every month"]
}
],
"comparison": [
{"name": "AnonDrop Pro", "storage_gb": 0.0025, "egress_gb": 0.0,
"note": "2 TB storage and 5 TB transfer for 4.99 per month", "ours": true}
],
"cycles": [
{"id": "monthly", "label": "Monthly", "days": 31},
{"id": "yearly", "label": "Yearly", "days": 366},
{"id": "lifetime", "label": "Lifetime", "days": 0}
],
"rate": {"sol_usd": 168.42, "updated": 1770000000}
}
The sample shows one plan and one comparison row. The live response lists Free, Pro and Business in that order, with the full highlight arrays.
The SOL price in USD, and when it was last refreshed. This is the rate a checkout created right now would use.
Request
curl -s "https://anondrop.net/api/rate"
Response
{"sol_usd": 168.42, "updated": 1770000000}
Everything about the account in one call: plan, allowances, live usage for the current month, access keys, buckets, webhooks, payment history and your SOL balance.
Parameters
key— your user key. Required.
Request
curl -s "https://anondrop.net/api/account?key=$ANONDROP_KEY"
Response
{
"plan": "pro",
"plan_name": "Pro",
"expires": 1801526400,
"cycle": "yearly",
"auto_renew": false,
"limits": {
"storage": 2199023255552,
"egress": 5497558138880,
"max_object": 5497558138880,
"api_keys": 10,
"buckets": 25,
"seats": 1,
"rate_limit": 1200
},
"features": {
"s3": true, "rest_api": true, "private_objects": true,
"signed_urls": true, "passwords": true, "expiry": true,
"download_limits": true, "webhooks": true, "analytics": true,
"priority": true, "bulk": true, "support": "email"
},
"usage": {
"stored": 41234567890,
"objects": 1842,
"egress": 9123456789,
"ingress": 41234567890,
"requests": 20431,
"month": "2026-08"
},
"keys": [
{
"id": "AKADIVMECTKQJRCUCQ2DJ5KU4VCJIQXGK6DBNVYGYZI",
"name": "ci-deploy",
"perms": ["read", "write"],
"buckets": ["releases"],
"created": 1769990000,
"last_used": 1771286400
}
],
"buckets": [
{"name": "releases", "created": 1769990000, "objects": 1842, "bytes": 41234567890}
],
"webhooks": [
{
"id": "7c1f9a2b4e5d",
"url": "https://example.com/hooks/anondrop",
"events": ["object.created", "object.deleted"],
"active": true
}
],
"payments": [
{
"plan": "pro", "cycle": "yearly", "usd": 49.0, "sol": 0.29094,
"rate": 168.42, "at": 1769990000,
"signature": "2Xy4TVgwhH8cKT9KZnGXqbdtPLuw79bsPrMtFdJaxpLJaMqzngfpgTX9d47ej6zxwZvCBFw6UxPbwcLzCaAjPsZ5"
}
],
"balance_sol": 0.412
}
Revoked keys are omitted, payments are newest first, and access key secrets are never returned here. last_used is rounded to the day on purpose: we count usage, we do not keep request logs.
Mints an S3 access key. The secret is returned once and never again, so store it as you receive it. Pro or Business only; on Free the call is refused with 402 upgrade_required.
Body
name— a label for your own use, trimmed to 64 characters.perms— any ofreadandwrite. Defaults to both; anything else is dropped.buckets— restrict the key to these bucket names, up to 50 of them. An empty list means every bucket on the account.
Request
curl -s -X POST "https://anondrop.net/api/account/keys?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "ci-deploy", "perms": ["read", "write"], "buckets": ["releases"]}'
Response
{
"id": "AKADIVMECTKQJRCUCQ2DJ5KU4VCJIQXGK6DBNVYGYZI",
"secret": "wJalrXUtnFEMI-K7MDENG-bPxRfiCYEXAMPLEKEY",
"name": "ci-deploy",
"perms": ["read", "write"],
"buckets": ["releases"],
"created": 1771286400
}
Reaching the key limit for your plan answers 409 key_limit.
Revokes an access key. It stops working immediately, everywhere, including presigned URLs it signed that have not expired yet.
Request
curl -s -X POST "https://anondrop.net/api/account/keys/delete?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "AKADIVMECTKQJRCUCQ2DJ5KU4VCJIQXGK6DBNVYGYZI"}'
Response
{"ok": true}
Registers an endpoint for object events. Pro or Business only.
Body
url— the URL we POST to. It must behttps, at most 400 characters, and must resolve to a public address.events— any ofobject.createdandobject.deleted. Anything else is dropped, and an empty list is read as["object.created"].
Request
curl -s -X POST "https://anondrop.net/api/account/webhooks?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/anondrop",
"events": ["object.created", "object.deleted"]}'
Response
{
"id": "7c1f9a2b4e5d",
"url": "https://example.com/hooks/anondrop",
"events": ["object.created", "object.deleted"]
}
There is no per-endpoint secret to store: deliveries are signed with our published key, so verification needs nothing but the public key below. An account may hold 20 endpoints; past that the call answers 409 limit.
Removes a webhook. Deliveries stop at once.
Request
curl -s -X POST "https://anondrop.net/api/account/webhooks/delete?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "7c1f9a2b4e5d"}'
Response
{"ok": true}
Turns automatic renewal on or off for the current plan.
Request
curl -s -X POST "https://anondrop.net/api/account/autorenew?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
Response
{"ok": true, "auto_renew": true}
Billing API
Plans are priced in USD and paid in SOL. A checkout converts the price at the live rate and locks that SOL amount for the life of the intent — the rate moving afterwards changes nothing about what you owe. There are three ways to settle an intent: a browser wallet, a Solana Pay QR or address, or your AnonDrop balance.
Opens a payment intent. Everything needed for all three payment routes comes back in one response.
Body
plan—proorbusiness.cycle—monthly,yearly, orlifetimefor Pro.
Request
curl -s -X POST "https://anondrop.net/api/billing/checkout?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"plan": "pro", "cycle": "yearly"}'
Response
{
"intent": "9f4c2a7e1b6d5c8a4b3e2f10",
"plan": "pro",
"plan_name": "Pro",
"cycle": "yearly",
"usd": 49.0,
"sol": 0.29094,
"rate": 168.42,
"address": "F7xTEsBoZNddHQRiCkg6BYViMQySvzHm6wAY4EQYbKhE",
"reference": "GBf9UZoP5Brk2jYWeHjwKwz49Ea6uAqG5HJJtJzajuzG",
"pay_url": "solana:F7xTEsBoZNddHQRiCkg6BYViMQySvzHm6wAY4EQYbKhE?amount=0.29094&reference=GBf9UZoP5Brk2jYWeHjwKwz49Ea6uAqG5HJJtJzajuzG&label=AnonDrop&message=Pro%20Yearly%20plan",
"qr_svg": "<svg xmlns=... ></svg>",
"status": "pending",
"expires": 1770001800,
"balance_sol": 0.412
}
Fields
usdandsol— show both.rateis the SOL price the conversion used.addressandreference— where to send, and the marker that ties a transfer to this intent.pay_urlandqr_svg— a Solana Pay link and a ready-made SVG QR you can drop straight into the page.expires— an intent, and the rate it locked, lives 30 minutes. Open a new one after that.balance_sol— the AnonDrop balance available right now, so you know whether route (c) is possible.
An unavailable rate answers 503 rate_unavailable, and a plan and cycle that is not sold, such as a Business lifetime, answers 400 invalid_plan.
Route (a), browser wallet. Builds an unsigned Solana transaction for the connected wallet to sign. No key parameter: the intent identifies the account.
Body
intent— the intent id from checkout.payer— the base58 public key of the connected wallet.
Request
curl -s -X POST "https://anondrop.net/api/billing/tx" \
-H "Content-Type: application/json" \
-d '{"intent": "9f4c2a7e1b6d5c8a4b3e2f10",
"payer": "Atgt3SLoEEXoVKk1fGLrXpzR1ybA4QcTtS991TuKAsiu"}'
Response
{
"transaction": "AQABA0Zk...base64 unsigned transaction...AAAA",
"sol": 0.29094,
"address": "F7xTEsBoZNddHQRiCkg6BYViMQySvzHm6wAY4EQYbKhE"
}
Hands us the signature the wallet returned. We verify it on chain and activate the plan.
Request
curl -s -X POST "https://anondrop.net/api/billing/confirm" \
-H "Content-Type: application/json" \
-d '{"intent": "9f4c2a7e1b6d5c8a4b3e2f10",
"signature": "2Xy4TVgwhH8cKT9KZnGXqbdtPLuw79bsPrMtFdJaxpLJaMqzngfpgTX9d47ej6zxwZvCBFw6UxPbwcLzCaAjPsZ5"}'
Response
{"ok": true, "plan": "pro", "expires": 1801526400}
A transaction that has not settled yet answers 202 not_confirmed; it applies on its own once it lands, so treat that as "keep waiting" rather than an error. A signature that already paid for something answers 409 already_used.
Route (b), QR or address. Poll this while the customer scans the QR or copies the address and amount. status is pending, paid or expired, and plan and expires appear once it is paid. An intent we have never heard of answers 404 unknown_intent.
Request
curl -s "https://anondrop.net/api/billing/status/9f4c2a7e1b6d5c8a4b3e2f10"
Response
{"status": "paid", "plan": "pro", "expires": 1801526400}
Route (c), pay from your AnonDrop wallet. Only offered when balance_sol from checkout covers the sol amount. Settles instantly, with no chain wait. Too small a balance answers 402 insufficient_balance.
Request
curl -s -X POST "https://anondrop.net/api/billing/balance?key=$ANONDROP_KEY" \
-H "Content-Type: application/json" \
-d '{"intent": "9f4c2a7e1b6d5c8a4b3e2f10"}'
Response
{"ok": true, "plan": "pro", "expires": 1801526400}
End to end in the browser
Open the intent, let the wallet sign, confirm. Phantom and Solflare expose the same two methods, so one path covers both:
import { Transaction } from '@solana/web3.js';
const key = localStorage.getItem('userkey');
const intent = await (await fetch('/api/billing/checkout?key=' + key, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro', cycle: 'yearly' })
})).json();
const provider = window.solana || window.solflare;
const account = await provider.connect();
const built = await (await fetch('/api/billing/tx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
intent: intent.intent,
payer: account.publicKey.toString()
})
})).json();
const raw = Uint8Array.from(atob(built.transaction), c => c.charCodeAt(0));
const signed = await provider.signAndSendTransaction(Transaction.from(raw));
const done = await (await fetch('/api/billing/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent: intent.intent, signature: signed.signature })
})).json();
console.log(done.plan, done.expires);
If the customer would rather scan, render qr_svg, show the exact sol amount next to the usd price it was converted from, and poll until the intent reports paid:
const poll = setInterval(async () => {
const s = await (await fetch('/api/billing/status/' + intent.intent)).json();
if (s.status === 'paid') {
clearInterval(poll);
location.reload();
} else if (s.status === 'expired') {
clearInterval(poll);
}
}, 4000);
Always display both numbers. The customer agreed to a USD price and sends a SOL amount, and showing the pair with the rate that produced it is what makes the payment feel exact rather than approximate.
Webhooks
Register an endpoint and we POST an event to it whenever an object changes. Delivery is best effort and completely off the request path, so a slow or unreachable endpoint of yours never slows down or fails a storage request. Webhooks are part of Pro and Business.
Events
| Event | Raised by |
|---|---|
| object.created | PutObject and CompleteMultipartUpload on the S3 API, and an object PUT on the REST API. |
| object.deleted | DeleteObject on the S3 API, and an object DELETE on the REST API. |
Multi-key DeleteObjects and CopyObject do not raise events. A registration that names no events is read as object.created.
Delivery
We POST the event as JSON. The body is the canonical form of the payload — keys sorted, no whitespace between separators — and those exact bytes are what the signature covers, so you verify the raw body without re-serialising anything first.
POST /hooks/anondrop HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: AnonDrop-Webhooks/1
X-AnonDrop-Event: object.created
X-AnonDrop-Delivery: 6f1c2d84-9a3b-4e57-8c10-2b7fd9e4a615
X-AnonDrop-Timestamp: 1771286400
X-AnonDrop-Hook: 7c1f9a2b4e5d
X-AnonDrop-Algorithm: ed25519
X-AnonDrop-Key: 8DHfUSYeYWSHG1iHiq312SPgoV8vQh0nnDZCeDMSwAw=
X-AnonDrop-Signature: vs6Hs4KYpqe4P1tIYyvVqc0kAckcedAaxNOXHY+xyUad8iO/oeCkf7e/EZWGnndz7DRbk0/QcuJ07sYZb9W2Cg==
{"at":1771286400,"bucket":"releases","etag":"9f86d081884c7d659a2feaa0c55ad015","event":"object.created","key":"builds/release.tar.gz","size":184320,"type":"anondrop.webhook.v1"}
Payload
event— the event name.bucketandkey— where it happened.size— the object size in bytes.etag— the object ETag, without quotes.at— when it happened, Unix seconds.type— alwaysanondrop.webhook.v1. It marks the document as a webhook event and nothing else, so a handler that also processes checkout receipts can tell the two apart before it looks at anything else.
Headers
X-AnonDrop-Signature— base64 Ed25519 signature over the raw request body.X-AnonDrop-KeyandX-AnonDrop-Algorithm— the public key that signed it, anded25519. Verify against your own pinned copy of the key rather than the one in the header.X-AnonDrop-Delivery— a unique id for this attempt's event, useful as an idempotency key.X-AnonDrop-EventandX-AnonDrop-Timestamp— the event name and its timestamp, so you can route or age-check before parsing.X-AnonDrop-Hook— which of your registered endpoints this delivery belongs to.
Signatures
Every delivery is signed with Ed25519, over the raw request body exactly as it arrives. Webhooks have their own signing key, separate from the checkout callback key documented on Info / API: the webhook key verifies webhook deliveries and nothing else, and the checkout key verifies receipts and nothing else, so a signed event can never be replayed as a signed payment. The webhook key is published at /webhookpubkey as JSON with public_key_base64, public_key_hex and algorithm:
Verify the bytes you received, not a re-encoding of them. The delivery above is a real signature over its own body: paste it into either sample and it passes.
Verify in Python
import base64, json
from nacl.signing import VerifyKey
PUBLIC_KEY_B64 = "8DHfUSYeYWSHG1iHiq312SPgoV8vQh0nnDZCeDMSwAw="
VERIFIER = VerifyKey(base64.b64decode(PUBLIC_KEY_B64))
def handle(raw_body, headers):
VERIFIER.verify(raw_body, base64.b64decode(headers["X-AnonDrop-Signature"]))
event = json.loads(raw_body)
print(event["event"], event["bucket"], event["key"], event["size"])
return event
On Flask or Quart that is request.get_data(); on Django it is request.body. Anything that hands you a parsed dict instead of bytes will not verify, because the whitespace is gone.
Verify in Node.js
const express = require("express");
const nacl = require("tweetnacl");
const PUBLIC_KEY_B64 = "8DHfUSYeYWSHG1iHiq312SPgoV8vQh0nnDZCeDMSwAw=";
const PUBLIC_KEY = Buffer.from(PUBLIC_KEY_B64, "base64");
const app = express();
app.post("/hooks/anondrop", express.raw({ type: "application/json" }), (req, res) => {
const ok = nacl.sign.detached.verify(
req.body,
Buffer.from(req.get("X-AnonDrop-Signature") || "", "base64"),
PUBLIC_KEY
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event, event.bucket, event.key, event.size);
res.sendStatus(200);
});
app.listen(3000);
Verify before you act. The signature is what proves the event came from AnonDrop rather than from anyone who guessed your endpoint, and it is checked offline against a public key, so nothing has to be called back to confirm it.
Retries
- Up to 3 attempts, each capped at 8 seconds, and never more than 20 seconds in total across all of them.
- A pause of 1 second then 3 seconds between attempts.
- Any
2xxis success. A5xx, a timeout or a connection failure is retried; a4xxstops delivery straight away, because your endpoint understood us and said no. - Redirects are not followed, and a redirect counts as a refusal.
- Your endpoint can never slow one of our requests down: delivery runs off the request path entirely and a failure is never visible to the caller who triggered it.
Good practice
- Answer quickly with any 2xx and do the real work after. A slow endpoint burns its budget and sees retries.
- Treat deliveries as at-least-once and make your handler idempotent, keyed on
X-AnonDrop-Delivery. - Reject anything whose
atis far from now, so an old capture cannot be replayed at you. - Pin the public key in your code. Never trust the copy in
X-AnonDrop-Keyto verify the message it arrived with. - Registration requires
httpsand a hostname that resolves to a public address, so a private or loopback destination is refused up front.
Rate limits
Limits are per account, per minute, and reset at the top of the next minute. The storage API answers a request over the ceiling with 503 SlowDown, which every S3 client already retries with backoff.
| Plan | Requests per minute | Access keys | Buckets | Storage API |
|---|---|---|---|---|
| Free | 60 | 0 | 0 | Not included |
| Pro | 1200 | 10 | 25 | Included |
| Business | 6000 | Unlimited | 500 | Included |
Pro is 4.99 a month, 49 a year, or 149 once for lifetime. Business is 19.99 a month or 199 a year. Both include storage, transfer, webhooks, presigned URLs and scoped keys; see the S3 page for the storage and transfer allowances that go with them.
- The ceiling covers the storage surfaces, the S3 gateway and
/api/v1alike. Account and billing endpoints are keyed to your user key and sit outside it. - The classic upload API stays free and unchanged. No plan, no key limits, nothing to buy.
- Webhook deliveries are ours to send and never count against your allowance.
Where next
- S3 API — endpoint, configuration snippets, operations and error codes.
- Info / API — the classic upload API and the checkout callback scheme.
- Account — keys, usage and plans.
- Erasure coding — how EC 14+4 across 18 storage nodes keeps objects durable.