API documentation

Relay publishing API

The /api/v1 API lets Carbon scheduled tasks and other tools publish through the same workspace and validation rules as the Relay composer. Publishing is asynchronous: creating a post queues it for the scheduler, and the status endpoint reports each account's outcome.

Authentication

A workspace owner or admin creates a key in Settings → API keys. Copy it immediately; Relay shows it once. Store it in your task runner's secret store, then send it on every request:

# Set these through your task runner's environment/secret configuration.
# RELAY_URL=https://relay.nestphones.com
# RELAY_API_KEY=relay_...
curl --fail-with-body "$RELAY_URL/api/v1/accounts" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Keys contain 32 cryptographically random bytes in base64url, prefixed with relay_. D1 stores only their SHA-256 hash, 12-character display prefix and administrative metadata. List responses never include the hash or plaintext. Key management checks the current session and the current active-workspace membership; only owner and admin can list, create or revoke keys.

Each key belongs to one workspace and grants all the endpoints below. It has no expiry or narrower permission scopes. It remains a workspace credential even if its creator's role changes, so revoke keys when retiring a tool or removing its access. Revocation blocks subsequent authentication; it does not cancel requests already authorized or posts already scheduled. Rotate by creating a replacement, updating the caller, then revoking the old key.

All API responses use Cache-Control: no-store. Missing, malformed, unknown and revoked keys return 401 with WWW-Authenticate: Bearer realm="Relay API". Session cookies alone do not authenticate these endpoints.

GET/api/v1/accounts

Lists the key workspace's connected accounts, including inactive accounts. Use status: "active" when selecting publish targets. The response never selects access tokens, refresh tokens or their encrypted values.

curl --fail-with-body "$RELAY_URL/api/v1/accounts" \
  -H "Authorization: Bearer $RELAY_API_KEY"
{
  "accounts": [
    {
      "id": "account-id",
      "platform": "instagram",
      "username": "nestphones",
      "displayName": "Nestphones",
      "status": "active"
    }
  ]
}

Platforms are x, facebook, instagram and tiktok. username and displayName may be null; account status is active, expired or revoked.

POST/api/v1/media

Upload exactly one multipart file field:

curl --fail-with-body "$RELAY_URL/api/v1/media" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -F 'file=@./image.jpg;type=image/jpeg' \
  -F 'width=1080' -F 'height=1080' \
  -F 'altText=هاتف ذكي معروض من الأمام والخلف'

Or import a public HTTPS URL server-side:

curl --fail-with-body "$RELAY_URL/api/v1/media" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://cdn.example.com/image.jpg","width":1080,"height":1080,"altText":"هاتف ذكي معروض من الأمام والخلف"}'

Both return 201:

{
  "id": "media-id",
  "kind": "image",
  "mimeType": "image/jpeg",
  "bytes": 245670,
  "url": "https://media.example.com/workspace-id/media-id.jpg"
}

Optional durationMs (positive integer, up to 86,400,000), width and height (positive integers, up to 65,535) use the shared media validator. Supply these numeric fields beside url in JSON, or as individual multipart fields:

curl --fail-with-body "$RELAY_URL/api/v1/media" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -F 'file=@./clip.mp4;type=video/mp4' \
  -F 'durationMs=15000' -F 'width=1080' -F 'height=1920'

The corresponding JSON import is:

curl --fail-with-body "$RELAY_URL/api/v1/media" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://cdn.example.com/clip.mp4","durationMs":15000,"width":1080,"height":1920}'

Optional altText is an image description of at most 1,000 characters. Supply it beside url, as a multipart text field, or on each object in mediaUrls. It is stored on that media record and reused with it; video alt text is rejected. Platforms receive it where supported. An empty string stores an empty description.

A metadata object beside url, or a single multipart metadata JSON string, is also supported. Use either the individual fields or metadata, never both; ambiguous or duplicate fields are rejected.

TikTok direct video posts without duration metadata are rejected with 422 validation_failed and the shared tiktokDurationUnknown issue. Measure the duration from the media before uploading. Relay passes these values to the shared media store, which persists durationMs as media.duration_ms; width and height are stored alongside it. You can supply the same per-file metadata in an object entry in mediaUrls, or upload through /media and reuse its ID.

Media is stored in R2 with a workspace-scoped database record. Its returned public URL is intended for platforms that pull media by URL; it is not a private file-sharing endpoint. Uploaded files remain available for reuse after a post is deleted or rejected. A failed multi-URL post request may already have stored some media; prefer explicit /media uploads when you need to retain their IDs for retries.

