Error Reference
All Canviq API errors follow RFC 7807 (Problem Details for HTTP APIs).
Responses carry Content-Type: application/problem+json:
{
"type": "https://canviq.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "No submission exists with that id.",
"request_id": "req_01HRXYZ..."
}
| Field | Always present | Description |
type | Yes | URI identifying the error class |
title | Yes | Short summary of the error class |
status | Yes | HTTP status code, repeated in the body |
detail | Yes | Explanation of this specific occurrence |
instance | No | URI of the specific resource or request |
request_id | No | Correlation ID, also sent as the X-Request-Id header |
| other fields | No | Context-specific (see individual errors) |
!!! note "Some routes still return a legacy shape" The RFC 7807 consolidation covers the /api/v1 surface and the standard error factories. A few older handlers still answer with a flat { "error": "<code>", ... } body and no type/title. Branch on the HTTP status first and treat the body shape as secondary, and your client will handle both. The examples below show whichever shape the route actually emits today.
HTTP Status → Error Code Reference
400 Bad Request
| Error code | When |
validation_error | Request body is malformed or missing required fields |
invalid_slug | Slug contains reserved words, invalid characters, or is too short/long |
invalid_redirect | Redirect URL is not a registered trusted origin |
{
"error": "validation_error",
"message": "title is required and must be between 5 and 200 characters",
"field": "title"
}
401 Unauthorized
| Error code | When |
unauthorized | No Authorization header, or key format is invalid |
session_expired | Session cookie has expired (browser flows) |
An invalid key prefix for the surface you are calling lands here too. SDK endpoints reject a pk_ key and REST endpoints reject a cvq_ key, both before the key is looked up, so a wrong-family key fails the same way a made-up one does. See Authentication.
403 Forbidden
| Error code | When |
forbidden | API key is valid but lacks the required scope |
trial_expired | Trial has ended and action requires an active plan |
org_suspended | Organization account is suspended |
{
"error": "forbidden",
"message": "This key does not have the 'webhooks:write' scope. Regenerate with the required scope.",
"required_scope": "webhooks:write"
}
402 Payment Required
| Error code | When |
plan_limit_reached:<limit> | A plan limit is exhausted |
trial_expired | Trial ended; action requires upgrade |
The error string carries the limit that was hit, for example plan_limit_reached:maxMembers:
{
"error": "plan_limit_reached:maxMembers",
"limit": 50,
"current": 50,
"plan": "trial"
}
!!! warning "Not every limit is monthly" Only maxResponsesPerMonth and llmCallsPerMonth reset each month. maxSubmissions, maxMembers, maxSurveys, maxCategories, and maxActiveKeys are standing totals counted across the whole life of the organization, so deleting older records is what frees headroom, not waiting for the month to roll over. See Billing & Plans for the per-plan values.
404 Not Found
| Error code | When |
not_found | Resource (org, submission, category, etc.) does not exist |
409 Conflict
| Error code | When |
slug_taken | Organization slug is already in use |
already_member | User is already a member of the org |
duplicate_vote | User already voted on this submission |
429 Too Many Requests
| Error code | When |
rate_limited | Request rate exceeds the limit for this key tier |
{
"error": "rate_limited",
"message": "Rate limit exceeded. Retry after 23 seconds.",
"retry_after": 23,
"limit": 100,
"window": "1 minute"
}
Always check the Retry-After header and back off accordingly.
500 Internal Server Error
| Error code | When |
internal_error | Unexpected server-side error |
{
"error": "internal_error",
"message": "An unexpected error occurred. Please retry. If the issue persists, contact support.",
"request_id": "req_01HRXYZ..."
}
Include the request_id when contacting support — it allows us to trace the request in our logs.
Error Handling Patterns
Retry with Backoff (Node.js)
async function callCanviq<T>(
fn: () => Promise<Response>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fn()
if (response.ok) return response.json() as Promise<T>
const error = await response.json()
// Don't retry client errors (4xx, except 429)
if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
) {
throw new Error(`Canviq error: ${error.error} — ${error.message}`)
}
// Rate limited — wait and retry
if (response.status === 429) {
const retryAfter = ((error.retry_after as number) ?? 60) * 1000
await new Promise((resolve) => setTimeout(resolve, retryAfter))
continue
}
// Server error — exponential backoff
if (attempt < maxRetries) {
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
)
}
}
throw new Error('Max retries exceeded')
}