# Connected Apps

A connected app is an OAuth 2.1 client LedgerBee registers for your product, so
it can act on a company's data with that company's consent. Register once, then
run the authorization-code flow for every company that connects.

## Registration

Registration is handled by LedgerBee, not self-service. Email
[development@ledgerbee.com](mailto:development@ledgerbee.com) with:

- your app's display name
- one or more **https** redirect URIs
- the scopes your integration needs
- a logo URL (optional)

You get back a `client_id` (`lb_app_client_…`) and a `client_secret`
(`lb_app_secret_…`). The secret is shown once and cannot be retrieved, so store
it before you close the message.

Email the same address to change any of this later, or to rotate the secret. A
rotation keeps the previous secret working for 24 hours, so you can roll
credentials without downtime.

## Authorize

Send the company's user to:

```
GET https://api.ledgerbee.com/auth/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
  &scope=customers-read%20customers-write
```

Wrapped for readability; every parameter belongs on one query string, and the
space between scope names must be encoded as `%20`.

| Parameter | Required | Notes |
|---|---|---|
| `response_type` | Yes | Must be `code`. |
| `client_id` | Yes | Your `lb_app_client_…` id. |
| `redirect_uri` | Yes | Must exactly match one of your registered https URIs. |
| `state` | Recommended | Echoed back on the redirect. Generate an unguessable value per request and reject a callback whose `state` doesn't match. |
| `code_challenge` | Yes | PKCE, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636). |
| `code_challenge_method` | Yes | Must be `S256`; `plain` is refused. |
| `scope` | No | Space-separated, and a subset of your registered scopes. Omit to request all of them. |

A signed-out user is sent to login and returned here afterward.

## Consent

The user picks the company to connect if they belong to more than one, then
approves or denies the requested scopes as a fixed list. Approval grants
exactly what `/authorize` requested; there is no partial grant. A company holds
one live grant per app, so reconnecting refreshes it rather than adding a
second.

Approval redirects to your `redirect_uri` with `code` and `state`; denial
redirects with `error=access_denied`.

## Token exchange

```bash
curl -X POST https://api.ledgerbee.com/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE" \
  -d "code_verifier=CODE_VERIFIER" \
  -d "redirect_uri=https://yourapp.com/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=$LEDGERBEE_CLIENT_SECRET"
```

```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "lb_app_refresh_...",
  "scope": "customers-read customers-write"
}
```

- `access_token` is a JWT valid for **1 hour**.
- `refresh_token` is valid for **30 days**, single-use, and rotates on every
  exchange.
- `scope` reports what was actually granted. Read it back rather than assuming
  your request was granted verbatim.

Authenticate with `client_secret_post` as above, or `client_secret_basic`, an
`Authorization: Basic` header carrying `client_id:client_secret`. The examples
read the secret from an environment variable; curl still expands that into its
argument list, so on a shared machine use your own HTTP client instead.

This endpoint and `/oauth/revoke` take a form-urlencoded body
([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) /
[RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)), so they are
documented here rather than in the [API Reference](/api), which covers the JSON
REST endpoints.

## Refresh

```bash
curl -X POST https://api.ledgerbee.com/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=$LEDGERBEE_CLIENT_SECRET"
```

The 30 days are counted per token: each replacement starts its own window, so
refreshing regularly keeps you connected indefinitely while a leaked token
still dies 30 days after issue. A connection ends when the tenant revokes it or
you go 30 days without refreshing.

Serialize refresh calls per grant across your workers: presenting an
already-used refresh token is treated as a compromised token family and revokes
the whole grant.

## Using the access token

```bash
curl https://api.ledgerbee.com/api/v1/customers \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

See the [API Reference](/api) for the available endpoints.

## Scopes

The `scope` you request must be a subset of the scopes registered for your app.
Three are never grantable to a connected app: `checkout`, `portal-sso-mint`,
and `portal-provision`, which are API-key-only provisioning credentials. Legacy
`vendors-read` / `vendors-write` are accepted at `/authorize` and normalized to
`suppliers-read` / `suppliers-write`.

## Revocation

Users disconnect your app from their **Connected apps** page at any time. Your
app can also revoke a token directly:

```bash
curl -X POST https://api.ledgerbee.com/api/v1/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=REFRESH_OR_ACCESS_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=$LEDGERBEE_CLIENT_SECRET"
```

The response is `200` with an empty body whether the token was valid, already
revoked, or unknown. Only a client-authentication failure returns `401`.

## Errors

| Condition | What you observe | Fix |
|---|---|---|
| Unknown or revoked `client_id`, or unregistered `redirect_uri` | Error page, no redirect | Check both against your registration |
| `response_type` isn't `code` | `error=unsupported_response_type` | Send `response_type=code` |
| Missing PKCE, or a method other than `S256` | `error=invalid_request` | Send PKCE S256 |
| Scope outside your registered set, or an unknown name | `error=invalid_scope` | Request only registered scopes, or email us to add one |
| User declines | `error=access_denied` | Nothing to fix |
| Bad, expired or replayed code, PKCE mismatch, `redirect_uri` mismatch | `400 invalid_grant` | Restart the flow |
| Wrong `client_id` / `client_secret` | `401 invalid_client` | Check your credentials |
| Refresh token used, unknown, or grant revoked | `400 invalid_grant`; a reused token revokes the grant | Reconnect |
| Token missing a required scope | `403`, `error="insufficient_scope"` | Reconnect with the broader scope |
| Tenant disconnected your app | `401`, `error="invalid_token"`, `details.reason: "grant_revoked"` | Reconnect |

## Discovery

```
GET https://api.ledgerbee.com/api/.well-known/oauth-authorization-server
```

Returns the OAuth 2.1 metadata: `authorization_endpoint`, `token_endpoint`,
`revocation_endpoint`, `jwks_uri`, supported grant types and scopes. The
[API Reference](/api) carries the full schema, including the JWKS and
protected-resource variants.
