# Sign in with Orbio

## How it works

Sign in with Orbio lets the people who use your app connect their Orbio account to it in one click, and lets your app call Orbio's models and tools on their balance. Nobody pastes a key. It is OAuth 2.1 with PKCE, and OpenID Connect when you ask for `openid`.

- You register an app at [/developers/apps](https://www.orbio.so/developers/apps) and get a client ID (and a client secret, for a server app).
- Your app sends the person to Orbio. They sign in with email or a wallet and approve your app on one screen.
- Orbio sends them back with a code. Your server swaps it for an access token and a refresh token.
- You call the API with the access token exactly as you would with an API key. Every request spends the person's balance, plus your app fee if you set one.

A person who already approved your app, for everything it asks, is signed straight back in without seeing the screen again when your redirect URI is https. With a custom scheme or a localhost redirect they see the screen each time, because another app on their device could be listening there. They can disconnect your app at any time from their dashboard, which stops its tokens at once.

## Register an app

Sign in at [/developers/apps](https://www.orbio.so/developers/apps) with any Orbio account and choose New app.

| Field | What it does |
| --- | --- |
| Name, logo, homepage | Shown on the consent screen beside the Orbio mark. |
| App type | A server app keeps a client secret on its backend and sends it with every token request. A public app (a web app with no backend, or a phone, desktop or terminal app) has no secret to keep and proves itself with PKCE alone. |
| Redirect URIs | Where Orbio may send the person back. Matched exactly, never by prefix. `https`, a custom scheme for a native app, or `http://localhost` for development. |
| App fee | A percentage on top of what Orbio charges for everything your app spends, from 0 to 100%. Paid to you in USDG. |

A server app's secret is shown once, when it is made. Two can be live at a time, so you can rotate without downtime: add a new one, deploy it, revoke the old one.

## 1. Send the person to Orbio

Make a random `code_verifier` and keep it with the person's session, then send the browser to the authorize URL with its SHA-256 hash as the `code_challenge`.

Authorize URL:

```
https://www.orbio.so/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/auth/orbio/callback
  &scope=openid profile email wallet inference
  &state=RANDOM_STATE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256
```

| Parameter |  |
| --- | --- |
| `client_id`, `redirect_uri` | Required. The redirect URI must be one you registered, exactly. |
| `response_type` | Required. Always `code`. |
| `code_challenge`, `code_challenge_method` | Required, for every app. `S256` only. |
| `state` | Recommended. Echoed back so you can tie the callback to the session that started it. |
| `scope` | What you ask for, space separated (see Scopes). Defaults to `balance inference`. |
| `nonce` | Optional. Echoed in the id_token. |
| `prompt` | Optional. `consent` always shows the screen. `none` signs the person straight back in when it can and otherwise shows the screen, so it never ends in an error. Neither forces a new sign-in. |

## 2. Handle the callback

Orbio sends the browser to your redirect URI with `code`, your `state`, and `iss` (Orbio's issuer, so you can tell providers apart). Check that `state` is the one you sent. A person who cancels comes back with `error=access_denied` instead of a code. A request Orbio cannot accept shows the person the reason with a link back to you carrying the error; it is never bounced to your redirect without a click.

## 3. Exchange the code

Within five minutes. A server app exchanges from its backend and authenticates with HTTP Basic (or `client_id` and `client_secret` in the body). A public app sends its `client_id` and no secret, from wherever it runs: the token endpoint answers browsers, so a web app with no backend can sign people in on its own.

curl:

```
curl https://www.orbio.so/api/oauth/token \
  -u "$ORBIO_CLIENT_ID:$ORBIO_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://yourapp.com/auth/orbio/callback \
  -d code_verifier="$CODE_VERIFIER"
```

Response:

```
{
  "access_token": "orbio_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "orbio_rt_…",
  "scope": "openid profile email wallet inference",
  "id_token": "eyJ…"
}
```

## 4. Call the API

The access token works wherever an Orbio API key does: the OpenAI-compatible gateway at `https://api.orbio.so/api/v1`, the tools at `https://api.orbio.so/api/v1/tools`, and MCP. Each request spends the person's balance through your app's own connection.

TypeScript · OpenAI SDK:

```
import OpenAI from 'openai'

const orbio = new OpenAI({ baseURL: 'https://api.orbio.so/api/v1', apiKey: accessToken })
const answer = await orbio.chat.completions.create({
  model: 'anthropic/claude-sonnet-4.5',
  messages: [{ role: 'user', content: 'Hello' }],
})
```

An Anthropic-shaped client (the Anthropic SDK, the Claude Agent SDK) takes `https://api.orbio.so/api` as its base, because it appends `/v1/messages` itself. The `@orbiodotso/sdk` package takes the access token as `apiKey`.

Call from your server, or straight from the page in a browser app: the gateway answers a browser for a person's Sign in with Orbio token. It never answers one for an Orbio API key, which belongs on a server. A server is still the stronger choice where you have one, because it keeps tokens off the page, out of reach of any script that runs there. In the browser, keep the tokens in memory rather than in `localStorage` where you can, and refresh when the access token expires.

## 5. Keep the person signed in

Access tokens last an hour. Swap the refresh token for a new pair before then. Refresh tokens are single use: every refresh returns a new one, and presenting an old one again is treated as theft and disconnects the person. Save the new refresh token before you use the new access token, and refresh from one place, not from several servers at once.

curl:

```
curl https://www.orbio.so/api/oauth/token \
  -u "$ORBIO_CLIENT_ID:$ORBIO_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
```

To sign the person out of your app, post the refresh token to `https://www.orbio.so/api/oauth/revoke`. That disconnects your app from their account.

## Who signed in

Ask for `openid` and the code exchange returns an id_token signed with ES256, checkable against `https://www.orbio.so/api/oauth/jwks`. The same claims come from `https://www.orbio.so/api/oauth/userinfo` with the access token as a Bearer token. Each claim is there only if its scope was approved and the account has it.

| Claim | Scope |  |
| --- | --- | --- |
| `sub` | always | The person's id for you. Stable, and the same across all apps you own; a different developer sees a different one. |
| `preferred_username`, `name`, `picture` | `profile` | From their linked X account, when they have one. |
| `account_kind` | `profile` | `email`, `wallet` or `mixed`: how they sign in to Orbio. |
| `email`, `email_verified` | `email` | Accounts that sign in with email or Google. |
| `wallet_address`, `chain_id` | `wallet` | Accounts that sign in with a wallet: the first wallet the account linked, on Robinhood Chain. |

OpenID Connect libraries need only the issuer, `https://www.orbio.so`: the configuration is at `https://www.orbio.so/.well-known/openid-configuration`.

Auth.js:

```
{
  id: 'orbio',
  name: 'Orbio',
  type: 'oidc',
  issuer: 'https://www.orbio.so',
  clientId: process.env.ORBIO_CLIENT_ID,
  clientSecret: process.env.ORBIO_CLIENT_SECRET,
  authorization: { params: { scope: 'openid profile email wallet inference' } },
  checks: ['pkce', 'state'],
}
```

## Scopes

The person approves everything you ask for or nothing. Ask for what you use: each scope is a line on the consent screen.

| Scope | Grants |
| --- | --- |
| `openid` | An OpenID Connect id_token on the code exchange, signed ES256. Its `sub` is the person's id for your app. |
| `profile` | `preferred_username` (X handle), `name`, `picture`, `account_kind` (email, wallet or mixed). |
| `email` | `email` and `email_verified`, for an account that signs in with email or Google. Absent on a wallet account. |
| `wallet` | `wallet_address` (the first wallet the account linked) and `chain_id`, for an account that signs in with a wallet. Absent on an email account. |
| `offline_access` | Accepted for compatibility. Every grant includes a refresh token. |
| `balance` | Read the balance and usage: `GET /api/v1/key`, `orbio_get_balance` over MCP. |
| `inference` | Every model route on the gateway: chat completions, messages, responses, embeddings, images, audio and video. |
| `tools` | Every read tool in `/api/v1/tools`: web search and scrape, X, Instagram and TikTok reads, chain reads. |
| `social` | `social.post`, `social.post.status`, `social.post.delete` and `social.accounts`: publishing as the person, through the accounts they authorized in their dashboard. |
| `infra` | Managed infrastructure in an isolated scope created automatically for this connection. Includes sandbox, deployment, server, database, and inbox actions within approved spending limits. Existing explicit assignments keep their permissions. Does not grant access to other connections or permission to issue keys. Never added to previously approved grants. |
| `keys` | Local MCP clients only (a self-registered client signing in through a loopback or custom-scheme redirect): read, create and revoke the account's permanent API key, and set its Incognito policy, which is how an MCP client configures a gateway on its user's machine. Never granted to a registered app or to any client whose redirect is a web address: a key outlives the connection that read it. |

Infrastructure requires explicit `infra` consent. Orbio creates an isolated resource scope for each approved connection on first use; no manual product setup or provider login is needed. Existing owner assignments keep their permissions. Hosted MCP and SDK 0.2.0 support E2B sandboxes, Vercel deployments, Fly.io servers, Supabase databases, and AgentMail inboxes. Actions require `infra.read` plus their resource permission; mutations require a saved idempotency key and approved cost ceiling. Start with the [infrastructure guide](https://www.orbio.so/docs/infrastructure).

A request outside what was granted is refused with `403` and the code `insufficient_scope`. To ask for more, send the person through the authorize URL again with the larger scope; they approve it on the consent screen, and the connection then holds both.

## Your app fee

Set a fee and every request your app makes adds it on top of what Orbio charges: a 10% fee on a request Orbio charges $0.0100 for takes $0.0110 from the person, and $0.0010 is yours. The fee is at most 100%.

- The person sees your fee on the consent screen and next to your app in their dashboard.
- A change applies from the next request, for everyone. A request already running keeps the rate it started with.
- Your own account pays no fee through your own app.
- The fee is charged from the person's spare balance, after the request's own cost. A request is never refused because of the fee.

What you earn shows on your app's page. Claim it in USDG on Robinhood Chain once it reaches $10: claims are reviewed and paid within 24 hours, and the transaction appears beside the claim.

## Errors

| Where | What it means |
| --- | --- |
| `invalid_client` (401) at the token endpoint | Wrong or revoked client secret, or a secret sent by a public app. |
| `invalid_grant` at the token endpoint | The code was used, expired, or issued to another redirect URI; or the refresh token was used before (the connection is now revoked). |
| `401` from the API | The token expired or the person disconnected your app. Refresh, or sign them in again. |
| `403 insufficient_scope` from the API | Your token was not granted what that route needs. |
| `402` from the API | The person's balance cannot cover the request. |
