Frank Public API — Developer Guide

API documentation

Create products and researches, publish them for interviews, and pull transcripts — programmatically. All requests and responses are camelCase JSON; all timestamps are ISO 8601 UTC strings.

Authentication

Send your secret key as a bearer token on every request. Keys are created under API keys and shown only once.

Base URL
https://api.hifrank.ai
Prefix
/v1
Header
Authorization: Bearer frank_sk_live_...

First request

curl
curl https://api.hifrank.ai/v1/products \
  -H "Authorization: Bearer frank_sk_live_..."

Typical flow

  1. POST /v1/products — describe what you're researching.
  2. POST /v1/researches — goals + topics/questions. Comes back as a draft.
  3. PUT /v1/researches/{id}/product — attach the product (optional).
  4. PUT /v1/researches/{id}/publish — pick interviewer + language; becomes active and can take interviews.
  5. GET /v1/researches/{id}/interviews then GET /v1/interviews/{id} — read transcripts.

Products

POST/v1/products

Create a product

Request body

FieldTypeNotes
namerequiredstringMax 60 characters. Must be unique across your account — a duplicate returns 409.
descriptionrequiredstringMax 1500 characters. What the product is.
categoryrequiredenumOne of the 16 values below.
briefoptionalstringContext the interviewer uses. Defaults to description.
urlsoptionalstring[]Up to 10 http(s) URLs.

category values

saas_software mobile_app apparel_accessories beauty_personal_care home_living electronics_consumer_tech sports_outdoor toys_hobbies food_beverages health_wellness pet_products automotive_accessories office_stationery online_services offline_services other

Request

curl
curl -X POST https://api.hifrank.ai/v1/products \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Notes",
    "description": "A note-taking app for teams",
    "category": "saas_software",
    "urls": ["https://acme.example"]
  }'

Response · 201 Created

FieldTypeNotes
productIdalways presentstring (uuid)Use this to link the product to a research.
namealways presentstringAs sent.
descriptionalways presentstring | nullAs sent.
categoryalways presentstringOne of the category values.
briefalways presentstringSent value, or the description.
urlsalways presentstring[]Empty array when none were sent.
createdAtalways presentstring (ISO 8601)UTC, e.g. 2026-01-01T10:00:00.000Z.

Response

JSON
{
  "productId": "d91c8f22-3b7e-4c21-9f10-6a2b8c4d5e70",
  "name": "Acme Notes",
  "description": "A note-taking app for teams",
  "category": "saas_software",
  "brief": "A note-taking app for teams",
  "urls": ["https://acme.example"],
  "createdAt": "2026-01-01T10:00:00.000Z"
}
GET/v1/products

List products

Returns an array of the product object above, newest first. No parameters.

GET/v1/products/{id}

Get a product

Returns one product object. id must be a uuid — anything else is a 400; a product that isn't yours is a 404.

PUT/v1/products/{id}

Update a product

Send only the fields you want to change — omitted fields keep their current value. Same field rules and types as create; all are optional here. Returns the updated product object.

Researches

POST/v1/researches

Create a research

Creates the research immediately as a draft. It cannot take interviews until you publish it.

Request body

FieldTypeNotes
namerequiredstringNon-empty; whitespace is trimmed.
descriptionrequiredstringWhat you want to learn.
goalsrequiredstring[]At least one non-empty goal.
topicsrequiredobject[]At least one topic — see the topic fields below.
researchTypeoptionalenumdiscovery, product_experience or retention_growth.
botTypesoptionalenum[]Any of audio, video, chat. Publishing sets this to ["audio"].
greetingMessageoptionalobjectOpening line per channel: chat, audio, video — all optional strings.
promptsoptionalobjectOverrides the generated prompts: voice, video, chat, tts_style — all optional strings. Sending these stops Frank from auto-updating them later.

topics[] — each topic

FieldTypeNotes
namerequiredstringTopic heading.
messageoptionalstringIntro the interviewer reads before the topic.
questionsrequiredobject[]At least one question.

topics[].questions[] — each question

FieldTypeNotes
textrequiredstringThe question itself.
followUpDepthoptionalstringHow hard to probe, e.g. shallow, medium, deep. Free text — it is guidance for the interviewer, not a fixed set.
probingoptionalstringWhat to dig into, e.g. ask for specific examples.
tagsoptionalstringYour own labels for grouping, e.g. onboarding,pricing.

Request

curl
curl -X POST https://api.hifrank.ai/v1/researches \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Trial churn discovery",
    "description": "Why trial users do not upgrade",
    "researchType": "discovery",
    "goals": ["Understand upgrade blockers"],
    "topics": [
      {
        "name": "Onboarding",
        "message": "Let us start with your first few days.",
        "questions": [
          {
            "text": "How was your first week?",
            "followUpDepth": "deep",
            "probing": "Ask for specific moments of friction",
            "tags": "onboarding"
          },
          { "text": "What confused you early on?" }
        ]
      }
    ]
  }'

Response · 201 Created

