# Authentication

> API key format, the two header forms, the six causes of a 401, the suspended-workspace 403, and how to roll or revoke a key.

Source: https://docs.amomic.in/api/authentication

---

Every call to `/api/v1` except the OpenAPI document carries an API key. There is one credential type and one place to get it: **Developers → API keys** in the console, which only owners and admins can open (on every plan). Six different reasons produce a `401`, so read `details.reason` before you start debugging. [The Developers page](/console/developers) walks through creating, rolling, and revoking keys screen by screen.

Authentication also enforces workspace billing state. A valid key from a suspended workspace returns `403 permission_denied` with `details.reason: workspace_suspended` on every route except the OpenAPI document, `/ping` included. Restoring billing re-enables the same keys; they do not need to be recreated.

## Sending the key

Two header forms are accepted. Use whichever your HTTP client makes cleaner.

::: codegroup

```bash Authorization
curl https://services.amomic.in/api/v1/ping \
  -H "Authorization: Bearer amk_live_7Fk2xQ..."
```

```bash X-Api-Key
curl https://services.amomic.in/api/v1/ping \
  -H "X-Api-Key: amk_live_7Fk2xQ..."
```

:::

They are equivalent. If you send both, **`X-Api-Key` wins** — it is checked first and the `Authorization` header is never read. That matters when a proxy or gateway in front of your service injects its own `Authorization` header: put your key in `X-Api-Key` and it will not be shadowed.

The `Bearer ` prefix is matched case-insensitively and the token is trimmed, so `bearer amk_live_…` and a stray trailing space both work. Anything else in the `Authorization` header is treated as no key at all.

Send neither and you get:

