Webhooks
Send publishing events and other staqly events to n8n or your own service as signed HTTP requests.
When a post is scheduled, published or fails, when a channel needs reconnecting or a
schedule asks for new posts, something usually has to happen next: log it, tell the team,
publish through your own tool. A webhook tells your automation about it. Staqly sends a signed
JSON request to your URL for every event you subscribe to, and keeps retrying until your
endpoint answers with a 2xx.
Webhook, alert or poll?
| You want to | Use |
|---|---|
| Run code when something happens (n8n, a script, your own service) | A webhook (this page) |
| Tell a person that a post went out or failed, or a channel needs reconnecting | Alerts |
| Publish through your own tool and let staqly wait for the result | A channel on your own automation: subscribe to post.publish_requested, or poll GET /v1/work |
How webhooks work
- Something happens in staqly, such as a post being published. Staqly records an event.
- For every active webhook whose
eventsandcollection_idsmatch, staqly queues a delivery. - A background worker sends the delivery as a
POSTwith a JSON body and anX-Staqly-Signatureheader. - Your endpoint verifies the signature, does its work and answers
2xx. Anything else is retried.
Setting up a webhook
Create the webhook
Pick the URL that should receive events and the events you care about. Creating webhooks
needs the webhooks:manage scope in cloud mode (admin includes it); the local access token and
dashboard sessions have it.
curl -s -H "Authorization: Bearer $STAQLY_API_KEY" -X POST http://localhost:3333/v1/webhooks -H 'Content-Type: application/json' -d '{
"url": "https://example.com/hooks/staqly",
"description": "Publishing log",
"events": ["post.published", "post.failed"]
}'Save the signing secret
The create response contains the signing secret (whsec_...). Store it now: it is
returned in full only by the create request and by a rotation (PATCH /v1/webhooks/{id}
with {"rotate_secret": true}). Every other response shows it masked (whsec_**** plus the
last 4 characters). Neither response is kept for Idempotency-Key
replay.
Verify requests in your receiver
Check the X-Staqly-Signature header on every request before you act on it. Copy-paste
code for Node.js, Python, Go and the TypeScript SDK is in
Verifying signatures.
Send a test event
Click Send test on the webhook in the dashboard, or run:
curl -s -H "Authorization: Bearer $STAQLY_API_KEY" -X POST http://localhost:3333/v1/webhooks/wh_lv5rc7rttjnfwnnsn4ny/testThis queues a webhook.test event for that webhook only. Then check the
delivery history to see what your endpoint answered.
Receiver on the same machine or in Docker?
Staqly blocks loopback targets such as http://localhost:5678 by default. If staqly runs
directly on your machine (not in Docker) and the receiver listens on localhost, set
WEBHOOK_ALLOW_LOOPBACK=true. If staqly runs in Docker, localhost is the staqly container
itself: use host.docker.internal or the receiver's container name on a shared Docker network instead. For example, with the
example n8n compose file the URL is http://n8n:5678/webhook/staqly-publish (Docker only). See
Network policy and n8n.
Webhook settings
| Field | Default | Notes |
|---|---|---|
url | — | Absolute http(s) URL, no credentials in it |
description | "" | Free text, up to 500 characters |
events | ["*"] | 1 to 50 patterns: "*", a glob inside an event family ("post.*", "post.*_requested", "schedule.*") or a known event type. Anything else, e.g. the typo "post.publised" or a custom event such as "post.shipped", is refused with 422. "*" leaves out the credential and session audit events; name their family to get them (admin only) |
collection_ids | [] | Only events from these collections; empty = all |
active | true | Pause without deleting. Deliveries already queued for a paused webhook finish as failed ("webhook disabled"); re-enable and retry them to resend |
timeout_ms | 10000 | 1,000 – 60,000 (the dashboard asks for seconds, 1 – 60) |
max_attempts | 8 | 1 – 20 |
Change any field with PATCH /v1/webhooks/{id}; delete a webhook with
DELETE /v1/webhooks/{id}. See Event types for everything you can subscribe
to.
Plan limits (cloud): 3 webhooks on Starter, 10 on Plus, unlimited on Pro and Business. Over the limit, creating
a webhook returns 402 plan_limit_exceeded with details.limit = "webhooks". See
Plans.
The request
POST <your url>
Content-Type: application/json
User-Agent: Staqly-Webhooks/1.0
X-Staqly-Event: post.published
X-Staqly-Event-Id: evt_looiyqe5kfkjh4xedcnd # stable per event: deduplicate on this
X-Staqly-Delivery: dlv_bgx4s2p4xnzimajdkoki # stable across retries of one delivery
X-Staqly-Timestamp: 1791800000 # unix seconds, when this attempt was signed
X-Staqly-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + raw body)>{
"id": "evt_looiyqe5kfkjh4xedcnd",
"type": "post.published",
"created_at": "2026-10-20T07:00:04.000000Z",
"workspace_id": "ws_4uvnnzgy5vvpo4hnqnco",
"delivery_id": "dlv_bgx4s2p4xnzimajdkoki",
"attempt": 1,
"actor": { "type": "system", "id": "publisher", "name": "Publisher" },
"data": {
"post": {
"id": "post_oalirbz67y4m4cqxq5dw", "status": "published", "content": "v1.4 is out.",
"publish_at": "2026-10-20T07:00:00Z", "published_at": "2026-10-20T07:00:04Z",
"targets": [ { "channel_id": "ch_7h2kq9xw3nbvd5yhzm6a", "channel_name": "Brand on X", "provider": "x", "status": "published", "external_url": "https://x.com/brand/status/1790000000000000042" } ]
},
"collection": { "id": "col_qjuybkx6355nz4no6pqk", "name": "Product updates" }
}
}- The payload is built from the event snapshot taken when the change happened, not the post's current state.
actor.typeislocal,session,api_keyorsystem(the scheduler, the publisher).post.publish_requestedaddschannel,targetandtimeout_secondsfor your own automation.- Media URLs inside
data.post.media_items[].urlusePUBLIC_API_URLand require authentication.
Verifying signatures
Compute HMAC-SHA256(secret, "<X-Staqly-Timestamp>.<raw body>"), hex-encode it, prefix
sha256= and compare it in constant time with X-Staqly-Signature. Use the raw request
body: re-serialized JSON will not match. Rejecting timestamps older than a few minutes
protects against replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStaqly(secret: string, rawBody: string, headers: Record<string, string | undefined>) {
const ts = headers['x-staqly-timestamp'] ?? '';
const sig = headers['x-staqly-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}The SDK also exports isValidWebhook(secret, rawBody, headers), which returns true or
false instead of throwing.
Delivery guarantees
Delivery is at-least-once and not ordered:
- The event and its delivery rows are written in the same database transaction as the change (a transactional outbox). If the change commits, the delivery exists.
- A background worker sends deliveries outside any transaction, several in parallel (see Retries). It leases each one for 2 minutes before sending; if the process dies mid-send, the delivery is sent again when the lease runs out. Pending deliveries survive restarts.
- So a receiver can see the same event twice. Deduplicate on
X-Staqly-Event-Id(or the body'sid) when a side effect must not repeat. - And events can arrive out of order: parallel deliveries and retries mean a later event
(say
post.published) can arrive before an earlier one of the same post (post.scheduled). Order by the body'screated_at(when the change happened, not when it was sent) and ignore an event older than the last one you handled for that post. The webhook body has no sequence number; when you need every event in exact order, readGET /v1/events, whoseseqgrows with every event, for example with the webhook only as the trigger.
Retries
A delivery succeeds on any 2xx response. Anything else (non-2xx, timeout, connection
error) is retried with exponential backoff:
| Setting | Value |
|---|---|
| First retry after | WEBHOOK_RETRY_BASE (default 15 s) |
| Growth | doubles every attempt, ±20 % jitter |
| Cap | WEBHOOK_RETRY_MAX (default 1 h) |
| Gives up after | max_attempts (default 8) → status failed |
| Stops immediately on | 410 Gone |
The worker sends at most 4 requests at a time overall and 2 per workspace. A workspace whose receivers are slow is skipped (its deliveries stay due) instead of holding up everyone else.
When a receiver keeps failing
After 5 failed attempts in a row to one webhook (any delivery: an error, a timeout or a
non-2xx answer), its circuit breaker opens: the webhook's due deliveries wait instead of
being sent, for the backoff of the failures past the fifth (up to WEBHOOK_RETRY_MAX). Waiting
counts no attempt, so nothing is given up because of it. Then one round goes through again; the
first success closes the breaker. The count is kept in the server's memory, so a restart starts
it afresh.
Delivery history and testing
The delivery history keeps the status, attempts, the last status code, the error and the first 2 KB of the last response. In cloud mode it keeps only the status code and a short reason, never the response body.
| Where | How |
|---|---|
| Dashboard | Webhooks → select a webhook to see its last 50 deliveries (event, status, attempts, response, error, next attempt), refreshed every 3 s while one is pending and every 15 s otherwise; Retry re-queues any delivery that is not pending |
GET/v1/webhook-deliveries | e.g. ?webhook_id=wh_lv5rc7rttjnfwnnsn4ny&status=failed |
POST/v1/webhook-deliveries/{id}/retry | re-queue a finished delivery |
POST/v1/webhooks/{id}/test | queue a webhook.test event to this webhook only |
The test endpoint is rate-limited per API key or session (burst 5, then 10 per minute;
429). The CLI has the same commands: staqly webhooks deliveries, staqly webhooks retry
and staqly webhooks test.
Network policy (SSRF guard)
Webhook URLs are user input, so the worker restricts where it connects:
- Always blocked: link-local addresses (including the
169.254.169.254cloud metadata endpoint), multicast, unspecified and reserved ranges. IPv6 forms that embed an IPv4 address (NAT64, 6to4) are checked as that IPv4 address. - Private addresses (LAN, Docker networks such as
http://n8n:5678;100.64.0.0/10counts as private) are allowed by default in Staqly Community Edition only (WEBHOOK_ALLOW_PRIVATE, defaulttruein local andfalsein cloud mode). - Loopback (
127.0.0.1) is blocked by default. Enable it withWEBHOOK_ALLOW_LOOPBACK=trueonly when the API runs outside Docker and the receiver listens on localhost. Inside Docker,localhostis the staqly container itself: usehost.docker.internalor the receiver's container name on a shared Docker network. - The check runs on the resolved IP at connect time, so DNS rebinding cannot sneak past it. Redirects are not followed and no proxy is taken from the environment.
A blocked target shows up in the delivery's last_error, for example
private destination 192.168.1.20 is not allowed (set WEBHOOK_ALLOW_PRIVATE=true).
Next steps
Schedules
Give a collection a publishing rhythm, pick which posts go out, and let a schedule ask your agent or an endpoint for new posts when the collection runs dry.
Alerts
Get a message on Telegram, Slack, Discord, ntfy or email when a post goes out or fails, when a channel needs reconnecting or when a schedule asks for new posts.