Webhooks
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.
Quick start
# 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):
{
"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...",
...
}
Event types
| Event | Fires when |
|---|---|
post.scheduled | A new post is created (after POST /v1/posts) |
post.publishing | A destination has started its publish request |
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 | A post is modified (PATCH /v1/posts/:id) |
post.deleted | A post is deleted |
account.connected | A new social account is connected |
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 | A media upload completes processing (status=ready) |
media.deleted | A media item is deleted |
Subscribe to one or many. New events are added over time without breaking existing subscriptions.
Payload shape
Every delivery has this envelope:
{
"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:
{
"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.
Request headers
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 | Per-attempt UUID |
X-PostEverywhere-Timestamp | Unix epoch seconds (anti-replay) |
X-PostEverywhere-Signature | sha256=<HMAC-SHA256(body, secret)> |
X-PostEverywhere-Attempt | Attempt number (1-6) |
Verifying the 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
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
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
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.
Retry policy
If your endpoint returns anything other than 2xx within 10 seconds, the delivery is retried with exponential backoff:
| Attempt | Delay before next 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} — that also resets the failure counter.
Testing your endpoint
Before flipping a webhook to production, send a synthetic ping:
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:
{
"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."
}
The test payload includes data.test: true so you can detect and ignore it in your handler if you want.
Managing subscriptions
# 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"
Limits
- 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)
Frequently asked questions
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?
// 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.