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

# Token Exchange

> Exchange an authorization code or refresh token for an access token

# Token Exchange

Exchanges either a one-time authorization `code` (from
`GET /apps/oauth/authorize`) or a long-lived `refresh_token` for a fresh
access/refresh token pair. Two grant types are supported:

* `authorization_code` — first-time install flow.
* `refresh_token` — silent token rotation after the 24h access token
  expires.

This endpoint is rate-limited to **10 requests per minute per IP**.

## Request

<CodeGroup>
  ```bash authorization_code theme={null}
  curl -X POST "https://api.launchmystore.io/apps/oauth/token" \
    -H "Content-Type: application/json" \
    -d '{
      "grant_type": "authorization_code",
      "client_id": "lms_app_xxx",
      "client_secret": "lms_secret_yyy",
      "code": "9fbb1c3e8d4a7b2e...",
      "state": "5d6f7c8b9e0d1c2a...",
      "code_verifier": "OPTIONAL_PKCE_VERIFIER"
    }'
  ```

  ```bash refresh_token theme={null}
  curl -X POST "https://api.launchmystore.io/apps/oauth/token" \
    -H "Content-Type: application/json" \
    -d '{
      "grant_type": "refresh_token",
      "refresh_token": "lms_refresh_abc123..."
    }'
  ```
</CodeGroup>

## Body Parameters

### Grant type: `authorization_code`

<ParamField body="grant_type" type="string" required>
  Must be `authorization_code`.
</ParamField>

<ParamField body="client_id" type="string" required>
  Your app's public client identifier.
</ParamField>

<ParamField body="client_secret" type="string" required>
  Your app's client secret. Verified against the stored (hashed)
  secret — the raw value is never persisted.
</ParamField>

<ParamField body="code" type="string" required>
  The one-time authorization code from
  `GET /apps/oauth/authorize`. Single-use, expires in 10 minutes.
</ParamField>

<ParamField body="state" type="string" required>
  The server-generated state token from the authorize call. Must match
  what was bound to the code server-side or the request fails with
  `Invalid state parameter`.
</ParamField>

<ParamField body="code_verifier" type="string">
  Required when the authorize call sent a `code_challenge`. Length 43-128
  chars. For `S256` flows, the server SHA-256 hashes this and compares
  (constant-time) against the stored challenge.
</ParamField>

### Grant type: `refresh_token`

<ParamField body="grant_type" type="string" required>
  Must be `refresh_token`.
</ParamField>

<ParamField body="refresh_token" type="string" required>
  The current refresh token. Must not be expired, blacklisted, or
  already rotated.
</ParamField>

## Response

<ResponseField name="status" type="integer">200 on success.</ResponseField>
<ResponseField name="state" type="string">`success` or `error`.</ResponseField>

<ResponseField name="data" type="object">
  <Expandable title="Token Object (RFC 6749 §5.1)">
    <ResponseField name="access_token" type="string">
      Opaque bearer token prefixed `lms_token_` followed by 64 hex chars.
      Send as `Authorization: Bearer <token>` on `/api/v1/*` calls.
    </ResponseField>

    <ResponseField name="refresh_token" type="string">
      Opaque refresh token prefixed `lms_refresh_`. Use with
      `grant_type=refresh_token` to get a new access token.
    </ResponseField>

    <ResponseField name="token_type" type="string">
      Always `bearer`.
    </ResponseField>

    <ResponseField name="expires_in" type="integer">
      Access token lifetime in seconds. Always `86400` (24 hours).
    </ResponseField>

    <ResponseField name="scope" type="string">
      Space-separated list of granted scopes (normalised — duplicates
      removed and legacy aliases collapsed).
    </ResponseField>
  </Expandable>
</ResponseField>

## Example Response