POST/api/v1/posts

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "body":"أحدث أخبار الهواتف من Nestphones.",
    "mediaIds":["media-id"],
    "targets":[
      {"platform":"facebook"},
      {"socialAccountId":"x-account-id","body":"أخبار الهواتف اليوم من Nestphones."}
    ]
  }'

Returns 201:

{"id":"post-id","status":"scheduled","scheduledAt":"2027-01-15T08:00:00.000Z"}

Fields:

FieldMeaning
bodyRequired caption string. Use "" for a media-only post. Maximum 10,000 characters before platform-specific rules.
mediaIdsOptional ordered list of existing media IDs owned by the key's workspace.
mediaUrlsOptional ordered list of public HTTPS URL strings or { "url": "https://cdn.example.com/clip.mp4", "durationMs": 15000, "width": 1080, "height": 1920 } objects. Metadata fields are optional and use the same limits as /media; image objects also accept altText. Strings and objects can be mixed; files are appended after mediaIds in array order.
targetsRequired array. Each item supplies exactly one socialAccountId or platform. Platform shorthand expands to every active account of that platform in the workspace. No match is a validation error.
targets[].bodyOptional caption override; an empty string is an explicit override.
targets[].optionsShared composer options, validated by post-validation.ts. Unsupported keys are rejected.
targets[].commercialDisclosureOptional shared TikTok commercial-content flag. When true, choose the appropriate brandOrganic or brandContent option.
publishAtOptional ISO 8601 timestamp including Z or a timezone offset. Must be in the future. Omit to publish on the next scheduler run.
draftOptional boolean, default false. A draft is saved without publishing and returns status: "draft" and scheduledAt: null. Drafts can use an empty targets array.
queueOptional boolean, default false. Choose the next available configured posting slot for all selected accounts. Cannot be combined with draft: true or publishAt.

draft: true, queue: true and publishAt are mutually exclusive. Every creation response includes the persisted scheduledAt in UTC (or null for a draft).

Queue slots use the workspace timezone and may be workspace-wide or account-specific. All selected accounts must share an available slot. Slots already occupied by scheduled posts for those accounts are skipped. If none is configured or available within the shared scheduler's 60-day search horizon, the API returns 422 queue_no_slots. Configure posting slots on the Schedule page. The shared post store rechecks slot availability in its write transaction and retries a conflicting selection. A slot may still become unavailable between a dry-run preview and a later import.

Scheduling example:

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"body":"Scheduled update","targets":[{"platform":"facebook"}],"publishAt":"2027-01-15T09:00:00+01:00"}'

Convenience URL import:

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"body":"جديد Nestphones","mediaUrls":[{"url":"https://cdn.example.com/image.jpg","width":1080,"height":1080,"altText":"هاتف ذكي جديد من Nestphones"}],"targets":[{"platform":"instagram"}]}'

Video URL import with per-item metadata:

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "body":"جديد Nestphones",
    "mediaUrls":[{"url":"https://cdn.example.com/clip.mp4","durationMs":15000,"width":1080,"height":1920}],
    "targets":[{"platform":"tiktok","options":{"mode":"direct","privacyLevel":"SELF_ONLY"}}]
  }'

All URL-item metadata is validated before downloads begin, and each item's metadata is passed to the shared media store for that file. The same HTTPS, timeout and file-size restrictions apply to strings and objects.

The shared composer/store is the authority for account ownership, duplicate media/targets, captions, platform media compatibility and TikTok creator restrictions. For example, overlapping an explicit account with a shorthand selecting it produces a duplicate-target error; it does not publish twice.

Target options

Options are per target. The shared validator rejects unknown options and options belonging to another platform. Media, account and provider restrictions still apply.

PlatformOptions
Instagramformat: "feed" | "story" (feed includes reels for a single video); shareToFeed: boolean for reels; coverOffsetMs: integer for a reel cover frame; firstComment: string up to 2,200 characters; collaborators: string[] of up to three Instagram usernames for feed posts/reels/carousels.
Facebookformat: "feed" | "story"; link: string (HTTPS, at most 2,048 characters, no attached media); videoTitle: string up to 255 characters; firstComment: string up to 2,200 characters.
Xthread: string[], up to 24 follow-up posts. Every follow-up must be nonempty and fit X's weighted 280-character limit. The main caption and media belong to the first post; follow-ups are sequential replies.
TikTokmode: "direct" | "inbox"; explicit privacyLevel; disableComment, disableDuet, disableStitch, brandOrganic, brandContent, autoAddMusic, isAigc booleans; coverTimestampMs: integer for a video cover; photoTitle: string up to 90 UTF-16 units; photoCoverIndex: integer selecting a zero-based image index.

