> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ofauth.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Per-environment destinations for lifecycle and Realtime OnlyFans events, and the Realtime WebSocket

V3 delivers events to **destinations**. A destination is one HTTPS URL in one environment with its own signing secret. Sandbox, Test, and Live never share a destination.

## Destinations

Configure destinations in the dashboard under your External Integration.

| Setting | Notes |
| - | - |
| Environment | `sandbox`, `test`, or `live`. One or more destinations per environment. |
| URL | A plain HTTPS URL. OFAuth verifies the destination before it receives events. |
| Signing secret | A `whsec_` secret generated per destination. It is shown once. Rotate it from the dashboard. |
| Send Realtime events | Realtime OnlyFans events for the accounts shared with you. |
| Send lifecycle events | All six lifecycle events (`lifecycleEventsEnabled`). Lifecycle deliveries are not billed. |

## Envelope

Every event shares one envelope. `data` depends on `type`.

```json theme={null}
{
  "eventId": "...",
  "type": "linked_account.expired",
  "schemaVersion": "1",
  "order": 42,
  "occurredAt": "2026-10-12T14:30:15.143Z",
  "environment": "live",
  "integrationId": "int_example123",
  "externalOauthClientId": "external_oauth_client_example123",
  "authorizationId": "external_live_example123",
  "linkedAccountId": "conn_example123",
  "test": false,
  "data": {
    "authorization_id": "external_live_example123",
    "linked_account_id": "conn_example123",
    "reason": "provider_session_expired"
  }
}
```

| Field | Description |
| - | - |
| `eventId` | Unique per event. Use it for idempotency. |
| `type` | Event type. See the tables below. |
| `schemaVersion` | `"1"`. |
| `order` | Ordering value. For the same linked account, a higher `order` is newer. |
| `occurredAt` | When the event happened. |
| `environment` | `sandbox`, `test`, or `live`. |
| `integrationId` | Your External Integration. |
| `externalOauthClientId` | The environment client the authorization belongs to. |
| `authorizationId` | The authorization. Map it to your user. |
| `linkedAccountId` | The linked account the event is about. `null` for `authorization.*` events. |
| `test` | `true` for test deliveries sent from the dashboard. |
| `data` | Event-specific payload. |

## Lifecycle events

One action sends one event. Changes to the whole authorization are `authorization.*`. Changes to one account are `linked_account.*`, sent to every authorization that includes that account.

