REST API
Errors
One shape, specific codes, and values you can act on.
The shape
Failures come back as an error object with a code and an English message. Anything the message interpolated is also sent as its own field, so you can rebuild the sentence in your own language.
We do not accept an Idempotency-Key header yet. Duplicate publishes are caught by content fingerprint instead: the same text and files to the same account within ten minutes is refused with duplicate_post. Pass force: true when you mean it.
Several violations come back together
When more than one rule is broken, error.errors carries all of them and the first one is also spread into error itself. Fix them in one pass instead of learning about the next one after each retry. Two exceptions come alone because everything else depends on them: no_accounts and empty_content.
{
"error": {
"code": "content_too_long",
"message": "Threads allows 500 characters. This is 620.",
"platform": "threads",
"limit": 500,
"actual": 620
}
}Codes
| Name | Type | Description |
|---|---|---|
verification_required | 401 | Email and phone verification are not finished. |
invalid_api_key | 401 | The bearer token is not a valid key. |
forbidden_scope | 403 | The key lacks the scope. Sends scope. |
no_accounts | 422 | accountIds was empty. Sends candidates, and next when nothing is connected. Sends needsReconnect and a reconnect hint when the channels you have are expired rather than missing. |
empty_content | 422 | Neither text nor media was given. |
account_not_connected | 404 | One of the accountIds is not connected. Sends candidates. |
account_expired | 422 | The channel token expired. Sends platform. |
platform_not_available | 422 | That channel is not live yet. Sends platform. |
content_too_long | 422 | Sends platform, limit and actual. |
media_required | 422 | That channel cannot post text alone. Sends platform. |
media_mixed | 422 | Images and video in one post. Sends platform. |
media_too_many | 422 | Sends platform, limit and actual. |
media_too_large | 422 | Sends limit, limitMb and actual. |
media_aspect_ratio | 422 | Sends limit and actual. |
media_dimensions | 422 | Video wider than the limit. Sends limit and actual. Image width is never a reason. |
media_type_not_supported | 422 | Sends types. |
media_too_long | 422 | Video longer than the limit. Sends limit and actual. |
media_url_invalid | 422 | Not an https URL. |
media_url_blocked | 422 | That address is private, loopback or link-local. |
media_url_unreachable | 422 | The host did not answer, or redirected too many times. |
workspace_required | 400 | This account has more than one workspace and the request did not say which. Sends workspaces, each with id, name, slug and channels, so you can match what the person said without extra calls. Pass the x-uplika-workspace header, or the workspaceId argument on MCP tools. |
workspace_mismatch | 404 | The post, channel, or media is in a different workspace on the same account. Sends workspaces. Retry there instead of connecting the account again. |
media_not_uploaded | 404 | media_complete was never called. |
media_expired | 404 | The upload was reclaimed. Upload the file again. |
media_not_configured | 503 | Media storage is not set up. |
post_not_found | 404 | No such publish in this workspace. |
media_not_found | 404 | No such media in this workspace. |
key_not_found | 404 | No such API key. |
grant_not_found | 404 | No such authorization. |
nothing_to_retry | 422 | Every target already succeeded. |
not_published | 422 | The post has not gone out yet. |
platform_error | 502 | The channel refused or did not answer. |
duplicate_post | 409 | The same content went to this account within 10 minutes. Sends postId. Pass force: true to publish anyway. |
unauthenticated | 401 | No session and no valid key. |
platform_rejected_content | 400 · 404 · 422 · 502 | The channel refused the content itself. Retrying will not help. Read paths pass the upstream status through; the publish path reports it as 502. |
platform_auth_error | 502 | The channel refused our access to that account. Reconnect it. Kept at 502 so it is never confused with a problem with your uplika key. |
topic_tag_invalid | 422 | The topic tag has a period or an ampersand in it. Threads does not allow those. |
topic_tag_not_supported | 422 | One of the channels you picked does not take a topic tag. Sends platform. Publish to it separately or drop the tag. |
api_key_revoked | 401 | That key was revoked. Create a new one. Kept separate from invalid_api_key so you do not go looking for a typo. |
api_key_expired | 401 | That key passed its expiry. Create a new one. |
idempotency_key_reused | 422 | That Idempotency-Key was already used with a different body. Use a new key for a new request. |
publish_in_flight | 409 | That post is still going out. Deleting mid-flight would let it publish after you deleted it. Wait a few seconds and try again. |
thread_and_content | 422 | You passed both content and threadItems. Pass one. |
thread_empty | 422 | threadItems was an empty array. |
container_expired | 502 | The upload container expired before the post went out. Create the post again. |
permission_denied | 502 | The channel refused for permission reasons. |
internal_error | 500 | Our fault. The response carries no detail; the request id is in the logs. |
rate_limited | 429 | Over the per-minute limit. Sends limit and retryAfter, and the response carries Retry-After. |
account_not_found | 404 | No such connected account in this workspace. |
empty_reply | 422 | The reply had no text. |
media_fields_required | 422 | presign needs fileName, contentType and bytes. |
not_our_post | 422 | That post was imported from the channel, not published through us. |
key_name_required | 422 | Give the key a name. |
invalid_locale | 422 | locale must be en or ko. |
bad_post_ref | 422 | The post reference was not an id, a media id or a post link. |
post_not_owned | 422 | That post belongs to an account this workspace has not connected. |
post_not_in_recent | 422 | That post was not in the recent window we searched. |
upload_session_not_found | 404 | That upload link is gone or expired. |
upload_session_full | 422 | That upload link already has its maximum files. |
media_url_invalid | 422 | Not an https URL. |
media_url_blocked | 422 | That address is private, loopback or link-local. |
media_url_unreachable | 422 | The host did not answer, or redirected too many times. |
workspace_required | 400 | This account has more than one workspace and the request did not say which. Sends workspaces, each with id, name, slug and channels, so you can match what the person said without extra calls. Pass the x-uplika-workspace header, or the workspaceId argument on MCP tools. |
workspace_mismatch | 404 | The post, channel, or media is in a different workspace on the same account. Sends workspaces. Retry there instead of connecting the account again. |
youtube_title_required | 422 | A YouTube target needs options.youtube.title. |
youtube_title_too_long | 422 | The title is over 100 characters. Sends limit and actual. |
youtube_angle_brackets | 422 | YouTube refuses < and > in the title and the description. |
youtube_video_required | 422 | YouTube takes exactly one video. Sends limit and actual. |
youtube_images_not_supported | 422 | YouTube does not take images in a post. Use options.youtube.thumbnailMediaId instead. Sends actual. |
youtube_tags_too_long | 422 | Tags are over 500 characters in total. A tag containing a space costs two extra for the quotes YouTube adds. Sends limit and actual. |
youtube_schedule_needs_private | 422 | options.youtube.publishAt only works while privacyStatus is private. |
youtube_schedule_invalid | 422 | options.youtube.publishAt was not an ISO 8601 timestamp. |
youtube_thumbnail_type | 422 | A thumbnail must be image/jpeg or image/png. |
youtube_thumbnail_too_large | 422 | A thumbnail can be up to 2MB. Sends limit and actual. |
youtube_short_no_thumbnail | 422 | A vertical video of three minutes or less publishes as a Short, and Shorts do not take custom thumbnails. Drop the thumbnail or change the video. |
youtube_no_threads | 422 | threadItems with a YouTube account would upload one video per item. Publish the video on its own. |
beta_access_required | 403 | That channel is in beta and this workspace has not been approved for it yet. Request access from the dashboard. Retrying will not help. |
How do I see what my key actually sent?
Every REST and MCP call is logged with its request and response body. This is the fastest way to find out why a publish was refused, because the channel's own words are in there.
GET/v1/logs
Recent API and agent calls, newest first, with bodies.
Paging
| Name | Type | Description |
|---|---|---|
requestId | query | One call by the id an error response gave you. |
outcome | query | failed for 4xx and 5xx, ok for the rest. Omit for everything. |
search | query | Matches the path or the request id. |
since | query | YYYY-MM-DD. Calls from that day onward. |
limit | query | 1-200, defaults to 50. |
before | query | A log id from a previous page's nextBefore. |
Common questions
What shape does an error come back in?
An error object with a code and an English message. Whatever the message interpolated is also sent as its own field, so you can rebuild the sentence in your own language instead of parsing ours.
Why are the messages English only?
Because a response that changed language would answer the same request differently depending on who asked. The screen is bilingual; the API is not. Render from code, not from message.
What should I do when I get rate_limited?
Wait and call again. The response carries the limit that was hit, so a client can pace itself rather than guessing at a backoff.