# HumanPost Publishing API

> Upload videos and image carousels, organize them with optional content tags, assign them to social accounts, and track the human posting workflow — via REST or the MCP server. This file is the complete documentation in one place; paste it into your AI tool or fetch it from /llms.txt.

- REST base URL: https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi
- MCP server URL: https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp
- OpenAPI spec: https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/openapi.json
- Version: 2.2.0

## Authentication

Create an API key in **Dashboard → Settings → API & MCP**. Send it with every request as `Authorization: Bearer ccb_live_<key_id>_<secret>`. Treat the key like a password and keep it on your server.

## Quickstart

### 1. List your accounts

Find the account IDs you want HumanPost to publish to.

```bash
curl "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/accounts" \
  -H "Authorization: Bearer $API_KEY"
```

### 2. Upload from a URL

Import a public video URL and keep the returned upload ID.

```bash
curl -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/uploads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"video","sourceUrl":"https://cdn.example.com/launch.mp4"}'
```

### 3. Create the post

Queue the processed upload for one or more accounts.

```bash
curl -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/posts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: launch-post-001" \
  -d '{"media":["UPLOAD_ID"],"accountIds":["ACCOUNT_ID"],"caption":"Launch day 🚀"}'
```

Send a unique `Idempotency-Key` with `POST /v2/posts`. Reusing the same key retries safely without creating a duplicate post.

## Carousels

A carousel is 2–20 images posted as one post. For each local file, create an upload, PUT the raw bytes to its signed URL within 15 minutes using the same Content-Type, and complete the upload. Then create one post with all upload IDs in slide order. A remote image can instead be imported with `{"kind":"image","sourceUrl":"https://..."}`; it completes automatically, so there is no PUT or completion call.

### One carousel with curl

```bash
# 1. Create an upload for slide 1
SIZE=$(wc -c < slide-1.jpg | tr -d ' ')
UPLOAD=$(curl -s -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/uploads" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d "{\"kind\":\"image\",\"filename\":\"slide-1.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":$SIZE}")
UPLOAD_ID_1=$(printf '%s' "$UPLOAD" | jq -r '.uploadId')
UPLOAD_URL=$(printf '%s' "$UPLOAD" | jq -r '.uploadUrl')

# 2. PUT the raw file, then mark the upload complete
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file slide-1.jpg
curl -s -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/uploads/$UPLOAD_ID_1/complete" \
  -H "Authorization: Bearer $API_KEY"

# 3. Repeat for slide 2
SIZE=$(wc -c < slide-2.jpg | tr -d ' ')
UPLOAD=$(curl -s -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/uploads" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d "{\"kind\":\"image\",\"filename\":\"slide-2.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":$SIZE}")
UPLOAD_ID_2=$(printf '%s' "$UPLOAD" | jq -r '.uploadId')
UPLOAD_URL=$(printf '%s' "$UPLOAD" | jq -r '.uploadUrl')
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file slide-2.jpg
curl -s -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/uploads/$UPLOAD_ID_2/complete" \
  -H "Authorization: Bearer $API_KEY"

# 4. Create one post; media array order is slide order
curl -s -X POST "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/posts" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: two-slide-launch" \
  -d "{\"media\":[\"$UPLOAD_ID_1\",\"$UPLOAD_ID_2\"],\"accountIds\":[\"ACCOUNT_ID\"],\"caption\":\"Two ideas worth saving\"}"
```

### A folder of carousels

Each subfolder in `./carousels/` becomes one post. Images are sorted by filename and the folder name becomes the caption.

