Skip to content

Docs

API reference

The Nosie API is a small REST surface — seven resources over plain JSON — for creating interview studies, adding consented contacts, launching outreach, and reading results. Base URL: https://nosie.app/api/v1. The machine-readable OpenAPI 3.1 spec lives at /api/v1/openapi.json.

Last updated: 9 September 2026

Authentication & limits

Authenticate every request with an API key in the Authorization header. Keys are created in the dashboard under Developers, shown once, and prefixed nk_live_.

Authorization: Bearer nk_live_...
  • Rate limit: 60 requests/minute per key. Exceeding it returns 429 with a Retry-After: 60 header.
  • Content type: application/json for all request bodies.
  • IDs are UUIDs.

Endpoints

POST/v1/projects

Create a project

A project is the product being researched. Studies hang off it, and contacts belong to it. Body: { name, website_url? }.

GET/v1/projects

List projects

GET/v1/projects/{id}

Get a project

POST/v1/projects/{id}/archive

Archive a project

The only way to remove a project from your workspace. Refused with 409 while any study is active or launching — pause it first. Every remaining study is archived, then the project; both leave the dashboard but stay readable by id. Contacts are untouched, because consent attestations and opt-outs outlive the project they were collected for. Not reversible.

POST/v1/projects/{id}/contacts

Add contacts (consent required)

Body: { contacts: Contact[], consent: Consent }. Every request must carry a consent attestation; requests without one are rejected with 422.

A contact is a person on this product, not a participant in one study. Enrol them into a study with POST /v1/studies/{id}/enrolments. An opt-out is project-wide and permanent: it covers every study of that project, including ones created later.

GET/v1/projects/{id}/contacts

List project contacts

POST/v1/studies

Create a study

A study is one interview effort against a project — one objective, one brief. Body: { project_id, name, brief } — see the Brief schema below. Returns 201 with the created study, 422 if validation fails, or 409 project_archived if the project has been archived — nothing can be created under an archived project. Adding contacts to an archived project, or enrolling anyone into an archived study, is refused the same way.

Add recurring: true and context_slots to run the study in repeated waves — the same people, interviewed again each round. Because a recurring study is quiet between waves by design, it is exempt from the exhaustion close and its quota counts per wave rather than across the whole study. That also means it needs some other way to finish, so it requires at least one of close_criteria.ends_at, close_criteria.spend_cap_cents or quota.

Briefs also accept two optional specificity levers: probes (up to 8 must-hit questions the interview cannot end without asking) and boundaries (up to 500 characters describing what the interviewer must not do).

Three optional fields decide how the study sounds. voice_id is an ElevenLabs voice id, for a study whose interviewer is a named character rather than a researcher; absent, every study shares the same default voice. first_message replaces the generated opening outright, and may carry {{slots}} declared in context_slots. consent_method only changes how the generated opening describes the consent it relies on — “you opted in” is true of a signup form and wrong for anything else. It never affects whether a contact may be interviewed; each contact's own consent attestation governs that.

GET/v1/studies/{id}

Get a study

PATCH/v1/studies/{id}

Update a study

Only the fields you send change: name, external_ref, brief, context_slots, close_criteria, quota and retention_days. brief is a whole Brief rather than a patch of one — GET the study, edit what you want, send it back. Merging a partial brief would mean you could set an optional field but never clear it again.

You can’t change project_id, recurring, status or research_mode. The project owns the contacts; recurring decides whether a quota counts per wave and whether a study can close on exhaustion, so changing it would reinterpret enrolments that already exist; status has its own launch and pause endpoints; and research mode is frozen once an interview has completed. Create a new study instead.

The rules are checked against the study as it would be after your change, not against the body you sent — so removing a context_slots entry the stored brief still uses is refused, the same as adding a slot nothing supplies.

Changing anything the interviewer is built from — the brief, the study name or retention_days — re-provisions the study’s voice agent, and the response carries agent_sync. If the provider rejects the update you get a 502 with study_updated: true: the study is saved but calls still use the previous voice and opening. Retry — PATCH replaces rather than accumulates, so repeating it is safe.

POST/v1/studies/{id}/enrolments

