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

Errors

Errors are {"error":{"message":"…","code":"…"}}; code is present when there is a stable one.

StatusCodeMeaning
400—Invalid input (message explains which field)
401—Missing, unknown or revoked API key
403approval_requiredApproval applies to the key, organization or account: comment moderation, published-post changes and sending direct messages are refused
403missing_permissionThe 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
409reconnect_requiredThe token is no longer valid; the account is needs_reconnect
409outside_messaging_windowDirect message outside Meta’s window (24 hours after the person’s last message, 7 days with HUMAN_AGENT); nothing was sent
409recipient_unavailableThe person cannot receive messages from this account right now
409sync_requiredPOST /inbox/conversations/{id}/sync before the account’s conversations were synced
409phone_number_not_registeredWhatsApp: register the number (POST /whatsapp/accounts/{id}/register)
409APPROVAL_REQUIRED, ALREADY_PUBLISHED, NOT_EDITABLE, …Post state conflicts
410pending_expiredPending connection expired or already completed
429rate_limitedThe platform rate-limited the request (Postgale’s own key limit answers 429 without a code)
400not_supportedThe platform does not offer this feature
400invalid_requestThe platform rejected the input
400template_errorWhatsApp: template name, language, parameters or status rejected by Meta
403recipient_not_allowed, account_restrictedWhatsApp: recipient not in the test number’s allowed list; WABA restricted or payment issue
429marketing_limitWhatsApp: Meta limits marketing messages to this person; retry later
502platform_errorAny 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.

