Skip to content

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?

  1. Once: create an embed integration on the Developers page, which gives you an API key locked to your origins and a signed webhook.
  2. Once per study: create a project and a study, then launch the study.
  3. Per participant, per page view: your server calls POST /api/v1/embed/sessions. It creates the contact, enrols them and returns an embed_url.
  4. In the browser: Nosie.mount() frames that URL and tells you when the interview starts and ends.
  5. Afterwards: contact.completed (or contact.failed) arrives on your webhook with the summary and the attributes your study asked for.
Developer beta. Embedding is free while it is in beta: API keys, webhooks and the embed surface cost nothing, and it will be priced afterwards. Completed interviews bill normally against your plan, because an embedded interview uses the same voice minutes and analysis as any other. The terms of service grant its use, so embedding your own users' interviews needs no separate agreement; running interviews on behalf of your customers' users does, and is not supported today.

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.disqualified and study.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.

One-time setup
# 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

POST/api/v1/embed/sessions

Contact, 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.ts
// 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 };
}
FieldTypeDescription
study_id *uuidA study on your account. It must be active (launched).
external_ref *stringYour own id for this person in this study, up to 200 characters. A repeat call with the same value reuses the enrolment.
contact *objectname, 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 *stringThe origin of the page that will frame the session. It must be one of the key's allowed origins.
wavestringThe round of a recurring study. Defaults to "". external_ref is unique per study, not per wave, so use a new one each wave.
contextobjectValues for the study's declared context slots. It must name exactly those slots, or the call is refused.
Request body
{
  "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"
}
201 Created (a repeat call: 200 OK, with reused: true)
{
  "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 same external_ref costs nothing but a fresh URL, mint one on every page view.
  • A repeat call is a replay. It returns 200 with "reused": true, a fresh URL and the same enrolment. It ignores contact, consent, wave and context entirely: 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_at is refused.
  • Treat embed_url as 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_origin leaves no contact behind. See refusal codes.
  • Rate limit: 60 requests a minute per API key, across every /api/v1 call. Over it, you get 429 with Retry-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.

Your page
<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>
FieldTypeDescription
el *ElementThe iframe is appended here. Keep it in place: moving it reloads the frame.
embedUrl *stringThe embed_url exactly as returned. Messages are accepted only from its origin and from this iframe.
titlestringThe iframe's accessible title. Defaults to "Interview".
heightnumber | stringFixes 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, onResizefunctionCalled 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, call Nosie.mount() from its onload, not from the next inline script.
  • Destroy on nosie:ended, not on nosie: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 message listener 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.

StatuserrorMeaningWritten
401unauthorizedThe Authorization header is missing, is not `Bearer nk_…`, or names a key that does not exist or was revoked.Nothing
403origin_not_allowed`host_origin` is not one of the frame_ancestors registered for this API key.Nothing
404not_foundNo such record on this account — one that exists on another account reads exactly the same.Nothing
409study_not_activeThe study is not running, so no interview session can open for it. Launch it first.Nothing
409study_archivedThe study is archived, so it takes no new enrolments or sessions.Nothing
409project_archivedThe project is archived, so it takes no new contacts, studies or sessions.Nothing
409contact_opted_outThe person has opted out of this project. Opt-outs are permanent and never overridden.Nothing
409enrolment_finishedThe enrolment is already completed, disqualified or excluded, so there is nothing left to open.Nothing
422validation_failedThe request body did not match the endpoint's schema; `issues` lists each problem.Nothing
422context_slots_mismatchThe slots a Brief uses and the slots the study declares (or the `context` supplied for them) do not match exactly.Nothing
429rate_limitedToo many requests: 60 per minute per API key (wait for `Retry-After` seconds), or the account's daily project-research limit.Nothing
503study_launchingThe study is mid-launch; retry after `Retry-After` seconds.Nothing
500internal_errorNosie 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.

  1. Your attestation. The consent block 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 in basis, and use existing_relationship or other as the method.
  2. 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.
  3. 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).

You must disclose this in your own product and privacy notice. The people you frame an interview for are your users first, and they meet Nosie inside your page. Opt-outs are final, and relaying them is your duty under the terms: if someone opts out with you, record it with 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_ref and 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 “no nosie:ready within 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.js and 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