Home / Docs / Webhooks
Webhooks

Webhooks

A webhook sends a message to your address when something happens in Postozo, for example when a post goes live. Every message is signed, so you can check it came from us.

Developer docs: Overview, API guide, API reference, MCP server, Webhooks, CLI, OAuth for apps. Machine readable: openapi.json.

Add a webhook

  1. In the app, open Settings, then Webhooks, and press Add a webhook. Only owners and admins can.
  2. Give it a name and your address. It must be a public http or https URL.
  3. Pick the events. Without a choice you get post.published and post.failed.
  4. Pick channels to only hear about those channels, or none for all.
  5. Save. The secret (it starts with whsec_) is shown right after you save. You can see it again any time with Show secret. Then press Send Test.

Webhooks per workspace: Standard 2, Team 10, Pro 30 and Ultimate 10,000. Webhooks over the limit after a plan change are paused, and come back on their own after an upgrade.

What we send

A POST with a JSON body of 3 fields: event, createdAt (when this attempt was sent, UTC) and data.

HeaderValue
Content-Typeapplication/json
User-AgentPostozo-Webhooks/1
X-Postozo-EventThe event name, for example post.published
X-Postozo-Signaturesha256= and the HMAC SHA-256 of the raw body, as hex, made with your webhook secret
{
  "event": "post.published",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "postId": "7e3077eb-7192-4bb2-a752-b7b6dcfd9f98",
    "content": "Our autumn menu is here.",
    "groupId": "409d29ee-fc74-4d2c-9ed3-9a6d384f3dd6",
    "provider": "demo",
    "channelId": "de53f029-255d-4104-b090-974efcca2c86",
    "releaseId": "demo_12b73a9fd5b5",
    "releaseUrl": "https://app.postozo.com/demo/demo_12b73a9fd5b5",
    "publishedAt": "2026-09-29T18:24:47.528Z"
  }
}

The order of the fields inside data can change, so read them by name. The Send Test button sends the event test with { "message": "This is a test from Postozo." }; it is not in the list of events you can pick.

The 8 events

EventWhen
post.publishedA post went live on 1 channel. Sent once per channel, not for follow-up comments.
post.failedA post failed on 1 channel and will not be retried on its own. The error and fix are the same plain words the app shows.
post.unconfirmedThe network did not answer after the post was sent, and a search on the network did not find it. The post is not sent again. Check the network, then link the live post or retry.
approval.requestedA post was sent for approval: type review, or a post for a customer that needs approval.
approval.approvedThe client (or an admin) approved a post.
approval.changesThe client asked for changes. comment says what to change.
channel.disconnectedThe regular token refresh for a channel failed, so the channel must be reconnected before it can post again.
billing.changedThe workspace plan changed: a plan was picked, changed, renewed, cancelled or resumed, a payment failed, or a plan ended. event says which (list below). test is true in billing test mode.

post.published

A post went live on 1 channel. Sent once per channel, not for follow-up comments.

{
  "event": "post.published",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "postId": "7e3077eb-7192-4bb2-a752-b7b6dcfd9f98",
    "content": "Our autumn menu is here.",
    "groupId": "409d29ee-fc74-4d2c-9ed3-9a6d384f3dd6",
    "provider": "demo",
    "channelId": "de53f029-255d-4104-b090-974efcca2c86",
    "releaseId": "demo_12b73a9fd5b5",
    "releaseUrl": "https://app.postozo.com/demo/demo_12b73a9fd5b5",
    "publishedAt": "2026-09-29T18:24:47.528Z"
  }
}

Captured from a real delivery on a test server.

post.failed

A post failed on 1 channel and will not be retried on its own. The error and fix are the same plain words the app shows.

{
  "event": "post.failed",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "fix": "Remove #fail from the text and retry.",
    "error": "Demo network rejected the post (#fail in text).",
    "postId": "cdc55555-9bbb-412c-aeb2-acb5a130d1b9",
    "groupId": "ca2b42ff-5317-4ca3-8b8f-85e336e73e4b",
    "provider": "demo",
    "channelId": "de53f029-255d-4104-b090-974efcca2c86"
  }
}

Captured from a real delivery on a test server.