Enrol contacts into this study

Body: { contact_ids: string[] }. The contacts must already exist on this study's project. A contact who has opted out is refused individually and reported in rejected — that refusal is enforced by the database and cannot be overridden, so the rest of the batch still enrols. It applies to every wave, including ones created later: a wave is a new round of contact, not new consent.

To run waves, send { enrolments: [{ contact_id, wave, context }] } instead. Exactly one of the two shapes must be present; a body carrying both is rejected rather than guessed at. The contact_ids shape is unchanged and enrols into the default wave.

wave is an opaque round label — "2026-08", "day-30". Nosie never parses it and never infers order from it; rounds are ordered by when their first enrolment was created, so your own naming scheme does not have to mean anything to us. The same person can be enrolled once per wave.

context carries per-person, per-wave values substituted into the interview, so two people in the same wave can be asked about different things. Every key must be declared in the study's context_slots and every declared slot must be supplied, or the enrolment is refused with 422 — an undeclared slot is never substituted, and the interviewer would read it aloud verbatim. Limits: 4,000 characters per value, 8,000 per enrolment.

external_ref is your own idempotency key for the enrolment, unique within the study. If a batch fails partway through, retry it unchanged: an entry whose external_ref already exists is returned in enrolled with the existing row's id — as { contact_id, wave, enrolment_id, external_ref } — instead of being inserted a second time, and outreach is not started again for it. Two entries in one request sharing an external_ref, or one reusing an external_ref that already belongs to a different contact, are refused individually via rejected rather than erroring.

If the study is active, outreach for these enrolments starts immediately — you do not need to launch again. The response includes scheduled and held; “held” means a contact's quota group is already full, so they stay pending and start automatically if you raise that target. This is what makes a continuously-refreshing cohort work without a weekly re-launch.

GET/v1/studies/{id}/enrolments

List enrolments

GET/v1/studies/{id}/contacts

List participants

POST/v1/studies/{id}/contacts

Moved — returns 410

Contacts belong to a project, not a study. Create them with POST /v1/projects/{id}/contacts, then enrol them with POST /v1/studies/{id}/enrolments.

GET/v1/studies/{id}/quota

Live quota state

Per-stratum progress: completed vs target, which groups are full, how many contacts are held, and how many were screened out. Returns 404 when the study has no quota configured. Interviews beyond a target still complete and still count, but are absorbed by Nosie rather than billed to you.

POST/v1/studies/{id}/launch

Launch outreach

Starts contacting every eligible contact within their local calling hours. Returns a launch summary.

POST/v1/studies/{id}/pause

Pause outreach

POST/v1/studies/{id}/archive

Archive a study

The terminal state you choose, as opposed to completed, which a study reaches when a close criterion stops it. Allowed from draft, paused and completed; refused with 409 while outreach is running — pause first. Cancels any outreach still scheduled and hides the study from the dashboard. Interview content still expires on the study’s retention schedule. Not reversible.

POST/v1/enrolments/{id}/call

Ask Nosie to phone one participant

Schedules exactly one call for this enrolment — nobody else is called — at the next time inside the study’s calling window. Body: { phone? } in E.164; it is written to the contact, whose modes are never changed — so no later launch or enrolment will call them. Omit it to use the number already on the contact (422 phone_required if there is none). Calling this endpoint asserts the participant asked, or agreed, to be phoned. Returns 202 { outcome: "call", scheduled_for }. When Nosie’s own rules refuse, it returns 202 { outcome: "skipped", reason } and writes nothing: opted_out, study_not_active, call_not_allowed_for_study (add call to the study’s allowed_modes), enrolment_finished, or call_already_scheduled.

POST/v1/embed/sessions

Create, enrol and mint an embed session in one call

A shortcut across contact create, enrolment and session mint. Body: { study_id, external_ref, contact: { …, consent }, mode, host_origin, wave?, context? }. contact.consent is the same mandatory consent attestation as on contact create. Returns 201 { embed_url, expires_at, enrolment_id }. A repeat call with the same external_ref in the same study returns 200 with reused: true and a fresh URL, ignoring the contact and consent fields. Nosie never invites or calls a participant enrolled this way — they are reached only through your embed. Refusals: 403 origin_not_allowed, 409 contact_opted_out, enrolment_finished, study_not_active, study_archived or project_archived, 422 context_slots_mismatch, and 503 study_launching with Retry-After.

