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

# Migrating from the legacy API

> Move an integration from the deprecated /api/v1 API-key surface to the OAuth 2.1 Platform API.

This guide is for integrators moving from the legacy, API-key-authenticated
`/api/v1` surface to the OAuth 2.1 `/api/oauth/v1` surface.

The two surfaces coexist during migration. The legacy `/api/v1` API is
**deprecated and unmaintained**: existing integrations may keep using its frozen
contract while they migrate, but it receives no new features or maintenance
fixes. New integrations must use OAuth for its per-scope permissions and
single-tenant safety. Everything below is grounded in the two OpenAPI specs:

* Legacy: `docs/api/sideshift-api-public.yaml` (and `docs/api/sideshift-api-private.yaml` for the restricted/Full-Access endpoints)
* OAuth: `docs/api/oauth/openapi.yaml`

Both surfaces share the same production host (`https://app.sideshift.app`) and
both require an **active subscription** for protected resource calls - a lapsed
subscription returns `402` on either API's resource surface.

|             | Legacy                                    | OAuth                                               |
| ----------- | ----------------------------------------- | --------------------------------------------------- |
| Base URL    | `https://app.sideshift.app/api/v1`        | `https://app.sideshift.app/api/oauth/v1`            |
| Auth        | `x-api-key` header                        | `Authorization: Bearer <token>`                     |
| Granularity | All-or-nothing per company                | Per-scope, one company per token                    |
| Pagination  | `?page=&limit=` → `{ data, page, total }` | `?cursor=&limit=` → `{ data, nextCursor, hasMore }` |
| Errors      | `{ "error": "<message>" }`                | `{ "error": { "code", "message", "requestId" } }`   |
| Rate limit  | 100 req/min/key (400 for partners)        | 600 req/min per (client, company)                   |

***

## 1. Auth model

**Legacy - static API key, all-or-nothing.** You send your company's API key in
the `x-api-key` header on every request. The key is created/rotated in the
dashboard under **Settings → Integrations**, grants access to that company's
entire `/api/v1` surface (no per-endpoint permissions), and never expires until
you rotate it.

```http theme={"system"}
GET /api/v1/programs?page=1&limit=25 HTTP/1.1
Host: app.sideshift.app
x-api-key: sk_live_9f8e7d6c5b4a...
```

**New - OAuth 2.1 bearer token, scoped and tenant-bound.** You exchange an
OAuth grant for a short-lived (1h) access token and send it as a bearer token.
The token carries only the **scopes** the user consented to (e.g.
`campaigns:read`) and is bound to **exactly one company tenant** (`company_id`),
so a single token can never reach across companies.

```http theme={"system"}
GET /api/oauth/v1/campaigns?limit=25 HTTP/1.1
Host: app.sideshift.app
Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
```

How you get a token (full walkthrough in [`getting-started.md`](/quickstart)):

* **Register a client** once via Dynamic Client Registration (`POST /register`,
  RFC 7591) to obtain a `client_id`.
* **Authorization code + PKCE** (`GET /authorize` → consent → `POST /token`) is
  the standard flow for user-facing apps. PKCE with `S256` is **required**. The
  consent step is where the user picks which company the grant is bound to.
* **Refresh tokens** (`grant_type=refresh_token`) rotate the access token
  without re-prompting when the client registered the `refresh_token` grant.
  `offline_access` is an optional OIDC signal, not a prerequisite.
* **Client credentials** (`grant_type=client_credentials`) is available for
  machine-to-machine clients.
* Access tokens are RFC 9068 `at+jwt` and expire after 3600s. The company's
  subscription is checked when the token calls a protected resource
  (`402 subscription_required` if lapsed).

<Note>
  Both APIs require an active subscription. On `/api/v1` a lapsed subscription
  is `402 { "error": "Active subscription required" }`; on `/api/oauth/v1` it is
  `402 { "error": { "code": "subscription_required", ... } }` at the resource
  endpoints. OAuth registration, consent, and token issuance can complete while
  a subscription is lapsed; the issued token cannot access protected resources.
</Note>

***

## 2. Pagination

**Legacy - offset pagination.** List endpoints take `page` (default 1) and
`limit` (default 25, max 100) and return `{ data, page, total }`. Some endpoints
additionally return `limit` and/or `totalPages` (e.g. `/programs`,
`/analytics/videos`, `/payouts/pending`). To pull a full data set you page until
`page * limit >= total`, which risks silent truncation / drift if rows are
inserted between page fetches.

