# Webhook recipes

This page documents **recipes built on the webhooks that already exist** in the app.
There is no separate integration platform, no rule engine and no new runtime: a recipe is
your own small receiver (or a no-code automation in your helpdesk/BI tool) that consumes our
outbound webhook and — where the flow needs it — calls the existing REST API back.

- Settings live on the **campaign detail page → Settings tab → Webhook** (`Webhook URL`,
  `Enable webhooks`) and the **Signing secret** panel right below it.
- Every campaign has its own URL and its own random signing secret; secrets can be rotated
  there at any time.
- The delivery log (**Webhook Events** on the same tab) shows event, status, HTTP status code
  and the receiver's response body, so you can debug without server access.

The API endpoints used by the recipes are listed on `/api/docs`. Authenticate API calls with
`Authorization: Bearer <API_TOKEN>` (create tokens in **Settings → API tokens**).
Webhook callbacks carry **no** API token: they are authenticated by the signature below.

## The envelope

Every event is a `POST` with `Content-Type: application/json` and these headers:

| Header | Example | Meaning |
| --- | --- | --- |
| `X-NPS-Event` | `response.received` | Same value as `event_type` in the body |
| `X-NPS-Signature` | `t=1760860800,s=K7f…=` | Unix timestamp + Base64 HMAC-SHA256 of `"<t>.<raw body>"` |

Body:

```json
{
  "event_type": "response.received",
  "campaign_id": 42,
  "timestamp": "2026-10-19T07:12:33.481Z",
  "test": false,
  "data": { }
}
```

