---
updatedAt: 2026-10-13T22:47:23.000Z
agentTools:
  projectIndex: https://docs.attributionapp.com/llms.txt
---

# API Authentication

Call the Attribution API from your own apps using OAuth 2.0 with PKCE. Create an API client in the dashboard, then exchange credentials for short-lived access tokens you can use with curl or any HTTP client.

## Overview

The Attribution API is organized around REST. It accepts form-encoded request bodies, returns
JSON-encoded responses, and uses standard HTTP response codes, verbs, and OAuth 2.0 Bearer
authentication. Every endpoint the dashboard calls is available to your apps and scripts under
the same auth.

**Base API URL**: `https://api.attributionapp.com`. The dashboard at `dashboard.attributionapp.com`
and the API are backed by the same service and share a single OAuth authorization server —
authorize at `https://dashboard.attributionapp.com/oauth/authorize`, exchange tokens at
`https://api.attributionapp.com/oauth/token`.

**Versioning**: some paths are prefixed with their version (e.g. `/v2/filters/tree`). Breaking
changes will introduce a new prefix; older versions keep working.

**Testing against non-production data**: there is no separate sandbox URL. Instead, the consent
screen during authorization asks the user to pick which project the access token will be scoped
to. If your account has a staging or dedicated test project, choose that one at consent time —
the resulting token only sees data from the project you selected. This is the recommended way
to exercise the API safely without touching production data.

## How authentication works

Attribution uses **OAuth 2.0 Authorization Code with PKCE**
([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749),
[RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)). The flow:

1. You create an API client in the dashboard and receive a `client_id` + `client_secret`.
2. Your app generates a random `code_verifier` and its SHA-256 `code_challenge`.
3. You open `/oauth/authorize` in a browser, sign in if needed, pick which project the token
   should belong to, and approve the requested scopes.
4. The browser redirects to your app with a one-time `authorization_code`.
5. Your app exchanges the code (plus the verifier) for an `access_token` + `refresh_token`.
6. Your app calls the API with `Authorization: Bearer <access_token>`.

**The three things you need**:

* A `client_id` and `client_secret` from the dashboard.
* A fresh `code_verifier` / `code_challenge` pair per authorization, generated by your app.
* The set of scopes you want the token to carry — see [Scopes](#scopes).

**Token lifetimes**:

* **Access token** — 1 hour, sent as `Authorization: Bearer ...` on every API call.
* **Refresh token** — 30 days, single-use. Each refresh rotates both tokens; the old refresh
  token becomes invalid the moment a new pair is issued.

## Before you start

You need:

* **API Access switched on for your project, and you need to be the project owner.** If you don't see the
  **API Access** card in *Settings → Security*, ask us to enable it.
* Comfort running `curl` from a terminal.

## 1. Create an API client

> **Heads up:** every account can only have one API client at a time. To rotate, revoke the
> existing one and create a new one. Revoking immediately invalidates all outstanding access
> and refresh tokens — apps will need to re-authorize.

1. Sign in to Attribution.
2. Open **Settings → Security**.
3. Scroll to **API Access** and fill out:
   * **Client name** — anything that helps you recognise the app later (e.g. *Weekly
     Reporting Pipeline*).
   * **Redirect URIs** — the local URL your app will listen on during authorization. For
     command-line tools this is typically `http://localhost:8080/callback`. You can list
     several, comma-separated.
4. Click **Create API Client**.
5. Attribution shows your **`client_id`** and **`client_secret`** *exactly once*. Copy both
   into your secrets store (1Password, AWS Secrets Manager, env vars in CI) immediately —
   the secret is **not recoverable** if you lose it.

## 2. Authorize your app

### 2.1 Generate the PKCE pair

PKCE protects the authorization code in transit. Generate a fresh `code_verifier` and its
SHA-256 `code_challenge`:

```bash
# 64 random bytes, url-safe base64, no padding
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=' | tr '/+' '_-')

# SHA-256 of the verifier, url-safe base64, no padding
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '\n=' | tr '/+' '_-')
```

### 2.2 Open the authorization URL

Print the URL and open it in your browser. You'll be asked to sign in (if you aren't already),
**pick which project the token will be scoped to**, and approve the requested scopes.