Cover offsets are in milliseconds, from zero through 86,400,000, and must be shorter than the attached video's known duration. Photo cover indexes must be in the actual image list (maximum 34). Stories require exactly one supported image/video and cannot use collaborators, first comments, share-to-feed, cover offsets, links or video titles. Instagram and TikTok require image/video dimensions, Facebook video stories require dimensions, and all video targets require duration for shared preflight. Provide measured width/height and video durationMs when importing/uploading media.

TikTok privacy values are SELF_ONLY, PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS and FOLLOWER_OF_CREATOR; the selected value must be permitted by the creator's current account. The API does not silently choose public visibility. Branded content cannot use SELF_ONLY; commercialDisclosure: true requires the appropriate brandOrganic or brandContent choice. Interaction settings, duration and media formats remain subject to the creator's restrictions and provider approvals. isAigc marks AI-generated content. autoAddMusic, photoTitle and photoCoverIndex apply to photo posts.

Instagram collaborators

Use usernames without @. Relay passes collaborators through shared validation to the Instagram adapter; invitations still require each collaborator's acceptance in Instagram. Collaborators are not supported for stories.

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "body":"تعاون جديد من Nestphones.",
    "mediaIds":["image-media-id"],
    "targets":[{"platform":"instagram","options":{"format":"feed","collaborators":["creator_one","creator.two"]}}]
  }'

A 19:00 reel for Instagram and TikTok

This schedules a 15-second vertical video at 19:00 in UTC+01:00, sharing the Instagram reel to the feed, choosing a two-second cover frame and adding a first comment. TikTok uses its own cover option and explicit private visibility. Replace the timestamp with a future date and use accounts with the required permissions.

curl --fail-with-body "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "body":"نظرة سريعة على أحدث الهواتف من Nestphones.",
    "mediaUrls":[{"url":"https://cdn.example.com/reel.mp4","durationMs":15000,"width":1080,"height":1920}],
    "targets":[
      {"platform":"instagram","options":{"format":"feed","shareToFeed":true,"coverOffsetMs":2000,"firstComment":"ما الميزة التي تهمكم أكثر؟"}},
      {"platform":"tiktok","options":{"mode":"direct","privacyLevel":"SELF_ONLY","coverTimestampMs":2000,"disableComment":false}}
    ],
    "publishAt":"2027-01-15T19:00:00+01:00"
  }'

Response: 201 {"id":"post-id","status":"scheduled","scheduledAt":"2027-01-15T18:00:00.000Z"}.

Creation is not idempotent. A repeated successful POST creates another post. Save the returned ID and poll its status. If a connection drops before a response, inspect the workspace's posts before retrying blindly.

POST/api/v1/posts/import

Import up to 500 CSV records or equivalent JSON rows into the API key's workspace. Each row is validated independently using the shared import core and composer rules. Use ?dryRun=true to preview row errors and resolved schedule times without storing posts or media. Dry runs may fetch remote media and query TikTok creator restrictions.

Upload UTF-8 CSV with Content-Type: text/csv. The parser accepts a UTF-8 BOM, LF/CRLF, quoted commas and newlines, and doubled quotes inside quoted fields. For example, posts.csv:

date,time,caption,platforms,draft
2027-01-15,19:00,أحدث أخبار الهواتف من Nestphones.,x,false
,,مسودة للمراجعة,facebook,true
# Preview first.
curl --fail-with-body "$RELAY_URL/api/v1/posts/import?dryRun=true" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: text/csv; charset=utf-8' \
  --data-binary @posts.csv

# Save valid rows after reviewing the preview.
curl --fail-with-body "$RELAY_URL/api/v1/posts/import" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: text/csv; charset=utf-8' \
  --data-binary @posts.csv

JSON accepts { "rows": [...] } or a bare array of row objects. Values are strings, using the same column names and meanings as CSV; this is a different payload from the single-post endpoint.

