Webhooks

Idempotency and ordering

Handle duplicate and out-of-order webhook deliveries safely by deduplicating on X-Pozy-Event-Id and reading state rather than trusting arrival order.

Pozy delivers every event at least once. Most events arrive exactly once, but some arrive twice: when your endpoint did the work and then took longer than 5 seconds to answer, when a response was lost on the network, or when someone redelivers an event from the logs. Events can also arrive in a different order from the one they happened in. A handler that is safe under both conditions never double-counts a sale, sends two emails or shows a stale status.

#Deduplicate on the event id

Every event has a stable id, sent twice in each delivery:

Where Value
X-Pozy-Event-Id header The event id
id in the body The same event id

It is identical on every retry and every redelivery, and different for every event. Record it before doing the work, and skip events you have already recorded. A unique index makes this safe even when two deliveries of the same event arrive at the same moment:

// CREATE TABLE pozy_events (id text PRIMARY KEY, type text NOT NULL, received_at timestamptz DEFAULT now());
import { Pool } from "pg";

const db = new Pool();

export async function handlePozyEvent(event: { id: string; event: string }) {
  const { rowCount } = await db.query(
    "INSERT INTO pozy_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
    [event.id, event.event],
  );
  if (rowCount === 0) return; // already handled

  try {
    await processEvent(event);
  } catch (err) {
    // Let a later retry or redelivery try again.
    await db.query("DELETE FROM pozy_events WHERE id = $1", [event.id]);
    throw err;
  }
}

If you process events in a background queue, put the check in the worker, not only in the HTTP handler: a redelivery can arrive long after the first copy was taken off the queue.

Keep processed ids for at least a few days. Retries span about 33 hours, and manual redeliveries can come later. A Redis set with a 7-day expiry works as well as a table.

The event id is not a resource id

id identifies the event, never the post, account or message it is about. Do not use it to look up a post. The post id is in post.id, the account in account.accountId or post.platforms[].accountId, depending on the event.

#Do not rely on order

Events are sent as they happen, but retries and parallel deliveries mean they can arrive out of order. Two examples:

  • post.platform.published for Instagram is retried after a timeout and arrives after post.partial for the same post.
  • An account.disconnected is retried for an hour and arrives after the account.connected that followed the reconnect.

Three habits keep this from biting:

  1. Compare timestamps. Every body has timestamp, set once when the event happened and kept on every retry. Store the latest timestamp you applied per resource, and ignore events older than it.
  2. Treat events as hints, read the state. For anything where order matters, use the event as a trigger and fetch the current state: GET /v1/posts/{postId}, or GET /v1/accounts?profileId=... for the profile's accounts. The API is always the source of truth.
  3. Make updates absolute. Write "status is now published" rather than "increment published count". Absolute writes can be repeated and reordered safely.
Ignore stale post events
async function onPostEvent(e: { event: string; timestamp: string; post: { id: string; status: string } }) {
  const current = await db.post.findUnique({ where: { pozyId: e.post.id } });
  if (current?.lastEventAt && new Date(e.timestamp) <= current.lastEventAt) return; // older than what we have
  await db.post.update({
    where: { pozyId: e.post.id },
    data: { status: e.post.status, lastEventAt: new Date(e.timestamp) },
  });
}

#Useful delivery headers

Header Use it for
X-Pozy-Event-Id Deduplication. Same on every copy of the event.
X-Pozy-Delivery-Id Support and logs. Unique per delivery, so a redelivery has a new one.
X-Pozy-Attempt Seeing retries. 1 for the first try, up to 7. A high number in your logs means your endpoint was slow or failing earlier.

#Test events

Events sent with POST /v1/webhooks/test carry "test": true in the body and a fresh id each time. Real events never carry test. Skip or route test events separately so a test never changes production data. See Test webhooks.

#Endpoints

Endpoint What it does
GET /v1/webhooks/logs List webhook delivery logs
POST /v1/webhooks/logs/redeliver Redeliver a webhook event