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

# Migrate from V2 to V3

> Prepare your app for the organization cutover, import your migrated accounts' V3 tokens, and map V2 routes to V3 operations

You move your whole organization from V2 to V3 in one guided flow in the OFAuth dashboard. This guide covers the changes your app needs before and after cutover.

<Warning>
  The move is a hard cut. At cutover, V2 stops serving your migrated connections, and V3 has no compatibility layer for V2 API keys, `/v2/*` routes, or the raw access proxy. Prepare your V3 integration before you schedule cutover.
</Warning>

Creators do not reconnect. Every Live connection moves with its id, its OnlyFans session, and its permissions.

## What changes

### What moves

| V2 | On V3 |
| - | - |
| Live client apps (redirect, embed, whitelabel) | Merged into one V3 integration. Redirect URLs become Live OAuth callbacks. Origins are dropped. |
| Live connections | Moved with unchanged ids. Creators stay connected. |
| Connection permissions | Mapped to V3 scopes for each connection. See [Permissions to scopes](#permissions-to-scopes). |
| Webhook endpoints | Moved, with their filters mapped to V3 events. See [Webhook events](#webhook-events). The `whsec_` signing secret is kept by default; rotating it is optional. |
| Realtime subscriptions and defaults | Become your V3 Realtime policy. |
| Vault+ settings and retained media | Moved for Developer destinations. Vault+ routes move to `/v3/vault-plus/*` with the same route table. |
| Dynamic rules | Kept while your V3 subscription stays active, if your V2 plan included them. |

### What is removed

| V2 | V3 alternative |
| - | - |
| API keys (`sk_live_...`, `sk_sandbox_...`) | External OAuth: a client ID and secret per environment, plus a bearer token per authorization. |
| Raw access proxy (`/v2/access/proxy/*`) | Typed V3 Access operations. See [Route map](#route-map). |
| Embedded and whitelabel Link | The hosted Live OAuth flow with your callbacks. |
| `/v2/account` import, settings, and whoami | None. Accounts connect through OAuth, and you manage them in the dashboard. |
| V2 sandbox apps, keys, and connections | Deleted. Set up the V3 Sandbox (mock data) and Test environments fresh. |

### Concept map

| V2 | V3 |
| - | - |
| API key in the `apikey` header | `Authorization: Bearer <access_token>`, from `POST https://api.ofauth.com/v3/link/token` |
| `POST /v2/link/init`, then redirect to the session URL | `GET https://auth.ofauth.com/link/authorize` with PKCE |
| Connection | Linked account. Migrated accounts keep their connection id. |
| `x-connection-id` header | `x-linked-account-id` header, with the same id |
| Client app permissions | Scopes on the authorization request and on each linked account |
| Environment chosen by key prefix | Environment chosen by client ID. Each environment has its own client. |
| `GET /v2/account/connections` | `GET /v3/linked-accounts`. See [Manage linked accounts](#manage-linked-accounts). |
| `connection.*` webhook events | `authorization.*` and `linked_account.*` lifecycle events |
| `/v2/vault-plus/*` | `/v3/vault-plus/*` |

## Permissions to scopes

Each migrated connection keeps its own V2 permissions, mapped to V3 scopes. Nothing is widened, and there is no review wait.

| V2 permission | V3 scopes |
| - | - |
| `all:read` | Every read scope: `analytics:read`, `finances:read`, `lists:read`, `messages:read`, `notifications:read`, `posts:read`, `profile:read`, `promotions:read`, `queue:read`, `stories:read`, `subscribers:read`, `subscriptions:read`, `users:read`, `vault:read` |
| `all:write` | Every write scope: `lists:write`, `messages:write`, `notifications:write`, `posts:write`, `profile:write`, `promotions:write`, `queue:write`, `stories:write`, `subscribers:write`, `subscriptions:write`, `users:write`, `vault:write`. No read scopes, as in V2. |
| `all:*` | Every read and write scope |
| `earnings:read` | `finances:read` |
| `messages:read` | `messages:read`, `queue:read` |
| `messages:write` | `messages:write`, `queue:write` |
| `posts:read` | `posts:read`, `queue:read` |
| `posts:write` | `posts:write`, `queue:write` |
| `users:write` | `users:write`, `users:read` |
| `payouts:write` | Removed. V3 has no equivalent. |
| All other permissions | The scope with the same name |

The extra `queue:*` and `users:read` scopes cover operations those V2 permissions already allowed. V3 lists scheduled posts and queued mass messages under `queue:*`, and blocked and restricted users under `users:read`.

## Route map

V3 Access operations live under `/v3/access`. Authenticate with `Authorization: Bearer <access_token>` instead of `apikey`, and send the same account id in the `x-linked-account-id` header.

These V2 routes call the same OnlyFans operation as the listed V3 operation.

### Profile and notifications

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/self` | `GET /v3/access/profile` | `profile:read` |
| `PATCH /v2/access/self` | `PATCH /v3/access/profile` | `profile:write` |
| `GET /v2/access/self/notifications` | `GET /v3/access/notifications` | `notifications:read` |
| `GET /v2/access/self/release-forms` | `GET /v3/access/release-forms` | `profile:read` |
| `GET /v2/access/self/tagged-friend-users` | `GET /v3/access/posts/tagged-friend-users` | `posts:read` |

### Chats and mass messages

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/chats` | `GET /v3/access/chats` | `messages:read` |
| `GET /v2/access/chats/:userId/messages` | `GET /v3/access/chats/:userId/messages` | `messages:read` |
| `POST /v2/access/chats/:userId/messages` | `POST /v3/access/chats/:userId/messages` | `messages:write` |
| `DELETE /v2/access/chats/:userId/messages/:messageId` | `DELETE /v3/access/chats/:userId/messages/:messageId` | `messages:write` |
| `GET /v2/access/chats/:userId/media` | `GET /v3/access/chats/:userId/media` | `messages:read` |
| `POST /v2/access/mass-messages` | `POST /v3/access/chats/mass-messages` | `messages:write` |
| `GET /v2/access/mass-messages/:massMessageId` | `GET /v3/access/queue/messages/:queueMessageId` | `queue:read` |
| `PUT /v2/access/mass-messages/:massMessageId` | `PUT /v3/access/chats/mass-messages/:massMessageId` | `messages:write` |
| `DELETE /v2/access/mass-messages/:massMessageId` | `DELETE /v3/access/queue/messages/:queueMessageId` | `queue:write` |

### Subscribers and subscriptions

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/subscribers` | `GET /v3/access/subscribers` | `subscribers:read` |
| `GET /v2/access/subscribers/active` | `GET /v3/access/subscribers/active` | `subscribers:read` |
| `GET /v2/access/subscribers/expired` | `GET /v3/access/subscribers/expired` | `subscribers:read` |
| `GET /v2/access/subscribers/activity` | `GET /v3/access/subscribers/activity` | `subscribers:read` |
| `GET /v2/access/subscribers/activity/new` | `GET /v3/access/subscribers/activity/new` | `subscribers:read` |
| `GET /v2/access/subscribers/activity/renewals` | `GET /v3/access/subscribers/activity/renewals` | `subscribers:read` |
| `GET /v2/access/subscribers/activity/promotions/:promotionId` | `GET /v3/access/subscribers/activity/promotions/:promotionId` | `subscribers:read` |
| `GET /v2/access/subscribers/activity/trial-links/:trialLinkId` | `GET /v3/access/subscribers/activity/trial-links/:trialLinkId` | `subscribers:read` |
| `PUT /v2/access/subscribers/:userId/note` | `PUT /v3/access/subscribers/:subscriberId/note` | `subscribers:write` |
| `DELETE /v2/access/subscribers/:userId/note` | `DELETE /v3/access/subscribers/:subscriberId/note` | `subscribers:write` |
| `PUT /v2/access/subscribers/:userId/discount` | `PUT /v3/access/subscribers/:subscriberId/discount` | `subscribers:write` |
| `PUT /v2/access/subscribers/:userId/custom-name` | `PUT /v3/access/subscribers/:subscriberId/display-name` | `subscribers:write` |
| `GET /v2/access/subscriptions/:userId/history` | `GET /v3/access/subscribers/:subscriberId/subscription-history` | `subscribers:read` |
| `GET /v2/access/subscriptions` | `GET /v3/access/subscriptions` | `subscriptions:read` |
| `GET /v2/access/subscriptions/count` | `GET /v3/access/subscriptions/count` | `subscriptions:read` |

### Users and lists

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/users/:userIdOrUsername` | `GET /v3/access/users/:userId` | `users:read` |
| `GET /v2/access/users/list` | `POST /v3/access/users/batch` | `users:read` |
| `GET /v2/access/users/search` | `GET /v3/access/users/search` | `users:read` |
| `GET /v2/access/users/blocked` | `GET /v3/access/users/blocked` | `users:read` |
| `GET /v2/access/users/restrict` | `GET /v3/access/users/restrict` | `users:read` |
| `POST /v2/access/users/:userId/restrict` | `POST /v3/access/users/:userId/restrict` | `users:write` |
| `DELETE /v2/access/users/:userId/restrict` | `DELETE /v3/access/users/:userId/restrict` | `users:write` |
| `GET /v2/access/users/lists` | `GET /v3/access/lists` | `lists:read` |
| `POST /v2/access/users/lists` | `POST /v3/access/lists` | `lists:write` |
| `GET /v2/access/users/lists/:listId` | `GET /v3/access/lists/:listId` | `lists:read` |
| `PATCH /v2/access/users/lists/:listId` | `PATCH /v3/access/lists/:listId` | `lists:write` |
| `DELETE /v2/access/users/lists/:listId` | `DELETE /v3/access/lists/:listId` | `lists:write` |
| `GET /v2/access/users/lists/:listId/users` | `GET /v3/access/lists/:listId/users` | `lists:read` |
| `POST /v2/access/users/lists/:listId/users/:userId` | `POST /v3/access/lists/:listId/users/:userId` | `lists:write` |
| `DELETE /v2/access/users/lists/:listId/users/:userId` | `DELETE /v3/access/lists/:listId/users/:userId` | `lists:write` |
| `POST /v2/access/users/:userId/lists` | `POST /v3/access/users/:userId/lists` | `lists:write` |

### Posts and stories

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/posts` | `GET /v3/access/posts` | `posts:read` |
| `GET /v2/access/posts/:postId` | `GET /v3/access/posts/:postId` | `posts:read` |
| `GET /v2/access/users/:userId/posts` | `GET /v3/access/users/:userId/posts` | `posts:read` |
| `POST /v2/access/posts` | `POST /v3/access/posts` | `posts:write` |
| `PUT /v2/access/posts/:postId` | `PUT /v3/access/posts/:postId` | `posts:write` |
| `DELETE /v2/access/posts/:postId` | `DELETE /v3/access/posts/:postId` | `posts:write` |
| `GET /v2/access/users/:userId/stories` | `GET /v3/access/users/:userId/stories` | `stories:read` |
| `PUT /v2/access/stories/:storyId/watched` | `PUT /v3/access/stories/:storyId/watched` | `stories:write` |

### Vault

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/vault/media` | `GET /v3/access/vault/media` | `vault:read` |
| `GET /v2/access/vault/lists` | `GET /v3/access/vault/lists` | `vault:read` |
| `POST /v2/access/vault/lists` | `POST /v3/access/vault/lists` | `vault:write` |
| `PATCH /v2/access/vault/lists/:listId` | `PATCH /v3/access/vault/lists/:listId` | `vault:write` |
| `DELETE /v2/access/vault/lists/:listId` | `DELETE /v3/access/vault/lists/:listId` | `vault:write` |
| `POST /v2/access/vault/lists/:listId/media` | `POST /v3/access/vault/lists/:listId/media` | `vault:write` |

Uploads use a different flow in V3: `POST /v3/access/uploads/start`, `PUT /v3/access/uploads/:mediaUploadId/parts/:partNumber`, then `PUT /v3/access/uploads/:mediaUploadId/finalize` or `POST /v3/access/uploads/finalize` (`vault:write`). These operations are not a one-to-one match for the V2 upload routes (`POST /v2/access/uploads/init`, `PUT /v2/access/uploads/:mediaUploadId/parts/:partNumber`, `PUT /v2/access/uploads/:mediaUploadId`, and `POST /v2/access/uploads/complete`).

### Earnings and analytics

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/earnings/chart` | `GET /v3/access/finances/earnings/timeseries`, or one metric: `/timeseries/total`, `/subscriptions`, `/messages`, `/posts`, `/profile-tips`, `/streams` | `finances:read` |
| `GET /v2/access/earnings/transactions` | `GET /v3/access/finances/earnings/transactions` | `finances:read` |
| `GET /v2/access/earnings/chargebacks` | `GET /v3/access/finances/earnings/chargebacks` | `finances:read` |
| `GET /v2/access/analytics/posts/chart` | `GET /v3/access/analytics/timeseries/engagement/posts` | `analytics:read` |
| `GET /v2/access/analytics/posts/top` | `GET /v3/access/analytics/posts/top` | `analytics:read` |
| `GET /v2/access/analytics/posts/:postId` | `GET /v3/access/analytics/posts/:postId` | `analytics:read` |
| `GET /v2/access/analytics/stories/chart` | `GET /v3/access/analytics/timeseries/engagement/stories` | `analytics:read` |
| `GET /v2/access/analytics/stories/top` | `GET /v3/access/analytics/stories/top` | `analytics:read` |
| `GET /v2/access/analytics/streams/chart` | `GET /v3/access/analytics/timeseries/engagement/streams` | `analytics:read` |
| `GET /v2/access/analytics/streams/top` | `GET /v3/access/analytics/streams/top` | `analytics:read` |
| `GET /v2/access/analytics/mass-messages/chart` | `GET /v3/access/analytics/timeseries/engagement/messages` | `analytics:read` |
| `GET /v2/access/analytics/mass-messages/sent` | `GET /v3/access/analytics/mass-messages/sent` | `analytics:read` |
| `GET /v2/access/analytics/mass-messages/:massMessageId/buyers` | `GET /v3/access/analytics/mass-messages/:massMessageId/buyers` | `analytics:read` |
| `GET /v2/access/analytics/promotions/chart` | `GET /v3/access/analytics/timeseries/promotions` | `analytics:read` |
| `GET /v2/access/analytics/promotions/top` | `GET /v3/access/analytics/promotions/top` | `analytics:read` |
| `GET /v2/access/analytics/trials/chart` | `GET /v3/access/analytics/timeseries/trial-links` | `analytics:read` |
| `GET /v2/access/analytics/trials/top` | `GET /v3/access/analytics/trial-links/top` | `analytics:read` |
| `GET /v2/access/analytics/campaigns/chart` | `GET /v3/access/analytics/timeseries/tracking-links` | `analytics:read` |
| `GET /v2/access/analytics/campaigns/top` | `GET /v3/access/analytics/tracking-links/rankings` | `analytics:read` |
| `GET /v2/access/analytics/visitor-countries/chart` | `GET /v3/access/analytics/timeseries/visitor-countries` | `analytics:read` |
| `GET /v2/access/analytics/visitor-countries/top` | `GET /v3/access/analytics/visitor-countries/top` | `analytics:read` |

### Promotions

| V2 route | V3 operation | Scope |
| - | - | - |
| `GET /v2/access/promotions` | `GET /v3/access/promotions` | `promotions:read` |
| `POST /v2/access/promotions` | `POST /v3/access/promotions` | `promotions:write` |
| `DELETE /v2/access/promotions/:promotionId` | `DELETE /v3/access/promotions/:promotionId` | `promotions:write` |
| `POST /v2/access/promotions/:promotionId/stop` | `POST /v3/access/promotions/:promotionId/end` | `promotions:write` |
| `GET /v2/access/promotions/bundles` | `GET /v3/access/promotions/bundles` | `promotions:read` |
| `POST /v2/access/promotions/bundles` | `POST /v3/access/promotions/bundles` | `promotions:write` |
| `GET /v2/access/promotions/bundles/:bundleId` | `GET /v3/access/promotions/bundles/:bundleId` | `promotions:read` |
| `DELETE /v2/access/promotions/bundles/:bundleId` | `DELETE /v3/access/promotions/bundles/:bundleId` | `promotions:write` |
| `GET /v2/access/promotions/tracking-links` | `GET /v3/access/promotions/tracking-links` | `promotions:read` |
| `POST /v2/access/promotions/tracking-links` | `POST /v3/access/promotions/tracking-links` | `promotions:write` |
| `DELETE /v2/access/promotions/tracking-links/:trackingLinkId` | `DELETE /v3/access/promotions/tracking-links/:trackingLinkId` | `promotions:write` |
| `GET /v2/access/promotions/tracking-links/:trackingLinkId/claimers` | `GET /v3/access/promotions/tracking-links/:trackingLinkId/claimed-users` | `promotions:read` |
| `POST /v2/access/promotions/tracking-links/share-access` | `PUT /v3/access/promotions/tracking-links/:trackingLinkId/shared-users/:userId` | `promotions:write` |
| `DELETE /v2/access/promotions/tracking-links/share-access` | `DELETE /v3/access/promotions/tracking-links/:trackingLinkId/shared-users/:userId` | `promotions:write` |
| `GET /v2/access/promotions/trial-links` | `GET /v3/access/promotions/trial-links` | `promotions:read` |
| `POST /v2/access/promotions/trial-links` | `POST /v3/access/promotions/trial-links` | `promotions:write` |
| `POST /v2/access/promotions/trial-links/:trialLinkId/deactivate` | `POST /v3/access/promotions/trial-links/:trialLinkId/deactivate` | `promotions:write` |
| `POST /v2/access/promotions/trial-links/share-access` | `PUT /v3/access/promotions/trial-links/:trialLinkId/shared-users/:userId` | `promotions:write` |
| `DELETE /v2/access/promotions/trial-links/share-access` | `DELETE /v3/access/promotions/trial-links/:trialLinkId/shared-users/:userId` | `promotions:write` |

### Vault+

V2 Vault+ routes move from `/v2/vault-plus/*` to `/v3/vault-plus/*` with the same route table. Authenticate with `Authorization: Bearer <access_token>` and send the account id in `x-linked-account-id`.

| V2 route | V3 route | Scope |
| - | - | - |
| `GET /v2/vault-plus/lists` | `GET /v3/vault-plus/lists` | `vault:read` |
| `GET /v2/vault-plus/lists/:listId/media` | `GET /v3/vault-plus/lists/:listId/media` | `vault:read` |
| `POST /v2/vault-plus/lists/:listId/add` | `POST /v3/vault-plus/lists/:listId/add` | `vault:write` |
| `PUT /v2/vault-plus/lists/:listId/sync` | `PUT /v3/vault-plus/lists/:listId/sync` | `vault:write` |
| `GET /v2/vault-plus/media` | `GET /v3/vault-plus/media` | `vault:read` |
| `POST /v2/vault-plus/media/batch` | `POST /v3/vault-plus/media/batch` | `vault:read` |
| `GET /v2/vault-plus/media/:mediaId` | `GET /v3/vault-plus/media/:mediaId` | `vault:read` |
| `DELETE /v2/vault-plus/media/:mediaId` | `DELETE /v3/vault-plus/media/:mediaId` | `vault:write` |
| `DELETE /v2/vault-plus/media` | `DELETE /v3/vault-plus/media` | `vault:write` |
| `GET /v2/vault-plus/status` | `GET /v3/vault-plus/status` | `vault:read` |
| `GET /v2/vault-plus/stats` | `GET /v3/vault-plus/stats` | `vault:read` |

Vault+ must be on for the environment, or V3 returns `403` `vault_plus_disabled`. It must also be on for the account, or `add` and `sync` return `403` `linked_account_vault_plus_disabled`. Turn it on or off per account with [linked account settings](#manage-linked-accounts).

### Not yet available on V3

These V2 routes have no V3 operation yet:

* `GET /v2/access/stories/map`
* `GET /v2/access/stories/items`
* `POST /v2/access/uploads/check`
* `GET /v2/access/promotions/tracking-links/:trackingLinkId`
* `PUT /v2/access/promotions/tracking-links/:trackingLinkId`
* `GET /v2/access/promotions/trial-links/:trialLinkId`
* `DELETE /v2/access/promotions/trial-links/:trialLinkId`
* `PUT /v2/access/promotions/:promotionId`
* `PUT /v2/access/promotions/bundles/:bundleId`

If you rely on one of these, or on a raw proxy path, contact [support@ofauth.com](mailto:support@ofauth.com) with the path before you schedule cutover.

## Prepare before cutover

<Steps>
  <Step title="Save your Live callback and client secret">
    The migration flow pre-fills Live callbacks from your V2 redirect URLs. Review them, then save the Live client secret where your app runs. It is shown once.
  </Step>

  <Step title="Build the V3 flow for new users">
    New users connect through V3 OAuth. Follow [Authorize](/v3/authorize) and [Tokens and refresh](/v3/tokens). The authorization URL replaces `POST /v2/link/init`, and your callback receives `code` and `state` instead of a connection id. Store each user's refresh token, encrypted at rest, with its `authorization_id`, and persist the rotated refresh token on every refresh.
  </Step>

  <Step title="Rehearse in Test">
    Get a Test token and make one successful Access call with your Test client.
  </Step>

  <Step title="Make a successful Live token request">
    Get at least one token with your Live client ID and current client secret.
  </Step>

  <Step title="Update your webhook receivers">
    V3 signs deliveries with the Standard Webhooks scheme, which is the same scheme V2 used. Only the header names change:

    | V2 header | V3 header |
    | - | - |
    | `svix-id` | `webhook-id` |
    | `svix-timestamp` | `webhook-timestamp` |
    | `svix-signature` | `webhook-signature` |

    Receivers that verify with a Standard Webhooks library keep working with the same `whsec_` secret. Receivers that read `svix-*` headers by name must change before cutover. Event names and payloads also change. See [Webhook events](#webhook-events).
  </Step>

  <Step title="Get ready to import your accounts">
    Build the import described in [Switch your accounts to V3](#switch-your-accounts-to-v3) and deploy it before cutover. You run it once, with the account bundle an owner downloads right after cutover.
  </Step>
</Steps>

The migration flow checks each step before you schedule cutover. You can schedule cutover with steps still open only if you accept that your API calls will fail until your app uses V3.

### Verify V3 webhooks

Until cutover, V2 still sends `svix-*` headers. This Node.js receiver accepts both header sets, so you can deploy it before cutover:

```javascript theme={null}
import { Webhook } from "standardwebhooks"

const secret = process.env.OFAUTH_WEBHOOK_SECRET
if (!secret) {
  throw new Error("Missing OFAUTH_WEBHOOK_SECRET")
}

// The whsec_ secret from your V2 endpoint, unless you rotated it
const webhook = new Webhook(secret)

const header = (request, name) =>
  request.headers.get(`webhook-${name}`) ??
  request.headers.get(`svix-${name}`) ??
  ""

export async function handleOfauthWebhook(request) {
  // Verify the raw body, before any JSON parsing
  const body = await request.text()

  let event
  try {
    event = webhook.verify(body, {
      "webhook-id": header(request, "id"),
      "webhook-timestamp": header(request, "timestamp"),
      "webhook-signature": header(request, "signature")
    })
  } catch {
    return new Response("Invalid signature", { status: 400 })
  }

  await processEvent(event)
  return new Response(null, { status: 204 })
}
```

The migration itself sends no webhook events.

## After cutover on V2

Every V2 response for a migrated connection is `401` with this body:

```json theme={null}
{
  "error": "Connection has migrated",
  "type": "SESSION_NOT_PROVIDED"
}
```

V2 Access responses also include `requestId`. Match on `error` being `Connection has migrated`.

The Live V2 API keys of a migrated organization are disabled when cutover finishes. After that, V2 rejects them as invalid keys. Remove them from your configuration.

## Switch your accounts to V3

When cutover finishes, an owner of your organization downloads a one-time account bundle from the migration page in the dashboard. The bundle holds one V3 refresh token for each migrated Live account. Your app imports it once. There is no per-account call to build into your app.

<Warning>
  From cutover until your app imports the bundle, API calls for your migrated accounts fail. Have the import deployed, and someone ready to download and run it, when cutover finishes. Scheduling cutover for a time your team is available keeps the gap short.
</Warning>

The bundle is available when your organization moves to Developer, or to Developer and Operator. Operator has no API, so an Operator-only migration has no bundle.

### Download the bundle

On the migration page, an owner downloads the account bundle. The file is named `ofauth-migrated-accounts-<organization>-<date>.json`.

* Only organization owners can download it.
* The tokens are created when the owner downloads the file. OFAuth does not keep a copy, so the same file can never be downloaded again.
* Treat the file like your client secret. Import it, then delete it.

```json theme={null}
{
  "format": "ofauth.migrated-accounts.v1",
  "client_id": "your_live_client_id",
  "token_endpoint": "https://api.ofauth.com/v3/link/token",
  "issued_at": "2026-10-14T09:30:00.000Z",
  "generation": 1,
  "notes": "...",
  "accounts": [
    {
      "linked_account_id": "conn_abc123",
      "authorization_id": "external_live_example123",
      "refresh_token": "...",
      "scope": "messages:read posts:read profile:read queue:read"
    }
  ],
  "skipped": [
    { "linked_account_id": "conn_def456", "reason": "not_connected" }
  ]
}
```

| Field | Description |
| - | - |
| `format` | Always `ofauth.migrated-accounts.v1`. |
| `client_id` | Your Live client ID. Use it with your Live client secret. |
| `token_endpoint` | Where to exchange the refresh tokens. |
| `issued_at` | When this bundle was created. |
| `generation` | `1` for the first bundle, then one higher each time it is regenerated. |
| `notes` | Short usage notes. |
| `accounts[].linked_account_id` | The account id, unchanged from V2. Send it in `x-linked-account-id`. |
| `accounts[].authorization_id` | The authorization the account belongs to. Several accounts can share one. |
| `accounts[].refresh_token` | A V3 refresh token for this account. |
| `accounts[].scope` | The scopes mapped from the account's V2 permissions. |
| `skipped` | Accounts with no token in this bundle, each with a `reason`. |

### Skipped accounts

| `reason` | Meaning |
| - | - |
| `not_granted` | Your integration no longer has access to the account. |
| `authorization_revoked` | The authorization that covered the account was revoked. |
| `not_connected` | The account is not connected, for example because its OnlyFans session ended. |
| `no_scopes` | The account's V2 permissions map to no V3 scope. |

To get a skipped account back, send the creator through [authorization](/v3/authorize).

### Import the refresh tokens

For each entry in `accounts`, exchange the refresh token at the token endpoint with `grant_type=refresh_token`. Authenticate with your Live client ID and secret using HTTP Basic authentication (`client_secret_basic`), as for any [refresh](/v3/tokens#refresh-the-access-token).

```bash cURL theme={null}
curl -X POST https://api.ofauth.com/v3/link/token \
  -u "$OFAUTH_LIVE_CLIENT_ID:$OFAUTH_LIVE_CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$BUNDLE_REFRESH_TOKEN"
```

This Node.js script imports a bundle file:

```typescript TypeScript theme={null}
import { readFile } from "node:fs/promises"

type AccountBundle = {
  format: "ofauth.migrated-accounts.v1"
  client_id: string
  token_endpoint: string
  accounts: {
    linked_account_id: string
    authorization_id: string
    refresh_token: string
    scope: string
  }[]
  skipped: { linked_account_id: string; reason: string }[]
}

const clientId = process.env.OFAUTH_LIVE_CLIENT_ID
const clientSecret = process.env.OFAUTH_LIVE_CLIENT_SECRET
if (!clientId || !clientSecret) {
  throw new Error("Missing OFAuth Live client credentials")
}

const credentials = Buffer.from(
  `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`
).toString("base64")

export async function importAccountBundle(path: string) {
  const bundle: AccountBundle = JSON.parse(await readFile(path, "utf8"))
  if (
    bundle.format !== "ofauth.migrated-accounts.v1" ||
    bundle.client_id !== clientId
  ) {
    throw new Error("This bundle is not for this Live client")
  }

  // One account at a time. Each refresh token works once.
  for (const account of bundle.accounts) {
    const response = await fetch(bundle.token_endpoint, {
      method: "POST",
      headers: {
        authorization: `Basic ${credentials}`,
        "content-type": "application/x-www-form-urlencoded"
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: account.refresh_token
      })
    })

    const body = await response.json()
    if (!response.ok) {
      // invalid_grant: already used, expired, or replaced by a newer bundle
      await recordImportFailure(account.linked_account_id, body.error)
      continue
    }

    // Persist the new refresh token before you use the access token.
    // The bundle's refresh token no longer works.
    await saveTokens(account.linked_account_id, body)
  }

  for (const skipped of bundle.skipped) {
    await markReauthorizationRequired(skipped.linked_account_id, skipped.reason)
  }
}
```

The response is the normal [token response](/v3/tokens#token-response). From here on, each account is an ordinary V3 session:

* Store tokens per `linked_account_id`, even when several accounts share an `authorization_id`.
* Refresh tokens rotate. Store the new refresh token after every refresh. Replaying a used refresh token revokes the whole authorization it belongs to.
* Serialize refreshes for each account. Two concurrent refreshes with the same token look like replay.
* Like any refresh token, a bundle refresh token is valid for 90 days from its issue. Import the bundle soon after cutover.

### Regenerate the bundle

If the file or its tokens are lost before or during the import, an owner can regenerate the bundle from the same page.

* Regenerating revokes every token issued from the previous bundle, including refresh tokens your app already rotated from it, and issues new ones. Tokens your app got through its own authorization flows are not affected.
* After regenerating, import the new file for every account.
* A bundle can be generated at most once a minute. A request within 60 seconds of the last one returns `409` with a message to retry later.
* Regenerating sends no webhook events.

### Errors

| Status | `error` | What to do |
| - | - | - |
| `400` | `invalid_grant` | The refresh token was already used, has expired, or was replaced by a newer bundle. Use the account's latest refresh token, or have an owner regenerate the bundle. |
| `400` | `invalid_request` | A parameter is missing, repeated, or not allowed. Fix the request. |
| `401` | `invalid_client` | The client credentials are missing or wrong. Use your Live client. The response includes `WWW-Authenticate: Basic realm="link", charset="UTF-8"`. |
| `429` | `rate_limited` | Too many requests. Wait for the number of seconds in `Retry-After`, then retry. |

### Call V3 with the same id

```bash cURL theme={null}
curl https://api.ofauth.com/v3/access/profile \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-linked-account-id: $ACCOUNT_ID"
```

V3 Access errors use the shape `{ "error": "...", "recovery": { "action": "..." }, "requestId": "..." }`. The account errors:

| Status | `error` | `recovery.action` | Meaning |
| - | - | - | - |
| `403` | `linked_account_required` | `select_linked_account` | The `x-linked-account-id` header is missing. |
| `403` | `linked_account_not_granted` | `grant_linked_account` | The authorization does not include this account. |
| `401` | `linked_account_reconnect_required` | `reconnect_linked_account` | The OnlyFans session ended. The creator must reconnect. |
| `403` | `linked_account_vault_plus_disabled` | `enable_linked_account_vault_plus` | Vault+ is off for this account. |
| `404` | `linked_account_not_found` | `list_linked_accounts` | The account is not part of this authorization (`/v3/linked-accounts/{id}` routes). |

See [V3 errors](/v3/errors) for every code.

### Error code mapping

| V2 `type` | V3 `error` | `recovery.action` |
| - | - | - |
| `UNAUTHORIZED` | `external_oauth_bearer_required`, `invalid_token` | `obtain_external_oauth_token` |
| `SESSION_NOT_PROVIDED` | `linked_account_required`, `linked_account_not_granted` | `select_linked_account`, `grant_linked_account` |
| `SESSION_EXPIRED` | `linked_account_reconnect_required` | `reconnect_linked_account` |
| `FORBIDDEN` | `missing_scope`, `additional_consent_required` | `request_additional_consent` |
| `FORBIDDEN` (plan or approval) | `subscription_required`, `integration_not_approved` | `update_developer_plan`, `complete_integration_approval` |
| `RATE_LIMIT_EXCEEDED` | `rate_limited` with `RateLimit-*` headers; `quota_exhausted` in Test | `retry`, `retry_after_reset` |
| `RULES_OUTDATED` | None. OFAuth handles rules. | |
| `UNKNOWN_ERROR` | `authorization_temporarily_unavailable`, `transient_provider_failure` | `retry` |

## Manage linked accounts

These routes take the bearer access token. They replace `GET /v2/account/connections` and the V2 connection routes for your app.

| Route | What it does |
| - | - |
| `GET /v3/linked-accounts` | Lists the accounts this authorization covers, with status and scopes. Paginate with `limit` (1 to 100, default 50) and `cursor`. Returns `{ data, has_more, next_cursor }`. |
| `GET /v3/linked-accounts/{id}` | Returns one account. |
| `DELETE /v3/linked-accounts/{id}` | Removes your app's access to one account. The account and other apps' access are unaffected. |
| `GET /v3/linked-accounts/{id}/settings` | Returns the account's settings, such as `{ "vault_plus": { "enabled": true } }`. |
| `PATCH /v3/linked-accounts/{id}/settings` | Turns Vault+ on or off for the account with `{ "vault_plus": { "enabled": boolean } }`. Needs `vault:write` on the account. |

## Revoke tokens

```http theme={null}
POST https://api.ofauth.com/v3/link/revoke
```

Send `token` (and optionally `token_type_hint`) with your client credentials, using HTTP Basic authentication. Revoking a refresh token ends that session only. Revoking the last active session ends the whole authorization and sends `authorization.revoked` with `reason: "oauth_revocation"`. Unknown tokens still return `200`.

To remove one account but keep the rest, call `DELETE /v3/linked-accounts/{id}` instead.

## Group accounts under one person

When a creator or agency reconnects through your integration, you can group the other migrated accounts that belong to the same person. Add `linked_account_ids` to the Live authorization request:

```http theme={null}
GET https://auth.ofauth.com/link/authorize?...&linked_account_ids=conn_abc,conn_def
```

| Parameter | Requirement |
| - | - |
| `linked_account_ids` | Optional. Up to 25 linked account ids, comma-separated. Live only. |

After the person reconnects, the listed accounts are grouped under the person who reconnects. Each account keeps its id, its scopes, and your tokens for it. A malformed list, more than 25 ids, or the parameter on a non-Live client returns `invalid_request`.

Grouping sends no webhook events.

## Webhook events

V2 `connection.*` events become V3 lifecycle events. The migration maps each V2 endpoint filter like this:

| V2 event | V3 lifecycle events |
| - | - |
| `connection.created` | `authorization.granted` |
| `connection.updated` | `authorization.updated`, `linked_account.reconnected` |
| `connection.expired` | `linked_account.expired` |
| `connection.disconnected` | `authorization.revoked`, `linked_account.disconnected` |
| `rules.updated` | None. Dropped. |
| Realtime events | Realtime events on V3 |

Lifecycle events are on or off per webhook destination (`lifecycleEventsEnabled`). A migrated endpoint gets them if its V2 filter had any `connection.*` event or no filter at all. Realtime turns on if the filter had any Realtime event or no filter at all.

Only enabled V2 endpoints with a plain HTTPS URL (no query string, fragment, or credentials) move. An endpoint whose filter had only `rules.updated` is not moved.

| V3 event | When | `data.reason` |
| - | - | - |
| `authorization.granted` | A person approved your app and a new authorization was created. | `authorized` |
| `authorization.updated` | The person consented again and added or removed accounts or changed scopes, or an account was added automatically. | `consent_updated`, `auto_added` |
| `authorization.revoked` | The whole authorization ended. | `owner_revoked`, `oauth_revocation`, `refresh_token_replay`, `integration_deleted`, `integration_suspended` |
| `linked_account.expired` | One account needs a reconnect. | `provider_session_expired` |
| `linked_account.reconnected` | One account works again after a reconnect. | `reconnected` |
| `linked_account.disconnected` | One account no longer works for this authorization. | `owner_removed`, `owner_disconnected`, `owner_deleted`, `developer_deleted` |

Payloads carry ids only. `data` has `authorization_id`, `reason`, and, for `linked_account.*` events, `linked_account_id`. `authorization.*` events list the account ids in `linked_account_ids`, or in `added_linked_account_ids` and `removed_linked_account_ids` (plus `scopes_changed`) for `authorization.updated`. Above 100 ids the list is replaced by `linked_account_ids_truncated: true`, `linked_account_count`, and, while the authorization still works, `linked_accounts_url: "/v3/linked-accounts"`. `authorization.revoked` never carries `linked_accounts_url`. Lifecycle deliveries are not billed. See [V3 webhooks](/v3/webhooks).

Every delivered event, lifecycle or Realtime, has this envelope:

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

`linkedAccountId` is `null` for `authorization.*` events. Deliveries use the Standard Webhooks headers `webhook-id`, `webhook-timestamp`, and `webhook-signature`, signed with the same `whsec_` secret.

### Realtime over WebSocket

Open `wss://v3-realtime.ofauth.com/v3/realtime?linkedAccountId=<account id>` with `Authorization: Bearer <access_token>`. Each message has `eventId`, `type`, `schemaVersion`, `order`, `receivedAt`, `environment`, `integrationId`, `linkedAccountId`, `test`, and `data`. The socket carries Realtime events only; lifecycle events arrive by webhook.

## Authorization server discovery

The authorization server metadata is at:

```http theme={null}
GET https://auth.ofauth.com/.well-known/oauth-authorization-server/link
```

The issuer is `https://auth.ofauth.com/link`. The document lists the authorize, token, and revoke endpoints and the JWKS URL.

## Pricing

Migrating organizations get 30% off the first month of the V3 plan they choose. The Developer Build plan keeps its own launch discount instead; the two discounts do not combine. The offer has no end date.

V2 billing stops at cutover.

## FAQ

<AccordionGroup>
  <Accordion title="Do creators need to log in to OnlyFans again?">
    No. Migrated accounts keep their OnlyFans session. Creators only reconnect if OnlyFans ends the session, as on V2.
  </Accordion>

  <Accordion title="Can I keep my V2 connection ids?">
    Yes. Every migrated account keeps its id. Send it in `x-linked-account-id` on V3.
  </Accordion>

  <Accordion title="What replaces the raw proxy?">
    Typed V3 Access operations. If an OnlyFans path you rely on has no operation, contact [support@ofauth.com](mailto:support@ofauth.com) with the path.
  </Accordion>

  <Accordion title="Can one person share several OnlyFans accounts with my app?">
    Yes. One authorization can cover several linked accounts. The token response and `GET /v3/linked-accounts` list them. Choose one per request with `x-linked-account-id`.
  </Accordion>

  <Accordion title="How long do tokens last?">
    Access tokens last 10 minutes. Refresh tokens last 90 days and are replaced on every refresh. If a session is unused for 90 days, the user authorizes again.
  </Accordion>

  <Accordion title="What happens if I lose a refresh token?">
    Send the user through authorization again. They see the accounts and scopes they already approved, and you get a new session in the same authorization. If you lose tokens from the account bundle before or during the import, an owner can [regenerate the bundle](#regenerate-the-bundle) instead.
  </Accordion>

  <Accordion title="Does Sandbox behave like V2 Sandbox?">
    Yes for data: deterministic fixtures and no OnlyFans calls. The difference is the flow: you authorize with your Sandbox client instead of a `sk_sandbox_` key.
  </Accordion>

  <Accordion title="Can I migrate one client app at a time?">
    No. The whole organization moves together: every Live client app and every Live connection. V2 Sandbox apps, keys, and connections are not migrated. Set up V3 Sandbox and Test fresh.
  </Accordion>
</AccordionGroup>


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