Webhooks
Receive real-time notifications when posts are published, fail, or when tokens are expiring.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /webhooks | List all webhooks |
| POST | /webhooks | Create a webhook |
| PATCH | /webhooks/:id | Update a webhook |
| DELETE | /webhooks/:id | Delete a webhook |
| POST | /webhooks/:id/regenerate-secret | Regenerate signing secret |
Headers
| Header | Required | Description |
|---|---|---|
x-api-key | Yes | Your API key |
x-postpost-user-id | No | Managed user ID (workspace only) |
List Webhooks
GET https://api.postpost.dev/api/v1/webhooksResponse
{
"success": true,
"webhooks": [
{
"_id": "65f8a1b2c3d4e5f6a7b8c9d0",
"name": "Production Notifications",
"url": "https://your-app.com/webhooks/postpost",
"events": ["post.published", "post.failed"],
"isActive": true,
"failureCount": 0,
"lastTriggeredAt": "2026-02-22T14:30:00.000Z",
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-22T14:30:00.000Z",
"__v": 0
}
]
}Note: Both API and dashboard list responses may include
__v(Mongoose version key). This field can be safely ignored.
Create Webhook
POST https://api.postpost.dev/api/v1/webhooksRequest Body
{
"name": "Production Notifications",
"url": "https://your-app.com/webhooks/postpost",
"events": ["post.published", "post.failed", "token.expiring"]
}Response
{
"success": true,
"webhook": {
"_id": "65f8a1b2c3d4e5f6a7b8c9d0",
"name": "Production Notifications",
"url": "https://your-app.com/webhooks/postpost",
"events": ["post.published", "post.failed", "token.expiring"],
"secret": "a1b2c3d4e5f6...your-signing-secret...x9y0z1",
"isActive": true,
"createdAt": "2026-02-22T10:00:00.000Z"
}
}Note: The Create Webhook endpoint returns HTTP 201 (Created), not 200.
Important: The
secretis only returned once when creating the webhook. Store it securely for signature verification.
Available Events
| Event | Description |
|---|---|
post.scheduled | Post was scheduled |
post.published | Post was successfully published |
post.failed | Post failed to publish |
token.expiring | Platform token is expiring soon |
Update Webhook
PATCH https://api.postpost.dev/api/v1/webhooks/:idRequest Body
{
"name": "Updated Name",
"url": "https://new-url.com/webhook",
"events": ["post.failed"],
"isActive": false
}All fields are optional. Only provided fields will be updated.
Note: The API uses truthy checks on
name,url, andevents. Passing an empty string""for any of these fields will be silently ignored (not treated as an update). Only non-empty values trigger updates.
Note: The
isActivefield requires a strict boolean type (typeof isActive === "boolean"). Passing a string like"false"or"true"will be silently ignored — only literaltrueorfalsevalues are accepted.
Response
{
"success": true,
"webhook": {
"_id": "65f8a1b2c3d4e5f6a7b8c9d0",
"name": "Updated Name",
"url": "https://new-url.com/webhook",
"events": ["post.failed"],
"isActive": false,
"updatedAt": "2026-02-22T15:00:00.000Z"
}
}Delete Webhook
DELETE https://api.postpost.dev/api/v1/webhooks/:idResponse
{
"success": true
}Regenerate Secret
POST https://api.postpost.dev/api/v1/webhooks/:id/regenerate-secretResponse
{
"success": true,
"secret": "new-secret-here..."
}Dashboard vs API Differences
Webhook management has two implementations: the public API (/api/v1/webhooks) and a dashboard route (/webhooks). They share the same underlying data but differ in several behaviors:
| Behavior | API (/api/v1/webhooks) | Dashboard (/webhooks) |
|---|---|---|
| Create error (invalid events) | Error includes valid events list suffix: "Invalid events: foo. Valid events: post.scheduled, ..." | Error omits the valid events list |
| Update field checks | Uses truthy checks on name/url/events — empty string "" is silently ignored | Uses !== undefined checks — empty string is treated as a value |
isActive type check | Requires strict boolean (typeof isActive === "boolean") — strings like "false" are silently ignored | Uses isActive !== undefined — accepts any truthy/falsy value |
| Re-enable webhook | Sets isActive: true but does not reset failureCount | Sets isActive: true and resets failureCount to 0 |
| List response | Excludes userId and secret from response; does not sort by createdAt; may include __v (Mongoose version key) | Excludes only secret from response; sorts by createdAt descending |
| URL validation error | Returns "URL must use HTTPS" for non-HTTP/HTTPS protocols | Returns "Only HTTP and HTTPS URLs are allowed" for non-HTTP/HTTPS protocols |
| Update response fields | Update response omits failureCount and lastTriggeredAt | Update response includes failureCount and lastTriggeredAt |
::1 / .localhost blocking | Does not block ::1 (IPv6 loopback) or .localhost subdomains | Blocks both ::1 and .localhost subdomains |
Tip: If you need to fully reset a webhook's failure state through the API, delete and recreate it. The dashboard UI handles this automatically.
Webhook Payload
Note: The webhook delivery system operates as a separate internal service. The behavior described in the Webhook Payload, Signature Verification, and Webhook Reliability sections below reflects the production implementation.
When an event occurs, PostPost sends a POST request to your webhook URL:
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-PostPost-Signature | HMAC-SHA256 signature of the payload |
X-PostPost-Event | Event type (e.g., post.published) |
Payload Structure
{
"event": "post.published",
"timestamp": "2026-02-22T14:30:00.000Z",
"data": {
"postId": "507f1f77bcf86cd799439012",
"postGroupId": "507f1f77bcf86cd799439011",
"platform": "linkedin",
"publishedAt": "2026-02-22T14:30:00.000Z"
}
}Event-Specific Data
post.scheduled
{
"postId": "507f1f77bcf86cd799439012",
"postGroupId": "507f1f77bcf86cd799439011",
"platform": "linkedin",
"scheduledAt": "2026-02-23T09:00:00.000Z"
}post.published
{
"postId": "507f1f77bcf86cd799439012",
"postGroupId": "507f1f77bcf86cd799439011",
"platform": "linkedin",
"publishedAt": "2026-02-22T14:30:00.000Z"
}post.failed
{
"postId": "507f1f77bcf86cd799439012",
"postGroupId": "507f1f77bcf86cd799439011",
"platform": "threads",
"error": {
"code": "PLATFORM_AUTH_EXPIRED",
"message": "Token expired"
},
"failedAt": "2026-02-22T14:30:00.000Z"
}token.expiring
{
"platform": "instagram",
"platformId": "instagram-17841412345678",
"username": "yourinstagram",
"expiresAt": "2026-02-25T08:00:00.000Z"
}Signature Verification
Verify webhook authenticity using HMAC-SHA256:
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express middleware
app.post('/webhooks/postpost', express.json(), (req, res) => {
const signature = req.headers['x-postpost-signature'];
const event = req.headers['x-postpost-event'];
if (!verifyWebhookSignature(req.body, signature, process.env.PUBLORA_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
console.log(`Received ${event}:`, req.body.data);
// Handle the event
switch (event) {
case 'post.published':
// Update your database, send notification, etc.
break;
case 'post.failed':
// Alert your team, retry logic, etc.
break;
case 'token.expiring':
// Notify user to reconnect
break;
}
res.status(200).json({ received: true });
});Python (Flask)
import os
import hmac
import hashlib
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['PUBLORA_WEBHOOK_SECRET']
def verify_signature(payload, signature, secret):
expected = hmac.new(
secret.encode(),
json.dumps(payload, separators=(',', ':')).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/postpost', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-PostPost-Signature')
event = request.headers.get('X-PostPost-Event')
payload = request.json
if not verify_signature(payload, signature, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
print(f"Received {event}: {payload['data']}")
if event == 'post.failed':
# Alert team about failed post
send_slack_alert(payload['data'])
return jsonify({'received': True}), 200Webhook Reliability
- Webhooks timeout after 10 seconds
- Failed webhooks are retried (up to 5 consecutive failures)
- After 5 consecutive failures, the webhook is automatically disabled
- Re-enable a disabled webhook by updating
isActive: true - Note: Re-enabling a webhook via the API does not reset
failureCount. The counter persists, meaning the webhook may be disabled again after fewer new failures. However, re-enabling a webhook via the dashboard does resetfailureCountto 0. If you need to reset the counter through the API, delete and recreate the webhook.
Limits
- Maximum 10 webhooks per user
- URL must use HTTPS (
"URL must use HTTPS") — note: despite the error message text, the API actually accepts both HTTP and HTTPS URLs - Localhost URLs are blocked (
"Localhost URLs are not allowed"— localhost, 127.0.0.1) - Private IP addresses are blocked (
"Private IP addresses are not allowed"— 10.x.x.x, 172.16-31.x.x, 192.168.x.x) - Link-local addresses are blocked (
"Link-local addresses are not allowed"— 169.254.x.x) - Cloud metadata endpoints are blocked (
"Cloud metadata endpoints are not allowed")
Note: The API route does not block
::1(IPv6 loopback) or.localhostsubdomains. These are only blocked by the dashboard route. This is a known difference — always use HTTP or HTTPS URLs with public hostnames.
Examples
Create Webhook with cURL
curl -X POST https://api.postpost.dev/api/v1/webhooks \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "My Webhook",
"url": "https://my-app.com/webhooks/postpost",
"events": ["post.published", "post.failed"]
}'List Webhooks
curl https://api.postpost.dev/api/v1/webhooks \
-H "x-api-key: YOUR_API_KEY"Delete Webhook
curl -X DELETE https://api.postpost.dev/api/v1/webhooks/65f8a1b2c3d4e5f6a7b8c9d0 \
-H "x-api-key: YOUR_API_KEY"Errors
| Status | Error | Cause |
|---|---|---|
| 400 | "Name, URL, and at least one event are required" | Missing required fields |
| 400 | "Invalid URL format" | Malformed URL |
| 400 | "Invalid events: ${invalidEvents}. Valid events: ${validEvents}" | Unrecognized event names on create (includes valid events list) |
| 400 | "Invalid events: ${invalidEvents}" | Unrecognized event names on update (shorter message, no valid events suffix) |
| 400 | "URL must use HTTPS" | URL uses unsupported protocol (note: both HTTP and HTTPS are actually accepted) |
| 400 | "Localhost URLs are not allowed" | URL points to localhost or 127.0.0.1 |
| 400 | "Private IP addresses are not allowed" | URL points to private network (10.x, 172.16-31.x, 192.168.x) |
| 400 | "Link-local addresses are not allowed" | URL points to 169.254.x.x |
| 400 | "Cloud metadata endpoints are not allowed" | URL targets cloud metadata service |
| 400 | "Maximum of 10 webhooks per user" | Webhook limit reached |
| 401 | "Invalid API key" | Bad or missing x-api-key |
| 404 | "Webhook not found" | Invalid webhook ID |
| 500 | "Failed to list webhooks" | Server error listing webhooks |
| 500 | "Failed to create webhook" | Server error creating webhook |
| 500 | "Failed to update webhook" | Server error updating webhook |
| 500 | "Failed to delete webhook" | Server error deleting webhook |
| 500 | "Failed to regenerate secret" | Server error regenerating secret |