Skip to content

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.

POST to your endpoint · contact.completed
{
  "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.

FieldTypeDescription
contact.invitedeventAn invitation went out. data: contact_id, study_id, mode.
contact.call_startedeventA voice call began. data: contact_id, study_id, attempt_no.
contact.completedeventInterview finished. data: contact_id, study_id, interview_id, summary, attributes (your output_schema keys), quality.
contact.failedeventAll attempts exhausted. data: contact_id, study_id, completion_status.
contact.opted_outeventThe 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.disqualifiedeventThe contact was screened out before an interview ran — terminal, and never billed. data: contact_id, study_id, reason (answer | stratum_full), answers.
study.completedeventEvery contact in a study has reached a terminal state, or the study's quota filled.
study.themes_updatedeventThe 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.recordedeventA 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.earnedeventA 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.

verify.ts (Node)
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, so X-Nosie-Signature is 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's id) 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 repeated X-Nosie-Delivery as a no-op rather than reprocess it.
  • Reject signatures with a t older than 5 minutes to prevent replay (see the verification snippet above).
Endpoint requirements: HTTPS URL, reachable from the public internet. Test locally with a tunnel (e.g. an ngrok or cloudflared URL) pointed at your dev server. The full API surface is described in the API reference.

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