distil

Developers

The distil API

Create a study, have a conversation, read what was said.

Five-minute quickstart

Be the first respondent. You’ll need a terminal, a browser and an email address. The conversation itself takes about 5–10 minutes.

1. Get a key

Sign in at /keys and create a key. Keep it on your server, never in browser code. Get a key →

export DISTIL_API_KEY="dk_live_replace_with_your_key"

2. Create a study

Save the returned study id and share_url. The brief tells distil what to ask about.

curl -sS https://api.distil.cx/v1/studies \
  -H "Authorization: Bearer $DISTIL_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: first-study-1' \
  -d '{"brief":"Find out what made signing up difficult.","question":"What was signing up like?"}'

3. Answer it yourself

Open the share_url from step 2 in your browser and answer until the conversation finishes; it takes about 5–10 minutes. Lost it? This reads the study again; replace study_REPLACE with your study id.

curl -sS https://api.distil.cx/v1/studies/study_REPLACE \
  -H "Authorization: Bearer $DISTIL_API_KEY"

4. List the conversations

Replace study_REPLACE with the study id. Copy a conv_… id from data.

curl -sS 'https://api.distil.cx/v1/studies/study_REPLACE/conversations?status=completed' \
  -H "Authorization: Bearer $DISTIL_API_KEY"

5. Read what you said

Replace conv_REPLACE with that conversation id. The messages contain the transcript.

curl -sS https://api.distil.cx/v1/conversations/conv_REPLACE \
  -H "Authorization: Bearer $DISTIL_API_KEY"

In a signup flow

One study serves every user. Create it once (step 2) and store its share_url. Send each user to share_url?pid=<their user id>&ctx_plan=pro, or embed the study with data-pid. The study page reads pid and ctx_<key> from the URL; they come back on the conversation as participant_id and context. The character and length rules are the embed attributes’ (see Embed); a ctx_ key is the data-context- key without the prefix.

There are no webhooks in v1. To learn a conversation finished, poll the list from your server with status=completed. It is newest-started first, and a conversation can finish after later ones, so skip ids you have already stored. Page with starting_after=<last id of the page> while has_more is true. In the browser, an embedded study fires distil:done on document. To fetch one user’s conversations, add participant_id=<user id>; it composes with the other parameters.

curl -sS "https://api.distil.cx/v1/studies/$STUDY_ID/conversations?status=completed&participant_id=$USER_ID&starting_after=$LAST_SEEN" \
  -H "Authorization: Bearer $DISTIL_API_KEY"
# Omit participant_id to list everyone. First page: omit starting_after. data[].participant_id is your user id; skip ids you stored.
# While has_more, set LAST_SEEN to the last data[].id and repeat.

Embed in your product

The create response includes a ready-to-paste embed snippet; it draws the corner chat. Add data-pid with your user id (opaque); it comes back as participant_id. Attributes such as data-context-plan="trial" come back in context as {"plan":"trial"}. These are untrusted correlation values, not proof of identity.

Embedding a distil study

Add one line to the page and a chat bubble appears in a corner. A person clicks it, talks with distil about your brief, and you read what they said at distil.cx. The snippet never changes once pasted; the look (colour, logo, font) lives on the study.

<script src="https://distil.cx/embed.js"
        data-distil="SHARE_TOKEN"
        data-launcher
        data-pid="USER_ID"
        async></script>

SHARE_TOKEN is the study’s share link token — the last part of its link. Nothing is drawn, and nothing on your page shifts, until distil confirms the study is open for this person.

Attributes

attribute meaning
data-distil required; the share token. Without data-launcher the conversation is framed inline where the tag sits (size it with data-width / data-height, default 480px × 640px).
data-launcher draw a corner button that opens the conversation in a panel (a full-screen sheet at 600px wide or narrower, or 560px high or shorter).
data-pid your opaque user id, forwarded to the study so you can join responses to your users.
data-context-<key> what the person just did, e.g. data-context-level="Year 9".
data-position bottom-right (default) or bottom-left; the panel opens from the same side.
data-offset-x, data-offset-y distance in px from the side and from the bottom; whole numbers, default 20, clamped to 0–400. Use them to clear your own help bubble.
data-hidden with data-launcher: load without drawing the bubble, so you open it yourself with Distil.open().

pid and context

The pid is stored only if it is at most 64 characters of A-Za-z0-9_-; anything else is dropped. Send an internal id — never a name or an email, and the charset rejects both. It identifies your user; it does not prove who they are.

Each data-context-<key> is kept if the key (hyphens become _) is 1–24 of a-z0-9_ — at most 6 keys — and the value is made only of letters, digits, space and _-,./&+'’:;?!()"; values over 40 characters are cut to 40. Any other key or value is dropped. Both appear beside each response in distil and in distil conversations --json. The interviewer does not read them.

JavaScript API

window.Distil exists as soon as the script runs, even when nothing is drawn.

Distil.open();    // open the panel
Distil.close();   // close it
Distil.dismiss(); // remove the launcher and remember that, as the visitor's dismiss button does

open() works after a dismissal, because you asked explicitly. It does nothing when the study is not open or this pid has finished, and it waits for the check if that is still pending.

Events fire on document: distil:ready (detail.live is true or false), distil:open, distil:close, distil:dismiss, distil:done.

document.addEventListener("distil:done", () => analytics.track("study_done"));

