CP CoPost API

// 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.

Base URL https://api.copost.io

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
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.

HeaderRequiredNotes
x-api-keyrequiredYour CoPost API key. Missing or unrecognized keys get a 401.
!
Every brand-space-scoped route (anything under /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.

200 OK
{
  "success": true,
  "data": { "...": "..." }
}
4xx / 5xx
{
  "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:

paginated list shape
{
  "data": [ "...": "..." ],
  "page": 1,
  "limit": 20,
  "total": 57,
  "hasMore": true
}
ParamTypeDefaultNotes
pageinteger11-indexed.
limitinteger20Clamped to 1–100.
i
Post slot collections are not paginated — 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

CodeMeaning
200 / 201OK / created.
400Malformed or invalid request body/query.
401Missing, invalid, or revoked API key.
402Insufficient AI credits (image generation only).
403Authenticated, but not a member of the requested brand space, or the current plan doesn't allow the action.
404Brand space or post not found.
500 / 502 / 504Unexpected server error, upstream generation failure, or generation timeout.

Account

A single endpoint to identify the caller and see which brand spaces they can reach.

GET /v1/me api key

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.

200 OK
{
  "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.

GET /v1/brand-spaces api key

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.

QueryType
pageintegeroptional
limitintegeroptional
200 OK · paginated
{
  "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
}
POST /v1/brand-spaces api key · Teams+ plan

Creates a new brand space with the caller as owner. Requires a Teams-tier subscription or above — other plans get a 403.

Body
FieldTypeNotes
namestringrequiredTrimmed; can't be empty.
logostringoptionalPublic 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.
approvalWorkflowbooleanoptionalOnly takes effect if your plan includes approval workflows.
shortLinksbooleanoptionalDefaults to true.
invitesarrayoptional{ emailAddress, role, canApprove }[] — role is one of co_owner / editor / client / viewer. Sent as invite emails after creation.
Request
{
  "name": "Acme Outdoors",
  "logo": "https://cdn.acme.com/logo.png",
  "invites": [
    { "emailAddress": "[email protected]", "role": "editor", "canApprove": false }
  ]
}
201 Created
{
  "success": true,
  "data": {
    "zoneId": "8e2c...",
    "name": "Acme Outdoors",
    "owner": { "userId": "65b0...", "fullName": "Karl Fountain" },
    "socials": [],
    "settings": { "approvalWorkflow": false, "shortLinks": true, "...": "..." }
  }
}
GET /v1/brand-spaces/:zoneId api key · membership

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.

200 OK
{
  "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.

GET /v1/brand-spaces/:zoneId/posts api key · membership
Query
ParamTypeNotes
pageintegeroptionalDefault 1.
limitintegeroptionalDefault 20, max 100.
statusstringoptionalOne post status, e.g. Scheduled, Published, Draft.
fromtimestampoptionalUnix ms. Filters by scheduledDate >= from.
totimestampoptionalUnix ms. Filters by scheduledDate <= to.
labelIdstringoptionalRestrict to one label.

Sorted by scheduledDate descending — except status=Draft, which sorts by createdAt (drafts have no scheduled date yet).

GET /v1/brand-spaces/20b6.../posts?status=Scheduled&limit=1
{
  "data": [ { "key": "f1527cc9-...", "content": { "...": "see Post model" }, "platforms": [ "..." ] } ],
  "page": 1, "limit": 1, "total": 12, "hasMore": true
}
POST /v1/brand-spaces/:zoneId/posts api key · membership

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/postType are ignored if set.
  • platforms — target specific connected accounts yourself and set per-platform options, alongside an explicit scheduledDate or a Draft status.
Body
FieldTypeNotes
captionstringrequiredThe post copy shared by every non-variant platform.
titlestringoptionalInternal label; also used as the Pinterest pin title and YouTube video title. Defaults to a truncated caption.
labelIdstringoptionalIgnored alongside slotId — the slot's own default label wins unless overridden here.
slotIdstringoptionalSee above. Overrides any scheduledDate.
scheduledDatetimestampoptionalUnix ms. Ignored if slotId is set.
statusstringoptionalOne 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.
postTypestringoptionalOne of FEED, REEL, STORY, POLL — applied uniformly across platforms, mapped to each platform's own equivalent. Defaults to FEED. Ignored if slotId is set.
platformsarrayoptionalSee platform input below. Ignored if slotId is set.
objectKeysstring[]optionalUp to 10 objectKeys of already-uploaded media. Takes priority over imageUrls/videoUrl.
imageUrlsstring[]optionalUp to 10 public image URLs, fetched and uploaded server-side.
videoUrlstringoptionalPublic video URL (Google Drive share links resolved automatically). Takes priority over imageUrls.
pinterestUrlstringoptionalDestination link for the Pinterest pin.

platforms[] entry

FieldTypeNotes
socialenumrequiredfacebook / instagram / twitter / youtube / linkedin / tiktok / pinterest / bluesky / mastodon / threads / gmb / reddit
socialUserIdstringoptionalWhich connected account to post as (socials[].user.id from get brand space). Required only when more than one account of that platform is connected.
instagramOptionsobjectoptionalpostMediaType, collaborators[], plus reel-only reelShareToFeed / isTrialReel / trialGraduationStrategy (discarded unless postType is REEL).
tiktokOptionsobjectoptionalprivacy, allowComments, allowDuet, allowStitch, discloseVideoContent, brand_content_toggle, brand_organic_toggle, auto_add_music.
youtubeOptionsobjectoptionalcategory, visibility, license, notifySubscribers, allowEmbedding, madeForKids, tags[].
twitterOptionsobjectoptionalreplySettings; poll: { options[], durationMinutes } — only applied when top-level postType is POLL.
linkedinOptionsobjectoptionalpoll: { question, options[], pollDuration, multipleChoice, voterVisibleToAuthor } — only applied when postType is POLL.
pinterestOptionsobjectoptional{ boardId }.
Request · explicit platforms
{
  "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" }
  ]
}
Request · via slot
{
  "caption": "New trailheads just opened south of the ridge 🏔️",
  "slotId": "9aa5b1e3-715a-4dd9-baa9-f8357e26bf76",
  "imageUrls": ["https://cdn.acme.com/trailhead.jpg"]
}
201 Created
{
  "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" } }
    ]
  }
}
!
400s here are disambiguated by message: anything starting with "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.
GET /v1/brand-spaces/:zoneId/posts/:postId api key · membership

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.

