Reference

REST API

Bearer-auth, JSON in / JSON out, scope-locked tokens. Project-scoped tokens are isolated to one project; account-scoped tokens see everything you own.

Conventions

  • Base URL: https://app.storylayer.ai/api/v1
  • Auth header: Authorization: Bearer sl_pat_… or sl_oat_…
  • Request bodies are JSON with content-type: application/json (multipart only for media uploads).
  • Errors: { error: { code, message } } with the appropriate HTTP status.
  • Successful responses include X-RateLimit-* headers — see Rate limits.

Health

A no-scope ping that returns the token's principal. Use this to confirm a token works before driving any mutating endpoint.

MethodPathScopeDescription
GET/api/v1/health(none)Returns user_id, project_id, scopes, server_time.

Projects

MethodPathScopeDescription
GET/api/v1/projectsprojects:readList projects you own (or the single project the token is bound to).
GET/api/v1/projects/:idprojects:readFetch a single project.

Templates

MethodPathScopeDescription
GET/api/v1/templatestemplates:readList Storylayer design templates. Optional `?content_type=`.

Stories

MethodPathScopeDescription
GET/api/v1/storiesstories:readList stories. Filters: `?status=`, `?channel=`, `?limit=`.
POST/api/v1/storiesstories:writeCreate a draft story (or scheduled, if `scheduledAt` / `scheduled_at_local` + `timezone` is provided).
POST/api/v1/stories/bulkstories:writeCreate up to 50 stories in one call. Per-item failures don't block the batch.
GET/api/v1/stories/:idstories:readFetch a single story with all variants and metadata.
GET/api/v1/stories/:id/previewstories:readPer-channel resolved preview — exactly what would post to each channel.
POST/api/v1/stories/:id/schedulestories:publishSet or update the publish time. Bridges into the publish queue.
POST/api/v1/stories/:id/publishstories:publishPublish a story to its bound channels (now or at a future time).

Create — single post

POST /api/v1/stories
Authorization: Bearer sl_pat_...
content-type: application/json

{
  "projectId": "PROJECT_ID",
  "title": "Snow report — 14 inches overnight",
  "caption": "Winter Park dropped 14\" of fresh snow last night...",
  "channels": ["instagram", "facebook"],
  "hashtags": "#winterpark #snowreport",
  "media_url": "https://example.com/cover.jpg",
  "scheduled_at_local": "2026-05-01T07:00:00",
  "timezone": "America/New_York"
}

Returns 201 with { story, enqueue, links }. links.dashboard_url opens the story in the Storylayer dashboard; links.preview_url returns the resolved per-channel content. Drafts and scheduled stories are both bridged into content_queue (drafts use status="draft" so the publishing cron skips them but the dashboard still lists them; scheduled stories use status="approved" so the cron consumes them).

Per-channel cardinality validation respects channel_overrides: a 7-slide story-level carousel ships fine when channel_overrides.x.slides trims X to 4. The validator only fails when neither the story-level slides nor the channel override fits a channel's hard limit.

Create — carousel (2–10 slides)

POST /api/v1/stories
{
  "projectId": "PROJECT_ID",
  "caption": "Winter Park's deepest week of the year — by the numbers.",
  "channels": ["instagram"],
  "post_type": "carousel",
  "slides": [
    { "media_url": "https://cdn.example.com/slide-1.png", "alt_text": "14 inches overnight",  "swipe_through_url": "https://winterpark.com/snow" },
    { "media_url": "https://cdn.example.com/slide-2.png", "alt_text": "+4 days of snow ahead" },
    { "media_url": "https://cdn.example.com/slide-3.png", "alt_text": "Lift status" }
  ],
  "pin_to_grid": { "pin": true, "until": "2026-06-01T00:00:00Z" },
  "comment_ask": "Tell us your home mountain in the comments.",
  "comment_ask_mode": "append",
  "destination_url": "https://winterpark.com/snow-report",
  "scheduled_at_local": "2026-05-01T19:00:00",
  "timezone": "America/New_York"
}

Carousel cardinality limits per channel: Instagram + Facebook 2–10, X up to 4. Pass media_urls: string[] instead of slides[] for the simple form (no per-slide alt text).

Each slide accepts media_url, alt_text, caption_overlay, and swipe_through_url (per-slide click-out URL — tracked so you can attribute taps per slide).

Pin-to-grid (Instagram)

pin_to_grid accepts both shapes:

"pin_to_grid": true                                              // pin indefinitely
"pin_to_grid": { "pin": true, "until": "2026-05-30T00:00:00Z" }  // bounded pin
"pin_until_at": "2026-05-30T00:00:00Z"                           // sugar form alongside pin_to_grid: true

Comment ask (auto-append vs. metadata)

