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

# OAuth: Scopes, Endpoints & Errors

> Which OAuth scope unlocks which resource endpoint, the OAuth and resource error shapes, and a security checklist for your integration.

## Reading organization data

Resource endpoints sit under **`/v1/`**, not `/api/v1/` — that prefix belongs to the OAuth endpoints in [Authorization Code + PKCE](/docs/oauth-authorization-code) and [Client Credentials](/docs/oauth-client-credentials). They take a bearer token the same way, and unlike `userinfo` they accept a **Client Credentials** token, which is what makes unattended access possible.

<Tip>
  `GET /v1/ping` accepts any valid token and touches no organization data — the cheapest way to confirm a token works before debugging anything else.
</Tip>

<Info>
  Every response nests its payload under `data`, alongside a `meta.requestId` worth logging — quote it when asking Soundlink about a specific request.
</Info>

The examples below cover the three read endpoints most integrations start with. See [Scopes and the endpoints they unlock](#scopes-and-the-endpoints-they-unlock) below for the full list — including the write scopes — and the [API reference](https://www.getsoundlink.com/docs/api-reference) for every parameter and response schema.

### List campaigns

`GET /v1/campaigns` · scope `campaigns:read`

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl "https://api.getsoundlink.com/v1/campaigns?page=1&pageSize=10" \
    -H "Authorization: Bearer ACCESS_TOKEN" \
    -H "Accept: application/json"
  ```

  ```ts TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const url = new URL("https://api.getsoundlink.com/v1/campaigns");
  url.searchParams.set("page", "1");
  url.searchParams.set("pageSize", "10");

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: "application/json",
    },
  });

  const { data } = await res.json();
  console.log(data.items, data.pagination.totalCount);
  ```
</CodeGroup>

```json 200 Response theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "data": {
    "items": [
      {
        "campaignId": "f1e28d31-c358-4284-9bef-00a2334625fd",
        "organizationId": "org_xyz",
        "status": "active",
        "socialPlatform": "meta",
        "dailyBudget": 20.0,
        "totalBudget": 140.0,
        "campaignDuration": 7,
        "generation": 3,
        "artistId": "4gzpq5DPGxSnKTe4SA8HAU",
        "artistName": "Coldplay",
        "trackId": "3AJwUDP919kvQ9QcozQPxg",
        "playlistId": null,
        "trackName": "Yellow",
        "campaignUrl": "https://getsoundlink.com/orgs/org_xyz/insights/V1StGXR8_Z5jdHi6B-myT",
        "createdAt": "2026-04-01T10:00:00Z",
        "updatedAt": "2026-04-08T12:00:00Z"
      }
    ],
    "pagination": { "page": 1, "pageSize": 10, "totalCount": 42, "totalPages": 5 }
  },
  "meta": { "requestId": "550e8400-e29b-41d4-a716-446655440000" }
}
```

<ParamField query="page" default="1" type="integer">
  Minimum `1`.
</ParamField>

<ParamField query="pageSize" default="10" type="integer">
  Between `1` and `100`. Values outside the range are rejected with `400`.
</ParamField>

<ParamField query="sortBy" default="createdAt" type="string">
  `createdAt` or `status`.
</ParamField>

<ParamField query="sortOrder" default="desc" type="string">
  `asc` or `desc`.
</ParamField>

### One campaign

`GET /v1/campaigns/{campaignId}` · scope `campaigns:read`

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl https://api.getsoundlink.com/v1/campaigns/CAMPAIGN_ID \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

The list summary's fields, plus an optional `strategyType`. A campaign the token's organization does not own answers `404` — the same as one that does not exist, so ownership is not enumerable.

<AccordionGroup>
  <Accordion title="status values">
    `creating` · `active` · `inactive` · `ended` · `renewing` · `restarting` · `restart_failed` · `renew_awaiting_charge` · `renew_failed` · `stopped`
  </Accordion>

  <Accordion title="generation values">
    `1`, `2` or `3`. Writes are supported only for generation `3`.
  </Accordion>
</AccordionGroup>

### Campaign metrics

`GET /v1/campaigns/{campaignId}/metrics/overview` · scope `metrics:read`

Totals for a date range, with no per-day rows.

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl "https://api.getsoundlink.com/v1/campaigns/CAMPAIGN_ID/metrics/overview?startDate=2026-04-01&endDate=2026-04-08" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json 200 Response theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "data": {
    "listeners": 4821,
    "streams": 9340,
    "followers": 312,
    "impressions": 48210,
    "ad_clicks": 1120,
    "link_clicks": 890,
    "spend_media": 134.5,
    "spend_total": 168.13,
    "fees": 33.63,
    "currency": "USD",
    "cpl": 0.028,
    "cpf": 0.43,
    "streams_per_listener": 1.94
  },
  "meta": { "requestId": "550e8400-e29b-41d4-a716-446655440000" }
}
```

