Authentication
4 scopesEvery request carries an API key as a bearer token. Keys are for servers. If you are connecting an assistant rather than writing code, use the MCP server instead, which signs in through your browser and needs no key at all.
Getting a key
Create one from your account settings. The full key is shown once, at the moment it is created, and cannot be retrieved afterwards because only its hash is stored. Losing it means creating another.
Authorization: Bearer ak_a1b2c3d4e5f6a7b8_<secret>
Keys look like ak_<id>_<secret>. The <id> half is public and identifies the key in your settings; the secret half is the part that must never reach a repository, a log or a browser.
Scopes
A key carries scopes. write, publish and media each imply read. write and publish are separate rather than nested: creating a draft needs write, and creating a post that will actually go out needs publish. Keys created from settings carry all four scopes; there is no way to narrow a key there yet.
| Scope | Allows |
|---|---|
| read | Read posts and connected accounts. |
| write | Create drafts, and delete or cancel posts. |
| publish | Create a post that will actually go out, now or on a schedule. Sufficient on its own. |
| media | Reserved for media endpoints. No endpoint checks it yet, so granting it changes nothing today. |
Creating posts
POST /v1/postsOne request creates one post per target account. Each is an independent post with its own schedule and its own outcome; they share only a campaign id.
curl https://antwork.io/api/v1/posts \
-H "Authorization: Bearer $ANTWORK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"accounts": ["acc_linkedin", "acc_x"],
"text": "Shipped the API today.",
"scheduledFor": "2026-10-01T09:00:00Z"
}'Request body
| Field | Meaning |
|---|---|
| accounts | Required. Account ids from GET /api/v1/accounts, at most 25. One post is created per account. |
| text | The post text, used for every account that has no entry in texts. |
| texts | Per-account text, keyed by account id. Overrides text for that account. Use it when one platform needs a shorter version. |
| media | Antwork media URLs, not arbitrary ones. A file has to be in your library before it can be attached; an external URL is refused with media_not_hosted rather than accepted and then failed at publish time. Uploading through the API is not available yet. |
| scheduledFor | ISO 8601 time to publish at, at most 30 days ahead. Omit it to publish immediately. |
| draft | true creates a draft and sends nothing, and needs only the write scope. Anything else creates a post that will go out, which needs publish. |
| options | Platform-specific settings, keyed by platform. See Platform options. |
| workspaceId | The workspace to post from. Required only when you own more than one workspace: without it the request is refused with workspace_required. Every account must belong to it. |
| campaignId | Groups the posts under one id. Set for you when more than one account is accepted; pass an existing id to add these posts to that group. |
platform_not_available, and the rest of the request goes ahead. GET /api/v1/accounts lists every connected account, so check platform before sending.The answer is 202 Accepted, never 200. Nothing publishes inside the request: a post is queued and a worker sends it, so the response tells you what was accepted rather than what was published.
{
"object": "post_batch",
"campaignId": "b3f1...",
"posts": [
{
"object": "post_result",
"id": "pQ7x...",
"accountId": "acc_linkedin",
"platform": "linkedin",
"status": "accepted",
"reason": null,
"message": null
},
{
"object": "post_result",
"id": null,
"accountId": "acc_x",
"platform": "x",
"status": "rejected",
"reason": "text_too_long",
"message": "@antwork: Text exceeds x character limit"
}
]
}What each target status means
accepted— written, and scheduled (or saved, for a draft). Poll the id to see what happened.rejected— refused before anything was written. Nothing exists; fixreasonand send again.failed— the post was created but could not be scheduled. It exists and will not go out.
202 can contain rejections. Read every entry in posts, not just the status code. Only when nothing at all was accepted is the answer a 400, and the array still ships with it.Scheduling
Omit scheduledFor to publish immediately. Supply an ISO 8601 time to schedule, at most 30 days ahead. The queue behind this will not accept a task further out, and the error names the latest time it will take. A time in the past is refused with scheduled_for_in_past.
Checking a post
GET /v1/posts/{id}The create call returns ids; this is where you learn what became of them. Poll rather than waiting on the create request, which returns long before the platform has answered.
{
"object": "post",
"id": "pQ7x...",
"workspaceId": "ws_...",
"accountId": "acc_linkedin",
"platform": "linkedin",
"campaignId": "b3f1...",
"status": "published",
"text": "Shipped the API today.",
"mediaUrls": [],
"scheduledFor": "2026-10-01T09:00:00.000Z",
"publishedAt": "2026-10-01T09:00:04.000Z",
"publishedUrl": "https://www.linkedin.com/feed/update/...",
"platformPostId": "urn:li:share:...",
"error": null,
"createdAt": "2026-09-22T10:00:00.000Z",
"updatedAt": "2026-10-01T09:00:04.000Z"
}Once it has gone out, publishedUrl and platformPostId are filled in. If it failed, error.message says what the platform said and error.advice says what to do about it.
A post that does not exist, belongs to someone else or has been deleted answers 404 with post_not_found. Needs the read scope.
Post statuses
draft— saved, not scheduled. It will not go out.scheduled— waiting for its time, or being sent right now.published— live on the platform.publishedUrlandplatformPostIdare set.failed— did not go out.errorsays why.
Listing posts
GET /api/v1/posts is the recovery path: the only other way to reach a post is an id the create call returned once. Newest first, across every workspace you own. Optional status filter (one of the four above), limit from 1 to 100 (default 25), and cursor from the previous page's nextCursor. A null nextCursor means there are no more.
curl "https://antwork.io/api/v1/posts?status=scheduled&limit=25" \
-H "Authorization: Bearer $ANTWORK_API_KEY"
{ "object": "list", "posts": [ ... ], "nextCursor": "MTc5MDA3..." }Listing accounts
GET /v1/accountsThe first call any integration makes, because creating a post takes account ids and there is no other way to learn them.
{
"object": "list",
"accounts": [
{
"object": "account",
"id": "acc_linkedin",
"workspaceId": "ws_...",
"platform": "linkedin",
"name": "Iker on LinkedIn",
"handle": "iker",
"accountType": "personal",
"active": true,
"tokenHealth": "healthy",
"connectedAt": "2026-01-05T09:00:00.000Z"
}
]
}Narrow with ?workspaceId= or ?platform=.
The list includes every connected account, including platforms the API cannot post to yet. Only LinkedIn and X (Twitter) accounts can be used in accounts when creating a post.
tokenHealth is worth reading before you post. A connection that needs reauthorising fails at publish time, which may be days after the request that scheduled it.Cancelling and deleting
DELETE /v1/posts/{id}DELETE /api/v1/posts/{id} cancels a scheduled post or removes a draft. A published post is soft-deleted so its engagement history survives, and disappears from every read on this API either way. Needs the write scope.
The post on the platform is left alone unless you ask. ?deleteFromPlatform=true also queues deletion of the live post, which is irreversible and visible to other people. Removing a post from Antwork and deleting it from LinkedIn are different intentions, so the API does not guess.
{
"object": "post_deleted",
"id": "pQ7x...",
"softDeleted": true,
"platformDeletionQueuedFor": ["linkedin"]
}softDeleted: true in the response means the document survives for analytics, not that the post is still visible. platformDeletionQueuedFor lists the platforms a deletion was queued for, which happens in the background like publishing does.Platform options
Anything platform-specific goes in options, keyed by platform and applied to every account of that platform in the request. Only the platforms listed here accept options through the API.
| Platform | Accepts |
|---|---|
| accepts no options | |
| x | communityId, shareWithFollowers |
{
"accounts": ["acc_x"],
"text": "Shipped the API today.",
"options": {
"x": { "communityId": "1493446837214", "shareWithFollowers": true }
}
}400 naming the field, not a post that quietly goes out without the setting you asked for.Idempotency
Idempotency-KeySend an Idempotency-Key header on every create. Without one, a network timeout followed by the retry your HTTP client makes on its own produces a second post.
The key is remembered for 24 hours, scoped to your account and to the exact request body.
- Same key, same body, already finished — the original response is returned again and nothing new is created.
- Same key while the first request is still running —
409. Retry in a moment. - Same key, different body —
422. Returning the first answer for a second request would be worse than refusing.
Rate limits
10,000/dayEach key allows 10,000 requests a day and 120 a minute. The ceilings exist to catch a runaway loop, not to ration normal use. If you are near either one, get in touch rather than working around it.
X-RateLimit-Limit: 10000 X-RateLimit-Remaining: 9812 X-RateLimit-Reset: 1790035200 Retry-After: 37
Every response carries the current budget, not just the ones that are refused, so you never have to exhaust the limit to discover it. X-RateLimit-Reset is a Unix timestamp; Retry-After appears only on a 429 and is in seconds.
Errors
Every failure has the same shape. type is the class to branch on, code is the stable identifier for the specific problem, and param names the offending field where there is one. Treat message as copy for a human reading a log; it can change.
{
"error": {
"type": "invalid_request_error",
"code": "scheduled_for_too_far",
"message": "Posts can be scheduled at most 30 days ahead. The latest accepted time is 2026-10-22T10:00:00.000Z.",
"param": "scheduledFor"
}
}| Type | Means |
|---|---|
| authentication_error | The key is missing, malformed, unknown or revoked. Always 401; code says which. |
| permission_error | The key is valid but lacks the scope, or the account behind it has no active subscription. 403. |
| invalid_request_error | Something about the request is wrong. 400, or 404 for a post that does not exist on your account. |
| rate_limit_error | A ceiling was reached. 429, with Retry-After. |
| api_error | Something broke on our side. 500. Safe to retry with the same idempotency key. |
Branch on code, never on message. The codes are part of the contract; the sentences are not.
Error codes
| Code | Status and meaning |
|---|---|
| api_key_missing | 401 — no Authorization: Bearer header. |
| api_key_malformed | 401 — the value is not shaped like an Antwork key. |
| api_key_unknown | 401 — no key with this id and secret exists. |
| api_key_revoked | 401 — the key was revoked in settings. |
| insufficient_scope | 403 — the key lacks the scope this call needs. |
| subscription_inactive | 403 — the account behind the key has no active subscription. |
| invalid_body | 400 — the body is not a JSON object. |
| accounts_required | 400 — accounts is missing or empty. |
| too_many_accounts | 400 — more accounts than one request allows. |
| invalid_text | 400 — text is not a string. |
| invalid_texts | 400 — texts is not an object of strings keyed by account id. |
| invalid_media | 400 — media is not an array of strings. |
| media_not_hosted | 400 — a media URL is not in your Antwork library. |
| invalid_scheduled_for | 400 — scheduledFor is not a valid ISO 8601 time. |
| scheduled_for_in_past | 400 — scheduledFor is in the past. |
| scheduled_for_too_far | 400 — scheduledFor is beyond the scheduling horizon. |
| workspace_required | 400 — you own several workspaces and sent no workspaceId. |
| workspace_not_found | 400 — workspaceId is not a workspace you own. |
| invalid_option | 400 — an options key is unknown, mistyped or for a platform not in the request. |
| no_targets_accepted | 400 — no account was accepted and none gave a more specific reason. |
| invalid_status | 400 — the status filter is not one of the four post statuses. |
| invalid_limit | 400 — limit is not a whole number in range. |
| invalid_cursor | 400 — cursor did not come from this API. |
| post_not_found | 404 — no such post on your account. |
| idempotency_in_flight | 409 — a request with this Idempotency-Key is still running. |
| idempotency_key_reused | 422 — this Idempotency-Key was used for a different body. |
| rate_limit_burst | 429 — over the per-minute limit. |
| rate_limit_daily | 429 — over the daily limit. |
| internal_error | 500 — something broke on our side. |
When no account in a create request is accepted, the answer is a 400 whose code is the first account's rejection reason (see the next table), with the full posts array beside it.
Why an account was rejected
The reason on a rejected or failed entry in a create response.
| Code | Status and meaning |
|---|---|
| account_unresolved | The id is not a connected account in this workspace. |
| platform_not_available | The account is on a platform the API does not publish to yet. |
| text_empty | No text for this account, and the platform needs some. |
| text_too_long | The text is over this platform's character limit. |
| media_required | The platform needs an image or video and none was attached. |
| unsupported_media | The platform does not accept one of the attached file types. |
| video_too_long | The video is longer than the platform allows. |
| token_invalid | The account's connection has expired. Reconnect it in Antwork. |
| token_refresh_failed | The connection could not be refreshed. Reconnect it in Antwork. |
| quota_exceeded | Your plan's post allowance for this cycle is used up. |
| schedule_failed | The post was created but could not be queued (failed). It exists and will not go out. |