# Webhooks

Source: https://postgale.com/docs/api/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](https://postgale.com/docs/whatsapp#buy-a-whatsapp-number).

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

```ts title="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

| 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_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.*`.
