# Webhooks

Source: https://postninja.app/docs/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.

## Set up an endpoint

1. Add the URL under **Settings → API → Webhooks**, or with [`POST /api/v1/webhooks`](https://postninja.app/docs/api#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

| Event | When | In `data` |
| --- | --- | --- |
| `post.created` | Posts: Created (draft or waiting) | `post`; `status` (`draft` or `awaiting_approval`) |
| `post.scheduled` | Posts: Scheduled | `post`; `status` |
| `post.updated` | Posts: Changed | `post`; `status` |
| `post.rescheduled` | Posts: Moved to a new time | `post` |
| `post.deleted` | Posts: Deleted | `postId` only (the post is gone) |
| `post.retried` | Posts: Retried | `post` |
| `post.published` | Posts: Published | `post` (every target published, with links) |
| `post.partial` | Posts: Partly published | `post` (see each target's `status` and `error`) |
| `post.failed` | Posts: Failed | `post` (see each target's `error`) |
| `post.submitted` | Approvals: Submitted for approval | `post` |
| `post.approved` | Approvals: Approved | `post`; `note` if one was left; `profileId` when approved from a client review link |
| `post.changes_requested` | Approvals: Changes requested | `post`; `note`; `profileId` when sent back from a client review link |
| `account.connected` | Accounts: Connected | `accountId`, `platform`, `username` |
| `account.disconnected` | Accounts: Disconnected | `accountId`, `platform` |
| `account.needs_reconnect` | Accounts: Needs reconnecting | `accountId`, `platform`, `username`, `note` (why) |
| `member.joined` | Team: Member joined | `role` |
| `comment.received` | Comments: New comment | `accountId`, `platform`, `inboxItemId`, `author`, `text` (first 280 characters), `reply`, `permalink`, and `post` when it's on a post from PostNinja |
| `message.received` | Direct messages: New message | `accountId`, `platform`, `conversationId`, `messageId`, `from` (username or name), `text` (first 280 characters), `attachments` (how many) |
| `message.sent` | Direct messages: Reply sent from PostNinja | `accountId`, `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`](https://postninja.app/docs/api#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

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `PostNinja-Event` | The event name, e.g. `post.published`. |
| `PostNinja-Delivery` | This attempt's id. A redelivery gets a new one; the body's `id` stays the same. |
| `PostNinja-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

Body:

```json
{
  "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):

```js
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):

```python
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.
