# OAuth2 apps

Let another app act on a Payer user's behalf. A standard OAuth2 authorization-code flow with PKCE and rotating refresh tokens.

OAuth2 lets a third-party app act on behalf of a Payer user, with the user's explicit consent and never more access than they granted. Payer runs a standard **authorization-code** flow with **PKCE**, and issues rotating refresh tokens.

Use OAuth when *your app serves other people's Payer accounts*. If you only need your own data, a [personal access token](/developers/docs/personal-tokens) is simpler; if you're a merchant taking payments, use a [secret key](/developers/docs/authentication).

## Registering an app

A merchant admin registers an app from the **Developers page** in the dashboard. Registration returns a `client_id` (prefixed `oauthapp_`) and, for confidential apps, a `client_secret` shown **once**.

| Setting | Notes |
| --- | --- |
| `redirect_uris` | Exact-match only — the `redirect_uri` in a request must be identical to a registered one. |
| `allowed_scopes` | The scopes the app may ever request. |
| `type` | `public` (mobile/SPA, no secret, PKCE required) or `confidential` (server-side, has a secret). |

## The flow

```text title="Authorization code + PKCE"
1. App → generates code_verifier + code_challenge (S256)
2. App → sends the user to Payer's consent screen
3. User → approves; Payer redirects back with ?code=...&state=...
4. App → POST /oauth/token with the code + code_verifier
5. Payer → returns access_token (oat_) + refresh_token (ort_)
```

### 1 · Send the user to consent

Redirect the user to Payer's hosted authorization screen with a PKCE challenge. The screen's URL is shown when you register your app; send it these query parameters:

```text title="Authorization request parameters"
response_type=code
client_id=oauthapp_...
redirect_uri=https://yourapp.com/callback
scope=balances:read transactions:read
state=<opaque csrf value>
code_challenge=<base64url(sha256(verifier))>
code_challenge_method=S256
```

> [!NOTE]
> Only `response_type=code` is supported, and only the `S256` challenge method. PKCE is **required** for public apps and recommended for all. Always send an unguessable `state` and check it on return.

The user reviews the requested scopes and approves or denies. Payer redirects back to your `redirect_uri` with `?code=...&state=...` on approval, or `?error=access_denied&state=...` on denial. Authorization codes are single-use and expire after **5 minutes**.

### 2 · Exchange the code for tokens

```http title="POST /oauth/token"
POST /oauth/token
```

Authenticate the client with HTTP Basic (`client_id:client_secret`) or with `client_id`/`client_secret` in the body. Public apps send `client_id` and the `code_verifier` only.

```bash title="Authorization code grant"
curl https://api.payer.app/oauth/token \
  -u "oauthapp_...:<client_secret>" \
  -d "grant_type=authorization_code" \
  -d "code=<code from the redirect>" \
  -d "code_verifier=<original verifier>" \
  -d "redirect_uri=https://yourapp.com/callback"
```

```json title="200 OK"
{
  "access_token": "oat_...",
  "refresh_token": "ort_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "balances:read transactions:read"
}
```

Use the access token as a bearer token against `/graphql`, exactly like a PAT — it can read whatever scopes the user consented to:

```bash title="Calling the API with an access token"
curl https://api.payer.app/graphql \
  -H "Authorization: Bearer oat_..." \
  -H "Content-Type: application/json" \
  -d '{"query":"query { balance { available } }"}'
```

### 3 · Refresh the access token

Access tokens live **1 hour**. When one expires, exchange the refresh token for a new pair. Refresh is **rotating** — the presented refresh token and its access token are revoked, and a fresh pair is returned. Store the new refresh token; the old one is now dead.

```bash title="Refresh token grant"
curl https://api.payer.app/oauth/token \
  -u "oauthapp_...:<client_secret>" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=ort_..."
```

Refresh tokens live **60 days**. If a used authorization code or a rotated refresh token is replayed, Payer revokes the whole chain issued for it — so a stolen token can't be quietly reused alongside the legitimate one.

### 4 · Revoke a token

```http title="POST /oauth/revoke"
POST /oauth/revoke
```

```bash title="Revoke (RFC 7009)"
curl https://api.payer.app/oauth/revoke \
  -u "oauthapp_...:<client_secret>" \
  -d "token=ort_..."
```

Revocation always returns `200` with an empty body — it never reveals whether the token existed — and revokes the token together with its linked pair.

## Token summary

| Token | Prefix | Lifetime |
| --- | --- | --- |
| Authorization code | — | 5 minutes, single use |
| Access token | `oat_` | 1 hour |
| Refresh token | `ort_` | 60 days, rotating |

## Errors

The token and revoke endpoints return RFC 6749 error bodies:

```json title="400 Bad Request"
{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid or has expired."
}
```

| `error` | Status | When |
| --- | --- | --- |
| `invalid_request` | 400 | A required parameter is missing or malformed. |
| `invalid_client` | 401 | Client authentication failed. |
| `invalid_grant` | 400 | The code or refresh token is invalid, expired or already used. |
| `unauthorized_client` | 400 | This client may not use this grant. |
| `unsupported_grant_type` | 400 | `grant_type` isn't `authorization_code` or `refresh_token`. |
| `invalid_scope` | 400 | A requested scope isn't in the app's allowed set. |
