# Authentication

> Authenticate with the Caramel API using OAuth 2.0 + PKCE — from discovery through token refresh.

The Caramel API uses **OAuth 2.0 with PKCE (S256)**. The same token works across the MCP endpoint and the REST gateway. You exchange an authorization code for an access token, send it as a bearer credential on every call, and refresh it before it expires.

## The flow at a glance

```
Your app                  Caramel auth              User inbox
   │                           │                         │
   │── 1. GET /mcp-oauth ─────►│                         │
   │   (PKCE challenge)        │── magic-link email ────►│
   │                           │                         │── click ──►│
   │◄─ 2. redirect ?code=… ───│◄────────────────────────│            │
   │                           │
   │── 3. POST /mcp-oauth ────►│
   │   (code + verifier)       │
   │◄─ { access_token,         │
   │     refresh_token } ──────│
```

## Step 1 — Discover endpoints

Fetch the discovery document before hardcoding any URL.

```bash
curl https://app.caramelme.com/.well-known/oauth-authorization-server
```

Response (abridged):

```json
{
  "issuer":                               "https://api.caramelme.com",
  "authorization_endpoint":               "https://api.caramelme.com/functions/v1/mcp-oauth",
  "token_endpoint":                       "https://api.caramelme.com/functions/v1/mcp-oauth",
  "registration_endpoint":                "https://api.caramelme.com/functions/v1/mcp-oauth/register",
  "response_types_supported":             ["code"],
  "grant_types_supported":                ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported":     ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}
```

Cache the document for at least one hour. If you receive a `404` on a token call, re-fetch discovery before failing — endpoints don't move often, but the cache may be stale.

The same document is reachable at three equivalent URLs:

- `https://app.caramelme.com/.well-known/oauth-authorization-server`
- `https://api.caramelme.com/.well-known/oauth-authorization-server`
- `https://app.caramelme.com/api/functions/caramel-mcp/.well-known/oauth-authorization-server`

## Step 2 — Register a client (optional)

MCP-compatible clients (Claude, Lovable, Cursor) and any client whose redirect URI is `https://claude.ai/api/mcp/auth_callback` or a `http://localhost:<port>/callback` loopback self-register automatically using Dynamic Client Registration (RFC 7591). Skip this step if that describes your client.

If your redirect URI is a hosted URL (for example `https://yourapp.com/oauth/callback`), register it:

```bash
curl -X POST https://api.caramelme.com/functions/v1/mcp-oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name":                "Your app name",
    "redirect_uris":              ["https://yourapp.com/oauth/callback"],
    "grant_types":                ["authorization_code", "refresh_token"],
    "response_types":             ["code"],
    "token_endpoint_auth_method": "none"
  }'
```

Response (`201 Created`):

```json
{
  "client_id":    "your-app-name",
  "redirect_uris": ["https://yourapp.com/oauth/callback"],
  "token_endpoint_auth_method": "none"
}
```

> **Note** Registration only accepts redirect URIs on the server allow-list. Loopback (`localhost`) and `claude.ai` URIs are pre-allowed. Any other hosted URI returns `400 invalid_redirect_uri` — email `aymen@reactmotion.com` to have yours added.

## Step 3 — Build the authorize URL

Generate a PKCE verifier and challenge, then redirect the user.

```js
// Browser or server-side. Store the verifier across the redirect.
function generateVerifier() {
  const arr = new Uint8Array(32);
  crypto.getRandomValues(arr);
  return base64UrlEncode(arr);
}

async function challengeFromVerifier(verifier) {
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  return base64UrlEncode(new Uint8Array(hash));
}

function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const verifier  = generateVerifier();
const challenge = await challengeFromVerifier(verifier);
sessionStorage.setItem('caramel.verifier', verifier);   // persist across redirect

const url = new URL('https://api.caramelme.com/functions/v1/mcp-oauth');
url.searchParams.set('response_type',         'code');
url.searchParams.set('client_id',             CLIENT_ID);
url.searchParams.set('redirect_uri',          REDIRECT_URI);
url.searchParams.set('code_challenge',        challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state',                 crypto.randomUUID());
url.searchParams.set('scope',                 'meta:read forms:write audience:write');
window.location.assign(url.toString());
```

