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

# OAuth authorization

> Implement the authorization-code flow with PKCE for a partner app.

Partner apps use the OAuth Authorization Code flow with PKCE, following OAuth 2.1 security practices. The app is a confidential client: use PKCE for every authorization and authenticate token and revocation requests with HTTP Basic.

The authorization server is `https://oauth.subtotal.com`. Its current endpoints and capabilities are published at:

```text theme={null}
https://oauth.subtotal.com/.well-known/oauth-authorization-server
```

| Purpose                    | Endpoint               | Authentication                                    |
| :------------------------- | :--------------------- | :------------------------------------------------ |
| Start authorization        | `GET /oauth/authorize` | Top-level browser navigation; no HTTP credentials |
| Exchange or refresh tokens | `POST /oauth/token`    | HTTP Basic with the Client ID and client secret   |
| Identify an installation   | `GET /oauth/me`        | Bearer access token                               |
| Revoke an installation     | `POST /oauth/revoke`   | HTTP Basic with the Client ID and client secret   |

## 1. Request authorization

Generate a high-entropy `state` value and an RFC 7636 PKCE verifier for each attempt. Save both in a short-lived, server-side install session.

```js theme={null}
import { createHash, randomBytes } from "node:crypto";

const base64url = (value) =>
  value.toString("base64url");

const state = base64url(randomBytes(32));
const codeVerifier = base64url(randomBytes(64));
const codeChallenge = base64url(
  createHash("sha256").update(codeVerifier).digest()
);
```

Redirect the user agent with a **top-level browser navigation**. Do not embed Subtotal authorization in an iframe.

```text theme={null}
GET https://oauth.subtotal.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_OAUTH_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fconnect.example.com%2Foauth%2Fsubtotal%2Fcallback
  &resource=https%3A%2F%2Fapi.subtotal.com
  &scope=connections%3Aread%20retailers%3Aread%20purchases%3Abrand_products
  &state=YOUR_RANDOM_STATE
  &code_challenge=YOUR_S256_CHALLENGE
  &code_challenge_method=S256
```

| Query parameter         | Required value or purpose                                                                                                                   |
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `response_type`         | Required. Use `code`.                                                                                                                       |
| `client_id`             | Required. The Client ID shown in **Created Apps**.                                                                                          |
| `redirect_uri`          | Required. One of the app's allow-listed redirect URIs. Matching is exact.                                                                   |
| `resource`              | Required. Use `https://api.subtotal.com` so the access token is valid for the Subtotal API.                                                 |
| `scope`                 | Optional. Omit to request the full set, or send `connections:read retailers:read purchases:brand_products` in any order, each exactly once. |
| `state`                 | Required. A high-entropy value unique to this authorization attempt. Validate it on return.                                                 |
| `code_challenge`        | Required. The base64url-encoded SHA-256 digest of the PKCE verifier.                                                                        |
| `code_challenge_method` | Required. Use `S256`.                                                                                                                       |

If a user belongs to multiple dashboard teams, Subtotal asks them to choose which team will grant access. The token response's `subtotal_client_id` identifies the selected team.

Partner apps do not negotiate permissions. Requests for a subset, an additional scope, or a duplicated scope value are rejected.

## 2. Handle the callback

Subtotal redirects the browser to the allow-listed URI after the brand approves, denies, or encounters an authorization error.

```text theme={null}
https://connect.example.com/oauth/subtotal/callback
  ?code=AUTHORIZATION_CODE
  &state=YOUR_RANDOM_STATE
```

| Query parameter | When present                                                                                           |
| :-------------- | :----------------------------------------------------------------------------------------------------- |
| `code`          | Approval. A temporary authorization code that expires after 10 minutes and can be exchanged only once. |
| `state`         | Every redirect. The original value from the authorization request.                                     |
| `error`         | Denial or authorization failure. An OAuth error code is returned instead of `code`.                    |

Before exchanging the code:

1. If `error` is present, stop the flow and show an appropriate message.
2. Compare `state` to the value in the install session using a constant-time comparison.
3. Reject missing, expired, or mismatched state.
4. Load the corresponding PKCE verifier and delete the install session after use.

## 3. Exchange the authorization code

Call the token endpoint from your server. Supply the Client ID and client secret from **Created Apps** with HTTP Basic, and send form-encoded parameters.

```bash theme={null}
curl --request POST https://oauth.subtotal.com/oauth/token \
  --user "$SUBTOTAL_OAUTH_CLIENT_ID:$SUBTOTAL_OAUTH_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "code=$AUTHORIZATION_CODE" \
  --data-urlencode 'redirect_uri=https://connect.example.com/oauth/subtotal/callback' \
  --data-urlencode "code_verifier=$PKCE_CODE_VERIFIER"
```

| Form parameter  | Required value or purpose                                             |
| :-------------- | :-------------------------------------------------------------------- |
| `grant_type`    | Required. Use `authorization_code`.                                   |
| `code`          | Required. The single-use authorization code returned to the callback. |
| `redirect_uri`  | Required. The same redirect URI used in the authorization request.    |
| `code_verifier` | Required. The original 43–128 character PKCE verifier.                |

The response has this shape:

```json theme={null}
{
  "access_token": "<JWT access token>",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "<opaque refresh token>",
  "refresh_token_expires_in": 2592000,
  "scope": "connections:read retailers:read purchases:brand_products",
  "subtotal_client_id": "<authorizing dashboard team ID>"
}
```