<ParamField query="startDate" default="campaign start" type="string">
  `YYYY-MM-DD`, inclusive.
</ParamField>

<ParamField query="endDate" default="today" type="string">
  `YYYY-MM-DD`, inclusive. Must be greater than or equal to `startDate`, or you get `400`.
</ParamField>

`cpl`, `cpf` and `streams_per_listener` are derived server-side — media spend per listener, per follower, and streams per listener, each `0` when listeners or followers are `0`. Handle `impressions`, `ad_clicks` and `link_clicks` as **nullable**: a platform that did not report them sends `null`, which is not the same as zero.

<Warning>
  `campaigns:read` does not imply `metrics:read`. A token holding only the first reads campaigns and gets `403 insufficient_scope` on metrics — so request both if you show them together, and handle each independently if you request them separately.
</Warning>

***

## Scopes and the endpoints they unlock

| Scope | Grants |
| - | - |
| `openid` | Verify Soundlink identity; required for `userinfo` |
| `email` | View the user's email address |
| `campaigns:read` | View campaigns |
| `wallet:read` | Read the organization wallet balance |
| `campaigns:write` | Create and manage wallet-funded campaigns |
| `metrics:read` | View campaign metrics |
| `soundlinks:read` | View self-serve soundlinks and their metrics |
| `soundlinks:write` | Create and archive self-serve soundlinks |
| `videos:write` | Import partner videos for Full Control creatives |

Request the minimum you need — the consent screen shows each scope to the user, and a shorter list converts better. `scope` values are space-separated, must be unique, and must fall within your client's registered `allowedScopes`.

