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

# Authentication

> Issue + manage API keys for the Vorel public API. Bearer-token auth; scrypt-hashed at rest; per-key scopes; rotation + revocation flows.

The Vorel public API uses bearer-token authentication with scoped, per-tenant API keys. Each key is hashed at rest with **scrypt**; the plaintext is shown to the issuer **once** at creation and never again.

## Key format

```
vapk_live_<48 hex characters>
```

Total length: **58 characters** (10-char prefix `vapk_live_` + 48 hex chars from 24 random bytes).

The `vapk_live_` prefix is intentional and has three purposes:

1. **Format-distinctive**: log scrubbers (our error-reporting redactor, log-shipper rules) can pattern-match `vapk_*` and drop these strings before they reach stored events.
2. **Cheap shape-validation**: middleware short-circuits malformed tokens (random Slack pastes, OAuth-flow `Bearer` carryover) before paying for a hash + DB round-trip.
3. **Indexed lookup prefix**: the first 22 chars of every issued key are stored in DB un-hashed (`vapk_live_<12 hex>`) so verification is a single indexed query plus an scrypt compare.

Today only one environment tag (`live`) ships. Future per-environment tags would slot into the same shape (e.g. `vapk_test_*` for sandbox keys) without breaking the format contract.

## Issuing a key

<Steps>
  <Step title="Sign in at app.vorel.ai">
    The dashboard uses an authenticated session. You need the `admin` or `owner` role on your tenant
    to issue keys.
  </Step>

  <Step title="Navigate to Settings → Integrations → API keys">
    The full path: `app.vorel.ai/(dashboard)/settings/integrations/api-keys`.
  </Step>

  <Step title="Click Create API key">
    Pick a descriptive name (used in audit logs + the key list view) and select the scopes the
    integration needs.
  </Step>

  <Step title="Copy the key (once)">
    The plaintext secret is shown ONCE. Subsequent reads see only the prefix + last 4 characters.
    Store it in your secret manager immediately. If you lose it, revoke + reissue.
  </Step>
</Steps>

The audit log records every issuance with the actor's user id, the requested scopes, and the
resulting key id. "Who created this key, when, with what scopes?" is always answerable.

## Scopes

Vorel's API surface is scope-gated. A key with scope `read` can call any GET endpoint; mutating
endpoints require the resource-specific write scope.

| Scope                 | Allows                                                                                     |
| --------------------- | ------------------------------------------------------------------------------------------ |
| `read`                | Every GET endpoint (conversations, leads, appointments, offerings, analytics)              |
| `leads:write`         | `POST /v1/leads`, `PATCH /v1/leads/:id`, `POST /v1/leads/:id/handoff`                      |
| `appointments:write`  | `POST /v1/appointments`, `PATCH /v1/appointments/:id`                                      |
| `offerings:write`     | `POST /v1/offerings`, `PATCH /v1/offerings/:id`                                            |
| `conversations:write` | `POST /v1/conversations`, `PATCH /v1/conversations/:id`, `POST /v1/conversations/:id/send` |
| `crm:write`           | `POST /v1/crm/create-record`                                                               |

Issue keys with the **minimum** scope set the integration needs. A key without the required
scope returns `403 forbidden`. The route doesn't fall through to a less-privileged behaviour.

A key with `read` only is the right choice for analytics/digest workflows; issue separate keys
for each integration so revoking one doesn't take down the others.

## Making a request

```bash theme={null}
curl https://app.vorel.ai/api/v1/conversations \
  -H "Authorization: Bearer vapk_live_..." \
  -H "Content-Type: application/json"
```

The `Authorization` header carries the full plaintext secret. We extract the prefix, look up the
hashed row, and run an scrypt compare against the candidate. Constant-time hash compare via
`timingSafeEqual` so an attacker probing for prefix collisions can't side-channel a partial match.

## Storage + hashing

API key storage and verification:

