DocsAPI Reference

API Reference

Complete reference for all Qual API v1 endpoints.

Base URLhttps://www.qual.cx/api/v1

Pagination

List endpoints use cursor-based pagination. Pass the next_cursor value from a previous response to fetch the next page.

ParameterTypeDescription
cursorstringCursor from previous response
limitintegerItems per page (1-100, default: 20)
sort_bystringField to sort by (default: created_at)
orderstringSort direction: asc or desc (default: desc)
Paginated request
# First page
curl -H "Authorization: Bearer {token}" \
  "https://www.qual.cx/api/v1/campaigns?limit=10"

# Next page
curl -H "Authorization: Bearer {token}" \
  "https://www.qual.cx/api/v1/campaigns?limit=10&cursor=eyJjcmVhdGVkX2F0..."

Rate Limiting

Requests are rate-limited based on your subscription plan.

PlanRequests/minInterviews/hrAnalysis/hr
Starter30105
Pro602020
Growth120100100
Enterprise300500500

Response headers:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1707566460

When the limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header.

Campaigns

Campaigns represent research studies with objectives, interview protocols, and settings.

GET/v1/campaigns

List Campaigns

Returns a paginated list of campaigns owned by the authenticated user.

Requires:campaigns:read
ParameterTypeDescription
statusstringFilter: draft, active, paused, completed
campaign_modestringFilter: research, hr, marketing
team_iduuidFilter by team
searchstringSearch in title and description
Response — 200 OK
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "User Onboarding Research",
      "slug": "user-onboarding-research-1707566400",
      "status": "active",
      "campaign_mode": "research",
      "max_questions": 10,
      "created_at": "2026-02-10T12:00:00Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0...",
    "has_more": true,
    "total_count": 12
  }
}
POST/v1/campaigns

Create Campaign

Creates a new campaign in draft status.

Requires:campaigns:write
ParameterTypeDescription
titlerequiredstringCampaign name (1-500 chars)
objectivesrequiredstringResearch objectives (1-10000 chars)
descriptionstringBrief description
campaign_modestringresearch, hr, marketing (default: research)
tonestringprofessional, casual, friendly, formal
languagestringISO language code (default: en)
max_questionsinteger1-50 (default: 10)
estimated_durationintegerDuration in minutes (default: 15)
team_iduuidAssign to a team
opening_messagestringCustom greeting message
research_questionsstring[]Core research questions
hypothesesstring[]Hypotheses to test
Request
curl -X POST \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "User Onboarding Research",
    "objectives": "Understand pain points in the onboarding flow",
    "campaign_mode": "research",
    "max_questions": 12,
    "tone": "friendly"
  }' \
  https://www.qual.cx/api/v1/campaigns
Response — 201 Created
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "User Onboarding Research",
    "slug": "user-onboarding-research-1707566400",
    "status": "draft",
    "campaign_mode": "research",
    "max_questions": 12,
    "tone": "friendly",
    "created_at": "2026-02-10T12:00:00Z"
  }
}
GET/v1/campaigns/{campaignId}

Get Campaign

Returns full campaign details. Use expand parameter for related data.

Requires:campaigns:read
ParameterTypeDescription
expandstringComma-separated: interviews, team
PATCH/v1/campaigns/{campaignId}

Update Campaign

Updates campaign fields. Only provided fields are modified.

Requires:campaigns:write
DELETE/v1/campaigns/{campaignId}

Delete Campaign

Permanently deletes a campaign and all related data.

Requires:campaigns:delete
Response — 200 OK
{
  "data": { "id": "550e8400-...", "deleted": true }
}
POST/v1/campaigns/{campaignId}/publish

Publish Campaign

Sets campaign status to active. Campaign must be in draft or paused status.

Requires:campaigns:publish
POST/v1/campaigns/{campaignId}/pause

Pause Campaign

Sets campaign status to paused. Campaign must be in active status.

Requires:campaigns:publish
POST/v1/campaigns/{campaignId}/clone

Clone Campaign

Creates a copy of the campaign with a new slug. Resets analytics to start fresh.

Requires:campaigns:write
GET/v1/campaigns/{campaignId}/export

Export Campaign

Exports campaign data including interviews, transcripts, findings, and quotes.