| Response field             | Meaning                                                                                |
| :------------------------- | :------------------------------------------------------------------------------------- |
| `access_token`             | Bearer token for authorized Subtotal API requests.                                     |
| `token_type`               | Always `Bearer`.                                                                       |
| `expires_in`               | Access-token lifetime in seconds. Currently `900`.                                     |
| `refresh_token`            | Opaque token used to obtain the next access token. Store it encrypted.                 |
| `refresh_token_expires_in` | Seconds until the returned refresh token expires. Currently up to `2592000` (30 days). |
| `scope`                    | The fixed permissions granted to the app.                                              |
| `subtotal_client_id`       | The dashboard team that authorized the installation.                                   |

A successful exchange activates the app installation for the selected dashboard team and starts configured webhook delivery.

### Identify the app and installation

The authorization flow uses two identifiers with different sources:

| Name                 | Source and meaning                                                                                                |
| :------------------- | :---------------------------------------------------------------------------------------------------------------- |
| `client_id`          | The OAuth app's Client ID, shown in **Created Apps** and sent by your app during authorization and token exchange |
| `subtotal_client_id` | The authorizing dashboard team's ID, returned by Subtotal in the token response                                   |

Store `subtotal_client_id` with the installation. It identifies the dashboard team that granted access; do not infer that team from the person who completed consent. An installation is unique to one app and dashboard team. Reauthorizing the same pair updates the existing installation instead of creating another one.

Each refresh-token rotation starts a new 30-day lifetime. Track expiry using the response values rather than hard-coded durations. Keep tokens encrypted on your server, out of browser storage, URLs, and logs.

## 4. Refresh access tokens

Use the current refresh token when the access token is near expiry. Authenticate the app with HTTP Basic and send form-encoded parameters.

```bash theme={null}
curl --request POST https://oauth.subtotal.com/oauth/token \
  --user "$SUBTOTAL_OAUTH_CLIENT_ID:$SUBTOTAL_OAUTH_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode "refresh_token=$SUBTOTAL_REFRESH_TOKEN"
```

| Form parameter  | Required value or purpose                                 |
| :-------------- | :-------------------------------------------------------- |
| `grant_type`    | Required. Use `refresh_token`.                            |
| `refresh_token` | Required. The current refresh token for the installation. |

A successful refresh preserves all three scopes and returns the same fields as the code exchange, with new tokens and expiry values. Refresh only once at a time per installation. Save all returned values in one atomic update before the next refresh, and always use the newest refresh token.

### Retries and recovery

After a timeout, network failure, or temporary server error, retry promptly with the same refresh token. If the first request succeeded, retries return the same replacement refresh token and a fresh access token.

The previous refresh token is retryable for **60 minutes from its rotation**, or until its replacement is used for another refresh, whichever comes first. Retries do not extend this window or the replacement's expiry.

Reusing an older token or retrying after that window returns `400 invalid_grant`, revokes the installation's refresh tokens, and disconnects the app, stopping new webhook delivery. Expired or revoked refresh tokens also return `invalid_grant`. On this error, stop retrying, pause background work, and ask the brand to authorize the app again.

### Upgrade an existing installation

Installations authorized before `retailers:read` was included need fresh consent. Existing access tokens keep their original permissions until expiry; refreshing does not add the new scope. Authorization codes and refresh tokens with the old two-scope grant return `invalid_grant`.

1. Have the brand click **Install app** again. Your Install URL must start a new authorization flow with all three scopes, or omit `scope`.
2. Have the brand select the same dashboard team and approve access.
3. Exchange the new code and replace the stored tokens for that installation.

This updates the existing installation. No uninstall is required. The new access token can call [GET /retailers](/docs/api-reference/retailers/get-retailers).

## 5. Recover installation identity

If the callback succeeds but your state-to-installation mapping is lost, call `/oauth/me` with the access token. This endpoint has no query or form parameters.

```bash theme={null}
curl https://oauth.subtotal.com/oauth/me \
  --header "Authorization: Bearer $SUBTOTAL_ACCESS_TOKEN"
```

```json theme={null}
{
  "subtotal_client_id": "<authorizing dashboard team ID>",
  "client_id": "<your OAuth app Client ID>",
  "scope": "connections:read retailers:read purchases:brand_products"
}
```

## 6. Disconnect in Subtotal

A brand can open the installed app under **Apps**, select **Disconnect**, and type `DISCONNECT` to confirm. This removes the app's access to that brand's data and stops sending new events to the app.

<img src="https://mintcdn.com/typecastleinc/PjnmwPaRXYsRXOYx/images/partner-oauth/disconnect-app-confirmation.png?fit=max&auto=format&n=PjnmwPaRXYsRXOYx&q=85&s=4be65f53d39f4f4331f7685d92cffb8e" alt="Confirmation dialog for disconnecting Northstar Rewards Sync" className="rounded-lg border border-gray-100" width="1160" height="540" data-path="images/partner-oauth/disconnect-app-confirmation.png" />

Disconnecting revokes active refresh tokens and deactivates the installation. Already-issued access tokens remain valid until their 15-minute expiry. If a refresh fails with `invalid_grant`, stop background work and require reauthorization. To reconnect, send the brand through a new authorization flow.

## 7. Revoke from your service

If a brand uninstalls from your product, revoke its active refresh-token session. Authenticate with the app's current Client ID and client secret.

```bash theme={null}
curl --request POST https://oauth.subtotal.com/oauth/revoke \
  --user "$SUBTOTAL_OAUTH_CLIENT_ID:$SUBTOTAL_OAUTH_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "token=$SUBTOTAL_REFRESH_TOKEN"
```

| Form parameter | Required value or purpose                                       |
| :------------- | :-------------------------------------------------------------- |
| `token`        | Required. The refresh token for the installation being revoked. |

Revocation returns `200` for known and unknown tokens. For a known token, it revokes active refresh tokens for that app and dashboard team, deactivates the Subtotal installation, and stops webhook delivery. Already-issued access tokens are self-contained and remain valid until their 15-minute expiry.
