Authentication
Every request is authenticated with an API key in the standard Authorization header. A key belongs to exactly one workspace, and every call is scoped to that workspace.
Authorization: Bearer pm_live_<your-key>Base URL: https://postclay.com/api. Missing key → 401 MISSING_API_KEY; unknown or revoked → 401 INVALID_API_KEY.
Create an API key
Create a key from the dashboard, or via the API. The secret is shown once — store it now.
POST https://postclay.com/api/api-keys
Authorization: Bearer <session>
Content-Type: application/json
{ "name": "My integration" }
# 201 Created — the raw key appears only in this response
{ "key": "pm_live_ab12cd34…" }GET /api-keys lists keys (never the secret): each has id, name, prefix, lastUsedAt, revokedAt, createdAt, createdByName (name, or failing that email, of whoever minted the key; null for keys minted before this field existed, or by an API-key caller). DELETE /api-keys/:id revokes a key and returns 204.
List accounts
Returns the social accounts connected to your workspace — this is how you obtain a real socialAccountId for create's targets below, in place of a placeholder string.
GET https://postclay.com/api/accounts
200 OK
[
{ "id": "account-uuid", "platform": "X", "handle": "@yourbrand",
"status": "ACTIVE", "isMock": false }
]Disconnected accounts are excluded by default; pass ?includeDisconnected=true to include them.
Upload media
Multipart upload into the workspace media library. Returns a MediaAsset whose id can be passed to create_post's mediaIds.
POST https://postclay.com/api/media
Content-Type: multipart/form-data
[email protected]
# 201 Created — { "id": "media-uuid", "kind": "IMAGE", ... }REST accepts images and video up to 150MB. MCP's upload_media tool is a separate, lower ceiling — 20MB decoded — because MCP has no multipart channel and carries bytes as base64 inside the JSON-RPC request body; an agent uploading a larger file should have a human (or a REST-capable step) use this endpoint instead.
Create a post
Creates a post in DRAFT. targets is required — one entry per connected account you want to publish to (see List accounts for a real socialAccountId). A scheduledAt passed here is ignored and discarded, not stored — every post is created with no schedule; call schedule afterwards to actually set a time.
POST https://postclay.com/api/posts
{
"content": "Hello from the Postclay API",
"kind": "SINGLE", // SINGLE | CAROUSEL | THREAD
"mediaIds": ["media-uuid"], // optional, ordered, ≤ 50
"labels": ["launch"], // optional
"targets": [
{ "socialAccountId": "account-uuid", // from GET /accounts, see above
"contentOverride": null, // null = inherit "content"
"mediaIds": null } // null = inherit; [] = no media
]
}Response — the created post (truncated):
201 Created
{
"id": "post-uuid",
"status": "DRAFT",
"kind": "SINGLE",
"content": "Hello from the Postclay API",
"mediaIds": ["media-uuid"],
"targets": [
{ "id": "target-uuid", "socialAccountId": "account-uuid",
"platform": "X", "status": "PENDING", "attempts": 0,
"externalPostId": null, "permalink": null }
],
"createdAt": "2026-08-20T09:00:00.000Z"
}curl
curl https://postclay.com/api/posts \
-H "Authorization: Bearer pm_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"content": "Hello from the Postclay API",
"kind": "SINGLE",
"targets": [{ "socialAccountId": "account-uuid" }]
}'Schedule a post
Moves a draft to SCHEDULED and enqueues each target. Both fields are required. scheduledAt is absolute when it carries a Z or offset, otherwise it is wall-clock time in timezone.
POST https://postclay.com/api/posts/:id/schedule
{ "scheduledAt": "2026-08-20T18:30:00Z", "timezone": "Asia/Riyadh" }
# 200 OK — the full post, status now "SCHEDULED"List posts
Returns a paginated envelope. Filter by status (repeatable — pass a comma-separated list to match any of several statuses in one request), free-text search, label, or date range.
GET https://postclay.com/api/posts?status=scheduled,failed&q=launch&page=1&pageSize=20
# status: scheduled | published | draft | pending | failed
# | publishing | partially_failed | cancelled
# (comma-separated = match any, up to 8)
# q: free text across content, first comment, thread segments,
# and per-target content overrides
# label: string from,to: ISO-8601 pageSize: 1–100 (default 20)
{ "data": [ /* posts */ ], "total": 42, "page": 1, "pageSize": 20 }Delete a post
Cancels any pending targets and deletes the post.
DELETE https://postclay.com/api/posts/:id
# 204 No ContentIdempotency
Postclay does not accept a client Idempotency-Key header today — treat POST /posts as create-once and store the returned id. Retries are safe downstream: each target carries an internal, server-generated idempotency value so the publish workers never double-post to a platform on retry. (A client-facing idempotency key is on the roadmap; this note will change when it ships.)
Webhooks
Subscribe a URL to publish events. Two events fire: POST_PUBLISHED and POST_FAILED.
POST https://postclay.com/api/webhooks
{ "url": "https://you.example/hook",
"events": ["POST_PUBLISHED", "POST_FAILED"] }
# 201 — response includes "secret" once (used to verify signatures)Delivered payloads:
// POST_PUBLISHED
{ "event": "POST_PUBLISHED", "occurredAt": "2026-08-20T12:00:00.000Z",
"data": { "postId": "…", "targetId": "…", "platform": "X",
"externalId": "…", "permalink": "https://…" } }
// POST_FAILED
{ "event": "POST_FAILED", "occurredAt": "2026-08-20T12:00:00.000Z",
"data": { "postId": "…", "targetId": "…", "platform": "X",
"error": "…" } }Every delivery is signed. Verify the HMAC before trusting a payload:
Content-Type: application/json
X-Postclay-Signature: sha256=<hex hmac of the raw body, keyed by your secret>
X-Postclay-Event: POST_PUBLISHED
X-Postclay-Delivery: <delivery-uuid>
# verify (Node):
const mac = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const ok = "sha256=" + mac === req.headers["x-postclay-signature"];Deliveries retry up to 5 times with exponential backoff (10s per-request timeout); internal/loopback URLs are refused. POST /webhooks/:id/test sends a test delivery (202); POST /webhooks/:id/rotate-secret issues a new secret.
Rate limits & quotas
There are no per-plan API rate limits. Two real limits apply:
Per-IP limits guard unauthenticated and AI endpoints (e.g. signup, login, caption/hashtag generation). Two authenticated routes carry their own per-workspace budgets instead: POST /webhooks (30/hour), POST /webhooks/:id/test (10/hour), and POST /mcp (120/hour, covering every MCP tool call over that connection). The authenticated /posts, /media and /api-keys endpoints remain unthrottled. On exhaustion you get:
429 { "error": { "code": "RATE_LIMITED", "messageKey": "errors.rateLimited" } }Per-plan quotas cap connected accounts, team seats, and media storage (posts are unlimited on every tier):
| Plan | Accounts | Seats | Workspaces | Storage | Posts | AI captions | AI images | AI videos |
|---|---|---|---|---|---|---|---|---|
| Trial | 25 | 10 | 1 | 1GB | Unlimited | 500 | 100 | 10 |
| Builder | 8 | 3 | 1 | 5GB | Unlimited | 100 | 20 | 3 |
| Team | 25 | 10 | 1 | 20GB | Unlimited | 500 | 100 | 10 |
| Scale | Unlimited | Unlimited | 1 | 50GB | Unlimited | Unlimited | 500 | 50 |
Storage is the one quota that is never unlimited, even on Scale — disk is a real, finite host cost.
A “connected account” is one social identity linked to your workspace (a specific X handle, Instagram business account, Facebook page, and so on). Reconnecting the same identity reuses its slot.
MCP — connect your agent
Postclay is an MCP server, so any MCP client can create, schedule, list and cancel posts as a first-class user. The endpoint is stateless Streamable-HTTP and takes the same API key:
POST https://postclay.com/api/mcp
Authorization: Bearer pm_live_<your-key>Claude Code
Add Postclay to your MCP config:
{
"mcpServers": {
"postclay": {
"url": "https://postclay.com/api/mcp",
"headers": { "Authorization": "Bearer pm_live_<your-key>" }
}
}
}Cursor
Add the same server block to Cursor’s MCP settings (~/.cursor/mcp.json or Settings → MCP). The url and Authorization header are identical to the snippet above.
Tools
The server exposes twelve tools. Platforms never run in a mock-publish mode — an unconfigured platform is simply not connectable; check configured from list_platforms before targeting one.
list_platforms()
list_accounts()
create_post({ content, kind: "SINGLE"|"CAROUSEL"|"THREAD", segments?,
targets: [{ socialAccountId, contentOverride?, mediaIds? }],
mediaIds?, timezone?, recurrence? })
// recurrence: { freq, interval, byDay?, timeOfDay, until?, count? }
schedule_post({ postId, scheduledAt, timezone })
list_posts({ status?, q?, from?, to?, page?, pageSize? })
// status: scheduled | published | draft | pending | failed
// | publishing | partially_failed | cancelled
get_post({ postId })
upload_media({ filename, mimeType, dataBase64 })
// 20MB decoded max — larger files go through REST POST /media instead
cancel_post({ postId })
search_media({ query?, kind?: "IMAGE"|"VIDEO", page? })
get_next_slots({ count? })
// the queue's own next free slots — use instead of inventing scheduledAt
update_post({ postId, content?, kind?, segments?, labels?,
firstComment?, mediaIds? })
get_analytics({ postId? })
// workspace overview, or one post's per-target metrics with postIdA ready-made skill file is published at postclay.com/skill.md — point OpenClaw / Hermes-style agents at it and they pick up these tools natively.
Questions? [email protected] · Back to home