---
title: "Duplicates and ordering"
description: "Providers deliver webhooks more than once and out of order. Deduplicate with dedupeKey and never move a message's status backward."
---

Providers retry webhooks that fail or time out, and they send events in parallel. The same event can arrive twice, and `message.sent` can arrive after `message.delivered`. Your handler has to tolerate both.

## Deduplicate with dedupeKey

Every `SmsEvent` has a `dedupeKey` that stays the same each time the provider delivers that event:

| Provider | `dedupeKey` |
| --- | --- |
| Twilio | `twilio:{MessageSid}:{MessageStatus}` or `twilio:{MessageSid}:received` |
| Telnyx | `telnyx:{data.id}`, the webhook event ID |
| Plivo | `plivo:{MessageUUID}:{Status}` or `plivo:{MessageUUID}:received` |
| Vonage | `vonage:{message_uuid}:{status}` or `vonage:{message_uuid}:received` |

Store each key with a unique constraint and skip events you have seen:

```ts
import { parseSmsWebhook } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export async function POST(request: Request): Promise<Response> {
  const event = await parseSmsWebhook({
    provider: "telnyx",
    request,
    credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
  });

  const isNew = await db.webhookEvents.insertIfNew(event.dedupeKey);
  if (!isNew) {
    return new Response(null, { status: 200 }); // a repeat: acknowledge and stop
  }

  // Handle the event once.
  return new Response(null, { status: 200 });
}
```

Answer repeats with a 2xx. An error response makes the provider retry again.

`insertIfNew` must be atomic, for example `INSERT ... ON CONFLICT DO NOTHING` returning whether a row was inserted. If you read first and insert second, two parallel deliveries can both pass. Keep keys at least as long as the provider retries webhooks, typically a day or more.

Deduplication also makes replayed Twilio and Plivo requests harmless. See [Webhook security](/receiving/webhook-security).

## Handle out-of-order status

Never let a late event move a message backward. Rank the statuses and keep the highest:

```ts
import type { MessageStatusType } from "@opencoredev/sms-sdk/webhooks";

const rank: Record<MessageStatusType, number> = {
  "message.queued": 0,
  "message.sent": 1,
  "message.delivered": 2,
  "message.undelivered": 2,
  "message.filtered": 2,
};

export function nextStatus(current: MessageStatusType | undefined, incoming: MessageStatusType): MessageStatusType {
  if (current === undefined) return incoming;
  return rank[incoming] >= rank[current] ? incoming : current;
}

console.log(nextStatus("message.delivered", "message.sent")); // "message.delivered"
```

The final states `delivered`, `undelivered`, and `filtered` share a rank, so the last one to arrive wins. Providers rarely send two different final states. When they do, `occurredAt`, if present, tells you which happened later.

## Status before the send result

The provider can call back while your process is still handling the `send()` response, so a status webhook may arrive before you save the result. If you look messages up by `providerId`, either hold unknown IDs briefly and apply them once the send result is saved, or upsert the status row keyed by `providerId`.

## Inbound repeats

A repeated inbound message has the same `dedupeKey`. If you reply, derive the reply's `idempotencyKey` from it so a repeat cannot trigger a second reply. See [Inbound SMS](/receiving/inbound-sms#reply-to-a-message).

## Next steps

- [Webhook events reference](/reference/webhook-events)
- [Deploy with multiple instances](/guides/multiple-instances)