post.unconfirmed

The network did not answer after the post was sent, and a search on the network did not find it. The post is not sent again. Check the network, then link the live post or retry.

{
  "event": "post.unconfirmed",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "fix": "Check the channel. If the post is not there, press Retry. If it is there, delete this post from the calendar.",
    "error": "The last attempt was interrupted, so we cannot tell if the post went live.",
    "postId": "cdc55555-9bbb-412c-aeb2-acb5a130d1b9",
    "groupId": "ca2b42ff-5317-4ca3-8b8f-85e336e73e4b",
    "provider": "demo",
    "channelId": "de53f029-255d-4104-b090-974efcca2c86"
  }
}

Same fields as post.failed. The Demo network does not produce this event, so it was not captured; the error and fix above are 1 of the real texts from the code, and others depend on what the network did.

approval.requested

A post was sent for approval: type review, or a post for a customer that needs approval.

{
  "event": "approval.requested",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "groupId": "49f3a0f1-b041-4564-bf7a-8a34a3d82eee",
    "publishAt": "2026-10-04T18:24:47.551Z",
    "channelIds": [
      "de53f029-255d-4104-b090-974efcca2c86"
    ],
    "previewUrl": "https://app.postozo.com/p/49f3a0f1-b041-4564-bf7a-8a34a3d82eee?t=9a5a69e723f644e49088708d3e619ad2",
    "requestedBy": "Radhika",
    "approvalStatus": "requested"
  }
}

Captured from a real delivery on a test server.

approval.approved

The client (or an admin) approved a post.

{
  "event": "approval.approved",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "groupId": "49f3a0f1-b041-4564-bf7a-8a34a3d82eee",
    "publishAt": "2026-10-04T18:24:47.551Z",
    "approvedBy": "Priya",
    "channelIds": [
      "de53f029-255d-4104-b090-974efcca2c86"
    ],
    "previewUrl": "https://app.postozo.com/p/49f3a0f1-b041-4564-bf7a-8a34a3d82eee?t=9a5a69e723f644e49088708d3e619ad2",
    "approvalStatus": "approved"
  }
}

Captured from a real delivery on a test server.

approval.changes

The client asked for changes. comment says what to change.

{
  "event": "approval.changes",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "comment": "Please add our opening hours.",
    "groupId": "49f3a0f1-b041-4564-bf7a-8a34a3d82eee",
    "publishAt": "2026-10-05T18:24:49.591Z",
    "channelIds": [
      "de53f029-255d-4104-b090-974efcca2c86"
    ],
    "previewUrl": "https://app.postozo.com/p/49f3a0f1-b041-4564-bf7a-8a34a3d82eee?t=9a5a69e723f644e49088708d3e619ad2",
    "requestedBy": "Priya",
    "approvalStatus": "changes"
  }
}

Captured from a real delivery on a test server.

channel.disconnected

The regular token refresh for a channel failed, so the channel must be reconnected before it can post again.

{
  "event": "channel.disconnected",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "channelId": "de53f029-255d-4104-b090-974efcca2c86",
    "provider": "linkedin",
    "name": "Acme Inc"
  }
}

The fields come from the code; this event cannot be produced on a test server, so it was not captured.

billing.changed

The workspace plan changed: a plan was picked, changed, renewed, cancelled or resumed, a payment failed, or a plan ended. event says which (list below). test is true in billing test mode.

{
  "event": "billing.changed",
  "createdAt": "2026-09-29T18:24:47.531Z",
  "data": {
    "plan": "team",
    "test": false,
    "event": "trial_plan",
    "amount": 29,
    "period": "month",
    "currency": "usd",
    "periodEnd": "2026-10-06T18:24:49.655Z"
  }
}

Captured from a real delivery on a test server.

Values of event in billing.changed:

ValueMeaning
activatedA plan started after payment.
changedThe plan changed now (an upgrade, or a scheduled change that took effect).
renewedThe plan renewed and was paid.
trial_planA plan was picked during the trial. It starts when the trial ends.
downgrade_scheduledA smaller plan or period was picked. It starts at the end of the current period.
change_canceledA scheduled change was cancelled. The plan stays as it is.
cancel_scheduledThe plan was cancelled. It stays active until periodEnd.
resumedA cancelled plan was resumed.
past_dueA payment failed. Everything keeps working for 7 days.
grace_reminderA reminder during the 7 days after a failed payment.
grace_overThe 7 days after a failed payment are over. Publishing is paused until payment.
endedThe plan ended. Publishing is paused until a plan is picked.

