# Postfeeds API Postfeeds is a social media scheduling platform. This API lets you list connected social accounts, and create, schedule, publish and cancel posts programmatically. Base URL: `https://postfeeds.com/api/v1` (local development: `http://localhost:3000/api/v1`) All requests and responses are JSON. All endpoints require authentication. ## Authentication Send your API key as a Bearer token on every request: ``` Authorization: Bearer pf_live_... ``` Get a key: log in at postfeeds.com -> Dashboard -> API Keys -> "New key". The full key is shown exactly once at creation (only a hash is stored). Keys can be revoked instantly from the same page. Scopes (all granted by default): `read` (all GET endpoints), `posts:write` (create/cancel posts), `profiles:write` (create profiles). ## Rate limits Default: 60 requests/minute per key (fixed window). Every response carries: ``` X-RateLimit-Limit: 60 X-RateLimit-Remaining: 59 X-RateLimit-Reset: 2026-06-11T08:29:00+00:00 ``` Exceeding the limit returns `429` with `Retry-After: 60`. ## Idempotency `POST /posts` supports an `Idempotency-Key` header (any unique string, e.g. a UUID). Retrying with the same key returns the original response with header `Idempotency-Replayed: true` and never creates a duplicate post. ## Errors Every error has this shape and a meaningful HTTP status: ```json { "error": { "code": "invalid_request", "message": "media must be an array of URLs" } } ``` | HTTP | code | meaning | |------|-----------------|------------------------------------------------| | 400 | invalid_request | Malformed body/params | | 401 | unauthorized | Missing, invalid or revoked API key | | 403 | forbidden | Key lacks the required scope | | 404 | not_found | Resource doesn't exist or isn't yours | | 422 | unsupported | e.g. platform not yet supported | | 429 | rate_limited | Rate limit exceeded | | 502 | upstream_error | The social platform rejected the request | | 500 | internal_error | Unexpected server error | ## Core concepts - **Account** — a connected social account (e.g. a TikTok account). Accounts are connected by a human in the Postfeeds dashboard (Dashboard -> Connections), NOT via the API: the OAuth flow requires a logged-in browser session. After connecting, fetch the account's `id` from `GET /accounts` and use it as `accountId` when posting. - **Profile** — a named group of accounts (a brand, client or campaign). - **Post** — one piece of content sent to one account. A single `POST /posts` call fans out to many accounts and creates one post per account. ### Post modes (POST /posts) - `"publishNow": true` — deliver to the platform immediately. - `"scheduledFor": ""` — stored and delivered automatically when due. - Neither — saved as a **draft** (media is persisted; nothing is sent). - `publishNow` and `scheduledFor` are mutually exclusive. ### Post statuses `draft` -> `scheduled` -> `uploading` -> `processing` -> `failed` | `canceled` - `processing` means the content was handed to the platform. For TikTok uploads this means the video/photos landed in the user's TikTok inbox (drafts) — the user finishes publishing in the TikTok app. - `failed` posts carry an `error` message. - `canceled` is set by `DELETE /posts/{id}` (drafts and scheduled only). ## Media rules Media is passed **by URL** — the server downloads it. URLs must be public http(s) (no localhost). If your files aren't hosted anywhere, upload them first with `POST /media/presign` (below) and use the returned `publicUrl`. Per post, exactly one of: - **Video**: one URL ending .mp4 / .mov / .webm, max 200 MB. - **Photos**: 1–35 URLs ending .jpg / .jpeg / .png / .webp, max 20 MB each. Mixing video and images in one post is rejected. Photos are automatically resized to fit TikTok's 1080x1920 canvas, re-encoded as JPEG and rehosted on Postfeeds' TikTok-verified storage — your image host does NOT need TikTok URL verification. ## Platform support Currently delivers to **TikTok** and **LinkedIn**. Other platforms (instagram, facebook, x, youtube, ...) return `422 unsupported` and are coming as their backends ship. TikTok notes: - Video posts use TikTok's inbox/draft flow: content lands in the connected user's TikTok app inbox and they tap to finish publishing. This is the correct flow for apps whose TikTok audit is pending (unaudited TikTok apps are limited to private/SELF_ONLY visibility for direct posting anyway). - Photo posts land as drafts via TikTok's MEDIA_UPLOAD. - Media is required (`media` cannot be empty for TikTok targets). LinkedIn notes: - Posts publish **straight to the member's feed** (no draft step) — the post status is `published` in the create response, with the post URN in `publishId`. - Supported content: text-only (omit `media`), or 1-35 images. Video is not yet supported for LinkedIn targets. - LinkedIn access tokens last ~60 days and can't be auto-refreshed; when one expires the account shows as `expired` in GET /accounts and must be reconnected in the dashboard. ## Endpoints ### GET /accounts List connected social accounts. Use an account's `id` as `accountId`. ```bash curl https://postfeeds.com/api/v1/accounts \ -H "Authorization: Bearer $PF_API_KEY" ``` Response 200: ```json { "accounts": [ { "id": "adee4cee-399a-480e-ab8d-274fcc31d794", "platform": "tiktok", "handle": "post.feeds", "displayName": "Postfeeds", "avatarUrl": "https://...", "status": "active", "profileId": "98437905-fa96-4f2d-b5bf-60071c52a6e7", "connectedAt": "2026-06-06T16:39:34.144031+00:00" } ] } ``` `status` is one of `active`, `expired`, `revoked`, `error`. ### GET /profiles ```json { "profiles": [ { "id": "...", "name": "Default Profile", "color": "violet", "isDefault": true, "createdAt": "2026-06-05T12:41:32+00:00" } ] } ``` ### POST /profiles Body: `{ "name": "Client — Acme", "color": "violet" }` (color optional). Returns 201 with the created profile object. ### POST /posts Create, schedule or publish a post to one or more accounts. Body fields: | field | type | required | notes | |--------------|----------------------------------------|----------|-------| | content | string | yes* | the caption/description, max 4000 chars (2200 for video) (*may be empty string) | | title | string | no | video title | | media | string[] of URLs | yes | see Media rules | | platforms | [{ platform, accountId }] | yes | one entry per target account; platform: "tiktok" or "linkedin" | | scheduledFor | ISO-8601 datetime (future) | no | schedule for later | | publishNow | boolean | no | deliver immediately | | tiktokSettings | object | no | TikTok only: `{ "draft": false, "privacyLevel": "..." }` directly publishes photos; omit it or use `draft: true` for TikTok drafts | ```bash curl -X POST https://postfeeds.com/api/v1/posts \ -H "Authorization: Bearer $PF_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 1f6d2c3e-8a4b-4f0e-9d1a-aaaa00000001" \ -d '{ "content": "New drop", "media": ["https://cdn.example.com/clip.mp4"], "platforms": [ { "platform": "tiktok", "accountId": "adee4cee-399a-480e-ab8d-274fcc31d794" } ], "publishNow": true }' ``` Directly publish a TikTok photo or carousel by setting `publishNow: true` and passing a privacy level currently available to the connected creator: ```json { "content": "Six ideas for a better launch", "media": [ "https://cdn.example.com/slide-1.png", "https://cdn.example.com/slide-2.png" ], "platforms": [ { "platform": "tiktok", "accountId": "adee4cee-399a-480e-ab8d-274fcc31d794" } ], "publishNow": true, "tiktokSettings": { "draft": false, "privacyLevel": "SELF_ONLY" } } ``` Direct TikTok publishing currently supports photo posts and carousels. Video requests continue to use TikTok drafts. Response 201 — one item per target account: ```json { "posts": [ { "id": "a4a4ab72-5183-46af-8c77-2785ce2572ec", "accountId": "adee4cee-399a-480e-ab8d-274fcc31d794", "platform": "tiktok", "status": "processing", "publishId": "v_inbox_file~v2.123456789" } ] } ``` Per-item `status` is `processing` (publishNow), `scheduled`, `draft`, or `failed` (with `error`). A failed item does not fail the whole request. ### GET /posts Query params: `status`, `platform`, `limit` (default 20, max 100), `offset` (default 0). Returns `{ "posts": [...], "limit": 20, "offset": 0 }`, newest first. ### GET /posts/{id} ```json { "id": "a4a4ab72-...", "accountId": "adee4cee-...", "platform": "tiktok", "title": "", "content": "New drop", "status": "processing", "scheduledFor": null, "publishId": "v_inbox_file~v2.123456789", "error": null, "createdAt": "2026-06-11T08:29:16+00:00" } ``` ### DELETE /posts/{id} Cancels a `draft` or `scheduled` post (other statuses -> 400). Stored media is removed. Returns `{ "id": "...", "status": "canceled" }`. ### POST /media/presign Direct upload for files that aren't hosted at a public URL. Returns a presigned PUT URL (valid 15 minutes) and the public URL the file will have. Body: `{ "contentType": "image/jpeg", "size": 199197 }` (`contentType` required: jpeg/png/webp images ≤ 20 MB, mp4/mov/webm video ≤ 200 MB. `size` in bytes is optional but recommended — when provided, the upload is rejected unless Content-Length matches.) ```bash curl -X POST https://postfeeds.com/api/v1/media/presign \ -H "Authorization: Bearer $PF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contentType": "image/jpeg", "size": 199197 }' ``` Response 201: ```json { "uploadUrl": "https://...r2.cloudflarestorage.com/...&X-Amz-Signature=...", "publicUrl": "https://media.postfeeds.com//uploads/.jpg", "key": "/uploads/.jpg", "expiresIn": 900 } ``` Then `PUT` the raw file bytes to `uploadUrl` with the same `Content-Type` header, and use `publicUrl` in the `media` array of `POST /posts`. ### GET /usage ```json { "postsThisMonth": 8, "connections": { "used": 2, "max": 2 }, "rateLimit": { "perMinute": 60 } } ``` ## Quickstart 1. Create an API key at Dashboard -> API Keys. 2. Connect a TikTok account at Dashboard -> Connections (browser, one time). 3. `GET /accounts` -> copy the account `id`. 4. `POST /posts` with `publishNow: true` -> check the TikTok app inbox. 5. Poll `GET /posts/{id}` for status. ## Migrating from a Zernio-style client If your code currently targets Zernio's API, the mapping is: | Zernio | Postfeeds | |------------------------------------------|----------------------------------------------------| | `https://zernio.com/api/v1` | `https://postfeeds.com/api/v1` | | key format `sk_...` | `pf_live_...` | | `GET /accounts` -> `_id` | `GET /accounts` -> `id` | | `POST /posts` -> `{ post: { _id } }` | `POST /posts` -> `{ posts: [{ id }] }` (array) | | body `mediaItems` | body `media` (plain array of URL strings) | | body `tiktokSettings` / `draft: true`| use Postfeeds `tiktokSettings`; `draft: false` + `privacyLevel` directly publishes photos | | `POST /media/presign` | `POST /media/presign` — same flow: presigned PUT, then use `publicUrl` | | `GET /connect/tiktok` | not available — connect accounts in the dashboard | | `POST /tools/validate/post` | not available — POST /posts returns precise 400s | | `GET /logs` | use `GET /posts/{id}` (`status` + `error`) | Account/profile IDs from Zernio are meaningless here — re-fetch them from `GET /accounts` and `GET /profiles` after connecting accounts.