InterviewRelay

API Reference#

This page documents the REST endpoints currently exposed by the application. Everything described here is implemented and live; if a feature is not on this page, it does not exist yet.

The API lives at the same origin as the dashboard. There is no separate api.* host.

Authentication#

Two bearer-token mechanisms are supported. Use whichever fits your client:

| Token type | When to use | How to get one | |---|---|---| | Supabase JWT | Browser sessions, mobile apps, anything with a logged-in user | Issued automatically on login; available in the sb-access-token cookie | | API key (ir_live_…) | Headless scripts, cron jobs, server-to-server integrations | Generate one in Settings β†’ API Keys |

Send the token in the Authorization header:

Authorization: Bearer <token>

API keys are scoped to a single user and inherit that user's permissions on the underlying projects and campaigns. Keys can be revoked at any time from the dashboard; revocation takes effect on the next request.

Plaintext API keys are shown exactly once on creation and never stored. If you lose a key, revoke it and create a new one.

Rate limits#

All authenticated traffic (API keys and browser sessions combined) shares one budget per account:

| Scope | Limit | |---|---| | All authenticated endpoints | 240 requests per minute per account | | Bulk transcript exports (campaign + project) | 20 requests per hour per account | | Quickstart (POST /api/quickstart) | 30 requests per hour per account | | PDF transcripts | 60 requests per hour per account | | AI question generation | 10 requests per hour per account | | Failed authentication attempts | 20 per 15 minutes per IP, then all requests from that IP are refused |

When a limit is hit, the API responds with HTTP 429 and a Retry-After header (seconds until the window resets), plus X-RateLimit-Limit and X-RateLimit-Reset headers. Back off until Retry-After has passed β€” retrying earlier only returns more 429s.

Public participant endpoints (share-link join, invite accept) have their own per-IP limits sized for human traffic. The bulk export endpoints additionally have a hard cap of 500 sessions per ZIP request to keep response times bounded.

Endpoints#

Export campaign transcripts#

GET /api/campaigns/{campaignId}/transcripts

Returns every transcript for one campaign.

| Query param | Type | Default | Description | |---|---|---|---| | format | json | csv | json | Output format. Also decides the inner file extension when include_audio=true. | | include_audio | true | false | false | When true, returns a streaming ZIP that bundles transcripts and per-session audio files. |

Text response (default): application/json or text/csv body. The JSON envelope includes campaign metadata, the generated-at timestamp, and one entry per session with both messages (raw conversation log) and turns (structured Q&A).

ZIP response (include_audio=true): application/zip containing:

transcripts.json          (or .csv if format=csv)
audio/<sessionId>.<ext>   one file per session that has audio
manifest.json             metadata + list of any sessions whose audio was missing

Examples:

# JSON, text only
curl -H "Authorization: Bearer ir_live_..." \
  "https://your-host/api/campaigns/<campaignId>/transcripts" \
  -o campaign.json

# CSV, text only
curl -H "Authorization: Bearer ir_live_..." \
  "https://your-host/api/campaigns/<campaignId>/transcripts?format=csv" \
  -o campaign.csv

# ZIP, transcripts + audio
curl -H "Authorization: Bearer ir_live_..." \
  "https://your-host/api/campaigns/<campaignId>/transcripts?include_audio=true" \
  -o campaign.zip

Returns 404 if the campaign does not exist or the caller does not own the parent project. Returns 413 if the ZIP request exceeds the session cap.

Export project transcripts#

GET /api/projects/{projectId}/transcripts

Same shape as the campaign endpoint, but walks every campaign in the project.

| Query param | Type | Default | Description | |---|---|---|---| | format | json | csv | json | Output format. | | include_audio | true | false | false | When true, returns a streaming ZIP. |

Text response:

  • JSON: an envelope with campaigns: [{ campaign, transcripts }] so each campaign's data is grouped.
  • CSV: a flat table with Campaign ID and Campaign Name columns up front so the rows can be split by campaign downstream.

ZIP response:

manifest.json
<campaign-slug>/transcripts.json   (or .csv)
<campaign-slug>/audio/<sessionId>.<ext>

The 500-session cap applies across the whole project.

Example:

curl -H "Authorization: Bearer ir_live_..." \
  "https://your-host/api/projects/<projectId>/transcripts?format=csv" \
  -o project.csv

Per-session downloads#

| Method | Path | Returns | |---|---|---| | GET | /api/session/{sessionId}/transcript | Transcript JSON (messages + timestamps) | | GET | /api/session/{sessionId}/pdf | Generated PDF transcript | | GET | /api/session/{sessionId}/files | List of artifacts (transcript JSON, audio, PDF) with 1-hour signed download URLs |