curl --fail-with-body "$RELAY_URL/api/v1/posts/import?dryRun=true" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"rows":[
    {"caption":"أحدث أخبار Nestphones.","platforms":"x,facebook","queue":"true"},
    {"caption":"مسودة للمراجعة","accounts":"account-id","draft":"true"}
  ]}'
ColumnMeaning
captionCommon caption. The CSV header must include this column; its value may be empty for supported media-only posts.
platformsComma-separated x,facebook,instagram,tiktok; expands to active accounts in the workspace. Quote CSV cells containing commas.
accountsComma-separated workspace account IDs, instead of platforms. Do not populate both on one row. A CSV header must include platforms or accounts.
date, timeYYYY-MM-DD and HH:MM in the workspace timezone. Required for a scheduled row unless queue or draft is true. The resolved instant must be in the future.
queueString "true" or "false". Choose the next matching posting slot; omit date/time when true.
draftString "true" or "false". Save a draft without publishing; cannot be combined with queue: "true".
media_urlsPublic HTTPS URLs separated by whitespace or commas. Uses the shared safe URL importer and reads available dimensions/duration from each file.
caption_x, caption_facebook, caption_instagram, caption_tiktokNonempty per-platform caption overrides.
first_commentFirst comment for Facebook/Instagram targets.
tiktok_privacyExplicit creator-permitted TikTok privacy value, such as SELF_ONLY.

A processed import returns 200, including when some or all rows fail validation:

{
  "ok":true,
  "dryRun":true,
  "timezone":"Africa/Algiers",
  "results":[
    {"row":2,"ok":true,"scheduledAt":"2027-01-15T18:00:00.000Z","errors":[],"issues":[]},
    {"row":3,"ok":false,"errors":[{"row":3,"column":"platforms","code":"platform"}],"issues":[]}
  ]
}

row counts CSV records including the header, so the first data row is 2; JSON rows use the same numbering. It does not count physical lines inside quoted captions. errors contains import column/parser errors; issues contains shared post-validation issues. Saved successful rows additionally return id; drafts omit scheduledAt. Inspect every row's ok, even when the HTTP response is 200. Top-level ok: true means the batch was processed, not that every row succeeded.

Header/file parsing errors return 422 invalid_csv with details in error.issues and save nothing. Malformed JSON, invalid request shapes and query parameters return 422. Invalid individual rows do not prevent other rows from saving. Imports are not atomic or idempotent: preserve successful row IDs and retry only failed rows. A failed row may leave uploaded media available in the library. dryRun defaults to false and is accepted only as a single query parameter with exactly true or false.

GET/api/v1/posts/:id

curl --fail-with-body "$RELAY_URL/api/v1/posts/post-id" \
  -H "Authorization: Bearer $RELAY_API_KEY"
{
  "id":"post-id",
  "body":"أحدث أخبار الهواتف من Nestphones.",
  "status":"published",
  "scheduledAt":"2027-01-15T08:00:00.000Z",
  "publishedAt":"2027-01-15T08:00:08.000Z",
  "createdAt":"2027-01-14T18:00:00.000Z",
  "targets":[
    {
      "id":"target-id",
      "socialAccountId":"account-id",
      "platform":"facebook",
      "status":"published",
      "error":null,
      "warning":null,
      "outcomeUnknown":false,
      "remoteDeletedAt":null,
      "externalUrl":"https://www.facebook.com/example/posts/123"
    }
  ]
}

Post states: draft, scheduled, publishing, published, partial, failed. Target states: pending, publishing, processing, published, failed. A processing target is still awaiting provider completion. Timestamps and externalUrl can be null. An unknown ID or an ID belonging to another workspace returns the same 404. warning is nullable and reports a nonfatal follow-up failure, such as an Instagram/Facebook first comment or an X thread reply. The original post can be published with a warning. Inspect the linked post before taking action; retry does not republish an already-created provider post.

outcomeUnknown: true means the platform may have published the target, but Relay never received confirmation. A missing externalUrl or provider post ID does not prove that nothing was published. Never automatically retry an unknown-outcome target. Check the actual social account first. Ordinary post retry skips these targets; the explicit single-target endpoint below is for a reviewed retry only.

PATCH/api/v1/posts/:id

Edit or reschedule a draft/scheduled post before any target starts publishing. Send the same complete body as POST: body and targets are required. This replaces the caption, targets, options and media association list; omitted media lists become empty. Include the media IDs you want to keep. Omit publishAt, queue and draft to publish on the next scheduler run; set draft: true to return to a draft or queue: true to choose a posting slot.

