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: 25 August 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 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.
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.

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 raw request body — before any JSON parsing.

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.
  • Delivery is at-least-once — key your processing on interview_id / contact_id so duplicates are harmless.
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