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: 6 July 2026
What does a delivery look like?
Every event shares one envelope: event, created_at, and an event-specific data object.
{
"event": "contact.completed",
"created_at": "2026-07-06T02:14:07.000Z",
"data": {
"contact_id": "9be0c1f2-…",
"project_id": "4d7a55e8-…",
"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.
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, project_id, mode. |
| contact.call_started | event | A voice call began. data: contact_id, project_id, attempt_no. |
| contact.completed | event | Interview finished. data: contact_id, project_id, interview_id, summary, attributes (your output_schema keys), quality. |
| contact.failed | event | All attempts exhausted. data: contact_id, project_id, completion_status. |
| contact.opted_out | event | The contact opted out — authoritative and permanent. data: contact_id, channel. |
| contact.disqualified | event | The contact was screened out before an interview ran — terminal, and never billed. data: contact_id, project_id, reason (answer | stratum_full), answers. |
| project.completed | event | Every contact in a project has reached a terminal state, or the project's quota filled. |
| usage.recorded | event | A billable completed interview was recorded. data: interview_id, project_id. Interviews beyond a project's quota are absorbed by Nosie and do not emit this event. |
| incentive.earned | event | A contact completed an interview on a project that declares an incentive. Nosie never issues the reward — you do, in your own system. data: contact_id, project_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.
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_idso duplicates are harmless.
Machine-readable versions: OpenAPI spec · llms.txt · llms-full.txt. Questions? hello@nosie.app