curl --fail-with-body -X PATCH "$RELAY_URL/api/v1/posts/post-id" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "body":"موعد جديد لأحدث أخبار Nestphones.",
    "mediaIds":["media-id"],
    "targets":[{"platform":"instagram","options":{"format":"feed"}}],
    "publishAt":"2027-01-16T19:00:00+01:00"
  }'

Returns 200 {"id":"post-id","status":"scheduled","scheduledAt":"2027-01-16T18:00:00.000Z"}. A post or target that has begun publishing returns 403 post_not_editable; absent/foreign posts return 404. The shared store performs the state guard atomically with the replacement.

POST/api/v1/posts/:id/retry

Retry only failed targets with no stored provider post ID and outcomeUnknown: false. Published targets, failures with an external post ID, and unknown-outcome targets are preserved. The endpoint will not retry an uncertain publication just because its provider ID is missing. A post with a publishing/processing target cannot be retried. Eligible targets have their attempts/error/warning reset and are scheduled now; a provider failure can still occur again.

curl --fail-with-body -X POST "$RELAY_URL/api/v1/posts/post-id/retry" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Returns 200 {"id":"post-id","status":"scheduled","scheduledAt":"2027-01-15T18:05:00.000Z","retried":1}. retried is the number of reset targets. No eligible targets or an active publish attempt returns 403 post_not_retryable; absent/foreign posts return 404. Send no body or an empty JSON object; extra fields are rejected.

POST/api/v1/posts/:id/targets/:targetId/retry

Explicitly retry one unknown-outcome target after checking its social account. The earlier request may already have published successfully, so this action can create a duplicate if you retry without checking. If the post is already visible on the account, do not call this endpoint.

Use the target ID returned by GET post detail. The target must belong to the URL post and key workspace, still be failed with outcomeUnknown: true, and have no confirmed provider post ID. A post with publishing/processing work cannot be retried. The shared store clears uncertainty and error/attempt fields for only this target, sets it pending, and schedules the parent post now.

# Run only after reviewing the actual account and deciding a new attempt is needed.
curl --fail-with-body -X POST \
  "$RELAY_URL/api/v1/posts/post-id/targets/target-id/retry" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Returns 200 {"id":"post-id","targetId":"target-id","status":"scheduled","scheduledAt":"2027-01-15T18:05:00.000Z","retried":1}. Send no body or {} with Content-Type: application/json. Ineligible targets return 403 post_not_retryable; missing, foreign or mismatched post/target IDs return 404 not_found. Repeating the call after a successful reset is refused because that target is no longer failed with an unknown outcome. Other targets are untouched. This endpoint is asynchronous: poll GET post detail to observe the new attempt.

POST/api/v1/posts/:id/duplicate

Copy a workspace post into a new draft with the same caption, media references, target overrides and options. The copy has fresh pending targets and no provider IDs, errors or warnings. This does not upload media again or publish anything.

curl --fail-with-body -X POST "$RELAY_URL/api/v1/posts/post-id/duplicate" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Returns 201 {"id":"new-draft-id","status":"draft","scheduledAt":null}. Send no body or {} with Content-Type: application/json. Absent/foreign posts return 404. Use PATCH with the full desired body to publish or schedule the draft. Repeating duplicate creates another draft.

DELETE/api/v1/posts/:id

Only drafts and scheduled posts whose targets are all still pending, have zero attempts and no provider post ID can be deleted. The state guard and deletion run in one SQL statement, so a concurrent scheduler claim cannot slip between the check and deletion.

curl --fail-with-body -X DELETE "$RELAY_URL/api/v1/posts/post-id" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Returns 200:

{"id":"post-id","deleted":true}

Publishing or completed posts return 403; absent/foreign posts return 404. This endpoint does not delete posts from social platforms or remove uploaded media from storage.

DELETE/api/v1/posts/:id/targets/:targetId/remote

Delete one published target from its social network. Get targetId from the post detail response; it must belong to the URL's post and the API key's workspace. This calls the shared platform deletion core, rather than deleting the Relay post record.

curl --fail-with-body -X DELETE \
  "$RELAY_URL/api/v1/posts/post-id/targets/target-id/remote" \
  -H "Authorization: Bearer $RELAY_API_KEY"

