Webhooks
Events, signatures, retries and redelivery of Postgale webhooks, and the inbound Meta webhook endpoints.
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} |
action.pending_approval | {actionId, action}: an agent action (reply, moderation, DM, published-post change, X engagement) was queued for approval; action is the same object as GET /actions/{id} |
action.approved | {actionId, action}: a member approved it; it runs right away |
action.rejected | {actionId, action}: a member rejected it (action.review.rejectReason); nothing was sent |
action.executed | {actionId, action}: it ran; action.result is the response the endpoint would have returned, action.text what was sent (edited when a member changed it) |
action.failed | {actionId, action}: it failed when it ran; action.error {message, code} (e.g. outside_messaging_window, credential_revoked) |
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 |
WhatsApp numbers bought through Postgale add phone_number.* events; see the WhatsApp guide.
Signatures and retries
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');
}Endpoints
| Method | Path | Description |
|---|---|---|
| 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) |
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/instagram -
https://postgale.com/api/webhooks/meta/facebook -
https://postgale.com/api/webhooks/meta/threads -
https://postgale.com/api/webhooks/meta/whatsapp
Then:
- 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.*.