Add a webhook
- In the app, open Settings, then Webhooks, and press Add a webhook. Only owners and admins can.
- Give it a name and your address. It must be a public http or https URL.
- Pick the events. Without a choice you get
post.publishedandpost.failed. - Pick channels to only hear about those channels, or none for all.
- 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.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Postozo-Webhooks/1 |
X-Postozo-Event | The event name, for example post.published |
X-Postozo-Signature | sha256= 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
| Event | When |
|---|---|
post.published | A post went live on 1 channel. Sent once per channel, not for follow-up comments. |
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. |
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. |
approval.requested | A post was sent for approval: type review, or a post for a customer that needs approval. |
approval.approved | The client (or an admin) approved a post. |
approval.changes | The client asked for changes. comment says what to change. |
channel.disconnected | The regular token refresh for a channel failed, so the channel must be reconnected before it can post again. |
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. |
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"
}
}
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"
}
}
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"
}
}
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"
}
}
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"
}
}
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"
}
}
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"
}
}
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"
}
}
Values of event in billing.changed:
| Value | Meaning |
|---|---|
activated | A plan started after payment. |
changed | The plan changed now (an upgrade, or a scheduled change that took effect). |
renewed | The plan renewed and was paid. |
trial_plan | A plan was picked during the trial. It starts when the trial ends. |
downgrade_scheduled | A smaller plan or period was picked. It starts at the end of the current period. |
change_canceled | A scheduled change was cancelled. The plan stays as it is. |
cancel_scheduled | The plan was cancelled. It stays active until periodEnd. |
resumed | A cancelled plan was resumed. |
past_due | A payment failed. Everything keeps working for 7 days. |
grace_reminder | A reminder during the 7 days after a failed payment. |
grace_over | The 7 days after a failed payment are over. Publishing is paused until payment. |
ended | The 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);
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.
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