Using OAuth2
OAuth 2.0 / OIDC sign-in for apps that sign a person in — the short version first, then discovery, client registration, the login flow and token usage.
Use OAuth2 when your app signs a person in and acts on their behalf. If no human is at the keyboard — a script, a cron job, an MCP client — use an API key instead. Same endpoints either way.
The short version
For a typical app, the whole flow is four moves:
-
Register a client once at
https://auth.competitortracker.io/oauth/registerand keep theclient_id. -
Run the login flow (PKCE): send the user to
/oauth/authorize, they sign in with an emailed code or link, you exchange the returned code at/oauth/tokenfor anaccess_tokenand arefresh_token. -
Call the API with the token on every request:
Authorization: Bearer <access_token> -
On a
401, refresh the token at/oauth/tokenand retry once.
What the token is allowed to do is set by its scopes and the member's role. The rest of this page is the detail behind those four moves.
How it fits together
Authentication for /v1/* follows OAuth 2.0 with PKCE and OpenID Connect. There is one authorization server (auth.competitortracker.io) and one or more resource servers (api.competitortracker.io, mcp.competitortracker.io). Tokens are short-lived ES256-signed JWTs (15 min) plus opaque refresh tokens (30 days, rotated on use). Our lead detective C. T. Lucky walks you through it.
Discovery
Two .well-known documents drive every client. Resource servers point at the AS via RFC 9728 protected-resource metadata. The AS itself publishes RFC 8414 metadata and OIDC discovery.
GET https://api.competitortracker.io/.well-known/oauth-protected-resource
GET https://auth.competitortracker.io/.well-known/oauth-authorization-server
GET https://auth.competitortracker.io/.well-known/openid-configuration
GET https://auth.competitortracker.io/oauth/jwks.jsonThe AS metadata document tells you the authorization, token, registration and revocation endpoints, the supported scopes and the supported PKCE methods (S256 only).
Dynamic Client Registration (RFC 7591)
Register a client at any time:
POST https://auth.competitortracker.io/oauth/register
Content-Type: application/json
{
"client_name": "My App",
"redirect_uris": ["https://app.example/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "openid email competitors:read",
"audience": ["https://api.competitortracker.io"]
}The response carries a client_id (and a client_secret for confidential auth methods). Public PKCE clients — browsers, mobile apps, MCP — use token_endpoint_auth_method: "none".
The login flow
- The user lands on
/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&scope=openid+email&audience=https://api.... If they're already authenticated againstauth.competitortracker.iovia the auth-domain session cookie, they jump straight to consent. Otherwise the authorization server renders the email-entry form. - User submits an email. The authorization server mints a 6-character code (
A-Zminus look-alikes,2-9) and a magic-link token, persists both as HMAC hashes and emails them. The email contains both. Either one completes the login. - User either types the code on the verify form (
POST /oauth/authorize/verify) or clicks the link (GET /oauth/authorize/callback). Both paths land on the sameresolveUserAndOrgflow: find-or-create the user, accept any pending invite, bootstrap a personal org if no membership exists. - Consent screen lists the requested scopes (with descriptions from the catalog) and, when the user has 2+ active memberships, an org picker. The selected org becomes the
org_idclaim on the access token. - The authorization server mints an authorization code and redirects to the client's
redirect_uri. - The client exchanges the code at
/oauth/token(PKCE verifier required) and receives{ access_token, refresh_token, expires_in, id_token }in the body. No tokens in cookies: the client decides storage.
Scopes and roles
The scopes you request at registration and login, and the role of the member who signs in, together decide what each token may do. The catalog and the rules live in one place: Scopes and permissions. The scope parameter on the authorize request must stay within the set the client registered.
Using the access token
Every request to /v1/* carries:
Authorization: Bearer <access_token>The resource server fetches the AS's JWKS, validates the signature, enforces aud (resource indicator) and iss, and pulls sub and org_id into the request context. Tokens whose jti has been revoked are rejected.
Refresh
The recommended pattern: on 401, call /oauth/token with grant_type=refresh_token and retry the original request once. Refresh rotation is mandatory. The previous refresh token is revoked atomically when a new one is issued.
POST https://auth.competitortracker.io/oauth/token
Content-Type: application/json
{
"grant_type": "refresh_token",
"client_id": "<client_id>",
"refresh_token": "<refresh_token>"
}Revocation (RFC 7009)
POST https://auth.competitortracker.io/oauth/revoke
Content-Type: application/json
{ "client_id": "<client_id>", "token": "<token>", "token_type_hint": "refresh_token" }Refresh tokens are revoked server-side. Access tokens are added to a revocation list keyed by jti until their natural expiry. RFC 7009 guarantees a 200 response regardless of whether the token existed.
Logout
POST https://auth.competitortracker.io/logout
Content-Type: application/json
{ "refresh_token": "<token>", "access_token": "<token>" }Revokes both tokens, clears the auth-domain session cookie and records an audit-log entry.
Error envelope
Auth failures use the common envelope:
| Code | HTTP | When |
|---|---|---|
AccessTokenMissing | 401 | The Authorization: Bearer <token> header is absent. |
AccessTokenInvalid | 401 | Signature, audience, issuer or expiry check failed. |
AccessTokenRevoked | 401 | The jti has been revoked. |
RefreshTokenInvalid | 401 | Token not found. |
RefreshTokenRevoked | 401 | Token already used or explicitly revoked. |
RefreshTokenExpired | 401 | Past expires_at. |
OAuthInvalidGrant | 400 | Code/PKCE mismatch, redirect_uri mismatch. |
OAuthInvalidScope | 400 | Requested scope outside the registered set. |
LoginCodeInvalid | 401 | Wrong 6-char code. |
LoginCodeExpired | 401 | TTL elapsed. |
LoginCodeLockedOut | 429 | Too many failed attempts on the code. |
LoginRateLimited | 429 | Too many sends from the same email or IP. |
MagicLinkInvalid | 401 | Link token not found. |
MagicLinkExpired | 401 | TTL elapsed. |
InviteInvalid | 404 | Invite token not found. |
InviteExpired | 410 | Past expires_at. |
InviteAlreadyAccepted | 409 | Already accepted by the same or different user. |
InviteEmailMismatch | 403 | The signed-in email doesn't match the invitee. |
InviteCapReached | 409 | The organization holds the maximum of ten pending invitations. |
UserNotFound | 404 | The signed-in user no longer exists. |
SignupAlreadyComplete | 409 | The user has already completed the one-shot signup form. |
DisposableEmailNotAllowed | 422 | The invitation recipient address is on the disposable-mailbox excludelist. Free-mail providers are fine. |
InsufficientRole | 403 | The caller's organization role is too low for the requested endpoint. |
OwnershipTransferRequired | 409 | The caller is the sole owner of the org and cannot leave without transferring ownership first. |
NotAnOrgMember | 404 | The supplied user is not an active member of the organization (e.g., the target of an ownership transfer). |
OrgMemberNotFound | 404 | The supplied user is not an active member of the current organization (role-change target). |
OwnerRoleImmutable | 403 | Attempted to change the owner's role through PATCH /v1/org/members/{userId} — use transfer-ownership. |
InsufficientCoinBalance | 402 | Subscribing another paid competitor would push paid count past the current coin balance. |
InvalidGrantAmount | 422 | An admin coin grant carried zero or a negative amount. |
OwnAppDomainImmutable | 409 | The organization's own-app domain is already set and cannot be changed. |
OwnAppDomainEmailMismatch | 403 | The submitted domain doesn't match the verified email-domain on the calling user's account. |
PersonalEmailDomainNotAllowed | 422 | The submitted own-app domain is a personal-mail or disposable-mail provider. |
Getting started
How Competitor Tracker & Co. works end to end, then your first API request in under ten minutes: authenticate, subscribe to a competitor, read its changes.
Using API keys
Machine credentials for scripts, jobs and MCP — minting, the X-API-Key header, scopes, expiry, revoke, regenerate, rate limits and the per-key usage log.