API reference
Webhooks
Get an event in your product when someone joins, moves up, gets in or leaves.
Use your agent. Paste this into it:
Read hunchwire.com/docs/webhooks and set up webhooks so my app hears when people join.
On this page
WebhooksWebhooks
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.
GET
/projects/:id/setup/site/webhooks?page=1
Get the endpoint and its delivery log, newest first, 20 a page.
Show 6 details
url: where events go, ornullwhile webhooks are off.events: the events it gets.all_events: every event there is.failing_since: when it started failing, ornull. 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,pageandper_page: ask for the nextpagewhilepage × per_page < total.deliveries: each withid,event,event_id,person_id,state(pending,deliveredorfailed),attempts,response_status(your endpoint's answer, ornull),error(why it failed, ornull),next_attempt_at,delivered_atandcreated_at. With emails, each also hasbody: what Hunchwire sent.
curl https://app.hunchwire.com/projects/:id/setup/site/webhooks?page=1 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/setup/site/webhooks?page=1",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/site/webhooks?page=1", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
PATCH
/projects/:id/setup/site/webhooks
Set the URL and the events.
Details
- The URL must be https.
http://localhostworks for testing. - Leave out
eventsto keep them. Send all of them, or none, to get every event. - Send
"url": ""to turn webhooks off.
curl -X PATCH https://app.hunchwire.com/projects/:id/setup/site/webhooks \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"webhooks": {"url": "https://yourapp.com/hooks/hunchwire", "events": ["person.joined"]}}'
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/setup/site/webhooks",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"webhooks": {"url": "https://yourapp.com/hooks/hunchwire", "events": ["person.joined"]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/site/webhooks", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"webhooks": {"url": "https://yourapp.com/hooks/hunchwire", "events": ["person.joined"]}}),
})
const data = await res.json()
POST
/projects/:id/setup/site/webhooks/test
Send a test event now, with a sample person. Returns the delivery with its body.
curl -X POST https://app.hunchwire.com/projects/:id/setup/site/webhooks/test \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/setup/site/webhooks/test",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/site/webhooks/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/projects/:id/setup/site/webhooks/deliveries/:webhook_delivery_id/retry
Send one delivery again, now. Returns the delivery.
curl -X POST https://app.hunchwire.com/projects/:id/setup/site/webhooks/deliveries/:webhook_delivery_id/retry \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/setup/site/webhooks/deliveries/:webhook_delivery_id/retry",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/site/webhooks/deliveries/:webhook_delivery_id/retry", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
Receive an event
Hunchwire posts each event to the URL:
personis 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
idyou've seen. - Behind a firewall? Let
User-Agent: Hunchwire-Webhooks/1.0through on this path.
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"}}
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.
import crypto from "node:crypto";
// req.body must be the raw text Hunchwire sent (express.text({ type: "application/json" })).
const [t, v1] = req.get("Hunchwire-Signature").split(",").map((part) => part.split("=")[1]);
const expected = crypto.createHmac("sha256", process.env.HUNCHWIRE_SECRET).update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 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'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 |
bysays who made the change:person,agent,signup(they signed up again and confirmed), ornullwhen it happened on its own.- With
invite,urlis always set. Send it yourself, by email or text, if your product does. - A friend's link sends
person.let_inwith 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(viareferral) when they confirm. The link's owner getsperson.friend_joinedthen, 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.
{ "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" } }
Made from skill.md, the file agents read. Add .md to any page's address for its Markdown.