```json theme={null}
{
  "status": 200,
  "state": "success",
  "data": {
    "access_token": "lms_token_9fbb1c3e8d4a7b2e0a5d6f7c8b9e0d1c2a3b4c5d6e7f8091a2b3c4d5e6f70819",
    "refresh_token": "lms_refresh_5d6f7c8b9e0d1c2a3b4c5d6e7f80910a2b3c4d5e6f70819b8c4d5e6f7081923a",
    "token_type": "bearer",
    "expires_in": 86400,
    "scope": "read_products write_metafields read_orders"
  }
}
```

## Token Lifetimes

| Token              | TTL                                | Notes                                           |
| ------------------ | ---------------------------------- | ----------------------------------------------- |
| Access token       | **24 hours** (`expires_in: 86400`) | Used as `Authorization: Bearer` on `/api/v1/*`. |
| Refresh token      | **30 days**                        | Resets on every successful rotation.            |
| Authorization code | **10 minutes**                     | Single-use; deleted after exchange.             |

When you refresh, the old refresh token is **blacklisted** for 31 days
(only its SHA-256 hash is stored) and a brand new pair is issued.
Re-using a rotated refresh token returns `401 Token has been revoked`.

## Install/Re-install Limits

When `grant_type=authorization_code` would create a brand new active
installation (or re-activate a previously disabled one), the server
checks per-shop function caps before issuing tokens. If the app ships
functions and the merchant already has too many active installs of apps
with the same function types, the endpoint returns:

```json theme={null}
{
  "status": 409,
  "state": "error",
  "message": "Per-shop function limit exceeded for <type>: max <N>"
}
```

This check is skipped when re-issuing tokens for an already-active
installation (no status flip = no new active install counted).

## Error Codes

| HTTP  | Error message                                           | When                                                                |
| ----- | ------------------------------------------------------- | ------------------------------------------------------------------- |
| `400` | `Unsupported grant_type`                                | `grant_type` is not `authorization_code` or `refresh_token`.        |
| `400` | `Invalid or expired authorization code`                 | `code` unknown (consumed or > 10 minutes old).                      |
| `400` | `Invalid state parameter`                               | `state` doesn't match the value bound to the code.                  |
| `400` | `State validation failed`                               | `state` unknown or `client_id` doesn't match.                       |
| `400` | `code_verifier is required for this authorization code` | Authorize call sent PKCE challenge but token call omitted verifier. |
| `400` | `code_verifier must be 43-128 characters`               | PKCE verifier length out of range.                                  |
| `400` | `code_verifier does not match the code_challenge`       | PKCE verification failed (constant-time compare).                   |
| `401` | `Invalid client credentials`                            | `client_id`/`client_secret` doesn't match.                          |
| `401` | `Invalid refresh token`                                 | No installation has this refresh token.                             |
| `401` | `Refresh token has expired. Please re-authenticate.`    | > 30 days since last rotation.                                      |
| `401` | `Token has been revoked`                                | Refresh token was rotated or revoked.                               |
| `409` | `Per-shop function limit exceeded for <type>: max <N>`  | Function caps prevent a new active install.                         |
| `429` | (throttler)                                             | More than 10 requests/minute from this IP.                          |

## Refresh Loop Pattern

```javascript theme={null}
// Server-side helper — refresh before every API call when expiry is close.
async function withFreshToken(installation) {
  const msLeft = new Date(installation.tokenExpiresAt) - Date.now();
  if (msLeft > 60_000) return installation.accessToken;

  const res = await fetch('https://api.launchmystore.io/apps/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: installation.refreshToken,
    }),
  });
  const { data } = await res.json();

  // Persist the new pair — both old tokens are now blacklisted.
  await db.installation.update({
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    tokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
  });
  return data.access_token;
}
```

## Security Notes

* Always send this request **server-side**. Never expose `client_secret`
  in browser code.
* For browser-based public clients, use PKCE (no `client_secret`).
* Successful exchange **consumes** both the code and its state — both
  are deleted atomically.
* `client_secret` verification is brute-force resistant: requests are
  throttled by the IP rate limit (10/min) and secrets are stored hashed.
