Build a platform

Route webhooks to tenants

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:

message.received (trimmed)
{
  "id": "evt_5b0e9c3a71f2d846",
  "event": "message.received",
  "timestamp": "2026-10-30T16:41:09.000Z",
  "profiles": [{ "id": "6720f3b1c9e4a85d2f0b7c14", "externalId": "cus_8f3a2" }],
  "message": { "...": "..." }
}
  • id is the Pozy profile id. externalId is yours, or null if 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.

webhooks.mjs
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:

  1. Verify first. Reject anything whose X-Pozy-Signature does not match (verify signatures).
  2. 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's id.
  3. 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" }
  }'

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: store account.accountId on the customer named by profiles[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