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, RFC 7636). The flow:
- You create an API client in the dashboard and receive a
client_id+client_secret. - Your app generates a random
code_verifierand its SHA-256code_challenge. - You open
/oauth/authorizein a browser, sign in if needed, pick which project the token should belong to, and approve the requested scopes. - The browser redirects to your app with a one-time
authorization_code. - Your app exchanges the code (plus the verifier) for an
access_token+refresh_token. - Your app calls the API with
Authorization: Bearer <access_token>.
The three things you need:
- A
client_idandclient_secretfrom the dashboard. - A fresh
code_verifier/code_challengepair per authorization, generated by your app. - The set of scopes you want the token to carry — see 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:
- An active Attribution plan that includes the API Access feature and you need to be the project owner. If you don't see the API Access card in Settings → Security, upgrade your plan first.
- Comfort running
curlfrom 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.
- Sign in to Attribution.
- Open Settings → Security.
- 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.
- Click Create API Client.
- Attribution shows your
client_idandclient_secretexactly 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:
# 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.
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:
# 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):
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:
{
"access_token": "eyJraWQiOi…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "5b1f…",
"scope": "filters:read reports:read"
}Alternative: you can pass
client_idandclient_secretas 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:
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.
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. 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).
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 and settings |
reports | reports:read, reports:write | Run reports; write covers saving project views |
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)
/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)
/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 | Your subscription doesn't include API access. |
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, upgrade your plan.
Security checklist
- Treat
client_secretlike 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_secretif 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_secretto the browser. Server-side web apps are fine; pure browser SPAs need a backend to hold the secret and proxy API calls.
Updated about 1 hour ago
