Webhooks
View as MarkdownSet one signed endpoint for your app and react to sales, refunds, subscriptions, payouts and ID-check results.
Webhooks are per approved app. Events for every creator who holds a key for your app are sent to one https endpoint.
1Set your endpoint
Use a key your app owner holds. The first call returns the signing secret once, so store it. Leave
eventsout to receive every event, including ones added later.curlcurl -X PUT "https://www.dropfans.io/api/external/v1/webhooks" \ -H "Authorization: Bearer $APP_OWNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://crm.example.com/hooks/2fans" }'Nodeconst res = await fetch('https://www.dropfans.io/api/external/v1/webhooks', { method: 'PUT', headers: { Authorization: 'Bearer ' + process.env.APP_OWNER_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ "url": "https://crm.example.com/hooks/2fans" }), }); console.log(await res.json());Pythonimport os, requests res = requests.put( "https://www.dropfans.io/api/external/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['APP_OWNER_KEY']}"}, json={ "url": "https://crm.example.com/hooks/2fans" }, ) print(res.json())2Verify the signature
The
DropFans-Signatureheader ist=<unix seconds>,v1=<hex HMAC-SHA256>, keyed with yourwhsec_…secret over<t>.<raw request body>. Compute it on the raw bytes before parsing JSON, compare in constant time, and reject timestamps more than 5 minutes old.Nodeimport crypto from 'node:crypto'; export function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ''); return a.length === b.length && crypto.timingSafeEqual(a, b); }Pythonimport hashlib, hmac, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", 0)) if not t or abs(time.time() - t) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))3Answer fast, then do the work
Answer any 2xx within 5 seconds and queue the work. Anything else is retried at about 1 min, 5 min, 15 min, 1 h, 3 h, then every 6 h, up to 8 attempts. Redirects are not followed.
Events can arrive more than once and out of order, so dedupe on
DropFans-Event-Id.4Test it
POST /webhooks/testsends apingevent.GET /webhooksshows your last 20 deliveries with status codes and errors.curlcurl -X POST "https://www.dropfans.io/api/external/v1/webhooks/test" \ -H "Authorization: Bearer $APP_OWNER_KEY" \ -H "Content-Type: application/json" \ -d '{}'Nodeconst res = await fetch('https://www.dropfans.io/api/external/v1/webhooks/test', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.APP_OWNER_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({}), }); console.log(await res.json());Pythonimport os, requests res = requests.post( "https://www.dropfans.io/api/external/v1/webhooks/test", headers={"Authorization": f"Bearer {os.environ['APP_OWNER_KEY']}"}, json={}, ) print(res.json())
Events
| Event | Fires when |
|---|---|
drop.paid |
A buyer completes checkout on a drop |
drop.refunded |
A paid drop is refunded or charged back |
tip.paid / tip.refunded |
A tip settles, or is refunded or charged back |
subscription.paid |
A first charge, a renewal, or a free trial starting |
subscription.canceled |
Renewal is turned off; access continues until accessUntil |
subscription.ended |
Access ends |
account.kyc_updated |
An account you created moves through the ID check |
payout.requested, payout.processing, payout.paid, payout.failed |
A payout moves through its stages |
ping |
You call POST /webhooks/test |
Next
Last updated 2026-11-02