MethodPathDescription
Accounts & connect
GET/accountsConnected accounts; ?externalId= filters by your reference
GET/accounts/{id}One account
PATCH/accounts/{id}Body {externalId} (≤200 chars, null clears it)
GET/accounts/healthHealth of every account: token, scopes, missingPermissions, recommendations (cached 10 min; ?refresh=1)
GET/accounts/{id}/healthHealth of one account (?refresh=1)
GET/accounts/{id}/publishing-limitPosts used/allowed in the 24-hour window (Instagram, Threads)
POST/accounts/{id}/webhook-subscriptionSubscribe 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/pendingUnexpired 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}/selectBody {optionIds}: connect one account per option (expires after 30 min)
Media
GET/mediaThe 200 most recent uploads
POST/mediamultipart file, or JSON {url | base64, fileName?, mimeType?}; returns publicUrl
GET/media/{id}One upload
Posts
GET/postsThe 100 most recent posts with targets; ?status=
POST/postsCreate 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}/publishPublish live now (retries failed targets)
Published posts
POST/posts/{id}/editBody {content, accountId?}: edit the live post (Facebook)
POST/posts/{id}/unpublishBody {accountId?}: delete the live post from the platform (Facebook, Threads)
POST/posts/{id}/comments-settingsBody {enabled, accountId?}: turn comments on or off (Instagram)
Comments & inbox
GET/posts/{id}/commentsComments on the published targets of a post
POST/comments/{id}/replyBody {platformPostId, text}: reply to a comment (Reddit, Instagram, Facebook, Threads)
GET/inbox/comments?postId= or ?accountId=&platformPostId=; limit, after (Instagram, Facebook, Threads)
POST/inbox/commentsBody {accountId, platformPostId, text}: top-level comment as the account
POST/inbox/comments/{commentId}/replyBody {accountId, text}
POST/DELETE/inbox/comments/{commentId}/hideHide / unhide; {accountId} in body or query
POST/DELETE/inbox/comments/{commentId}/likeLike / unlike (Facebook only); {accountId}
DELETE/inbox/comments/{commentId}Delete a comment (Instagram, Facebook); ?accountId=
GET/inbox/eventsInbound Meta webhook events; ?kind=comment|mention|reply|feed|message|other&accountId&limit
GET/accounts/{id}/mentionsMedia the Instagram account was tagged in; ?limit= (default 25)
Direct messages
GET/inbox/conversationsInstagram, Messenger and WhatsApp conversations, latest first; ?accountId&platform=instagram|facebook|whatsapp&status=active|archived&unread=true&limit&cursor
POST/inbox/conversations/syncBody {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}/messagesMessages, oldest to newest; ?limit&before (nextBefore of the previous page)
POST/inbox/conversations/{id}/messagesBody {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}/readBody {platform?}: mark read, and seen on Meta unless platform is false
POST/inbox/conversations/{id}/syncBody {limit?}: fetch the latest messages of one conversation from Meta
WhatsApp (see /docs/whatsapp)
POST/whatsapp/connectBody {accessToken, phoneNumberId, wabaId, pin?, register?, subscribe?, externalId?}: connect a number with an existing token (201)
GET/whatsapp/accounts/{id}/phone-numberDisplay number, verified name, quality rating, messaging limit, onboarding state; ?refresh=true
POST/whatsapp/accounts/{id}/registerBody {pin?}: (re-)register the number for the Cloud API
POST/whatsapp/accounts/{id}/messagesBody {to | recipient | conversationId, type, …}: send a live message (201); outside 24 hours only templates
POST/whatsapp/accounts/{id}/messages/{messageId}/readBody {typing?}: mark an inbound message read (blue ticks), optional typing indicator
GET/whatsapp/accounts/{id}/templatesTemplates of the WABA; ?status&name&category&language&limit&after
POST/whatsapp/accounts/{id}/templatesBody {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}/mediaBody {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/accountsAd accounts of a connected Facebook account; ?accountId
POST/ads/boostBody {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/campaignsBody {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/adsetsBody {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/creativesCreatives of the ad account
POST/ads/creativesBody {objectStoryId | instagramMediaId | link + message + imageUrl, callToAction?}
GET/ads/adsAds; ?parentId (ad set)
POST/ads/adsBody {adSetId, creativeId, name, confirm: true}: create (PAUSED)
GET/ads/ads/{id}One ad
PATCH/ads/ads/{id}Update or pause an ad
GET/ads/audiencesCustom audiences
POST/ads/audiencesBody {name, description?}: customer-list audience
GET/ads/audiences/{id}One audience
DELETE/ads/audiences/{id}Delete an audience
POST/ads/audiences/{id}/usersBody {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-followersFollowers online per hour (Instagram)
GET/analytics/best-timeBest UTC weekday/hour slots from past posts; ?accountId&days=90
Webhooks
GET/webhooksWebhook endpoints (secrets are never shown again)
POST/webhooksBody {url, events?}: returns the signing secret once
DELETE/webhooks?id={id}Remove an endpoint
GET/webhooks/{id}/deliveriesNewest deliveries; ?status=pending|sending|delivered|failed&limit
POST/webhooks/{id}/testQueue a webhook.test event to this endpoint only
POST/webhooks/deliveries/{deliveryId}/redeliverQueue 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.

POST/api/v1/posts
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
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.

Instagram

KeyNotes
mediaUrl / mediaUrlsRequired. Public HTTPS; images must be JPEG. Carousel: 2–10 items
mediaType / contentTypeIMAGE | 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
firstCommentMax 2200 characters; not on stories
collaboratorsUp to 3 usernames; not on stories
userTags[{username, x, y, mediaIndex?}]; photos need x and y in [0,1], videos only the username
locationIdLocation page id; not on stories
altText / altTextsImages only, max 1000 characters; altTexts per carousel item
shareToFeed, thumbOffset, coverUrl, audioNameReels only (thumbOffset in ms, coverUrl HTTPS)
trialParamsReels only: {graduationStrategy: MANUAL | SS_PERFORMANCE}
commentsEnabledfalse turns comments off right after publishing

Facebook Pages

KeyNotes
contentTypefeed (default) | reel | story
mediaUrl / mediaUrls, mediaTypeFeed: 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
linkHTTPS link; text posts without media only
titleVideos and reels, max 255 characters
firstCommentMax 8000 characters; not on stories or drafts
drafttrue creates an unpublished Page post (text, link or multi-photo only)

Threads

KeyNotes
text (post content)Max 500 characters
mediaUrl / mediaUrls, mediaTypeTEXT | IMAGE | VIDEO | CAROUSEL (2–20 items)
topicTag1–50 characters, no "." or "&"
replyControleveryone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
linkAttachmentHTTPS URL shown as a preview; text posts only
altText / altTextsMax 1000 characters
firstCommentMax 500 characters, published as a reply
threadItemsReply chain of 1–20 {content, mediaUrl?, mediaUrls?, mediaType?, altText?}; replaces the post content

Reddit

KeyNotes
subredditRequired for live posts (without r/)
title, url, flairIdTitle defaults to the first line (max 300); url makes a link post

TikTok

KeyNotes
privacyLevelRequired: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY (unaudited apps: SELF_ONLY)
mediaUrl / mediaUrls, mediaTypevideo (default) or image (photo posts take mediaUrls; title max 90)
disableComment, disableDuet, disableStitch, brandContent, brandOrganic, autoAddMusicInteraction 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.

message.received
{
  "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}:

Eventdata
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.receivedSame 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.sentSame shape, direction outbound: sent through Postgale (eventId null) or elsewhere, e.g. the Instagram app (Meta echo)
message.readSame shape: the person read the account’s messages up to watermark / platformMessageId
message.deliveredSame shape (Messenger): platformMessageIds and/or watermark delivered
message.reactionSame shape with reaction {action: react | unreact, emoji}
message.statusWhatsApp: {eventId, platform, accountId, externalId, conversationId, messageId, platformMessageId, status: sent | delivered | read | failed | deleted, errors, pricing, conversation, recipient{phone, userId}, timestamp, receivedAt}
template.statusWhatsApp: {eventId, platform, accountId, externalId, wabaId, kind: status, templateId, name, language, event: APPROVED | REJECTED | PAUSED | …, reason, otherInfo, receivedAt}
template.qualityWhatsApp: same shape, kind quality (previousQualityScore, newQualityScore) or category (previousCategory, newCategory, correctCategory)
account.qualityWhatsApp: {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.testSent only by POST /webhooks/{id}/test; cannot be subscribed to
Verify a delivery (TypeScript)
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: