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

# Introduction

> OnlyTraffic Studio API: manage your accounts, agencies, CPL/CPC/RevShare/Swap orders, subscribers, and transactions programmatically.

The OnlyTraffic Studio API lets you read and act on your Studio data: subscribers, transactions, orders, campaigns and more. Use it to build integrations, dashboards and automated workflows.

## Base URL

```text theme={null}
https://studio-api.onlytraffic.com/api/external/v1
```

<CardGroup cols={3}>
  <Card title="Authentication" icon="key" href="/api/authentication">
    Get your API key and start making requests.
  </Card>

  <Card title="Pagination" icon="list-ol" href="#pagination">
    Page-based and cursor-based listings.
  </Card>

  <Card title="Rate Limits" icon="gauge-high" href="#rate-limits">
    Per-key, per-tier hourly and burst caps.
  </Card>
</CardGroup>

## Authentication

All requests require the `X-API-Key` header. Create your API key in the [Studio Dashboard](https://studio.onlytraffic.com/api).

```bash theme={null}
curl https://studio-api.onlytraffic.com/api/external/v1/subscribers \
  -H "X-API-Key: your-api-key-here"
```

<Info>See the [Authentication](/api/authentication) page for error codes and detailed setup.</Info>

## Rate Limits

Limits are per API key, rolling window, and **tiered by request kind**:

| Tier       | Per hour | Per minute (burst) | Applies to                                    |
| ---------- | -------- | ------------------ | --------------------------------------------- |
| `read`     | 1,000    | 60                 | `GET` requests                                |
| `write`    | 100      | 20                 | `POST` / `PUT` / `PATCH` / `DELETE`           |
| `upstream` | 10       | 5                  | A small set of endpoints with stricter limits |

Both windows are enforced together. The hour quota is the long-window cap; the per-minute cap is burst protection on top of it.

### Response headers (on every response, success or error)

| Header                  | Description                                     |
| ----------------------- | ----------------------------------------------- |
| `X-RateLimit-Limit`     | The active tier's hourly cap                    |
| `X-RateLimit-Remaining` | Requests left in the current hour for this tier |
| `X-RateLimit-Reset`     | Unix timestamp when the hour window resets      |
| `X-RateLimit-Tier`      | Active tier (`read`, `write`, or `upstream`)    |

Example:

```http theme={null}
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 947
X-RateLimit-Reset: 1746453296
X-RateLimit-Tier: read
```

### When you go over

`429 Too Many Requests` with the standard error envelope plus a `Retry-After` header (seconds to wait):

```json theme={null}
{
  "success": false,
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Try again in 37 seconds.",
  "retry_after": 37
}
```

Best practice: read `X-RateLimit-Remaining` after each call and back off when it gets low, instead of waiting for the 429.

## Pagination

The API uses **two pagination styles** depending on the dataset size and access pattern.

### Page-based (default for orders / campaigns)

Endpoints with bounded result sets (`cpl/orders`, `cpc/orders`, `revshare/campaigns`, etc.) use page-based pagination. The default page size is 50, max 100. Pass `?page=N` to navigate.

```json theme={null}
{
  "pagination": {
    "page": 1,
    "total": 150,
    "total_pages": 2
  }
}
```

### Cursor-based (high-volume feeds)

Endpoints over high-volume tables (`subscribers`, `transactions`) use cursor pagination. The first call returns the first page plus a `next_cursor` token; pass that token back as `?after=` to continue.

```http theme={null}
GET /api/external/v1/transactions?limit=50
```

```json theme={null}
{
  "data": [/* 50 rows, newest first */],
  "pagination": {
    "next_cursor": "eyJ0cyI6MTc0NjQ1MzI5NiwiaWQiOjk4NzY1fQ==",
    "has_next": true,
    "limit": 50
  }
}
```

Continue:

```http theme={null}
GET /api/external/v1/transactions?limit=50&after=eyJ0cyI6MTc0NjQ1MzI5NiwiaWQiOjk4NzY1fQ==
```

When `has_next` is `false` (or `next_cursor` is `null`), you've reached the end.

**Why cursor on these endpoints**

* Stable ordering even when new rows are inserted between calls (page-based would shift).
* No `total` count: cursor feeds optimize for partial reads, not row counts.
* O(1) per page regardless of how deep you've scrolled.

Treat the cursor as an opaque string, don't parse it.

**Sort direction**

Pass `?sort=<field>_asc` to walk oldest to newest. Default is `<field>_desc` (newest first). The cursor is bound to the direction it was issued for; switching mid-pagination requires restarting.

## Retries & idempotency

### Concurrent mutations

Some write endpoints are serialized per account: a second request hitting the same resource while the first is still in flight returns `429 action_in_progress`. Wait for the original to complete, then re-poll the corresponding `GET` to see the result. Don't blind-retry through the in-flight window.

### Safe to retry

| Code                      | Why retryable                                                                                                   |
| ------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `502`                     | Upstream temporarily unavailable. Retry with back-off.                                                          |
| `429 rate_limit_exceeded` | Body has `retry_after` (seconds), header has `Retry-After`. Wait the suggested duration before retrying.        |
| Network timeout           | Retryable for idempotent methods (`GET`, `DELETE`). For non-idempotent methods, re-check state via `GET` first. |

### NOT safe to blind-retry

`400`, `404`, `409`, `422`, `426` are deterministic. The same request will produce the same error. Investigate (read `error` and `details`), fix the input, and only then resubmit.

### Recommended back-off

Exponential, capped: 1s, 2s, 4s, 8s, 16s, 30s. If the response carries `retry_after` (body) or `Retry-After` (header), honour the server-suggested wait, it beats local back-off.

### Example: handling 429

```javascript JavaScript theme={null}
async function fetchWithRetry(url, options) {
  const res = await fetch(url, options);
  if (res.status !== 429) return res;
  const retryAfter = Number(res.headers.get('Retry-After')) || 5;
  await new Promise(r => setTimeout(r, retryAfter * 1000));
  return fetch(url, options); // retry once
}
```

## Response Format

All responses follow a consistent structure:

<CodeGroup>
  ```json Success theme={null}
  {
    "success": true,
    "data": [ ... ],
    "pagination": { ... }
  }
  ```

  ```json Error theme={null}
  {
    "success": false,
    "error": "error_code",
    "message": "Human-readable message"
  }
  ```
</CodeGroup>

## Write responses & schema evolution

### Writes return id-only

Successful `POST` / `PUT` / `PATCH` / `DELETE` responses carry just the resource id (and `success: true`), not the full record:

```json theme={null}
{
  "success": true,
  "data": { "order_id": "cplo_xxxxxxx" }
}
```

To read the post-write state, call the matching `GET` endpoint. The list / detail endpoints are the single source of truth for the response shape.

Two exceptions:

* **Image upload endpoints** also return `thumbnail_url` (a 3-day signed URL) so you can render the just-saved image without a follow-up call. Originals stay behind the regular `GET`.
* **One-time secrets** (the plaintext API key returned on key creation, verification tokens) appear in `data` for that single response and are documented as "shown once, never again".

### Schema evolution policy

We may add fields to existing responses **without notice**. Consumers MUST ignore unknown fields rather than fail-on-extra.

`v1` is still young and may change without warning as we fix and refine it. Keep an eye on this doc — if something breaks for you, check here first.

Practically:

* Configure your parser to allow extra fields rather than reject them.
* Validate inputs strictly, treat outputs liberally.
