Webhooks

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.

EventWhen
story.scheduledA story has a confirmed publish time.
story.publishedA story shipped successfully on at least one channel.
story.published_lateA 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.failedA 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.skippedThe 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.detectedA detector fired against your data.
moment.auto_draftedA high-severity moment turned into a draft story.
connection.expiringProactive 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_soonStronger 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.refreshedThe daily refresh cron successfully extended a token's expiry. Useful for ops dashboards that want to confirm the auto-refresh loop is running.
connection.brokenA token has already expired and the auto-refresh attempt failed. Action required from the user. Fires immediately (not debounced).
connection.reconnectedThe 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_belowA 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_detectedA 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.

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 caseSubscribe toWhy
Editorial Slackstory.published · story.failed · moment.detected · moment.auto_draftedThe 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-callconnection.expiring · connection.expiring_soon · connection.broken · connection.reconnectedThe 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 / analyticspillar.benchmark_below · pillar.gap_detected · story.publishedDrives 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 / dashboardsstory.scheduled · story.published · story.failed · connection.brokenAnything 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 or POST /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 200 as 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-id header — 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/alerts in your monitoring dashboard so a quietly-broken endpoint surfaces fast.