> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hopscotchlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> API keys end to end: the Bearer header, the key format, shown-once keys, the five-slot ceiling, and how a key ends.

export const FeatureStatus = ({missing, feature, instead, detail, children}) => <Info>
    <strong>Not available yet: {missing ?? feature}.</strong>
    {instead ?? detail ? ` ${instead ?? detail}` : null} This page documents what
    the product does today. When that changes, this page changes with it.
    {children}
  </Info>;

export const NotDocumented = ({subject, reason}) => <Info>
    <strong>{subject} is not documented here.</strong> {reason} We would rather
    say nothing than describe something you cannot use.
  </Info>;

Every request to the public API is authenticated by one API key on one header.
There is no other credential, no session, and no signature: a key opens the door
and nothing else does.

## The header

Every request to the API carries your key in an `Authorization` header using the
`Bearer` scheme. A missing header, a scheme other than `Bearer`, a key of the
wrong shape, and a key we reject are all answered with `401`.

<CodeGroup>
  ```bash curl theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  curl https://api.hopscotchlabs.ai/v1/chat/completions \
    -H "Authorization: Bearer $HOPSCOTCH_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "openai/gpt-4o-mini",
      "messages": [{"role": "user", "content": "Say hi in five words."}]
    }'
  ```

  ```python Python theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  import os
  from openai import OpenAI

  client = OpenAI(
      base_url="https://api.hopscotchlabs.ai/v1",
      api_key=os.environ["HOPSCOTCH_API_KEY"],
  )
  ```

  ```typescript Node theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  import OpenAI from "openai";

  const client = new OpenAI({
    baseURL: "https://api.hopscotchlabs.ai/v1",
    apiKey: process.env.HOPSCOTCH_API_KEY,
  });
  ```
</CodeGroup>

Keep the key out of your source. Read it from the environment, as above.

<CodeGroup>
  ```bash curl theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  curl https://api.hopscotchlabs.ai/v1/chat/completions \
    -H "Authorization: Bearer ub_live_YOUR_KEY_HERE" \
    -H "Content-Type: application/json" \
    -d '{"model": "openai/gpt-4o-mini", "messages": [{"role": "user", "content": "Hello."}]}'
  ```

  ```python Python theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  from openai import OpenAI

  # The SDK puts the key on the Authorization header for you.
  client = OpenAI(
      base_url="https://api.hopscotchlabs.ai/v1",
      api_key="ub_live_YOUR_KEY_HERE",
  )
  ```

  ```javascript Node theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
  import OpenAI from "openai";

  // The SDK puts the key on the Authorization header for you.
  const client = new OpenAI({
    baseURL: "https://api.hopscotchlabs.ai/v1",
    apiKey: "ub_live_YOUR_KEY_HERE",
  });
  ```
</CodeGroup>

The scheme must be `Bearer`. A missing header and a header using another scheme
get the same refusal, because from our side they are the same thing: no key
arrived.

## The key format

A key is a non-secret prefix followed by 43 random characters from the base64url
alphabet, which is `A-Z`, `a-z`, `0-9`, `-` and `_`.

| Prefix     | Environment                                             |
| ---------- | ------------------------------------------------------- |
| `ub_live_` | Production. This is the one you get from the dashboard. |
| `ub_stg_`  | Staging.                                                |
| `ub_dev_`  | Development.                                            |

The prefix carries the environment for two reasons. Secret scanners can recognise
a leaked Hopscotch key in a public repository, and a key pasted into the wrong
configuration says out loud which environment it belongs to rather than failing
with a puzzle.

The prefix and the last four characters are not secret and stay visible in the
dashboard, which is how you tell two keys apart after the fact. The other 39
characters are gone from our side the moment the key is shown.

## Keys are shown once

The dashboard says it plainly, and this page says the same thing: a key appears
once and we keep only a hash, so we cannot show it to you again or recover it for
you. Copy it into your secret store before you leave the screen.