> **Tip:** if you have a dedicated staging or test project, select it on the consent screen —
> the access token will only see data from that project, leaving production untouched.

```bash
CLIENT_ID="atb_…paste your client id here…"
CLIENT_SECRET="…your client secret…"
REDIRECT_URI="http://localhost:8080/callback"
SCOPES="filters:read reports:read"
STATE=$(openssl rand -hex 16)

echo "https://dashboard.attributionapp.com/oauth/authorize?\
client_id=${CLIENT_ID}&\
redirect_uri=$(printf %s "$REDIRECT_URI" | jq -sRr @uri)&\
response_type=code&\
scope=$(printf %s "$SCOPES" | jq -sRr @uri)&\
state=${STATE}&\
code_challenge=${CODE_CHALLENGE}&\
code_challenge_method=S256"
```

After you approve, your browser is redirected to:

```
http://localhost:8080/callback?code=<authorization_code>&state=<the same state>
```

The simplest way to capture the code during a one-off test is to listen on the port with `nc`:

```bash
# In another terminal, before clicking Approve in the browser:
nc -l 8080
# Once the callback hits, copy the `code` value from the GET line.
```

In production, your app should run a small HTTP server on the redirect URI (`http.server`
in Python, `express` in Node.js, `Net::HTTPServer` in Ruby).

## 3. Exchange the code for tokens

Send client credentials via HTTP Basic auth (RFC 6749 §2.3.1, the recommended method):

```bash
CODE="…the authorization_code from the callback…"

curl -sS https://api.attributionapp.com/oauth/token \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d grant_type=authorization_code \
  -d "code=${CODE}" \
  -d "code_verifier=${CODE_VERIFIER}" \
  -d "redirect_uri=${REDIRECT_URI}"
```

Response:

```json
{
  "access_token":  "eyJraWQiOi…",
  "token_type":    "Bearer",
  "expires_in":    3600,
  "refresh_token": "5b1f…",
  "scope":         "filters:read reports:read"
}
```

> **Alternative:** you can pass `client_id` and `client_secret` as form-body fields instead of
> the Basic-auth header (RFC 6749 §2.3.1, `client_secret_post`). Either works.

The authorization code is single-use and expires within a few minutes — exchange it
immediately after the callback.

## 4. Call the API

Send the access token in the `Authorization: Bearer` header:

```bash
ACCESS_TOKEN="…the access_token from the response above…"

curl -sS https://api.attributionapp.com/v2/filters/tree \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"
```

Endpoints check the scopes embedded in your token. If a request returns
`403 insufficient_scope`, edit the `scope` parameter in your authorization URL (step 2.2) and
re-authorize.

## 5. Refresh the access token

Access tokens are valid for 1 hour. Refresh tokens are valid for 30 days and **rotate on every
use** — the old refresh token becomes invalid the moment a new pair is issued.

```bash
REFRESH_TOKEN="…the refresh_token from earlier…"

curl -sS https://api.attributionapp.com/oauth/token \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d grant_type=refresh_token \
  -d "refresh_token=${REFRESH_TOKEN}"
```

The response shape matches step 3. Persist the **new** `refresh_token` immediately; the
previous one is dead.

If a refresh ever returns `400 invalid_grant`, your refresh token was either revoked, used
twice, or has expired — your app must go back to step 2 and re-authorize via the browser.

## 6. Revoke a token (optional)

[RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009). Invalidates a specific refresh or
access token without revoking the entire client. Useful when:

* An app is being decommissioned and you want its stored refresh token to stop working
  immediately.
* A token was leaked but the client itself is still trusted (rotating the secret is overkill).

```bash
curl -sS https://api.attributionapp.com/oauth/revoke \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d "token=${REFRESH_TOKEN}" \
  -d "token_type_hint=refresh_token"
```

Returns `200` with an empty body on success — including for already-revoked or unknown tokens,
per RFC 7009, to avoid leaking token-existence information.

> **In-dashboard revocation:** you can also revoke an entire OAuth session interactively from
> **Settings → Security → Active Sessions**. This lists every active session — dashboard
> logins, MCP connections, and API clients — and lets you sign each one out individually.

