# Webhooks

> Get a request at your own URL when someone joins the waitlist, feedback arrives or a changelog entry is published. Signed, retried, and ready for Slack, Discord, Zapier, n8n and Make.

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

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

Or over the API, with a secret key:

```bash
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

| 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:

```json
{
  "id": "evt_01K5…",
  "type": "waitlist.signup.created",
  "created_at": "2026-09-17T20:41:07.512Z",
  "project_id": "prj_01K4…",
  "data": { "email": "jane@example.com", "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`.

## 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.

```typescript
// Node 18+, any framework. `rawBody` must be the exact bytes received.

  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);
}
```

```typescript
// Next.js App Router: app/api/nordva/route.ts

  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
# Python 3

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

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

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: jane@example.com (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](/guides/changelog-widget-api/#slack-and-discord).

These URLs are credentials. They are stored encrypted and never shown in full again.

## 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

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

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

Source: https://docs.nordva.dev/guides/webhooks/