```json title="401 Unauthorized"
{
  "error": {
    "code": "unauthenticated",
    "message": "Provide your API key as `Authorization: Bearer amk_…` or `X-Api-Key`.",
    "details": { "reason": "missing_api_key" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

Every `401` also carries a `WWW-Authenticate: Bearer` response header, so standards-aware HTTP clients recognise it as an authentication challenge rather than a generic failure.

## Key format

```
amk_live_7Fk2xQ9pLm4RtY8vN3wZ0aB6cD1eF5gH7iJ2kL4mN6o
│   │    └─ secret: 32 random bytes, base64url, padding stripped — 43 characters
│   └────── environment: exactly "live" or "test"
└────────── product prefix: always "amk"
```

Each part earns its place:

- **`amk`** is a constant so that secret scanners — GitHub's, your own pre-commit hook, your log scrubber — can find a leaked Amomic-AI key by pattern. A key that looks like generic base64 is a key nobody notices in a diff.
- **`live` / `test`** makes the environment visible at a glance, in a log line, in a `.env` file, in a screenshot on a support call. It is also enforced server-side; see [environments](#environments-test-versus-live) below.
- **The secret** is 256 bits from a cryptographic RNG. Nothing about it is derived from your workspace, your email, or the time it was created.

A token that is not exactly three underscore-separated parts, whose second part is not `live` or `test`, or whose secret is shorter than 20 characters is rejected as malformed **before any lookup happens**. A truncated key pasted from a terminal fails instantly with `malformed_api_key`, not with a vague "invalid".

## We store a hash, not your key

When a key is created, the raw token is generated, returned to you, and then discarded. What is stored is a SHA-256 hash of the secret plus a six-character display hint for the masked form you see in the console (`amk_live_7Fk2xQ…`).

::: danger The secret is shown exactly once
There is no second look. Support cannot retrieve it. An engineer with database access cannot retrieve it — the value is not there. Put the key in your secret manager before you close the creation dialog.

Lost it? Do not create a second key and leave the first one live. **Roll** it: you get a new secret and the old one keeps working for 24 hours, so you can deploy without a gap. See [Rolling a key](#rolling-a-key-without-downtime).
:::

SHA-256 rather than bcrypt or Argon2 is a deliberate choice, and the reasoning is worth understanding because it is the opposite of the right answer for passwords. Password hashing is slow on purpose because human passwords are guessable and an attacker with the hash file can grind them. An API key secret is 256 bits from a CSPRNG — there is nothing to grind. Brute force is not the threat model, so the design optimises for what is: verification is a single fast lookup, which is what lets a revoked key stop working in seconds instead of minutes.

![The API keys tab listing keys with masked prefixes, scope badges, last used dates and Active, Expires soon and Revoked statuses, with Roll and Revoke buttons and the base URL footer](https://docs.amomic.in/screens/developers/keys.light.webp)

*The key list shows the name, a masked prefix, the scopes, when it was last used, and its status. The full secret is never shown again.*

## Environments: test versus live

There is one production API. The `live` / `test` distinction is not a mode you toggle per request — it is baked into the key at creation and checked against the host it is used on.

- A key issued in production is a **live** key. It works against `services.amomic.in`. Real messages leave the building, real calls are dialled, real money is spent.
- A key issued in a non-production environment is a **test** key, and it only works there.

Cross them and you get a `401` that names the problem exactly:

```json title="401 Unauthorized"
{
  "error": {
    "code": "unauthenticated",
    "message": "That is a test key; this environment expects a live key.",
    "details": { "reason": "wrong_environment" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

::: note wrong_environment is not a bad secret
This is the single most misread error on the API. `wrong_environment` means the key is genuine, unrevoked, and correctly formatted — it is simply the wrong side of the environment boundary. Nobody needs to re-issue anything. Your staging deployment is pointed at the production host, or your production deployment picked up the staging secret. Check which `AMOMIC_API_KEY` your process actually loaded before you touch the console.
:::

Note it is a `401`, not a `403`. The key is not recognised *as a credential here*, which is an authentication outcome, not a permission one.

## The validation order

Checks run in a fixed order, and each one produces a different error. Knowing the order tells you what has already been proven true by the time you see a given failure.

::: steps

::: step Is a key present?
Neither header set, or an `Authorization` header that is not a bearer token.
→ `401` `unauthenticated`, `reason: missing_api_key`
:::

::: step Does it parse?
Three parts, `amk` first, `live` or `test` second, a secret of at least 20 characters. **No datastore lookup happens at this stage** — a malformed token never touches storage.
→ `401` `unauthenticated`, `reason: malformed_api_key`, message `"That doesn't look like an Amomic-AI API key."`
:::

::: step Does the secret match a real key?
The record is looked up and the hash compared.
→ `401` `unauthenticated`, `reason: invalid_api_key`, message `"That API key is not valid."`
:::

::: step Has it been revoked?
→ `401` `unauthenticated`, `reason: revoked_api_key`, message `"That API key has been revoked."`
:::

::: step Has it expired?
Only rolled keys expire, when their 24-hour grace window closes.
→ `401` `unauthenticated`, `reason: expired_api_key`, message `"That API key was rolled and its grace period has ended."`
:::

::: step Is it the right environment?
→ `401` `unauthenticated`, `reason: wrong_environment`
:::

::: step Is the workspace in good standing?
A suspended workspace's keys stop working until billing is restored.
→ `403` `permission_denied`, `reason: workspace_suspended`, message `"This workspace is suspended. Restore billing to use its API keys."`
:::

::: step Is the key within its rate limit?
→ `429` `rate_limited`. See [Rate limits](/api/rate-limits).
:::

::: step Does it have the scope this route needs?
→ `403` `permission_denied`, `reason: missing_scope`. See [Scopes](/api/scopes).
:::

:::

Read that backwards when you are debugging. A `403` with `missing_scope` proves your key is valid, unrevoked, in the right environment, in a workspace in good standing, and under its rate limit: the only thing wrong is what it is allowed to do. A `429` proves everything except the scope check. And an `invalid_api_key` proves the token was well-formed, which usually means you are holding a *real* key from another workspace or another environment's database, not a typo.

### The six causes of a 401

| `details.reason` | Message | What it actually means |
|---|---|---|
| `missing_api_key` | Provide your API key as `Authorization: Bearer amk_…` or `X-Api-Key`. | No header reached us. Check for a proxy stripping `Authorization`. |
| `malformed_api_key` | That doesn't look like an Amomic-AI API key. | Truncated paste, wrapped line, quotes included, or a placeholder that was never substituted. |
| `invalid_api_key` | That API key is not valid. | Well-formed but unknown. Usually a key from a different workspace, or one character altered. |
| `revoked_api_key` | That API key has been revoked. | Someone revoked it in the console. Permanent — mint a new key. |
| `expired_api_key` | That API key was rolled and its grace period has ended. | The old half of a roll, more than 24 hours later. Deploy the new secret. |
| `wrong_environment` | That is a `<env>` key; this environment expects a `<env>` key. | Right key, wrong host. Not a credential problem. |

All six are `code: "unauthenticated"` and HTTP `401`. Branch on `details.reason`, never on the message text — messages are written for humans and may be reworded.

## The five-second verification cache

A verified key record is cached in-process for **5 seconds**. This is why the API can afford a single hash lookup per request without a datastore round-trip on every call.

Two consequences you will actually notice:

- **Changes take effect within seconds, not instantly everywhere.** Rolling or revoking a key takes effect immediately on the server that handled the change, and everywhere else within 5 seconds. If you revoke a leaked key and a request slips through 2 seconds later, that is this window — not a failure of the revoke.
- **`last_used_at` in the console is coarse.** It is written at most once a minute per key, so a key that fired 4,000 requests in the last minute may show a `last_used_at` up to a minute stale. Use it to answer "is this key still in use at all", not "when exactly was the last call". For per-request truth, use the request log under **Developers → Logs**.

## Rolling a key without downtime

Rolling is how you rotate a secret without a deployment window. In the console, on the key's row, click **Roll**. There is no confirmation step: the replacement is created straight away.

What happens:

1. A **new** key is minted with the same name, the same scopes, and the same rate limit. Its secret is shown once, in the **Copy your API key** dialog. This is the only moment other than creation that a secret is shown.
2. The old key's row shows **Expires soon**, and the key expires **24 hours** from that moment.
3. **Both keys authenticate for those 24 hours.**

So the safe sequence is: roll, copy the new secret into your secret manager, deploy, confirm traffic is flowing on the new key, and let the old one lapse. You never have a moment where no valid key exists.

After the grace window closes, the old secret returns `401` with `reason: expired_api_key`. Nothing is deleted before then, and nothing needs to be cleaned up after.

::: tip Rolling is the answer to more than rotation
Lost the secret, a laptop went missing, a contractor left, the key showed up in a CI log — all of these are rolls, not revokes. Revoke is for when you need the old secret dead *now* and can accept the outage. Roll is for everything with a deploy in front of it.
:::

A revoked key cannot be rolled: its row has no **Roll** or **Revoke** button.

Rolling copies the scopes exactly, so it cannot change what a key can do. To grant a different set of scopes, see [Scopes](/api/scopes#changing-what-a-key-can-do).

## Revoking a key

Revoking is immediate and permanent. In the console, click **Revoke** on the key's row, then **Revoke key** in the **Revoke this key?** dialog. Within 5 seconds every request carrying it fails with `401` and `reason: revoked_api_key`.

The key is not deleted from the list. Its row stays, with a **Revoked** status and no actions, so you can still see what it was called and which scopes it held.

**There is no un-revoke.** If you revoke the wrong key, you create a new one and redeploy — the secret is genuinely gone.

Revoke, rather than roll, when the secret is known to be compromised and you would rather break your own integration than leave a window open. That is the whole trade: roll keeps you running, revoke stops the bleeding.

## Key hygiene that actually helps

These are not ceremony. Each one exists because of a specific bad hour it prevents.

::: steps

::: step One key per system that calls us
Not one per workspace, not one shared across every service. The order service, the CRM sync, and the analytics exporter get three keys.

The payoff arrives when something goes wrong: you can revoke the leaked one without taking down the other two, the request log's **Key** column shows which system is generating the errors, and you can scope each one to exactly what it needs — the analytics exporter never gets `messages:send`.
:::

::: step Name the key after the system, not the person or the date
`Order service` and `Zoho sync (prod)` are good names. `Vinit's key`, `test`, and `key 2` are not. At 3 a.m., when one key is throwing 429s, the name is the only thing that tells you which deployment to look at — and the person who created it may have left the company.
:::

::: step Give it the narrowest scope that works
The create dialog starts with two read scopes ticked (**Read conversations** and **Read agents**). Tick the write scopes this system needs before you create the key: a key's scopes cannot be changed afterwards. If a request later fails with `missing_scope`, create a replacement key with the right scopes, deploy it, and revoke the old one. See [Scopes](/api/scopes).
:::

::: step Keep it out of source control, images, and logs
The `amk_` prefix exists so scanners can catch it — enable secret scanning on your repositories and add the pattern to your log scrubber. Load it from your secret manager at runtime; do not bake it into a container image, where it survives in every layer that ever held it.
:::

::: step Roll on staff change and on schedule
When someone who could have copied a key leaves, roll every key they could have copied. Absent that, a quarterly roll costs one deploy and bounds how long any undetected leak stays useful.
:::

::: step Point every key at the right host
The environment is in the key. Load `amk_live_…` in production and `amk_test_…` in staging, and make your config loader assert the prefix matches the environment it thinks it is running in. That one assertion turns a confusing `wrong_environment` in production into a startup failure in staging.
:::

:::

## Verify all of this in one call

`GET /ping` is the fastest way to find out what the API thinks of your key. No side effects, no scope required beyond a valid key.

::: codegroup

```bash cURL
curl https://services.amomic.in/api/v1/ping \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```javascript Node.js
const response = await fetch("https://services.amomic.in/api/v1/ping", {
  headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
});
console.log(await response.json());
```

```python Python
import os, requests

response = requests.get(
    "https://services.amomic.in/api/v1/ping",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    timeout=10,
)
print(response.json())
```

:::

```json title="200 OK"
{
  "ok": true,
  "workspace_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4",
  "key_name": "Order service",
  "environment": "live",
  "scopes": ["agents:read", "conversations:read", "messages:send"]
}
```

That response answers four questions at once: the key is valid, it is pointed at the right workspace, it is the environment you expected, and it holds the scopes you think it does. Run it in your integration's startup health check.

## Next

::: cards

::: card Scopes
[What each scope unlocks](/api/scopes), the read-only default, and the endpoint matrix.
:::

::: card Errors
[The full error contract](/api/errors) — nine codes, one envelope, every `details.reason`.
:::

::: card Rate limits
[120 requests per key per minute](/api/rate-limits), and how to back off without a `Retry-After`.
:::

::: card Security and data
[What we store and for how long](/operate/security), including the request log.
:::

:::