GET /v1/brand-spaces/:zoneId/media api key · membership
Query
ParamTypeNotes
objectKeysstringrequiredComma-separated objectKeys, up to 25. Keys from other brand spaces are silently dropped, not errored.
GET .../media?objectKeys=20b6.../image/669d...
{
  "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.

POST /v1/brand-spaces/:zoneId/media api key · membership

Uploads a file directly. multipart/form-data with a single field named file.

ConstraintValue
Allowed typesJPEG, PNG, GIF, WEBP, MP4, MOV, AVI, WEBM, MPEG
Max size50 MB images · 500 MB video
curl
curl -X POST https://api.copost.io/v1/brand-spaces/20b6.../media \
  -H "x-api-key: YOUR_API_KEY" \
  -F "file=@./trailhead.jpg"
i
The upload response is the raw media object and does not include a signedUrl — call GET .../media with the returned objectKey to get a viewable link.
POST /v1/brand-spaces/:zoneId/media/generate api key · membership · AI credits

Generates an on-brand image from a text prompt and adds it to the media library.

Body
FieldTypeNotes
promptstringrequired
modelenumrequired 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)
orientationenumrequiredlandscape / portrait / square
brandFacebooleanoptionalIncludes the brand's configured face reference image. Only the two Gemini models support this — others return 400.
useBrandImageSpecbooleanoptionalMerges the brand's visual style brief into the prompt server-side. Defaults true.
Request
{
  "prompt": "A trailhead sign at golden hour, pine forest behind it",
  "model": "google/gemini-2.5-flash-image",
  "orientation": "square"
}
201 Created
{
  "success": true,
  "data": {
    "objectKey": "20b6.../image/f0a1...",
    "signedUrl": "https://....s3.amazonaws.com/...",
    "mimeType": "image/png",
    "creditsUsed": 20,
    "remainingCredits": 480,
    "imageSpecApplied": true,
    "brandFaceApplied": false
  }
}
Error codes
StatusWhen
400Unknown model, invalid orientation, or brandFace on an unsupported model.
402Not enough AI credits for the model's cost.
502Generation failed upstream.
504Generation 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.