```js
// Node.js 18+ — run with HUMANPOST_API_KEY=... node carousels.mjs
import fs from "node:fs/promises";
import path from "node:path";
const API = "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi";
const key = process.env.HUMANPOST_API_KEY;
if (!key) throw new Error("Set HUMANPOST_API_KEY first");
const auth = { Authorization: `Bearer ${key}` };
const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
  webp: "image/webp", heic: "image/heic" };
async function request(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  return response.json();
}
const root = "./carousels";
const folders = (await fs.readdir(root, { withFileTypes: true }))
  .filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
for (const folder of folders) {
  const dir = path.join(root, folder);
  const images = (await fs.readdir(dir)).filter((file) =>
    types[path.extname(file).slice(1).toLowerCase()]).sort();
  if (images.length < 2 || images.length > 20)
    throw new Error(`${folder} needs 2–20 supported images`);

  // Upload and complete every image in filename order.
  const uploadIds = [];
  for (const filename of images) {
    const file = path.join(dir, filename);
    const contentType = types[path.extname(filename).slice(1).toLowerCase()];
    const { size } = await fs.stat(file);
    const { uploadId, uploadUrl } = await request(`${API}/v2/uploads`, {
      method: "POST", headers: { ...auth, "Content-Type": "application/json" },
      body: JSON.stringify({ kind: "image", filename, contentType, sizeBytes: size }) });
    const put = await fetch(uploadUrl, { method: "PUT",
      headers: { "Content-Type": contentType }, body: await fs.readFile(file) });
    if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
    await request(`${API}/v2/uploads/${uploadId}/complete`, { method: "POST", headers: auth });
    uploadIds.push(uploadId);
  }

  // The stable key makes this safe to run again without duplicate posts.
  await request(`${API}/v2/posts`, { method: "POST", headers: { ...auth,
    "Content-Type": "application/json", "Idempotency-Key": `carousel-${folder}` },
    body: JSON.stringify({ media: uploadIds, accountIds: ["ACCOUNT_ID"], caption: folder }) });
  console.log(`Created: ${folder}`);
}
```

**Carousel limits:** 2–20 images per carousel; JPG, PNG, WebP, or HEIC; 50 MB maximum per image; The media array sets slide order; Videos are single-file posts.

**Video limits:** One MP4 or MOV file per video post; H.264 or HEVC (H.265) video with AAC audio when audio is present; 200 MB maximum; 3 minutes maximum; Up to 2160 × 3840; 60 fps maximum. Upload completion returns `invalid_media_specs` with a corrective hint when a video fails validation.

## Content tags and template attribution

Content tags organize posts for dashboard analytics and reporting. They are optional, company-scoped metadata and do not appear on the social post. Tags and template provenance are returned together in `contentGroupIds` and appear in the same performance table.

- Tagging is optional. A post can have up to 10 explicit tag or template reporting groups.
- Use the stable contentGroupId returned when a tag is created, not its display name or bare tagId.
- contentGroupIds is a replacement list: resend the complete desired list to add or remove one tag, or send [] to remove all explicit tags.
- Tag assignments can be changed before or after a post is published. Other post fields still follow normal editability rules.
- A template:<id> group inherited from the template used to create a post is provenance and remains visible even when explicit tags are cleared.

### REST workflow

```text
# Create a tag. Save contentGroupId from the response.
POST /v2/content-tags
{ "name": "Launch", "color": "blue" }
→ { "tagId": "abc123", "contentGroupId": "tag:abc123", ... }

# Assign tags while creating a post.
POST /v2/posts
{ "media": ["UPLOAD_ID"], "accountIds": ["ACCOUNT_ID"], "contentGroupIds": ["tag:abc123"] }

# Replace the complete explicit tag list before or after publishing.
PATCH /v2/posts/POST_ID
{ "contentGroupIds": ["tag:abc123", "tag:def456"] }

# Remove every explicit tag.
PATCH /v2/posts/POST_ID
{ "contentGroupIds": [] }
```

List tags with `GET /v2/content-tags`. Rename, recolor, archive, or restore a tag with `PATCH /v2/content-tags/{tagId}`. Delete one permanently with `DELETE /v2/content-tags/{tagId}`; deletion removes that tag from all company posts, so prefer archiving when historical continuity matters. Filter posts with `GET /v2/posts?contentGroupId=tag:abc123`, or use `contentGroupId=untagged`.

## Scheduling posts

Guide: /docs/scheduling

| Window start | Poster local time |
| --- | --- |
| 08:00 | 8 AM – 11 AM |
| 11:00 | 11 AM – 2 PM |
| 14:00 | 2 PM – 5 PM |
| 17:00 | 5 PM – 8 PM |

