# 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:

```ts Node
// 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;
  }
}
```
```python Python
# 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()
        raise
```

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.

```ts
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](https://pozy.ai/docs/webhooks/testing).

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/webhooks/logs`](https://pozy.ai/docs/api/getWebhookLogs) | List webhook delivery logs |
| [`POST /v1/webhooks/logs/redeliver`](https://pozy.ai/docs/api/redeliverWebhookEvent) | Redeliver a webhook event |