POST/v1/enrolments/{id}/session

Mint an embeddable interview session

Returns a short-lived URL to frame in your own page. Body: { mode: "web_voice"|"web_text", host_origin }. host_origin must be one of the frame_ancestors registered on your API key — it becomes the sole origin permitted to frame the session; the chosen mode is carried on the returned URL and preselected in the frame. An unregistered origin is refused with 403 origin_not_allowed. Refused with 409 if the contact has opted out (contact_opted_out), the enrolment has already finished (enrolment_finished), or the enrolment’s study is not active (study_not_active). Returns { embed_url, expires_at } on success.

GET/v1/contacts/{id}/result

Get a contact's interview result

Returns the transcript, summary, extracted attributes, quality signals, and outreach history.

POST/v1/contacts/{id}/opt-out

Record an opt-out

For an opt-out the person gave you directly — by email, on a call, in person. Body: { channel: "email"|"phone"|"in_person"|"other", note? }. Same cascade as an SMS STOP: the contact is opted out of the whole project, permanently; scheduled outreach is cancelled; and contact.opted_out is delivered to your webhooks. Returns { opted_out: true, already_opted_out: false }, or already_opted_out: true if they already were and nothing changed. Nothing reverses it, including Nosie staff.

GET/v1/webhooks

List webhooks

POST/v1/webhooks

Create a webhook

The signing secret is returned once, on creation. See Webhooks for events and signature verification.

PATCH/v1/webhooks/{id}

Pause or resume a webhook

Body: { active: boolean }. Pausing stops deliveries without deleting the endpoint or rotating its secret; events that occur while it is paused are not queued for later. Returns the webhook.

DELETE/v1/webhooks/{id}

Delete a webhook

Brief schema

The brief is the interview's contract: what to learn, from whom, and exactly which attributes to extract.

FieldTypeDescription
objective *stringWhat you want to learn.
audience_description *stringWho is being interviewed.
key_topics *string[]3–6 topics the conversation must cover.
success_criteria *stringWhat a successful interview produces.
identity_label *stringWho Nosie says it's calling on behalf of.
tonestringConversation tone. Default: "warm, concise, respectful".
allowed_modes *("call"|"sms"|"email")[]Channels Nosie may use.
timebox_secondsintegerInterview length cap. Default: 180.
output_schema *objectMap of attribute key → { type: string|number|boolean, description }. Extracted answers are returned under these exact keys.
scheduleobjectWhen the study may call. { type: "asap" } (default) uses Nosie's 9am–8pm window in each contact's local time; add allowed_local_hours: { from, to } (hours 0–23, contact-local, clamped to 8am–9pm) to set the study's own. type: "window" is accepted with the same field for compatibility.

Contact schema

FieldTypeDescription
name *stringContact's name.
phonestringE.164 format, e.g. +64211234567. Required for call/sms modes.
emailstringRequired for email mode.
allowed_modes *("call"|"sms"|"email")[]Channels this contact may be reached on.
timezonestringIANA timezone, e.g. Pacific/Auckland. Used for local calling hours.
country"NZ" | "AU" | "US"Contact's country.
custom_fieldsobjectString key-value pairs carried through to results.

Errors

Every refusal is JSON with a machine-readable code in error, plus whatever fields that code carries. Match on error, never on message: the sentence may be reworded, the code never changes.

{ "error": "validation_failed", "issues": [
  { "path": ["brief", "objective"], "message": "Required" }
] }

The complete list, rendered from the same registry the API answers from — a code you see in a response is on this page.

