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:

  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.

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 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:

# 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_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:

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.

ResourceScopesWhat it lets you do
accountaccount:read, account:writeRead or update account and team settings
billingbilling:read, billing:writeRead invoices or update payment method
filtersfilters:read, filters:writeList, create, or modify filters
integrationsintegrations:read, integrations:writeList integrations or manage their settings
projectproject:read, project:writeRead or update project metadata and settings
reportsreports:read, reports:writeRun reports; write covers saving project views
visitorsvisitors:readList 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)

HTTPerrorWhen
302 → redirect_uriaccess_deniedThe user refused on the consent screen.
302 → redirect_uriinvalid_scopeOne of the requested scopes is unknown.
400invalid_requestA required parameter (client_id, redirect_uri, code_challenge, …) is missing or malformed.
400invalid_clientclient_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)

HTTPerrorWhen
400invalid_requestA required parameter is missing or malformed.
400invalid_grantCode or refresh token is unknown, expired, already used, or doesn't match the client.
401invalid_clientclient_secret doesn't match the stored value (Basic header or form body).
400unsupported_grant_typegrant_type is not authorization_code or refresh_token.
400invalid_scopeRequested 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

HTTPerrorWhen
401invalid_tokenToken expired, signature failed, or audience mismatch.
403insufficient_scopeThe token doesn't carry the scope this endpoint requires.
403api_access_requiredYour 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_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.