Check the signature

Make the HMAC SHA-256 of the raw body (the exact bytes you received) with your secret, write it as hex, put sha256= in front and compare it with the X-Postozo-Signature header using a constant time compare. If it does not match, answer 401 and ignore the message. The Node and Python samples below were run against a real delivery.

Node

// Node 18+ with Express. Read the raw body: the signature is made from the exact bytes we sent.
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.POSTOZO_WEBHOOK_SECRET; // whsec_... from Settings, Webhooks

export function isFromPostozo(rawBody, header) {
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(String(header || ''));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post('/postozo-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!isFromPostozo(req.body, req.get('x-postozo-signature'))) return res.status(401).send('bad signature');
  const { event, createdAt, data } = JSON.parse(req.body);
  console.log(event, createdAt, data);
  res.sendStatus(200); // answer fast with a 2xx, do slow work after
});
app.listen(3000);

Python

# Python 3 with Flask. request.get_data() is the raw body.
import hashlib, hmac, json, os
from flask import Flask, request, abort

SECRET = os.environ["POSTOZO_WEBHOOK_SECRET"].encode()  # whsec_... from Settings, Webhooks

def is_from_postozo(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

app = Flask(__name__)

@app.post("/postozo-webhook")
def postozo_webhook():
    if not is_from_postozo(request.get_data(), request.headers.get("X-Postozo-Signature")):
        abort(401)
    message = json.loads(request.get_data())
    print(message["event"], message["createdAt"], message["data"])
    return "", 200

PHP

<?php
// PHP 7.4+. php://input is the raw body.
$secret = getenv('POSTOZO_WEBHOOK_SECRET'); // whsec_... from Settings, Webhooks
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_POSTOZO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $header)) {
    http_response_code(401);
    exit('bad signature');
}
$message = json_decode($raw, true);
error_log($message['event'] . ' ' . $message['createdAt']);
http_response_code(200);

The PHP sample uses the same steps as the other 2 but was not run in our test.

Retries

  • Your address must answer with a 2xx status within 15 seconds. Anything else counts as a failure: another status, a timeout, or no connection.
  • A failed delivery is tried again, up to 6 attempts in total. The waits between attempts double: about 15 seconds, 30 seconds, 1 minute, 2 minutes, then 4 minutes.
  • Each attempt is a new request with a new createdAt and a new signature. The data stays the same.
  • Up to 5 redirects are followed, and every address on the way must be public.
  • Deliveries to a webhook that was switched off, deleted or paused by a plan limit stop.

Channel filters

A webhook with channels picked only gets events that are about at least 1 of those channels: post.* and channel.disconnected for that channel, and approval.* when any channel of the post is picked. billing.changed is not about a channel, so every webhook that picked it gets it.

Your secret

Only owners and admins can see a webhook's secret. In Settings, Webhooks, press Show secret next to the webhook: the secret shows with a Copy button. You can do this any time, not only after you save.

If the secret leaked, press Rotate secret in the same window. A new secret is made and the old 1 stops working at once, also for retries that are still waiting. Put the new secret on your server right away; until then your check will reject our messages.

Delivery log

Every attempt is written down and kept for 30 days. In Settings, Webhooks, press Deliveries next to a webhook to see the last 50, newest first: the time, the event, the HTTP status your address answered ("No answer" when it did not), the attempt number (1 for the first try and for Send Test, up to 6 with retries) and the error. The Send Test button also shows the answer of your address right away ("Test sent. Your server answered 200."), or what went wrong with a fix, for example "The test did not go through. The address answered HTTP 404."

Webhook settings over the API

The same actions work with your workspace API key (or the token of an owner or admin) on the app's own API at https://app.postozo.com. These are not part of the Postiz compatible /public/v1 API. Other roles get 403.

GET /api/webhooks

Your webhooks. Owners and admins also get each secret; other roles get the list without it. Clients get an empty list.