FieldTypeDescription
401unauthorizedThe Authorization header is missing, is not `Bearer nk_…`, or names a key that does not exist or was revoked. Fields: message.
403origin_not_allowed`host_origin` is not one of the frame_ancestors registered for this API key. Fields: message.
404not_foundNo such record on this account — one that exists on another account reads exactly the same. Fields: message.
404quota_not_configuredThe study exists but has no quota, so there is no quota state to report. Fields: message.
404study_not_foundThe study disappeared while its retention period was being changed.
409archive_conflictThe record cannot be archived while outreach is running. Fields: message.
409contact_opted_outThe person has opted out of this project. Opt-outs are permanent and never overridden.
409enrolment_finishedThe enrolment is already completed, disqualified or excluded, so there is nothing left to open.
409launch_conflictThe study's current state does not allow a launch (or resume) right now. Fields: message.
409launch_not_readyThe study cannot launch until the items in `missing` are in place. Fields: missing, message.
409pause_conflictThe study cannot be paused now: it is not active, or a launch or conversation is in progress. Fields: message.
409project_archivedThe project is archived, so it takes no new contacts, studies or sessions. Fields: message.
409study_archivedThe study is archived, so it takes no new enrolments or sessions. Fields: message.
409study_not_activeThe study is not running, so no interview session can open for it. Launch it first.
410movedThis endpoint no longer exists; `message` names the ones that replaced it. Fields: message.
422contacts_rejectedThe contacts passed validation but could not be saved; `message` says why. Fields: message.
422context_slots_mismatchThe slots a Brief uses and the slots the study declares (or the `context` supplied for them) do not match exactly. Fields: undeclared, missing, detail, message.
422phone_requiredThe contact has no phone number; send one as `phone` (E.164). Fields: message.
422recurring_needs_close_criteriaA recurring study needs an end date, a spend cap or a quota, because exhaustion cannot close it. Fields: detail.
422retention_extension_refusedRetention cannot be raised once the study has interviews: participants consented under the period disclosed then. Fields: detail.
422retention_lookup_failedThe retention change could not be checked, so nothing was changed. Safe to retry.
422retention_out_of_range`retention_days` is outside the allowed range. Fields: detail.
422retention_write_failedThe retention change passed its checks but could not be saved. Safe to retry.
422study_config_frozenThe change would reinterpret enrolments or interviews that already exist, so it is refused. Fields: detail.
422study_ids_need_project_id`study_ids` scopes a webhook within one project, so `project_id` is required with it. Fields: message.
422study_not_in_projectEvery `study_ids` entry must be a study of `project_id`. Fields: message.
422unknown_project_id`project_id` does not name a project on this account. Fields: message.
422validation_failedThe request body did not match the endpoint's schema; `issues` lists each problem. Fields: issues.
429rate_limitedToo many requests: 60 per minute per API key (wait for `Retry-After` seconds), or the account's daily project-research limit. Fields: message. Header: Retry-After.
500create_failedThe project could not be created. Safe to retry. Fields: message.
500internal_errorNosie failed on its side; nothing about the cause is returned. Safe to retry.
502agent_sync_failedThe study saved but its voice agent did not update, so calls still use the previous voice. Retry the PATCH. Fields: detail, study_updated, hint.
503study_launchingThe study is mid-launch; retry after `Retry-After` seconds. Fields: message. Header: Retry-After.

POST /v1/studies/{id}/enrolments refuses individual entries rather than the request: a 201 whose rejected[] carries one of these codes beside a human reason.

FieldTypeDescription
study_not_foundrejected[].codeThe study no longer exists.
study_archivedrejected[].codeThe study is archived and takes no new enrolments.
external_ref_repeatedrejected[].codeThe same `external_ref` appears more than once in this request; every repeat after the first is refused.
external_ref_conflictrejected[].code`external_ref` is already enrolled in this study for a different contact.
contact_not_on_projectrejected[].codeThe contact does not belong to this study's project.
contact_opted_outrejected[].codeThe person has opted out of this project.
context_slots_mismatchrejected[].codeThe entry's `context` does not supply exactly the slots the study declares.
enrolment_conflictrejected[].codeThe contact looked already enrolled in that wave, but the existing enrolment could not be found. Safe to retry.
study_launchingrejected[].codeThe study is mid-launch; retry shortly.
insert_failedrejected[].codeThe enrolment could not be saved. Safe to retry.

Machine-readable versions: OpenAPI spec · llms.txt · llms-full.txt. Questions? hello@nosie.app