Skip to content

Error Reference

All Canviq API errors follow RFC 7807 (Problem Details for HTTP APIs).


Error Format

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')
}