What you get
A webhook is a request that Whooshly sends to an https address you own whenever something happens. Add up to five endpoints under Settings → Webhooks and tick the events each one wants.
link.visits – scans and clicks on your links and QR codes, in batches of up to 50. lead.created – a landing-page form or a bio-page sign-up. rsvp.created – a registration to an event. ticket.sold – a ticket order was paid. product.sold – a bio-page product was paid for. booking.created – a free or paid appointment was booked.
Webhooks are part of Core. Admins of a workspace manage them, because an endpoint receives customer details. Your landing-page form webhook, set on the page itself, keeps working as before.
Add an endpoint
- Open Settings → WebhooksPaste your https address. It must be public: Whooshly's own addresses, localhost and private networks are refused.
- Pick the eventsAll six are ticked by default. You can change them later.
- Copy the signing secretIt looks like whsec_ followed by 64 characters and is shown once. Use New secret to replace it.
- Send a testSend test posts a signed ping and shows what your endpoint answered. Recent deliveries lists the latest attempts (the last 50 are kept), without any customer data.
What every request looks like
Every event is a POST with a JSON body in this envelope. id never changes across retries, so use it to ignore a repeat. Headers: X-Whooshly-Event, X-Whooshly-Event-Id and X-Whooshly-Signature.
{
"version": "1",
"id": "evt_3f9c1a52d7e44b0a9d1e6c2b7a8f0e11",
"type": "lead.created",
"createdAt": "2026-10-05T10:15:00.000Z",
"data": { ... }
}Check that it came from Whooshly
The signature header is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 of t + "." + the raw body with your signing secret, compare it to v1 in constant time, and reject requests whose t is more than five minutes old. Use the raw bytes, not a re-serialised copy of the JSON.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const [t, v1] = header.split(",").map((part) => part.split("=")[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Leads and RSVPs
lead.created and rsvp.created carry what the visitor entered. A registration to an event is an RSVP (with an attendeeId), never also a lead. A bio-page sign-up is a lead with kind: "bio_signup". source says where the visitor came from when it is known.
"data": {
"submissionId": "9b1c…",
"pageId": "ab12…",
"formKey": "1",
"data": { "name": "Ada Lovelace", "email": "ada@example.com" },
"source": { "link": "7c0d…", "utm": { "source": "instagram" }, "ref": "https://instagram.com/" }
}Tickets, products and bookings
Amounts are in the smallest currency unit (cents). Each is sent once, when the payment or booking is confirmed; a repeated notice from the payment processor does not send it again. A paid booking arrives as booking.created with paid: true, not also as a product sale.
// ticket.sold
"data": { "orderId": "…", "pageId": "…", "amountMinor": 10000, "currency": "usd", "quantity": 2,
"buyerEmail": "grace@example.com", "paidAt": "2026-10-05T10:15:00.000Z" }
// product.sold
"data": { "orderId": "…", "bioLinkId": "…", "productName": "Field guide", "amountMinor": 2500,
"currency": "USD", "buyerEmail": "buyer@example.com", "paidAt": "…" }
// booking.created
"data": { "bookingId": "…", "service": "Intro call", "startsAt": "2026-11-02T15:00:00.000Z",
"endsAt": "2026-11-02T15:30:00.000Z", "timezone": "Europe/Paris", "paid": false,
"customer": { "name": "Asha", "email": "asha@example.com", "phone": null } }Scans and clicks
link.visits arrives a few seconds after the activity, in batches of up to 50 events. Each event has what your dashboard already shows (country, device, referrer, channel, UTM tags) and the link it belongs to. It never includes an IP address or anything that identifies a person. A busy QR code produces many batches, so point this at something built for volume.
"data": {
"events": [
{ "kind": "scan", "at": "2026-10-05T10:15:02.000Z",
"link": { "id": "…", "slug": "spring", "title": "Spring flyer", "type": "qr" },
"country": "DE", "device": "mobile", "referrer": null, "source": "direct",
"utm": { "source": "flyer", "campaign": "spring" } }
]
}Delivery, retries and order
Answer with any 2xx within eight seconds. A 5xx, a timeout, a refused connection, 408 or 429 is retried up to three more times, after about one, two and three minutes. Any other 4xx, and any redirect, is treated as a final no: Whooshly does not follow redirects.
Delivery is at least once, so the same event can arrive twice, and events can arrive out of order. De-duplicate on id.
An endpoint that fails 25 events in a row is switched off, with the reason shown in Settings, so a broken receiver doesn't fill your logs. Fix it and use Turn on.
Zapier
- Create a ZapChoose Webhooks by Zapier as the trigger and Catch Hook as the event.
- Copy the addressPaste Zapier's custom webhook URL into Settings → Webhooks and pick the events.
- Send a testPress Send test in Whooshly, then Test trigger in Zapier. The test event has type ping.
- Build the actionMap fields such as data.data.email into Google Sheets, your CRM or Slack. Zapier does not verify signatures; keep the address private.
Make and n8n
- MakeAdd a Custom webhook module, copy its address into Whooshly, then press Send test and Redetermine data structure.
- n8nAdd a Webhook node with method POST, copy its Production URL into Whooshly, and keep the response code at 200. To verify signatures, add a Code node that runs the check above on the raw body.