Build a platform
Use the profiles array on every event to send each webhook to the right customer, filter endpoints by profile or account, and fan out safely.
When many customers share your workspace, every event has to find its way back to the right one. Pozy makes that a field lookup: each event lists the profiles it concerns, with the externalId you gave them.
#The profiles array
Every event body carries profiles:
{
"id": "evt_5b0e9c3a71f2d846",
"event": "message.received",
"timestamp": "2026-10-30T16:41:09.000Z",
"profiles": [{ "id": "6720f3b1c9e4a85d2f0b7c14", "externalId": "cus_8f3a2" }],
"message": { "...": "..." }
}idis the Pozy profile id.externalIdis yours, ornullif you never set one.- Most events concern one profile. A post that targets accounts in several profiles lists each of them.
- Workspace-level events can arrive with an empty array. Route those to your own operations channel.
Set externalId on every customer profile (profiles) and you never need a database query to route an event.
#Pattern 1: one endpoint, route in your code
Register a single endpoint for all customers and route on externalId. This is the simplest setup and scales to any number of customers.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.POZY_WEBHOOK_SECRET;
const seen = new Set(); // use Redis or your database in production
function verified(raw, signature) {
const expected = createHmac("sha256", SECRET).update(raw).digest("hex");
return typeof signature === "string" && signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", async () => {
if (!verified(raw, req.headers["x-pozy-signature"])) return res.writeHead(401).end();
const eventId = req.headers["x-pozy-event-id"];
if (seen.has(eventId)) return res.writeHead(200).end(); // duplicate delivery
seen.add(eventId);
const event = JSON.parse(raw);
const tenants = (event.profiles ?? []).map((p) => p.externalId).filter(Boolean);
// Acknowledge fast, work later: Pozy waits 5 seconds at most.
res.writeHead(200).end();
if (tenants.length === 0) await queue.push("ops", event);
for (const tenant of tenants) await queue.push(`tenant:${tenant}`, event);
});
}).listen(8080);Follow three rules:
- Verify first. Reject anything whose
X-Pozy-Signaturedoes not match (verify signatures). - Dedupe on
X-Pozy-Event-Id. A delivery can arrive more than once, for example when your server timed out but had already processed it. The header equals the body'sid. - Answer within 5 seconds. Push the event to a per-tenant queue and return
200. Slow handlers cause retries.
#Pattern 2: one endpoint per customer
When a customer wants events delivered to their own URL, give them an endpoint filtered to their profile, with its own secret:
curl -X POST https://api.pozy.ai/v1/webhooks/settings \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "cus_8f3a2",
"url": "https://hooks.acme.example/pozy",
"secret": "9c2f6b8e1d4a7f3c5e0b2d9a6c8f1e4b",
"events": ["post.published", "post.failed", "account.disconnected"],
"profileIds": ["6720f3b1c9e4a85d2f0b7c14"],
"customHeaders": { "X-Tenant": "cus_8f3a2" }
}'import { randomBytes } from "node:crypto";
const secret = randomBytes(32).toString("hex");
const res = await fetch("https://api.pozy.ai/v1/webhooks/settings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: customer.id,
url: customer.webhookUrl,
secret,
events: ["post.published", "post.failed", "account.disconnected"],
profileIds: [customer.pozyProfileId],
customHeaders: { "X-Tenant": customer.id },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// show `secret` to the customer once so they can verify deliveriesimport os, secrets, requests
secret = secrets.token_hex(32)
resp = requests.post(
"https://api.pozy.ai/v1/webhooks/settings",
headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
json={
"name": "cus_8f3a2",
"url": "https://hooks.acme.example/pozy",
"secret": secret,
"events": ["post.published", "post.failed", "account.disconnected"],
"profileIds": ["6720f3b1c9e4a85d2f0b7c14"],
"customHeaders": {"X-Tenant": "cus_8f3a2"},
},
)A workspace can have up to 50 endpoints. Beyond that, use pattern 1 and forward to customer URLs from your own queue (pattern 3).
#Endpoint filters
An event is delivered to an endpoint only when it passes every filter the endpoint has:
| Filter | Empty means | Set means |
|---|---|---|
events |
(required, at least one) | Only these event types. |
profileIds |
Every profile | Only events whose profiles include one of these ids. |
accountIds |
Every account | Only events about one of these connected accounts. |
customHeaders are added to every delivery to that endpoint. They are useful for routing at a gateway, but they are not a substitute for checking the signature.
Change filters later with PUT /v1/webhooks/settings, passing the endpoint's webhookId and the fields to change.
#Pattern 3: fan out from your own queue
For many customers with their own URLs, or for strict per-customer isolation, receive everything on one endpoint (pattern 1), then deliver to each customer from your own infrastructure. You control retries, per-customer rate limits and the signing secret your customers verify, and a slow customer endpoint can never delay events for the others.
#Account events tie it together
Two events keep your tenant records in step with what customers connect:
account.connected: storeaccount.accountIdon the customer named byprofiles[0].externalId.account.disconnected: mark that account as needing attention and prompt the customer to reconnect.
| Event | When it fires |
|---|---|
account.connected |
Account connected. |
account.rate_limited |
Mailbox sending cooldown started. |
account.disconnected |
Account disconnected. |
account.permissions_changed |
Account permissions changed. |
account.ads.initial_sync_completed |
Ads initial sync completed. |
account.ads.sync_failed |
Ads sync failed. |
account.ads.sync_recovered |
Ads sync recovered. |
#Testing a route
POST /v1/webhooks/test sends a sample event to one endpoint so you can watch it reach the right queue. Test deliveries carry "test": true in the body; ignore them in production handlers. Delivery attempts, response codes and bodies are kept in GET /v1/webhooks/logs, filterable by endpoint, event and status (test webhooks).
#Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/webhooks/settings |
List webhooks |
POST /v1/webhooks/settings |
Create webhook |
PUT /v1/webhooks/settings |
Update webhook |
DELETE /v1/webhooks/settings |
Delete webhook |
GET /v1/webhooks/logs |
List webhook delivery logs |
POST /v1/webhooks/logs/redeliver |
Redeliver a webhook event |
POST /v1/webhooks/test |
Send test webhook |