Errors
Every error the Caramel API can return, with the cause and the action to take. The tool reference pages in Tools list per-tool errors — this page is the complete union.
Error response shape
Section titled “Error response shape”The REST gateway returns a standard JSON body on all errors:
{ "error": "rate_limited", "message": "Per-token rate cap exceeded.", "status": 429}The MCP endpoint wraps tool-level errors in the MCP result envelope:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "Per-token rate cap exceeded." }], "isError": true }}Protocol-level failures (expired token, malformed JSON-RPC) return standard JSON-RPC 2.0 errors:
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32001, "message": "Unauthorized — access token expired or invalid" }}-32001 is a Caramel extension meaning “access token expired; refresh and retry.” Standard JSON-RPC codes: -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error.
Normalizing error bodies
Section titled “Normalizing error bodies”Important Some responses occasionally return
messsage(three s’s) instead ofmessage. This is a known quirk in one upstream dependency. Normalize both keys in every error-handling path — failing to do so causes silent “200-shaped UI but no content” failures.
function asError(body) { return { code: body.error ?? body.code, message: body.message ?? body.messsage ?? 'Unknown error', status: body.status, };}Two lines, one helper, applied everywhere. The TypeScript SDK already does this internally.
Status codes
Section titled “Status codes”200 OK
Section titled “200 OK”The call succeeded. Response body shape depends on the tool — see Tools.
400 Bad Request
Section titled “400 Bad Request”The request was malformed or a required field is missing.
| Code | Cause | Fix |
|---|---|---|
email_required | email argument missing or empty on caramel.v1.contact.upsert | Send a valid email address. Email is the unique contact key — there is no phone-only path. |
invalid_request | OAuth token endpoint received a malformed body | Check grant_type, code, client_id. |
invalid_grant | OAuth code is wrong, expired, used, or refresh token was rotated | Restart the authorize flow. |
invalid_client | OAuth client_id doesn’t exist or is disabled | Verify the client ID; contact support if it should exist. |
malformed_request | Generic validation failure — missing JSON body, wrong Content-Type | Send Content-Type: application/json and a valid body. |
401 Unauthorized
Section titled “401 Unauthorized”Authentication failed. The bearer token is missing, expired, or invalid.
| Code | Cause | Fix |
|---|---|---|
unauthorized | Access token expired or invalid | Refresh the token once and retry. If still 401, restart the OAuth flow. |
token_missing | No Authorization header | Add Authorization: Bearer <access_token>. |
token_revoked | User revoked the connection | Restart the OAuth flow — the user must re-grant access. |
A 401 during a refresh exchange returns invalid_grant instead. See Authentication.
402 Payment Required
Section titled “402 Payment Required”The call requires AI credits that the business doesn’t have.
| Code | Cause | Fix |
|---|---|---|
insufficient_credits | No AI credits remaining for this billing cycle | Add credits or upgrade tier. Check the current balance with caramel.v1.meta.usage. |
Affects generate_campaign and refine_campaign only.
403 Forbidden
Section titled “403 Forbidden”The token is valid but the caller isn’t authorized for this operation.
| Code | Cause | Fix |
|---|---|---|
forbidden | Caller is not a member of the target business | Verify business_id is one you have access to via list_businesses. |
tier_required | Caller is below the tool’s minimum tier | Upgrade the business to the required tier. See Tiers and scopes. |
scope_required | Token doesn’t have the OAuth scope the tool needs | Restart the authorize flow with the additional scope. |
404 Not Found
Section titled “404 Not Found”The resource doesn’t exist, or you don’t have access to it.
| Code | Cause | Fix |
|---|---|---|
campaign_not_found | campaign_id doesn’t exist or you don’t have access | Confirm the ID via list_campaigns. |
form_not_found | form_id doesn’t exist or the form is inactive | Confirm the ID via caramel.v1.form.list. Inactive forms can’t receive submissions. |
business_not_found | business_id doesn’t exist or you’re not a member | Confirm via list_businesses. |
Note 404 deliberately doesn’t distinguish “doesn’t exist” from “exists but you can’t see it.” This prevents resource ID enumeration.
409 Conflict
Section titled “409 Conflict”The resource is in a state that doesn’t allow the requested operation.
| Code | Cause | Fix |
|---|---|---|
deployed_campaign | Tried to delete_campaign on a deployed campaign | Pause it first with pause_campaign, then delete. |
not_approved | Tried to deploy_campaign on a non-approved campaign | Refine until the quality score clears, then deploy. |
not_running | Tried to pause_campaign on a non-running campaign | Only deployed campaigns can be paused. |
not_paused | Tried to resume_campaign on a non-paused campaign | Only paused campaigns can be resumed. |
Campaign state transitions are summarized in Tools — campaign state machine.
422 Unprocessable Entity
Section titled “422 Unprocessable Entity”The request was syntactically valid but failed business-rule validation.
| Code | Cause | Fix |
|---|---|---|
validation | Required form fields missing or invalid on caramel.v1.form.submit | Inspect the details array — each entry has field and reason. |
Example 422 body:
{ "error": "validation", "message": "One or more required fields missing or invalid.", "status": 422, "details": [ { "field": "email", "reason": "format" }, { "field": "first_name", "reason": "required" } ]}429 Too Many Requests
Section titled “429 Too Many Requests”A rate limit was exceeded. Always honor the Retry-After header.
| Code | Cause | Fix |
|---|---|---|
rate_limited | Per-token, per-IP, or per-host gateway limit hit | Wait Retry-After seconds, then retry with jitter. |
submission_cap | Per-tier monthly form-submission cap reached | Upgrade tier, or wait until next billing cycle. |
credit_throttle | AI generation calls arriving faster than the credit ledger can settle | Serialize generation requests; add 250 ms between calls. |
Example 429 body:
HTTP/1.1 429 Too Many RequestsRetry-After: 12Content-Type: application/json
{ "error": "rate_limited", "message": "Per-token rate cap exceeded.", "status": 429 }See Rate limits for the full cap matrix.
500 Internal Server Error
Section titled “500 Internal Server Error”Something went wrong on Caramel’s side. Safe to retry with exponential backoff.
| Code | Cause | Fix |
|---|---|---|
internal_error | Unexpected server failure | Retry with exponential backoff (1 s, 2 s, 4 s). Alert and stop after 3 failures. |
dependency_unavailable | An internal dependency is degraded | Same as internal_error. Check https://status.caramelme.com. |
Include the x-caramel-request-id from the response header in any support request — it correlates directly with server logs.
502 / 503 / 504 — gateway errors
Section titled “502 / 503 / 504 — gateway errors”The gateway couldn’t reach the backing service. Treat as transient.
- Retry with exponential backoff.
Retry-Afteris set when the gateway knows when it will recover.- Persistent failures in a region: check https://status.caramelme.com.
Retry matrix
Section titled “Retry matrix”| Status | Retry? | Strategy |
|---|---|---|
| 400, 401, 402, 403, 404, 409, 422 | No | Fix the request, token, or business state first. |
| 429 | Yes | Wait Retry-After seconds, then retry with jitter. |
| 500 | Yes | Exponential backoff: 1 s, 2 s, 4 s, 8 s. Cap at 3 retries. |
| 502, 503, 504 | Yes | Same as 500. Check the status page after 2 consecutive failures. |
Reference implementation:
async function callWithRetry(fn, opts = {}) { const maxRetries = opts.maxRetries ?? 3; for (let attempt = 0; attempt <= maxRetries; attempt++) { const res = await fn(); if (res.ok) return res;
if (res.status === 429) { const wait = (Number(res.headers.get('retry-after')) || 1) * 1000; await sleep(wait + Math.random() * 500); continue; } if (res.status >= 500 && res.status < 600 && attempt < maxRetries) { await sleep(2 ** attempt * 1000 + Math.random() * 500); continue; } throw await res.json(); }}import time, random
def call_with_retry(fn, max_retries=3): for attempt in range(max_retries + 1): res = fn() if res.is_success: return res if res.status_code == 429: wait = int(res.headers.get('retry-after', '1')) time.sleep(wait + random.random() * 0.5) continue if 500 <= res.status_code < 600 and attempt < max_retries: time.sleep(2 ** attempt + random.random() * 0.5) continue res.raise_for_status() raise RuntimeError('exceeded retries')Idempotency
Section titled “Idempotency”The API does not currently accept an Idempotency-Key header. Tools that mutate state have different retry semantics:
| Tool | Safe to retry? | Why |
|---|---|---|
caramel.v1.contact.upsert | Yes | Email is the unique key. Repeated calls with the same payload are idempotent by design. |
deploy_campaign, pause_campaign, resume_campaign | Effectively yes | The state machine returns 409 if the operation is already complete — treat as a no-op. |
delete_campaign | Effectively yes | Returns 404 campaign_not_found if already deleted. |
caramel.v1.form.submit | No | Every call creates a new submission row. |
generate_campaign, refine_campaign | No | Every call consumes AI credits. Use the campaign ID from the first response; don’t re-call on UI retries. |
deploy_template | No | Repeated calls create duplicate workspace entities. |
An Idempotency-Key header is planned. Until it ships, use the table above as the contract.
Debugging unknown errors
Section titled “Debugging unknown errors”- Capture the
x-caramel-request-idresponse header and therequest_idin the response body. - Inspect the
detailsarray if present — validation errors put per-field reasons there. - Email
aymen@reactmotion.comwith the request ID and timestamp. Logs are retained for 30 days.
Next steps
Section titled “Next steps”- Rate limits — what triggers 429 before you hit it.
- Tiers and scopes — what causes
tier_requiredandscope_required. - Authentication — refresh token rotation.