# Authentication

Every gateway request authenticates with a **virtual key** — one key that works
on both wire surfaces, spends one org balance, and never touches a provider
credential of yours.

## Virtual keys

| Property | Value |
|---|---|
| Format | `tm_vk_` followed by 48 hex characters (24 random bytes) |
| Shown | exactly once, at creation |
| Stored | only the SHA-256 hash, plus the first 12 characters so the console can label it |
| Spends | the org's shared prepaid balance |
| Limits | per-key requests-per-minute; optional per-key and org-wide monthly spend caps |

> [!WARNING]
> The raw key is displayed once and cannot be recovered — only its hash is
> stored. If you lose a key, disable it at [https://app.routerplus.com/console/keys](https://app.routerplus.com/console/keys) and create a new one.

## Both header forms are accepted

The gateway reads your key from either standard header, on **both** surfaces:

| Header | Convention | Typically sent by |
|---|---|---|
| `Authorization: Bearer tm_vk_...` | OpenAI | OpenAI SDKs; Claude Code via `ANTHROPIC_AUTH_TOKEN` |
| `x-api-key: tm_vk_...` | Anthropic | Anthropic SDKs via `api_key` |

You do not have to match the header to the surface — `x-api-key` works on
`POST /v1/chat/completions` and `Authorization: Bearer` works on
`POST /v1/messages`. If both headers are present, `Authorization: Bearer` wins.

```bash
# OpenAI surface, bearer auth
curl -s https://api.routerplus.com/v1/chat/completions \
  -H "Authorization: Bearer $TM_API_KEY" \
  -H "content-type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'

# Anthropic surface, x-api-key auth
curl -s https://api.routerplus.com/v1/messages \
  -H "x-api-key: $TM_API_KEY" \
  -H "content-type: application/json" \
  -d '{"model":"claude-haiku-4-5","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
```

> [!NOTE]
> `anthropic-version` is **not required** on `/v1/messages` — the gateway pins
> its own version (`2023-06-01`) when it dispatches upstream, and forwards your
> `anthropic-beta` header when the serving provider speaks the Anthropic
> dialect — filtered to an allowlist (`prompt-caching`, `token-efficient-tools`,
> `fine-grained-tool-streaming`, `interleaved-thinking`, `claude-code`, `oauth`
> and `computer-use` betas); any other value is dropped, and the drop is named
> in `x-tm-dropped-params`. The one place `anthropic-version` changes behavior: sending it on
> `GET /v1/models` returns the Anthropic-shaped model list instead of the
> OpenAI list shape. Anthropic SDKs send it automatically; that is fine.

In the SDKs, the key goes exactly where the provider's own key would:

```python
import os
from openai import OpenAI
import anthropic

openai_client = OpenAI(base_url="https://api.routerplus.com/v1", api_key=os.environ["TM_API_KEY"])
anthropic_client = anthropic.Anthropic(base_url="https://api.routerplus.com", api_key=os.environ["TM_API_KEY"])
# anthropic.Anthropic(auth_token=...) also works — that sends Authorization: Bearer,
# which the gateway accepts too.
```

```typescript
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";

const openai = new OpenAI({ baseURL: "https://api.routerplus.com/v1", apiKey: process.env.TM_API_KEY });
const anthropic = new Anthropic({ baseURL: "https://api.routerplus.com", apiKey: process.env.TM_API_KEY });
```

## Getting a key

### In the browser

Open [Sign up](https://app.routerplus.com/signup) to create an account, or
[Sign in](https://app.routerplus.com/login) if you already have one. Public signup and sign-in
use Clerk. Complete the authentication and email-verification steps shown there.
Use the same verified email as your existing marketplace account to keep its
organization, keys and balance; switching sign-in methods does not create another
trial. If you have an older marketplace account but no Clerk sign-in yet, use
**Sign up** with that same email to connect it to your existing account.

For a new account, the first verified sign-in mints your first API key. Signup
and email verification do not grant free credit. Add paid credits in [Billing](https://app.routerplus.com/console/billing) before making model calls. Save the key when it is shown after
sign-in; the raw value is shown only once. At
[API keys](https://app.routerplus.com/console/keys) you can create additional named keys,
each likewise shown once. Browser sign-in does not change how your applications
authenticate with existing `tm_vk_` keys.

Public programmatic signup and the local magic-link submission route have been
removed: `POST /v1/signup` and `POST /login` return HTTP `404`. The `?magic=1`
query parameter does not provide an alternate signup or sign-in form. Existing
accounts do not need to be recreated; sign in through Clerk using their verified email.

A marketplace session lasts up to 7 days and remains subject to the linked Clerk
session. To end it sooner, use **Sign out** in the account menu (the avatar in the
app bar) or at the bottom of the console rail. Sign out ends this browser's
session on the server. Other devices stay signed in.

## Key rate limits

Each key has a request limit over a rolling 60-second window, enforced per key
and shared across gateway instances:

| Key origin | Requests per minute |
|---|---|
| First key shown after verified sign-in (trial) | 20 |
| Created in the console, or with the identity API | 300 |
| The console's managed Playground key | 60 |

Until your organization has bought credit, its customer keys and the organization as
a whole run at the trial rate, 20 requests per minute, whatever a key's own limit
says; the first purchase lifts it within seconds. Requests served only by your own
provider keys (BYOK) and the managed Playground key are not held to it.

Exceeding it returns `429` with `error_type: "rate_limit"` and a `retry-after`
header. Honor the header and retry; don't hammer. Your organization also has
token-per-minute and concurrency limits; see [Rate limits & spend caps](/docs/limits).

## Disabling a key

At [https://app.routerplus.com/console/keys](https://app.routerplus.com/console/keys), each key row has a **Disable**
action. Disabling is permanent — there is no re-enable in v1; create a new key
instead. The gateway caches key rows briefly, so a disabled key can keep
working for up to about five seconds before every request returns 401.

You can also cap a key without killing it: per-key **monthly spend caps** are
set on the same page (**Set cap**), and the org-wide cap at
[https://app.routerplus.com/console/billing](https://app.routerplus.com/console/billing). A breached cap returns
`429 insufficient_quota` with the exact UTC reset time in both the message and
the `x-tm-cap-reset` header.

## What a 401 looks like

A missing, invalid, or disabled key gets `401` with `error_type: "auth"`. The
body is shaped for whichever SDK family is calling, so your SDK's native error
class fires; the stable `error_type` rides alongside. Headers always include
`x-request-id`, `x-tm-error-code: auth` and `x-tm-error-origin: authorization`.

OpenAI surface (`/v1/chat/completions` and every non-Anthropic path):

```json
{
  "error": {
    "code": "auth",
    "message": "missing or invalid API key",
    "type": "invalid_request_error",
    "metadata": { "error_type": "auth", "origin": "authorization", "retryable": false }
  },
  "request_id": "..."
}
```

Anthropic surface (`/v1/messages`):

```json
{
  "type": "error",
  "error": {
    "type": "authentication_error",
    "message": "missing or invalid API key",
    "error_type": "auth",
    "metadata": { "origin": "authorization", "retryable": false }
  },
  "request_id": "..."
}
```

Both SDK families raise their own `AuthenticationError` for these. The message
is deliberately the same for every 401 cause — check, in order: the header form,
the key value, and whether the key was disabled in the console.

## Key hygiene

- Keep keys in environment variables or a secret store; never commit them and
  never put them in URLs — the gateway only reads the two headers above.
- Use one key per app or agent, so the console's per-key spend and caps tell
  you who spent what — and so disabling one key kills one integration, not all
  of them.
- The console also mints keys of its own: one **Playground** key per
  organization, and one per Optimize search. They show on the keys page with a
  **managed** tag, are never displayed, and bill your credits like any key.

Next: [Quickstart](/docs/quickstart) for browser signup and the first API calls, or
[Errors](/docs/errors) for the complete `error_type` table.
