Skip to content

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.

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 } ──────│

Fetch the discovery document before hardcoding any URL.

Terminal window
curl https://app.caramelme.com/.well-known/oauth-authorization-server

Response (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-server
  • https://api.caramelme.com/.well-known/oauth-authorization-server
  • https://app.caramelme.com/api/functions/caramel-mcp/.well-known/oauth-authorization-server

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:

Terminal window
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) and claude.ai URIs are pre-allowed. Any other hosted URI returns 400 invalid_redirect_uri β€” email aymen@reactmotion.com to have yours added.

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 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.

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

Terminal window
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"
}

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

Terminal window
# 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.

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

Terminal window
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 returns 400 invalid_grant and forces the user back through the authorize flow.

// 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);
TokenLifetimeWhat happens at expiry
Access token1 hourAPI calls return 401. Refresh once, then retry.
Refresh token30 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.

ScopeWhat it coversExample tools
meta:readCapability discovery and usage counters (always granted)caramel.v1.meta.capabilities, caramel.v1.meta.usage, caramel.v1.business.list
forms:readList forms and read form schemascaramel.v1.form.list
forms:writeSubmit form datacaramel.v1.form.submit
audience:readList and read contacts(shipping soon)
audience:writeUpsert contactscaramel.v1.contact.upsert
messaging:sendTrigger message sendscaramel.v1.email.send, caramel.v1.sms.send (coming soon)
provisioning:writeSender domain managementcaramel.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.

HTTPBody codeCauseFix
400invalid_requestMissing or malformed parameterCheck client_id, code, code_verifier, redirect_uri
400invalid_grantCode expired, already used, or refresh token rotated outRestart the authorize flow
400invalid_clientclient_id not found or disabledCheck the value; contact support if it should exist
401unauthorizedToken missing, malformed, or expiredRefresh once, then retry; if still 401, re-authorize
403scope_requiredToken valid but missing required scopeRe-authorize with the additional scope
403tier_requiredTool requires a higher business tierUpgrade the business tier

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',
};
}
  • 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.

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.

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.

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.

  • Your first call β€” get a token and make a working list_businesses call.
  • Quickstart β€” one-page reference for token, tool call, refresh, and 429 handling.
  • Reference β€” every tool, its parameters, and response shapes.