Skip to content

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.

In the dashboard: Webhooks → Add webhook. Paste the URL, choose the events, press Test.

Or over the API, with a secret key:

Terminal window
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.

EventWhendata
waitlist.signup.createdSomeone joins the waitlist (API, widget, hosted page). Not sent for CSV imports or for an email that was already on the listsignup_id, email, position, referral_code, referred, source, utm_source, utm_medium, utm_campaign, referrer, landing_path, metadata, created_at
waitlist.signups.invitedYou invite people. One event per invite wave, not per personcount, invited (list of signup_id and email), invited_at
changelog.entry.publishedAn entry goes live: published directly, published from a draft, or when its scheduled time comesentry_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.createdSomeone 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.createdFeedback arrives, already classifiedfeedback_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…",
"data": { "email": "[email protected]", "position": 42, "source": "hn" }
}

Headers: Nordva-Event-Id (the same across retries, so use it to ignore duplicates), Nordva-Event-Type, Nordva-Delivery-Id, Nordva-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);
}
app/api/nordva/route.ts
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 3
import 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 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.

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 hn
New feedback (bug): Export fails for lists over 2,000 rows
Changelog 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.

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.

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.

CodeHTTPMeaning
INVALID_WEBHOOK_URL422The URL is not a public https:// URL. message says which rule it broke.
WEBHOOK_LIMIT_REACHED403The project already has as many endpoints as the plan includes.
PLAN_UPGRADE_REQUIRED403Webhooks start on Indie.
KEY_INSUFFICIENT_PERMISSIONS403A publishable key was used. Webhooks need a secret key.