# Authentication

Bearer tokens, modes and installation context.

Base URL:

```text
https://api.southbill.com/v1/app
```

Every request carries an installation access token:

```bash
curl "https://api.southbill.com/v1/app/payments?limit=10" \
  -H "Authorization: Bearer sbat_live_…"
```

```node
const res = await fetch("https://api.southbill.com/v1/app/payments?limit=10", {
  headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error((await res.json()).error?.type ?? res.statusText);
const { data, has_more } = await res.json();
```

```python
import requests

res = requests.get(
    "https://api.southbill.com/v1/app/payments",
    params={"limit": 10},
    headers={"Authorization": f"Bearer {access_token}"},
    timeout=30,
)
res.raise_for_status()
payments = res.json()["data"]
```

```php
<?php
$ch = curl_init("https://api.southbill.com/v1/app/payments?limit=10");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer {$accessToken}"],
]);
$body = json_decode(curl_exec($ch), true);
$payments = $body["data"];
```

The token identifies the app **and** the merchant. There is no merchant id parameter — you cannot address a merchant that has not installed you.

## Getting a token

Tokens come from the OAuth flow at install time (`authorization_code`, PKCE supported) and are renewed with the `refresh_token` grant. See [Installs & OAuth](/docs/dev/apps/installs).

| Property | Value |
| --- | --- |
| Access token | `sbat_live_…` / `sbat_test_…`, valid **1 hour** (`expires_in: 3600`) |
| Refresh token | `sbrt_live_…` / `sbrt_test_…`, **single use and rotating** — store the new one on every refresh |
| Mode | Baked into the token, cannot be switched |
| Scopes | Exactly what the merchant approved |
| Reissue | Issuing a new access token revokes the installation's previous access tokens |

Because refresh tokens rotate, refreshing twice with the same token invalidates the installation's tokens — serialise your refresh calls.

## Failure modes

| Status | `error.type` | Do this |
| --- | --- | --- |
| 401 | `invalid_token` | Refresh, then retry once |
| 403 | `insufficient_scope` | Request the scope in a new version; do not retry |
| 403 | `merchant_suspended` | Back off, retry later, keep data |
| 401 | `installation_inactive` | Installation revoked or suspended — stop calling |
| 404 | `not_found` | Object does not exist for this merchant |
| 404 | `unknown_endpoint` | Path typo or unsupported resource |
| 405 | `method_not_allowed` | Method not supported on this route. Reads are `GET`; Store writes use `POST` / `PATCH` / `DELETE` with the `products:write`, `files:write` or `orders:write` scope |
| 429 | `rate_limit_exceeded` | Honour `Retry-After` |

Error bodies are uniform:

```json
{ "error": { "type": "insufficient_scope", "message": "Missing required scope: payments:read" } }
```

## Server-side only

The client secret and refresh tokens must never reach a browser or a mobile binary. Public clients use PKCE and keep only the short-lived access token.
