Webhooks

View as Markdown

Receive real-time post lifecycle notifications, including scheduled, published, failed, and media-demotion events. token.expiring is defined and subscribable but is not currently dispatched, so do not depend on it for token monitoring.

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-publora-key Yes Your API key
x-publora-user-id No Managed user ID (workspace only)

List Webhooks

GET https://api.publora.com/api/v1/webhooks

Response

{
  "success": true,
  "webhooks": [
    {
      "_id": "65f8a1b2c3d4e5f6a7b8c9d0",
      "name": "Production Notifications",
      "url": "https://your-app.com/webhooks/publora",
      "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.publora.com/api/v1/webhooks

Request Body

{
  "name": "Production Notifications",
  "url": "https://your-app.com/webhooks/publora",
  "events": ["post.published", "post.failed", "post.demoted"]
}

Response

{
  "success": true,
  "webhook": {
    "_id": "65f8a1b2c3d4e5f6a7b8c9d0",
    "name": "Production Notifications",
    "url": "https://your-app.com/webhooks/publora",
    "events": ["post.published", "post.failed", "post.demoted"],
    "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

Event Description
post.scheduled Post was scheduled
post.published Post was successfully published
post.failed Post failed to publish
post.demoted A scheduled post was returned to draft after media changed
token.expiring Defined and subscribable, but not currently dispatched; do not build flows that depend on it

Update Webhook

PATCH https://api.publora.com/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.publora.com/api/v1/webhooks/:id

Response

{
  "success": true
}

Regenerate Secret

POST https://api.publora.com/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:

Behavior API (/api/v1/webhooks) Dashboard (/webhooks)
Create error (invalid events) Returns a flat { "error": "Invalid events" } — submitted event names are not echoed back (to avoid leaking admin-only event names to non-admin probes). Error echoes the invalid event names: "Invalid events: foo"
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, Publora sends a POST request to your webhook URL:

Headers

Header Description
Content-Type application/json
X-Publora-Signature HMAC-SHA256 signature of the payload
X-Publora-Event Event type (e.g., post.published)

Payload Structure

{
  "version": "1",
  "event": "post.published",
  "timestamp": "2026-02-22T14:30:00.000Z",
  "data": {
    "postId": "507f1f77bcf86cd799439012",
    "postGroupId": "507f1f77bcf86cd799439011",
    "platform": "linkedin",
    "platformId": "ABC123",
    "postedId": "urn:li:share:7654321",
    "permalink": null,
    "publishedAt": "2026-02-22T14:30:00.000Z"
  }
}

version is currently the string "1". The HMAC covers the raw bytes of this complete envelope: { version, event, timestamp, data }.

Event-Specific Data

post.scheduled

{
  "postGroupId": "507f1f77bcf86cd799439011",
  "scheduledTime": "2026-02-23T09:00:00.000Z",
  "platforms": ["linkedin-ABC123", "twitter-123456789"]
}

post.demoted

{
  "postGroupId": "507f1f77bcf86cd799439011",
  "reason": "media_changed",
  "changeType": "attach",
  "mediaFileId": "507f1f77bcf86cd799439013"
}

changeType is attach or detach. The event is emitted when that media change demotes a scheduled group back to draft.

post.published

{
  "postId": "507f1f77bcf86cd799439012",
  "postGroupId": "507f1f77bcf86cd799439011",
  "platform": "linkedin",
  "platformId": "ABC123",
  "postedId": "urn:li:share:7654321",
  "permalink": null,
  "publishedAt": "2026-02-22T14:30:00.000Z"
}
Field Type Description
postId string Publora's ID for the individual platform post
postGroupId string Publora's ID for the parent post group
platform string Platform name (e.g., linkedin)
platformId string/null Raw platform account ID the post was published to — null when absent
postedId string/null The platform's own ID for the live post — use this to map the event to the real post on the platform
permalink string/null Public URL of the published post — null when unavailable
publishedAt string ISO-8601 timestamp of when the event was emitted

Stable shape: all seven keys are always present on post.published. Unavailable values are explicitly null rather than omitted, so no existence checks are needed. This holds on both delivery paths — the normal publish path and the recovered/reconciled publish path (where the scheduler re-emits a success it had already committed).

permalink is not yet populated. The field is delivered on every post.published event, but is currently null for essentially all posts pending a separate backfill. If you need the live post today, resolve it from platform + platformId + postedId.

Publication identity: post.published previously carried only Publora-internal IDs (postId, postGroupId), which gave receivers no way to locate the actual live post on the platform. platformId, postedId and permalink close that gap.

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

Planned shape: this event is defined and can be selected when creating a webhook, but no production code currently dispatches it. Do not depend on receiving it.

{
  "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(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
 
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
 
// Express middleware
app.post('/webhooks/publora', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-publora-signature'];
  const event = req.headers['x-publora-event'];
 
  if (!verifyWebhookSignature(req.body, signature, process.env.PUBLORA_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
 
  const payload = JSON.parse(req.body.toString('utf8'));
 
  console.log(`Received ${event}:`, payload.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;
  }
 
  res.status(200).json({ received: true });
});

Python (Flask)

import os
import hmac
import hashlib
from flask import Flask, request, jsonify
 
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['PUBLORA_WEBHOOK_SECRET']
 
def verify_signature(raw_body, signature, secret):
    expected = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)
 
@app.route('/webhooks/publora', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Publora-Signature')
    event = request.headers.get('X-Publora-Event')
    raw_body = request.get_data()
 
    if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401
 
    payload = request.get_json()
 
    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
  • Each event gets one delivery attempt; failed deliveries are not retried automatically
  • After 5 consecutive failures, the webhook is automatically disabled
  • A successful delivery resets failureCount; in a concurrent in-flight race it may also restore isActive. An already-disabled webhook receives no further deliveries and must be re-enabled explicitly with an update request.
  • 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.

Signature verification: Compute the HMAC-SHA256 over the exact raw request body bytes. Capture and verify the raw body before parsing JSON; parsing and re-serializing can change the signed bytes.

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.publora.com/api/v1/webhooks \
  -H "x-publora-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Webhook",
    "url": "https://my-app.com/webhooks/publora",
    "events": ["post.published", "post.failed"]
  }'

List Webhooks

curl https://api.publora.com/api/v1/webhooks \
  -H "x-publora-key: YOUR_API_KEY"

Delete Webhook

curl -X DELETE https://api.publora.com/api/v1/webhooks/65f8a1b2c3d4e5f6a7b8c9d0 \
  -H "x-publora-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" One or more submitted event names is not in the allowed-events list for this caller. Names are not echoed back (admin-only events would otherwise leak to non-admin probes). Applies to both create and update.
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-publora-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

Publora is built by Creative Content Crafts, Inc. Need AI-powered content creation for LinkedIn, Threads, and X? Try Co.Actor — the best AI service for authentic thought leadership at scale.