# Webhooks

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

```bash
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:

```javascript
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:

```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. Verify 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:

```javascript
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:

```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. Answer 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. Test it

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

curl:

```bash
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:

```javascript
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:

```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

- [Sell on Telegram and in chats](https://2fans.dev/docs/build/telegram.md): Create one drop, give each fan a personal pay-per-view link and match every sale back to the right chat.
- [Earnings, balances and payouts](https://2fans.dev/docs/build/payouts.md): Show earnings and balances in your product, set up crypto, bank or Paxum payouts, and request them through the API without sending creators to 2fans.

2fans was called Dropfans until October 2026, so the API host is still www.dropfans.io and keys still start with dpfn_.

Last updated 2026-11-02. All pages: https://2fans.dev/llms.txt.