API Reference
Complete reference for all Qual API v1 endpoints.
https://www.qual.cx/api/v1Pagination
List endpoints use cursor-based pagination. Pass the next_cursor value from a previous response to fetch the next page.
| Parameter | Type | Description |
|---|---|---|
cursor | string | Cursor from previous response |
limit | integer | Items per page (1-100, default: 20) |
sort_by | string | Field to sort by (default: created_at) |
order | string | Sort direction: asc or desc (default: desc) |
# 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.
| Plan | Requests/min | Interviews/hr | Analysis/hr |
|---|---|---|---|
| Starter | 30 | 10 | 5 |
| Pro | 60 | 20 | 20 |
| Growth | 120 | 100 | 100 |
| Enterprise | 300 | 500 | 500 |
Response headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1707566460When 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.
/v1/campaignsList Campaigns
Returns a paginated list of campaigns owned by the authenticated user.
campaigns:read| Parameter | Type | Description |
|---|---|---|
status | string | Filter: draft, active, paused, completed |
campaign_mode | string | Filter: research, hr, marketing |
team_id | uuid | Filter by team |
search | string | Search in title and description |
{
"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
}
}/v1/campaignsCreate Campaign
Creates a new campaign in draft status.
campaigns:write| Parameter | Type | Description |
|---|---|---|
titlerequired | string | Campaign name (1-500 chars) |
objectivesrequired | string | Research objectives (1-10000 chars) |
description | string | Brief description |
campaign_mode | string | research, hr, marketing (default: research) |
tone | string | professional, casual, friendly, formal |
language | string | ISO language code (default: en) |
max_questions | integer | 1-50 (default: 10) |
estimated_duration | integer | Duration in minutes (default: 15) |
team_id | uuid | Assign to a team |
opening_message | string | Custom greeting message |
research_questions | string[] | Core research questions |
hypotheses | string[] | Hypotheses to test |
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{
"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"
}
}/v1/campaigns/{campaignId}Get Campaign
Returns full campaign details. Use expand parameter for related data.
campaigns:read| Parameter | Type | Description |
|---|---|---|
expand | string | Comma-separated: interviews, team |
/v1/campaigns/{campaignId}Update Campaign
Updates campaign fields. Only provided fields are modified.
campaigns:write/v1/campaigns/{campaignId}Delete Campaign
Permanently deletes a campaign and all related data.
campaigns:delete{
"data": { "id": "550e8400-...", "deleted": true }
}/v1/campaigns/{campaignId}/publishPublish Campaign
Sets campaign status to active. Campaign must be in draft or paused status.
campaigns:publish/v1/campaigns/{campaignId}/pausePause Campaign
Sets campaign status to paused. Campaign must be in active status.
campaigns:publish/v1/campaigns/{campaignId}/cloneClone Campaign
Creates a copy of the campaign with a new slug. Resets analytics to start fresh.
campaigns:write/v1/campaigns/{campaignId}/exportExport Campaign
Exports campaign data including interviews, transcripts, findings, and quotes.
campaigns:read| Parameter | Type | Description |
|---|---|---|
format | string | json or csv (default: json) |
anonymize | boolean | Replace names with "Participant N" (default: false) |
status | string | Filter interviews by status |
Interviews
Interviews are individual conversations between the AI and a participant.
/v1/interviewsCreate Interview
Creates a new interview session for a published campaign. Returns a session token for the chat streaming endpoint.
| Parameter | Type | Description |
|---|---|---|
slugrequired | string | Campaign slug |
interviewee_name | string | Participant name |
interviewee_email | string | Participant email |
{
"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..."
}
}/v1/campaigns/{campaignId}/interviewsList Campaign Interviews
Returns a paginated list of interviews for a campaign.
interviews:read| Parameter | Type | Description |
|---|---|---|
status | string | in_progress, completed, abandoned |
sort_by | string | created_at, completed_at, duration_seconds |
/v1/interviews/{interviewId}Get Interview
Returns interview details with optional analysis.
interviews:read{
"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"
}
}/v1/interviews/{interviewId}/messagesGet Interview Messages
Returns the conversation transcript.
interviews:read| Parameter | Type | Description |
|---|---|---|
role | string | Filter: user, assistant, system |
order | string | Sort direction (default: asc) |
limit | integer | Items per page (1-100, default: 50) |
/v1/interviews/{interviewId}/analysisGet Interview Analysis
Returns all analyses and extracted quotes for the interview.
findings:readFindings
Findings are themes, hypotheses, patterns, and insights extracted from interviews.
/v1/campaigns/{campaignId}/findingsList Findings
findings:read| Parameter | Type | Description |
|---|---|---|
type | string | theme, hypothesis, contradiction, pattern, insight |
status | string | emerging, developing, established, saturated |
priority | string | critical, high, medium, low |
include_quotes | boolean | Include illustrative quotes (default: true) |
/v1/campaigns/{campaignId}/findingsCreate Finding
findings:write| Parameter | Type | Description |
|---|---|---|
typerequired | string | theme, hypothesis, contradiction, pattern, insight |
titlerequired | string | Finding title (1-500 chars) |
descriptionrequired | string | Description (1-10000 chars) |
confidence_score | integer | 0-100 (default: 50) |
status | string | Default: emerging |
priority | string | Default: medium |
/v1/campaigns/{campaignId}/findings/{findingId}Get Finding
Returns the finding with associated quotes and evidence interviews.
findings:read{
"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" }
]
}
}/v1/campaigns/{campaignId}/findings/{findingId}Update Finding
All fields from Create Finding are accepted (all optional).
findings:write/v1/campaigns/{campaignId}/findings/{findingId}Delete Finding
findings:write/v1/campaigns/{campaignId}/findings/bulkBulk Update Findings
Upserts multiple findings. If a finding with the same title and type exists, it is merged and prevalence_count is incremented.
findings:write{
"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.
/v1/campaigns/{campaignId}/graphGet Graph Data
Returns the full knowledge graph for a campaign.
graph:read| Parameter | Type | Description |
|---|---|---|
concept_type | string | theme, behavior, outcome, belief, need |
entity_type | string | product, tool, person, organization, place |
min_prevalence | integer | Minimum mention count |
include_quotes | boolean | Attach quotes to concepts |
limit | integer | Max nodes (1-500) |
{
"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
}
]
}
}/v1/campaigns/{campaignId}/graph/analyzeRun Graph Algorithms
Runs PageRank and Louvain community detection on the campaign's knowledge graph. Updates concept scores in the database.
graph:read/v1/campaigns/{campaignId}/analysisGet Campaign Analysis
Returns campaign analysis including the campaign brain state.
findings:read| Parameter | Type | Description |
|---|---|---|
type | string | Filter: interview_summary, campaign_insights, pattern_detection |
/v1/campaigns/{campaignId}/saturationGet Saturation Analysis
Computes how close the campaign is to information saturation.
findings:read{
"data": {
"saturation_score": 72,
"new_concept_rate": 0.3,
"unique_concepts": 45,
"total_interviews": 20,
"recommendation": "nearly_saturated",
"confidence": "medium",
"trend": "decreasing"
}
}| Recommendation | Meaning |
|---|---|
continue | Keep interviewing, new insights emerging |
nearly_saturated | 3-5 more interviews recommended |
saturated | Further interviews unlikely to yield new insights |
/v1/campaigns/{campaignId}/quotesList Quotes
Returns quotes extracted from interviews.
findings:read| Parameter | Type | Description |
|---|---|---|
finding_id | uuid | Filter by finding |
interview_id | uuid | Filter by interview |
illustrative_only | boolean | Only illustrative quotes |
theme | string | Filter by theme tag |
Teams
/v1/teamsList Teams
Returns all teams the authenticated user belongs to.
teams:read/v1/teamsCreate Team
teams:write| Parameter | Type | Description |
|---|---|---|
namerequired | string | Team name |
slugrequired | string | URL-safe identifier |
company_url | string | Company website |
/v1/teams/{teamId}Get Team
Must be a member of the team.
teams:read/v1/teams/{teamId}Update Team
Must be owner or admin. Accepts name, company_url, internal_lexicon, core_audience.
teams:write/v1/teams/{teamId}Delete Team
Must be the team owner.
teams:write/v1/teams/{teamId}/membersList Members
teams:read/v1/teams/{teamId}/membersAdd Member
Must be owner or admin.
teams:write| Parameter | Type | Description |
|---|---|---|
user_idrequired | uuid | User to add |
role | string | admin, member, viewer (default: member) |
/v1/teams/{teamId}/members/{memberId}Update Member Role
teams:write| Parameter | Type | Description |
|---|---|---|
rolerequired | string | admin, member, viewer |
/v1/teams/{teamId}/members/{memberId}Remove Member
Cannot remove the team owner.
teams:writeAPI Keys
/v1/api-keysList API Keys
Returns all non-revoked API keys. Key hashes are never returned.
{
"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"
}
]
}/v1/api-keysCreate API Key
| Parameter | Type | Description |
|---|---|---|
namerequired | string | Human-readable name |
key_type | string | secret, public, scoped (default: secret) |
scopesrequired | string[] | List of permission scopes |
team_id | uuid | Associate with a team |
Important: The full plaintext key is only returned in this response. Store it immediately and securely.
/v1/api-keys/{keyId}Get API Key
Returns key metadata (never the hash or plaintext).
/v1/api-keys/{keyId}Update API Key
Update name and/or scopes.
/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.
/v1/webhooksList Webhooks
webhooks:manage/v1/webhooksCreate Webhook
webhooks:manage| Parameter | Type | Description |
|---|---|---|
urlrequired | string | Endpoint URL (HTTPS required) |
eventsrequired | string[] | Event types to subscribe |
description | string | Human-readable description |
The secret is only returned at creation. Store it securely to verify signatures.
/v1/webhooks/{webhookId}Get Webhook
Returns webhook details and recent delivery history.
webhooks:manage/v1/webhooks/{webhookId}Update Webhook
Re-enabling a webhook resets its failure_count to 0.
webhooks:manage/v1/webhooks/{webhookId}Delete Webhook
Deletes the webhook and all associated delivery logs.
webhooks:manage/v1/webhooks/{webhookId}/testTest Webhook
Sends a test campaign.created event to the webhook endpoint.
webhooks:manage{
"data": {
"success": true,
"status_code": 200,
"response_time_ms": 142,
"error_message": null
}
}Webhook Events
| Event | Description |
|---|---|
campaign.created | A new campaign was created |
campaign.published | A campaign was published |
campaign.completed | A campaign was marked completed |
interview.started | A participant started an interview |
interview.completed | An interview was completed |
interview.abandoned | An interview was abandoned |
message.created | A new message was sent |
analysis.completed | Interview analysis completed |
analysis.brain_updated | Campaign brain updated with new insights |
graph.concept_extracted | New concepts extracted from transcripts |
graph.synced | Knowledge 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}
Extract the timestamp and signature from the header
Check the timestamp is within 5 minutes of current time
Compute HMAC-SHA256(secret, "{timestamp}.{raw_body}")
Compare using constant-time comparison
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:
| Attempt | Delay |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 3 hours |
After 5 consecutive failures, the webhook is automatically disabled. Re-enable via PATCH /v1/webhooks/{id} with {"enabled": true}.
Error Codes
| Code | HTTP | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid authentication |
FORBIDDEN | 403 | Insufficient permissions or scopes |
NOT_FOUND | 404 | Resource does not exist |
VALIDATION_ERROR | 400 | Invalid request body or parameters |
RATE_LIMITED | 429 | Too many requests |
QUOTA_EXCEEDED | 402 | Monthly quota exceeded |
CONFLICT | 409 | Resource conflict (e.g., duplicate slug) |
INTERNAL_ERROR | 500 | Internal server error |
Security Headers
All API responses include these security headers:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
X-XSS-Protection | 1; mode=block |
Referrer-Policy | strict-origin-when-cross-origin |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Permissions-Policy | camera=(), 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.