curl "https://app.postozo.com/api/webhooks" \
  -H "Authorization: pz_live_YOUR_KEY"
[
  {
    "id": "6a0c2e4f-8b1d-4c3e-9f5a-7b2d4e6f8a1c",
    "name": "Team chat",
    "url": "https://example.com/postozo-webhook",
    "events": [
      "post.published",
      "post.failed"
    ],
    "channelIds": [],
    "active": true,
    "pausedReason": null,
    "secret": "whsec_f3Kq8LmR2xVb7NpD1sTw4Y",
    "createdAt": "2026-09-30T10:12:04.118Z"
  }
]

POST /api/webhooks/{id}/rotate-secret

Makes a new secret. The old secret stops working at once, also for retries still waiting. Owners and admins.

curl -X POST "https://app.postozo.com/api/webhooks/WEBHOOK_ID/rotate-secret" \
  -H "Authorization: pz_live_YOUR_KEY"
{
  "id": "6a0c2e4f-8b1d-4c3e-9f5a-7b2d4e6f8a1c",
  "name": "Team chat",
  "url": "https://example.com/postozo-webhook",
  "events": [
    "post.published",
    "post.failed"
  ],
  "channelIds": [],
  "active": true,
  "pausedReason": null,
  "secret": "whsec_Zp4Hc9Wn1Qk6Rt3Vy8Lm2B",
  "createdAt": "2026-09-30T10:12:04.118Z"
}

GET /api/webhooks/{id}/deliveries

The last 50 delivery attempts, newest first, kept for 30 days. Owners and admins.

curl "https://app.postozo.com/api/webhooks/WEBHOOK_ID/deliveries" \
  -H "Authorization: pz_live_YOUR_KEY"
[
  {
    "id": "b4e1d7a2-5c3f-4a9b-8e6d-1f2a3b4c5d6e",
    "createdAt": "2026-09-30T10:15:31.402Z",
    "event": "post.published",
    "status": 200,
    "error": null,
    "attempt": 2,
    "ok": true
  },
  {
    "id": "c5f2e8b3-6d4a-4b0c-9f7e-2a3b4c5d6e7f",
    "createdAt": "2026-09-30T10:15:16.077Z",
    "event": "post.published",
    "status": 500,
    "error": "HTTP 500",
    "attempt": 1,
    "ok": false
  },
  {
    "id": "d6a3f9c4-7e5b-4c1d-8a8f-3b4c5d6e7f80",
    "createdAt": "2026-09-30T10:12:09.950Z",
    "event": "test",
    "status": 200,
    "error": null,
    "attempt": 1,
    "ok": true
  }
]

In the delivery log, status is null when your address did not answer, error is null when it worked, and ok is true when the status was 2xx.

Ideas

  • Post a message in your team chat on post.failed, with the error and the fix.
  • Mark a task done in your project tool on approval.approved.
  • Save every releaseUrl from post.published in a sheet as a record of what went live.
  • Send billing.changed to your finance tool.

Poll instead? Use GET /posts from the API reference. Hosted on https://app.postozo.com.

FAQ

Questions

How fast must my address answer?

Within 15 seconds, with any 2xx status. Answer first and do slow work afterwards.

Can I get the same event twice?

Yes, if your address answered with an error or too slowly and the delivery was retried. Use data.postId (or data.groupId) with the event name to spot repeats.

Why does my signature check fail?

Most often the body was parsed and turned back into text before the check, which changes the bytes. Check the raw body exactly as it arrived.

Can I send webhooks to localhost?

No. The address must be a public http or https URL. To test on your computer, use a tunnel service that gives you a public URL.

How many webhooks can I add?

It depends on the plan: Standard 2, Team 10, Pro 30 and Ultimate 10,000.

I lost my webhook secret. What now?

Owners and admins can see it again any time: Settings, Webhooks, Show secret. If it leaked, press Rotate secret there.

How do I see if my webhook worked?

Settings, Webhooks, Deliveries shows the last 50 messages with the time, event, HTTP status, error and attempt number.

Get your API key

Start a 7-day free trial. The API, MCP server, webhooks and CLI are in every plan.

Create your free account