Requires:campaigns:read
ParameterTypeDescription
formatstringjson or csv (default: json)
anonymizebooleanReplace names with "Participant N" (default: false)
statusstringFilter interviews by status

Interviews

Interviews are individual conversations between the AI and a participant.

POST/v1/interviews

Create Interview

Creates a new interview session for a published campaign. Returns a session token for the chat streaming endpoint.

Public endpoint — no authentication required
ParameterTypeDescription
slugrequiredstringCampaign slug
interviewee_namestringParticipant name
interviewee_emailstringParticipant email
Response — 201 Created
{
  "data": {
    "interview": {
      "id": "660e8400-e29b-41d4-a716-446655440000",
      "session_token": "a1b2c3d4e5f6..."
    },
    "campaign": {
      "title": "User Onboarding Research",
      "estimated_duration": 15,
      "brand_name": "Qual",
      "language": "en"
    },
    "opening_message": "Hello! Thank you for participating..."
  }
}
GET/v1/campaigns/{campaignId}/interviews

List Campaign Interviews

Returns a paginated list of interviews for a campaign.

Requires:interviews:read
ParameterTypeDescription
statusstringin_progress, completed, abandoned
sort_bystringcreated_at, completed_at, duration_seconds
GET/v1/interviews/{interviewId}

Get Interview

Returns interview details with optional analysis.

Requires:interviews:read
Response — 200 OK
{
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440000",
    "campaign_id": "550e8400-...",
    "status": "completed",
    "interviewee_name": "Jane Doe",
    "message_count": 14,
    "duration_seconds": 480,
    "started_at": "2026-02-10T12:00:00Z",
    "completed_at": "2026-02-10T12:08:00Z"
  }
}
GET/v1/interviews/{interviewId}/messages

Get Interview Messages

Returns the conversation transcript.

Requires:interviews:read
ParameterTypeDescription
rolestringFilter: user, assistant, system
orderstringSort direction (default: asc)
limitintegerItems per page (1-100, default: 50)
GET/v1/interviews/{interviewId}/analysis

Get Interview Analysis

Returns all analyses and extracted quotes for the interview.

Requires:findings:read

Findings

Findings are themes, hypotheses, patterns, and insights extracted from interviews.

GET/v1/campaigns/{campaignId}/findings

List Findings

Requires:findings:read
ParameterTypeDescription
typestringtheme, hypothesis, contradiction, pattern, insight
statusstringemerging, developing, established, saturated
prioritystringcritical, high, medium, low
include_quotesbooleanInclude illustrative quotes (default: true)
POST/v1/campaigns/{campaignId}/findings

Create Finding

Requires:findings:write
ParameterTypeDescription
typerequiredstringtheme, hypothesis, contradiction, pattern, insight
titlerequiredstringFinding title (1-500 chars)
descriptionrequiredstringDescription (1-10000 chars)
confidence_scoreinteger0-100 (default: 50)
statusstringDefault: emerging
prioritystringDefault: medium
GET/v1/campaigns/{campaignId}/findings/{findingId}

Get Finding

Returns the finding with associated quotes and evidence interviews.

Requires:findings:read
Response — 200 OK
{
  "data": {
    "id": "770e8400-...",
    "type": "theme",
    "title": "Users struggle with initial setup",
    "description": "Multiple participants mentioned...",
    "confidence_score": 78,
    "prevalence_count": 5,
    "status": "developing",
    "quotes": [ ... ],
    "evidence_interviews": [
      { "id": "...", "interviewee_name": "Participant 3" }
    ]
  }
}
PATCH/v1/campaigns/{campaignId}/findings/{findingId}

Update Finding

All fields from Create Finding are accepted (all optional).

Requires:findings:write
DELETE/v1/campaigns/{campaignId}/findings/{findingId}

Delete Finding

Requires:findings:write
PATCH/v1/campaigns/{campaignId}/findings/bulk

Bulk Update Findings

Upserts multiple findings. If a finding with the same title and type exists, it is merged and prevalence_count is incremented.

Requires:findings:write
Request body
{
  "findings": [
    {
      "type": "theme",
      "title": "Users want faster onboarding",
      "description": "...",
      "confidence_score": 72
    }
  ]
}

Knowledge Graph

The knowledge graph represents concepts, entities, and their relationships extracted from interview transcripts.

GET/v1/campaigns/{campaignId}/graph

