Docs
Embedding interviews in your product
Run a Nosie interview inside your own product instead of sending people a link. Your server makes one API call per participant and gets back a short-lived embed_url; your page frames it with a small loader script; the results reach you by webhook, exactly as they do for any other Nosie interview.
Setup is a form, a study and a launch. After that, each participant costs you one request and about ten lines of browser code.
Last updated: 21 September 2026
How does embedding work?
- Once: create an embed integration on the Developers page, which gives you an API key locked to your origins and a signed webhook.
- Once per study: create a project and a study, then launch the study.
- Per participant, per page view: your server calls
POST /api/v1/embed/sessions. It creates the contact, enrols them and returns anembed_url. - In the browser:
Nosie.mount()frames that URL and tells you when the interview starts and ends. - Afterwards:
contact.completed(orcontact.failed) arrives on your webhook with the summary and the attributes your study asked for.
1. Set up an embed integration
Sign in, open Developers and fill in the Embed integration form. One submit creates an API key and a webhook together:
- Allowed origins, one per line: the pages allowed to frame the interview. Scheme and host only, such as
https://app.example.com. No paths and no wildcards. - Webhook URL (https). The events a host needs are pre-ticked:
contact.completed,contact.failed,contact.opted_out,contact.disqualifiedandstudy.themes_updated. You can change them. - Your terms of service: whether they already obtain each participant's consent to be interviewed and recorded. It defaults to no. See what participants are told.
The API key (nk_live_…) and the webhook signing secret (whsec_…) are shown once, on that screen. Store both as server secrets. You can edit the origins and the terms declaration on the key later.
2. Create and launch a study
A project is the product being researched; a study is one interview effort under it, with a brief that says what to ask and which attributes to extract. Both are one call each. Then launch the study: a study that is not live refuses every session call with 409 study_not_active.
# 1. A project: the product being researched
curl -X POST https://nosie.app/api/v1/projects \
-H "Authorization: Bearer $NOSIE_API_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Example Ltd", "subject_url": "https://example.com" }'
# 2. A study under it, with its interview brief
curl -X POST https://nosie.app/api/v1/studies \
-H "Authorization: Bearer $NOSIE_API_KEY" -H "Content-Type: application/json" \
-d '{
"project_id": "PROJECT_ID",
"name": "Why people upgrade",
"brief": {
"objective": "Understand what made customers upgrade to a paid plan",
"audience_description": "Customers who upgraded in the last 30 days",
"key_topics": ["trigger for upgrading", "alternatives considered", "first paid week"],
"success_criteria": "The specific moment each customer decided to pay",
"identity_label": "the Example Ltd team",
"allowed_modes": ["email"],
"timebox_seconds": 300,
"output_schema": {
"upgrade_trigger": { "type": "string", "description": "What made them pay" }
}
}
}'
# 3. Launch it. Until it is active, every session call is refused.
curl -X POST https://nosie.app/api/v1/studies/STUDY_ID/launch \
-H "Authorization: Bearer $NOSIE_API_KEY"The brief is documented in full in the API reference. If you will ever ask Nosie to phone a participant who requests it (POST /api/v1/enrolments/{id}/call), add call to the brief's allowed_modes now. That lets the study place the one call you ask for; it never makes anyone auto-dialled.
3. Mint a session per participant
/api/v1/embed/sessionsContact, enrolment and session in one call
Creates the contact, enrols them in the study and returns a URL to frame. Call it from your server, with your API key, every time you are about to show the interview.
// Server only: your API key never reaches the browser.
// Call this each time you render the interview. Never cache the result.
export async function mintNosieSession(user: { id: string; name: string; email: string }) {
const response = await fetch("https://nosie.app/api/v1/embed/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOSIE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
study_id: process.env.NOSIE_STUDY_ID,
external_ref: user.id, // your id: the same user again reuses the enrolment
contact: {
name: user.name,
email: user.email, // a phone or an email is required
allowed_modes: ["email"],
consent: {
attested: true,
basis: "Accepted Example Ltd terms of service v3 (clause 9 covers research interviews)",
method: "existing_relationship",
},
},
mode: "web_voice", // or "web_text": what the participant chose in your UI
host_origin: "https://app.example.com", // one of the key's allowed origins
}),
});
const body = await response.json();
if (!response.ok) {
// body.error is a machine code, e.g. study_not_active. On 503, retry after Retry-After.
throw new Error(`Nosie refused the session: ${response.status} ${body.error}`);
}
// 201 for a new participant, 200 with reused: true for a repeat
return { embedUrl: body.embed_url as string, enrolmentId: body.enrolment_id as string };
}| Field | Type | Description |
|---|---|---|
| study_id * | uuid | A study on your account. It must be active (launched). |
| external_ref * | string | Your own id for this person in this study, up to 200 characters. A repeat call with the same value reuses the enrolment. |
| contact * | object | name, allowed_modes and a phone or email, as for any contact, plus the mandatory consent attestation: attested: true, basis (at least 10 characters) and method. |
| mode * | "web_voice" | "web_text" | What the participant chose in your UI. The frame offers only that mode, so they are not asked again. |
| host_origin * | string | The origin of the page that will frame the session. It must be one of the key's allowed origins. |
| wave | string | The round of a recurring study. Defaults to "". external_ref is unique per study, not per wave, so use a new one each wave. |
| context | object | Values for the study's declared context slots. It must name exactly those slots, or the call is refused. |
{
"study_id": "4d7a55e8-2b1c-4c3e-9f0a-6d2e8b7c1a90",
"external_ref": "user_8421",
"contact": {
"name": "Aroha Ngata",
"email": "aroha@example.co.nz",
"allowed_modes": [
"email"
],
"consent": {
"attested": true,
"basis": "Accepted Example Ltd terms of service v3 (clause 9 covers research interviews)",
"method": "existing_relationship"
}
},
"mode": "web_voice",
"host_origin": "https://app.example.com"
}{
"embed_url": "https://nosie.app/embed/…?mode=web_voice",
"expires_at": "2026-09-21T02:44:07.000Z",
"enrolment_id": "8c1f0e7a-…"
}- Mint on render; never cache
embed_url. A URL opens for 30 minutes from minting, and cannot be extended. Because a repeat call with the sameexternal_refcosts nothing but a fresh URL, mint one on every page view. - A repeat call is a replay. It returns
200with"reused": true, a fresh URL and the same enrolment. It ignorescontact,consent,waveandcontextentirely: nothing is compared or updated. Change a person's details through the contact routes. The same person in a second study gets a second contact. - The 30 minutes bound starting, not finishing. Someone who starts at minute 29 can finish. Opening or reloading the frame after
expires_atis refused. - Treat
embed_urlas a secret. It is not single-use: it reopens on a reload until it expires, the interview completes, or the enrolment or study closes. Each mint issues a new URL, and earlier ones stay valid on the same terms. - Nosie never emails, texts or calls someone enrolled this way. Not at launch, not when a paused study is relaunched, not in a later wave. They reach the interview only through your frame. The email or phone is still required on the contact (see Limits).
- Nothing is written when the call is refused. A mistyped
host_originleaves no contact behind. See refusal codes. - Rate limit: 60 requests a minute per API key, across every
/api/v1call. Over it, you get429withRetry-After: 60.
The three calls this one replaces (create contacts, enrol, mint a session) stay available in the API reference. Use them when you also want Nosie to email the invitation.
4. Mount it with nosie.js
nosie.js is one dependency-free script. Load it from the origin of your embed_url, then call Nosie.mount() with the URL your server minted for this page view.
<div id="nosie-interview"></div>
<script src="https://nosie.app/embed/v1/nosie.js"></script>
<script>
// EMBED_URL is the embed_url your server minted for this page view.
var interview = Nosie.mount({
el: document.getElementById("nosie-interview"),
embedUrl: EMBED_URL,
title: "Customer interview",
onReady: function () {},
onStarted: function () {},
onEnded: function (detail) {
// Terminal. detail.reason: completed | declined | interrupted
interview.destroy(); // removing the frame is what releases the microphone
},
onError: function (detail) {
// Not terminal: detail.code says why. Keep the frame; the page may offer a retry.
},
});
</script>| Field | Type | Description |
|---|---|---|
| el * | Element | The iframe is appended here. Keep it in place: moving it reloads the frame. |
| embedUrl * | string | The embed_url exactly as returned. Messages are accepted only from its origin and from this iframe. |
| title | string | The iframe's accessible title. Defaults to "Interview". |
| height | number | string | Fixes the frame height (a number or digit string is px). Leave it out and the frame follows nosie:resize, starting at 560px. |
| onReady, onStarted, onEnded, onError, onResize | function | Called with detail, an object of short strings, for the matching nosie:* event. A throwing callback never stops the listener. |
- If you load the script with
async, callNosie.mount()from itsonload, not from the next inline script. - Destroy on
nosie:ended, not onnosie:error.destroy()removes the iframe, which is what releases the microphone; hiding the frame does not. - The loader accepts messages only from the embed URL's origin and from its own iframe. If you write your own
messagelistener instead, check both. - The loader is cached for five minutes (
max-age=300), so a loader fix reaches your pages within minutes.
Which events does the frame send?
Each event arrives as a postMessage of { type, detail }, where detail holds short machine strings only: never a transcript, a name or an error message.
- nosie:ready
- The interview page mounted in the frame. Fires once.
- nosie:started
- The conversation connected.
- nosie:ended
- Terminal: the interview is over for this frame. detail.reason says why. Call destroy() now.
- nosie:error
- Not terminal: detail.code names the failure. The page may offer the participant its own retry, so keep the frame.
- nosie:resize
- detail.height is the content height in px, as an integer string. The loader applies it unless you fixed a height.
nosie:ended detail.reason:
- completed
- The participant finished the interview.
- declined
- The study's screener excluded the participant.
- interrupted
- Reserved for an interruption that leaves no retry in the frame. Nothing sends it today; handle it as terminal.
nosie:error detail.code, a session failure or the reason a link was refused:
- invalid
- The link's token is unknown, malformed or was never issued.
- expired
- The link is past its lifetime. Embedded links last 30 minutes from mint.
- consumed
- The link was already used by an interview that completed.
- opted_out
- The person has opted out of this project. Opt-outs are permanent.
- enrolment_finished
- The enrolment already completed, was screened out, or was excluded.
- study_paused
- The study is paused. The same link opens again once the study is resumed.
- study_closed
- The study is closed. This is final.
- interviews_unavailable
- No interview session could be opened. The page shows the interview as unavailable.
- session_start_failed
- The conversation could not be started.
- microphone_denied
- The participant refused microphone access for a voice interview.
- connection_error
- The conversation's connection dropped.
- session_save_failed
- The session could not be saved after a disconnect. The page offers a retry.
expired and invalid rarely reach you as events: the browser refuses the frame before anything inside can post one (see Limits).
When a conversation drops, the page offers its own retry and posts no nosie:ended. If the participant never retries, the webhook tells you how it ended.
Which webhooks tell you how it ended?
Every framed session that starts ends in contact.completed or contact.failed; none is left silent. A session in which the participant never spoke is contact.failed with completion_status abandoned. One that could not be processed, or was interrupted and never retried, is technical_failure. A session nothing finishes is settled within about three hours.
- contact.invited
- An invitation was sent. Never fires for an enrolment made by the session call.
- contact.call_started
- A phone call connected.
- contact.completed
- An interview finished. Carries summary, attributes, quality and interview_id.
- contact.failed
- The enrolment ended without a completed interview. data.completion_status says how, e.g. abandoned or technical_failure.
- contact.opted_out
- The participant opted out of the project.
- contact.disqualified
- The screener excluded the participant.
- study.completed
- Every enrolment reached a terminal state, or the study's quota filled.
- study.themes_updated
- The study's emergent themes were rewritten. Fires after every completion; fetch GET /api/v1/studies/{id} for the themes.
- usage.recorded
- A billable completed interview was recorded.
- incentive.earned
- A participant completed a study that declares an incentive. You issue the reward.
Match a delivery to your participant with data.enrolment_id, the enrolment_id the session call returned. The payload's external_ref is the study's, if you set one; your per-person external_ref is not in any payload. Dedupe on the X-Nosie-Delivery header and verify X-Nosie-Signature as described in Webhooks.
What refusal codes can the session call return?
A refusal is JSON with a machine error code, and a message where one helps. validation_failed also carries issues.
| Status | error | Meaning | Written |
|---|---|---|---|
| 401 | unauthorized | The Authorization header is missing, is not `Bearer nk_…`, or names a key that does not exist or was revoked. | Nothing |
| 403 | origin_not_allowed | `host_origin` is not one of the frame_ancestors registered for this API key. | Nothing |
| 404 | not_found | No such record on this account — one that exists on another account reads exactly the same. | Nothing |
| 409 | study_not_active | The study is not running, so no interview session can open for it. Launch it first. | Nothing |
| 409 | study_archived | The study is archived, so it takes no new enrolments or sessions. | Nothing |
| 409 | project_archived | The project is archived, so it takes no new contacts, studies or sessions. | Nothing |
| 409 | contact_opted_out | The person has opted out of this project. Opt-outs are permanent and never overridden. | Nothing |
| 409 | enrolment_finished | The enrolment is already completed, disqualified or excluded, so there is nothing left to open. | Nothing |
| 422 | validation_failed | The request body did not match the endpoint's schema; `issues` lists each problem. | Nothing |
| 422 | context_slots_mismatch | The slots a Brief uses and the slots the study declares (or the `context` supplied for them) do not match exactly. | Nothing |
| 429 | rate_limited | Too many requests: 60 per minute per API key (wait for `Retry-After` seconds), or the account's daily project-research limit. | Nothing |
| 503 | study_launching | The study is mid-launch; retry after `Retry-After` seconds. | Nothing |
| 500 | internal_error | Nosie failed on its side; nothing about the cause is returned. Safe to retry. | Removed |
A repeat call checks the existing enrolment in the same order as a first one would: an opted-out contact first, then a finished enrolment.
What are participants told?
Consent in an embedded interview has three parts, and you own two of them.
- Your attestation. The
consentblock on every session call is your statement, as the party whose users these are, that this person agreed to be interviewed. It is required, stored unchangeably and stamped with your API key, and you are responsible for its accuracy under the terms of service. Name the version of your terms you rely on inbasis, and useexisting_relationshiporotheras themethod. - The written step in the frame. Before the interview starts, the frame asks the participant to confirm they are happy to talk to an AI interviewer and have their answers recorded, transcribed and stored. If your integration declares on the Developers page that your terms of service obtain the participant's consent, the written step is skipped, and you are responsible for that declaration being true. Leave it off unless your terms say so.
- The interviewer's opening, always. Whatever you declare, the interviewer's opening always says it is an AI, that it records and transcribes, and asks permission to continue.
Voice interviews are recorded by Nosie's voice provider for quality review; that audio is deleted after 7 days, and Nosie keeps no audio of its own. A web_text chat has no audio. Transcripts and results follow the study's retention period (30 to 1,825 days, 90 by default).
POST /api/v1/contacts/{id}/opt-out. Nobody, including Nosie, can undo one.Limits
These are gaps in the embedding capability today. Plan around them rather than assuming them away.
- No isolation between your own customers. Everything you embed lives in your one Nosie account, told apart only by
external_refand by how you scope your own queries. Nosie enforces the boundary between accounts, never a boundary between your customers inside yours. If you embed Nosie for several customers of your own, keeping their data apart is your responsibility. - Every participant needs a phone number or an email. A contact cannot exist without one, so a product whose users are pseudonymous cannot embed yet. Nosie never sends an invitation to someone enrolled through the session call.
- An expired or unknown link shows a blank frame, with no event. The browser refuses to render it, so nothing inside can post
nosie:error. Mint on render, and treat “nonosie:readywithin a few seconds” as its own timeout. - Keys and origins are managed in the dashboard only. No API endpoint creates a key or edits its allowed origins.
- No SDK or npm package.
nosie.jsand plain HTTPS calls are the whole client surface. - Voice in Safari is unverified. Microphone access inside a cross-origin frame has been tested in Chrome only.
- Participants cannot call Nosie. There is no inbound number. A call happens only when you ask for one for a participant who wants it.
- Host-requested calls come from a US number. Only a US caller number is registered today, so a call to a participant in New Zealand or Australia shows a US caller ID.
Machine-readable versions: OpenAPI spec · llms.txt · llms-full.txt. Questions? hello@nosie.app