# HumanPost Publishing API

> Upload videos and image carousels, 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.0.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–10 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 > 10)
    throw new Error(`${folder} needs 2–10 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–10 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 file per video post; H.264 video with AAC audio when audio is present; 120 MB maximum; 3 minutes maximum; Up to 1080 × 1920; 60 fps maximum. Upload completion returns `invalid_media_specs` with a corrective hint when a video fails validation.

## 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. |
| `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, inspect status, and read account queues. |
| `posts:write` | Upload media and create, queue, edit, or cancel posts. |
| `accounts:read` | List publishing accounts and inspect their details. |
| `accounts:write` | Create new TikTok and Instagram publishing accounts. |
| `analytics:read` | Read post and account performance analytics. |
| `usage:read` | View plan limits and daily API usage. |

## Endpoint reference

### GET /v2/openapi.json

OpenAPI document

**Responses:** 200 Success

### POST /v2/uploads

Create an upload

**JSON body**
- Variant 1:
  - `kind`: string (required)
  - `filename`: string (required)
  - `contentType`: string (required)
  - `sizeBytes`: integer (required)
- 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

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

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

**JSON body**
- `media`: array<string> (required)
- `accountIds`: array<string> (required)
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string
- `priority`: integer
- `scheduledAfter`: string

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

### GET /v2/posts

List posts

**Parameters**
- `limit` (query, optional): integer
- `cursor` (query, optional): string
- `accountId` (query, optional): string
- `status` (query, optional): string
- `since` (query, optional): string

**Responses:** 200 Success

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

Queue an existing draft post

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

**JSON body**
- `accountIds`: array<string> (required)
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string
- `priority`: integer
- `scheduledAfter`: string

**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 an editable post

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

**JSON body**
- `caption`: string
- `hashtags`: array<string>
- `sound`: object {mode, url, urlsByPlatform}
- `notes`: string | null

**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/accounts

List publishing accounts

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

**Responses:** 200 Success

### POST /v2/accounts

Create a publishing account

**JSON body**
- `platform`: string (required)
- `username`: string (required)
- `displayName`: string
- `warmup`: object {searchTerms, instructions} (required)
- `managedPostingEnabled`: boolean
- `credentials`: object {email, password}

**Responses:** 201 Success; 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

## 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_account_queue` | Inspect the next queued posts for an account. | `posts:read` |
| `create_account` | Create an account in setup state with required warm-up search terms. | `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 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 and status filters. | `posts:read` |
| `update_post` | Edit metadata while a post remains editable. | `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–10 image upload IDs for a carousel. Their order is the slide order.