- `test` is `true` for test campaigns (task #22). Test campaigns fire real webhooks so a
  receiver can be rehearsed end-to-end — check this flag before creating tickets or sending
  customer-facing messages.
- `data` is event-specific; see the reference at the end.
- `timestamp` is when the event was created (ISO-8601, UTC). The signature's `t` is when the
  delivery attempt was made; use it for replay protection.

### Delivery and retries

- 10-second timeout per attempt. Any `2xx` counts as delivered; anything else (including a
  timeout or connection error) is a failure.
- Failed events are retried by a scheduled sweep every 5 minutes, **3 attempts in total**
  (initial attempt plus two retries; the scheduled retry is not before ~5 then ~15 minutes).
- After the last attempt the event is marked `FAILED`. Re-enable/repair the receiver and the
  event can be replayed by hand from your side if needed — the payload is stored in the log.
- Event logs are pruned after 90 days.
- Delivery is at-least-once from your point of view: two attempts can both reach the
  receiver (e.g. your `2xx` response was lost). Deduplicate on `(campaign_id, event_type,
  data)` identity, not on arrival count.

### Verifying the signature

Compute `HMAC-SHA256(secret, "<t>.<raw request body>")`, Base64-encode, compare with `s`
using a constant-time comparison, and reject `t` older than ~5 minutes. Use the **raw** body
bytes (before JSON parsing; re-serialising changes whitespace and breaks the HMAC).

Python (Flask):

```python
import base64, hmac, hashlib, time

def verify(req, secret: str, max_skew: int = 300) -> bool:
    header = req.headers.get("X-NPS-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t", ""), parts.get("s", "")
    if not t.isdigit() or abs(time.time() - int(t)) > max_skew:
        return False
    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{t}.".encode() + req.get_data(), hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, sig)
```

Node/Express (keep the raw body):

```js
const crypto = require("crypto");

function verify(rawBody, header, secret, maxSkew = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > maxSkew) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`).update(rawBody)
    .digest("base64");
  const a = Buffer.from(expected), b = Buffer.from(parts.s || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Recipe 1 — Detractor alert into your helpdesk

**Trigger:** `response.received` with `data.rating <= 6` (detractor).
**Goal:** create a follow-up ticket/case within seconds, with the comment and the campaign
name attached, instead of waiting for someone to open the dashboard.

1. On the campaign detail page → **Settings → Webhook**, set the URL of a small receiver
   (or a helpdesk "incoming webhook" endpoint that accepts JSON) and enable webhooks.
2. Receiver: verify `X-NPS-Signature`, ignore `test: true`, and when the rating is 0–6 create
   a case. Return `2xx` quickly (queue slow work) — the request times out after 10 s.

Sample inbound payload:

```json
{
  "event_type": "response.received",
  "campaign_id": 42,
  "timestamp": "2026-10-19T07:12:33.481Z",
  "test": false,
  "data": {
    "respondent_name": "Ada Lovelace",
    "respondent_email": "ada@example.com",
    "rating": 4,
    "csat_rating": 2,
    "comment": "Onboarding was confusing and nobody answered my email."
  }
}
```

`csat_rating` and `comment` are present only when the respondent supplied them.

## Recipe 2 — Ticket closed → create respondent and send the survey

**Trigger:** your helpdesk fires "ticket closed" (its own webhook/automation).
**Goal:** invite the customer to the NPS survey without a CSV import.

This is the reverse direction: your automation calls the existing NPS REST API. Both calls
need `Authorization: Bearer <API_TOKEN>`; the token's user must own (or edit) the campaign,
and the campaign must not be `CLOSED` or `ANONYMIZED`. A `DRAFT` campaign becomes `ACTIVE`
automatically when the first survey goes out.

```bash
# 1) Add the respondent (name is optional; defaults to the email)
curl -sS -X POST "https://niotio.dk/api/campaigns/42/respondents" \
  -H "Authorization: Bearer $NPS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","name":"Ada Lovelace","language":"en"}'

# 2) Send all pending surveys for the campaign
curl -sS -X POST "https://niotio.dk/api/campaigns/42/send" \
  -H "Authorization: Bearer $NPS_API_TOKEN"
```

The send response is `{"sent":1,"failed":0,"total":1}`.

Notes:

- `language` is `en` or `da`; omit it to use the API caller's locale.
- Keep the API token in the automation's secret store, never in the receiver URL.
- If your helpdesk can only send a fixed JSON shape, point it at a 20-line adapter that maps
  `ticket.id`/`customer.email` to the two calls above. No new NPS-side integration is needed.

Sample "ticket closed" input (your side, not an NPS format):

```json
{
  "event": "ticket.closed",
  "ticket": { "id": "ZD-1042", "closed_at": "2026-10-19T06:55:00Z" },
  "customer": { "email": "ada@example.com", "name": "Ada Lovelace", "locale": "en" }
}
```

## Recipe 3 — Sync campaign lifecycle to a team channel or BI job

**Trigger:** `campaign.activated`, `campaign.closed` or `campaign.anonymized`.
**Goal:** the team channel (or warehouse job) learns about lifecycle changes without polling
`GET /api/campaigns`.

Slack and Teams incoming webhooks expect their own shapes (`{"text": …}` / Adaptive Card),
so use a tiny translator, or post the payload as-is to a generic endpoint (Zapier/Make/n8n,
an internal Lambda, …). Return `2xx` only after you have accepted the message, otherwise the
event is retried up to three times and you may post it twice.

```json
{
  "event_type": "campaign.closed",
  "campaign_id": 42,
  "timestamp": "2026-10-19T08:00:00.000Z",
  "test": false,
  "data": { "campaign_name": "Q4 onboarding NPS", "user_id": 7 }
}
```

`campaign.anonymized` additionally carries `comments_deleted` (boolean) and, once the
campaign has follow-up cases, `case_notes_deleted` (count of internal case notes removed) —
a good hook for "purge PII downstream" jobs. `campaign.activated` is the right moment to
start (or resume) any external reminder schedule you keep outside the app.

### Case events (closed-loop follow-up)

Campaigns with follow-up cases emit four more event types through the same envelope, the
same `X-NPS-Signature` and the same retry policy:

| `event_type` | When | `data` fields |
| --- | --- | --- |
| `case.opened` | A detractor (or opted-in passive) response opened a case | `case_id`, `response_id`, `rating`, `status`, `outcome`, `assignee_email`*, `due_at`*, `closed_at`* |
| `case.assigned` | The assignee changed — including unassignment (`assignee_email` is then `null`) | same fields |
| `case.status_changed` | Any status move (`NEW` → `IN_PROGRESS`, reopen, …) | same fields |
| `case.closed` | The case reached `RESOLVED` or `NOT_POSSIBLE` (sent *together with* `case.status_changed`) | same fields |

Case payloads never contain the internal notes — only the facts listed above. Re-saving the
same assignee does not emit an event, and bulk/simulated detractor runs open cases silently
for notifications but still emit `case.opened` so test integrations behave like production.

## Event reference

| `event_type` | When | `data` fields |
| --- | --- | --- |
| `response.received` | A respondent submits a survey | `respondent_name`, `respondent_email`, `rating` (0–10), `csat_rating`*, `comment`* |
| `campaign.activated` | Campaign moved to `ACTIVE` | `campaign_name`, `user_id` |
| `campaign.closed` | Campaign moved to `CLOSED` | `campaign_name`, `user_id` |
| `campaign.anonymized` | Closed campaign anonymised | `campaign_name`, `user_id`, `comments_deleted`, `case_notes_deleted` |
| `case.opened` | A response opened a follow-up case | `case_id`, `response_id`, `rating`, `status`, `outcome`, `assignee_email`*, `due_at`*, `closed_at`* |
| `case.assigned` | Case assignee changed (or cleared) | same as `case.opened` |
| `case.status_changed` | Case status moved | same as `case.opened` |
| `case.closed` | Case closed (also emits `case.status_changed`) | same as `case.opened` |

\* optional. `campaign_id` is always in the envelope, not in `data`.

Out of scope on purpose: inbound webhook *engines*, retry queues you configure, and
third-party iPaaS setup. Recipes are receiver-side code; the app keeps exactly one outbound
webhook per campaign and the documented retry policy above.
