Post webhooks
Follow the publishing lifecycle, inspect per-platform results, and verify webhook signatures.
PostPeer sends signed webhook events as posts move through the publishing lifecycle. Each event identifies the post, reports its overall status, and includes the result for every matching platform target.
Events
| Event | Description |
|---|---|
post.scheduled | A post was accepted and scheduled for future publication. |
post.published | Every platform target published successfully without warnings. |
post.partial | At least one platform failed or returned a warning. |
post.failed | Every platform target failed to publish. |
How the events fit together
A scheduled post emits post.scheduled when PostPeer accepts it. When publishing finishes, the post emits one final event:
post.publishedwhen every target succeeds.post.partialwhen results contain a failure or warning.post.failedwhen every target fails.
Posts published immediately emit only their final event. Each final payload contains all per-platform results in data.platforms; PostPeer does not send separate platform events.
If a subscription has a profileId, data.platforms contains only targets connected to that profile. Set onlyScheduledPosts to true when you want final events only for posts that were originally scheduled.
Delivery model
PostPeer sends each event as an HTTP POST request to your endpoint. A 2xx response marks the delivery as successful. Return it promptly after you persist the event, then perform slow work in a background job.
Failed deliveries time out after 10 seconds and retry with exponential backoff. PostPeer makes up to three delivery attempts, so your endpoint may receive the same event more than once. Store the top-level event id as an idempotency key and skip events you have already processed.
For each request:
- Read and verify the raw request body.
- Ignore the event if you have already processed its
id. - Find your database record with
data.post.id. - Store the new overall and per-platform statuses.
- Return a
2xxresponse.
Create a webhook subscription
Create one subscription for the events you want to receive:
curl -X POST "https://api.postpeer.dev/v1/notifications/" \
-H "x-access-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "webhook",
"eventTypes": [
"post.scheduled",
"post.published",
"post.partial",
"post.failed"
],
"webhook": {
"url": "https://example.com/webhooks/postpeer"
}
}'The response includes a signing secret in notification.webhook.secret. Store this secret securely. List requests return a masked secret.
See the notification API reference for all subscription options.
Identify the post
When you create a post, store the returned postId with your database record. Webhooks return the same value in data.post.id.
Create post response: postId
Webhook payload: data.post.idUse data.post.id to find the record and data.post.status to update it. You do not need a custom webhook identifier.
Verify the signature
PostPeer includes these headers with every webhook:
| Header | Description |
|---|---|
X-PostPeer-Event-Id | Unique event ID |
X-PostPeer-Event-Type | Event type, such as post.published |
X-PostPeer-Timestamp | Unix timestamp in seconds |
X-PostPeer-Signature | HMAC-SHA256 signature prefixed with sha256= |
Content-Type | application/json |
User-Agent | PostPeer-Webhooks/1.0 |
To verify a request:
- Read the request body as raw text. Do not parse or reserialize it before verification.
- Join the timestamp, a period, and the raw body:
<timestamp>.<rawBody>. - Compute an HMAC-SHA256 digest with your webhook signing secret.
- Prefix the hexadecimal digest with
sha256=. - Compare the expected and received signatures with a constant-time comparison.
- Reject old timestamps to prevent replay attacks. A five-minute tolerance is a common choice.
Node.js and Express example
Register this route before any global express.json() middleware so that req.body remains a Buffer.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post(
'/webhooks/postpeer',
express.raw({ type: 'application/json' }),
async (req, res) => {
const secret = process.env.POSTPEER_WEBHOOK_SECRET;
const timestamp = req.get('X-PostPeer-Timestamp');
const receivedSignature = req.get('X-PostPeer-Signature');
if (!secret || !timestamp || !receivedSignature) {
return res.sendStatus(400);
}
const timestampNumber = Number(timestamp);
const age = Math.abs(Math.floor(Date.now() / 1000) - timestampNumber);
if (!Number.isFinite(timestampNumber) || age > 300) {
return res.sendStatus(401);
}
const rawBody = req.body.toString('utf8');
const digest = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedSignature = `sha256=${digest}`;
const expected = Buffer.from(expectedSignature, 'utf8');
const received = Buffer.from(receivedSignature, 'utf8');
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(expected, received);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(rawBody);
const postId = event.data.post.id;
const status = event.data.post.status;
// Replace this with your database update.
// await db.posts.update({ where: { postpeerId: postId }, data: { status } });
return res.sendStatus(200);
},
);Return a 2xx response after processing the event successfully.
Python and FastAPI example
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/webhooks/postpeer")
async def handle_postpeer_webhook(request: Request):
secret = os.environ.get("POSTPEER_WEBHOOK_SECRET")
timestamp = request.headers.get("X-PostPeer-Timestamp")
received_signature = request.headers.get("X-PostPeer-Signature")
if not secret or not timestamp or not received_signature:
return Response(status_code=400)
try:
timestamp_number = int(timestamp)
except ValueError:
return Response(status_code=401)
if abs(int(time.time()) - timestamp_number) > 300:
return Response(status_code=401)
raw_body = await request.body()
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
digest = hmac.new(
secret.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
expected_signature = f"sha256={digest}"
if not hmac.compare_digest(expected_signature, received_signature):
return Response(status_code=401)
event = json.loads(raw_body)
post_id = event["data"]["post"]["id"]
status = event["data"]["post"]["status"]
# Replace this with your database update.
# await update_post_status(post_id, status)
return Response(status_code=200)Webhook payload
{
"version": "2026-06-01",
"id": "evt_123",
"type": "post.published",
"createdAt": "2026-06-06T12:00:00.000Z",
"projectId": "project_123",
"profileId": null,
"data": {
"post": {
"id": "post_123",
"status": "published",
"content": "Hello world",
"scheduledFor": null,
"publishedAt": "2026-06-06T12:00:00.000Z",
"mediaItems": [],
"links": {
"postpeer": "https://postpeer.dev/dashboard/posts?post=post_123",
"api": "https://api.postpeer.dev/v1/posts/post_123"
}
},
"platforms": [
{
"platform": "twitter",
"integrationId": "integration_123",
"profileId": null,
"status": "published",
"platformPostId": "tweet_123",
"platformPostUrl": "https://x.com/user/status/tweet_123",
"publishedAt": "2026-06-06T12:00:00.000Z",
"errorMessage": null,
"warningMessage": null
}
]
}
}Top-level fields
| Field | Type | Description |
|---|---|---|
version | string | Webhook payload version |
id | string | Unique event ID; use it to detect duplicate deliveries |
type | string | post.scheduled, post.published, post.partial, or post.failed |
createdAt | string | ISO 8601 event creation time |
projectId | string | Project that owns the post |
profileId | string or null | Profile selected by the subscription, if any |
data | object | Post and per-platform results |
data.post fields
| Field | Type | Description |
|---|---|---|
id | string | PostPeer post ID; matches the postId returned when creating a post |
status | string | Current overall post status |
content | string | Post text |
scheduledFor | string or null | Scheduled publication time in ISO 8601 format |
publishedAt | string or null | Earliest successful platform publication time |
mediaItems | array | Media attached to the post |
links | object | Dashboard and API URLs for the post |
Each mediaItems entry includes type, url, thumbnail, filename, size, and mimeType. Video and normalized media may also include dimensions, duration, codecs, bitrate, and probe status.
data.platforms fields
| Field | Type | Description |
|---|---|---|
platform | string | Social platform |
integrationId | string | Connected account integration ID |
profileId | string or null | Profile assigned to the integration |
status | string | Status for this platform |
platformPostId | string or null | Post ID returned by the social platform |
platformPostUrl | string or null | Published post URL |
publishedAt | string or null | Platform publication time |
errorMessage | string or null | Failure reason |
warningMessage | string or null | Non-fatal warning |