By default comment_ask is auto-appended to the caption tail at publish time (idempotent — won't double-print if the ask is already in the caption). Set comment_ask_mode: "metadata" to keep the ask analytics-only and write it into primary_caption yourself.

Destination URL

destination_url is per-post link-in-bio / outbound destination metadata. Storylayer doesn't update your Instagram bio link, but recording the intended destination per post lets brands attribute traffic per post over time. Stored on both the story row and the queued content_queue entry.

Create — per-channel overrides

Cross-post once and let each channel diverge on caption, hashtags, media, or post type.

POST /api/v1/stories
{
  "projectId": "PROJECT_ID",
  "caption": "Default text for the rest of the channels.",
  "channels": ["instagram", "x", "facebook"],
  "post_type": "carousel",
  "slides": [
    { "media_url": "https://cdn.example.com/slide-1.png" },
    { "media_url": "https://cdn.example.com/slide-2.png" },
    { "media_url": "https://cdn.example.com/slide-3.png" }
  ],
  "channel_overrides": {
    "instagram": { "hashtags": "" },
    "x":         {
      "caption": "Three takeaways from today's relaunch:\n\nDoor 1: the manifesto.\n\nDoor 2: the library.\n\nDoor 3: the field guide.",
      "caption_format": "thread",
      "thread_separator": "\n\n"
    },
    "facebook":  { "caption": "Mirror to Facebook with full context.", "hashtags": "#afore #relaunch" }
  },
  "scheduled_at_local": "2026-05-01T19:00:00",
  "timezone": "America/New_York"
}

Each channel override may set: caption, hashtags, media_urls, slides, post_type, pin_to_grid, pin_until_at, alt_text. For X you can also set caption_format: "thread" and thread_separator (defaults to \\n\\n) — Storylayer auto-splits the caption on the separator and posts a chained reply thread. Anything missing falls back to the story-level field.

Bulk create — load a calendar

POST /api/v1/stories/bulk
{
  "projectId": "PROJECT_ID",
  "stories": [
    { "caption": "Day 1 — manifesto", "scheduled_at_local": "2026-05-01T19:00:00", "timezone": "America/New_York", "slides": [...] },
    { "caption": "Day 2 — two doors",  "scheduled_at_local": "2026-05-02T19:00:00", "timezone": "America/New_York", "slides": [...] },
    { "caption": "Day 3 — library",   "scheduled_at_local": "2026-05-03T19:00:00", "timezone": "America/New_York", "slides": [...] }
  ]
}

→ 201 (or 207 if any item failed)
{
  "ok": true,
  "count": 3,
  "total": 3,
  "created": [
    { "index": 0, "story": {...}, "enqueue": {...} },
    ...
  ],
  "errors": []
}

Up to 50 stories per call. Per-item failures don't block the rest of the batch — you'll get back both created[] and errors[] arrays with the original input indices.

Schedule + publish

POST /api/v1/stories/:id/schedule
{ "scheduled_at_local": "2026-05-01T19:00:00", "timezone": "America/New_York" }
# or
{ "scheduledAt": "2026-05-01T23:00:00Z" }

POST /api/v1/stories/:id/publish
(empty body — schedules now + 60s)

Both endpoints bridge the story into content_queue so the publish cron picks it up. The response includes an enqueue object summarising which channels were enqueued.

Media

MethodPathScopeDescription
GET/api/v1/mediamedia:readList brand assets for a project.
POST/api/v1/mediamedia:writeRegister a remote URL, upload base64 JSON, or multipart file.
POST/api/v1/media/upload-urlmedia:writeMint a 15-minute presigned PUT URL — bytes stream straight to storage with no Authorization header (token is in the URL). Use for context-bounded agents.
PUT/api/v1/media/uploads/:token(token IS auth)Public PUT endpoint for a presigned upload. Body is raw bytes; Content-Type sets the mime.
GET/api/v1/media/uploads/:token(public)Inspect an upload intent by token. Recovers file_url after a successful PUT if the response was lost.
GET/api/v1/media/upload-url/:idmedia:readInspect an upload intent by id.
DELETE/api/v1/media/upload-url/:idmedia:writeAbort an unused intent before its TTL.
POST/api/v1/media/upload-sessionsmedia:writeOpen a chunked upload session (per-call cap, generous total budget).
POST/api/v1/media/upload-sessions/:id/chunksmedia:writeAppend a base64 chunk. Recommended 48 KB binary per call.
POST/api/v1/media/upload-sessions/:id/finalizemedia:writeConcatenate, upload to storage, return file_url.
GET/api/v1/media/upload-sessions/:idmedia:readInspect a session's progress.
DELETE/api/v1/media/upload-sessions/:idmedia:writeAbort a session and drop its chunks.

Three input shapes for POST /api/v1/media, all returning a hosted public URL you can pass into create_story.

1. Register a remote URL (no re-host)

POST /api/v1/media
content-type: application/json

{
  "projectId": "PROJECT_ID",
  "name": "winter-cover",
  "source_url": "https://images.unsplash.com/...",
  "category": "photo",
  "tags": ["winter", "powder"]
}

2. Upload binary bytes (base64 JSON)

The path AI agents should use when the user hands them slides as attachments — no separate hosting step required.

POST /api/v1/media
content-type: application/json

{
  "projectId": "PROJECT_ID",
  "filename": "slide-1.png",
  "mime_type": "image/png",
  "data_base64": "iVBORw0KGgoAAAANSUhEUgAA..."
}

→ 201
{
  "asset": { "id": "...", "file_url": "https://.../slide-1.png", ... },
  "file_url": "https://.../slide-1.png"
}

3. multipart/form-data

POST /api/v1/media
content-type: multipart/form-data

file=@cover.jpg
projectId=PROJECT_ID
category=photo
name=winter-cover

Max 10 MB (decoded bytes). Allowed mime types: JPEG, PNG, GIF, WebP, SVG, MP4.

4. Presigned PUT (server-side fetch — for context-bounded agents)

The complete fix for agents that pay context tokens for every byte they read (Cowork, ChatGPT MCP, etc.). Bytes never enter the agent's conversation; the agent passes the URL to a shell tool (curl) and reads back ~200 bytes of JSON. Per-slide context cost is constant regardless of file size.

# 1. Mint a presigned URL (auth required)
POST /api/v1/media/upload-url
{ "projectId": "...", "filename": "slide-1.png", "mime_type": "image/png" }

→ 201
{
  "upload_url": "https://app.storylayer.ai/api/v1/media/uploads/{token}",
  "asset_intent_id": "...",
  "expires_at": "...",        # 15 minutes
  "max_bytes": 10485760
}

# 2. PUT the bytes directly. NO Authorization header — the token in
#    the URL replaces it. The body is the raw asset.
curl -X PUT "$UPLOAD_URL" \
     -H "Content-Type: image/png" \
     --data-binary @slide-1.png

→ 201
{
  "ok": true,
  "file_url": "https://.../slide-1.png",
  "asset": { "id": "...", "file_url": "...", "file_size": 487213, ... },
  "bytes": 487213
}

# 3. Pass file_url into create_story.slides[].media_url

Idempotent on retry: replaying a successful PUT returns the same asset; the signed token is one-shot once consumed. To recover a lost PUT response, GET /api/v1/media/uploads/{token} (no auth) returns the intent state including file_url if uploaded.

To invalidate an unused intent before its TTL: DELETE /api/v1/media/upload-url/{asset_intent_id} with your normal Bearer auth.

5. Chunked upload (per-call cap, generous total budget)

Some MCP hosts cap each individual tool-call payload at ~25K tokens (~80 KB base64) but allow generous total conversation context. The chunked flow splits the binary into small base64 pieces that each fit under the per-call cap. Note: every byte still passes through the agent's context, so for context-bounded agents (Cowork-style billing), use Path 4 instead.

# 1. Open a session
POST /api/v1/media/upload-sessions
{ "projectId": "...", "filename": "slide-1.png", "mime_type": "image/png", "total_size_bytes": 487213 }

→ 201
{
  "session_id": "...",
  "expires_at": "2026-04-30T13:00:00Z",
  "recommended_chunk_bytes": 49152,
  "max_chunk_bytes": 262144,
  "max_total_bytes": 10485760
}

# 2. Append chunks (repeat — chunk_index is 0-based)
POST /api/v1/media/upload-sessions/{session_id}/chunks
{ "chunk_index": 0, "data_base64": "..." }   # ~48 KB binary per call

→ 200
{ "chunks_received": 1, "bytes_received": 49152, "expires_at": "..." }

# 3. Finalize → returns the hosted URL ready for slides[]
POST /api/v1/media/upload-sessions/{session_id}/finalize

→ 201
{
  "asset": { "id": "...", "file_url": "https://...png", "file_size": 487213, ... },
  "file_url": "https://...png",
  "bytes": 487213
}

Per-chunk cap: 256 KB binary (server-enforced max). Recommended for context-tight agents: 48 KB binary (~64 KB base64 ≈ 16 K tokens). Sessions expire 1 hour after creation. Replaying the same chunk_index is idempotent (overwrites the previous chunk). To abort early: DELETE /api/v1/media/upload-sessions/{id}.

Analytics & engagement reporting

Engagement metrics (total_impressions, total_reach, total_clicks, total_saves, total_engagement, best_performing_channel) populate after publish on a fixed schedule — they will be zero/null immediately after a story flips to published and that's expected.

Poll cadence

SyncFiresWhat it pulls
initialWithin ~15 min of publishFirst impressions / reach / saves snapshot. Most numbers are still warming up; expect them to climb on each subsequent run.
backfill~1 hour after publishCatches anything missed by the first run (IG sometimes takes minutes to attach insights to a freshly published media id).
24h+24 hours after publishThe first "trustworthy" snapshot. analytics_sync_24h flips to true; this is the row consumed by recommendations / scoring.
7d+7 days after publishFinal post-mortem. Feeds story memory / pattern detection. Beyond 7 days metrics are not refreshed automatically.

The cron driving these runs every 15 minutes (*/15 * * * *), so initial / backfill latency is bounded by that tick — worst case ~14 min. The analytics_synced_at timestamp on each story attribution row tells you exactly when each platform was last refreshed.

Instagram saved count

Storylayer pulls saved on every IG sync and exposes it as total_saves at the story level (sum across channels) and as saves on each row of GET /api/analytics/story-attribution?story_id={id} (per-channel). For brands whose load-bearing engagement KPI is saves rather than likes (editorial / luxury), this is the primary number to chart.

Every story object now includes:

  • published_url — primary channel's permalink (Instagram permalink, Facebook permalink_url, X status URL, or Ghost post URL). Populated on first successful publish.
  • published_urls — per-channel map: { instagram: "https://www.instagram.com/p/...", facebook: "...", x: "..." }. Populated as each channel publishes.
  • published_channels — string array of channels that have completed publish so far.

For multi-channel stories, story status follows the queue rows: all channels posted → published; some posted, some failed → partial; all failed → failed. The publisher cron also surfaces a skip_reason on the underlying queue row when a channel can't ship (missing_connection:facebook, oauth_revoked:instagram, stale-scheduled — no successful publish within 1h of scheduled_at).

Per-slide analytics for carousels

Per-slide insight sources, in order of fidelity:

  1. Storylayer click tracking — minted automatically when you supply slides[].swipe_through_url or a story-level destination_url. Returns per-slide click counts and CTR via GET /api/analytics/story-attribution?story_id={id} in the slide_attribution array. Works for image carousels (which Instagram does not expose insights for).
  2. Instagram per-child insights — only available for video / reel carousel children. When present, surfaced as data_snapshot.carousel_children on the post_analytics row with per-child views / reach / saved. Image carousel children return no insights from the IG API; use Storylayer click tracking for those.

Mint links retroactively for an existing story with the MCP tool mint_story_links, or call GET /api/analytics/story-attribution?story_id=… to see what's already minted.

Unified per-slide endpoint

GET /api/stories/{id}/slide-insights stitches all of the above into one response designed to answer "which slide is doing the work?". Each slide returns:

{
  "story_id": "...",
  "format": "carousel",
  "channels": ["instagram"],
  "has_ig_per_child": true,
  "total_clicks": 184,
  "slides": [
    {
      "slide_index": 0,
      "media_url": "https://...png",
      "media_type": "IMAGE",
      "alt_text": null,
      "swipe_through_url": "https://...",
      "short_url": "https://app.storylayer.ai/r/abc123",
      "ig_views": null,        // image — no IG insights
      "ig_reach": null,
      "ig_saved": null,
      "clicks": 92,
      "click_share": 50.0,     // % of total tracked clicks
      "performance_score": 0.74
    },
    ...
  ],
  "best_slide": {
    "slide_index": 0,
    "by": "saves" | "reach" | "clicks" | null,
    "rationale": "Slide 1 earned 47 saves — the highest in the carousel."
  },
  "notes": [
    "IG only exposes per-child insights for video/reel children..."
  ]
}

Same shape is exposed via the MCP tool get_slide_insights so an agent can answer the question in conversation. The performance_score is a weighted soft signal (saves 0.5, reach 0.2, clicks 0.3) — not a metric, just a UI hint for ranking.

Superseded queue rows

The publisher cron sweeps anything still approved with scheduled_at more than 1 hour in the past, marking it failed with an explicit skip_reason. When that sweep finds a sibling row for the same project + channel that successfully posted within ±10 minutes of the stuck row's scheduled_at, the skip_reason becomes superseded — queue item <id> took the same slot on <channel> and the data_snapshot.superseded_by_queue_item_id field carries the winning sibling's id. The story.failed webhook payload also carries superseded_by_queue_item_id so external listeners can tell "took over by another post" apart from "publisher couldn't ship".

Moments

MethodPathScopeDescription
GET/api/v1/momentsmoments:readList detected moments. Filters: `?status=`, `?severity=`, `?limit=`.

Each moment carries severity (low / medium / high / exceptional), a human headline, AI reasoning, the source data snapshot, and a status the user can act on (new, acted_on, dismissed).

Social connections

MethodPathScopeDescription
GET/api/v1/social-connectionsconnections:readList social channels connected to a project (no secrets).

Read-only. OAuth tokens for Instagram / Facebook / X / Ghost are never returned through the API — only the channel metadata (handle, status, last refresh).

Pre-flight connection check at create time

POST /api/v1/stories, POST /api/v1/stories/bulk, POST /api/v1/stories/{id}/schedule, and POST /api/v1/stories/{id}/publish all run a connection pre-flight before enqueueing. If any requested channel has no social_connections row (or has one with token_expires_at in the past), the request fails with HTTP 400 and code missing_connection:

HTTP/1.1 400 Bad Request
{
  "error": {
    "code": "missing_connection",
    "message": "No connection for: facebook. Connect it in Settings → Social connections, or remove the channel from this story.",
    "preflight": {
      "ok": false,
      "available": ["instagram"],
      "missing":   ["facebook"],
      "expired":   [],
      "reasons":   { "instagram": "ok", "facebook": "missing_connection" }
    }
  }
}

This catches the common footgun where an agent schedules to a channel the user never connected — previously the publisher cron would mark the row failed an hour later with skip_reason=missing_connection:<ch>; now the agent gets the error on its first turn and can prompt the user to connect.

The same shape is used by MCP create_story and create_stories_bulk: any item with a missing connection comes back in the errors[] array with code: "missing_connection" and a preflight object instead of being silently scheduled.

Opt-out: add "connection_check": "skip" at the top of the body to bypass the check entirely (you may genuinely want to schedule before OAuth is done). "connection_check": "warn" still runs the check but allows the request through; the preflight object is returned in warnings[] instead of errors[]. Drafts always bypass — saving a draft never requires a live connection.

Brand-voice lint at create time

Editorial brands maintain hard voice rules (no "hidden gem", no "ultimate", no emojis, no exclamation points). Configure them per-project in Settings → Voice rules and Storylayer will lint every caption and per-channel content before it lands in the queue. The same surfaces that run the connection pre-flight (POST /api/v1/stories, /bulk, /{id}/schedule, /{id}/publish, MCP create_story, MCP create_stories_bulk) all run this check.

Rules are stored in brand_voice_rules and cover:

  • forbidden_words — single tokens (case-insensitive, word-boundary). "epic" blocks "EPIC" but not "epicenter".
  • forbidden_phrases — multi-word substrings (case-insensitive). "hidden gem" blocks "Hidden Gem" and "the hidden gem of...".
  • block_emojis — reject if any Unicode emoji is present.
  • max_exclamation_count — integer. 0 means none allowed; null disables the check.
  • block_all_caps_words — reject ALLCAPS words ≥ 4 letters (so "AMAZING" trips it but "NYC" / "USA" don't).

Violations come back as HTTP 400 with code voice_violation and a structured lint object so clients can highlight offending substrings inline:

HTTP/1.1 400 Bad Request
{
  "error": {
    "code": "voice_violation",
    "message": "Voice rules violated — forbidden word: epic; emojis blocked: ✨.",
    "lint": {
      "ok": false,
      "rules_present": true,
      "violations": [
        { "type": "word",  "value": "epic", "position": 21, "channel": "default" },
        { "type": "emoji", "value": "✨",   "position": 35, "channel": "instagram" }
      ]
    }
  }
}

Opt-out: top-level "lint_check": "skip" bypasses entirely; "warn" lints + logs but still allows the create. "enforce" (default) blocks on any violation. Drafts are still linted by default — authors should know about voice issues even when staging — pass "lint_check": "skip" on the draft create if you're staging non-final copy intentionally.

Per-channel: the lint runs against the primary caption AND every channel_overrides[ch].caption separately, so a clean primary caption with a forbidden word on the X override still gets flagged. Each violation includes the channel field so clients know which surface tripped.

No-op when unset: projects with no brand_voice_rules row see this entire pre-flight as a no-op — existing projects aren't affected until they opt in via Settings → Voice rules.

Endpoints: GET /api/projects/{id}/voice-rules fetches the current rules; PUT upserts; DELETE clears (returning to no-op). POST /api/projects/{id}/voice-rules/lint takes a { caption, channel_content? } body and returns a VoiceLintResult without modifying any stories — used by the Settings page's "Test caption" panel.

Content pillars + benchmarks

Every project can configure a list of content pillars — the buckets your editorial calendar should rotate through. Each pillar maps to a content_type string already stored on every story, and carries:

  • allocation_percent — target share of voice (e.g. Afore's 30/25/15/15/15).
  • benchmark_metric + benchmark_value — minimum rolling metric the pillar should hit (save_rate, share_rate, click_rate, engagement_rate, or a raw count like impressions).
  • benchmark_window_days — rolling window length (default 30).
  • max_gap_days — alert if no story of this content_type fires for N days.
  • color — calendar accent color.

The pillars surface drives two flows:

  1. Calendar gap detection. The /dashboard/calendar page renders a banner showing the current pillar mix, flags any pillar whose days_since_last_post > max_gap_days, and color-codes individual chips so the rotation is glanceable.
  2. Daily benchmark + gap alerts. A cron at 08:00 UTC (/api/cron/pillar-thresholds) computes the rolling-window report for every project and fires pillar.benchmark_below (debounced once per 7 days) and pillar.gap_detected (debounced once per 24h) webhooks whenever a pillar slips.

Endpoints:

  • GET /api/projects/{id}/pillars — list pillars. Append ?include=performance to embed the rolling report.
  • PUT /api/projects/{id}/pillars — bulk replace (pillars not in body are deleted).
  • PATCH /api/projects/{id}/pillars — bulk upsert (pillars not in body are kept).
  • GET /api/analytics/pillar-performance?project_id={id}&window=30 — rolling performance report. Returns pillars[], in_gap_pillars[], below_benchmark_pillars[], plus aggregate metrics per pillar.

Rate metrics (save_rate, share_rate, click_rate) are computed as sum(saves|shares|link_clicks) / sum(impressions) from the project's post_analytics rows in the window. Stories whose impressions are 0/null are still counted toward share-of-voice but skipped for rate computation. Configure pillars under Settings → Pillars in the dashboard, or via the API.

Bulk reschedule

Multi-select reschedule for stories and content_queue items via a single endpoint:

POST /api/v1/queue/bulk-reschedule
Authorization: Bearer sl_pat_...
content-type: application/json

# Mode 1 — shift by a delta:
{ "story_ids": ["story_...","story_..."], "shift_days": 2 }

# Mode 2 — move to a fixed wall-clock time, keeping each item's date:
{ "story_ids": [...], "fixed_time": "19:15", "timezone": "America/New_York" }

# Mode 3 — move all to a single moment:
{ "story_ids": [...], "scheduled_at": "2026-05-08T23:15:00Z" }
  • Pass story_ids and/or queue_item_ids — story IDs are expanded into their content_queue children server-side so multi-channel rows stay in sync.
  • Stories already in posted, failed, or rejected are skipped and surface in the response's skipped array with a reason.
  • Each rescheduled story emits a story.scheduled webhook with via: "bulk_reschedule" for downstream calendars / Slack rooms.
  • Available in the dashboard at Queue → Bulk reschedule: toggle the mode, pick stories, then Shift by N days or Move to HH:MM.

Per-story UTM defaults

Every project carries a utm_template JSONB column on the projects table. When a story is archived, Storylayer resolves the template once against the story's (channel, content_type, pillar, template_name, date) context and persists the result on both the stories row and every linked content_queue item — so attribution doesn't depend on hand-entering UTMs each time.

Supported tokens:

  • {{channel}} — instagram, facebook, twitter, linkedin, ghost.
  • {{format}} — story, post, reel, carousel, blog (mapped from story format).
  • {{pillar}} — slugified project_pillars.display_name (e.g. named-room). Falls back to slugified content_type when no pillar config exists.
  • {{content_type}} — alias for {{pillar}}.
  • {{template_name}} — the Creatomate / blog template name used for the story.
  • {{automation_name}} — back-compat alias for the wizard / automation that produced the story.
  • {{date}} · {{year}} · {{month}} · {{yearmonth}} — date components at archive time (UTC).
  • {{project_slug}} · {{project_name}} · {{story_id}} — for per-client and per-story isolation.

The default template is { source: "{{channel}}", medium: "{{format}}", campaign: "{{automation_name}}", content: "{{template_name}}-{{date}}" }; an editorial brand might override campaign to "{{pillar}}_{{date}}" to match Afore's strategy ("named-room_2026-04-30") so saved-rate tracking groups by pillar in GA4.

Override precedence:

  1. Per-story utm_campaign / utm_content / utm_term / destination_url passed to POST /api/v1/stories — always win. Pass them when you want one-off attribution.
  2. Per-queue-item values written before publish — used by re-scheduled or split-channel stories.
  3. Auto-resolved from the project's utm_template at story-archive time.
  4. Per-publisher fallback (e.g. Ghost / X) recomputes on the fly if all of the above are still null.

Endpoints:

  • POST /api/projects/{id}/utm-resolve — preview what the resolved values would be for a given channel + content_type without persisting.
  • Configure the template under Settings → Tracking, or write directly to projects.utm_template.

Unified IG + FB inbox

The dashboard's Inbox tab merges Instagram + Facebook comments and DMs into a single triage queue, replying through the project's existing connection tokens.

  • Webhook receiver: POST/GET /api/webhooks/meta-inbox. Configure in the Meta App dashboard with verify token METAINBOX_VERIFY_TOKEN and subscribe Pages → feed, messages; Instagram → comments, messages, mentions. When METAAPPSECRET is set we verify the x-hub-signature-256 HMAC on every delivery.
  • Storage: inbox_conversations + inbox_messages, idempotent on (project_id, kind, external_id).
  • Resolution: incoming Meta payloads carry the business account_id; we look that up against social_connections to attribute the event to the right project. Connections with mismatched account ids are ignored (status unknown_account).
  • GET /api/inbox/conversations?project_id={id}&status=open|resolved|snoozed|spam&platform=instagram|facebook&kind=comment|dm — list.
  • GET /api/inbox/conversations/{id} — single thread + full message history.
  • PATCH /api/inbox/conversations/{id} — update status / archived / tags; pass read: true to clear unread_count.
  • POST /api/inbox/conversations/{id}/reply — body { "body": "..." }. Routes to the right Graph endpoint per (platform, kind): IG comment → /{comment-id}/replies, IG DM → /{ig-user-id}/messages, FB comment → /{comment-id}/comments, Messenger DM → /me/messages. Outbound messages persist with direction='us' and send_status='sent' | 'failed' so the UI can surface delivery state.

Brand caption corpus + AI suggestions

Each project carries a caption_corpus table of operator-approved captions, embedded with OpenAI text-embedding-3-small for RAG-style retrieval. The AI suggester grounds the LLM in this corpus instead of generic web text, so suggestions match the brand's actual voice.

  • GET /api/projects/{id}/caption-corpus?approved=true|false&source=seed|published|manual&content_type=&limit= — list.
  • POST /api/projects/{id}/caption-corpus — single caption ({ caption, content_type?, channel?, source? }) or bulk ({ captions: [...] }). Embeddings are generated synchronously.
  • PATCH /api/caption-corpus/{id} — approve / unapprove / edit. Caption-text edits trigger a re-embed.
  • DELETE /api/caption-corpus/{id}
  • POST /api/ai/caption-suggest — body { project_id, brief, content_type?, channel?, format?, destination_url?, num_suggestions?, max_chars? }. Returns N suggestions with { caption, rationale, pillar_match, lint }. Forbidden-word violations are filtered out before responding.
  • Auto-snapshot: when story.published fires, the cron snapshots the published caption into the corpus with source='published' and approved=false — operators ratify in the dashboard before suggestions can pull from it.

Recurring templates

Schedule a story payload on cadence — daily, weekly (with weekday picker), monthly, or full crontab. Cron /api/cron/recurring-templates runs every 5 minutes and routes due rows through archiveStory(), so recurring fires inherit pillar coding, voice rules, and UTM defaults the same way wizard creates do. Title / caption templates support {{date}}, {{year}}, {{month}}, {{day}}, {{weekday}}, {{weekday_short}}, {{month_long}}, {{pillar}}, {{content_type}} tokens.

  • GET / POST /api/projects/{id}/recurring-templates
  • GET / PATCH / DELETE /api/recurring-templates/{id}
  • UI: Dashboard → Recurring.

Saved searches / smart lists

Insights surface (/dashboard/insights) lets operators build pillar-aware filters against stories + post_analytics and persist them as named "smart lists" on a per-user-per-project basis.

  • GET / POST /api/projects/{id}/saved-searches
  • GET / PATCH / DELETE /api/saved-searches/{id}
  • POST /api/insights/run — execute a saved search by id, an ad-hoc filter, or a saved search with override filters. Body filters override saved filters when both are present.
  • Filter shape: { window_days, channels, content_types, statuses, min_save_rate, min_share_rate, min_click_rate, min_engagement_rate, min_impressions, min_saves, min_shares, min_link_clicks, has_destination_url, sort_by, sort_order, limit }. Rate metrics require impressions > 0 to be meaningful (we exclude zero-impression stories from rate threshold checks).

Connection health

GET /api/connections/health?project_id={id} returns per-platform health for the dashboard. Each platform reports a status (ok, stale, token_expired, publish_error, sync_error, missing) and a human-friendly summary, plus the most-recent successful publish, the most-recent failed publish (with skip_reason / error_message), the most-recent successful analytics sync, the most-recent failed sync, a 24h failure counter, the auto-refresh audit (last_refresh_at, last_refresh_ok, last_refresh_error, refresh_failed_count), and a token_expiring_soon flag. The Storylayer dashboard's Settings → Social connections panel renders these badges next to each connected account.

The analytics sync worker also has a circuit breaker: if a (project, platform) hits ≥ 5 auth-class failures in 60 minutes, subsequent sync attempts short-circuit with circuit_breaker: N auth failures in last 60min. Reconnect <platform> to resume sync. instead of repeatedly calling the API with a dead token. The breaker auto-clears the moment the user reconnects (the breaker only counts failures that happened after social_connections.connected_at / last_refresh_at) — no manual intervention required.

Automatic token refresh

A daily cron at 03:00 UTC (/api/cron/refresh-tokens) extends every social_connection token whose token_expires_at falls within the next 7 days. Per-platform refresh paths:

PlatformAuth methodRefresh path
Instagraminstagram_logingraph.instagram.com/refresh_access_token?grant_type=ig_refresh_token — extends 60d token by another 60d.
Instagramfacebook_login (via Meta Business)graph.facebook.com/access_token?grant_type=fb_exchange_token — re-issues 60d long-lived token.
FacebookPage tokenSame FB exchange path.
LinkedInOAuth2linkedin.com/oauth/v2/accessToken?grant_type=refresh_token using stored refresh_token.
X (Twitter)OAuth1No refresh — OAuth1 tokens are non-expiring. The cron emits connection.expiring_soon only if token_expires_at was set, which OAuth1 connections don't.
GhostAdmin API keyNo refresh — keys are user-issued.

Outcomes are persisted on social_connections as last_refresh_at / last_refresh_ok / last_refresh_error / refresh_failed_count / notified_at_tier. The connection-health endpoint reflects them.

Webhook events the cron emits

Five webhook events cover the connection lifecycle. The first two are the proactive heads-ups; the rest fire on action or failure:

  • connection.expiring — proactive multi-tier heads-up. Fires once at D-30 (severity info), again at D-7 (warning), again at D-1 (critical), regardless of whether auto-refresh would succeed. Tracked via social_connections.notified_at_tier; resets on connection.refreshed or connection.reconnected so a freshly-refreshed token starts a clean cycle. Use this to drive your own dashboards, Slack pings, or ops tooling.
  • connection.refreshed — auto-refresh succeeded; new expires_at in the payload.
  • connection.expiring_soon — stronger signal than connection.expiring: token expires in less than 7d AND auto-refresh actually failed (or there's no refresh path). Use this to drive user-action prompts. Debounced to one fire per 24h per connection.
  • connection.broken — token has already expired AND auto-refresh failed. Fires immediately, not debounced.
  • connection.reconnected — user re-completed OAuth (or re-saved credentials manually). Pairs with broken / expiring_soon as the resolution event.

Connection-health surface

The /api/connections/health endpoint and the dashboard's Settings → Social connections panel show a token_expiring badge from D-30 onward, with the badge colour escalating (yellow at info / warning, red at critical). Each platform entry includes days_until_expiry and expiring_severity so client UIs can render their own escalation.

See /docs/webhooks for full payload shapes and signing.

Pinterest channel

Pinterest is a first-class channel alongside Instagram, Facebook, X, LinkedIn, and Ghost. Storylayer supports image pins, carousel pins (2–5 images), and video pins (mp4/mov/webm). Add "pinterest" to channels[] on any story and we'll create one pin per publish.

Connect a Pinterest account

The user-facing path is Settings → Accounts → Connect Pinterest. That triggers the Pinterest OAuth 2.0 flow (scopes boards:read, boards:write, pins:read, pins:write, user_accounts:read) and stores access token + refresh token on the project's social_connections row. The connection-refresh cron rotates both tokens automatically — Pinterest access tokens expire at 30d and refresh tokens at 60d, but rotating on every refresh keeps both windows fresh.

After connecting, the user picks a default board in the same panel. The chosen board id is stored on social_connections.metadata.default_board_id and is used for any story that doesn't supply a per-story override.

If the connected Pinterest account has zero boards, the dashboard board picker exposes a + New board control that calls POST /api/social-connections/pinterest/boards directly — users no longer need to bounce out to pinterest.com to create their first board.

Boards API

MethodPathScopeDescription
GET/api/social-connections/pinterest/boards?project_id=&refresh=1connections:readList the boards on the connected Pinterest account. Returns the cached list by default; pass refresh=1 to re-fetch from Pinterest.
PATCH/api/social-connections/pinterest/boardsconnections:writeSet the default board. Body: { project_id?, default_board_id, default_board_name? }.
POST/api/social-connections/pinterest/boardsconnections:writeCreate a new board on the connected Pinterest account. Body: { project_id?, name, description?, privacy?, set_as_default? }. Returns the new board and (if set_as_default) the updated default.

The cached list refreshes automatically when it's older than 24h, so the dashboard usually serves boards instantly without an extra Pinterest round-trip. Creating a board updates the cache in-place — no refresh round-trip needed.

Creating a Pinterest story

Pinterest pins always belong to a board. Resolution order:

  1. channel_overrides.pinterest.board_id — per-story override (recommended when the story has a specific topical home).
  2. social_connections.metadata.default_board_id — project default.

If neither is set, the publisher marks the queue row failed with a clear skip_reason at publish time. Use the MCP list_pinterest_boards tool to discover ids before scheduling.

Pinterest-specific fields you can set under channel_overrides.pinterest:

FieldTypeNotes
board_idstringRequired for the override path. Discoverable via list_pinterest_boards.
board_section_idstringOptional — pin to a sub-section of the board.
titlestringPinterest pin title. Searchable on Pinterest. Cap: 100 chars.
descriptionstringPin description. Falls back to caption. Cap: 800 chars.
linkstringOutbound destination URL the pin opens. Cap: 2048 chars. Auto-wrapped with UTM params when the story has a utm_campaign.
alt_textstringAccessibility alt text. Cap: 500 chars.
cover_image_urlstringRequired for video pins. The still image users see before they hit play. Pinterest rejects video pins without one. Ignored for image / carousel pins.

Example creating a Pinterest-only story for the project's default board:

POST /api/v1/stories
{
  "channels": ["pinterest"],
  "caption": "How we tile the marble surround in Cabinet 4 — patient, by hand, against the grain. ",
  "media_url": "https://cdn.example.com/le-journal/cabinet-4-marble.jpg",
  "destination_url": "https://aforedesign.com/journal/cabinet-4",
  "utm_campaign": "named-room_2026-05-03",
  "channel_overrides": {
    "pinterest": {
      "title": "Patient marble work — Cabinet 4 at Afore",
      "description": "Tiling the surround by hand, against the grain. Le Journal: Cabinet 4.",
      "link": "https://aforedesign.com/journal/cabinet-4"
    }
  }
}

Set post_type: "carousel" with 2–5 slides. Pinterest's carousel cap is 5 (vs IG's 10) — if you supply more we'll silently slice to the first 5. The validator rejects fewer than 2.

POST /api/v1/stories
{
  "channels": ["pinterest"],
  "post_type": "carousel",
  "caption": "Three faces of the Editorial Stand at Afore.",
  "slides": [
    { "media_url": "https://cdn.example.com/stand-front.jpg", "alt_text": "Front view" },
    { "media_url": "https://cdn.example.com/stand-side.jpg",  "alt_text": "Side view"  },
    { "media_url": "https://cdn.example.com/stand-back.jpg",  "alt_text": "Back view"  }
  ],
  "channel_overrides": {
    "pinterest": {
      "title": "Editorial Stand: three angles",
      "description": "Marble, brass, end-grain ash."
    }
  }
}

Pinterest carousels render as a single swipeable pin and use the first slide as the cover. The publishing flow is one API call (no container/build step like IG).

Video pins

Pass a video URL (mp4/mov/webm/m4v) as media_url and Storylayer routes the story through Pinterest's asynchronous video upload pipeline:

  1. Register an upload intent — Pinterest returns a media_id + presigned S3 URL.
  2. Stream the video to S3 (no Storylayer disk usage).
  3. Poll GET /v5/media/{media_id} for up to 90s until status === "succeeded".
  4. Create the pin with source_type: "video_id".

If processing exceeds the 90s window, the queue row stays scheduled and the next cron tick retries from step 1. Hard processing failures (Pinterest returns status: "failed") flip the row to failed with the upstream error.

Video pins require a cover image. Set it via channel_overrides.pinterest.cover_image_url:

POST /api/v1/stories
{
  "channels": ["pinterest"],
  "caption": "Inside the Cabinet 4 install — 30s b-roll.",
  "media_url": "https://cdn.example.com/cabinet-4-broll.mp4",
  "channel_overrides": {
    "pinterest": {
      "title": "Cabinet 4 install reel",
      "cover_image_url": "https://cdn.example.com/cabinet-4-poster.jpg",
      "link": "https://aforedesign.com/journal/cabinet-4"
    }
  }
}

Pinterest analytics

The analytics-sync cron polls Pinterest 1m / 24h / 7d after publish and aggregates over a rolling 28-day window (Pinterest pins compound over time, unlike IG/FB which cluster engagement in the first 24h — the 28d window reflects cumulative impact). Metrics map onto post_analytics as:

Pinterest metricpost_analytics columnNotes
IMPRESSIONimpressionsTotal times the pin was shown.
SAVEsavesTimes users saved the pin to one of their own boards. Counts as the load-bearing engagement KPI for editorial brands and rolls up into stories.total_saves.
OUTBOUND_CLICKlink_clicksTimes users followed the destination link. The traffic-driving metric.
PIN_CLICKdata_snapshot.pin_clicksPinterest-specific. Stored on the data_snapshot for the dashboard breakdown.

Limits and gotchas

  • Carousel cap is 5. IG carousels accept 10 — Pinterest tops out at 5. If you supply more we slice; if you supply fewer than 2 with post_type: "carousel" the validator rejects the request.
  • 2:3 vertical performs best. Pinterest will accept other aspect ratios and just crop, but the algorithm rewards 1000×1500. The dashboard surfaces a soft hint when the primary image looks horizontal.
  • Public media URL required. Pinterest fetches the image (or video, for video pins) from its URL — Storylayer media on Supabase storage works without extra configuration.
  • Video pins need a cover image. Pinterest treats the cover as a hard requirement. Set channel_overrides.pinterest.cover_image_url when the primary media is a video.
  • Video processing is async. The poster waits up to 90s; longer-processing videos retry on the next cron tick rather than failing terminally.
  • Boards must exist OR be created via the API. The dashboard's + New board button (and POST /api/social-connections/pinterest/boards) handles that for users who connect with zero boards.

Webhooks

Manage webhook endpoints over the API. The full event reference, signing scheme, and retry behaviour live on the Webhooks page.

MethodPathScopeDescription
GET/api/v1/webhookswebhooks:readList webhook endpoints.
POST/api/v1/webhookswebhooks:writeCreate a webhook endpoint. Returns `signing_secret` ONCE.
GET/api/v1/webhooks/:idwebhooks:readFetch a single endpoint (without the secret).
PATCH/api/v1/webhooks/:idwebhooks:writeUpdate events, URL, description, or active flag.
DELETE/api/v1/webhooks/:idwebhooks:writeDelete an endpoint.
POST/api/v1/webhooks/:id/testwebhooks:writeSend a signed `test.ping` to the endpoint.
GET/api/v1/webhooks/:id/deliverieswebhooks:readList recent delivery attempts (status, response, retries).

Create

POST /api/v1/webhooks
{
  "url": "https://hooks.acme.example/storylayer",
  "events": ["story.published", "story.failed", "moment.detected"],
  "description": "Production fan-out",
  "projectId": null
}

→ 201
{
  "endpoint": { "id": "wh_…", "url": "...", "events": [...], "active": true },
  "signing_secret": "whsec_..."  // shown ONCE — store this now
}