Postgale REST API
Publish, schedule and moderate posts on Reddit, Instagram, Facebook Pages, Threads and TikTok, answer Instagram and Messenger direct messages, and send and receive WhatsApp messages (WhatsApp guide). This page is a concise reference; the machine-readable spec is at /api/v1/openapi.json. AI agents can use the same operations through the MCP server.
Basics
- Base URL:
https://postgale.com/api/v1. JSON in, JSON out. - Authentication:
Authorization: Bearer sk_…with a key from Dashboard → API keys. A key acts for its organization only. - Rate limit: 60 requests per minute per key by default; over the limit answers 429.
- Idempotency: send
x-request-idonPOST /posts. A retry with the same id returns the original post (200 instead of 201). The same text cannot be posted twice within 24 hours. - Approval: when the organization, an account or the key requires approval, new posts are held as
pending_approvaluntil a person approves them in the dashboard (/posts/{id}/approveand/rejectalways answer 403 to API keys). While approval applies, comment moderation, published-post changes and sending direct messages are refused withapproval_required; reads are never blocked. - API keys (
/keys) and organization settings (/org-settings) are dashboard-session only.
Errors
Errors are {"error":{"message":"…","code":"…"}}; code is present when there is a stable one.
| Status | Code | Meaning |
|---|---|---|
| 400 | — | Invalid input (message explains which field) |
| 401 | — | Missing, unknown or revoked API key |
| 403 | approval_required | Approval applies to the key, organization or account: comment moderation, published-post changes and sending direct messages are refused |
| 403 | missing_permission | The account did not grant the permission; add it to the Meta app, then reconnect the account |
| 404 | — | Unknown id, or it belongs to another organization |
| 409 | reconnect_required | The token is no longer valid; the account is needs_reconnect |
| 409 | outside_messaging_window | Direct message outside Meta’s window (24 hours after the person’s last message, 7 days with HUMAN_AGENT); nothing was sent |
| 409 | recipient_unavailable | The person cannot receive messages from this account right now |
| 409 | sync_required | POST /inbox/conversations/{id}/sync before the account’s conversations were synced |
| 409 | phone_number_not_registered | WhatsApp: register the number (POST /whatsapp/accounts/{id}/register) |
| 409 | APPROVAL_REQUIRED, ALREADY_PUBLISHED, NOT_EDITABLE, … | Post state conflicts |
| 410 | pending_expired | Pending connection expired or already completed |
| 429 | rate_limited | The platform rate-limited the request (Postgale’s own key limit answers 429 without a code) |
| 400 | not_supported | The platform does not offer this feature |
| 400 | invalid_request | The platform rejected the input |
| 400 | template_error | WhatsApp: template name, language, parameters or status rejected by Meta |
| 403 | recipient_not_allowed, account_restricted | WhatsApp: recipient not in the test number’s allowed list; WABA restricted or payment issue |
| 429 | marketing_limit | WhatsApp: Meta limits marketing messages to this person; retry later |
| 502 | platform_error | Any other platform failure |
For missing_permission, GET /accounts/{id}/health lists the account's missingPermissions and the features they block.
Endpoints
Paths are relative to https://postgale.com/api/v1.
| Method | Path | Description |
|---|---|---|
| Accounts & connect | ||
| GET | /accounts | Connected accounts; ?externalId= filters by your reference |
| GET | /accounts/{id} | One account |
| PATCH | /accounts/{id} | Body {externalId} (≤200 chars, null clears it) |
| GET | /accounts/health | Health of every account: token, scopes, missingPermissions, recommendations (cached 10 min; ?refresh=1) |
| GET | /accounts/{id}/health | Health of one account (?refresh=1) |
| GET | /accounts/{id}/publishing-limit | Posts used/allowed in the 24-hour window (Instagram, Threads) |
| POST | /accounts/{id}/webhook-subscription | Subscribe the account to Meta webhooks (comments / Page feed and the messaging fields) for comment.received and message.* |
| GET | /connect/{platform} | OAuth URL for the account owner (valid 10 min); ?externalId= is stored on the new account(s). WhatsApp: the Embedded Signup page |
| GET | /connect/pending | Unexpired pending connections of the organization, newest first (find the id after an API-started connect) |
| GET | /connect/pending/{id} | Options of a pending connection, e.g. the Facebook Pages to pick from |
| POST | /connect/pending/{id}/select | Body {optionIds}: connect one account per option (expires after 30 min) |
| Media | ||
| GET | /media | The 200 most recent uploads |
| POST | /media | multipart file, or JSON {url | base64, fileName?, mimeType?}; returns publicUrl |
| GET | /media/{id} | One upload |
| Posts | ||
| GET | /posts | The 100 most recent posts with targets; ?status= |
| POST | /posts | Create a draft, scheduled or live post (see the example below) |
| GET | /posts/{id} | One post with per-target status, platformPostId, URL and error |
| PATCH | /posts/{id} | Body {content}: replace the text of a draft, scheduled, pending_approval or failed post |
| DELETE | /posts/{id} | Delete from Postgale (cancels scheduled posts; published posts stay on the platforms) |
| POST | /posts/{id}/publish | Publish live now (retries failed targets) |
| Published posts | ||
| POST | /posts/{id}/edit | Body {content, accountId?}: edit the live post (Facebook) |
| POST | /posts/{id}/unpublish | Body {accountId?}: delete the live post from the platform (Facebook, Threads) |
| POST | /posts/{id}/comments-settings | Body {enabled, accountId?}: turn comments on or off (Instagram) |
| Comments & inbox | ||
| GET | /posts/{id}/comments | Comments on the published targets of a post |
| POST | /comments/{id}/reply | Body {platformPostId, text}: reply to a comment (Reddit, Instagram, Facebook, Threads) |
| GET | /inbox/comments | ?postId= or ?accountId=&platformPostId=; limit, after (Instagram, Facebook, Threads) |
| POST | /inbox/comments | Body {accountId, platformPostId, text}: top-level comment as the account |
| POST | /inbox/comments/{commentId}/reply | Body {accountId, text} |
| POST/DELETE | /inbox/comments/{commentId}/hide | Hide / unhide; {accountId} in body or query |
| POST/DELETE | /inbox/comments/{commentId}/like | Like / unlike (Facebook only); {accountId} |
| DELETE | /inbox/comments/{commentId} | Delete a comment (Instagram, Facebook); ?accountId= |
| GET | /inbox/events | Inbound Meta webhook events; ?kind=comment|mention|reply|feed|message|other&accountId&limit |
| GET | /accounts/{id}/mentions | Media the Instagram account was tagged in; ?limit= (default 25) |
| Direct messages | ||
| GET | /inbox/conversations | Instagram, Messenger and WhatsApp conversations, latest first; ?accountId&platform=instagram|facebook|whatsapp&status=active|archived&unread=true&limit&cursor |
| POST | /inbox/conversations/sync | Body {accountId, limit?, pages?}: backfill conversations and their latest messages from Meta |
| GET | /inbox/conversations/{id} | One conversation with its messagingWindow |
| PATCH | /inbox/conversations/{id} | Body {status: active | archived} |
| GET | /inbox/conversations/{id}/messages | Messages, oldest to newest; ?limit&before (nextBefore of the previous page) |
| POST | /inbox/conversations/{id}/messages | Body {text? | mediaUrl? | mediaId?, attachmentType?, tag?: HUMAN_AGENT}: send a live DM (201). WhatsApp also takes template, interactive, location, reaction, whatsappMediaId, replyTo |
| POST | /inbox/conversations/{id}/read | Body {platform?}: mark read, and seen on Meta unless platform is false |
| POST | /inbox/conversations/{id}/sync | Body {limit?}: fetch the latest messages of one conversation from Meta |
| WhatsApp (see /docs/whatsapp) | ||
| POST | /whatsapp/connect | Body {accessToken, phoneNumberId, wabaId, pin?, register?, subscribe?, externalId?}: connect a number with an existing token (201) |
| GET | /whatsapp/accounts/{id}/phone-number | Display number, verified name, quality rating, messaging limit, onboarding state; ?refresh=true |
| POST | /whatsapp/accounts/{id}/register | Body {pin?}: (re-)register the number for the Cloud API |
| POST | /whatsapp/accounts/{id}/messages | Body {to | recipient | conversationId, type, …}: send a live message (201); outside 24 hours only templates |
| POST | /whatsapp/accounts/{id}/messages/{messageId}/read | Body {typing?}: mark an inbound message read (blue ticks), optional typing indicator |
| GET | /whatsapp/accounts/{id}/templates | Templates of the WABA; ?status&name&category&language&limit&after |
| POST | /whatsapp/accounts/{id}/templates | Body {name, language, category, components}: submit for review (201) |
| GET | /whatsapp/accounts/{id}/templates/{templateId} | One template |
| DELETE | /whatsapp/accounts/{id}/templates/{templateId} | Delete this language; ?allLanguages=true every language of the name; ?name= |
| POST | /whatsapp/accounts/{id}/media | Body {mediaId | url}: upload to WhatsApp, returns the WhatsApp media id (201) |
| GET | /whatsapp/accounts/{id}/media/{waMediaId} | Media info; ?download=1 streams the bytes |
| DELETE | /whatsapp/accounts/{id}/media/{waMediaId} | Delete the media from WhatsApp |
| Meta Ads (see /docs/ads) | ||
| GET | /ads/accounts | Ad accounts of a connected Facebook account; ?accountId |
| POST | /ads/boost | Body {accountId, adAccountId, postId | platformPostId, budget, schedule?, targeting?, confirm: true}: boost a post (PAUSED by default) |
| GET | /ads/campaigns | ?accountId&adAccountId&statuses&limit&after |
| POST | /ads/campaigns | Body {accountId, adAccountId, name, objective | goal, budget?, confirm: true}: create (PAUSED) |
| GET | /ads/campaigns/{id} | One campaign; ?accountId |
| PATCH | /ads/campaigns/{id} | Body {accountId, name?, status?, budgets?, confirm?}: update or pause |
| GET | /ads/adsets | ?accountId&adAccountId&parentId (campaign) |
| POST | /ads/adsets | Body {accountId, adAccountId, campaignId, name, targeting, dailyBudget | lifetimeBudget, startTime?, endTime?, confirm: true} |
| GET | /ads/adsets/{id} | One ad set |
| PATCH | /ads/adsets/{id} | Update or pause an ad set |
| GET | /ads/creatives | Creatives of the ad account |
| POST | /ads/creatives | Body {objectStoryId | instagramMediaId | link + message + imageUrl, callToAction?} |
| GET | /ads/ads | Ads; ?parentId (ad set) |
| POST | /ads/ads | Body {adSetId, creativeId, name, confirm: true}: create (PAUSED) |
| GET | /ads/ads/{id} | One ad |
| PATCH | /ads/ads/{id} | Update or pause an ad |
| GET | /ads/audiences | Custom audiences |
| POST | /ads/audiences | Body {name, description?}: customer-list audience |
| GET | /ads/audiences/{id} | One audience |
| DELETE | /ads/audiences/{id} | Delete an audience |
| POST | /ads/audiences/{id}/users | Body {emails | phones}: hashed with SHA-256 before upload |
| GET | /ads/insights | ?accountId&objectId&datePreset|since&until&level&breakdowns&timeIncrement |
| GET | /ads/targeting/search | ?accountId&q&type=adinterest|adgeolocation|… |
| Analytics | ||
| GET | /analytics/posts/{id} | Insights per published target; ?metrics=a,b&accountId= |
| GET | /analytics/accounts/{id} | Account insights; ?metrics&period&since&until&metricType&breakdown&timeframe |
| GET | /analytics/accounts/{id}/demographics | ?breakdown=age|city|country|gender&metric&timeframe (Instagram needs 100+ followers) |
| GET | /analytics/accounts/{id}/online-followers | Followers online per hour (Instagram) |
| GET | /analytics/best-time | Best UTC weekday/hour slots from past posts; ?accountId&days=90 |
| Webhooks | ||
| GET | /webhooks | Webhook endpoints (secrets are never shown again) |
| POST | /webhooks | Body {url, events?}: returns the signing secret once |
| DELETE | /webhooks?id={id} | Remove an endpoint |
| GET | /webhooks/{id}/deliveries | Newest deliveries; ?status=pending|sending|delivered|failed&limit |
| POST | /webhooks/{id}/test | Queue a webhook.test event to this endpoint only |
| POST | /webhooks/deliveries/{deliveryId}/redeliver | Queue a delivery again (same X-Idempotency-Key) |
Create a post
platforms lists 1–20 distinct accounts, each with its own platformSpecificData. Without publishNow or scheduledFor the post is a draft; publishNow: true publishes live. Host media with POST /media and use its publicUrl.
curl https://postgale.com/api/v1/posts \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-H 'x-request-id: menu-2026-11-01' \
-d @post.json{
"content": "New menu is live ☕",
"scheduledFor": "2026-11-01T15:00:00Z",
"platforms": [
{
"accountId": "acc_instagram",
"platformSpecificData": {
"mediaUrl": "https://postgale.com/media/…/menu.jpg",
"firstComment": "#coffee #brunch"
}
},
{
"accountId": "acc_facebook_page",
"platformSpecificData": {
"mediaUrl": "https://postgale.com/media/…/menu.jpg"
}
},
{
"accountId": "acc_threads",
"platformSpecificData": {
"topicTag": "coffee",
"replyControl": "everyone"
}
}
]
}platformSpecificData by platform
Only the keys for the account's platform are used. Skipped options are returned as warnings on the target.
| Key | Notes |
|---|---|
mediaUrl / mediaUrls | Required. Public HTTPS; images must be JPEG. Carousel: 2–10 items |
mediaType / contentType | IMAGE | VIDEO | REELS | STORIES | CAROUSEL, or feed | reel | story | carousel. Videos publish as Reels |
caption (post content) | Max 2200 characters and 30 hashtags; not used on stories |
firstComment | Max 2200 characters; not on stories |
collaborators | Up to 3 usernames; not on stories |
userTags | [{username, x, y, mediaIndex?}]; photos need x and y in [0,1], videos only the username |
locationId | Location page id; not on stories |
altText / altTexts | Images only, max 1000 characters; altTexts per carousel item |
shareToFeed, thumbOffset, coverUrl, audioName | Reels only (thumbOffset in ms, coverUrl HTTPS) |
trialParams | Reels only: {graduationStrategy: MANUAL | SS_PERFORMANCE} |
commentsEnabled | false turns comments off right after publishing |
Facebook Pages
| Key | Notes |
|---|---|
contentType | feed (default) | reel | story |
mediaUrl / mediaUrls, mediaType | Feed: one photo or video, or 2–10 photos (no videos mixed in). Reel: one video. Story: one photo or video, no caption. mediaType IMAGE | VIDEO |
message (post content) | Max 63206 characters |
link | HTTPS link; text posts without media only |
title | Videos and reels, max 255 characters |
firstComment | Max 8000 characters; not on stories or drafts |
draft | true creates an unpublished Page post (text, link or multi-photo only) |
Threads
| Key | Notes |
|---|---|
text (post content) | Max 500 characters |
mediaUrl / mediaUrls, mediaType | TEXT | IMAGE | VIDEO | CAROUSEL (2–20 items) |
topicTag | 1–50 characters, no "." or "&" |
replyControl | everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only |
linkAttachment | HTTPS URL shown as a preview; text posts only |
altText / altTexts | Max 1000 characters |
firstComment | Max 500 characters, published as a reply |
threadItems | Reply chain of 1–20 {content, mediaUrl?, mediaUrls?, mediaType?, altText?}; replaces the post content |
| Key | Notes |
|---|---|
subreddit | Required for live posts (without r/) |
title, url, flairId | Title defaults to the first line (max 300); url makes a link post |
TikTok
| Key | Notes |
|---|---|
privacyLevel | Required: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY (unaudited apps: SELF_ONLY) |
mediaUrl / mediaUrls, mediaType | video (default) or image (photo posts take mediaUrls; title max 90) |
disableComment, disableDuet, disableStitch, brandContent, brandOrganic, autoAddMusic | Interaction and disclosure settings |
Direct messages
Instagram (Instagram API with Instagram Login) and Facebook Page Messenger conversations. The account needs instagram_business_manage_messages (Instagram) or pages_messaging (Facebook Pages); without it the endpoints that call Meta answer 403 missing_permission and accounts connected earlier must reconnect.
- Inbound: Meta webhooks deliver new messages, echoes, reads, deliveries, reactions and postbacks. Postgale stores them as conversations and messages (deduplicated by Meta message id), as inbound events of kind
message, and sends themessage.*webhooks below. The account must be subscribed to the messaging fields (POST /accounts/{id}/webhook-subscription). - Messaging window: Meta only lets the business reply to someone who wrote first. Free-form messages are allowed for 24 hours after the participant's last message. With
tag: "HUMAN_AGENT"a person (not automation) can reply up to 7 days after it; the Meta app needs the Human Agent permission. Outside the windowPOST …/messagesanswers409 outside_messaging_windowbefore anything is sent. Every conversation reportsmessagingWindow{open, closesAt, humanAgentClosesAt}. - Sending: text (Instagram ≤1000, Messenger ≤2000 characters) or one attachment (
mediaUrl, ormediaIdof an upload;attachmentTypeimage | video | audio | file), not both. It is a live message to a real person and cannot be edited or deleted. While approval applies it is refused withapproval_required. - Read:
POST …/readsetsunreadCountto 0 and sendsmark_seento Meta (only while the window is open; failures come back aswarnings). Archived conversations become active again on the next inbound message. - Sync:
POST /inbox/conversations/syncbackfills up topages(default 3, max 10) ×limit(default 25, max 100) conversations. Meta returns details only for the 20 most recent messages of each conversation, so older history is not available. Syncs are idempotent and emit no webhooks.
{
"id": "cmdlv0a1b0001x2y3z4",
"event": "message.received",
"createdAt": "2026-10-18T09:15:02.481Z",
"data": {
"eventId": "cmdlv09zz0000x2y3z4",
"platform": "instagram",
"accountId": "acc_instagram",
"externalId": "cust-1",
"conversationId": "cmdluz8k70003a1b2c3",
"messageId": "cmdlv0a0q0002x2y3z4",
"platformMessageId": "aWdfZAG1faXRlbToxOklHTWVzc2FnZAUlE",
"platformMessageIds": [
"aWdfZAG1faXRlbToxOklHTWVzc2FnZAUlE"
],
"direction": "inbound",
"participant": {
"id": "1234567890123456",
"username": "curious_shopper",
"name": null
},
"text": "Hi! Do you ship to Canada?",
"attachments": [],
"postback": null,
"reaction": null,
"watermark": null,
"sentAt": "2026-10-18T09:15:00.000Z",
"receivedAt": "2026-10-18T09:15:02.470Z"
}
}Webhooks
Register an HTTPS endpoint with POST /webhooks and a list of events (default post.published, post.failed, post.pending_approval). Each delivery is a POST with the body {id, event, createdAt, data}:
| Event | data |
|---|---|
post.published | {postId, status} |
post.failed | {postId, status} |
post.pending_approval | {postId} |
post.approved | {postId, status} |
post.rejected | {postId, status} |
comment.received | {eventId, platform, accountId, externalId, postId, platformPostId, comment{id, text, username, parentId}, receivedAt} |
mention.received | Same shape as comment.received. With Instagram Login, @mentions in comments arrive as comment.received (Meta sends them in the comments field) |
message.received | {eventId, platform, accountId, externalId, conversationId, messageId, platformMessageId, platformMessageIds, direction, participant{id, username, name}, text, attachments[{type, url}], postback, reaction, watermark, sentAt, receivedAt}: a DM (or postback) from a person |
message.sent | Same shape, direction outbound: sent through Postgale (eventId null) or elsewhere, e.g. the Instagram app (Meta echo) |
message.read | Same shape: the person read the account’s messages up to watermark / platformMessageId |
message.delivered | Same shape (Messenger): platformMessageIds and/or watermark delivered |
message.reaction | Same shape with reaction {action: react | unreact, emoji} |
message.status | WhatsApp: {eventId, platform, accountId, externalId, conversationId, messageId, platformMessageId, status: sent | delivered | read | failed | deleted, errors, pricing, conversation, recipient{phone, userId}, timestamp, receivedAt} |
template.status | WhatsApp: {eventId, platform, accountId, externalId, wabaId, kind: status, templateId, name, language, event: APPROVED | REJECTED | PAUSED | …, reason, otherInfo, receivedAt} |
template.quality | WhatsApp: same shape, kind quality (previousQualityScore, newQualityScore) or category (previousCategory, newCategory, correctCategory) |
account.quality | WhatsApp: {eventId, platform, accountId, externalId, wabaId, displayPhoneNumber, event, currentLimit, oldLimit, qualityRating, maxDailyConversations, receivedAt} |
account.connected | {accountId, platform, accountName, externalId} |
account.disconnected | {accountId, platform, accountName, externalId, reason, error} |
connection.pending | {pendingId, platform, externalId, optionCount, expiresAt}: OAuth needs a choice (e.g. several Facebook Pages); select within 30 minutes |
webhook.test | Sent only by POST /webhooks/{id}/test; cannot be subscribed to |
X-SocialAPI-Signature: hex HMAC-SHA256 of`${X-SocialAPI-Timestamp}.${body}`with the webhook secret.X-SocialAPI-Timestamp(unix seconds),X-SocialAPI-Event,X-SocialAPI-Delivery(this delivery row).X-Idempotency-Key: the original delivery id (also the bodyid), stable across retries and redeliveries.- Any non-2xx answer or timeout (10 s) is retried with exponential backoff, up to 6 attempts. Inspect with
GET /webhooks/{id}/deliveries, replay withPOST /webhooks/deliveries/{deliveryId}/redeliver, check your endpoint withPOST /webhooks/{id}/test. comment.received,mention.receivedandmessage.*need the account subscribed to Meta webhooks (POST /accounts/{id}/webhook-subscription). Comments the account itself posted are not reported.
import { createHmac, timingSafeEqual } from 'node:crypto';
const seen = new Set<string>(); // use a persistent store in production
export async function POST(request: Request) {
const body = await request.text(); // the raw body, before JSON.parse
const timestamp = request.headers.get('x-socialapi-timestamp') ?? '';
const signature = request.headers.get('x-socialapi-signature') ?? '';
// Reject stale or replayed requests (5-minute tolerance).
if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return new Response('stale', { status: 400 });
const expected = Buffer.from(createHmac('sha256', process.env.POSTGALE_WEBHOOK_SECRET!).update(`${timestamp}.${body}`).digest('hex'));
const actual = Buffer.from(signature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return new Response('bad signature', { status: 401 });
// Retries and redeliveries carry the same key: process each event once.
const key = request.headers.get('x-idempotency-key')!;
if (seen.has(key)) return new Response('ok');
seen.add(key);
const { event, data } = JSON.parse(body);
// ... handle event
return new Response('ok');
}Inbound Meta webhooks
For operators configuring the Meta apps of a Postgale instance. Set each product's callback URL to:
https://postgale.com/api/webhooks/meta/instagramhttps://postgale.com/api/webhooks/meta/facebookhttps://postgale.com/api/webhooks/meta/threadshttps://postgale.com/api/webhooks/meta/whatsapp
- GET performs the verification handshake:
hub.verify_tokenmust equalMETA_WEBHOOK_VERIFY_TOKEN, andhub.challengeis echoed. - POST requires a valid
X-Hub-Signature-256made with that product's app secret (INSTAGRAM_CLIENT_SECRET,FACEBOOK_APP_SECRET,THREADS_APP_SECRET; WhatsApp:WHATSAPP_APP_SECRET, thenFACEBOOK_APP_SECRET); otherwise 401. - Events are deduplicated, stored as inbound events (
GET /inbox/events) and forwarded ascomment.received/mention.received. Messaging events are stored as conversations and messages and forwarded asmessage.*.