When the launcher is not shown

  • Dismissed. Your page called Distil.dismiss(). We store distil:dismissed:<token>:<pid> in your site’s sessionStorage, so the bubble returns on the next visit.
  • Done. The visitor finished the conversation. The panel stays open on the thank-you screen and the launcher goes when they close it; we store distil:done:<token>:<pid>.
  • Closed. The study is at its response cap or past its end date, or this pid already finished.
  • Unreachable. distil.cx did not answer within about 8 seconds, or errored.

In each case your page is left exactly as it was. If localStorage is blocked, the launcher still works but forgets dismissals between visits. The launcher never throws on your page. Your browser’s network log may show a 404 for the check when a study is closed or finished; that is the signal, not a fault.

Accessibility

Escape closes the panel. Focus moves into the conversation on open and back to the bubble on close. The bubble announces its open state; the mobile sheet is a modal dialog and respects safe-area insets.

Content Security Policy

If you set a CSP, allow:

script-src https://distil.cx;
img-src    https://api.distil.cx;
frame-src  https://distil.cx;

The launcher loads its icon and its open/closed check as images from api.distil.cx. The conversation is a frame of https://distil.cx/e/<token>; it uses the microphone for voice answers, which the frame requests through its allow attribute (microphone; clipboard-write) — your own Permissions-Policy must not block the microphone for it. distil makes no cross-origin requests from your page, so you need no CORS configuration on either side.

API reference

Base URL: https://api.distil.cx/v1. HTTPS only: plain HTTP is redirected, so a key sent over it has already crossed in clear. Never use http://. Requests and responses are JSON. Authenticate with Authorization: Bearer dk_live_…. Keys belong to an owner; that owner’s keys share access to its API-created studies.

The default free budget is 100 respondent conversations started per owner per UTC calendar month, across API-created studies. It resets on the first day of the month. At the limit, new starts stop; conversations already started can finish. The owner allows 120 requests per minute, shared by all its keys; 429 responses carry a Retry-After header in seconds.

POST /studies

201 · Create a study. JSON: brief (required, 1–4000 characters), question (optional, ≤2000 characters; defaults to the brief’s first line), metadata (optional, ≤20 string entries; keys ≤40 characters, values ≤500). Optional Idempotency-Key: the same key and body return the original response for 24 hours; a different body returns 409 idempotency_conflict. A replay carries the response header Idempotent-Replayed: true; a first create does not.

GET /studies/{id}

200 · Read a study belonging to your owner.

GET /studies/{id}/conversations

200 · List respondent conversations, newest first; creator/setup conversations are excluded. Query: status (active or completed), limit (1–100, default 20), starting_after (a conv_… cursor), participant_id (only conversations started with that pid; 1–64 of A–Z a–z 0–9 _ -). Returns {object: 'list', data: [conversation without messages], has_more: boolean}. Pass the last id as starting_after for the next page.

GET /conversations/{id}

200 · Read a respondent conversation, including messages.

GET /openapi.json

200 · OpenAPI 3 document. No interactive Swagger UI. Key-management routes are excluded.

GET /keys

200 · Session cookie required, not Bearer auth. Returns {object: 'list', data: [{id, name, prefix, created, last_used}], usage: {month: 'YYYY-MM', conversations, limit}}. prefix is the key’s first 12 characters. usage counts respondent conversations started this UTC month against the free limit.

POST /keys

201 · Session cookie and a matching Origin required. JSON: {name?}. Returns {id, name, key}; the full key is shown only here.

DELETE /keys/{id}

200 · Session cookie and a matching Origin required. Returns {id, deleted: true}. Revoking a key does not remove its owner’s studies.

Study object

id (study_…), object (study), status (open or closed), brief, question, share_url (https://distil.cx/<share_token>), share_token, embed (the embed.js script snippet, with the corner chat launcher), metadata (string map), created (ISO 8601 UTC). The creator token is never returned.

Conversation object

id (conv_…), object (conversation), study (study_…), status (active or completed), participant_id (the pid from data-pid or ?pid=, or null), context (data-context-* or ?ctx_* string map), started, completed (null until completed). Dates are ISO 8601 UTC.

The single-conversation read adds messages: an ordered array of {role, content, created}. Role is assistant or user; content is text and created is ISO 8601 UTC.

Errors

{"error":{"type":"invalid_request_error","code":"invalid_param","message":"…","param":"brief"}}

Every /v1 error uses this envelope. param names an invalid field, otherwise it is null. Another owner’s study or conversation returns 404, never 403.

401 · authentication_error · missing_api_key
Send Authorization: Bearer dk_live_…
401 · authentication_error · invalid_api_key
The key is invalid or revoked.
401 · authentication_error · not_signed_in
Sign in to manage keys.
400 · invalid_request_error · invalid_param
Correct the field named by param.
404 · invalid_request_error · not_found
The resource does not exist or belongs to another owner.
409 · invalid_request_error · idempotency_conflict
Use a new idempotency key for a different body.
429 · rate_limit_error · rate_limited
The owner exceeded 120 requests per minute (shared by all its keys), or minted more than 10 keys in an hour. Wait the seconds in the Retry-After header.
405 · invalid_request_error · method_not_allowed
This path does not accept that method. The Allow header lists the ones it does.
413 · invalid_request_error · request_too_large
The body exceeds 1 MB.
500 · api_error · —
The request failed on the server.