Webhooks & polling
Signed webhooks tell your app about sales, refunds and ID-check results as they happen; polling endpoints cover the same ground as a fallback.
Webhooks are per app. Set one https endpoint for your app with PUT /api/external/webhooks using a key your app owner holds (their own, or one their agency minted for its personas). Events about every creator who holds a key for your app (and every account you created) are then POSTed to it.
Events#
| Event | Fires when | data |
|---|---|---|
drop.paid | a buyer completes checkout on a drop | an Order, including your per-fan externalRef |
drop.refunded | a paid drop order is refunded or charged back | the Order plus refundedAt and reason (refund or chargeback) |
tip.paid | a fan's tip settles | a Tip, including your per-fan externalRef and the fan's message |
tip.refunded | a paid tip is refunded or charged back | the Tip plus refundedAt and reason (refund or chargeback) |
account.kyc_updated | an account you created moves through the ID check | accountId, username, kycStatus (in_review, approved, declined), sellerStatus |
ping | you call POST /webhooks/test | message |
Envelope#
Every delivery is a JSON POST:
{
"id": "evt_2b7c91d0e4a5f6b7c8d9e0f1",
"type": "drop.paid",
"created": "2026-10-17T16:20:01.000Z",
"data": {
"orderId": "clxor0001order",
"productId": "clxdr0p000001prod",
"accountId": "clx2f8a1b0001qw3k",
"externalRef": "tg_48213377",
"amountCents": 2750,
"currency": "USD",
"paidAt": "2026-10-17T16:20:00.000Z",
"paidWith": "card",
"status": "paid",
"buyer": { "id": "clxbu0001buyer", "email": "[email protected]", "telegramUserId": "48213377", "telegramUsername": "fan_one" }
}
}Headers:
| Header | Value |
|---|---|
DropFans-Event-Id | the event id (same as id) — dedupe on it |
DropFans-Event | the event type |
DropFans-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Verify the signature#
The signature is an HMAC-SHA256, keyed with your whsec_… secret, over <t>.<raw request body>. Compute it on the raw bytes, before any JSON parsing. Compare in constant time, and reject timestamps more than 5 minutes old.
import 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);
}import 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", ""))Delivery and retries#
- Answer any 2xx within 5 seconds to acknowledge. Do the work afterwards (queue it). Redirects are not followed.
- Anything else is retried with backoff (about 1 min, 5 min, 15 min, 1 h, 3 h, then every 6 h), up to 8 attempts in total. After that the event is marked failed.
- Events can arrive more than once and out of order. Dedupe on
DropFans-Event-Id(or onorderIdfor sales). - GET /api/external/webhooks shows your last 20 deliveries with status codes and errors.
Polling (fallback)#
Webhooks can be missed if your endpoint is down for longer than the retry window. Keep a slow sweep as a safety net and budget it against your rate tier: 5,000 requests a day (personal) and 50,000 per key (app).
1. Sales — orders and check-status#
GET /api/external/orders with since = your last sweep returns every paid order with its externalRef. POST /drops/check-status (up to 200 product ids per call) does the same per product. With webhooks on, one sweep every 15–60 minutes is plenty.
2. Video processing — video-status#
While a video is processing after upload, poll POST /vault/video-status (up to 50 ids) every 10–15 seconds until isReady or isFailed.
3. Moderation — posts and vault#
- Posts: GET /posts?status=PENDING or GET /posts/{id} every 30–60 seconds while something is pending.
- Vault items: GET /vault?includePending=true every few minutes after a batch upload, until nothing is PENDING.
Next: Broadcast one drop to many fans or Creator sign-up from your platform.
Questions? [email protected]
