# Webhooks

Get an event in your product when someone joins, moves up, gets in or leaves.

## Webhooks

Get an event in the founder's own product when someone joins, moves up, gets in or leaves. Hunchwire sends each event to one URL, signed and retried. Webhooks work with every way, on every plan.

### Set them up

- `GET /projects/:id/setup/site/webhooks?page=1`: get the endpoint and its delivery log, newest first, 20 a page. *analytics*
  - `url`: where events go, or `null` while webhooks are off.
  - `events`: the events it gets. `all_events`: every event there is.
  - `failing_since`: when it started failing, or `null`. It starts failing when a delivery runs out of retries, and stops when the next one is delivered.
  - `secret_set`: whether the signing secret exists. Let people in uses the same secret. Only the founder can copy it, from **Setup › Connect › Webhooks**.
  - `total`, `page` and `per_page`: ask for the next `page` while `page × per_page < total`.
  - `deliveries`: each with `id`, `event`, `event_id`, `person_id`, `state` (`pending`, `delivered` or `failed`), `attempts`, `response_status` (your endpoint's answer, or `null`), `error` (why it failed, or `null`), `next_attempt_at`, `delivered_at` and `created_at`. With *emails*, each also has `body`: what Hunchwire sent.
- `PATCH /projects/:id/setup/site/webhooks` with `{"webhooks": {"url": "https://yourapp.com/hooks/hunchwire", "events": ["person.joined"]}}`: set the URL and the events. *manage*
  - The URL must be https. `http://localhost` works for testing.
  - Leave out `events` to keep them. Send all of them, or none, to get every event.
  - Send `"url": ""` to turn webhooks off.
- `POST /projects/:id/setup/site/webhooks/test`: send a test event now, with a sample person. Returns the delivery with its `body`. *manage*
- `POST /projects/:id/setup/site/webhooks/deliveries/:webhook_delivery_id/retry`: send one delivery again, now. Returns the delivery. *manage*

### Receive an event

Hunchwire posts each event to the URL:

```http
POST https://yourapp.com/hooks/hunchwire
Content-Type: application/json
User-Agent: Hunchwire-Webhooks/1.0
Hunchwire-Event: person.joined
Hunchwire-Delivery: evt_8Gq2…
Hunchwire-Signature: t=1791751809,v1=5f2c…

{"id": "evt_8Gq2…", "type": "person.joined", "created_at": "2026-10-11T14:02:11Z", "project": {"key": "…", "name": "Loop"}, "person": {…}, "data": {"via": "api"}}
```

- `person` is the person, exactly as the API returns it (see The person).
- Answer with a 2xx within 10 seconds.
- Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours. Then the delivery fails.
- An event can arrive twice. Skip an `id` you've seen.
- Behind a firewall? Let `User-Agent: Hunchwire-Webhooks/1.0` through on this path.

### Check the signature

Check `Hunchwire-Signature` before you trust the body. `v1` is the HMAC-SHA256 of `t`, a dot and the raw body, keyed with the signing secret, in hex. Refuse a `t` more than 5 minutes old. Node is shown here. Python and Ruby are on the **Webhooks** screen.

```js
import crypto from &quot;node:crypto&quot;;
// req.body must be the raw text Hunchwire sent (express.text({ type: &quot;application/json&quot; })).
const [t, v1] = req.get(&quot;Hunchwire-Signature&quot;).split(&quot;,&quot;).map((part) =&gt; part.split(&quot;=&quot;)[1]);
const expected = crypto.createHmac(&quot;sha256&quot;, process.env.HUNCHWIRE_SECRET).update(`${t}.${req.body}`).digest(&quot;hex&quot;);
const fresh = Math.abs(Date.now() / 1000 - Number(t)) &lt; 300;
if (!fresh || v1.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(401);
const event = JSON.parse(req.body); // skip it if you&#39;ve seen event.id before
```

### Events

| `type` | When | `data` |
| --- | --- | --- |
| `person.joined` | Someone new is on the list | `via`: `embed` or `api` |
| `person.friend_joined` | A friend now counts for them: both have confirmed, a held friend was approved, or their link let in someone waiting. When they confirm, each pending friend sends one | `friend_id`, and `rewards` this friend reached for them |
| `person.moved` | The founder or an agent moved them in line | `from_place`, `to_place`, `by` |
| `person.let_in` | They got access | `via`: `invite`, `referral` or `friends`. With `invite` or `referral`, also `invite`: `id`, `url`, `expires_at`, `wave_id` |
| `person.joined_product` | Your product reported they joined it | `invite_id`, or `null` |
| `person.removed` | The founder or an agent took them off the list | `by` |
| `person.restored` | They're back on the list | `by` |
| `person.deleted` | Their data was deleted | Nothing. `person` has only `id`: delete what you hold for it |
| `test` | You sent a test event | `message` |

- `by` says who made the change: `person`, `agent`, `signup` (they signed up again and confirmed), or `null` when it happened on its own.
- With `invite`, `url` is always set. Send it yourself, by email or text, if your product does.
- A friend's link sends `person.let_in` with the same invite as the sign-up's answer. If you already welcomed them, skip it.
- Someone already on the list let in through a friend's link gets `person.let_in` (`via` `referral`) when they confirm. The link's owner gets `person.friend_joined` then, or when they confirm, if later.
- A place that shifts because someone else joined, left or moved sends nothing. Look the person up for their current place.

### The person.joined event

```json
{ "id": "evt_8Gq2…", "type": "person.joined", "created_at": "2026-10-11T14:02:11Z",
  "project": { "key": "…", "name": "Loop" },
  "person": { "id": 4812, "email": "maya@fernwood.studio", "phone": null, "first_name": "Maya", "list": null,
    "state": "waiting", "place": 148, "code": "k7q2m9xa", "link": "https://tryloop.app/?ref=k7q2m9xa",
    "friends": 2, "pending_friends": 0, "referred_by": 4790, "access": { "granted": false, "via": null, "at": null },
    "rewards": [], "joined_product_at": null, "signed_up_at": "2026-10-11T14:02:11Z", "confirmed": false },
  "data": { "via": "api" } }
```