<Note>
  `scopes_supported` in the [discovery document](/docs/set-up-soundlink-oauth#discovery) is the authoritative list — it currently returns `openid`, `email`, `campaigns:read`, `campaigns:write`, `metrics:read`, `videos:write`, `soundlinks:read`, `soundlinks:write`, and `wallet:read`.
</Note>

### No token — client authentication only

| Endpoint | Notes |
| - | - |
| `GET /.well-known/oauth-authorization-server` | Public. No credentials. |
| `GET /api/v1/oauth/authorize` | Starts consent. `client_id` + PKCE. |
| `POST /api/v1/oauth/token` | `client_id` + `client_secret` (or PKCE verifier). |
| `POST /api/v1/oauth/grants/revoke` | `client_id` + `client_secret` + `grant_id`. |

### Any scope

| Endpoint | Notes |
| - | - |
| `GET /v1/ping` | Verifies connectivity and authentication. Any valid token passes. |

### `openid`

| Endpoint | Notes |
| - | - |
| `GET /api/v1/oauth/userinfo` | **Authorization Code tokens only.** A Client Credentials token gets `403 access_denied`. |

### `email`

Adds the `email` claim to the `userinfo` response. Unlocks no endpoint of its own.

### `campaigns:read`

| Endpoint | Notes |
| - | - |
| `GET /v1/strategies` | See reference |
| `GET /v1/campaigns` | Paginated, `createdAt desc` by default. |
| `GET /v1/campaigns/{campaignId}` | Adds `strategyType`. `404` when not owned. |
| `GET /v1/campaigns/{campaignId}/tiers` | See reference |

### `wallet:read`

Returns the organization wallet. `available` is the amount `POST /v1/campaigns` checks before reserving. When create returns `402 insufficient_credit`, `error.details.available` matches this field. `reserved` is the sum of active reservations. An organization with no credits row returns zeros and `updatedAt: null`. Existing keys do not have this scope until it is granted.

| Endpoint | Notes |
| - | - |
| `GET /v1/wallet` | `balance`, `available`, `reserved`, `currency`, `updatedAt`. |

### `campaigns:write`

Wallet-funded campaigns only. Same endpoints and rules as the API key scope of the same name — see [Creating campaigns](/docs/creating-campaigns) and [Managing campaigns](/docs/managing-campaigns).

| Endpoint | Notes |
| - | - |
| `POST /v1/campaigns` | Create a campaign. Requires an `Idempotency-Key` header. |
| `PATCH /v1/campaigns/{campaignId}/tiers` | Enable/disable tiers and reallocate budget shares. |
| `POST /v1/campaigns/{campaignId}/stop` | Stop delivery. |
| `PUT /v1/campaigns/{campaignId}/auto-renew` | Toggle auto-renew. |
| `POST /v1/campaigns/{campaignId}/budget/increase` | Increase daily budget. |
| `POST /v1/campaigns/{campaignId}/budget/decrease` | Decrease daily budget. |

### `metrics:read`

| Endpoint | Notes |
| - | - |
| `GET /v1/campaigns/{campaignId}/metrics/overview` | Totals for a date range. |
| `GET /v1/campaigns/{campaignId}/metrics/creatives` | Lifetime creatives, grouped by video. |
| `GET /v1/campaigns/{campaignId}/metrics/breakdown` | Per-day × country (`campaign_country_daily`). |
| `GET /v1/campaigns/{campaignId}/metrics/breakdown/export` | JSONL bulk for breakdown. |
| `GET /v1/campaigns/{campaignId}/metrics/engagement` | Per-day × track (`campaign_engagement_daily`). |
| `GET /v1/campaigns/{campaignId}/metrics/engagement/export` | JSONL bulk for engagement. |

### `soundlinks:read`

Self-serve soundlinks (no Soundlink-funded campaign). Same routes as the API key scope — see [Syncing soundlinks](/docs/soundlinks) and [Soundlink metrics](/docs/soundlink-metrics). `metrics:read` does not unlock these routes.

| Endpoint | Notes |
| - | - |
| `GET /v1/soundlinks` | Paginated catalog. Campaign-linked soundlinks are omitted. |
| `GET /v1/soundlinks/{soundlinkId}` | Detail. `404` when not owned or campaign-linked. |
| `GET /v1/soundlinks/{soundlinkId}/metrics/overview` | Period totals. Maps to Insights **Overview**. |
| `GET /v1/soundlinks/{soundlinkId}/metrics/breakdown` | Per-day × country (`soundlink_country_daily`). |
| `GET /v1/soundlinks/{soundlinkId}/metrics/breakdown/export` | JSONL bulk for breakdown. |
| `GET /v1/soundlinks/{soundlinkId}/metrics/engagement` | Per-day × track (`soundlink_engagement_daily`). |
| `GET /v1/soundlinks/{soundlinkId}/metrics/engagement/export` | JSONL bulk for engagement. |

### `soundlinks:write`

Same endpoints and rules as the API key scope of the same name — see [Creating soundlinks](/docs/creating-soundlinks).

| Endpoint | Notes |
| - | - |
| `POST /v1/soundlinks` | Create a soundlink. Requires an `Idempotency-Key` header. Does not create a wallet campaign. |
| `DELETE /v1/soundlinks/{soundlinkId}` | Archive. No `Idempotency-Key`. |

### `videos:write`

Same endpoints and rate limit as the API key scope of the same name — see [Importing videos](/docs/importing-videos).

| Endpoint | Notes |
| - | - |
| `POST /v1/videos/import` | Import a partner video. Separate rate limit (default 20 imports/hour per API key or OAuth grant). |
| `GET /v1/videos/import/{id}` | Check import status. |

<Card title="API reference" icon="book-open" href="/docs/api-reference">
  Request parameters, response schemas and error codes for every endpoint above.
</Card>

<Note>
  The reference documents these endpoints with `Authorization: Bearer sk_...` authentication. The
  same endpoints accept an OAuth bearer token too — send `Authorization: Bearer <access_token>`
  in place of the API key, and the scopes above apply.
</Note>

***

## Errors and rate limits

All OAuth errors return `{ "error": "<code>" }`.

| Code | Typical status | Cause |
| - | - | - |
| `invalid_request` | `400` | Missing/malformed parameter, or a JSON body where form encoding is required |
| `invalid_client` | `401` | `client_id`/`client_secret` rejected |
| `invalid_grant` | `400` | Code invalid, reused or expired; or no active grant |
| `unauthorized_client` | `400` | Client not allowed to use this grant type |
| `unsupported_grant_type` | `400` | `grant_type` is not one of the two supported values |
| `unsupported_response_type` | `400` | `response_type` is not `code` |
| `invalid_scope` | `400` | Scope unknown, duplicated, or outside what was granted |
| `invalid_token` | `401` | Bearer token missing, malformed or expired |
| `insufficient_scope` | `403` | Token lacks a required scope |
| `access_denied` | `403` (or redirect) | User declined; or wrong grant type |
| `temporarily_unavailable` | `429` | Rate limited |

### Resource errors have a different shape

The `/v1/` resource endpoints wrap the error in an object and add a request id, rather than returning the flat `{ "error": "<code>" }` the OAuth endpoints use. Branch on the nested `error.code`:

```json 401 theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "error": {
    "code": "invalid_token",
    "message": "OAuth access token is invalid or expired."
  },
  "meta": { "requestId": "550e8400-e29b-41d4-a716-446655440000" }
}
```

`400` (bad query), `401`, `403`, `404`, `429` and `500` are all possible. Campaign `404`s use `campaign_not_found` (missing or another organization's campaign). Soundlink `404`s use `not_found` (missing, another organization, or linked to a campaign).

### Authorize error behaviour

Two distinct behaviours, which affect how you handle failures:

<Tabs>
  <Tab title="Redirected errors">
    When `client_id` and `redirect_uri` are both valid, validation failures redirect to your callback with `?error=<code>&state=<state>`. Handle these in your callback route.
  </Tab>

  <Tab title="Direct errors">
    When `client_id` is unknown or revoked, or `redirect_uri` is missing or unregistered, Soundlink responds `400` **without redirecting** — it will not send a user to an unverified URL. These surface as a browser error page, so catch them in staging.
  </Tab>
</Tabs>

### Rate limits

`/authorize` and `/token` are rate limited per client; exceeding the limit returns `429` with `temporarily_unavailable` and a `Retry-After` header. Respect it with backoff. Caching Client Credentials tokens for their full hour (see [Client Credentials](/docs/oauth-client-credentials)) keeps normal usage far below any limit.

***

## Security checklist

<AccordionGroup>
  <Accordion title="Always send and verify state" icon="shield-check">
    Generate an unguessable `state` per authorization request, bind it to the user's session, and reject any callback whose `state` does not match. This is your CSRF defence on the redirect.
  </Accordion>

  <Accordion title="Keep the PKCE verifier server-side" icon="lock">
    The `code_verifier` must never reach the browser, a URL, or client-side storage. Hold it in a signed httpOnly cookie or server session with a short TTL, and consume it once.
  </Accordion>

  <Accordion title="Store the client secret in a secrets manager" icon="vault">
    Never commit it, never ship it in a client bundle, never expose it through an API response. Load it from the environment at runtime and rotate by requesting a new client. Exchange codes and request tokens only from your backend.
  </Accordion>

  <Accordion title="Register exact redirect URIs" icon="link">
    Use the fewest possible, all HTTPS, each a complete URL. Never build a redirect target from user input or an open redirector, and do not rely on prefix matching — none is performed.
  </Accordion>

  <Accordion title="Redact tokens and secrets from logs" icon="eye-slash">
    Treat `code`, `code_verifier`, `client_secret`, `access_token` and full `Authorization` headers as secrets. Log the token's `exp` and `organization_id` if you need traceability, not the token. Watch for accidental capture in request-body logs, error reporters and APM traces — a `400` from the token endpoint often carries the whole form body.
  </Accordion>

  <Accordion title="Do not trust unverified token claims" icon="triangle-exclamation">
    No JWKS is published, so token signatures cannot be verified by clients. Decode claims only to read values from a token you just received over TLS, and never as an authorization decision in your own system.
  </Accordion>
</AccordionGroup>

## Next

Back to the [OAuth overview](/docs/set-up-soundlink-oauth) for discovery, client onboarding, and the reference app.


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