Guides
Webhooks
Add an endpoint
On the Webhooks page, or with create_webhook_endpoint, give the address, and optionally the events it takes and a newsletter. It gets every event when you pick none, new kinds included, and every newsletter's when you pick none. The answer has the signing secret, shown once.
The address must be https:// on the default port, at a public address: not localhost, a private network or a cloud metadata address. Redirects aren't followed, so give the final address. Webhooks are on every plan, with this many endpoints: Starter 3, Growth 5, Pro 10, Scale 20, Max 50. Each Zapier or Make trigger uses one. A self-hosted server has no limit.
curl https://eu.flockstack.io/api/v1/webhooks \
-H "Authorization: Bearer $FLOCKSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/flockstack-events","events":["subscriber.created","subscriber.unsubscribed"]}'Events
subscriber.createdSomeone signed up, was added or came back after unsubscribing. status is pending until they confirm, on a newsletter that asks.subscriber.confirmedA pending subscriber clicked the link in the confirmation email.subscriber.unsubscribedA subscriber unsubscribed: from a link in an email, their mail app's button, or on the dashboard or API.subscriber.updatedA subscriber's tags or custom fields changed. changes says which.subscriber.upgradedA reader started a paid subscription, or their trial began.subscriber.downgradedA paid reader's subscription ended or stopped paying: they get free issues from now on.campaign.sentA campaign finished going out. A campaign sent in steps sends this after each step.order.paidA reader paid for a digital product.
Subscriber events carry the subscriber with the same fields, by the same names, as the API's contacts. Contacts added by an import send no events, so a large import doesn't flood your endpoint. webhook.test is only sent when you ask for a test event.
What a delivery looks like
A POST with a JSON body: the event's type, when it happened, and its data. Answer with any 2xx status within 15 seconds; do slow work after answering.
POST /flockstack-events HTTP/1.1
content-type: application/json
user-agent: FlockStack-Webhooks/1.0 (+https://flockstack.io/docs/webhooks)
webhook-id: msg_6650f1c2e9b1d4a0123456ee
webhook-timestamp: 1760952600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4={
"type": "subscriber.created",
"timestamp": "2026-10-20T09:30:00.000Z",
"data": {
"subscriber": {
"id": "6650f1c2e9b1d4a012345678",
"newsletterId": "6650f1c2e9b1d4a0123456aa",
"email": "jane@example.com",
"status": "active",
"source": "website",
"tier": "free",
"paidUntil": null,
"tags": [
"podcast"
],
"fields": {
"first_name": "Jane"
},
"subscribedAt": "2026-10-20T09:30:00.000Z",
"confirmedAt": null,
"unsubscribedAt": null
},
"resubscribed": false
}
}Check the signature
Events are signed the Standard Webhooks way, so any of its libraries verifies them with your secret. By hand: compute HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed with the base64 after whsec_, and compare it with each v1, value of webhook-signature. Refuse events whose timestamp is more than 5 minutes away.
// Node.js 18 or later, no packages needed. Use the raw body: parsing and
// re-serializing the JSON changes it, and the signature stops matching.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.FLOCKSTACK_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
export function verifyFlockStackEvent(rawBody, headers) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"] ?? "";
if (!id || !timestamp) return false;
// Refuse old events, so a captured request can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// While you rotate the secret, events carry two signatures: one is enough.
return signatures.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || !value) return false;
const received = Buffer.from(value, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// Express: keep the body as text for this route.
app.post("/flockstack-events", express.text({ type: "application/json" }), (req, res) => {
if (!verifyFlockStackEvent(req.body, req.headers)) return res.status(401).end();
const event = JSON.parse(req.body);
// event.type, event.timestamp, event.data. Use webhook-id to skip repeats.
res.status(204).end();
});rotate_webhook_secret makes a new secret. For 24 hours events carry two signatures, one with each secret, so you can change your server without missing any.
Retries and failures
An answer other than 2xx, a redirect, or no answer in 15 seconds is a failure. FlockStack tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then once a day: 11 attempts over about 5 days. Every attempt has the same webhook-id, so you can tell a repeat from a new event. Events can arrive out of order.
An endpoint that hasn't accepted an event through those 5 days is turned off, and the account's owner gets an email. So is one that answers 410 Gone. Fix it, send a test event, and turn it back on. Events from while it was off aren't sent on their own: resend the ones you need from its log (resend_webhook_delivery).
Each endpoint's log keeps its latest 100 deliveries with the body sent, the attempts and the last answer (list_webhook_deliveries).
Zapier and Make
The Zapier and Make apps use these webhooks for their triggers: turning a Zap or a scenario on adds an endpoint, turning it off deletes it.