Webhooks
Receive post outcomes and account health events from PostZen in real time.
Webhooks let your application react when a post finishes publishing or a connected account needs attention. PostZen sends an HTTPS POST request to every active endpoint subscribed to the event.
Webhook delivery is at least once. Store and deduplicate on the stable X-PostZen-Event-Id value before applying side effects.
Set up an endpoint
- Open Webhooks in the dashboard and select Create webhook.
- Enter a public HTTPS URL, choose at least one event, and select the profiles the endpoint can receive.
- Add a signing secret. PostZen permits unsigned webhooks, but signing is strongly recommended for every production endpoint.
- Add any custom authentication headers your receiver needs, save the endpoint, then send a test event.
You can also manage endpoints with the Webhooks API reference. Signing secrets and custom header values are encrypted at rest and are never returned after saving.
Events
| Event | Sent when |
|---|---|
post.published | Every target for a post has published successfully. |
post.partially_failed | At least one target published and at least one target failed. |
post.failed | A post reaches a terminal failure without a successful target. |
account.needs_reauth | A connected account first changes to needs_reauth. |
account.disconnected | A provider first reports that an account is disconnected. |
webhook.test | You send a test from the dashboard or API. |
Repeated writes of the same status do not create another event. If an account reconnects and later needs reauthorization again, that later transition creates a new event.
Payload
Every request has the same top-level envelope. The data object varies by event type.
{
"id": "evt_01j4z6vy7k8x9m2q3r4s5t6uvw",
"type": "post.published",
"apiVersion": "2026-08-06",
"createdAt": "2026-08-06T18:42:10.123Z",
"data": {
"post": {
"id": "post_01j4z6r8p4fmw7k3n2q1t9abcd",
"profileId": "profile_01j4z5zj8ab2q6t7m3n4p5cdef",
"status": "published",
"scheduledFor": "2026-08-06T18:40:00.000Z",
"targets": [
{
"accountId": "account_01j4z5x1m8n7q3p2r6t9uvwxyz",
"platform": "linkedin",
"status": "published",
"platformPostUrl": "https://www.linkedin.com/feed/update/urn:li:share:123"
}
]
}
}
}Account events include the account ID, profile ID, platform, username, status, and a machine-readable reason when one is available. Payloads never include access tokens, API keys, signing secrets, custom header values, billing data, or provider credentials.
Request headers
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-PostZen-Event | Event type, such as post.published. |
X-PostZen-Event-Id | Stable event ID shared by retries and manual redeliveries. |
X-PostZen-Delivery-Id | Delivery log record ID. |
X-PostZen-Timestamp | Unix timestamp in seconds for this delivery attempt. |
X-PostZen-Signature | v1=<hex> HMAC signature. Present only when the endpoint has a secret. |
Configured custom headers are added after PostZen's reserved headers. Retries, tests, and manual redeliveries use the endpoint's current URL, secret, and custom header values.
Retry schedule
PostZen treats every 2xx response as success. Network errors, timeouts, 429 responses, and other non-2xx responses are retried. Each request has a 10-second timeout.
| Attempt | Timing |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the previous attempt |
| 3 | 5 minutes after the previous attempt |
| 4 | 30 minutes after the previous attempt |
| 5 | 2 hours after the previous attempt |
| 6 | 8 hours after the previous attempt |
When a response includes Retry-After, PostZen waits for the longer of that value and the normal retry delay, capped at 24 hours. An endpoint is disabled after 10 consecutive events exhaust all six attempts. A successful delivery resets its consecutive failure count.
Verify signatures
When an endpoint has a secret, PostZen signs the exact raw request body. Build the signed value as:
<timestamp>.<raw request body>Then calculate HMAC-SHA256 with the endpoint secret and compare v1=<hex digest> with X-PostZen-Signature using a constant-time comparison. Do not parse and reserialize the JSON before verification because that changes the signed bytes.
Reject requests whose X-PostZen-Timestamp is more than five minutes old. You should also reject timestamps too far in the future and deduplicate the event ID to prevent replayed side effects.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyPostZenWebhook({ rawBody, secret, signature, timestamp }) {
const timestampSeconds = Number(timestamp)
if (!Number.isFinite(timestampSeconds)) return false
if (Math.abs(Date.now() / 1000 - timestampSeconds) > 5 * 60) return false
const digest = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const expected = Buffer.from(`v1=${digest}`, 'utf8')
const received = Buffer.from(signature, 'utf8')
return expected.length === received.length && timingSafeEqual(expected, received)
}Pass the body to this function exactly as your HTTP framework received it, before JSON parsing.
Python
import hashlib
import hmac
import time
def verify_postzen_webhook(raw_body: bytes, secret: str, signature: str, timestamp: str) -> bool:
try:
timestamp_seconds = int(timestamp)
except ValueError:
return False
if abs(time.time() - timestamp_seconds) > 5 * 60:
return False
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
digest = hmac.new(
secret.encode("utf-8"),
signed_payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"v1={digest}", signature)Use the original request bytes as raw_body, not the output of json.dumps().
Operational guidance
- Return a
2xxresponse quickly, then process the event asynchronously. - Deduplicate by event ID before applying side effects.
- Keep signing enabled for production endpoints, even though unsigned endpoints are permitted.
- Use the delivery logs to inspect response status, timing, excerpts, and the next retry.
- Rotate an expired custom authentication value on the endpoint; the next retry reads the current value.