Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Duplicates and ordering

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:

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.

Handle out-of-order status

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

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.

Next steps