```http theme={"system"}
GET /api/v1/posts?page=2&limit=50
```

```json theme={"system"}
{ "data": [ ... ], "page": 2, "total": 137 }
```

**New - opaque cursor-shaped pagination.** List endpoints take an opaque `cursor` and
`limit` (default 25, max 100) and return `{ data, nextCursor, hasMore }`. Pass
the previous response's `nextCursor` back as `?cursor=` to get the next page,
and stop when `hasMore` is `false` (`nextCursor` is then `null`). The cursor is
an encoded continuation value over the current page-based implementation, so
you must still account for records changing between page fetches.

```http theme={"system"}
GET /api/oauth/v1/posts?limit=50
```

```json theme={"system"}
{ "data": [ ... ], "nextCursor": "opaque_cursor_from_response", "hasMore": true }
```

```http theme={"system"}
GET /api/oauth/v1/posts?limit=50&cursor=opaque_cursor_from_response
```

<Note>
  Treat `nextCursor` as opaque - do not parse or construct it. A `null`
  `nextCursor` with `hasMore: false` means you have read the last page.

  Note: `GET /posts/{id}/metrics-history` is an exception on both surfaces - it
  uses `days` + `limit` (max 500) rather than cursor/offset pagination.
</Note>

***

## 3. Error shapes

**Legacy - flat string error.** Every error is `{ "error": "<message>" }` with
the matching HTTP status (`400` invalid request, `401` invalid/missing key,
`402` no active subscription, `403` restricted endpoint or cross-company, `404`
not found, `429` rate limited). A few endpoints add a machine code such as
`LEAD_NOT_FOUND`, but there is no stable, documented code registry.

```json theme={"system"}
{ "error": "Active subscription required" }
```

**New (resource endpoints) - structured envelope with a stable code.** Every
error from a `/api/oauth/v1` resource endpoint is:

```json theme={"system"}
{ "error": { "code": "insufficient_scope", "message": "Requires scope 'campaigns:write'", "requestId": "req_..." } }
```

`code` comes from a fixed registry: `invalid_request`, `unauthorized`,
`insufficient_scope`, `forbidden`, `subscription_required`, `not_found`,
`conflict`, `idempotency_conflict`, `rate_limited`, `internal`. `requestId` is
echoed for support/correlation. On `401` and `403` (`insufficient_scope`) the
response also carries a `WWW-Authenticate` challenge (RFC 6750/9728). See
[`errors-rate-limits.md`](/platform/errors) for the full registry and
`Idempotency-Key` / `Retry-After` semantics.

**New (protocol endpoints) - RFC 6749 bodies.** The OAuth protocol endpoints
(`/register`, `/authorize`, `/token`, `/revoke`, client management) do **not**
use the resource envelope. They return RFC 6749-style
`{ "error", "error_description" }` bodies with codes like `invalid_grant`,
`invalid_client`, `invalid_scope`, `unsupported_grant_type`,
`subscription_required`:

```json theme={"system"}
{ "error": "invalid_grant", "error_description": "Authorization code is invalid or expired" }
```

***

## 4. Scope mapping

Where a legacy API key reached an entire `/api/v1` endpoint group with no
permission boundary, the OAuth token must carry the specific scope(s) below.
Scopes are defined in the `oauth2` security scheme of `docs/api/oauth/openapi.yaml`.

| Legacy `/api/v1` group (endpoints)                                                                                    | OAuth scope(s)                             |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Programs / campaigns (`/programs`, `/programs/{id}/invite`)                                                           | `campaigns:read` / `campaigns:write`       |
| Contracts (`/contracts`)                                                                                              | `contracts:read` / `contracts:write`       |
| Creators, collections & program invites (`/creators`, `/creators/collections`, `/programs/{id}/invite`, invite links) | `creators:read` / `creators:write`         |
| Applications / applicants (campaign handle requests)                                                                  | `applications:read` / `applications:write` |
| Posts (`/posts`, `/posts/{id}`, `/posts/{id}/metrics-history`, `/posts/export`)                                       | `posts:read`                               |
| Payouts - read (`/payouts`, `/payouts/pending`, `/payouts/stats`)                                                     | `payouts:read`                             |
| Payouts - execute / Quick Pay (`/payouts/execute`, `/payouts/quick-pay`)                                              | `payouts:write`                            |
| Invoices (`/invoices`, `/invoices/{id}`, `/send`, `/void`)                                                            | `invoices:read` / `invoices:write`         |
| Messages (conversations + send)                                                                                       | `messages:read` / `messages:write`         |
| Settings / company profile                                                                                            | `settings:read` / `settings:write`         |
| Analytics reads (`/analytics/*`)                                                                                      | `analytics:read`                           |
| Campaign ghost handles and videos (`POST /campaigns/{id}/ghost-handles*`, `POST /campaigns/{id}/ghost-videos*`)       | `campaigns:write`                          |
| Campaign analytics-history migration (`POST /campaigns/{id}/analytics-history`)                                       | `campaigns:write`                          |
| Discover leads (`/discover/leads`, `/discover/leads/{id}`)                                                            | `discover:read`                            |