* **Hashing algorithm:** scrypt (Node's native `crypto.scrypt`), N=2^14, r=8, p=1, 64-byte derived
  key. \~50 ms verification time on an M-class laptop: slow enough to make offline brute-force
  infeasible on a stolen DB, fast enough to live in the per-request middleware.
* **Salt:** 16 random bytes per key, stored alongside the hash as `<salt>:<hash>`.
* **Last-used tracking:** `lastUsedAt` is bumped fire-and-forget on every successful verification
  so a write failure on the bump doesn't block the request.
* **Revocation:** `revokeApiKey(id)` flips `revokedAt` to the current timestamp. Subsequent
  verification calls return `401 unauthorized` even if the secret is otherwise valid.

The hash + salt are stored in `api_keys.key_hash`. The plaintext secret is **never** persisted:
no log line, no metric tag, no error envelope contains it. Our error-reporting redactor + log
scrubbers pattern-match `vapk_*` prefixes and drop them as a defence-in-depth.

## Rotating + revoking keys

<AccordionGroup>
  <Accordion icon="rotate" title="Rotation">
    The lifecycle is:

    1. Issue a new key with the same scope set.
    2. Update your integration to use the new key.
    3. Verify traffic on the new key (the dashboard surfaces "last used" per key).
    4. Revoke the old key.

    There's no in-place rotation API today; issue + cutover + revoke is the canonical flow. The
    "issue" + "revoke" pair are the only mutations on api\_keys.
  </Accordion>

  <Accordion icon="ban" title="Revocation">
    Revoke from the dashboard or via the admin API. The mutation is **idempotent**: re-revoking
    an already-revoked key is a no-op (no audit log noise, no error).

    Once revoked, every call carrying the key returns `401 unauthorized` with `code='unauthorized'`
    and `message='unknown or revoked api key'`. The revoked row stays in DB for forensic purposes;
    it's not purged.
  </Accordion>

  <Accordion icon="triangle-exclamation" title="Suspected compromise">
    If a key has leaked (committed to a public repo, shared in a screenshot, etc.):

    1. **Revoke immediately.** First action.
    2. **Audit usage.** `lastUsedAt` + the audit log (`actor_type='api_key'`) tells you what the
       leaked key did during its window.
    3. **Reissue** with a fresh secret. Update your integration to use it.
    4. **Optional: rotate any downstream credentials** the leaked key could have read (CRM
       credentials, webhook secrets) if you're being thorough.

    Revoke first, audit second: minimising the attacker's window matters more than perfect
    forensics.
  </Accordion>
</AccordionGroup>

## Errors

The error envelope is consistent across every authentication failure mode:

```json theme={null}
{
  "error": {
    "code": "unauthorized",
    "message": "<context-specific message>"
  }
}
```

| HTTP | Code           | Message variants                                                                           |
| ---- | -------------- | ------------------------------------------------------------------------------------------ |
| 401  | `unauthorized` | `missing or malformed Authorization header` · `unknown or revoked api key`                 |
| 403  | `forbidden`    | `key missing required scope '<scope>'`                                                     |
| 429  | `rate_limited` | `per-key rate limit exceeded` (200 req/min; see [Rate limits](/api-reference/rate-limits)) |

The middleware emits `api_v1.verify_failed` log lines on every 401 with the request id (no
key plaintext). Operators can investigate via the audit log + log search.

## Security properties (in plain language)

* **DB compromise alone doesn't yield working keys.** Hashes leak, but the work factor plus 24 bytes
  of randomness make offline guessing infeasible at any reasonable scale.
* **Stolen-key amplification is bounded.** A leaked key gets its own per-API-key rate-limit bucket
  (200 req/min); it can't burn through other tenants' quotas.
* **Timing-safe verification.** Hash compare uses `timingSafeEqual` against equal-length buffers.
* **Per-issuance audit trail.** Every issuance, every revocation, every successful verification
  with `lastUsedAt` updates land in `audit_log` with the actor + the key id (never the plaintext).

## What's NOT supported today

* **OAuth-style end-customer auth.** All API keys are tenant-scoped; we don't ship an end-customer
  OAuth flow ("let your customers grant access to your data"). The product model is operator-side.
* **Hierarchical / sub-keys.** No "child" keys with scoped subsets of the parent's scope. Issue
  separate top-level keys per integration.
* **Time-bounded keys.** Keys don't auto-expire. Rotate manually per your security policy.
* **IP allowlisting.** No per-key IP allowlist today. The per-IP-webhook rate limit (500 req/min)
  is the only IP-shape gate.
* **Per-environment key tags beyond `live`.** Sandbox / test keys would slot into the same shape
  (`vapk_test_*`) but aren't issued today.

## Related docs

* [API introduction](/api-reference/introduction): the high-level surface
* [Rate limits](/api-reference/rate-limits): per-key + tenant + IP layered stack
* [SDKs](/api-reference/sdks): `@vorel/sdk` typed client
* [Security overview](/security/overview): broader posture, RLS, encryption

{/* verified-against: apps/web/src/lib/api-v1-auth.ts ApiScope union (read, leads:write, appointments:write, offerings:write, conversations:write, crm:write); PER_KEY_RATE_LIMIT=200 */}
