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}.
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).
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
}