GET /v1/brand-spaces/:zoneId/labels api key · membership

Paginated list of labels, sorted alphabetically.

POST /v1/brand-spaces/:zoneId/labels api key · membership
Body
FieldTypeNotes
labelstringrequired
colorstringoptionalHex code. Defaults to #6b7280.
GET /v1/brand-spaces/:zoneId/post-slot-collections api key · membership

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.

Post
FieldTypeDescription
keystringStable post id, used as :postId.
subjectstringInternal title.
statusenumDraft · 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.
contentobjectThe shared { text, media[], mentions[] } every non-variant platform publishes. See Platform for when a platform overrides this.
platformsPlatform[]One entry per connected account this post targets. See below.
labelLabel | null
scheduledDatetimestampUnix ms. 0 for undated drafts.
draft / scheduled / approvedbooleanDerived flags kept in sync with status.
mediaPreviewsSmall / mediaPreviewsMediumarray{ type, objectKey, signedUrl }[] — calendar-sized thumbnails.
hasMedia / hasImage / hasVideoboolean
historyarray{ timestamp, type: info|warning|failure, info }[] — an audit trail of what happened to the post.
zoneobject{ zoneId, teamName }
originalAuthor / lastUpdatedByobject{ userId, userEmail, fullName }
createdAt / lastUpdatedtimestamp
hashtagGroupsstring[]
Platform — one entry in platforms[]
FieldTypeDescription
platformenumfacebook / instagram / twitter / youtube / linkedin / tiktok / pinterest / bluesky / mastodon / threads / gmb / reddit
socialUserIdstringThe connected account posting this.
userobjectThat account's profile: { id, name, username, avatar, ... }.
isVariantbooleanfalse — 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).
contentobjectPresent only when isVariant is true. Same shape as the post's top-level content.
threadsarrayPresent only for a Threads variant with reply posts attached: { text, media[], mentions[] }[].
optionsobjectPlatform-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).
postConditionobject | null{ condition: delay|comments|likes|impressions, value, status } — a conditional-publish rule, if set.
liveUrlstringPresent once published — link to the live post.
i
There's no separate "generic" entry in 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.
Media reference (embedded in posts)
FieldTypeDescription
objectKeystringPass this to GET .../media for the full object.
signedUrlstringPresigned, ~15 minute TTL.
Media object (full — from GET .../media)
FieldTypeDescription
objectKey / signedUrlstringAs above.
endpointenumimage / video
typestringMIME type, e.g. image/webp.
extensionstring
width / height / resolution / pixelsnumber / stringImages only.
sizenumberBytes.
thumbSmallObjectKey / thumbMediumObjectKeystringResolve separately if you need the thumbnail itself.
videoDetailsobjectVideo only — duration, codec, frame rate, cover choices.
originalFileName / caption / altText / tags / labelsstring / string[]
sourcestringWhere it came from, e.g. cogen_preview_images, api.
createdAttimestamp
!
mediaChecks (per-platform validation warnings computed internally) is intentionally stripped from this response — it's not part of the public shape.
Brand Space (Zone)
FieldTypeDescription
zoneIdstring
namestring
ownerobject{ userId, userEmail, fullName, userName, status, permissions }
teamMembersarraySame shape as owner, plus role and canApprove.
socialsarrayConnected accounts: { id, name, isActive, user: { id, username, name, avatar }, totalQueued, lastSynced, connectionErrors }[].
socialsActiveobjectBoolean per platform — quick "is anything connected" map.
settingsobjectapprovalWorkflow, shortLinks, notifyOnPublishFailure, hideImagesInCalendar, plus AI/brand-voice fields (defaultTone, audience, styleGuidelines, ...).
subscriptionTypestring
logoMedia | null
createdAt / lastUpdatedtimestamp
Label
FieldTypeDescription
labelIdstring
zoneIdstring
labelstringDisplay name.
colorstringHex.
custombooleantrue for user-created labels (all API-created ones are).
createdAt / lastUpdatedtimestamp
Slot Collection
FieldTypeDescription
slotIdstringPass as slotId when creating a post.
slotTypeenumRECURRING / CUSTOM / DYNAMIC
weekDay / startTime / endTime / timezonenumber / stringFor recurring slots.
customDaysarray{ day: 1-7, times: [{ minutes, timeFriendly }] }[] for CUSTOM slots.
labelLabelApplied to posts dropped into this slot unless overridden.
defaultSocialUserIdsarrayAccounts a post gets automatically when scheduled via this slot: { social, socialUser, pinterestBoard }[].
defaultOptionsobjectPer-platform option defaults applied alongside defaultSocialUserIds.
postednumberRunning 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.

