Docs menu · Webhooks

REST API

Webhooks

Get a signed HTTPS request the moment something happens in PostNinja: a post goes out or fails, a teammate submits a post for approval, an account needs reconnecting, a comment arrives. Ideal for n8n, Zapier, Make, Slack alerts or your own backend.

View as Markdown

Set up an endpoint

  1. Add the URL under Settings → API → Webhooks, or with POST /api/v1/webhooks. It must be a public https:// address. Up to 10 per workspace.
  2. Pick the events you want, or leave it empty for all of them.
  3. Copy the signing secret (whsec_…). It's shown once; you can rotate it in Settings.
  4. Click Send test event: PostNinja sends one webhook.test so you can check your receiver and its signature check.

Events

EventWhenIn data
post.createdPosts: Created (draft or waiting)post; status (draft or awaiting_approval)
post.scheduledPosts: Scheduledpost; status
post.updatedPosts: Changedpost; status
post.rescheduledPosts: Moved to a new timepost
post.deletedPosts: DeletedpostId only (the post is gone)
post.retriedPosts: Retriedpost
post.publishedPosts: Publishedpost (every target published, with links)
post.partialPosts: Partly publishedpost (see each target's status and error)
post.failedPosts: Failedpost (see each target's error)
post.submittedApprovals: Submitted for approvalpost
post.approvedApprovals: Approvedpost; note if one was left; profileId when approved from a client review link
post.changes_requestedApprovals: Changes requestedpost; note; profileId when sent back from a client review link
account.connectedAccounts: ConnectedaccountId, platform, username
account.disconnectedAccounts: DisconnectedaccountId, platform
account.needs_reconnectAccounts: Needs reconnectingaccountId, platform, username, note (why)
member.joinedTeam: Member joinedrole
comment.receivedComments: New commentaccountId, platform, inboxItemId, author, text (first 280 characters), reply, permalink, and post when it's on a post from PostNinja
message.receivedDirect messages: New messageaccountId, platform, conversationId, messageId, from (username or name), text (first 280 characters), attachments (how many)
message.sentDirect messages: Reply sent from PostNinjaaccountId, platform, conversationId, messageId, to, text (first 280 characters); sent from PostNinja by a person or through the API

Post events carry the post exactly as GET /api/v1/posts/:id returns it, in data.post. When many comments or messages arrive at once, comment.received and message.received send one summary instead (count, platforms, summary: true).

The request

HeaderValue
Content-Typeapplication/json
PostNinja-EventThe event name, e.g. post.published.
PostNinja-DeliveryThis attempt's id. A redelivery gets a new one; the body's id stays the same.
PostNinja-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Body
{
  "id": "c2a1b3d4-e5f6-4a7b-9c8d-e9f0a1b2c3d4",
  "event": "post.published",
  "createdAt": "2026-10-12T09:00:06.512Z",
  "data": {
    "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9",
    "post": {
      "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9",
      "status": "published",
      "content": "We just shipped dark mode. Here's the full story.",
      "platformContent": {
        "x": "Dark mode is here 🌙"
      },
      "xThread": [],
      "xThreadMedia": [],
      "instagramFormat": "post",
      "tiktok": {},
      "firstComment": null,
      "youtubeTitle": null,
      "youtubeThumbnail": null,
      "queued": false,
      "scheduledAt": "2026-10-12T09:00:00.000Z",
      "publishedAt": "2026-10-12T09:00:06.000Z",
      "createdAt": "2026-10-06T14:21:07.512Z",
      "media": [
        {
          "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e",
          "kind": "image",
          "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg",
          "previewUrl": null
        }
      ],
      "targets": [
        {
          "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d",
          "platform": "x",
          "username": "acme",
          "status": "published",
          "url": "https://x.com/acme/status/1843210987654321098",
          "error": null
        },
        {
          "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40",
          "platform": "instagram",
          "username": "acme.studio",
          "status": "failed",
          "url": null,
          "error": "Instagram: photos must be between 4:5 (portrait) and 1.91:1 (landscape). Crop this one first."
        }
      ]
    }
  }
}

Verify the signature

v1 is the HMAC-SHA256 of {t}.{raw body} with your endpoint's secret, in hex. Compute it over the raw body exactly as received (before parsing JSON), compare in constant time, and reject requests whose t is more than 5 minutes old.

Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.POSTNINJA_WEBHOOK_SECRET; // whsec_…

app.post("/webhooks/postninja", express.raw({ type: "application/json" }), (req, res) => {
  const parts = Object.fromEntries((req.get("PostNinja-Signature") ?? "").split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    fresh &&
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(400).send("Bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  // Skip events you've already handled: deliveries can repeat.
  console.log(event.id, event.event, event.data.postId);
  res.sendStatus(204);
});
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["POSTNINJA_WEBHOOK_SECRET"].encode()  # whsec_…

@app.post("/webhooks/postninja")
def postninja():
    raw = request.get_data()  # the raw body, before parsing
    parts = dict(p.split("=", 1) for p in request.headers.get("PostNinja-Signature", "").split(",") if "=" in p)
    t, v1 = parts.get("t", "0"), parts.get("v1", "")
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected):
        abort(400)
    event = json.loads(raw)
    print(event["id"], event["event"], event["data"].get("postId"))
    return "", 204

Retries and switching off

  • Answer with any 2xx status within 10 seconds. Do slow work after answering.
  • Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h; after that the delivery is marked failed.
  • After 50 failed attempts in a row, the endpoint is switched off and the workspace owner gets an email. Switch it back on in Settings; that resets the count.
  • Deliveries can arrive more than once or out of order. Use the body's id to skip events you've handled, and createdAt to order them.
  • Settings shows each endpoint's recent deliveries (status code and the start of your answer) and can Redeliver any of them. History is kept 30 days.

No-code tools

In n8n, Zapier or Make, create a webhook trigger, paste its URL as the endpoint, and choose the events. To verify signatures there, compute the HMAC in a code step with the raw body; if your tool only gives you parsed JSON, keep the URL secret instead and check the event by fetching the post with the REST API.