Get Graph Data

Returns the full knowledge graph for a campaign.

Requires:graph:read
ParameterTypeDescription
concept_typestringtheme, behavior, outcome, belief, need
entity_typestringproduct, tool, person, organization, place
min_prevalenceintegerMinimum mention count
include_quotesbooleanAttach quotes to concepts
limitintegerMax nodes (1-500)
Response — 200 OK
{
  "data": {
    "campaign_id": "550e8400-...",
    "nodes": {
      "concepts": [
        {
          "id": "...",
          "name": "User Frustration",
          "type": "theme",
          "prevalence_count": 12,
          "confidence_score": 85,
          "pagerank_score": 0.042
        }
      ],
      "entities": [
        { "id": "...", "name": "Slack", "type": "tool", "mention_count": 8 }
      ]
    },
    "edges": [
      {
        "source_type": "concept",
        "source_id": "...",
        "target_type": "entity",
        "target_id": "...",
        "relationship_type": "MENTIONS",
        "weight": 3
      }
    ]
  }
}
POST/v1/campaigns/{campaignId}/graph/analyze

Run Graph Algorithms

Runs PageRank and Louvain community detection on the campaign's knowledge graph. Updates concept scores in the database.

Requires:graph:read
GET/v1/campaigns/{campaignId}/analysis

Get Campaign Analysis

Returns campaign analysis including the campaign brain state.

Requires:findings:read
ParameterTypeDescription
typestringFilter: interview_summary, campaign_insights, pattern_detection
GET/v1/campaigns/{campaignId}/saturation

Get Saturation Analysis

Computes how close the campaign is to information saturation.

Requires:findings:read
Response — 200 OK
{
  "data": {
    "saturation_score": 72,
    "new_concept_rate": 0.3,
    "unique_concepts": 45,
    "total_interviews": 20,
    "recommendation": "nearly_saturated",
    "confidence": "medium",
    "trend": "decreasing"
  }
}
RecommendationMeaning
continueKeep interviewing, new insights emerging
nearly_saturated3-5 more interviews recommended
saturatedFurther interviews unlikely to yield new insights
GET/v1/campaigns/{campaignId}/quotes

List Quotes

Returns quotes extracted from interviews.

Requires:findings:read
ParameterTypeDescription
finding_iduuidFilter by finding
interview_iduuidFilter by interview
illustrative_onlybooleanOnly illustrative quotes
themestringFilter by theme tag

Teams

GET/v1/teams

List Teams

Returns all teams the authenticated user belongs to.

Requires:teams:read
POST/v1/teams

Create Team

Requires:teams:write
ParameterTypeDescription
namerequiredstringTeam name
slugrequiredstringURL-safe identifier
company_urlstringCompany website
GET/v1/teams/{teamId}

Get Team

Must be a member of the team.

Requires:teams:read
PATCH/v1/teams/{teamId}

Update Team

Must be owner or admin. Accepts name, company_url, internal_lexicon, core_audience.

Requires:teams:write
DELETE/v1/teams/{teamId}

Delete Team

Must be the team owner.

Requires:teams:write
GET/v1/teams/{teamId}/members

List Members

Requires:teams:read
POST/v1/teams/{teamId}/members

Add Member

Must be owner or admin.

Requires:teams:write
ParameterTypeDescription
user_idrequireduuidUser to add
rolestringadmin, member, viewer (default: member)
PATCH/v1/teams/{teamId}/members/{memberId}

Update Member Role

Requires:teams:write
ParameterTypeDescription
rolerequiredstringadmin, member, viewer
DELETE/v1/teams/{teamId}/members/{memberId}

Remove Member

Cannot remove the team owner.

Requires:teams:write

API Keys

GET/v1/api-keys

List API Keys

Returns all non-revoked API keys. Key hashes are never returned.

Response — 200 OK
{
  "data": [
    {
      "id": "990e8400-...",
      "name": "Production Key",
      "key_prefix": "qual_sk_a1b2",
      "key_type": "secret",
      "scopes": ["campaigns:read", "campaigns:write"],
      "last_used_at": "2026-02-10T13:00:00Z",
      "created_at": "2026-01-20T09:00:00Z"
    }
  ]
}
POST/v1/api-keys

Create API Key