```py
# Python — server-side, store verifier + state in the session
import secrets, hashlib, base64
from urllib.parse import urlencode

verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()

session['caramel_verifier'] = verifier
session['caramel_state']    = secrets.token_urlsafe(16)

params = {
    'response_type':         'code',
    'client_id':             CLIENT_ID,
    'redirect_uri':          REDIRECT_URI,
    'code_challenge':        challenge,
    'code_challenge_method': 'S256',
    'state':                 session['caramel_state'],
    'scope':                 'meta:read forms:write audience:write',
}
return redirect(f'https://api.caramelme.com/functions/v1/mcp-oauth?{urlencode(params)}')
```

The user enters their email address. Caramel sends a magic-link email. The user clicks the link, and the browser redirects to your `redirect_uri` with `?code=AUTH_CODE&state=…`.

> **Important** Always verify the returned `state` matches what you sent before proceeding. A mismatch indicates a CSRF attempt — discard the code and do not exchange it.

## Step 4 — Exchange the code for tokens

POST to the token endpoint. There is no client secret — PKCE replaces it for public clients.

```bash
curl -X POST https://api.caramelme.com/functions/v1/mcp-oauth \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "redirect_uri=$REDIRECT_URI" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$VERIFIER"
```

```js
const res = await fetch('https://api.caramelme.com/functions/v1/mcp-oauth', {
  method:  'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type:    'authorization_code',
    code:          authCode,
    redirect_uri:  REDIRECT_URI,
    client_id:     CLIENT_ID,
    code_verifier: verifier,
  }),
});
if (!res.ok) throw await normalizeError(res);
const { access_token, refresh_token, expires_in } = await res.json();
```

Response:

```json
{
  "access_token":  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "rt_8f3e21c4b7d94a1f8e2c6b5d4a3f2e1d",
  "expires_in":    3600,
  "token_type":    "Bearer",
  "scope":         "meta:read forms:write audience:write"
}
```

## Step 5 — Call the API

Send the bearer token on every request. The MCP endpoint and the REST gateway both accept the same header.

```bash
# MCP
curl -X POST https://app.caramelme.com/api/functions/caramel-mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"caramel.v1.business.list","arguments":{}}}'
```

> **Note** The REST gateway (`https://gateway.caramelme.com/v1/*`) is in limited preview. Use the MCP endpoint above for new integrations.

## Step 6 — Refresh before expiry

Access tokens expire after **1 hour** (`expires_in: 3600`). Refresh 60 seconds before expiry to avoid gaps.

```bash
curl -X POST https://api.caramelme.com/functions/v1/mcp-oauth \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=$CLIENT_ID"
```

```js
async function refresh(refreshToken) {
  const res = await fetch('https://api.caramelme.com/functions/v1/mcp-oauth', {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type:    'refresh_token',
      refresh_token: refreshToken,
      client_id:     CLIENT_ID,
    }),
  });
  if (!res.ok) throw await normalizeError(res);
  return res.json();   // { access_token, refresh_token, expires_in }
}
```

> **Important** Refresh tokens rotate on every use. The response contains a new `refresh_token` — persist it immediately and discard the old one. Using a rotated-out token returns `400 invalid_grant` and forces the user back through the authorize flow.

```js
// Wrong: keeps using the original token — breaks after the second refresh
setInterval(async () => {
  const { access_token } = await refresh(originalRefreshToken);
}, 50 * 60 * 1000);

// Right: replace the stored token on every refresh
let currentRefreshToken = '…';
setInterval(async () => {
  const { access_token, refresh_token } = await refresh(currentRefreshToken);
  currentRefreshToken = refresh_token;   // persist the new one
}, 50 * 60 * 1000);
```