There is no recovery path, and that is a property of the design rather than a
missing feature: what we store is a one-way hash, so a leak of our database
yields no working key, and the same fact is what stops us reading yours back. If
you lose a key, mint a new one. Ending the lost one is covered under
[how a key ends](#how-a-key-ends) below, and today that is not yet something you
can do for yourself.

## Keys belong to the workspace

A key is the workspace's, not the person's who minted it. Its spend lands on the
workspace's balance and its requests land in the workspace's usage. That has a
consequence worth knowing before you organise your team around it: removing the
member who created a key does not, on its own, stop the key. Revoking is an
explicit choice, made per key, at the moment of removal. See
[workspaces and members](/concepts/workspaces-and-members).

## Five live keys per workspace

A workspace holds up to five live keys. An outstanding key reservation attached
to an invitation occupies one of the five while it is outstanding, and frees it
if the reservation is cancelled or lapses. When all five slots are held, minting
refuses until a slot frees.

Five is the ceiling on live keys, not on keys ever minted. A key that has been
ended frees its slot.

## How a key ends

The set of things that may ever **end** a key, meaning the key stops existing and
its slot frees, is closed. There are three, and this is their status today:

| Reason                                                                     | What causes it                                                     | Available today |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------- |
| You revoke it                                                              | Somebody with access ends that key deliberately.                   | Not yet         |
| It was replaced                                                            | Minting a replacement ends the key it replaces in the same motion. | Not yet         |
| A member was removed and the person removing them chose to revoke that key | Removal asks about each key rather than deciding for you.          | Yes             |

Expiry is a fourth way a key **stops serving traffic**, but it does not end the
key in the sense above: an expired key still exists and still holds its slot, it
is simply refused with `code: key_expired`. Nothing renews it, so the only way
to keep serving traffic is a new key, and the old one continues to occupy a slot
until it is separately revoked or replaced.

Two things that do not end a key, stated because their absence is worth knowing:
a role change never revokes a key, and neither does a workspace pause. A pause
stops requests while the keys stay live, so lifting it needs no new key.

We do not publish a figure for how quickly ending a key takes effect everywhere.
The mechanism that would make a global timing promise true is not built, and
quoting a number we have not measured would be worse than saying nothing.

## The 401 responses

Refusals arrive in the OpenAI error envelope, so an OpenAI client parses them
without changes.

Every error on the API, at every status, is one JSON object with a single
top-level `error` key. An OpenAI client parses it with the error handling it
already has.

```json Error envelope theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
{
  "error": {
    "message": "Incorrect API key provided.",
    "type": "authentication_error",
    "param": null,
    "code": "invalid_api_key",
    "request_id": "REQUEST_ID"
  }
}
```

| Field        | Type           | Notes                                                                       |
| ------------ | -------------- | --------------------------------------------------------------------------- |
| `message`    | string         | Prose for a person. It may be reworded, so do not branch on it.             |
| `type`       | string         | The category an SDK branches on. A closed set.                              |
| `param`      | string or null | The request field at fault, or `null`. Present and null rather than absent. |
| `code`       | string or null | The stable machine-readable reason. Branch on this.                         |
| `request_id` | string         | Our id for this request. Quote it when you contact support.                 |

Branch on `type` and `code`. Both are stable. `message` is prose and may change.

The `type` values are `invalid_request_error`, `authentication_error`,
`permission_error`, `not_found_error`, `rate_limit_error`, `api_error`,
`insufficient_quota`, and `server_error`.

All four carry `"type": "authentication_error"`. The first three share
`"code": "invalid_api_key"`; the fourth carries its own code, `key_expired`,
because the fix is different: an invalid key means the string is not a key
here, while an expired key was valid and just needs replacing. Branch on
`code` rather than on the message.

| What happened                                              | The message                                                                                                                                       |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| No `Authorization` header, or a scheme other than `Bearer` | `You did not provide an API key. Send it in an Authorization header using Bearer auth.`                                                           |
| A string that is not shaped like a Hopscotch key           | `Incorrect API key format. A Hopscotch key looks like ub_live_ followed by 43 characters. Check you have not pasted a key from another provider.` |
| A correctly shaped key we do not accept                    | `Incorrect API key provided.`                                                                                                                     |
| A key past its own expiry date (`code: key_expired`)       | Names the instant the key expired on. Checked before the per-key rate limiter, so an expired key never consumes that allowance.                   |

The second is separated from the third on purpose. A key of the wrong shape is a
configuration mistake, usually a key from another provider or a truncated paste,
and saying so saves you an afternoon while telling an attacker nothing they do
not already know about their own string.

The third covers both a key that never existed and a key that was revoked, with
one identical sentence. That is deliberate: confirming that a stolen key was once
real is worth a great deal to whoever stole it and nothing to anybody else. Only
our own logs tell the two apart.

```json 401 theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
{
  "error": {
    "message": "Incorrect API key provided.",
    "type": "authentication_error",
    "param": null,
    "code": "invalid_api_key",
    "request_id": "00000000-0000-4000-8000-000000000000"
  }
}
```

## Per-key rate limiting

A key carries a request rate limit, checked as the key is authenticated. Over it,
the response is 429 with type `rate_limit_error` and code `rate_limit_exceeded`,
and it carries a `Retry-After` header in seconds. The message names the wait:

```json 429 theme={"theme":{"light":"vitesse-light","dark":"vesper"}}
{
  "error": {
    "message": "Rate limit reached for this key. Try again in 12 seconds. Request id 00000000-0000-4000-8000-000000000000.",
    "type": "rate_limit_error",
    "param": null,
    "code": "rate_limit_exceeded",
    "request_id": "00000000-0000-4000-8000-000000000000"
  }
}
```

This is the limit on your key's request rate, which is ours to set and which you
cannot raise yourself. It is a different refusal from your account spend rate cap,
which you can raise, and the codes are distinct so your client can tell them
apart. See [rate limits and spend controls](/concepts/rate-limits-and-spend-controls).

<Note>
  Every response carries the same id on two headers: `x-hopscotch-request-id`,
  which is ours, and `x-request-id`, which is the one the official OpenAI SDKs
  surface on their error objects. Every error envelope repeats it as
  `request_id`. Quote it when you ask us about a request. Without it we are
  guessing at which of your requests you mean.
</Note>

## Handling keys well

* Keep keys in a secret store or an environment variable, never in source
  control. The `ub_live_` prefix exists partly so that scanners catch the mistake,
  but catching it after the fact is not the same as avoiding it.
* Use one key per deployment or per service rather than one shared key, within the
  five-slot ceiling. Usage is attributed per key, so separate keys are what make a
  spending question answerable, and ending one then costs you one deployment
  rather than all of them.
* We set no expiry on a key ourselves and publish no rotation requirement, so a
  key stays live until something on the list above ends it. You may give a key an
  expiry date of your own, and a key past it is refused with code `key_expired`.
  See [rate limits and spend
  controls](/concepts/rate-limits-and-spend-controls).
