Relay
DocsLog inStart free

REST API

Schedule, publish, and inspect Instagram posts over plain HTTP. JSON in, JSON out, one bearer token. Every capability here is also exposed to agents through the MCP server.

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.

Field naming is not uniform yet. Post, media, and target objects use 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 lastUsedAt timestamp 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 revoked

Rate limits

ScopeLimitResponse when exceeded
Per API key120 requests / minute429 with Retry-After
Failed auth per IP30 attempts / minute429 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.

Relay's request limit is separate from Instagram's publishing quota of 100 posts per rolling 24 hours per account. Hitting Instagram's quota does not produce an HTTP error — see Publishing quota.

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

StatusMeaningWhat to do
400Validation 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.
401Missing, malformed, unknown, or revoked API key.Check the header; rotate the key if it was revoked.
404No such post, or it belongs to another account.Do not retry.
429Rate limited.Sleep for Retry-After seconds, then retry.
5xxTransient 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.

There are no webhooks yet. To track a publish, poll the post — every ~30 seconds is plenty, since the scheduler itself runs once a minute.

Endpoints

GET/v1/accounts
List connected social accounts, newest connection first. The ids returned here are what post targets refer to.
curl https://<your-api-url>/v1/accounts \
  -H "Authorization: Bearer $RELAY_API_KEY"
200 OK
{
  "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.

POST/v1/posts
Create a post and queue it for publishing. Returns 201 with the queued post; delivery happens asynchronously.
FieldTypeDescription
targetsstring[] requiredAccount IDs to post to (from accounts_list) Example: ["9d1f…"]
captionstring requiredCaption text, 1–2200 characters (ignored for stories) Example: "Fall drop is live — link in bio."
post_typestring?feed | carousel | reel | story — inferred from media when omitted (stories never inferred) Example: reel
mediastring[]Up to 10 media IDs or public URLs (JPEG images, MP4/MOV videos) Example: ["https://cdn.example.com/fall.jpg"]
share_to_feedboolean?Reels only: also show the reel on the profile grid (Instagram defaults to true) Example: false
schedule_atstring?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"
  }'
201 Created
{
  "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

Reel, kept off the profile grid
{
  "targets": ["9d1f…"],
  "caption": "Behind the drop.",
  "post_type": "reel",
  "media": ["https://cdn.example.com/behind.mp4"],
  "share_to_feed": false
}
Carousel from previously uploaded media
{
  "targets": ["9d1f…"],
  "caption": "Every colourway.",
  "media": ["a1b2c3d4-…", "e5f6a7b8-…", "c9d0e1f2-…"]
}
Story (Instagram shows no caption on stories)
{
  "targets": ["9d1f…"],
  "caption": "internal note — not shown",
  "post_type": "story",
  "media": ["https://cdn.example.com/story.jpg"]
}
Fan out to several accounts
{
  "targets": ["9d1f…", "4a7b…"],
  "caption": "Same drop, two brands.",
  "media": ["a1b2c3d4-…"]
}
GET/v1/posts
List posts newest-first, with their media and per-account targets.
Query paramTypeDescription
statusstring?draft | scheduled | publishing | published | partly | failed | cancelled
limitnumber?1–100, default 20
cursorstring?next_cursor from a previous page
200 OK
{
  "posts": [ /* post objects, newest first */ ],
  "next_cursor": "MjAyNi0wNy0zMFQxMTo0Mzoy…"
}
GET/v1/posts/:id
Fetch one post with its per-target delivery timeline — the endpoint to poll after creating a post.
200 OK — one target live, one rejected
{
  "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.

PATCH/v1/posts/:id
Edit an unpublished post: caption, schedule_at, or both. At least one is required.
curl -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.

DELETE/v1/posts/:id
Cancel a draft or scheduled post before it publishes. Returns the cancelled post.

Cancellation 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.

POST/v1/media
Fetch a public URL, store it in Relay, and return a reusable media ID.
curl -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" }'
201 Created
{
  "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/mp4 and video/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/posts accepts URLs directly. Upload first when you want to reuse one asset across posts or validate it before composing.

Object reference

Post

FieldTypeDescription
idstringUUID.
captionstringCaption text, 1–2,200 characters.
statusstringRoll-up across targets — see status reference.
post_typestring | nullfeed, carousel, reel, or story. Null only for a media-less draft.
share_to_feedboolean | nullReels only. Null means Instagram’s default (shown on the grid).
schedule_atstring | nullISO 8601. When the scheduler becomes eligible to publish it.
published_atstring | nullISO 8601. Set when the first target goes live.
sourcestringdashboard, api, or mcp — where the post was created.
created_atstringISO 8601.
mediaMedia[]Ordered media items.
targetsTarget[]One per account.

Target

FieldTypeDescription
idstringUUID of the post↔account row.
account_idstringThe connected account.
usernamestringHandle at publish time.
platformstringinstagram.
statusstringpending, uploading, processing, published, failed, cancelled.
platform_post_idstring | nullInstagram media ID once published.
permalinkstring | nullPublic Instagram URL.
published_atstring | nullISO 8601.
error{ code, message } | nullPopulated on failure, and occasionally as a non-fatal breadcrumb (for example quota_exceeded while waiting).

Media

FieldTypeDescription
idstringUUID — pass this in post media.
urlstringPublic URL Instagram fetches from.
kindstringimage or video.
content_typestringimage/jpeg, video/mp4, video/quicktime.
size_bytesnumber | nullStored size.
widthnumber | nullPixels. Parsed for JPEGs; null for video.
heightnumber | nullPixels. Parsed for JPEGs; null for video.
created_atstringISO 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

StatusMeaning
draftCreated in the dashboard without a schedule. The API never returns a draft it created.
scheduledQueued. The scheduler will pick it up at schedule_at.
publishingClaimed by the scheduler; at least one target is still in flight. No longer editable.
publishedEvery target is live.
partlySome targets are live, others failed. Inspect targets[] to see which.
failedEvery target failed.
cancelledCancelled before publishing.

Target status

StatusMeaning
pendingNot started.
uploadingCarousel children are being created and transcoded.
processingThe media container exists; waiting for Instagram to finish processing it.
publishedLive, with a platform_post_id and usually a permalink.
failedTerminal. Read error.code.
cancelledThe post was cancelled before this target ran.
© 2026 Relaysupport@lantean.techPrivacyTerms