## Token lifetimes

| Token | Lifetime | What happens at expiry |
|---|---|---|
| Access token | 1 hour | API calls return `401`. Refresh once, then retry. |
| Refresh token | 30 days (from last use) | `400 invalid_grant` on refresh. Re-authorize the user. |

Refresh tokens rotate on every use. If you don't use a refresh token for 30 days, it expires silently.

## Scopes

Request only the scopes your app needs. Scopes are additive — a token granted `meta:read` cannot call `audience:write` tools.

| Scope | What it covers | Example tools |
|---|---|---|
| `meta:read` | Capability discovery and usage counters (always granted) | `caramel.v1.meta.capabilities`, `caramel.v1.meta.usage`, `caramel.v1.business.list` |
| `forms:read` | List forms and read form schemas | `caramel.v1.form.list` |
| `forms:write` | Submit form data | `caramel.v1.form.submit` |
| `audience:read` | List and read contacts | (shipping soon) |
| `audience:write` | Upsert contacts | `caramel.v1.contact.upsert` |
| `messaging:send` | Trigger message sends | `caramel.v1.email.send`, `caramel.v1.sms.send` (coming soon) |
| `provisioning:write` | Sender domain management | `caramel.v1.domain.status` |

`meta:read` is always included regardless of what you request. If a tool requires a scope your token doesn't have, the call returns `403` with `code: "scope_required"`. There is no scope-upgrade endpoint — send the user through the authorize flow again with the additional scope.

## Error responses

| HTTP | Body `code` | Cause | Fix |
|---|---|---|---|
| `400` | `invalid_request` | Missing or malformed parameter | Check `client_id`, `code`, `code_verifier`, `redirect_uri` |
| `400` | `invalid_grant` | Code expired, already used, or refresh token rotated out | Restart the authorize flow |
| `400` | `invalid_client` | `client_id` not found or disabled | Check the value; contact support if it should exist |
| `401` | `unauthorized` | Token missing, malformed, or expired | Refresh once, then retry; if still `401`, re-authorize |
| `403` | `scope_required` | Token valid but missing required scope | Re-authorize with the additional scope |
| `403` | `tier_required` | Tool requires a higher business tier | Upgrade the business tier |

### The `messsage` (sic) gotcha

Some error responses use the misspelled key `messsage` (three s's) instead of `message`:

```json
{ "error": "invalid_grant", "messsage": "Code expired" }
```

Normalize both keys in your error handler or you'll get silent empty-error failures:

```js
function normalizeError(body) {
  return {
    code:    body.error || body.code,
    message: body.message ?? body.messsage ?? 'Unknown error',
  };
}
```

## Token storage

- **Refresh tokens** are bearer credentials. Encrypt at rest. Never log them. Never send them to the browser outside the OAuth round-trip.
- **PKCE verifiers** belong in server-side session storage — not `localStorage`. Discard after one authorize round-trip.
- **State parameters** belong in the same session storage. Discard after one use.
- **Access tokens** can live in memory for the request lifetime.

## Edge cases

### User revokes the connection

Users can revoke your app at `caramelme.com/settings/connections`. After revocation, API calls return `401` and refresh calls return `400 invalid_grant`. There's no proactive notification. After 2 consecutive `invalid_grant` responses, treat the connection as severed and re-run the authorize flow.

### Multi-business access

One token can access multiple businesses if the user is a member of more than one. `caramel.v1.business.list` returns them all. Pass `business_id` per tool call to scope the operation. Newly provisioned businesses do not appear under an existing token — the user must re-authorize.

### Long-lived server integrations

As long as you refresh within the 30-day window, the rotation chain stays alive indefinitely. Persist the latest refresh token in durable encrypted storage.

## Next steps

- [Your first call](your-first-call) — get a token and make a working `list_businesses` call.
- [Quickstart](quickstart) — one-page reference for token, tool call, refresh, and 429 handling.
- [Reference](../reference/index) — every tool, its parameters, and response shapes.