Returns 200 {"id":"post-id","targetId":"target-id","deleted":true}. GET post detail then includes an ISO remoteDeletedAt on the affected target (null before remote deletion). The Relay record is retained for history. Repeating a deletion already recorded by Relay returns the same successful response without another network deletion. Send no body or {} with Content-Type: application/json.

An unsupported network returns 409 notSupported; an account requiring reconnection returns 409 accountExpired; a rejected deletion or target that has not been published returns 422 rejected. An overlapping deletion returns 409 inProgress; check post detail before retrying. Unknown, foreign, or mismatched post/target identifiers return 404 not_found. Remote deletion is separate from DELETE on the post itself, which only removes untouched drafts/scheduled posts in Relay.

Errors

Errors from the implemented endpoint handlers use a JSON envelope:

{
  "error": {
    "code":"validation_failed",
    "message":"The post is invalid.",
    "issues":[{"code":"instagramMedia","socialAccountId":"account-id"}]
  }
}
HTTPCodeMeaning
401unauthorizedMissing, malformed, unknown or revoked Bearer key.
403post_not_editablePost or one of its targets has started publishing, or is no longer a draft/scheduled post.
403post_not_retryableNo eligible failed target for the chosen retry endpoint, or publishing/processing is still active.
403post_not_deletablePublishing has started or the post is no longer a draft/scheduled post.
404not_foundPost or target missing from this workspace or target does not belong to the URL post.
409notSupportedPlatform does not support remote deletion through Relay.
409inProgressAnother remote deletion currently holds the target lease.
409accountExpiredReconnect the account before remotely deleting its post.
422rejectedThe remote deletion could not be accepted.
422queue_no_slotsNo configured matching slot is available for all selected accounts within the scheduler search horizon.
422validation_failedInvalid fields or shared composer/media rules failed. issues includes schema paths/messages or shared issue codes.
422invalid_csvInvalid CSV UTF-8, header/file syntax or parser-level file limit. Details are in error.issues when available.
422invalid_jsonMalformed UTF-8 JSON request or multipart metadata.
422invalid_content_typeJSON endpoint called without Content-Type: application/json.
422invalid_multipartMultipart body could not be parsed.
422payload_too_largeRequest or downloaded file exceeds the limit.
422unsafe_media_urlURL or DNS answer is not permitted.
422media_redirectProvide the final URL instead of a redirect.
422media_fetch_failedDNS or HTTP download failed, or the remote server did not return success.
422media_timeoutURL lookup/download exceeded 15 seconds.
429rate_limitedRetry after the number of seconds in Retry-After.
500internal_errorUnexpected server or persistence failure. Raw database/provider exceptions are not exposed by the API handler.

Common shared issue codes include invalidAccount, invalidMedia, duplicateMedia, duplicateTargets, scheduleFuture, emptyPost, xCaption, xMedia, instagramMedia, instagramImageType, tiktokPrivacy, tiktokDurationUnknown, fileType and fileSize. src/server/post-validation.ts defines the complete shared set. For scheduled publishing, later provider errors appear in the target's error field.

Limits and URL fetching

  • JSON bodies: 256 KiB, enforced while reading even without Content-Length. CSV import bodies: 2,000,000 bytes; at most 500 data records per import. Imported media also has a 32,000,000-byte aggregate buffer limit per row.
  • REST uploads/imports: images up to 8 MiB; videos up to 25 MiB. Multipart envelopes allow an additional 64 KiB. These REST limits remain separate from the composer's larger multipart upload flow.
  • Media types: JPEG, PNG, WebP, MP4 and QuickTime. The shared store checks file signatures, not just MIME declarations. Individual platforms apply stricter formats and sizes.
  • Up to 35 combined mediaIds/mediaUrls and 20 expanded targets. Platform limits can be smaller. For an image sent to all four platforms, use JPEG under 5,000,000 bytes and a caption valid for X.
  • URL imports require HTTPS on port 443, public DNS hostnames and no embedded credentials. IP literals, localhost/internal names and private/special-use DNS answers are rejected, including mixed public/private answers. Redirects are rejected. Downloads send no caller authorization headers or cookies.
  • Each import has a 15-second timeout covering DNS and body consumption. Both IPv4 and IPv6 answers are screened via Cloudflare DNS-over-HTTPS. Production uses the normal Cloudflare Workers public-network fetch; do not replace it with a VPC/private-network fetch binding. DNS screening alone is not a DNS-rebinding defense outside this runtime.
  • Rate limiting is a cheap, bounded in-memory guard of 60 requests per key per 60-second window per Worker isolate, with 429 and Retry-After. It is best effort, resets with isolate restarts and is not a global billing quota. At most 2,048 key windows are retained. Use a distributed limiter before relying on strict customer-wide quotas.
  • last_used_at updates at most once per minute per key using a conditional D1 write. Authentication checks revocation on every request.