ParameterTypeDescription
namerequiredstringHuman-readable name
key_typestringsecret, public, scoped (default: secret)
scopesrequiredstring[]List of permission scopes
team_iduuidAssociate with a team

Important: The full plaintext key is only returned in this response. Store it immediately and securely.

GET/v1/api-keys/{keyId}

Get API Key

Returns key metadata (never the hash or plaintext).

PATCH/v1/api-keys/{keyId}

Update API Key

Update name and/or scopes.

DELETE/v1/api-keys/{keyId}

Revoke API Key

Soft-deletes the key by setting revoked_at. The key immediately stops working.

Webhooks

Receive real-time notifications when events occur in your account.

GET/v1/webhooks

List Webhooks

Requires:webhooks:manage
POST/v1/webhooks

Create Webhook

Requires:webhooks:manage
ParameterTypeDescription
urlrequiredstringEndpoint URL (HTTPS required)
eventsrequiredstring[]Event types to subscribe
descriptionstringHuman-readable description

The secret is only returned at creation. Store it securely to verify signatures.

GET/v1/webhooks/{webhookId}

Get Webhook

Returns webhook details and recent delivery history.

Requires:webhooks:manage
PATCH/v1/webhooks/{webhookId}

Update Webhook

Re-enabling a webhook resets its failure_count to 0.

Requires:webhooks:manage
DELETE/v1/webhooks/{webhookId}

Delete Webhook

Deletes the webhook and all associated delivery logs.

Requires:webhooks:manage
POST/v1/webhooks/{webhookId}/test

Test Webhook

Sends a test campaign.created event to the webhook endpoint.

Requires:webhooks:manage
Response — 200 OK
{
  "data": {
    "success": true,
    "status_code": 200,
    "response_time_ms": 142,
    "error_message": null
  }
}

Webhook Events

EventDescription
campaign.createdA new campaign was created
campaign.publishedA campaign was published
campaign.completedA campaign was marked completed
interview.startedA participant started an interview
interview.completedAn interview was completed
interview.abandonedAn interview was abandoned
message.createdA new message was sent
analysis.completedInterview analysis completed
analysis.brain_updatedCampaign brain updated with new insights
graph.concept_extractedNew concepts extracted from transcripts
graph.syncedKnowledge graph synced to Neo4j

Payload format

{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "interview.completed",
  "timestamp": 1707566400,
  "data": {
    "interview_id": "660e8400-...",
    "campaign_id": "550e8400-...",
    "duration_seconds": 480,
    "message_count": 14
  },
  "account": { "id": "user_123" }
}

Signature Verification

Every delivery includes a qual-signature header. Format: v1,{timestamp},sha256={hex_hash}

1

Extract the timestamp and signature from the header

2

Check the timestamp is within 5 minutes of current time

3

Compute HMAC-SHA256(secret, "{timestamp}.{raw_body}")

4

Compare using constant-time comparison

TypeScript example
import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const parts = signature.split(',')
  if (parts.length !== 3 || parts[0] !== 'v1') return false

  const timestamp = parseInt(parts[1], 10)
  const receivedSig = parts[2]

  // Check timestamp freshness (5 min tolerance)
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false

  // Compute expected signature
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex')

  return timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(receivedSig)
  )
}

Retry Policy

Failed deliveries are retried with exponential backoff:

AttemptDelay
11 minute
25 minutes
315 minutes
41 hour
53 hours

After 5 consecutive failures, the webhook is automatically disabled. Re-enable via PATCH /v1/webhooks/{id} with {"enabled": true}.

Error Codes

CodeHTTPDescription
UNAUTHORIZED401Missing or invalid authentication
FORBIDDEN403Insufficient permissions or scopes
NOT_FOUND404Resource does not exist
VALIDATION_ERROR400Invalid request body or parameters
RATE_LIMITED429Too many requests
QUOTA_EXCEEDED402Monthly quota exceeded
CONFLICT409Resource conflict (e.g., duplicate slug)
INTERNAL_ERROR500Internal server error

Security Headers

All API responses include these security headers:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
X-XSS-Protection1; mode=block
Referrer-Policystrict-origin-when-cross-origin
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Permissions-Policycamera=(), microphone=(), geolocation=()

CORS: Public endpoints allow all origins. Protected endpoints restrict to configured origins.

Need help?

Contact us at support@qual.cx or check out the getting started guide.