# PostEverywhere Webhooks Guide > Receive real-time HTTPS notifications when posts publish or fail, accounts disconnect, or media uploads complete. Includes HMAC-SHA256 signing, retry policy, and verification examples. **Source:** https://posteverywhere.ai/docs/webhooks **Section:** Core concepts **API reference:** https://posteverywhere.ai/docs/api/reference --- Webhooks let you receive **real-time HTTPS notifications** the moment something interesting happens in PostEverywhere, instead of polling `/v1/posts/{id}` every few seconds waiting for a publish to finish. Subscribe once, and PostEverywhere will POST a signed JSON payload to your endpoint every time a matching event occurs. ## Set up your first webhook ```bash # 1. Create a webhook subscription curl -X POST https://app.posteverywhere.ai/api/v1/webhooks \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/posteverywhere", "events": ["post.published", "post.failed", "account.reconnect_needed"], "name": "Production" }' ``` Response (the `secret` is shown **once only**, save it now): ```json { "data": { "id": "9f3c4d12-...", "url": "https://your-app.example.com/webhooks/posteverywhere", "events": ["post.published", "post.failed", "account.reconnect_needed"], "secret": "whsec_abc123...64chars", "secret_warning": "Save this secret now — it is shown only once.", "is_active": true, "created_at": "2026-06-11T...", ... }, "error": null, "meta": { "request_id": "a1b2c3d4", "timestamp": "2026-06-11T09:55:12.341Z" } } ``` ## Every webhook event and when it fires | Event | Fires when | |-------|------------| | `post.scheduled` | A new post is created (after `POST /v1/posts`) | | `post.publishing` | Declared but **not currently emitted**: do not build on it yet | | `post.published` | A destination has successfully published | | `post.failed` | A destination has failed after all retries | | `post.partially_failed` | A post group ends with some destinations succeeded + some failed | | `post.updated` | Declared but **not currently emitted**: poll `GET /v1/posts/:id` instead | | `post.deleted` | A post is deleted | | `account.connected` | Declared but **not currently emitted**: poll `GET /v1/accounts` after a connect link | | `account.disconnected` | A social account is disconnected by the user | | `account.reconnect_needed` | A social account's token is detected dead (user must reconnect) | | `media.uploaded` | An **image** upload is finalised via `POST /v1/media/{id}/complete` (not emitted for video, documents or URL imports) | | `media.deleted` | A media item is deleted | | `post.approval_requested` | A draft is submitted for approval (data carries `requested_by`) | | `post.approved` | An approver clears a pending post; it can now be scheduled (data carries `reviewed_by` and any `note`) | | `post.changes_requested` | A pending post is sent back to its author (data carries the reviewer's `note`) | Subscribe to one or many. New events are added over time without breaking existing subscriptions. ## What a webhook payload contains Every delivery has this envelope: ```json { "event": "post.published", "event_id": "9c4d12e0-...", "created_at": "2026-06-11T05:42:18.234Z", "organization_id": "56cb4496-...", "data": { ... event-specific fields ... } } ``` The `data` shape depends on the event. Example for `post.published`: ```json { "event": "post.published", "event_id": "9c4d12e0-...", "created_at": "2026-06-11T05:42:18.234Z", "organization_id": "56cb4496-...", "data": { "post_id": "a808f10f-...", "destination_id": "b9070ed5-...", "platform": "instagram", "account_id": 5356, "account_name": "taxrefundonspot.com.au", "published_at": "2026-06-11T05:42:17.000Z", "platform_post_id": "17841451231344189_..." } } ``` Unknown fields **may be added** over time. Receivers should treat the payload as **forward-compatible**: ignore fields you don't recognise. ## Headers sent with every webhook Every delivery includes these headers: | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `User-Agent` | `PostEverywhere-Webhook/1.0` | | `X-PostEverywhere-Event` | The event name (e.g. `post.published`) | | `X-PostEverywhere-Event-Id` | Stable UUID: use as a dedup key (we may deliver at-least-once) | | `X-PostEverywhere-Delivery-Id` | Delivery UUID, constant across that delivery's retries (`X-PostEverywhere-Attempt` identifies the attempt) | | `X-PostEverywhere-Timestamp` | Unix epoch seconds (anti-replay) | | `X-PostEverywhere-Signature` | `sha256=` | | `X-PostEverywhere-Attempt` | Attempt number (1-6) | ## Verifying the PostEverywhere webhook signature **You MUST verify the signature on every incoming webhook.** Without verification, anyone could POST to your URL and pretend to be PostEverywhere. ### Node.js / TypeScript ```ts import crypto from "crypto"; function verifyWebhook(rawBody: string, signatureHeader: string, secret: string): boolean { if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false; const provided = signatureHeader.slice(7); // strip "sha256=" const expected = crypto.createHmac("sha256", secret) .update(rawBody) // ⚠️ MUST be the raw body before any parsing .digest("hex"); // Constant-time compare to prevent timing attacks. try { return crypto.timingSafeEqual( Buffer.from(provided, "hex"), Buffer.from(expected, "hex"), ); } catch { return false; } } ``` ### Python ```python import hmac import hashlib def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: if not signature_header or not signature_header.startswith("sha256="): return False provided = signature_header[7:] expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(provided, expected) ``` ### Ruby ```ruby require "openssl" def verify_webhook(raw_body, signature_header, secret) return false unless signature_header&.start_with?("sha256=") provided = signature_header.sub("sha256=", "") expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body) Rack::Utils.secure_compare(provided, expected) end ``` ### Important verification gotchas - **Use the RAW request body**, not a re-serialised JSON object. JSON re-encoding may reorder keys or change whitespace and the HMAC will not match. - **Use a constant-time compare** (`crypto.timingSafeEqual` / `hmac.compare_digest`). Naive `==` leaks timing information. - **Optionally check `X-PostEverywhere-Timestamp`**: reject deliveries older than ~5 minutes to prevent replay of a captured payload. - **Dedupe by `X-PostEverywhere-Event-Id`**: delivery is at-least-once. Idempotent processing prevents double-acting on rare retries. ## How PostEverywhere retries failed deliveries If your endpoint returns anything other than `2xx` within 10 seconds, the delivery is retried with **exponential backoff**: | Attempt | Delay before this attempt | |---------|--------------------------| | 1 | immediate | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 10 minutes | | 5 | 1 hour | | 6 | 6 hours | After **6 failed attempts**, the delivery is marked `dead` and not retried again. After **20 consecutive failed deliveries on the same webhook**, the webhook is **auto-disabled**. You can re-enable it via `PATCH /v1/webhooks/:id` with `{"is_active": true}`, which also resets the failure counter. ## Testing your webhook endpoint Before flipping a webhook to production, send a synthetic ping: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID/test \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` Response shows the receiver's HTTP status + duration: ```json { "data": { "ok": true, "status": 200, "duration_ms": 87, "message": "Test webhook delivered successfully. Verify your endpoint received the X-PostEverywhere-Signature header and validated it against your saved secret." }, "error": null, "meta": { "request_id": "a1b2c3d4", "timestamp": "2026-06-11T09:55:12.341Z" } } ``` The test payload includes `data.test: true` so you can detect and ignore it in your handler if you want. ## Creating, listing and deleting subscriptions **Related:** [testing webhook delivery](/docs/testing) · [webhook validation errors](/docs/errors) · [reconnect_needed and account health](/docs/account-health) · [webhook event changes](/docs/changelog) Full reference: [List](/docs/api/list-webhooks) · [Create](/docs/api/create-webhook) · [Get](/docs/api/get-webhook) · [Update](/docs/api/update-webhook) · [Delete](/docs/api/delete-webhook) · [Test](/docs/api/test-webhook). ```bash # List your webhooks curl https://app.posteverywhere.ai/api/v1/webhooks \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" # Update events / URL / active state curl -X PATCH https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"events": ["post.published","post.failed","post.partially_failed"]}' # Disable temporarily curl -X PATCH https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"is_active": false}' # Delete (also cascades delivery history) curl -X DELETE https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` ## PostEverywhere webhook limits per organisation - **25 webhooks per organization** (contact support to raise) - **HTTPS only** in production (HTTP refused). Localhost / private IPs refused (SSRF protection). - **10-second timeout** per delivery - **2-KB response body capture** for debugging (longer responses are truncated) ## Common questions about webhooks ### Where is the signing secret? It's not in the GET response. The signing secret is returned **once only** in the `POST /v1/webhooks` create response. There is no API to retrieve it later. If you lost it, delete the webhook and create a new one. ### What if my endpoint is slow? You have **10 seconds** to respond with a `2xx` status. If your processing is slow, return `200` immediately and process the event asynchronously in your worker / queue. Idempotent processing keyed on `event_id` makes retries safe. ### How do I prevent replay attacks? Verify the `X-PostEverywhere-Timestamp` header is within the last 5 minutes. Combined with HMAC verification, this prevents an attacker who captures a payload from re-submitting it later. ### What's a sensible processing pattern? ```ts // 1. Read raw body BEFORE parsing. const rawBody = await req.text(); // 2. Verify signature. if (!verifyWebhook(rawBody, req.headers.get('x-posteverywhere-signature') || '', secret)) { return new Response('Invalid signature', { status: 401 }); } // 3. Dedupe. const eventId = req.headers.get('x-posteverywhere-event-id'); if (await alreadyProcessed(eventId)) return new Response('OK', { status: 200 }); // 4. Parse. const event = JSON.parse(rawBody); // 5. Enqueue + return fast. await queue.add('posteverywhere-event', event); return new Response('OK', { status: 200 }); ``` ### How do I subscribe to all events? Pass the full event list explicitly. There's no `*` wildcard: this is intentional, so adding a new event type in the future doesn't silently start firing to your endpoint without you opting in.