- Each selected window adds one scheduled post per day per account, provided approved posts and an available poster are ready. Windows follow each assigned poster's local timezone, not the caller's timezone or UTC.
- The account override takes precedence over the company default. If neither is set, global posting-window and gap rules apply, including the legacy company postsPerDay setting. The new schedule controls cadence through window count; it does not require a separate postsPerDay field.
- Company changes apply only to accounts that inherit the default. Individual account schedules still take priority even if you edit the company default afterward, through the dashboard, API, or MCP. Clearing an account override makes it use the current company default, including any changes made since the override was created. Company scope always means the API key's company, never all companies.
- Replace the complete windows array when enabling or disabling slots. Use 1–4 sorted HH:mm starts, each at least 3 hours apart, from 08:00 through 17:00 inclusive. The four standard dashboard slots are shown above; the shared validator also accepts other starts that meet these constraints.
- Send schedule: null to clear an account override and inherit the company default. Clearing the company schedule restores global rules for inheriting accounts. An empty windows array is invalid. Clearing a schedule does not pause posting.
- Changes affect future dispatches and do not move or cancel tasks already released to posters. Posting windows are delivery windows, not exact publication times. Queue posts as usual with create_post / POST /v2/posts or queue_post / POST /v2/posts/{postId}/queue.
- Missed posts may catch up or use unused windows as make-up slots. Four posts per account per poster-local day remains the hard maximum, including make-up posts. Selecting fewer slots sets the normal cadence, not an absolute cap on make-up posts.
- Read schedules with accounts:read and update them with accounts:write. Account IDs must belong to the API key's company. Reads return source (account, company, or global), scheduledPostsPerDay (null for global rules), and maxPostsPerDay. Account reads also return the company default, effective schedule, and resolved timezone; before poster assignment the reported timezone is a fallback.

### REST scheduling

```bash
# Read the company default
curl "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/schedule" \
  -H "Authorization: Bearer $HUMANPOST_API_KEY"

# Two daily windows for accounts using the company default
curl -X PATCH "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/schedule" \
  -H "Authorization: Bearer $HUMANPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"schedule":{"windows":["08:00","17:00"]}}'

# Override one account with a single midday window
curl -X PATCH "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/accounts/ACCOUNT_ID/schedule" \
  -H "Authorization: Bearer $HUMANPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"schedule":{"windows":["11:00"]}}'

# Restore company inheritance for that account
curl -X PATCH "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/accounts/ACCOUNT_ID/schedule" \
  -H "Authorization: Bearer $HUMANPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"schedule":null}'

# Verify the effective schedule and its source
curl "https://us-central1-clickbaitcarousel.cloudfunctions.net/publishingApi/v2/accounts/ACCOUNT_ID/schedule" \
  -H "Authorization: Bearer $HUMANPOST_API_KEY"
```

### MCP scheduling

```javascript
get_company_schedule({})
update_company_schedule({"schedule":{"windows":["08:00","17:00"]}})
get_account_schedule({"accountId":"ACCOUNT_ID"})
update_account_schedule({"accountId":"ACCOUNT_ID","schedule":{"windows":["11:00"]}})
// Clear the override to inherit the company default again
update_account_schedule({"accountId":"ACCOUNT_ID","schedule":null})
```

## Post status lifecycle

| Status | Meaning |
| --- | --- |
| `draft` | The post exists but has not been queued to any accounts. |
| `pending_approval` | The post is waiting for a HumanPost reviewer. |
| `queued` | Approved and waiting in each target account's queue. |
| `posting` | A human operator is actively publishing the post. |
| `posted` | Published successfully to the social platform. |
| `partially_posted` | Published to some accounts while other account targets remain active. |
| `processing` | Uploaded media is still being prepared. |
| `cancelled` | The queued item was cancelled before posting. |
| `failed` | Publishing or media processing could not complete. |

## Errors

