Docs
Webhooks
Webhooks push interview results to your endpoint the moment they happen — no polling. Every delivery is a JSON POST, signed with HMAC-SHA256 so you can verify it came from Nosie. Create webhooks in the dashboard under Developers or via POST /v1/webhooks; the signing secret is returned once, on creation.
Last updated: 21 September 2026
What does a delivery look like?
Every event shares one envelope: event, created_at, and an event-specific data object.
Every payload identifies both levels: study_id is the interview effort, project_id is the product it belongs to, and external_ref echoes the identifier you set on the study — so you can classify a delivery in your own taxonomy without a lookup.
{
"event": "contact.completed",
"created_at": "2026-07-06T02:14:07.000Z",
"data": {
"contact_id": "9be0c1f2-…",
"study_id": "4d7a55e8-…",
"project_id": "1f6b28aa-…",
"external_ref": "onboarding-v2",
"interview_id": "c3a91b04-…",
"summary": "- Switched from a whiteboard after double-bookings…",
"attributes": {
"previous_tool": "whiteboard + group chat",
"switch_trigger": "missed jobs",
"team_size": 6
},
"quality": { "engagement_level": "high", "coverage": 0.85 }
}
}Headers on every delivery: Content-Type: application/json, X-Nosie-Event, X-Nosie-Delivery (a stable id for this delivery — see Retries & idempotency), and X-Nosie-Signature.
contact.opted_out is project-wide
When you receive this, stop contacting that person for every study of that project — including studies you create later. It is not scoped to the study the opt-out happened under. Nosie enforces this on its side and cannot be overridden, not even by an administrator; your system needs to honour it too.
The same applies to contact_id generally: a contact belongs to a project, so the same id appears across every study of that project and lets you follow one participant over time.
Can I scope an endpoint to one project?
Yes. Set project_id when you create the webhook to receive only that product's events, and study_ids to narrow further to named studies within it. Leave both out for everything.
Scoping is at the project on purpose: the integration boundary is your app, and studies come and go beneath it. Account-level events that belong to no project — billing and spend-cap notices — are always delivered, whatever the scope.
Which events can I subscribe to?
Choose events when creating the webhook; subscribing to none means all events.
| Field | Type | Description |
|---|---|---|
| contact.invited | event | An invitation went out. data: contact_id, study_id, mode. |
| contact.call_started | event | A voice call began. data: contact_id, study_id, attempt_no. |
| contact.completed | event | Interview finished. data: contact_id, study_id, interview_id, summary, attributes (your output_schema keys), quality. |
| contact.failed | event | All attempts exhausted. data: contact_id, study_id, completion_status. |
| contact.opted_out | event | The person opted out. PROJECT-WIDE and PERMANENT: never contact them again for any study of that project, including studies you create later. data: contact_id, project_id, channel. |
| contact.disqualified | event | The contact was screened out before an interview ran — terminal, and never billed. data: contact_id, study_id, reason (answer | stratum_full), answers. |
| study.completed | event | Every contact in a study has reached a terminal state, or the study's quota filled. |
| study.themes_updated | event | The study's theme rollup was rewritten. Fires after EVERY completion's rollup, not once per study. data: study_id, interviews_considered, generated_at — no theme text or quotes here; fetch GET /v1/studies/{id} for emergent_themes. |
| usage.recorded | event | A billable completed interview was recorded. data: interview_id, study_id. Interviews beyond a study's quota are absorbed by Nosie and do not emit this event. |
| incentive.earned | event | A contact completed an interview on a study that declares an incentive. Nosie never issues the reward — you do, in your own system. data: contact_id, study_id, interview_id, description. |
To check your endpoint before a real interview runs, use Send test event on an endpoint in the Developers page. It delivers one webhook.test event to that endpoint only, with the same envelope, a real X-Nosie-Delivery and a real signature, and shows your endpoint's status code and response body. It is sent once and never retried. You cannot subscribe to webhook.test, and its data carries test: true and no interview fields: do not store it as a result. A paused endpoint has to be resumed before it can be tested.
How do I verify a signature?
The X-Nosie-Signature header has the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the string `${t}.${rawBody}` keyed with your webhook secret. Compute it over the exact bytes of the raw request body — before any JSON parsing and without re-serialising it. Re-serialising (even to identical JSON) changes whitespace and key order and will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyNosieSignature(
header: string, // X-Nosie-Signature: "t=1751760000,v1=6f2a…"
rawBody: string, // the exact request body string
secret: string // whsec from webhook creation (shown once)
): boolean {
const { t, v1 } = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2))
);
if (!t || !v1) return false;
// Reject stale timestamps to prevent replay (5 minutes).
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Retries & idempotency
- Respond with a 2xx within 10 seconds; anything else counts as a failed attempt. Do slow work after acknowledging.
- Failed deliveries are retried with backoff, up to 5 attempts. Every retry re-signs the payload with a fresh
t, soX-Nosie-Signatureis different on each attempt — do not use it to detect duplicates. - Delivery is at-least-once — dedupe on
X-Nosie-Delivery, a stable id (the delivery row'sid) that stays the same across every retry of the same delivery, unlike the signature. A receiver that takes longer than 10 seconds and gets the event again should treat a repeatedX-Nosie-Deliveryas a no-op rather than reprocess it. - Reject signatures with a
tolder than 5 minutes to prevent replay (see the verification snippet above).
Machine-readable versions: OpenAPI spec · llms.txt · llms-full.txt. Questions? hello@nosie.app