Skip to main content

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

EventDescription
post.scheduledA post was accepted and scheduled for future publication.
post.publishedEvery platform target published successfully without warnings.
post.partialAt least one platform failed or returned a warning.
post.failedEvery 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.published when every target succeeds.
  • post.partial when results contain a failure or warning.
  • post.failed when 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:

  1. Read and verify the raw request body.
  2. Ignore the event if you have already processed its id.
  3. Find your database record with data.post.id.
  4. Store the new overall and per-platform statuses.
  5. Return a 2xx response.

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.id

Use 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:

HeaderDescription
X-PostPeer-Event-IdUnique event ID
X-PostPeer-Event-TypeEvent type, such as post.published
X-PostPeer-TimestampUnix timestamp in seconds
X-PostPeer-SignatureHMAC-SHA256 signature prefixed with sha256=
Content-Typeapplication/json
User-AgentPostPeer-Webhooks/1.0

To verify a request:

  1. Read the request body as raw text. Do not parse or reserialize it before verification.
  2. Join the timestamp, a period, and the raw body: <timestamp>.<rawBody>.
  3. Compute an HMAC-SHA256 digest with your webhook signing secret.
  4. Prefix the hexadecimal digest with sha256=.
  5. Compare the expected and received signatures with a constant-time comparison.
  6. 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

FieldTypeDescription
versionstringWebhook payload version
idstringUnique event ID; use it to detect duplicate deliveries
typestringpost.scheduled, post.published, post.partial, or post.failed
createdAtstringISO 8601 event creation time
projectIdstringProject that owns the post
profileIdstring or nullProfile selected by the subscription, if any
dataobjectPost and per-platform results

data.post fields

FieldTypeDescription
idstringPostPeer post ID; matches the postId returned when creating a post
statusstringCurrent overall post status
contentstringPost text
scheduledForstring or nullScheduled publication time in ISO 8601 format
publishedAtstring or nullEarliest successful platform publication time
mediaItemsarrayMedia attached to the post
linksobjectDashboard 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

FieldTypeDescription
platformstringSocial platform
integrationIdstringConnected account integration ID
profileIdstring or nullProfile assigned to the integration
statusstringStatus for this platform
platformPostIdstring or nullPost ID returned by the social platform
platformPostUrlstring or nullPublished post URL
publishedAtstring or nullPlatform publication time
errorMessagestring or nullFailure reason
warningMessagestring or nullNon-fatal warning

On this page