Notes:

* Register the **`refresh_token`** grant type to receive a refresh token;
  `offline_access` may still be requested as an optional OIDC signal. Without
  the registered grant type, `/token` returns no `refresh_token`.
* A few endpoints map across groups: campaign/program invite links live under
  `creators:write` on OAuth (`POST /campaigns/{id}/invites`, `POST /invites`,
  `DELETE /invites/{id}`), and listing invites uses `creators:read`. Creating
  and revoking invites is flagged sensitive under `settings:write`/`creators:write`.
* `payouts:write`, `invoices:write`, and `messages:write` are **sensitive** -
  they move money or fire external side effects. Sandbox/test-mode grants are
  rejected (`403 forbidden`) and `payouts:write` requires an `Idempotency-Key`.
* A request whose token lacks the needed scope returns `403 insufficient_scope`
  with a `WWW-Authenticate` challenge naming the required scope.

***

## 5. Rate limits

**Legacy.** 100 requests/minute per API key; allowlisted partner accounts get
400/minute. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
and `X-RateLimit-Reset`; exceeding the limit returns `429`.

**New.** Resource endpoints are limited to **600 requests/minute per (client,
company)** pair - i.e. the budget is scoped to the client *and* the tenant the
token is bound to, not to a single key. Responses carry `X-RateLimit-*` headers
and `429` carries `Retry-After`. The protocol endpoints (`/register`,
`/authorize`, `/token`, `/revoke`) have their own, separate per-IP / per-client
limits and return RFC 6749 `429` bodies. See
[`errors-rate-limits.md`](/platform/errors) for header details and
back-off guidance.

***

## What remains available on `/api/v1` during migration

* The legacy `/api/v1` surface remains reachable for backward compatibility,
  but it is deprecated and unmaintained. Existing API-key integrations should
  migrate to OAuth; do not build new integrations against this frozen contract.
* The **restricted** endpoints - Jobs (`/jobs`), Applicants (`/applicants`),
  and payout execution (`/payouts/execute`, `/payouts/quick-pay`) - stay
  **API-key + partner-allowlist only** and are documented in the **Full Access**
  spec (`docs/api/sideshift-api-private.yaml`). A non-allowlisted key calling
  them gets `403`. (Payout execution and Quick Pay also exist on OAuth under
  `payouts:write`, but the legacy restricted variants are unchanged.)
* **API-key management** (creating/rotating keys, digest automations) stays in
  the dashboard with a Firebase session - it is not part of the API-key contract
  and has no OAuth equivalent.
* For **new** integrations, use OAuth: you get least-privilege scoping and
  guaranteed single-tenant binding instead of a single all-powerful per-company
  key.

***

## 7. Endpoint parity notes

**New / OAuth-only (no `/api/v1` equivalent):**

* **Outbound webhook subscriptions** - full CRUD (`/webhooks`), signed test
  delivery (`POST /webhooks/{id}/test`), and a delivery log
  (`GET /webhooks/{id}/deliveries`). The legacy API has no push/webhook surface
  at all (it is pull-only).
* **`Idempotency-Key` on mutations** - every OAuth `POST`/`PATCH`/`PUT` honors
  an `Idempotency-Key` (required on `payouts:write` and campaign analytics-history imports); replaying the same key +
  body returns the original response, a different body is `409`. The legacy
  surface has no idempotency mechanism.
* **First-class campaign write operations** - create/update/archive/duplicate
  campaigns, set payment structures, create/cancel contracts, review
  applications, create collections - most of which the read-leaning `/api/v1`
  surface does not expose as writes.

**Without a direct OAuth equivalent:**

* **Analytics recruitment** - `/analytics/recruitment` has no direct OAuth
  equivalent. OAuth does expose `accounts`, `kpis`, `overview`, `time-series`,
  and `videos` under the `analytics:read` scope.
