// public api · v1 · + mcp server
CoPost API Reference
Everything an integrator or agent needs to manage brand spaces, schedule posts across platforms, and move media in and out of CoPost — over plain REST, or as tools inside an MCP client. Both surfaces are backed by the same service layer and return the same shapes.
Authentication
Every /v1 request — and every MCP call — is authenticated with an API key tied to your CoPost account. There is no session or cookie auth on this surface.
curl https://api.copost.io/v1/me \
-H "x-api-key: YOUR_API_KEY"
The key is sent as a raw header value, hashed with SHA-256, and matched against a stored key record — the plaintext key is never persisted server-side, so treat it like a password: it isn't recoverable if lost, only reissuable.
| Header | Required | Notes |
|---|---|---|
| x-api-key | required | Your CoPost API key. Missing or unrecognized keys get a 401. |
/brand-spaces/:zoneId/...) additionally checks that the authenticated user owns or is a member of that brand space, independent of the API key check. A valid key for a different brand space returns 403, not the resource.Conventions
A handful of shapes repeat across the whole surface.
Response envelope
Every response, success or failure, is a JSON object with a success boolean.
{
"success": true,
"data": { "...": "..." }
}
{
"success": false,
"message": "caption is required"
}
Pagination
List endpoints that can grow unbounded accept page and limit query params and wrap their array in a paging envelope instead of nesting it under plain data:
{
"data": [ "...": "..." ],
"page": 1,
"limit": 20,
"total": 57,
"hasMore": true
}
| Param | Type | Default | Notes |
|---|---|---|---|
| page | integer | 1 | 1-indexed. |
| limit | integer | 20 | Clamped to 1–100. |
GET .../post-slot-collections returns the full array under data with no paging envelope, since a brand space typically has a handful of slots.Status codes
| Code | Meaning |
|---|---|
| 200 / 201 | OK / created. |
| 400 | Malformed or invalid request body/query. |
| 401 | Missing, invalid, or revoked API key. |
| 402 | Insufficient AI credits (image generation only). |
| 403 | Authenticated, but not a member of the requested brand space, or the current plan doesn't allow the action. |
| 404 | Brand space or post not found. |
| 500 / 502 / 504 | Unexpected server error, upstream generation failure, or generation timeout. |
Account
A single endpoint to identify the caller and see which brand spaces they can reach.
Returns the authenticated user's profile and the brand spaces they own or belong to. Use this to resolve a zoneId before calling any brand-space-scoped route.
{
"success": true,
"data": {
"userId": "65b02c4a57e8884025d342c6",
"userEmail": "[email protected]",
"fullName": "Karl Fountain",
"userName": "karlf",
"subscriptionType": "teams",
"zonesOwned": [
{ "zoneId": "20b63826-aa94-4b63-839a-f99c4d23bceb", "name": "CoPost", "asUser": false, "logo": null, "deleted": false }
],
"zonesMemberOf": []
}
}
Brand Spaces
A brand space (internally a zone) is the workspace that holds connected social accounts, labels, post slots, and posts. Almost every other route hangs off a zoneId.
Lists the brand spaces you own or are a member of. Returns a trimmed projection — call the single-space endpoint for connected accounts and settings.
| Query | Type | |
|---|---|---|
| page | integer | optional |
| limit | integer | optional |
{
"data": [
{
"zoneId": "20b63826-aa94-4b63-839a-f99c4d23bceb",
"name": "CoPost",
"owner": { "userId": "65b02c4a57e8884025d342c6", "fullName": "Karl Fountain" },
"subscriptionType": "teams",
"createdAt": 1735689600000,
"lastUpdated": 1787890549122
}
],
"page": 1, "limit": 20, "total": 1, "hasMore": false
}
Creates a new brand space with the caller as owner. Requires a Teams-tier subscription or above — other plans get a 403.
| Field | Type | Notes | |
|---|---|---|---|
| name | string | required | Trimmed; can't be empty. |
| logo | string | optional | Public image URL, or a Google Drive share link. Fetched and re-hosted server-side; failures are swallowed — the space is still created without a logo. |
| approvalWorkflow | boolean | optional | Only takes effect if your plan includes approval workflows. |
| shortLinks | boolean | optional | Defaults to true. |
| invites | array | optional | { emailAddress, role, canApprove }[] — role is one of co_owner / editor / client / viewer. Sent as invite emails after creation. |
{
"name": "Acme Outdoors",
"logo": "https://cdn.acme.com/logo.png",
"invites": [
{ "emailAddress": "[email protected]", "role": "editor", "canApprove": false }
]
}
{
"success": true,
"data": {
"zoneId": "8e2c...",
"name": "Acme Outdoors",
"owner": { "userId": "65b0...", "fullName": "Karl Fountain" },
"socials": [],
"settings": { "approvalWorkflow": false, "shortLinks": true, "...": "..." }
}
}
Returns the full brand space document, including connected social accounts (socials) and their user ids — the values you'll pass as socialUserId when creating a post. See Brand Space for the full shape.
{
"success": true,
"data": {
"zoneId": "20b63826-aa94-4b63-839a-f99c4d23bceb",
"name": "CoPost",
"socials": [
{
"id": "instagram",
"name": "Instagram",
"isActive": true,
"user": { "id": "17841462393533535", "username": "copost.io", "name": "CoPost" }
}
],
"settings": { "approvalWorkflow": true, "shortLinks": true }
}
}
Posts
Posts are the core resource: one piece of content, optionally split into per-platform variants, targeted at one or more connected accounts. Both list and read return the Post shape — a shared content block plus a platforms array, not the raw internal document.
| Param | Type | Notes | |
|---|---|---|---|
| page | integer | optional | Default 1. |
| limit | integer | optional | Default 20, max 100. |
| status | string | optional | One post status, e.g. Scheduled, Published, Draft. |
| from | timestamp | optional | Unix ms. Filters by scheduledDate >= from. |
| to | timestamp | optional | Unix ms. Filters by scheduledDate <= to. |
| labelId | string | optional | Restrict to one label. |
Sorted by scheduledDate descending — except status=Draft, which sorts by createdAt (drafts have no scheduled date yet).
{
"data": [ { "key": "f1527cc9-...", "content": { "...": "see Post model" }, "platforms": [ "..." ] } ],
"page": 1, "limit": 1, "total": 12, "hasMore": true
}
Creates a post. There are two ways to route it to platforms — use one or the other, not both:
- slotId — drop it into a post slot collection's next open time, inheriting that slot's default accounts and options. Simplest path;
platforms/postTypeare ignored if set. - platforms — target specific connected accounts yourself and set per-platform options, alongside an explicit
scheduledDateor aDraftstatus.
| Field | Type | Notes | |
|---|---|---|---|
| caption | string | required | The post copy shared by every non-variant platform. |
| title | string | optional | Internal label; also used as the Pinterest pin title and YouTube video title. Defaults to a truncated caption. |
| labelId | string | optional | Ignored alongside slotId — the slot's own default label wins unless overridden here. |
| slotId | string | optional | See above. Overrides any scheduledDate. |
| scheduledDate | timestamp | optional | Unix ms. Ignored if slotId is set. |
| status | string | optional | One of Draft, Scheduled Draft, Scheduled, Scheduled for Approval. Defaults to Scheduled when scheduledDate is given, the slot's own inference when slotId is given, or Draft otherwise. Non-Draft requires scheduledDate or slotId. |
| postType | string | optional | One of FEED, REEL, STORY, POLL — applied uniformly across platforms, mapped to each platform's own equivalent. Defaults to FEED. Ignored if slotId is set. |
| platforms | array | optional | See platform input below. Ignored if slotId is set. |
| objectKeys | string[] | optional | Up to 10 objectKeys of already-uploaded media. Takes priority over imageUrls/videoUrl. |
| imageUrls | string[] | optional | Up to 10 public image URLs, fetched and uploaded server-side. |
| videoUrl | string | optional | Public video URL (Google Drive share links resolved automatically). Takes priority over imageUrls. |
| pinterestUrl | string | optional | Destination link for the Pinterest pin. |
platforms[] entry
| Field | Type | Notes | |
|---|---|---|---|
| social | enum | required | facebook / instagram / twitter / youtube / linkedin / tiktok / pinterest / bluesky / mastodon / threads / gmb / reddit |
| socialUserId | string | optional | Which connected account to post as (socials[].user.id from get brand space). Required only when more than one account of that platform is connected. |
| instagramOptions | object | optional | postMediaType, collaborators[], plus reel-only reelShareToFeed / isTrialReel / trialGraduationStrategy (discarded unless postType is REEL). |
| tiktokOptions | object | optional | privacy, allowComments, allowDuet, allowStitch, discloseVideoContent, brand_content_toggle, brand_organic_toggle, auto_add_music. |
| youtubeOptions | object | optional | category, visibility, license, notifySubscribers, allowEmbedding, madeForKids, tags[]. |
| twitterOptions | object | optional | replySettings; poll: { options[], durationMinutes } — only applied when top-level postType is POLL. |
| linkedinOptions | object | optional | poll: { question, options[], pollDuration, multipleChoice, voterVisibleToAuthor } — only applied when postType is POLL. |
| pinterestOptions | object | optional | { boardId }. |
{
"caption": "New trailheads just opened south of the ridge 🏔️",
"scheduledDate": "2026-09-04 09:00:00",
"objectKeys": ["8e2c.../image/91a4..."],
"platforms": [
{ "social": "instagram", "instagramOptions": { "postMediaType": "photo" } },
{ "social": "facebook" }
]
}
{
"caption": "New trailheads just opened south of the ridge 🏔️",
"slotId": "9aa5b1e3-715a-4dd9-baa9-f8357e26bf76",
"imageUrls": ["https://cdn.acme.com/trailhead.jpg"]
}
{
"success": true,
"data": {
"key": "f1527cc9-d0ae-4ec9-8d27-4fe633193ccd",
"status": "Scheduled",
"scheduledDate": 1788080400000,
"content": {
"text": "New trailheads just opened south of the ridge 🏔️",
"media": [ { "objectKey": "8e2c.../image/91a4...", "signedUrl": "https://....s3.amazonaws.com/...&Signature=..." } ],
"mentions": []
},
"platforms": [
{ "platform": "instagram", "socialUserId": "17841462393533535", "user": { "...": "..." }, "isVariant": false, "options": { "publishType": "post", "postMediaType": "photo" } },
{ "platform": "facebook", "socialUserId": "119860494542512", "user": { "...": "..." }, "isVariant": false, "options": { "publishType": "timeline" } }
]
}
}
"No connected" or "Multiple " means a platforms[].social/socialUserId problem (missing account, or an ambiguous one that needs socialUserId). Everything else is a validation error.Fetches one post by its key. 404 if it doesn't exist or belongs to a different brand space.
Media
Post responses only ever embed { objectKey, signedUrl } for images and video — enough to render them, not enough to bloat a list response. Resolve the rest on demand here.
| Param | Type | Notes | |
|---|---|---|---|
| objectKeys | string | required | Comma-separated objectKeys, up to 25. Keys from other brand spaces are silently dropped, not errored. |
{
"success": true,
"data": [
{
"objectKey": "20b63826-.../image/669dc810-...",
"signedUrl": "https://....s3.amazonaws.com/...&Signature=...",
"originalFileName": "Your_Voice,_Amplified",
"extension": "webp",
"resolution": "928 x 1152",
"width": 928, "height": 1152, "pixels": 1069056,
"size": 119622,
"type": "image/webp",
"endpoint": "image",
"thumbSmallObjectKey": "20b63826-.../image/669dc810-...-thumb-small",
"thumbMediumObjectKey": "20b63826-.../image/669dc810-...-thumb-medium",
"tags": [], "labels": [], "altText": "", "caption": "",
"createdAt": 1787890549106
}
]
}
Signed URLs are presigned S3 GETs, valid for ~15 minutes — re-call this endpoint rather than caching them.
Uploads a file directly. multipart/form-data with a single field named file.
| Constraint | Value |
|---|---|
| Allowed types | JPEG, PNG, GIF, WEBP, MP4, MOV, AVI, WEBM, MPEG |
| Max size | 50 MB images · 500 MB video |
curl -X POST https://api.copost.io/v1/brand-spaces/20b6.../media \
-H "x-api-key: YOUR_API_KEY" \
-F "file=@./trailhead.jpg"
signedUrl — call GET .../media with the returned objectKey to get a viewable link.Generates an on-brand image from a text prompt and adds it to the media library.
| Field | Type | Notes | |
|---|---|---|---|
| prompt | string | required | |
| model | enum | required |
google/gemini-2.5-flash-image (20 credits, image input) ·
google/gemini-3-pro-image-preview (30 credits, image input) ·
openai/gpt-image-1 (35 credits) ·
openai/gpt-image-1.5 (35 credits)
|
| orientation | enum | required | landscape / portrait / square |
| brandFace | boolean | optional | Includes the brand's configured face reference image. Only the two Gemini models support this — others return 400. |
| useBrandImageSpec | boolean | optional | Merges the brand's visual style brief into the prompt server-side. Defaults true. |
{
"prompt": "A trailhead sign at golden hour, pine forest behind it",
"model": "google/gemini-2.5-flash-image",
"orientation": "square"
}
{
"success": true,
"data": {
"objectKey": "20b6.../image/f0a1...",
"signedUrl": "https://....s3.amazonaws.com/...",
"mimeType": "image/png",
"creditsUsed": 20,
"remainingCredits": 480,
"imageSpecApplied": true,
"brandFaceApplied": false
}
}
| Status | When |
|---|---|
| 400 | Unknown model, invalid orientation, or brandFace on an unsupported model. |
| 402 | Not enough AI credits for the model's cost. |
| 502 | Generation failed upstream. |
| 504 | Generation exceeded the 90s timeout. |
Labels & Post Slot Collections
Labels are simple tags for organizing posts. Post slot collections are recurring publishing queues — give a post a slotId instead of a scheduledDate and it drops into the slot's next open time.
Paginated list of labels, sorted alphabetically.
| Field | Type | Notes | |
|---|---|---|---|
| label | string | required | |
| color | string | optional | Hex code. Defaults to #6b7280. |
Returns every slot configured for the brand space — not paginated. See Slot Collection for the shape.
Data Models
The shapes referenced throughout the endpoints above.
| Field | Type | Description |
|---|---|---|
| key | string | Stable post id, used as :postId. |
| subject | string | Internal title. |
| status | enum | Draft · Scheduled · Scheduled Draft · Scheduled for Approval · Attention Needed · Publishing · Published · Partial Failure · Failed · Cancelled · Rejected · Pending Requeue · Deleted · Template. Only the first four can be set on creation. |
| content | object | The shared { text, media[], mentions[] } every non-variant platform publishes. See Platform for when a platform overrides this. |
| platforms | Platform[] | One entry per connected account this post targets. See below. |
| label | Label | null | |
| scheduledDate | timestamp | Unix ms. 0 for undated drafts. |
| draft / scheduled / approved | boolean | Derived flags kept in sync with status. |
| mediaPreviewsSmall / mediaPreviewsMedium | array | { type, objectKey, signedUrl }[] — calendar-sized thumbnails. |
| hasMedia / hasImage / hasVideo | boolean | |
| history | array | { timestamp, type: info|warning|failure, info }[] — an audit trail of what happened to the post. |
| zone | object | { zoneId, teamName } |
| originalAuthor / lastUpdatedBy | object | { userId, userEmail, fullName } |
| createdAt / lastUpdated | timestamp | |
| hashtagGroups | string[] |
platforms[]| Field | Type | Description |
|---|---|---|
| platform | enum | facebook / instagram / twitter / youtube / linkedin / tiktok / pinterest / bluesky / mastodon / threads / gmb / reddit |
| socialUserId | string | The connected account posting this. |
| user | object | That account's profile: { id, name, username, avatar, ... }. |
| isVariant | boolean | false — this platform publishes the post's shared top-level content as-is. true — it has its own content below instead (a rewritten caption, different media, etc). |
| content | object | Present only when isVariant is true. Same shape as the post's top-level content. |
| threads | array | Present only for a Threads variant with reply posts attached: { text, media[], mentions[] }[]. |
| options | object | Platform-specific publish options — only the fields relevant to platform, mirroring the shape of platforms[].<platform>Options on create. Omitted when there's nothing to report (e.g. a non-poll Twitter post has no poll key). |
| postCondition | object | null | { condition: delay|comments|likes|impressions, value, status } — a conditional-publish rule, if set. |
| liveUrl | string | Present once published — link to the live post. |
platforms[] — the shared draft content lives at the post's top level as content, and every platform that isn't its own variant simply doesn't repeat it.| Field | Type | Description |
|---|---|---|
| objectKey | string | Pass this to GET .../media for the full object. |
| signedUrl | string | Presigned, ~15 minute TTL. |
| Field | Type | Description |
|---|---|---|
| objectKey / signedUrl | string | As above. |
| endpoint | enum | image / video |
| type | string | MIME type, e.g. image/webp. |
| extension | string | |
| width / height / resolution / pixels | number / string | Images only. |
| size | number | Bytes. |
| thumbSmallObjectKey / thumbMediumObjectKey | string | Resolve separately if you need the thumbnail itself. |
| videoDetails | object | Video only — duration, codec, frame rate, cover choices. |
| originalFileName / caption / altText / tags / labels | string / string[] | |
| source | string | Where it came from, e.g. cogen_preview_images, api. |
| createdAt | timestamp |
mediaChecks (per-platform validation warnings computed internally) is intentionally stripped from this response — it's not part of the public shape.| Field | Type | Description |
|---|---|---|
| zoneId | string | |
| name | string | |
| owner | object | { userId, userEmail, fullName, userName, status, permissions } |
| teamMembers | array | Same shape as owner, plus role and canApprove. |
| socials | array | Connected accounts: { id, name, isActive, user: { id, username, name, avatar }, totalQueued, lastSynced, connectionErrors }[]. |
| socialsActive | object | Boolean per platform — quick "is anything connected" map. |
| settings | object | approvalWorkflow, shortLinks, notifyOnPublishFailure, hideImagesInCalendar, plus AI/brand-voice fields (defaultTone, audience, styleGuidelines, ...). |
| subscriptionType | string | |
| logo | Media | null | |
| createdAt / lastUpdated | timestamp |
| Field | Type | Description |
|---|---|---|
| labelId | string | |
| zoneId | string | |
| label | string | Display name. |
| color | string | Hex. |
| custom | boolean | true for user-created labels (all API-created ones are). |
| createdAt / lastUpdated | timestamp |
| Field | Type | Description |
|---|---|---|
| slotId | string | Pass as slotId when creating a post. |
| slotType | enum | RECURRING / CUSTOM / DYNAMIC |
| weekDay / startTime / endTime / timezone | number / string | For recurring slots. |
| customDays | array | { day: 1-7, times: [{ minutes, timeFriendly }] }[] for CUSTOM slots. |
| label | Label | Applied to posts dropped into this slot unless overridden. |
| defaultSocialUserIds | array | Accounts a post gets automatically when scheduled via this slot: { social, socialUser, pinterestBoard }[]. |
| defaultOptions | object | Per-platform option defaults applied alongside defaultSocialUserIds. |
| posted | number | Running count of posts published through this slot. |
MCP Server
The same service layer is exposed as an MCP server, so any MCP-capable client — Claude, an agent framework, your own tool-calling loop — can drive CoPost directly. It speaks Streamable HTTP at a single endpoint.
Connecting
Two ways in, both landing on the same tool set:
| Method | Best for | How |
|---|---|---|
| x-api-key | Scripts, servers, anything you control | Same header, same key as the REST API. Simplest option. |
| OAuth 2.1 + PKCE | Third-party MCP clients you don't control | Dynamic client registration and a browser consent screen — see below. |
curl https://mcp.copost.io/mcp \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
OAuth flow
Clients that support MCP's OAuth discovery find everything they need at:
| /.well-known/oauth-protected-resource | points at the authorization server |
| /.well-known/oauth-authorization-server | advertises the endpoints below |
Register
POST /mcp/oauth/registerwithredirect_uris(must behttps://) &client_name→ back comes aclient_id. No secret; this is a public-client flow.Authorize
GET /mcp/oauth/authorizewithresponse_type=code,client_id,redirect_uri, a PKCEcode_challenge(S256), andstate→ redirects the user to CoPost's consent screen.Consent
The user approves in-app. CoPost redirects back to your
redirect_uriwith a short-livedcode.Exchange
POST /mcp/oauth/tokenwithgrant_type=authorization_code, thecode, and yourcode_verifier→ a Beareraccess_token(1 hour) andrefresh_token.Call
Send
Authorization: Bearer <access_token>to/mcp. Refresh withgrant_type=refresh_tokenonce it expires — each refresh rotates both tokens.
Tool catalog
13 tools, one MCP server per authenticated user. Every tool that touches a brand space checks membership the same way the REST middleware does — a zoneId you don't belong to throws, it doesn't leak data.
Lists every brand space the caller owns or belongs to.
Full brand space document — same shape as GET brand space.
Same filters and shape as GET posts, including the content / platforms post shape.
One post by id.
Full media objects with a fresh signed URL — the MCP mirror of GET .../media.
Every slot for the brand space — use a slotId from here as create_post's slotId.
Uploads an image or video from a public URL (Google Drive share links supported). Returns the full media object — its objectKey plugs straight into create_post.
Same model/orientation/credit rules as POST media/generate.
Field-for-field the same as POST posts, with one convenience: scheduledDate takes a friendly local string (e.g. "2026-10-10 12:12:00") interpreted in the caller's own timezone, rather than a raw timestamp.
All labels for the brand space.
Same as POST labels.
type: "brand_brief" returns the brand's writing guide and content pillars; "image_spec" returns its visual style guide. No REST equivalent — useful for an agent drafting captions or prompts in the brand's voice before calling create_post or generate_image.
Same Teams-plan gate as POST brand-spaces. One gap: if logoUrl is given, the space is still created but the logo upload is rejected with an error pointing you at the REST endpoint instead — MCP can't stream the resulting binary back through itself.
REST parity notes
The two surfaces share one service layer, so behavior matches almost everywhere. The exceptions:
| Difference | Detail |
|---|---|
| Media by URL | REST has no standalone "upload from URL" route — pass imageUrls/videoUrl straight into POST .../posts. MCP exposes it separately as upload_media so an agent can get an objectKey back before deciding what to attach it to. |
| Direct file bytes | Only REST's POST .../media accepts a raw multipart upload. MCP tools only ever take URLs. |
| Brand brief / image spec | MCP-only (get_brand_brief) — there's no REST route for it today. |
| scheduledDate format | REST takes a Unix ms timestamp; the create_post tool takes a friendly local datetime string instead. |
Build your first post
End to end, over REST: find a brand space, attach an image, publish it.
-
Find your zoneId
curlcurl https://api.copost.io/v1/me -H "x-api-key: YOUR_API_KEY" # -> data.zonesOwned[0].zoneId -
Check which accounts are connected
curlcurl https://api.copost.io/v1/brand-spaces/ZONE_ID \ -H "x-api-key: YOUR_API_KEY" # -> data.socials[].{ id, user.id } -
Upload an image
curlcurl -X POST https://api.copost.io/v1/brand-spaces/ZONE_ID/media \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@./trailhead.jpg" # -> data.objectKey -
Create the post
curlcurl -X POST https://api.copost.io/v1/brand-spaces/ZONE_ID/posts \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "caption": "New trailheads just opened south of the ridge", "scheduledDate": 1788080400000, "objectKeys": ["ZONE_ID/image/OBJECT_ID"], "platforms": [{ "social": "instagram" }, { "social": "facebook" }] }' -
Confirm it
curlcurl https://api.copost.io/v1/brand-spaces/ZONE_ID/posts/POST_KEY \ -H "x-api-key: YOUR_API_KEY"
list_brand_spaces → get_brand_space → upload_media → create_post → get_post.