FieldTypeNotes
researchIdalways presentstring (uuid)Use it for link, publish and interview calls.
namealways presentstring | nullAs sent.
statusalways presentstringdraft on create, active after publish, stopped when halted.
typealways presentstring | nullThe researchType you sent, else null.
goalsalways presentstring[]As sent.
productIdalways presentstring (uuid) | nullnull until you link a product.
createdAtalways presentstring (ISO 8601)UTC.

Response

JSON
{
  "researchId": "6b2f8c10-1a4d-4e88-b0c3-2f7a9d1e4b56",
  "name": "Trial churn discovery",
  "status": "draft",
  "type": "discovery",
  "goals": ["Understand upgrade blockers"],
  "productId": null,
  "createdAt": "2026-01-01T10:00:00.000Z"
}
PUT/v1/researches/{id}/product

Link a product

Request body

FieldTypeNotes
productIdrequiredstring (uuid)A product you own.

A product can back many researches; a research holds at most one. Sending the product that's already linked is a no-op (200). Linking a research that already has a different product returns 409 — there is no re-link. Returns the research object.

Request

curl
curl -X PUT https://api.hifrank.ai/v1/researches/6b2f8c10-.../product \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "productId": "d91c8f22-..." }'
PUT/v1/researches/{id}/publish

Publish (activate)

Sets the interviewer and flips the research to active so it can take voice interviews.

Request body

FieldTypeNotes
languagerequiredenumISO 639-1 code from the list below. Case-insensitive.
personaoptionalenumolivia (default) or frank — the interviewer's voice and name.
durationoptionalintegerTarget interview length in minutes. Minimum 1.
promptsoptionalobjectSame shape as on create; overrides the generated prompts.

language values

zhChinese
enEnglish
frFrench
deGerman
itItalian
jaJapanese
koKorean
ptPortuguese
esSpanish
multiMultilanguage — follows the participant

Request

curl
curl -X PUT https://api.hifrank.ai/v1/researches/6b2f8c10-.../publish \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "persona": "olivia", "language": "en", "duration": 15 }'

Returns the research object with status set to active.

GET/v1/researches

List researches

Returns an array of the research object above, newest first. No parameters.

Participants

A research keeps a list of known participants. Give one private context and, when they later join an interview with the matching email, the interviewer receives that context and asks sharper questions. The context is never shown to the participant.

PUT/v1/researches/{researchId}/participants

Add or update a participant

Upserts a single participant by email. Sending the same email again updates that entry rather than creating a duplicate.

Request body

FieldTypeNotes
emailrequiredstringMatched trimmed and case-insensitive.
nameoptionalstringThe participant's name.
contextrequiredstringPrivate interviewer notes, plain text, up to 1,000 characters. Required when adding a participant; on an update you may omit it to keep the stored value. It can never be blanked — remove the participant instead. Over 1,000 characters returns 400 and stores nothing.

Request

curl
curl -X PUT https://api.hifrank.ai/v1/researches/6b2f8c10-.../participants \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "email": "bob@example.com", "name": "Bob", "context": "Former enterprise customer — probe on pricing and onboarding." }'

Response

FieldTypeNotes
researchIdalways presentstring (uuid)The parent research.
emailalways presentstringThe stored, normalized email.
namealways presentstring | nullThe participant's name.
hasContextalways presentbooleanWhether usable private context is stored.
createdAtalways presentstring (ISO 8601)When the entry was created.
updatedAtalways presentstring (ISO 8601)When it was last updated.

Draft, active and stopped researches accept participants. A deleted research returns 409.

DELETE/v1/researches/{researchId}/participants

Remove a participant

Removes the participant and their private context. Past interviews they already completed are left untouched. Returns 204 with no body; an email that isn't on the list returns 404.

Request body

FieldTypeNotes
emailrequiredstringThe participant to remove. Matched trimmed and case-insensitive.

Request

curl
curl -X DELETE https://api.hifrank.ai/v1/researches/6b2f8c10-.../participants \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "email": "bob@example.com" }'

Website widget

Every research has three widget formats — floating, inline and popup — each with its own embed ID and settings. The protected domains and the widget's on/off switch are changed in the dashboard only.

GET/v1/researches/{researchId}/embeds

List widget formats

The three formats with their embed IDs, plus the research's protected domains and whether the widget is switched on.

Request

curl
curl https://api.hifrank.ai/v1/researches/6b2f8c10-.../embeds \
  -H "Authorization: Bearer frank_sk_live_..."

Response

FieldTypeNotes
researchIdalways presentstring (uuid)The research.
protectedDomainsalways presentstring[]The sites the widget may run on.
activatedalways presentbooleanWhether the widget is switched on.
formatsalways present{ type, embedId }[]One entry each for floating, inline and popup.
GET/v1/researches/{researchId}/embeds/{type}

Get a widget format

One format's settings, its install snippet — exactly what the Code tab shows — how to install it, and whether it will run. type is floating, inline or popup.

Request

