Here is the loop almost every integration starts with. You schedule a post through the API, then you poll GET /v1/posts/{id} every few seconds to find out whether it actually went live. It works, it burns your rate limit, and it is still the slowest way to learn that something failed.
Social media webhooks remove the loop. Subscribe once, and PostEverywhere POSTs a signed JSON payload to your endpoint the moment a destination publishes, a post fails, or a social account's token dies.
"Tell me the instant anything fails, and let me stop asking."
That is the whole idea. This guide covers every PostEverywhere webhook event, what the payload actually contains, how to verify the signature properly, and exactly how deliveries are retried when your endpoint is down. For the endpoint-by-endpoint reference, see the PostEverywhere webhooks docs.
Written by Jamie Partridge, Founder.
Table of Contents
- What social media webhooks replace
- Every webhook event, and which ones actually fire
- What a webhook payload contains
- Headers sent with every delivery
- How to verify a webhook signature
- How a failed delivery is retried
- Why scheduled posts fail, and the webhook that catches each
- Handling failed and partially failed posts with webhooks
- Catching token expiry before a post fails
- Wiring webhooks into an AI agent runtime
- When polling is still the right call
- Testing your endpoint before you trust webhooks
- Webhook limits, and what they mean in practice
- FAQs
What social media webhooks replace
Publishing to a social platform is not instant. A post is accepted, queued, handed to the platform's API, and then either succeeds or fails, sometimes seconds later and sometimes considerably longer for video.
Anything you build on top of the social media API has to learn the outcome somehow.
Polling is the obvious answer and the wrong one. It costs requests against your rate limit, it adds latency between the failure and your reaction to it, and it scales badly: ten scheduled posts is ten polling loops.
(To look back at what your own integration sent, rather than react to events, the Logs page lists every API call with its status and timing.)
A webhook inverts it. You register one HTTPS endpoint, tell PostEverywhere which events you care about, and receive a POST when they happen:
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"
}'
The create response returns a secret beginning whsec_. It is shown once and never again. There is no endpoint to retrieve it later, so save it to your secret store before you close the terminal.
If you lose it, the only remedy is deleting the webhook and creating a new one.
Building on the API? Webhooks, the REST API, the typed Node SDK and the hosted MCP server all run on one key and are included on every plan from $9/mo. Start a 7-day trial: card required, $0 charged today.
Every webhook event, and which ones actually fire
PostEverywhere declares fifteen events. Twelve of them are emitted today, and three are reserved names that are not currently sent. That distinction matters more than it sounds, because building a handler around an event that never arrives is a silent failure you will not notice until production.
| Event | Fires when |
|---|---|
post.scheduled |
A new post is created via POST /v1/posts |
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 and some failed |
post.deleted |
A post is deleted |
account.disconnected |
A social account is disconnected by the user |
account.reconnect_needed |
An account's token is detected dead and the user must reconnect |
media.uploaded |
An image upload is finalised via POST /v1/media/{id}/complete |
media.deleted |
A media item is deleted |
post.approval_requested |
A draft is submitted for approval, carrying requested_by |
post.approved |
An approver clears a pending post, carrying reviewed_by and any note |
post.changes_requested |
A pending post is sent back to its author with the reviewer's note |
The three declared but not currently emitted: post.publishing, post.updated and account.connected. Do not build on them yet. For those cases, poll GET /v1/posts/:id or GET /v1/accounts after the relevant action instead.
Two further details worth knowing. media.uploaded fires for images only, not for video, documents or URL imports. And post.partially_failed exists because a single post can fan out to many destinations through cross-posting: four destinations can end as three published and one failed, which is neither a clean success nor a clean failure.
New events are added over time without breaking existing subscriptions, so subscribe to what you need and ignore the rest.
What a webhook payload contains
Every PostEverywhere delivery uses the same envelope:
{
"event": "post.published",
"event_id": "9c4d12e0-...",
"created_at": "2026-06-11T05:42:18.234Z",
"organization_id": "56cb4496-...",
"data": { }
}
The data object varies by event. For post.published it carries the identifiers you need to reconcile against your own records:
{
"data": {
"post_id": "a808f10f-...",
"destination_id": "b9070ed5-...",
"platform": "instagram",
"account_id": 5356,
"account_name": "yourbrand",
"published_at": "2026-06-11T05:42:17.000Z",
"platform_post_id": "17841451231344189_..."
}
}
platform_post_id is the identifier the network itself assigned, which is what you store if you later want to link to the live post or pull its analytics.
Treat the payload as forward-compatible. Unknown fields may be added over time, so ignore anything you do not recognise rather than failing validation on it. A handler that rejects unexpected keys will break the first time the API gains a field.
Headers sent with every delivery
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
PostEverywhere-Webhook/1.0 |
X-PostEverywhere-Event |
The event name, for example post.published |
X-PostEverywhere-Event-Id |
Stable UUID. Use it as your dedup key |
X-PostEverywhere-Delivery-Id |
Delivery UUID, constant across that delivery's retries |
X-PostEverywhere-Timestamp |
Unix epoch seconds, for anti-replay |
X-PostEverywhere-Signature |
sha256=<HMAC-SHA256(body, secret)> |
X-PostEverywhere-Attempt |
Attempt number, 1 to 6 |
The pair worth internalising is Event-Id and Delivery-Id. Delivery is at-least-once, so the same event can legitimately arrive twice. Dedupe on X-PostEverywhere-Event-Id and your handler becomes safe to retry.
Delivery-Id stays constant across the retries of one delivery, which is what you want in your logs when you are working out why attempt 4 arrived six hours after attempt 1.
How to verify a webhook signature
Verify every incoming webhook. Your endpoint is a public URL, so without verification anyone who discovers it can POST a fake post.published and your system will believe it.
The X-PostEverywhere-Signature header is sha256= followed by an HMAC-SHA256 (RFC 6234) of the request body, keyed with your whsec_ secret. Three rules account for nearly every "the signature never matches" report:
- Hash the raw request body, not a re-serialised object. Parsing and re-stringifying JSON can change bytes, and the HMAC is computed over bytes.
- Use a constant-time compare:
crypto.timingSafeEqualin Node,hmac.compare_digestin Python. A naive===leaks timing information. - Check the timestamp. Reject deliveries whose
X-PostEverywhere-Timestampis older than about five minutes, so a captured payload cannot be replayed later.
The full walkthrough, with working Node, Python and Ruby code, is in verifying PostEverywhere webhook signatures. The handler at the end of this guide calls a verifyWebhook() function from there.
How a failed delivery is retried
If your endpoint returns anything other than a 2xx within 10 seconds, PostEverywhere retries 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 never retried. After 20 consecutive failures on the same webhook, the webhook is auto-disabled to stop us hammering a dead endpoint.
Re-enable it with PATCH /v1/webhooks/:id and {"is_active": true}, which also resets the failure counter.
That ladder spans just under eight hours, which is deliberate: it is long enough to survive a deploy or a short outage, and short enough that a genuinely dead endpoint stops consuming attempts.
The practical consequence is that a webhook you never fixed will silently switch itself off, so alert on your own 5xx rate rather than assuming silence means nothing happened.
Keep the two retry systems separate in your head. Platform publishing retries happen inside PostEverywhere before post.failed is emitted at all, so by the time you receive that event we have already tried.
Webhook delivery retries, the ladder above, are only about reaching your endpoint.
Why scheduled posts fail, and the webhook that catches each
Scheduled posts fail for a small number of predictable reasons, and one dominates. In order of frequency:
- Expired or revoked tokens. Every platform expires access tokens on its own schedule, as Meta's Graph API documentation and LinkedIn's marketing API docs both set out. A password change, a revoked app or lost Page admin rights all invalidate the connection silently. Caught by
account.reconnect_needed, and bypost.failedif nothing acted on it. - Platform-side rejection of the media. Aspect ratios, durations and file sizes vary by network, so a video that is fine on one platform can be rejected by another in the same cross-post. Caught by
post.failedorpost.partially_failed. - Rate limiting. Rarer than people expect: the API allows 60 requests per minute per key, which scheduled use never approaches, though a bulk job that fans out aggressively can.
- Transient platform outages. Networks have bad hours. These usually resolve inside our platform retries, which is the best argument for automated retries over manual intervention.
The order tells you where to spend effort. Elaborate rate-limit handling with no token-expiry handling optimises the rare case and leaves the common one unhandled.
Handling failed and partially failed posts with webhooks
Three emitted events cover the failure surface: post.failed, post.partially_failed and account.reconnect_needed. Handle them distinctly rather than collapsing them into one branch:
switch (event.event) {
case 'account.reconnect_needed':
await emailOwner(event.data.account_name, event.data.platform);
await pauseQueueFor(event.data.account_id);
break;
case 'post.failed':
await flagForRetry(event.data.post_id, event.data.destination_id);
break;
case 'post.partially_failed':
await reconcileDestinations(event.data.post_id);
break;
}
Partial failures need their own branch. Publish one piece of content to four accounts and three can succeed while one fails. Treating that as post.failed implies nothing published, and a handler that retries the whole post duplicates the three that worked.
Instead, fetch GET /v1/posts/:id, see which destinations carry which status, and retry only the failed ones. That is why destination_id sits in the payload next to post_id: the destination is the unit of success, not the post.
It matters most for bulk scheduling, where a naive retry multiplies rather than repairs.
Put together, the recovery loop needs no human in the normal case:
account.reconnect_neededarrives. Pause that account's queue and email whoever owns the connection.post.failedarrives for something already queued. Record it against the destination, not the post.- The owner reconnects. Retry the failed destinations with the retry tools, which are reachable from the REST API and the MCP server.
post.publishedarrives. Clear the flag and store theplatform_post_idfor later analytics.
Catching token expiry before a post fails
The best failure is one that never reaches a post. Two mechanisms help.
Subscribe to account.reconnect_needed. It fires when PostEverywhere detects a dead token, before the next scheduled post attempts to publish, so it is the only failure event that arrives ahead of the damage. It converts a failed campaign into an email asking someone to reconnect.
Check account health before publishing. The hosted MCP server exposes an account-health tool, and the REST equivalent reports token status, recent failures and whether an account can currently post. An agent that checks health before a batch skips or warns rather than queueing into a dead connection.
For teams running many client accounts through multi-account management, one expired token in twenty is normal rather than exceptional.
Wiring webhooks into an AI agent runtime
Polling costs an AI agent far more than it costs a script. Every status check is a tool call plus a tool result in the context window, so the agent pays tokens to be told "still pending" again and again, uses up rate limit, and still reacts late.
Worse, a window full of repeated status objects degrades its attention on the actual task.
With webhooks, the agent's turn ends after scheduling. When post.failed arrives hours later, your infrastructure starts a fresh turn with the failure as the input, and "here is a failed destination, decide what to do" is a far better prompt than a status object the model has seen eleven times.
Four events carry most of the value for an agent: post.published, post.failed, post.partially_failed and account.reconnect_needed. If a human reviews agent output before it ships, add the approval events too.
Never invoke the agent inline from the webhook handler. A model call routinely takes longer than the 10-second delivery timeout. The timeout triggers the retry ladder, which delivers the same event again, which starts a second agent turn: an accidental fork bomb powered by your own inference budget.
Verify, dedupe, enqueue, return 200, and let a worker start the agent turn (the handler shape is in the limits section below).
If the agent runs inside an assistant rather than your own code, the two halves complement each other: the Claude connector or ChatGPT connector schedules on request, while a small service receives the failure events and raises anything that needs attention.
That service can be a no-code one: an n8n Webhook trigger can catch the events and hand each failure to an n8n AI Agent workflow connected to PostEverywhere.
When polling is still the right call
Webhooks are not universally better. Three cases genuinely favour a direct status call:
- The three reserved events.
post.publishing,post.updatedandaccount.connectedare not currently emitted, so pollGET /v1/posts/:idorGET /v1/accountsafter the relevant action. - Interactive conversations. If someone in a chat asks "did it go out?", one status check is the right answer. Webhooks serve unattended work.
- No public endpoint. A local agent on a laptop has nowhere for us to deliver, since localhost and private IPs are refused. It polls, or tunnels through a public HTTPS hostname.
The rule of thumb: unattended and long-running favours webhooks; interactive and short favours a direct call. Most real integrations do both.
Want to see the whole tool surface first? The MCP server page lists all 39 tools by group, and the developer docs cover every endpoint including the full webhooks reference.
Testing your endpoint before you trust webhooks
Send a synthetic delivery before you depend on it:
curl -X POST https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $POSTEVERYWHERE_API_KEY"
The response reports what your receiver actually did, including its HTTP status and how long it took:
{
"data": {
"ok": true,
"status": 200,
"duration_ms": 87
}
}
The test payload carries data.test: true, so you can detect and ignore it in your handler rather than creating a phantom record. If duration_ms is creeping toward 10,000 you have already found your next problem.
Webhook limits, and what they mean in practice
- 25 webhooks per organisation. Contact support to raise it. In practice one endpoint per environment is the sane pattern, not one per event.
- HTTPS only in production. Plain HTTP is refused, as are localhost and private IPs, which is SSRF protection. Use a tunnel with a public HTTPS hostname for local development.
- 10-second timeout per delivery. Return
200immediately and process asynchronously. Verify, dedupe, enqueue, respond. - 2 KB of your response body is captured for debugging. Put something useful in it.
The recommended handler shape follows directly from those limits:
const rawBody = await req.text();
if (!verifyWebhook(rawBody, req.headers.get('x-posteverywhere-signature') || '', secret)) {
return new Response('Invalid signature', { status: 401 });
}
const eventId = req.headers.get('x-posteverywhere-event-id');
if (await alreadyProcessed(eventId)) return new Response('OK', { status: 200 });
await queue.add('posteverywhere-event', JSON.parse(rawBody));
return new Response('OK', { status: 200 });
Read raw, verify, dedupe, enqueue, return fast. Everything slow happens in the worker, where a failure is yours to retry rather than ours.
If you are building an agent rather than a webhook receiver, the same events are what stop it polling. The agents page covers the patterns people build on this surface, and our guide to building a social media agent with the API walks a full pipeline.
FAQs
What are PostEverywhere webhooks?
PostEverywhere webhooks are real-time HTTPS notifications. Instead of polling the API to ask whether a post published, you register an endpoint and PostEverywhere POSTs a signed JSON payload the moment the event happens: a destination publishes, a post fails, or a social account needs reconnecting.
How many webhook events does PostEverywhere support?
Fifteen events are declared and twelve are emitted today. The three currently reserved are post.publishing, post.updated and account.connected. For those, poll GET /v1/posts/:id or GET /v1/accounts instead of waiting for a delivery that will not arrive.
Where do I find my PostEverywhere webhook signing secret?
It is returned once, in the POST /v1/webhooks create response, as a value beginning whsec_. There is no API to retrieve it afterwards. If you lose it, delete the webhook and create a new one.
How do I verify a PostEverywhere webhook signature?
Compute HMAC-SHA256 over the raw request body using your signing secret, and compare it to the X-PostEverywhere-Signature header using a constant-time compare. Do not re-serialise the JSON before hashing, and reject deliveries whose X-PostEverywhere-Timestamp is more than about five minutes old.
What happens if my endpoint is down?
PostEverywhere retries six times with exponential backoff: immediate, 30 seconds, 2 minutes, 10 minutes, 1 hour, then 6 hours. After six failures the delivery is dead. After 20 consecutive failures the webhook is auto-disabled and must be re-enabled with PATCH /v1/webhooks/:id.
Why did my scheduled social media post fail?
Most often an expired or revoked access token on the connected account. Password changes, app revocations and lost Page admin rights all invalidate connections silently. Media rejected by a platform, rate limiting and transient outages account for most of the rest.
What is the difference between post.failed and post.partially_failed?
post.failed is one destination failing after all platform retries. post.partially_failed is a post group with mixed outcomes across destinations. The distinction stops you retrying a whole post when three of four destinations already published, which would duplicate the successful ones.
Should an AI agent poll for publish status or use webhooks?
Use webhooks for unattended work. Polling costs the agent tokens on every check, uses rate limit and reacts late. Enqueue each verified event and start the agent turn from a worker, never inline, because a model call can exceed the 10-second delivery timeout and trigger a duplicate delivery.
Do webhooks cost extra on PostEverywhere?
No. Webhooks, the REST API, the Node SDK, the CLI and the hosted MCP server are included on every plan from $9/mo. The 7-day free trial covers all of it, though a card is required to start and there is no free plan.
Why is my webhook receiving nothing at all?
Check three things in order: the webhook is is_active: true and has not been auto-disabled by 20 consecutive failures, your URL is public HTTPS rather than localhost or a private IP, and you subscribed to an event that is actually emitted rather than one of the three reserved names.
Webhooks are the difference between an integration that tells you something broke and one you have to interrogate. Register one endpoint, verify the signature, dedupe on the event id, and the publish pipeline stops being a thing you poll.
Start your 7-day free trial and point your first webhook at a test endpoint in the next ten minutes.
For the surrounding surface, automating social media posting with an API walks the REST side end to end, and API vs MCP covers when to drive PostEverywhere with code rather than an assistant.

Founder & CEO of PostEverywhere. Writing about social media strategy, publishing workflows, and analytics that help brands grow faster.