Every failed request uses this JSON shape. The `hint` explains how to fix the problem; include `requestId` if you contact support.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "accountIds must contain at least one account",
    "hint": "pass one or more IDs returned by GET /v2/accounts",
    "requestId": "req_01J..."
  }
}
```

| Error code | Meaning |
| --- | --- |
| `unauthorized` | The API key is missing, malformed, revoked, or invalid. |
| `insufficient_scope` | The key does not include the required scope. |
| `invalid_request` | The body, query, or path values failed validation. |
| `account_not_found` | The account does not exist or belongs to another company. |
| `upload_not_ready` | The upload has not finished processing. |
| `media_not_ready` | The existing post has no ready video or current rendered carousel media. |
| `invalid_media_set` | The media combination cannot form a supported post. |
| `instagram_feed_posts_disabled` | The company is not permitted to publish regular Instagram feed posts; use a Reel. |
| `duplicate_queue_item` | The same post is already queued for an account. |
| `post_not_editable` | The post has advanced beyond an editable status. |
| `post_not_cancellable` | No targeted item can be cancelled; released items may be in an operator's hands, and posted items cannot be deleted. |
| `account_limit_reached` | The company has used every account slot allowed by its plan. |
| `rate_limited` | A per-company UTC daily API, post, upload, or account-creation cap has been reached. |
| `quota_exceeded` | The company has reached a plan or posting limit. |
| `file_too_large` | The uploaded file exceeds the supported size. |
| `unsupported_media_type` | The file type or content type is not supported. |
| `invalid_media_specs` | The video failed codec, duration, dimension, or frame-rate validation. |
| `invalid_cursor` | The value used to get the next page is malformed or expired. |

## Getting more results

List endpoints return up to 100 items at a time. When there are more, the response includes a `nextCursor` value. Send it back on the next request as `?cursor=` to get the next page; never change or interpret it. When `nextCursor` is `null`, you have everything.

```text
# First page
GET /v2/posts?limit=25
→ { "data": [ 25 posts ], "nextCursor": "eyJj..." }

