REST API
Base URL & versioning
https://api.relay.lantean.tech/v1
All endpoints live under the /v1 prefix and take and return application/json. Changes within v1 are additive: new fields may appear in responses, new optional request fields may be accepted. Parse responses tolerantly and ignore unknown keys.
snake_case; GET /v1/accounts returns camelCase because it serializes database columns directly. Both are shown verbatim below.Authentication
Every request carries an API key as a bearer token. Create keys in the dashboard under API keys; the full value is displayed once, at creation.
Authorization: Bearer rly_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Keys look like
rly_live_plus 32 random base62 characters (~190 bits of entropy). - Only a SHA-256 hash of the key is stored. Relay cannot show you a key again — rotate by creating a new one and revoking the old.
- One key authorizes both this API and the MCP server, and inherits every account its owner has connected.
- Revocation is immediate. Each key records a
lastUsedAttimestamp so you can spot keys that are safe to retire. - Send the key in the header only — never in a query string, where it would land in logs and referrers.
A missing, malformed, revoked, or unknown key returns 401 with a WWW-Authenticate: Bearer header:
{ "error": "Missing or malformed API key" } // no Bearer prefix, or not rly_live_*
{ "error": "Invalid API key" } // well-formed but unknown or revokedRate limits
| Scope | Limit | Response when exceeded |
|---|---|---|
| Per API key | 120 requests / minute | 429 with Retry-After |
| Per user (all keys combined) | 300 requests / minute | 429 with Retry-After |
| Failed auth per IP | 30 attempts / minute | 429 with Retry-After |
On the MCP endpoint, every JSON-RPC message in a batched request counts against these budgets individually — a batch of 20 tool calls costs 20, and batches are capped at 20 messages per request.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "error": "Rate limit exceeded (120 requests/minute per API key)" }Limits use a fixed 60-second window, so a burst straddling a window boundary can briefly pass up to twice the limit — treat the number as a floor, not a precise budget. Back off for the number of seconds in Retry-After rather than retrying immediately.
POST /v1/uploads is charged against a second, separate budget: 512 MB per minute per user, on top of the request counts above. Counting requests alone would let a 100 MB endpoint move tens of gigabytes a minute. Exceeding it returns the same 429 and Retry-After contract with its own message, so match on the status rather than the text:
HTTP/1.1 429 Too Many Requests
Retry-After: 41
{ "error": "Upload limit exceeded (512MB/minute per user)" }Idempotency
POST /v1/posts and POST /v1/media accept an Idempotency-Key header so a retry after a timeout can't create a duplicate post or a second copy of an asset.
Idempotency-Key: fall-drop-2026-08-04
- Keys are scoped to (your account, endpoint, key) and remembered for 24 hours.
- A replayed request returns the original response body and status, plus
Idempotency-Replayed: true. - Only successful responses are stored. A request that failed validation can be corrected and retried with the same key.
- Maximum length is 255 characters; longer keys are rejected with
400. - Two genuinely simultaneous requests with the same key may both execute — the window is milliseconds wide. Serialize retries rather than firing them in parallel.
- Use a key derived from your own domain object (campaign ID, content hash), not a random UUID generated per attempt — a fresh UUID per retry defeats the mechanism.
POST /v1/posts/:id/retryandDELETE /v1/posts/:idignore the header — their status guards make an accidental duplicate a safe400(the post is already scheduled or cancelled), not a second execution.POST /v1/uploadsignores it too, for a different reason: replays are remembered for 24 hours but a staged file is deleted within the hour, so a replay would hand back a URL that has already 404'd. Re-upload instead — a duplicate staged file costs nothing and expires on its own.
Pagination
GET /v1/posts is cursor-paginated. Pass limit (1–100, default 20) and read next_cursor from the response; when it is null you have the last page.
curl "https://api.relay.lantean.tech/v1/posts?status=scheduled&limit=50" \ -H "Authorization: Bearer $RELAY_API_KEY" # then, while next_cursor is not null: curl "https://api.relay.lantean.tech/v1/posts?status=scheduled&limit=50&cursor=MjAyNi0wNy0zMFQxMTo0..." \ -H "Authorization: Bearer $RELAY_API_KEY"
The cursor is an opaque keyset over (created_at, id) descending — stable while posts are being created, unlike offset paging. Treat it as an opaque string; a cursor you didn't get from a previous page is rejected with 400.
Errors
| Status | Meaning | What to do |
|---|---|---|
| 400 | Validation or domain error — bad post type for the media, unknown account ID, un-editable post, unsupported media format. | Fix the request. Safe to retry with the same idempotency key. |
| 401 | Missing, malformed, unknown, or revoked API key. | Check the header; rotate the key if it was revoked. |
| 404 | No such post, or it belongs to another account. | Do not retry. |
| 411 | POST /v1/uploads only — no Content-Length. It is required so the upload byte budget can be charged before the body is read. | Send the exact body size and retry. |
| 413 | POST /v1/uploads only — the body is over the cap for its type (8 MB image, 100 MB video). | Do not retry. Compress or re-encode first. |
| 429 | Rate limited. | Sleep for Retry-After seconds, then retry. |
| 5xx | Transient server-side failure. | Retry with backoff and the same idempotency key. |
Two error body shapes
Domain errors — the ones you'll actually read — are a single message:
{ "error": "Feed posts take exactly one image — use post_type 'reel' for a single video or 'carousel' for multiple items" }Requests that fail schema validation before reaching the handler (a missing required field, a caption over 2,200 characters, a non-ISO timestamp) return the validator's own output instead, where error.message is a JSON-encoded string of issues:
{
"success": false,
"error": {
"name": "ZodError",
"message": "[{\"code\":\"too_small\",\"minimum\":1,\"path\":[\"caption\"],\"message\":\"Too small: expected string to have >=1 characters\"}]"
}
}Handle both: read error when it is a string, otherwise parse error.message.
Publish-time failures are not HTTP errors
Creating a post only queues it. Anything Instagram rejects later — a revoked token, a video that fails transcoding, a quota ceiling — surfaces on the post's per-target timeline, not on the original HTTP call. Poll GET /v1/posts/:id and read targets[].error; the codes are listed in the failure reference.
Endpoints
/v1/accountscurl https://api.relay.lantean.tech/v1/accounts \ -H "Authorization: Bearer $RELAY_API_KEY"
{
"accounts": [
{
"id": "9d1f2c34-5b6a-4c8d-9e0f-1a2b3c4d5e6f",
"platform": "instagram",
"username": "northlight.co",
"displayName": "Northlight",
"status": "connected",
"tokenExpiresAt": "2026-09-21T10:02:11.000Z",
"connectedAt": "2026-07-23T10:02:11.000Z",
"followersCount": 12840,
"mediaCount": 318,
"statsSyncedAt": "2026-07-30T05:30:00.000Z",
"canComment": true,
"canDirectPost": false,
"canUpload": false
}
]
}platform is instagram or tiktok. status is connected, expiring, reconnect_required, or disconnected. Only the first two can be used as post targets. Follower and media counts refresh once a day. canComment (Instagram) says whether first comments can be posted; canDirectPost and canUpload (TikTok) say whether the account allowed direct posting and sending to the TikTok inbox.
/v1/accounts/:id/tiktok/creator-info{
"creator": {
"username": "northlight.co",
"nickname": "Northlight",
"avatar_url": "https://p16-sign.tiktokcdn.com/…",
"privacy_level_options": ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"],
"comment_disabled": false,
"duet_disabled": false,
"stitch_disabled": true,
"max_video_post_duration_sec": 600,
"can_post": true,
"cannot_post_reason": null
}
}A disabled interaction is forced off on every post. can_post is false when TikTok is not accepting posts from the account right now — usually its daily cap — with the reason in cannot_post_reason. See TikTok settings.
/v1/posts| Field | Type | Description |
|---|---|---|
| targets | string[] required | Account IDs to post to (from accounts_list), up to 10 Example: ["9d1f…"] |
| caption | string required | Caption text, 1–2200 characters (ignored for stories) Example: "Fall drop is live — link in bio." |
| post_type | string? | feed | carousel | reel | story — inferred from media when omitted (stories never inferred) Example: reel |
| media | (string | object)[] | Media IDs or public URLs (JPEG images, MP4/MOV videos) — up to 10 when an Instagram account is targeted, up to 35 images for a TikTok-only photo post. Required unless draft is true, since neither platform has text-only posts. For a per-slide caption on an Instagram carousel, pass { id, caption } or { url, caption } instead of a bare string — exactly one of id or url Example: ["https://cdn.example.com/fall.jpg", { "id": "a1b2…", "caption": "Sand, our best seller." }] |
| share_to_feed | boolean? | Reels only: also show the reel on the profile grid (Instagram defaults to true) Example: false |
| first_comment | string? | Text posted as a comment on the post the moment it goes live, 1–2200 characters — the usual home for a hashtag block you want out of the caption. Posted once per Instagram target account (TikTok has no first comments, so it needs at least one Instagram target). Not valid on stories, which have no comments Example: "#fallcollection #newin #slowfashion" |
| tiktok | object? | TikTok settings, required when any target is a TikTok account: { mode: "direct" | "inbox" (default direct), privacy_level: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY (required for direct, no default), allow_comment, allow_duet, allow_stitch (all default false; duet/stitch are video only), brand_organic (promotes your own brand), brand_content (paid partnership — never SELF_ONLY), is_aigc, caption (TikTok-only caption override), title (photo posts, ≤90), cover_timestamp_ms (video), photo_cover_index (photo), auto_add_music (photo) } Example: { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true } |
| schedule_at | string? | ISO 8601 timestamp with offset — omit to publish now Example: "2026-08-04T18:30:00Z" |
| draft | boolean? | Save as a draft instead of queueing it — nothing publishes until the user approves it on the preview page Example: true |
Media entries are either media IDs from POST /v1/media or public https URLs, which Relay fetches and stores on the spot — including the short-lived URLs handed back by POST /v1/uploads, which is how you post a file you hold locally. Order is preserved — the first item is the carousel cover.
Per-slide captions: a carousel slide can carry its own caption. Replace the bare string with { "id": "…", "caption": "…" } for a stored media ID or { "url": "…", "caption": "…" } for a public link — exactly one of id or url, and the caption is 1–2,200 characters. Instagram shows that text on that slide; the post's own caption still applies to the whole post. Mix bare strings and objects freely — a string is simply a slide with no caption of its own. Per-item captions are carousel-only: send one on a feed post, reel, or story and the request is rejected.
Type inference: omit post_type and Relay derives it from the media — one image is a feed post, one video is a reel, two or more is a carousel. Stories are never inferred; ask for them explicitly.
Scheduling: omit schedule_at to publish on the next scheduler tick (under a minute). A past timestamp behaves the same way. Posts created through this API always come back with status scheduled — drafts are a dashboard concept.
curl -X POST https://api.relay.lantean.tech/v1/posts \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: fall-drop-2026-08-04" \
-d '{
"targets": ["9d1f2c34-5b6a-4c8d-9e0f-1a2b3c4d5e6f"],
"caption": "Fall drop is live — link in bio.",
"media": ["https://cdn.example.com/fall-drop.jpg"],
"schedule_at": "2026-08-04T18:30:00Z"
}'{
"post": {
"id": "3f0c8a71-2d64-4b18-9a55-7c2e11d0f4ab",
"caption": "Fall drop is live — link in bio.",
"status": "scheduled",
"post_type": "feed",
"share_to_feed": null,
"schedule_at": "2026-08-04T18:30:00.000Z",
"published_at": null,
"source": "api",
"created_at": "2026-07-30T12:41:09.220Z",
"media": [
{
"id": "a1b2c3d4-0000-4a1b-8c2d-3e4f5a6b7c8d",
"url": "https://media.relay.lantean.tech/users/…/a1b2….jpg",
"kind": "image",
"content_type": "image/jpeg",
"size_bytes": 412903,
"width": 1080,
"height": 1350,
"created_at": "2026-07-30T12:41:08.911Z",
"caption": null
}
],
"targets": [
{
"id": "b7e1…",
"account_id": "9d1f2c34-5b6a-4c8d-9e0f-1a2b3c4d5e6f",
"username": "northlight.co",
"platform": "instagram",
"status": "pending",
"platform_post_id": null,
"permalink": null,
"published_at": null,
"error": null
}
]
}
}More examples
{
"targets": ["9d1f…"],
"caption": "Behind the drop.",
"post_type": "reel",
"media": ["https://cdn.example.com/behind.mp4"],
"share_to_feed": false
}{
"targets": ["9d1f…"],
"caption": "Every colourway.",
"media": ["a1b2c3d4-…", "e5f6a7b8-…", "c9d0e1f2-…"]
}{
"targets": ["9d1f…"],
"caption": "Every colourway.",
"media": [
{ "id": "a1b2c3d4-…", "caption": "Sand, our best seller." },
{ "url": "https://cdn.example.com/slate.jpg", "caption": "Slate — new this season." },
"c9d0e1f2-…"
]
}{
"targets": ["9d1f…"],
"caption": "internal note — not shown",
"post_type": "story",
"media": ["https://cdn.example.com/story.jpg"]
}{
"targets": ["9d1f…", "4a7b…"],
"caption": "Same drop, two brands.",
"media": ["a1b2c3d4-…"]
}/v1/posts| Query param | Type | Description |
|---|---|---|
| status | string? | draft | scheduled | publishing | published | partly | failed | cancelled |
| limit | number? | 1–100, default 20 |
| cursor | string? | next_cursor from a previous page |
{
"posts": [ /* post objects, newest first */ ],
"next_cursor": "MjAyNi0wNy0zMFQxMTo0Mzoy…"
}/v1/posts/:id{
"post": {
"id": "3f0c8a71-…",
"status": "partly",
"post_type": "feed",
"published_at": "2026-08-04T18:30:42.180Z",
"targets": [
{
"account_id": "9d1f…",
"username": "northlight.co",
"status": "published",
"platform_post_id": "17912345678901234",
"permalink": "https://www.instagram.com/p/C9xY…/",
"published_at": "2026-08-04T18:30:42.180Z",
"error": null
},
{
"account_id": "4a7b…",
"username": "northlight.eu",
"status": "failed",
"platform_post_id": null,
"permalink": null,
"published_at": null,
"error": {
"code": "auth_reconnect_required",
"message": "Instagram authorization expired or was revoked — reconnect the account"
}
}
]
}
}Returns 404 with { "error": "Post not found" } for an unknown ID or one owned by a different account.
/v1/posts/:idcurl -X PATCH https://api.relay.lantean.tech/v1/posts/3f0c8a71-… \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "schedule_at": "2026-08-05T09:00:00Z" }'Only draft and scheduled posts can be edited — once the scheduler claims a post it is frozen, and you get 400 with Only draft/scheduled posts can be edited. Media, per-slide captions, and targets are immutable on this API — caption here is the post's own caption, not a carousel slide's. To change any of them, cancel and create a new post instead (the dashboard can edit them directly).
/v1/posts/:id/retrycurl -X POST https://api.relay.lantean.tech/v1/posts/3f0c8a71-…/retry \ -H "Authorization: Bearer $RELAY_API_KEY"
Only failed and partly posts can be retried — anything else returns 400. Failed targets reset to pending and the post re-enters the publish queue on the next scheduler tick; targets that already published are untouched, so a partly-published post republishes only what failed. The post keeps its ID. Fix the underlying cause first (the per-target error on GET /v1/posts/:id says why Instagram said no) or the retry will fail the same way.
/v1/posts/:idCancellation is atomic against the scheduler: if a publish has already started you get 400 rather than a half-cancelled post. It cannot remove something already live on Instagram — delete that in the Instagram app.
/v1/mediacurl -X POST https://api.relay.lantean.tech/v1/media \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: fall-drop-hero-v3" \
-d '{ "url": "https://cdn.example.com/fall-drop.jpg" }'curl -X POST https://api.relay.lantean.tech/v1/media \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"data\": \"$(base64 -w0 fall-drop.jpg)\", \"content_type\": \"image/jpeg\" }"{
"media": {
"id": "a1b2c3d4-0000-4a1b-8c2d-3e4f5a6b7c8d",
"url": "https://media.relay.lantean.tech/users/…/a1b2….jpg",
"kind": "image",
"content_type": "image/jpeg",
"size_bytes": 412903,
"width": 1080,
"height": 1350,
"created_at": "2026-07-30T12:41:08.911Z"
}
}- Pass exactly one of
url(a publicly reachable http(s) URL Relay fetches) ordata(the file content itself, as raw base64 or adata:URI). Withdata, the type comes fromcontent_type, thedata:URI, or the bytes themselves, in that order. - Accepted types:
image/jpeg(≤ 8 MB),video/mp4andvideo/quicktime(≤ 100 MB). PNG and other image formats are rejected here rather than at publish time, because Instagram will not accept them — convert before uploading. (The dashboard does this conversion in the browser, which is why it accepts any image you give it.) - Image dimensions are parsed on upload so aspect-ratio problems surface now, not when the post goes out.
- The source URL must be publicly reachable over http(s). Private and link-local hosts are refused. For video over ~50 MB use
url— base64 inflates payloads ~33% and oversized requests are rejected. - Uploading is optional —
POST /v1/postsaccepts URLs directly. Upload first when you want to reuse one asset across posts or validate it before composing.
/v1/uploadscurl -X POST https://api.relay.lantean.tech/v1/uploads \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H "Content-Type: image/jpeg" \ --data-binary @fall-drop.jpg
{
"upload": {
"url": "https://api.relay.lantean.tech/v1/uploads/users/…/a1b2….jpg",
"kind": "image",
"content_type": "image/jpeg",
"size_bytes": 412903,
"expires_at": "2026-07-30T13:41:08.911Z"
}
}curl -X POST https://api.relay.lantean.tech/v1/posts \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "targets": ["3f0c8a71-…"], "caption": "Fall drop", "media": ["<the url above>"] }'- The request body is the file itself, not JSON — unlike every other endpoint here.
Content-Typeidentifies the format andContent-Lengthis required. - The URL expires. Staged files are deleted an hour after upload, and the URL 404s from that moment. This is a hand-off, not storage: create the post while the URL is alive and it keeps a permanent copy of the bytes, unaffected by the expiry — so scheduling a post weeks out is fine. For an asset you want to reuse across posts, use
POST /v1/mediainstead. - Same accepted types and size caps as
POST /v1/media:image/jpeg(≤ 8 MB),video/mp4andvideo/quicktime(≤ 100 MB). - Reach for this when you hold the bytes and have nowhere public to put them. It avoids base64 entirely, which matters most for video — inline
datainflates the payload by about a third. - Uploads are charged against a 512 MB/minute budget per user, on top of the request rate limits. Over it you get a
429withRetry-After.Idempotency-Keyis not accepted here — replays are honoured for 24 h but a staged file is gone within the hour, so retrying the upload is the right move.
Object reference
Post
| Field | Type | Description |
|---|---|---|
| id | string | UUID. |
| caption | string | Caption text, 1–2,200 characters. |
| status | string | Roll-up across targets — see status reference. |
| post_type | string | null | feed, carousel, reel, or story. Null only for a media-less draft. |
| share_to_feed | boolean | null | Reels only. Null means Instagram’s default (shown on the grid). |
| tiktok | object | null | TikTok settings applied to every TikTok target (mode, privacy_level, interactions, disclosure). Null when the post has no TikTok target. See TikTok settings. |
| schedule_at | string | null | ISO 8601. When the scheduler becomes eligible to publish it. |
| published_at | string | null | ISO 8601. Set when the first target goes live. |
| source | string | dashboard, api, or mcp — where the post was created. |
| created_at | string | ISO 8601. |
| media | Media[] | Ordered media items. On a carousel each carries its own caption. |
| targets | Target[] | One per account. |
Target
| Field | Type | Description |
|---|---|---|
| id | string | UUID of the post↔account row. |
| account_id | string | The connected account. |
| username | string | Handle at publish time. |
| platform | string | instagram or tiktok. |
| status | string | pending, uploading, processing, published, failed, cancelled. |
| platform_post_id | string | null | The platform’s post ID once published (TikTok: once public). |
| permalink | string | null | Public post URL. On TikTok it can arrive a little after published, once TikTok’s review finishes; SELF_ONLY posts have none. |
| published_at | string | null | ISO 8601. |
| error | { code, message } | null | Populated on failure, and occasionally as a non-fatal breadcrumb (for example quota_exceeded while waiting, or tiktok_inbox on a published target that was delivered to the TikTok inbox). |
Media
| Field | Type | Description |
|---|---|---|
| id | string | UUID — pass this in post media. |
| url | string | Public URL Instagram and TikTok fetch from. |
| kind | string | image or video. |
| content_type | string | image/jpeg, video/mp4, video/quicktime. |
| size_bytes | number | null | Stored size. |
| width | number | null | Pixels. Parsed for JPEGs; null for video. |
| height | number | null | Pixels. Parsed for JPEGs; null for video. |
| duration_ms | number | null | Video length, read from the MP4/MOV header at upload (TikTok checks it against the creator’s limit). Null for images. |
| created_at | string | ISO 8601. |
| caption | string | null | This slide's own caption. Carousels only; null elsewhere and on uncaptioned slides. |
caption appears only on media inside a post. The media-library object — what POST /v1/media and the MCP media_upload tool return — has no caption, because a caption belongs to a post's use of an asset, not to the asset itself. The same photo can carry different captions in two different carousels.
Account
Returned by GET /v1/accounts in camelCase: id, platform, username, displayName, status, tokenExpiresAt, connectedAt, followersCount, mediaCount, statsSyncedAt. The MCP accounts_list tool returns the same account in snake_case with a derived health and token_expires_in_days.
Status reference
Post status
| Status | Meaning |
|---|---|
| draft | Created in the dashboard without a schedule. The API never returns a draft it created. |
| scheduled | Queued. The scheduler will pick it up at schedule_at. |
| publishing | Claimed by the scheduler; at least one target is still in flight. No longer editable. |
| published | Every target is live. |
| partly | Some targets are live, others failed. Inspect targets[] to see which. |
| failed | Every target failed. |
| cancelled | Cancelled before publishing. |
Target status
| Status | Meaning |
|---|---|
| pending | Not started. |
| uploading | Carousel children are being created and transcoded. |
| processing | The media container exists; waiting for Instagram to finish processing it. |
| published | Live, with a platform_post_id and usually a permalink. |
| failed | Terminal. Read error.code. |
| cancelled | The post was cancelled before this target ran. |