Webhooks
A webhook is a URL of yours that Nordva Launch calls when something happens in your project. Indie includes 2 endpoints per project, Builder 5.
Add one
Section titled “Add one”In the dashboard: Webhooks → Add webhook. Paste the URL, choose the events, press Test.
Or over the API, with a secret key:
curl -X POST https://api.nordva.dev/v1/webhooks \ -H "Authorization: Bearer nv_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/nordva", "events": ["waitlist.signup.created", "feedback.created"] }'The response contains signing_secret once. Store it; it cannot be shown again. The URL is never returned in full either: later responses carry a masked version such as https://example.com/…ordva.
Use "events": ["*"] for everything, including events added later.
URL rules. https:// only, a public hostname, the default port, no username or password. IP addresses and internal names are refused, and redirects are not followed, so give the final URL.
Events
Section titled “Events”| Event | When | data |
|---|---|---|
waitlist.signup.created | Someone joins the waitlist (API, widget, hosted page). Not sent for CSV imports or for an email that was already on the list | signup_id, email, position, referral_code, referred, source, utm_source, utm_medium, utm_campaign, referrer, landing_path, metadata, created_at |
waitlist.signups.invited | You invite people. One event per invite wave, not per person | count, invited (list of signup_id and email), invited_at |
changelog.entry.published | An entry goes live: published directly, published from a draft, or when its scheduled time comes | entry_id, title, body_markdown, version, category, published_at, public_url, audience (public, signed_in or segment: only a public entry’s public_url opens for other people), labels (slugs), cover_image_url, cta_label, cta_url |
changelog.subscriber.created | Someone subscribes to changelog emails (from a browser: when they confirm by email) | subscriber_id, email, created_at, labels (the label slugs they follow; empty means every public entry) |
feedback.created | Feedback arrives, already classified | feedback_id, feedback_text, classification, confidence, summary, user_id, page_url, rating (1 to 5, or null), has_email, created_at |
The submitter’s email is not included in feedback.created, the same as for every feedback routing destination. has_email tells you there is one; read it with GET /v1/feedback/<feedback_id>.
Every delivery has the same envelope:
{ "id": "evt_01K5…", "type": "waitlist.signup.created", "created_at": "2026-09-17T20:41:07.512Z", "project_id": "prj_01K4…",}Headers: Nordva-Event-Id (the same across retries, so use it to ignore duplicates), Nordva-Event-Type, Nordva-Delivery-Id, Nordva-Signature.
Verify the signature
Section titled “Verify the signature”Nordva-Signature looks like t=1789680067,v1=5f2b…. v1 is the HMAC-SHA256, in hex, of the timestamp, a dot and the raw request body, keyed with your signing secret. Check it against the raw body, before parsing JSON, and reject timestamps older than five minutes.
// Node 18+, any framework. `rawBody` must be the exact bytes received.import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromNordva(rawBody: string, header: string | null, secret: string): boolean { const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=") as [string, string])); const timestamp = Number(parts.t); if (!timestamp || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b);}import { isFromNordva } from "@/lib/nordva-webhook";
export async function POST(req: Request) { const raw = await req.text(); if (!isFromNordva(raw, req.headers.get("nordva-signature"), process.env.NORDVA_WEBHOOK_SECRET!)) { return new Response("bad signature", { status: 401 }); } const event = JSON.parse(raw); if (event.type === "waitlist.signup.created") { // queue your own work here; answer first } return new Response("ok");}# Python 3import hashlib, hmac, time
def is_from_nordva(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > 300: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))Answer quickly
Section titled “Answer quickly”Answer with any 2xx within 10 seconds. Do the slow work after you have answered. Anything else counts as a failure: a 3xx (redirects are not followed), a 4xx, a 5xx, or no answer in time.
A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. An endpoint that fails 20 deliveries in a row is switched off; its card in the dashboard says why. Switch it back on there, or with PATCH /v1/webhooks/<id> and {"status": "enabled"}.
Every delivery of the last 30 days is listed under Log on the endpoint, and at GET /v1/webhooks/<id>/deliveries: what was sent, what your endpoint answered, and when the next try is.
A webhook never slows down or fails the API call that caused it. If your endpoint is down, the signup still succeeds.
Slack and Discord
Section titled “Slack and Discord”Paste a Slack incoming-webhook URL (https://hooks.slack.com/services/…) or a Discord webhook URL (https://discord.com/api/webhooks/…) as the URL. Launch recognises them and sends one readable line instead of JSON, because both reject anything else:
New waitlist signup: [email protected] (position 42) from hnNew feedback (bug): Export fails for lists over 2,000 rowsChangelog published: Dark mode https://launch.nordva.dev/log/acme/ce_01K5…A published changelog entry is richer than one line. In Slack it arrives as a Block Kit message: the title (linked to the entry when it is public), a short excerpt, the cover image, the category, labels and version, and buttons for the entry’s own button and “Read the entry”. In Discord it is an embed with the same parts. The cover image and the button are only included when they are absolute links. A signed-in or segment entry gets no link, because its page does not open for anyone else.
An entry published with notify_chat: false skips Slack and Discord endpoints (JSON endpoints still get the event). The dashboard’s “Post to Slack and Discord” box is the same setting. See Changelog widget API.
These URLs are credentials. They are stored encrypted and never shown in full again.
Zapier, n8n, Make
Section titled “Zapier, n8n, Make”Create a “Catch Hook” (Zapier), “Webhook” node (n8n) or “Custom webhook” (Make), and paste its URL. They receive the JSON envelope above; map fields from data. These tools do not check signatures, so treat the hook URL itself as the secret and do not publish it.
With an AI agent
Section titled “With an AI agent”The MCP server has the whole lifecycle: create_webhook, update_webhook, test_webhook, delete_webhook, list_webhooks and list_webhook_deliveries. An agent working in your repository can write the receiving route, create the endpoint, put the signing secret into your env file as NORDVA_WEBHOOK_SECRET, and press test, in one go. The secret is returned to the agent once, in text, with the instruction to write it to the env file and not repeat it; it is left out of the structured result that clients may display or log. Remember to set the same variable in production.
Errors
Section titled “Errors”| Code | HTTP | Meaning |
|---|---|---|
INVALID_WEBHOOK_URL | 422 | The URL is not a public https:// URL. message says which rule it broke. |
WEBHOOK_LIMIT_REACHED | 403 | The project already has as many endpoints as the plan includes. |
PLAN_UPGRADE_REQUIRED | 403 | Webhooks start on Indie. |
KEY_INSUFFICIENT_PERMISSIONS | 403 | A publishable key was used. Webhooks need a secret key. |