curl
curl https://api.hifrank.ai/v1/researches/6b2f8c10-.../embeds/floating \
  -H "Authorization: Bearer frank_sk_live_..."

Response

FieldTypeNotes
embedIdalways presentstringThis format's key; it only works with this format's snippet.
settingsalways presentobjectIntro copy, button labels, launcher position (floating only), identity mode and fields, consent options and appearance.
snippetalways presentstringThe install snippet.
installalways presentobjectWhere the snippet goes, what the page needs, what single-page apps must do, the identity placeholders to replace, and a link to the setup guide.
runnablealways presentbooleanWhether participants can take an interview once it's installed.
blockersalways present{ code, message }[]What stops them: research_draft, research_stopped, embed_revoked, widget_off, no_protected_domain, out_of_credits. out_of_credits leaves the widget visible but stops interviews from starting; the others keep it hidden.
PUT/v1/researches/{researchId}/embeds/{type}

Change a widget format

Send any subset of settings; everything else keeps its stored value and the other formats are untouched. Unknown fields, domain and activated return 400.

Request

curl
curl -X PUT https://api.hifrank.ai/v1/researches/6b2f8c10-.../embeds/floating \
  -H "Authorization: Bearer frank_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "floatingAlign": "left", "startingContent": { "title": "How was checkout?" } }'

Response (in addition to the GET fields)

FieldTypeNotes
snippetChangedalways presentbooleanTrue only when the identify call in the snippet changed — replace the installed snippet then.
snippetChangedNotealways presentstringThe same, in a sentence.

Title up to 50 characters, description 350, button labels 40. A deleted research returns 409.

Interviews & transcripts

GET/v1/researches/{researchId}/interviews

List a research's interviews

Summaries only, newest first — use it to discover interview ids.

Response · array of

FieldTypeNotes
interviewIdalways presentstring (uuid)Fetch the transcript with it.
researchIdalways presentstring (uuid)The parent research.
statusalways presentstring | nullscheduled, in_progress, completed, incomplete or dropped.
typealways presentstringaudio, video or chat.
participantalways presentobjectname and email, each a string or null when the participant stayed anonymous.
startedAtalways presentstring (ISO 8601) | nullnull if it never started.
endedAtalways presentstring (ISO 8601) | nullnull while in progress.
durationalways presentinteger | nullLength in seconds.

Response

JSON
[
  {
    "interviewId": "2b7f8c10-7c1d-4a90-9e63-1d5c8b3a2f41",
    "researchId": "6b2f8c10-1a4d-4e88-b0c3-2f7a9d1e4b56",
    "status": "completed",
    "type": "audio",
    "participant": { "name": "Jane", "email": "jane@acme.com" },
    "startedAt": "2026-01-01T10:00:00.000Z",
    "endedAt": "2026-01-01T10:20:00.000Z",
    "duration": 1200
  }
]
GET/v1/interviews/{id}

Get an interview + transcript

Every field from the summary above, plus the full transcript in order.

transcript[] — each turn

FieldTypeNotes
sequencealways presentinteger1-based turn order.
rolealways presentstringassistant (the interviewer) or user (the participant).
textalways presentstringWhat was said.
startTimestampalways presentstring (ISO 8601)When the turn started.
endTimestampalways presentstring (ISO 8601)When the turn ended.

Response

JSON
{
  "interviewId": "2b7f8c10-...",
  "researchId": "6b2f8c10-...",
  "status": "completed",
  "type": "audio",
  "participant": { "name": "Jane", "email": "jane@acme.com" },
  "startedAt": "2026-01-01T10:00:00.000Z",
  "endedAt": "2026-01-01T10:20:00.000Z",
  "duration": 1200,
  "transcript": [
    {
      "sequence": 1,
      "role": "assistant",
      "text": "Thanks for joining — how was your first week?",
      "startTimestamp": "2026-01-01T10:00:04.000Z",
      "endTimestamp": "2026-01-01T10:00:08.000Z"
    },
    {
      "sequence": 2,
      "role": "user",
      "text": "Honestly, setup took longer than I expected.",
      "startTimestamp": "2026-01-01T10:00:09.000Z",
      "endTimestamp": "2026-01-01T10:00:14.000Z"
    }
  ]
}

Errors & rate limits

Every error uses the same envelope. field is present on validation errors and names the offending property. Every response carries an x-request-id header — include it when reporting an issue.

JSON
{
  "error": {
    "code": "validation_error",
    "message": "language must be one of: zh, en, fr, de, it, ja, ko, pt, es, multi",
    "field": "language",
    "requestId": "req_1a2b3c"
  }
}
StatusCodeWhen
400validation_errorMalformed id, or the body failed validation
401invalid_api_keyMissing, invalid, or revoked key
404not_foundNot yours, or does not exist
409conflictProduct name taken, or research already has a product
429rate_limitedRate limit hit — wait Retry-After seconds

Requests are rate limited per key. On 429 the Retry-After header tells you how many seconds to wait. Anything that isn't yours returns 404 rather than 403, so ids can't be probed.