---
title: Authentication
description: How Half Off Dev API keys work: the four places a key can travel, a free way to test one, the 401 errors you may see, and how to keep keys safe.
---

Half Off Dev has a single kind of credential: an API key tied to your prepaid balance. The same key calls Claude, GPT and Gemini, and you send it in whatever header your SDK or CLI already uses, so there is no auth code to write.

## Creating a key {#create-a-key}

Keys are made on the [API keys](/app/keys) page. Each one is `ho-` plus 40 letters and digits, and it is displayed once. Half Off Dev keeps only a hash, which means a lost key is gone for good: revoke it and issue a new one.

Every sample in these docs expects the key in `$HALFOFF_API_KEY`:

```bash
export HALFOFF_API_KEY="ho-..."
```

Spend caps, model allow-lists and expiry dates are covered in [API keys](/docs/account/api-keys).

## Where the key can go {#key-locations}

You don't have to pick a header to suit Half Off Dev: each of the four forms below is accepted on every endpoint, so leave your client's default alone.

| Form | Looks like | Typical sender |
| --- | --- | --- |
| Bearer token | `Authorization: Bearer ho-...` | OpenAI SDK, Codex CLI, Claude Code via `ANTHROPIC_AUTH_TOKEN`, OpenAI-compatible apps |
| `x-api-key` header | `x-api-key: ho-...` | Anthropic SDK |
| `x-goog-api-key` header | `x-goog-api-key: ho-...` | Google Gen AI SDK, Gemini CLI |
| `key` query parameter | `?key=ho-...` | Gemini REST samples |

When a request carries several, Half Off Dev reads them in this order and uses the first it finds: `Authorization: Bearer`, then `x-api-key`, then `x-goog-api-key`, then `?key=`.

> [!TIP] Headers beat query strings
> A URL with a key in it gets copied into proxy logs and shell history. Reach for `?key=` only when a client has no way to set a header.

## Test a key for free {#test-a-key}

Token counting checks the key and the model exactly as a real request would, but nothing is charged. A `200` with an `input_tokens` number means the key is live and allowed to call that model.

```bash title="cURL"
curl https://halfoff.dev/v1/messages/count_tokens \
  -H "x-api-key: $HALFOFF_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-5.5",
    "messages": [{"role": "user", "content": "Is this key working?"}]
  }'
```

```python title="Python"
import os
import anthropic

client = anthropic.Anthropic(
    base_url="https://halfoff.dev",
    api_key=os.environ["HALFOFF_API_KEY"],
)
result = client.messages.count_tokens(
    model="claude-sonnet-5.5",
    messages=[{"role": "user", "content": "Is this key working?"}],
)
print(result.input_tokens)
```

```js title="Node.js"
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  baseURL: 'https://halfoff.dev',
  apiKey: process.env.HALFOFF_API_KEY,
});
const result = await client.messages.countTokens({
  model: 'claude-sonnet-5.5',
  messages: [{ role: 'user', content: 'Is this key working?' }],
});
console.log(result.input_tokens);
```

## Public endpoints {#public-endpoints}

The model catalog is open, so you can check ids and prices before you create an account. Any key sent to these endpoints is ignored.

| Endpoint | Returns |
| --- | --- |
| `GET /v1/models` | All models with context size, prices and channel groups |
| `GET /v1beta/models` | The same catalog in Gemini's format |

All model requests need a key, token counting included.

## 401 errors {#auth-errors}

If the key is absent, unknown, revoked or expired, you get a `401`, shaped like the errors of the API you were calling.

| Cause | Message |
| --- | --- |
| The request has no key | `Missing API key` |
| The key is unknown or revoked, or the account is suspended | `Invalid or revoked API key` |
| The key's expiry date has passed | `This API key has expired` |

```json title="Anthropic"
{"type": "error", "error": {"type": "authentication_error", "message": "Invalid or revoked API key"}}
```

```json title="OpenAI"
{"error": {"message": "Invalid or revoked API key", "type": "authentication_error", "code": "authentication_error"}}
```

```json title="Gemini"
{"error": {"code": 401, "message": "Invalid or revoked API key", "status": "UNAUTHENTICATED"}}
```

A key that authenticates fine can still be turned away by its own limits: `403` when the model isn't on its allow-list, `402` once its spend cap is used up, `429` at its rate limit. [API keys](/docs/account/api-keys) explains those limits and [Errors](/docs/api/errors) lists every status.

## Handling keys safely {#key-safety}

> [!WARNING] Leaked a key?
> Revoke it on [API keys](/app/keys). The very next request made with it is refused. Then create a replacement and look through [Logs](/app/logs) for calls you didn't make.

- **Server side only.** The API answers requests from any origin, so a key shipped inside a web page or mobile app can be lifted and spent by anyone. Call Half Off Dev from your backend.
- **Never in the repo.** Read keys from environment variables or a secrets manager, and list `.env` files in `.gitignore`.
- **Separate keys per app and per environment.** A leaked staging key can't reach production, and [Logs](/app/logs) tell you which key sent what.
- **Give shared keys limits.** Keys for CI jobs, scripts or teammates should carry a spend cap, a model allow-list and an expiry date. See [API keys](/docs/account/api-keys).
- **Rotate on a schedule**, and immediately when someone who had a key leaves. [Security](/docs/account/security) has more.