## Scopes

Pass a space-separated list to the `scope` parameter in step 2.2. Every scope your app
requests is shown to the user on the consent screen; request only what you need.

| Resource       | Scopes                                    | What it lets you do                                       |
| -------------- | ----------------------------------------- | --------------------------------------------------------- |
| `account`      | `account:read`, `account:write`           | Read or update account and team settings                  |
| `billing`      | `billing:read`, `billing:write`           | Read invoices or update payment method                    |
| `filters`      | `filters:read`, `filters:write`           | List, create, or modify filters                           |
| `integrations` | `integrations:read`, `integrations:write` | List integrations or manage their settings                |
| `project`      | `project:read`, `project:write`           | Read or update project metadata, settings and saved views |
| `reports`      | `reports:read`, `reports:write`           | Run reports; `write` requests, deletes or refreshes them  |
| `visitors`     | `visitors:read`                           | List and inspect visitors and companies                   |

Widening scopes later requires a fresh authorization — re-run step 2 with the updated
`scope` parameter.

## Errors

Errors come back as a JSON body with an `error` field — and, on the authorization endpoint, in
the redirect query string per RFC 6749.

### Authorization (`/oauth/authorize`)

| HTTP                | `error`           | When                                                                                             |
| ------------------- | ----------------- | ------------------------------------------------------------------------------------------------ |
| 302 → redirect\_uri | `access_denied`   | The user refused on the consent screen.                                                          |
| 302 → redirect\_uri | `invalid_scope`   | One of the requested scopes is unknown.                                                          |
| 400                 | `invalid_request` | A required parameter (`client_id`, `redirect_uri`, `code_challenge`, …) is missing or malformed. |
| 400                 | `invalid_client`  | `client_id` doesn't match a known client, or `redirect_uri` is not on the client's whitelist.    |

**Recovery:** fix the URL and resend. `access_denied` means start over from step 2.

### Token exchange (`/oauth/token`)

| HTTP | `error`                  | When                                                                                  |
| ---- | ------------------------ | ------------------------------------------------------------------------------------- |
| 400  | `invalid_request`        | A required parameter is missing or malformed.                                         |
| 400  | `invalid_grant`          | Code or refresh token is unknown, expired, already used, or doesn't match the client. |
| 401  | `invalid_client`         | `client_secret` doesn't match the stored value (Basic header or form body).           |
| 400  | `unsupported_grant_type` | `grant_type` is not `authorization_code` or `refresh_token`.                          |
| 400  | `invalid_scope`          | Requested scope contains an unknown value.                                            |

**Recovery:** on `invalid_grant`, restart from step 2.1 with a fresh `code_verifier` and a fresh
browser approval — the previous code or refresh token cannot be revived.

### API calls

| HTTP | `error`               | When                                                      |
| ---- | --------------------- | --------------------------------------------------------- |
| 401  | `invalid_token`       | Token expired, signature failed, or audience mismatch.    |
| 403  | `insufficient_scope`  | The token doesn't carry the scope this endpoint requires. |
| 403  | `api_access_required` | API access is not enabled for this project.               |

**Recovery:** on `invalid_token`, refresh per step 5. On `insufficient_scope`, re-authorize per
step 2 with a wider `scope` list. On `api_access_required`, ask us to enable API access for the project.

## Security checklist

* Treat `client_secret` like a password: never commit it, never log it, never paste it into
  client-side code.
* Use HTTPS for every API call — the access token must never travel over plain HTTP.
* Store refresh tokens at rest the same way you store the secret. They grant a fresh access
  token without re-authorization.
* If you suspect a leak, revoke the client in the dashboard and create a new one. All
  outstanding access and refresh tokens become invalid immediately.
* **Rotate the `client_secret`** if a contributor leaves, even if no specific leak is known.
* **Use Settings → Security → Active Sessions** to interactively sign out any active OAuth
  session if you suspect a compromise — the fastest path when you can't immediately locate
  the leaked refresh token to revoke programmatically.
* **Never expose `client_secret` to the browser.** Server-side web apps are fine; pure browser
  SPAs need a backend to hold the secret and proxy API calls.