Webhooks
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;
}
}# CREATE TABLE pozy_events (id text PRIMARY KEY, type text NOT NULL, received_at timestamptz DEFAULT now());
import psycopg
def handle_pozy_event(conn: psycopg.Connection, event: dict) -> None:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO pozy_events (id, type) VALUES (%s, %s) ON CONFLICT (id) DO NOTHING",
(event["id"], event["event"]),
)
if cur.rowcount == 0:
return # already handled
conn.commit()
try:
process_event(event)
except Exception:
with conn.cursor() as cur:
cur.execute("DELETE FROM pozy_events WHERE id = %s", (event["id"],))
conn.commit()
raiseIf 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.
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.publishedfor Instagram is retried after a timeout and arrives afterpost.partialfor the same post.- An
account.disconnectedis retried for an hour and arrives after theaccount.connectedthat followed the reconnect.
Three habits keep this from biting:
- 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. - 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}, orGET /v1/accounts?profileId=...for the profile's accounts. The API is always the source of truth. - Make updates absolute. Write "status is now
published" rather than "increment published count". Absolute writes can be repeated and reordered safely.
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 |