MCP endpoint https://mcp.copost.io/mcp

Connecting

Two ways in, both landing on the same tool set:

MethodBest forHow
x-api-keyScripts, servers, anything you controlSame header, same key as the REST API. Simplest option.
OAuth 2.1 + PKCEThird-party MCP clients you don't controlDynamic client registration and a browser consent screen — see below.
x-api-key · simplest path
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-resourcepoints at the authorization server
/.well-known/oauth-authorization-serveradvertises the endpoints below
  1. Register

    POST /mcp/oauth/register with redirect_uris (must be https://) & client_name → back comes a client_id. No secret; this is a public-client flow.

  2. Authorize

    GET /mcp/oauth/authorize with response_type=code, client_id, redirect_uri, a PKCE code_challenge (S256), and state → redirects the user to CoPost's consent screen.

  3. Consent

    The user approves in-app. CoPost redirects back to your redirect_uri with a short-lived code.

  4. Exchange

    POST /mcp/oauth/token with grant_type=authorization_code, the code, and your code_verifier → a Bearer access_token (1 hour) and refresh_token.

  5. Call

    Send Authorization: Bearer <access_token> to /mcp. Refresh with grant_type=refresh_token once 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.

list_brand_spacesno params

Lists every brand space the caller owns or belongs to.

get_brand_spacezoneId

Full brand space document — same shape as GET brand space.

list_postszoneId, page?, limit?, status?, from?, to?, labelId?

Same filters and shape as GET posts, including the content / platforms post shape.

get_postzoneId, postId

One post by id.

get_mediazoneId, objectKeys[] (1–25)

Full media objects with a fresh signed URL — the MCP mirror of GET .../media.

list_post_slot_collectionszoneId

Every slot for the brand space — use a slotId from here as create_post's slotId.

upload_mediazoneId, url

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.

generate_imagezoneId, prompt, model, orientation?, brandFace?, useBrandImageSpec?

Same model/orientation/credit rules as POST media/generate.

create_postzoneId, caption, title?, labelId?, slotId?, scheduledDate?, status?, postType?, platforms[]?, objectKeys[]?, imageUrls[]?, videoUrl?, pinterestUrl?

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.

list_labelszoneId

All labels for the brand space.

create_labelzoneId, label, color?

Same as POST labels.

get_brand_briefzoneId, type

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.

create_brand_spacename, logoUrl?, approvalWorkflow?, shortLinks?, invites[]?

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:

DifferenceDetail
Media by URLREST 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 bytesOnly REST's POST .../media accepts a raw multipart upload. MCP tools only ever take URLs.
Brand brief / image specMCP-only (get_brand_brief) — there's no REST route for it today.
scheduledDate formatREST 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.

  1. Find your zoneId

    curl
    curl https://api.copost.io/v1/me -H "x-api-key: YOUR_API_KEY"
    # -> data.zonesOwned[0].zoneId
  2. Check which accounts are connected

    curl
    curl https://api.copost.io/v1/brand-spaces/ZONE_ID \
      -H "x-api-key: YOUR_API_KEY"
    # -> data.socials[].{ id, user.id }
  3. Upload an image

    curl
    curl -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
  4. Create the post

    curl
    curl -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" }]
      }'
  5. Confirm it

    curl
    curl https://api.copost.io/v1/brand-spaces/ZONE_ID/posts/POST_KEY \
      -H "x-api-key: YOUR_API_KEY"
i
The same five steps as MCP tool calls: list_brand_spaces → get_brand_space → upload_media → create_post → get_post.