A webhook is an HTTPS endpoint of yours that Zilfu calls when something happens in a space — a post publishes, a publish fails, someone comments. It is the push half of the API: instead of polling GET /spaces/{space}/posts to find out whether the 9am post went out, you get told.
Endpoints are scoped to one space. A webhook created in a space only ever hears about that space's accounts and posts, so an agency running five client spaces subscribes five times, and each client's events stay on their own endpoint.
Create an endpoint
From the app, open Webhooks in the sidebar, choose Add Webhook, paste the URL, and tick the events you want. Or over the API:
curl https://zilfu.app/api/spaces/42/webhooks \
-H "Authorization: Bearer $ZILFU_API_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/zilfu",
"events": ["post.published", "post.failed"]
}'
The response carries the endpoint plus its signing secret, as a sibling of data:
{
"data": {
"id": 7,
"url": "https://example.com/hooks/zilfu",
"events": ["post.published", "post.failed"],
"is_active": true,
"created_at": "2026-08-16T09:12:44+00:00",
"updated_at": "2026-08-16T09:12:44+00:00"
},
"secret": "a32characterstringgoeshereokay00"
}
The secret is returned once, on the response that creates the endpoint. Listing webhooks never includes it. Store it wherever your app keeps its other secrets before you move on.
Managing webhooks is restricted to the space owner. Any member of your team can list a space's endpoints; only the owner can create, edit, rotate, or delete one.
If you lose the secret
Rotate it. There is no way to read the old one back, but you can issue a replacement without touching the URL or the event list:
curl -X POST https://zilfu.app/api/spaces/42/webhooks/7/rotate \
-H "Authorization: Bearer $ZILFU_API_TOKEN" \
-H "Accept: application/json"
The response has the same shape as the create call — the endpoint under data, the new secret beside it. In the app, the circular-arrows button on the webhook's row does the same thing.
Rotation takes effect on the next delivery, and there is no overlap window where both secrets verify. Update your receiver first, then rotate, or accept a short gap where signatures fail.
Events
| Event | Fires when |
|---|---|
post.scheduled |
A post is created with a future publish time, or an approved post is scheduled |
post.published |
A post reaches the platform |
post.failed |
Publishing a post failed after its final retry |
comment.published |
A scheduled follow-up comment reaches the platform |
comment.failed |
A scheduled follow-up comment failed |
comment.received |
Someone else comments on one of your posts |
account.connected |
A social account is connected to the space |
account.disconnected |
A social account is disconnected |
Drafts and immediate publishes do not raise post.scheduled — the first has no publish time, and the second goes straight to post.published or post.failed.
The payload
Every delivery is a POST with a JSON body in the same envelope:
{
"event": "post.published",
"timestamp": "2026-08-16T09:00:03+00:00",
"data": {
"post_id": 8821,
"cluster_id": "9f1c...",
"account_id": 310,
"platform_id": "17851234567890",
"permalink": "https://www.threads.net/@you/post/C8xY",
"published_at": "2026-08-16T09:00:02+00:00"
}
}
event and timestamp are always present. What sits in data depends on the event:
| Event | data fields |
|---|---|
post.scheduled |
post_id, cluster_id, account_id, scheduled_at |
post.published |
post_id, cluster_id, account_id, platform_id, permalink, published_at |
post.failed |
post_id, cluster_id, account_id, error |
comment.published |
comment_id, post_id, account_id, platform_id, permalink, published_at |
comment.failed |
comment_id, post_id, account_id, error |
comment.received |
inbox_comment_id, account_id, post_id, remote_comment_id, author_username, content, permalink |
account.connected |
account_id, social, display_name |
account.disconnected |
account_id, social, display_name |
cluster_id groups the posts created together in one composer submission — the same content going to five accounts shares one, so you can tell "five platforms, one post" from "five separate posts".
A post that publishes to several accounts sends one delivery per account, because each account publishes independently and can fail on its own.
Verifying a delivery
Each request carries a Signature header: the hex-encoded HMAC-SHA256 of the raw request body, keyed with your signing secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromZilfu(rawBody, signature, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
// Compare in constant time so a mismatch does not leak where it diverged.
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
}
$expected = hash_hmac('sha256', $request->getContent(), $secret);
abort_unless(hash_equals($expected, $request->header('Signature', '')), 403);
Sign the raw body, not a re-encoded one. The signature covers the exact bytes sent. Most frameworks hand you a parsed object, and serializing it back to JSON will reorder keys or change spacing, producing a different digest and a failed check. Reach for the raw-body accessor — req.rawBody, request.getContent(), await request.text().
Verification is optional; nothing breaks if you skip it. What it buys you is knowing the request came from Zilfu, which matters as soon as the handler does something consequential — spending money, messaging a customer, kicking off another publish. Anyone who learns your endpoint URL can otherwise POST whatever they like at it.
Delivery, retries, and failures
- Deliveries are
POSTrequests withContent-Type: application/json, over HTTPS with certificate verification on. - Your endpoint has 3 seconds to respond. Do the slow part in a background job and answer immediately.
- Any
2xxcounts as accepted. Anything else — or a timeout — is a failure. - A failed delivery is retried twice, roughly 10 seconds and then 100 seconds later. After the third attempt it is dropped.
- Deliveries are not deduplicated. A retry can arrive after your endpoint quietly succeeded, so handle events idempotently — key on
post_idorcomment_idand ignore the ones you have already processed.
Zilfu does not currently expose a log of past deliveries, so your own endpoint's access logs are the record of what arrived. If an endpoint has been down, the events it missed are gone; reconcile by reading the current state from the API rather than waiting for a replay.
Disabling a webhook stops deliveries without deleting it — useful while you are redeploying a receiver. Toggle it from the Enable/Disable button, or PUT the endpoint with {"is_active": false}.
Where to go next
- API reference — the webhook endpoints, with full request and response shapes.
- Rate limits — the ceilings that apply to the calls your handler makes back into Zilfu.