Push events to your stack.
Subscribe a URL to Storylayer events. Every delivery is HMAC-signed, retried with exponential backoff, and viewable in the dashboard.
Events
Thirteen event types are published today. Subscribe to specific events or use "*" to fan out everything.
| Event | When |
|---|---|
story.scheduled | A story has a confirmed publish time. |
story.published | A story shipped successfully on at least one channel. |
story.published_late | A story shipped successfully but more than 15 minutes past its scheduled_at. Fires *in addition* to story.published. Use for slip alerts (cron lag, transient publisher failures, manual retries that landed long after the scheduled slot). |
story.failed | A scheduled story errored at publish time. Fires per channel (a multi-channel story emits one event per failed channel) so subscribers know exactly which surface broke. For known, classifiable refusals, also emits story.skipped — see below. |
story.skipped | The publisher refused to attempt a post for a known, classifiable reason — vs. story.failed which fires for any failed publish. Fires alongside story.failed for back-compat, so existing subscribers see no change. Use this event when you want to discriminate "the customer needs to fix something" (this event) from "something crashed unexpectedly" (story.failed only). Categories: connection (missing/revoked OAuth), render (Storylayer's render service paused or out of credits), content (placeholder slides, missing media), review (platform-side approval gate, e.g. Pinterest trial mode), rate (Storylayer or platform rate limit), other (unclassified). |
moment.detected | A detector fired against your data. |
moment.auto_drafted | A high-severity moment turned into a draft story. |
connection.expiring | Proactive heads-up that a token is approaching expiry. Fires at three escalating tiers: D-30 (severity: info), D-7 (severity: warning), D-1 (severity: critical). Each tier fires at most once per refresh cycle — the cycle resets on connection.reconnected or a successful auto-refresh. |
connection.expiring_soon | Stronger signal than connection.expiring: token expires within 7d AND auto-refresh actually failed (or there's no refresh path at all). Use this to drive immediate user action vs. the heads-up tone of connection.expiring. Debounced to once per 24h per connection. |
connection.refreshed | The daily refresh cron successfully extended a token's expiry. Useful for ops dashboards that want to confirm the auto-refresh loop is running. |
connection.broken | A token has already expired and the auto-refresh attempt failed. Action required from the user. Fires immediately (not debounced). |
connection.reconnected | The user completed OAuth (or re-saved credentials) on a previously broken / expiring connection. Pairs with connection.broken / connection.expiring_soon as the resolution event. |
pillar.benchmark_below | A configured pillar's rolling-window metric (save_rate / share_rate / click_rate / engagement_rate / a raw count) is below the project's target. Computed daily by the pillar-thresholds cron over the pillar's benchmark_window_days. Debounced to once per 7 days per pillar. |
pillar.gap_detected | A pillar with max_gap_days configured hasn't had a story published in the configured window. Computed daily by the pillar-thresholds cron. Debounced to once per 24h per pillar. |
Recommended subscriptions
Most stacks don't want all twelve events flowing into the same channel. Pick the bundle that matches your downstream system, then layer extras as needed.
| Use case | Subscribe to | Why |
|---|---|---|
| Editorial Slack | story.published · story.failed · moment.detected · moment.auto_drafted | The day-to-day editorial loop: confirmations of what shipped, alerts when something didn't, and a heads-up when a moment trigger fires (e.g. weather, market data) so the editor can react. |
| Ops / on-call | connection.expiring · connection.expiring_soon · connection.broken · connection.reconnected | The full token-lifecycle. Use the multi-tier connection.expiring (D-30 / D-7 / D-1 with severity) to drive different escalation paths. broken is action-required; reconnected is the resolution event. |
| Strategy / analytics | pillar.benchmark_below · pillar.gap_detected · story.published | Drives weekly content reviews. pillar.benchmark_below tells you when a pillar's rolling save/share/click rate slips under target; pillar.gap_detected catches when a pillar hasn't fired in N days. Pair with story.published if you want to side-load metrics into a separate warehouse. |
| Status page / dashboards | story.scheduled · story.published · story.failed · connection.broken | Anything you want a humans-in-the-loop dashboard to surface in real time. The scheduled → published pair lets you compute publish lag. |
| Everything (mirror) | "*" | Use only if you're piping into a generic event lake (Snowflake / BigQuery / a queue). The wildcard fans out every event including ping. |
Threshold-style events (pillar.benchmark_below, connection.expiring, connection.expiring_soon) are debounced server-side — see the events table above for each event's debounce window — so you can wire them straight into a paging tool without rate-limiting wrappers.
Payload shape
All events share an envelope; the data field carries event-specific content.
POST <your-url>
content-type: application/json
x-storylayer-event: story.published
x-storylayer-event-id: evt_2T...
x-storylayer-timestamp: 1745948123
x-storylayer-signature: <hex>
{
"id": "evt_2T...",
"event": "story.published",
"created_at": "2026-04-30T...",
"data": {
"story": {
"id": "...",
"title": "Snow report — 14 inches",
"published_at": "2026-04-30T...",
"published_channels": ["instagram","facebook"]
}
}
}Signing
Each request carries an x-storylayer-signature header — a hex-encoded HMAC-SHA256 of the raw request body, keyed by the signing_secret returned when you create the endpoint. Always verify in constant time.
import crypto from "node:crypto";
const sig = req.headers["x-storylayer-signature"];
const expected = crypto
.createHmac("sha256", SIGNING_SECRET) // whsec_...
.update(rawBody) // raw bytes, NOT JSON.stringify(parsed)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(sig, "hex"),
Buffer.from(expected, "hex"),
);
if (!ok) return res.status(401).end();The signing_secret is shown once when you create an endpoint via POST /api/v1/webhooks. Store it securely; we never return it again. Use the dashboard or PATCH /api/v1/webhooks/:id to rotate it.
Retries
If your endpoint returns anything other than a 2xx within 10 seconds, we retry on this schedule:
- 1 minute
- 5 minutes
- 15 minutes
- 1 hour
- 4 hours
- 12 hours
After the sixth failure the delivery is marked permanent_failure and the endpoint's failure_count increments.
Health, alerts, and auto-disable
Each endpoint carries a health_status that reflects how the receiver is doing:
healthy— most recent delivery succeeded.unhealthy— 3 consecutive deliveries failed; an in-app alert is raised.auto_disabled— 10 consecutive deliveries failed; the endpoint is deactivated to avoid drowning your queue. You re-enable it once the receiver is back via the dashboard orPOST /api/v1/webhooks/:id/reactivate.
The first successful delivery after a failure run resets the counter and clears the unhealthy state.
Replay
If your receiver was down for a window, you don't need to ask us to resend events — you can replay them yourself.
# single replay
curl -X POST https://app.storylayer.ai/api/v1/webhooks/wh_.../deliveries/dlv_.../replay \
-H "Authorization: Bearer sl_pat_..."
# bulk replay (failed deliveries in a window)
curl -X POST https://app.storylayer.ai/api/v1/webhooks/wh_.../deliveries/replay \
-H "Authorization: Bearer sl_pat_..." \
-H "content-type: application/json" \
-d '{
"since": "2026-04-30T14:00:00Z",
"until": "2026-04-30T16:00:00Z",
"status": ["failed","permanent_failure"]
}'Replays are inserted as new deliveries with replay_of set to the original delivery id, so audit history is preserved.
Managing endpoints
From the dashboard at /dashboard/developers you can create endpoints, send a signed test ping, and inspect every delivery — including filtering by status and replaying individual or bulk deliveries with one click.
Or via the API:
# create
curl -X POST https://app.storylayer.ai/api/v1/webhooks \
-H "Authorization: Bearer sl_pat_..." \
-H "content-type: application/json" \
-d '{
"url": "https://hooks.acme.example/storylayer",
"events": ["story.published","story.failed","moment.detected"],
"description": "Production fan-out"
}'
# test ping
curl -X POST https://app.storylayer.ai/api/v1/webhooks/wh_.../test \
-H "Authorization: Bearer sl_pat_..."
# delivery history (filter with ?status=failed,permanent_failure)
curl https://app.storylayer.ai/api/v1/webhooks/wh_.../deliveries \
-H "Authorization: Bearer sl_pat_..."
# alerts feed (in-app health events for your endpoints)
curl 'https://app.storylayer.ai/api/v1/webhooks/alerts?unread_only=true' \
-H "Authorization: Bearer sl_pat_..."
# reactivate after auto-disable
curl -X POST https://app.storylayer.ai/api/v1/webhooks/wh_.../reactivate \
-H "Authorization: Bearer sl_pat_..."Best practices
- Respond with
200as soon as you've persisted the payload — do work asynchronously. - Verify the signature in constant time (
timingSafeEqual), never===. - De-duplicate using the
x-storylayer-event-idheader — retries and replays reuse the same id. - Allow at least one full minute of clock skew when checking
x-storylayer-timestamp(rare, but helpful). - Subscribe to
webhooks/alertsin your monitoring dashboard so a quietly-broken endpoint surfaces fast.