| Type | When | `data.reason` | Typical handling |
| - | - | - | - |
| `authorization.granted` | An owner approved your app and a new authorization was created. | `authorized` | Store the account ids under `authorization_id`. |
| `authorization.updated` | The owner consented again and added or removed accounts or changed scopes, or an account was shared automatically ([auto-add](/v3/authorize#accounts-linked-later)). | `consent_updated`, `auto_added` | Add and remove accounts in your records. Re-read scopes from `GET /v3/linked-accounts` when `scopes_changed` is `true`. |
| `authorization.revoked` | The whole authorization ended. | `owner_revoked`, `oauth_revocation`, `refresh_token_replay`, `integration_deleted`, `integration_suspended` | Delete the stored refresh tokens. The user must authorize again. |
| `linked_account.expired` | One account needs a reconnect. Your access remains. | `provider_session_expired` | Pause operations for the account. |
| `linked_account.reconnected` | One account works again after the owner reconnected it. | `reconnected` | Resume operations. |
| `linked_account.disconnected` | One account no longer works for this authorization. | `owner_removed`, `owner_disconnected`, `owner_deleted`, `developer_deleted` | Stop operations for the account and remove it from your records. |

`authorization.revoked` reasons:

| Reason | Cause |
| - | - |
| `owner_revoked` | The owner revoked your app from their dashboard. |
| `oauth_revocation` | You revoked the last active session with `POST /v3/link/revoke`. |
| `refresh_token_replay` | A used refresh token was presented again. |
| `integration_deleted` | Your integration was deleted. |
| `integration_suspended` | Your integration was suspended. |

`linked_account.disconnected` reasons:

| Reason | Cause |
| - | - |
| `owner_removed` | The owner removed this account from your authorization. |
| `owner_disconnected` | The owner disconnected the account from OFAuth. |
| `owner_deleted` | The owner deleted the account. |
| `developer_deleted` | You called `DELETE /v3/linked-accounts/{id}`. |

### Payloads

Payloads carry ids only. Fetch names, status, and scopes from [`GET /v3/linked-accounts`](/v3/linked-accounts#list-linked-accounts).

| Type | `data` fields |
| - | - |
| `authorization.granted` | `authorization_id`, `reason`, `linked_account_ids` |
| `authorization.updated` | `authorization_id`, `reason`, `scopes_changed`, `added_linked_account_ids`, `removed_linked_account_ids` |
| `authorization.revoked` | `authorization_id`, `reason`, `linked_account_ids` |
| `linked_account.*` | `authorization_id`, `linked_account_id`, `reason` |

```json theme={null}
{
  "authorization_id": "external_live_example123",
  "reason": "auto_added",
  "scopes_changed": false,
  "added_linked_account_ids": ["conn_example123"],
  "removed_linked_account_ids": []
}
```

A payload lists at most 100 account ids. Above that, the id lists are replaced by `linked_account_ids_truncated: true` and `linked_account_count`, and, while the authorization still works, `linked_accounts_url: "/v3/linked-accounts"`. Fetch the list with that authorization's access token. `authorization.revoked` never carries `linked_accounts_url`, because a revoked authorization cannot read its list.

```json theme={null}
{
  "authorization_id": "external_live_example123",
  "reason": "authorized",
  "linked_account_ids_truncated": true,
  "linked_account_count": 240,
  "linked_accounts_url": "/v3/linked-accounts"
}
```

## Realtime events

Realtime events are translated OnlyFans activity for one linked account. Enable them per destination. They require the account to be `connected`.

| Type | When |
| - | - |
| `realtime.chat_message.received` | A chat message event is received. |
| `realtime.chat_message.liked` | A chat message is liked. |
| `realtime.chat_message.unliked` | A chat message like is removed. |
| `realtime.chat_typing.updated` | Typing activity changes in a chat. |
| `realtime.inbox_counts.updated` | Unread counts or priority inbox totals change. |
| `realtime.stories.updated` | Story activity updates are received. |
| `realtime.post.published` | A post publication event is received. |
| `realtime.post_fundraising.updated` | A post fundraising update is received. |
| `realtime.system_message.received` | A new system message is received. |
| `realtime.system_messages.updated` | The system message summary is updated. |
| `realtime.toasts.received` | Toast-style notifications are received. |

Payload schemas for each type are documented under [Realtime OnlyFans Events](/api-reference/realtime/onlyfans-events). The `data` object is the same in V2 and V3; only the envelope differs. Realtime envelopes carry `authorizationId` and `linkedAccountId` like lifecycle envelopes.

## Realtime over WebSocket

You can also receive Realtime events for one linked account over a WebSocket:

```http theme={null}
GET wss://v3-realtime.ofauth.com/v3/realtime?linkedAccountId=<linked account id>
Authorization: Bearer <access_token>
```

| Status | `error` | Cause |
| - | - | - |
| `400` | `invalid_request` | `linkedAccountId` is missing, repeated, or blank. |
| `401` | `authentication_required` | No bearer token. |
| `401` | `authentication_denied` | The token is not valid for this linked account. |
| `426` | `websocket_upgrade_required` | The request is not a WebSocket upgrade. |
| `503` | `realtime_unavailable` | Retry later. |

Each message has `eventId`, `type`, `schemaVersion`, `order`, `receivedAt`, `environment`, `integrationId`, `linkedAccountId`, `test`, and `data`. Lifecycle events are delivered by webhook only.

## Verify deliveries

Deliveries use the [Standard Webhooks](https://www.standardwebhooks.com) scheme: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, with the signature computed over `${webhook-id}.${webhook-timestamp}.${body}` using the destination's `whsec_` secret. Any Standard Webhooks library works.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyDelivery(req, rawBody, secret) {
  const id = req.headers["webhook-id"]
  const timestamp = req.headers["webhook-timestamp"]
  const signatures = String(req.headers["webhook-signature"] || "").split(" ")

  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!id || !timestamp || age > 300) return false

  const expected = createHmac("sha256", Buffer.from(secret.replace(/^whsec_/, ""), "base64"))
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64")

  return signatures.some((entry) => {
    const [, value] = entry.split(",")
    return value && value.length === expected.length &&
      timingSafeEqual(Buffer.from(value), Buffer.from(expected))
  })
}
```

Verify before parsing. Respond `2xx` within 10 seconds; do work asynchronously. Failed deliveries are retried with backoff.

## Idempotency and ordering

* Deduplicate on `eventId`.
* Apply events per `linkedAccountId` in `order`. Deliveries can arrive out of order after retries.
* Treat lifecycle events as signals and read the current state from `GET /v3/linked-accounts` when you need names, status, or scopes.

## Test deliveries

Send a test event from the destination page in the dashboard. Test deliveries carry `"test": true` and synthetic ids. Do not write them to production state.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.