PostPost

Webhooks

Receive real-time notifications when posts are published, fail, or when tokens are expiring.

Endpoints

MethodEndpointDescription
GET/webhooksList all webhooks
POST/webhooksCreate a webhook
PATCH/webhooks/:idUpdate a webhook
DELETE/webhooks/:idDelete a webhook
POST/webhooks/:id/regenerate-secretRegenerate signing secret

Headers

HeaderRequiredDescription
x-api-keyYesYour API key
x-postpost-user-idNoManaged user ID (workspace only)

List Webhooks

GET https://api.postpost.dev/api/v1/webhooks

Response

{
  "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/webhooks

Request 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 secret is only returned once when creating the webhook. Store it securely for signature verification.

Available Events

EventDescription
post.scheduledPost was scheduled
post.publishedPost was successfully published
post.failedPost failed to publish
token.expiringPlatform token is expiring soon

Update Webhook

PATCH https://api.postpost.dev/api/v1/webhooks/:id

Request 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, and events. 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 isActive field requires a strict boolean type (typeof isActive === "boolean"). Passing a string like "false" or "true" will be silently ignored — only literal true or false values 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/:id

Response

{
  "success": true
}

Regenerate Secret

POST https://api.postpost.dev/api/v1/webhooks/:id/regenerate-secret

Response

{
  "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:

BehaviorAPI (/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 checksUses truthy checks on name/url/events — empty string "" is silently ignoredUses !== undefined checks — empty string is treated as a value
isActive type checkRequires strict boolean (typeof isActive === "boolean") — strings like "false" are silently ignoredUses isActive !== undefined — accepts any truthy/falsy value
Re-enable webhookSets isActive: true but does not reset failureCountSets isActive: true and resets failureCount to 0
List responseExcludes 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 errorReturns "URL must use HTTPS" for non-HTTP/HTTPS protocolsReturns "Only HTTP and HTTPS URLs are allowed" for non-HTTP/HTTPS protocols
Update response fieldsUpdate response omits failureCount and lastTriggeredAtUpdate response includes failureCount and lastTriggeredAt
::1 / .localhost blockingDoes not block ::1 (IPv6 loopback) or .localhost subdomainsBlocks 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

HeaderDescription
Content-Typeapplication/json
X-PostPost-SignatureHMAC-SHA256 signature of the payload
X-PostPost-EventEvent 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}), 200

Webhook 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 reset failureCount to 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 .localhost subdomains. 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

StatusErrorCause
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

On this page