2fansAPI docs

Set 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.

  1. 1Set your endpoint

    Use a key your app owner holds. The first call returns the signing secret once, so store it. Leave events out to receive every event, including ones added later.

    curl
    curl -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"
        }'
    Node
    const 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());
    Python
    import 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())
  2. 2Verify the signature

    The DropFans-Signature header is t=<unix seconds>,v1=<hex HMAC-SHA256>, keyed with your whsec_… 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.

    Node
    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);
    }
    Python
    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", ""))
  3. 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.

  4. 4Test it

    POST /webhooks/test sends a ping event. GET /webhooks shows your last 20 deliveries with status codes and errors.

    curl
    curl -X POST "https://www.dropfans.io/api/external/v1/webhooks/test" \
      -H "Authorization: Bearer $APP_OWNER_KEY" \
      -H "Content-Type: application/json" \
      -d '{}'
    Node
    const 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());
    Python
    import 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