# Next page — pass nextCursor back exactly as received
GET /v2/posts?limit=25&cursor=eyJj...
→ { "data": [ 12 posts ], "nextCursor": null }   # null = no more pages
```

## Scopes

A key can only do what its scopes allow. Pick scopes when you create the key in the dashboard.

| Scope | What it allows |
| --- | --- |
| `posts:read` | List posts and content tags, inspect status, and read account queues. |
| `posts:write` | Upload media; create, queue, edit, or cancel posts; and manage content tags. |
| `accounts:read` | List publishing accounts and read company or account posting schedules. |
| `accounts:write` | Create publishing accounts and change company or account posting schedules. |
| `analytics:read` | Read post and account performance analytics. |
| `usage:read` | View plan limits and daily API usage. |
| `workspace:read` | Read the key's company, connected accounts, image folders, and image-pack metadata. |
| `media:read` | Create short-lived download URLs for selected company-owned images. |

## Endpoint reference

### GET /v2/openapi.json

OpenAPI document

**Responses:** 200 Success

### POST /v2/uploads

Create an upload

Videos must be MP4 or MOV with H.264 or HEVC (H.265) video and AAC audio when present, no larger than 200 MB or 3 minutes, up to 2160 × 3840 and 60 fps. Supported uploads are stored without transcoding.

**JSON body**
- Variant 1:
  - `kind`: string (required)
  - `filename`: string (required) — For video uploads, the filename must use the .mp4 or .mov extension.
  - `contentType`: string (required) — For video uploads, use video/mp4 for MP4 or video/quicktime for MOV.
  - `sizeBytes`: integer (required) — Video uploads may be at most 209,715,200 bytes (200 MiB); images may be at most 52,428,800 bytes (50 MiB).
- Variant 2:
  - `kind`: string (required)
  - `sourceUrl`: string (required)
  - `filename`: string

**Responses:** 201 Success; 400 Error; 401 Error; 403 Error; 413 Error; 415 Error; 422 Error; 429 Error

### POST /v2/uploads/{uploadId}/complete

Complete a signed upload

Validates uploaded video container, codecs, size, duration, dimensions, and frame rate before marking it ready.

**Parameters**
- `uploadId` (path, required): string

**Responses:** 200 Success; 409 Error; 413 Error; 415 Error; 422 Error

### GET /v2/uploads/{uploadId}

Get upload status

**Parameters**
- `uploadId` (path, required): string

**Responses:** 200 Success; 404 Error

### POST /v2/posts

Create and queue a post

Optionally assign up to 10 tags or template reporting groups with contentGroupIds. Tags are for internal analytics and do not appear in the social caption.

**Parameters**
- `Idempotency-Key` (header, optional): string

**JSON body**
- `media`: array<string> (required)
- `accountIds`: array<string> (required)
- `skipApproval`: boolean — When true, send the post directly to the posting queue instead of waiting for dashboard approval.
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string
- `priority`: integer
- `contentGroupIds`: array<string> — Optional tag or template reporting-group IDs. Use the contentGroupId returned by POST /v2/content-tags. Multiple groups are allowed; omit this field for no explicit tags.
- `instagramPostFormat`: string — How Instagram should publish the media. Regular posts require a company capability; Reels are the default.
- `slides`: array<object>

**Responses:** 200 Idempotent replay; 201 Post created; 400 Error; 409 Error; 429 Error

### GET /v2/posts

List posts

Optionally filter by one tag or template reporting group with contentGroupId, or use `untagged` to return posts with no attribution.

**Parameters**
- `limit` (query, optional): integer
- `cursor` (query, optional): string
- `accountId` (query, optional): string
- `status` (query, optional): string
- `since` (query, optional): string
- `contentGroupId` (query, optional): string or string — Return only posts attributed to this tag or template reporting group. Use `untagged` for posts with no tag or template attribution.

**Responses:** 200 Success

### POST /v2/posts/{postId}/queue

Queue an existing draft post

Adds one queue item per requested account to an existing post whose media is ready.

**Parameters**
- `postId` (path, required): string
- `Idempotency-Key` (header, optional): string

**JSON body**
- `accountIds`: array<string> (required)
- `skipApproval`: boolean — When true, send the post directly to the posting queue instead of waiting for dashboard approval.
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string
- `priority`: integer
- `instagramPostFormat`: string — How Instagram should publish the media. Regular posts require a company capability; Reels are the default.

**Responses:** 200 Idempotent replay; 201 Post queued; 400 Error; 404 Error; 409 Conflict (`media_not_ready`, `duplicate_queue_item`, or post already published); 429 Error

### GET /v2/posts/{postId}

Get a post

**Parameters**
- `postId` (path, required): string

**Responses:** 200 Success; 404 Error

### PATCH /v2/posts/{postId}

Update post metadata or content tags

contentGroupIds replaces the complete explicit assignment list and can be changed before or after publishing. Resend the desired list to add or remove individual tags; send [] to remove all explicit tags. Other metadata remains subject to normal post editability rules.

**Parameters**
- `postId` (path, required): string

**JSON body**
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string | null
- `contentGroupIds`: array<string> — Replace the post's complete list of explicitly assigned tag or template reporting groups. This remains editable after publishing. To remove one tag, resend the list without it; pass [] to remove every explicit tag. Template provenance inherited from the post's original template remains visible.
- `instagramPostFormat`: string — How Instagram should publish the media. `post` is rejected unless the company has regular feed posts enabled.

**Responses:** 200 Success; 409 Error

### DELETE /v2/posts/{postId}

Cancel a post

**Parameters**
- `postId` (path, required): string
- `accountId` (query, optional): string

**Responses:** 200 Success; 409 Error

### GET /v2/content-tags

List content tags

Returns active and archived custom tags with the stable contentGroupId value used when creating, updating, or filtering posts.

**Responses:** 200 Success

### POST /v2/content-tags

Create a content tag

Creates an optional company-scoped analytics tag. Use the returned contentGroupId in a post's contentGroupIds array.

**JSON body**
- `name`: string (required) — Unique tag name within the API key's company.
- `color`: string — Dashboard display color. Defaults to slate.

**Responses:** 201 Success; 409 Error

### PATCH /v2/content-tags/{tagId}

Update or archive a content tag

Rename or recolor a tag, archive it to prevent new assignments, or restore it with archived: false. Archiving preserves existing post attribution and analytics.

**Parameters**
- `tagId` (path, required): string

**JSON body**
- `name`: string — New unique tag name.
- `color`: string — New dashboard display color.
- `archived`: boolean — Set true to hide the tag from new assignments without changing historical attribution; set false to restore it.

**Responses:** 200 Success; 404 Error; 409 Error

### DELETE /v2/content-tags/{tagId}

Delete a content tag

Permanently deletes the tag and removes it from all posts in the company. Prefer archiving for historical continuity.

**Parameters**
- `tagId` (path, required): string

**Responses:** 200 Success; 404 Error

### GET /v2/schedule

Get the company posting schedule

Requires accounts:read. Returns the default for the API key's company. Individual account overrides take precedence.

**Responses:** 200 Success; 403 Error; 404 Error

### PATCH /v2/schedule

Set the company posting schedule

Requires accounts:write. Replaces the company default, preserving individual account overrides. Individual account schedules still take priority even when this default is edited afterward through the dashboard, API, or MCP. One daily post per selected 3-hour window, in each assigned worker's local timezone. Send schedule: null to restore global rules, not pause posting. Applies to future dispatches; already released tasks are unchanged. Missed posts may use unused make-up windows, up to 4 posts/day/account.

**JSON body**
- `schedule`: object | null {windows} (required) — Replace the active windows with 1–4 sorted HH:mm starts, at least 3 hours apart, between 08:00 and 17:00 inclusive. Standard slots: 08:00, 11:00, 14:00, 17:00. Times follow each assigned worker's timezone. null clears the override; it does not pause posting.

**Responses:** 200 Success; 400 Error; 403 Error; 404 Error

### GET /v2/accounts/{accountId}/schedule

Get an account's effective posting schedule

Requires accounts:read. Returns the account override, company default, effective schedule, source, and timezone. Account must belong to the API key's company.

**Parameters**
- `accountId` (path, required): string

**Responses:** 200 Success; 403 Error; 404 Error

### PATCH /v2/accounts/{accountId}/schedule

Set or clear an account posting schedule

Requires accounts:write. Replaces this account's override. Send schedule: null to inherit the company default (or global rules), not pause posting. Applies to future dispatches; already released tasks are unchanged. Times follow the assigned worker's timezone. Missed posts may use unused make-up windows, up to 4 posts/day.

**Parameters**
- `accountId` (path, required): string

**JSON body**
- `schedule`: object | null {windows} (required) — Replace the active windows with 1–4 sorted HH:mm starts, at least 3 hours apart, between 08:00 and 17:00 inclusive. Standard slots: 08:00, 11:00, 14:00, 17:00. Times follow each assigned worker's timezone. null clears the override; it does not pause posting.

**Responses:** 200 Success; 400 Error; 403 Error; 404 Error

### GET /v2/accounts

List publishing accounts

**Parameters**
- `platform` (query, optional): string

**Responses:** 200 Success

### POST /v2/accounts

Create a publishing account

Provide 1–5 warmup.searchTerms. Requests with more than five terms are rejected. Existing accounts and tasks with longer lists remain supported; new or edited lists must have at most five terms.

**JSON body**
- `platform`: string (required)
- `username`: string (required)
- `displayName`: string (required)
- `bio`: string (required)
- `profilePictureUrl`: string (required)
- `bioUrl`: string
- `warmup`: object {searchTerms} (required)

**Responses:** 201 Success; 400 Error; 403 Error; 409 Error; 429 Error

### GET /v2/accounts/{accountId}

Get a publishing account

**Parameters**
- `accountId` (path, required): string

**Responses:** 200 Success; 404 Error

### GET /v2/accounts/{accountId}/queue

Get an account queue

**Parameters**
- `accountId` (path, required): string
- `limit` (query, optional): integer
- `cursor` (query, optional): string

**Responses:** 200 Success

### GET /v2/posts/{postId}/analytics

Get post analytics

**Parameters**
- `postId` (path, required): string
- `limit` (query, optional): integer
- `cursor` (query, optional): string

**Responses:** 200 Success

### GET /v2/accounts/{accountId}/analytics

Get account analytics

**Parameters**
- `accountId` (path, required): string

**Responses:** 200 Success

### GET /v2/usage

Get plan and API usage

**Responses:** 200 Success

### GET /v2/workspace

Read a company workspace

Returns only the company bound to the API key, its connected accounts, and sanitized image folder and pack metadata. Requires workspace:read.

**Responses:** 200 Success; 401 Error; 403 Error; 404 Error

### POST /v2/workspace/media-urls

Create short-lived image download URLs

Resolves selected images through company-owned folder records before signing. Storage paths and credentials are never accepted from the caller. Requires media:read.

**JSON body**
- `items`: array<object> (required)
- `expiresInSeconds`: integer

**Responses:** 200 Success; 400 Error; 401 Error; 403 Error; 404 Error

## MCP

Connect an MCP client to https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp using Streamable HTTP. Send `Authorization: Bearer YOUR_API_KEY` on every request.

### Claude Code

Register HumanPost as a user-level Streamable HTTP server.

```bash
claude mcp add --transport http humanpost https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp --header "Authorization: Bearer YOUR_API_KEY"
```

### Claude (web/desktop)

Add HumanPost as a custom connector in Claude.

1. Open Settings, then Connectors.
2. Choose Add custom connector and paste the MCP URL.
3. Set the Authorization header to Bearer followed by your API key.

```text
URL: https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp
Authorization: Bearer YOUR_API_KEY
```

### Cursor

Add this server entry to your Cursor mcp.json file.

```json
{
  "mcpServers": {
    "humanpost": {
      "url": "https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}
```

### Any client

Use Streamable HTTP transport and send the API key on every request.

```json
{
  "name": "humanpost",
  "transport": {
    "type": "streamable-http",
    "url": "https://us-central1-clickbaitcarousel.cloudfunctions.net/mcp"
  },
  "headers": {
    "Authorization": "Bearer YOUR_API_KEY"
  }
}
```

### MCP tools

| Tool | What it does | Required scope |
| --- | --- | --- |
| `list_accounts` | List account IDs, readiness, daily usage, and queue state. | `accounts:read` |
| `get_company_schedule` | Read the company default posting windows. | `accounts:read` |
| `update_company_schedule` | Replace or clear the company default, preserving account overrides. | `accounts:write` |
| `get_account_schedule` | Read an account's override, effective schedule, and timezone. | `accounts:read` |
| `update_account_schedule` | Replace an account's windows or restore company inheritance. | `accounts:write` |
| `get_account_queue` | Inspect the next queued posts for an account. | `posts:read` |
| `create_account` | Send a complete account profile to the poster invite pool for setup. | `accounts:write` |
| `upload_media` | Import media from a URL or begin a signed upload. | `posts:write` |
| `complete_upload` | Verify and process a completed signed upload. | `posts:write` |
| `create_post` | Create a video or carousel post with optional content tags and add it to queues. | `posts:write` |
| `queue_post` | Queue an existing draft post for one or more accounts. | `posts:write` |
| `get_post_status` | Read a post and its per-account publishing status. | `posts:read` |
| `list_posts` | List recent posts with account, status, and content-group filters. | `posts:read` |
| `update_post` | Edit post metadata; replace content tags before or after publishing, using [] to clear them. | `posts:write` |
| `list_content_tags` | List custom tags and their stable contentGroupId values. | `posts:read` |
| `create_content_tag` | Create an optional company tag for post grouping and analytics. | `posts:write` |
| `update_content_tag` | Rename, recolor, archive, restore, or otherwise manage a content tag. | `posts:write` |
| `delete_content_tag` | Permanently delete a tag and remove it from all posts; archive it when history should be preserved. | `posts:write` |
| `cancel_post` | Cancel pre-release items and report targets already posting, posted, or cancelled. | `posts:write` |
| `get_post_analytics` | Read the latest post metrics and analytics history. | `analytics:read` |
| `get_usage` | Read plan limits and today's API and post counts. | `usage:read` |

The `create_post` tool accepts 2–20 image upload IDs for a carousel. Their order is the slide order.

### Content tags with MCP

```text
# Create a reusable company tag; keep the returned contentGroupId.
create_content_tag({ name: "Launch", color: "blue" })
→ { contentGroupId: "tag:abc123", ... }

# Add it when creating a post.
create_post({ media: ["UPLOAD_ID"], accountIds: ["ACCOUNT_ID"], contentGroupIds: ["tag:abc123"] })

# Replace the post's tags at any time, including after publishing.
update_post({ postId: "POST_ID", contentGroupIds: ["tag:abc123", "tag:def456"] })

# Remove every explicit tag.
update_post({ postId: "POST_ID", contentGroupIds: [] })
```