Carbon scheduled task

Configure RELAY_URL and RELAY_API_KEY securely in the task environment. This example uploads a prepared JPEG, discovers every active account in the workspace, then posts an Arabic caption to all of them. It requires curl and jq. The TikTok target explicitly requests private visibility (SELF_ONLY); use a visibility permitted by that creator and the intended task. The complete request is rejected if any target fails validation, so configure TikTok only when its integration and approvals are ready.

set -eu
: "${RELAY_URL:?Set the Relay base URL}"
: "${RELAY_API_KEY:?Set the workspace API key}"

media_id=$(curl --fail-with-body -sS "$RELAY_URL/api/v1/media" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -F 'file=@./nestphones-post.jpg;type=image/jpeg' \
  -F 'width=1080' -F 'height=1080' \
  -F 'altText=أحدث الهواتف الذكية من Nestphones' | jq -er '.id')

accounts=$(curl --fail-with-body -sS "$RELAY_URL/api/v1/accounts" \
  -H "Authorization: Bearer $RELAY_API_KEY")

targets=$(printf '%s' "$accounts" | jq -ce '
  [.accounts[] | select(.status == "active") |
    if .platform == "tiktok" then
      {socialAccountId: .id, options: {privacyLevel: "SELF_ONLY", mode: "direct"}}
    else {socialAccountId: .id} end
  ] | if length == 0 then error("No active Relay accounts") else . end')

payload=$(jq -cn --arg media "$media_id" --argjson targets "$targets" \
  --arg caption 'أحدث أخبار الهواتف من Nestphones. اكتشفوا التفاصيل على موقعنا.' \
  '{body: $caption, mediaIds: [$media], targets: $targets}')

result=$(curl --fail-with-body -sS "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' --data "$payload")
printf '%s\n' "$result"
# Persist .id in the task record and poll GET /api/v1/posts/:id for completion.

Omit publishAt when Carbon itself runs at the intended publishing time. Add a future timezone-qualified publishAt if Carbon should prepare the post ahead of time and let Relay schedule it.

Carbon polling and unknown outcomes

Carbon must never auto-retry an outcomeUnknown target, including by creating a new post or duplicate. Pause that task's retry path and check the actual account first. Neither status: "failed" nor a missing URL proves that the platform did not publish it. Ordinary /posts/:id/retry skips unknown outcomes; the single-target retry endpoint is an explicit action after account review, not an automatic fallback.

# post_id is the ID saved from the original creation response.
status=$(curl --fail-with-body -sS "$RELAY_URL/api/v1/posts/$post_id" \
  -H "Authorization: Bearer $RELAY_API_KEY")
if printf '%s' "$status" | jq -e 'any(.targets[]; .outcomeUnknown == true)' >/dev/null; then
  printf '%s\n' 'Publication outcome unknown: check the social account before any retry.' >&2
  exit 1
fi
# Continue recording/polling the result. Do not turn this check into a blanket retry loop.

Add to the posting queue from Carbon

Configure posting slots in Relay first. After obtaining media_id and targets above, use queue: true instead of publishAt. Carbon can prepare content at any time; Relay chooses its next matching slot in the workspace timezone.

payload=$(jq -cn --arg media "$media_id" --argjson targets "$targets" \
  --arg caption 'أحدث أخبار الهواتف من Nestphones.' \
  '{body: $caption, mediaIds: [$media], targets: $targets, queue: true}')

result=$(curl --fail-with-body -sS "$RELAY_URL/api/v1/posts" \
  -H "Authorization: Bearer $RELAY_API_KEY" \
  -H 'Content-Type: application/json' --data "$payload")
printf '%s\n' "$result" | jq '{id, status, scheduledAt}'
# Save id and scheduledAt in the task record. Poll status; do not resubmit on a warning.

Typical response: 201 {"id":"post-id","status":"scheduled","scheduledAt":"2027-01-15T18:00:00.000Z"}. 422 queue_no_slots means the task needs matching slots configured or freed; it should not fall back to publishing immediately. A paused workspace can still receive scheduled posts, but dispatch waits until it is resumed.