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_…orsl_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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/health | (none) | Returns user_id, project_id, scopes, server_time. |
Projects
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/projects | projects:read | List projects you own (or the single project the token is bound to). |
| GET | /api/v1/projects/:id | projects:read | Fetch a single project. |
Templates
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/templates | templates:read | List Storylayer design templates. Optional `?content_type=`. |
Stories
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/stories | stories:read | List stories. Filters: `?status=`, `?channel=`, `?limit=`. |
| POST | /api/v1/stories | stories:write | Create a draft story (or scheduled, if `scheduledAt` / `scheduled_at_local` + `timezone` is provided). |
| POST | /api/v1/stories/bulk | stories:write | Create up to 50 stories in one call. Per-item failures don't block the batch. |
| GET | /api/v1/stories/:id | stories:read | Fetch a single story with all variants and metadata. |
| GET | /api/v1/stories/:id/preview | stories:read | Per-channel resolved preview — exactly what would post to each channel. |
| POST | /api/v1/stories/:id/schedule | stories:publish | Set or update the publish time. Bridges into the publish queue. |
| POST | /api/v1/stories/:id/publish | stories:publish | Publish 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: trueComment 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
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/media | media:read | List brand assets for a project. |
| POST | /api/v1/media | media:write | Register a remote URL, upload base64 JSON, or multipart file. |
| POST | /api/v1/media/upload-url | media:write | Mint 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/:id | media:read | Inspect an upload intent by id. |
| DELETE | /api/v1/media/upload-url/:id | media:write | Abort an unused intent before its TTL. |
| POST | /api/v1/media/upload-sessions | media:write | Open a chunked upload session (per-call cap, generous total budget). |
| POST | /api/v1/media/upload-sessions/:id/chunks | media:write | Append a base64 chunk. Recommended 48 KB binary per call. |
| POST | /api/v1/media/upload-sessions/:id/finalize | media:write | Concatenate, upload to storage, return file_url. |
| GET | /api/v1/media/upload-sessions/:id | media:read | Inspect a session's progress. |
| DELETE | /api/v1/media/upload-sessions/:id | media:write | Abort 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-coverMax 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_urlIdempotent 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
| Sync | Fires | What it pulls |
|---|---|---|
initial | Within ~15 min of publish | First impressions / reach / saves snapshot. Most numbers are still warming up; expect them to climb on each subsequent run. |
backfill | ~1 hour after publish | Catches anything missed by the first run (IG sometimes takes minutes to attach insights to a freshly published media id). |
24h | +24 hours after publish | The first "trustworthy" snapshot. analytics_sync_24h flips to true; this is the row consumed by recommendations / scoring. |
7d | +7 days after publish | Final 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.
Permalinks & published_url
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:
- Storylayer click tracking — minted automatically when you supply
slides[].swipe_through_urlor a story-leveldestination_url. Returns per-slide click counts and CTR viaGET /api/analytics/story-attribution?story_id={id}in theslide_attributionarray. Works for image carousels (which Instagram does not expose insights for). - Instagram per-child insights — only available for video / reel carousel children. When present, surfaced as
data_snapshot.carousel_childrenon the post_analytics row with per-childviews/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
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/moments | moments:read | List 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
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/social-connections | connections:read | List 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.0means none allowed;nulldisables 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 likeimpressions).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:
- Calendar gap detection. The
/dashboard/calendarpage renders a banner showing the current pillar mix, flags any pillar whosedays_since_last_post > max_gap_days, and color-codes individual chips so the rotation is glanceable. - Daily benchmark + gap alerts. A cron at 08:00 UTC (
/api/cron/pillar-thresholds) computes the rolling-window report for every project and firespillar.benchmark_below(debounced once per 7 days) andpillar.gap_detected(debounced once per 24h) webhooks whenever a pillar slips.
Endpoints:
GET /api/projects/{id}/pillars— list pillars. Append?include=performanceto 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. Returnspillars[],in_gap_pillars[],below_benchmark_pillars[], plus aggregatemetricsper 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_idsand/orqueue_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, orrejectedare skipped and surface in the response'sskippedarray with a reason. - Each rescheduled story emits a
story.scheduledwebhook withvia: "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}}— slugifiedproject_pillars.display_name(e.g.named-room). Falls back to slugifiedcontent_typewhen 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:
- Per-story
utm_campaign / utm_content / utm_term / destination_urlpassed toPOST /api/v1/stories— always win. Pass them when you want one-off attribution. - Per-queue-item values written before publish — used by re-scheduled or split-channel stories.
- Auto-resolved from the project's
utm_templateat story-archive time. - 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 tokenMETAINBOX_VERIFY_TOKENand subscribe Pages →feed,messages; Instagram →comments,messages,mentions. WhenMETAAPPSECRETis set we verify thex-hub-signature-256HMAC 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 againstsocial_connectionsto attribute the event to the right project. Connections with mismatched account ids are ignored (statusunknown_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}— updatestatus/archived/tags; passread: trueto clearunread_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 withdirection='us'andsend_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.publishedfires, the cron snapshots the published caption into the corpus withsource='published'andapproved=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-templatesGET / 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-searchesGET / 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 requireimpressions > 0to 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:
| Platform | Auth method | Refresh path |
|---|---|---|
instagram_login | graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token — extends 60d token by another 60d. | |
facebook_login (via Meta Business) | graph.facebook.com/access_token?grant_type=fb_exchange_token — re-issues 60d long-lived token. | |
| Page token | Same FB exchange path. | |
| OAuth2 | linkedin.com/oauth/v2/accessToken?grant_type=refresh_token using stored refresh_token. | |
| X (Twitter) | OAuth1 | No refresh — OAuth1 tokens are non-expiring. The cron emits connection.expiring_soon only if token_expires_at was set, which OAuth1 connections don't. |
| Ghost | Admin API key | No 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 viasocial_connections.notified_at_tier; resets onconnection.refreshedorconnection.reconnectedso 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; newexpires_atin the payload.connection.expiring_soon— stronger signal thanconnection.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 withbroken/expiring_soonas 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
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/social-connections/pinterest/boards?project_id=&refresh=1 | connections:read | List 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/boards | connections:write | Set the default board. Body: { project_id?, default_board_id, default_board_name? }. |
| POST | /api/social-connections/pinterest/boards | connections:write | Create 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:
channel_overrides.pinterest.board_id— per-story override (recommended when the story has a specific topical home).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:
| Field | Type | Notes |
|---|---|---|
board_id | string | Required for the override path. Discoverable via list_pinterest_boards. |
board_section_id | string | Optional — pin to a sub-section of the board. |
title | string | Pinterest pin title. Searchable on Pinterest. Cap: 100 chars. |
description | string | Pin description. Falls back to caption. Cap: 800 chars. |
link | string | Outbound destination URL the pin opens. Cap: 2048 chars. Auto-wrapped with UTM params when the story has a utm_campaign. |
alt_text | string | Accessibility alt text. Cap: 500 chars. |
cover_image_url | string | Required 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"
}
}
}Carousel pins (2–5 images)
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:
- Register an upload intent — Pinterest returns a
media_id+ presigned S3 URL. - Stream the video to S3 (no Storylayer disk usage).
- Poll
GET /v5/media/{media_id}for up to 90s untilstatus === "succeeded". - 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 metric | post_analytics column | Notes |
|---|---|---|
IMPRESSION | impressions | Total times the pin was shown. |
SAVE | saves | Times 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_CLICK | link_clicks | Times users followed the destination link. The traffic-driving metric. |
PIN_CLICK | data_snapshot.pin_clicks | Pinterest-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_urlwhen 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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/webhooks | webhooks:read | List webhook endpoints. |
| POST | /api/v1/webhooks | webhooks:write | Create a webhook endpoint. Returns `signing_secret` ONCE. |
| GET | /api/v1/webhooks/:id | webhooks:read | Fetch a single endpoint (without the secret). |
| PATCH | /api/v1/webhooks/:id | webhooks:write | Update events, URL, description, or active flag. |
| DELETE | /api/v1/webhooks/:id | webhooks:write | Delete an endpoint. |
| POST | /api/v1/webhooks/:id/test | webhooks:write | Send a signed `test.ping` to the endpoint. |
| GET | /api/v1/webhooks/:id/deliveries | webhooks:read | List 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
}