REST API
Base URL & versioning
https://<your-api-url>/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 |
| Failed auth per IP | 30 attempts / minute | 429 with Retry-After |
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.
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.
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://<your-api-url>/v1/posts?status=scheduled&limit=50" \ -H "Authorization: Bearer $RELAY_API_KEY" # then, while next_cursor is not null: curl "https://<your-api-url>/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. |
| 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://<your-api-url>/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"
}
]
}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.
/v1/posts| Field | Type | Description |
|---|---|---|
| targets | string[] required | Account IDs to post to (from accounts_list) 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[] | Up to 10 media IDs or public URLs (JPEG images, MP4/MOV videos) Example: ["https://cdn.example.com/fall.jpg"] |
| share_to_feed | boolean? | Reels only: also show the reel on the profile grid (Instagram defaults to true) Example: false |
| schedule_at | string? | ISO 8601 timestamp with offset — omit to publish now Example: "2026-08-04T18:30:00Z" |
Media entries are either media IDs from POST /v1/media or public https URLs, which Relay fetches and stores on the spot. Order is preserved — the first item is the carousel cover.
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://<your-api-url>/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"
}
],
"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": "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://<your-api-url>/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 and targets are immutable: cancel and create a new post instead.
/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://<your-api-url>/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" }'{
"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"
}
}- Accepted types:
image/jpeg(≤ 8 MB),video/mp4andvideo/quicktime(≤ 100 MB). PNG is rejected here rather than at publish time, because Instagram will not accept 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.
- Uploading is optional —
POST /v1/postsaccepts URLs directly. Upload first when you want to reuse one asset across posts or validate it before composing.
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). |
| 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. |
| 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. |
| status | string | pending, uploading, processing, published, failed, cancelled. |
| platform_post_id | string | null | Instagram media ID once published. |
| permalink | string | null | Public Instagram URL. |
| 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). |
Media
| Field | Type | Description |
|---|---|---|
| id | string | UUID — pass this in post media. |
| url | string | Public URL Instagram fetches 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. |
| created_at | string | ISO 8601. |
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. |