Authentication
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
Section titled β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
Section titled βStep 1 β Discover endpointsβFetch the discovery document before hardcoding any URL.
curl https://app.caramelme.com/.well-known/oauth-authorization-serverResponse (abridged):
{ "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-serverhttps://api.caramelme.com/.well-known/oauth-authorization-serverhttps://app.caramelme.com/api/functions/caramel-mcp/.well-known/oauth-authorization-server
Step 2 β Register a client (optional)
Section titled β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:
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):
{ "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) andclaude.aiURIs are pre-allowed. Any other hosted URI returns400 invalid_redirect_uriβ emailaymen@reactmotion.comto have yours added.
Step 3 β Build the authorize URL
Section titled βStep 3 β Build the authorize URLβGenerate a PKCE verifier and challenge, then redirect the user.
// 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());# Python β server-side, store verifier + state in the sessionimport secrets, hashlib, base64from 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'] = verifiersession['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
statematches 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
Section titled βStep 4 β Exchange the code for tokensβPOST to the token endpoint. There is no client secret β PKCE replaces it for public clients.
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"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:
{ "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
Section titled βStep 5 β Call the APIβSend the bearer token on every request. The MCP endpoint and the REST gateway both accept the same header.
# MCPcurl -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
Section titled βStep 6 β Refresh before expiryβAccess tokens expire after 1 hour (expires_in: 3600). Refresh 60 seconds before expiry to avoid gaps.
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"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 returns400 invalid_grantand forces the user back through the authorize flow.
// Wrong: keeps using the original token β breaks after the second refreshsetInterval(async () => { const { access_token } = await refresh(originalRefreshToken);}, 50 * 60 * 1000);
// Right: replace the stored token on every refreshlet currentRefreshToken = 'β¦';setInterval(async () => { const { access_token, refresh_token } = await refresh(currentRefreshToken); currentRefreshToken = refresh_token; // persist the new one}, 50 * 60 * 1000);Token lifetimes
Section titled β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.
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
Section titled β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
Section titled βThe messsage (sic) gotchaβSome error responses use the misspelled key messsage (three sβs) instead of message:
{ "error": "invalid_grant", "messsage": "Code expired" }Normalize both keys in your error handler or youβll get silent empty-error failures:
function normalizeError(body) { return { code: body.error || body.code, message: body.message ?? body.messsage ?? 'Unknown error', };}Token storage
Section titled β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
Section titled βEdge casesβUser revokes the connection
Section titled β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
Section titled β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
Section titled β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
Section titled βNext stepsβ- Your first call β get a token and make a working
list_businessescall. - Quickstart β one-page reference for token, tool call, refresh, and 429 handling.
- Reference β every tool, its parameters, and response shapes.