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}:

Eventdata
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.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

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 body id), 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 with POST /webhooks/deliveries/{deliveryId}/redeliver, check your endpoint with POST /webhooks/{id}/test.
  • comment.received, mention.received and message.* need the account subscribed to Meta webhooks (POST /accounts/{id}/webhook-subscription). Comments the account itself posted are not reported.
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');
}

Endpoints

MethodPathDescription
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)

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_token must equal META_WEBHOOK_VERIFY_TOKEN, and hub.challenge is echoed.
  • POST requires a valid X-Hub-Signature-256 made with that product's app secret (INSTAGRAM_CLIENT_SECRET, FACEBOOK_APP_SECRET, THREADS_APP_SECRET; WhatsApp: WHATSAPP_APP_SECRET, then FACEBOOK_APP_SECRET); otherwise 401.
  • Events are deduplicated, stored as inbound events (GET /inbox/events) and forwarded as comment.received / mention.received. Messaging events are stored as conversations and messages and forwarded as message.*.

On this page