These existed before the bulk export endpoints and remain the right choice when you only need data for a single session.

Script export#

GET /api/scripts/{scriptId}/export

Exports the script definition (steps, locales, policy) as JSON. Useful for backing up or moving a script between environments.

Quickstart#

POST /api/quickstart

Creates a complete, live interview in one call: a project (reused across quickstart calls), a script, a published script version, an active campaign, and either a shareable link or a batch of email invites. All created entities are regular domain objects (marked created_via: "quickstart") and can be managed from the dashboard afterwards.

Body:

{
  "title": "Why customers cancel",
  "locale": "en",
  "questions": [
    { "id": "q1", "type": "open", "text": "What made you cancel?" },
    { "id": "q2", "type": "scale", "text": "How likely are you to return?", "scale": { "min": 1, "max": 10 } },
    { "id": "q3", "type": "multi", "text": "Which plan were you on?", "options": ["Free", "Standard", "Pro"] }
  ],
  "options": { "tone": "warm-professional", "ttsVoice": "alloy", "includeConsent": true, "runtimeMode": "mini" },
  "distribution": { "mode": "share_link", "maxRespondents": 100, "expiresInDays": 30 }
}

Question types: open (optional probe: { depth, maxFollowUps }), yes_no, scale, multi (requires options, 2–12 entries). 1–20 questions per request. For email distribution use "distribution": { "mode": "email", "emails": ["a@b.com"] } (max 50 addresses). options is optional; runtimeMode accepts default, mini (default), or hybrid β€” Advanced Voice is not available through quickstart. A localized welcome message, optional consent question, and closing message are added automatically.

Response (201): { project_id, script_id, script_version_id, campaign_id } plus either share_link: { url, max_respondents, expires_at, … } or invites: { count, emailStats }.

POST /api/quickstart/generate-questions

Turns a research goal into a draft title + question list (same question shape as above). Available on all plans; rate-limited to 10 requests per hour per user.

Body: { "goal": "Why do customers cancel their subscription?", "locale": "en", "count": 7 } (count optional, 3–12, default 7).

Response (200): { "title": "...", "questions": [...] }. Returns 429 when the hourly limit is reached and 502 with error_code: "generation_failed" if the model output cannot be validated β€” fall back to manually written questions.

A share link is one public multi-use URL for a campaign. Each respondent who opens it and starts gets their own invite and session, so results appear in the dashboard exactly like email-invite sessions (with an anonymous placeholder email).

POST   /api/campaigns/{campaignId}/share-links            create; returns the join URL ONCE
GET    /api/campaigns/{campaignId}/share-links            list (with respondent_count, never token hashes)
DELETE /api/campaigns/{campaignId}/share-links/{linkId}   revoke (existing sessions keep working)

Create body (all optional): { "label": "...", "locale": "nl", "max_respondents": 100, "expires_in_days": 30 }. max_respondents defaults to 100 and caps how many respondents can join. The response contains url β€” the full join link (https://…/{locale}/join/{token}). The token is only returned at creation time.

Two public endpoints back the join page (no authentication, rate-limited per IP):

GET  /api/share-links/{token}         status + campaign/script title (no PII)
POST /api/share-links/{token}/join    mints an invite; returns interview_url + delete_url

join returns 410 with a status field (expired, revoked, full) when the link is no longer usable, and 429 when the per-link hourly join guard trips.

API key management#

These endpoints accept only Supabase JWT tokens (browser sessions). An API key cannot be used to create or revoke another API key β€” that would defeat the point of being able to revoke a leaked key from the dashboard.

GET    /api/me/api-keys           list non-revoked keys (no plaintext)
POST   /api/me/api-keys           body: { "name": "..." }; returns plaintext ONCE
DELETE /api/me/api-keys/{id}      revoke

The POST response includes key.plaintext. This is the only time it is ever returned β€” store it immediately or generate a new key.

Errors#

All endpoints return JSON errors of the form { "error": "<message>" } with conventional HTTP status codes:

| Status | Meaning | |---|---| | 400 | Invalid input (bad format, malformed body, …) | | 401 | Missing, malformed, expired, or revoked token | | 404 | Resource not found, or caller does not own it | | 413 | ZIP request exceeded the session cap | | 5xx | Server error β€” check your logs and retry |

Beyond the cap#

If your project genuinely needs more than 500 sessions in a single archive, the recommended pattern today is to call the campaign endpoint per-campaign in a loop and assemble the result client-side. A real async job pipeline (queue + email-when-ready) is on the roadmap; in the meantime, please get in touch if you hit this limit regularly.