# Duplicate SMS

Providers retry. A webhook that times out, or that Connect acknowledged too slowly, is sent again, sometimes several times. Connect keys each inbound SMS on the provider's own message identifier, so the second delivery updates the row the first created instead of adding another. The one thing that defeats it is a provider that issues a different identifier for what is genuinely the same message.

- **Status:** Foundation
- **Audience:** both
- **Channels:** sms
- **In the app:** #/inbox
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/sms/sms-dedupe/

## Why a duplicate exists at all

Every messaging provider treats an unacknowledged webhook as an undelivered one. If Connect is slow to respond, or a network hop drops the acknowledgement, the provider has no way to distinguish that from a message that never landed, and its only safe move is to send it again. This is correct behaviour on the provider's part: losing a customer's message is worse than sending it twice.

The consequence is that at-least-once delivery is the contract, not exactly-once, and the burden of turning at-least-once into effectively-once sits with the receiver. Connect carries that burden at ingest, before a message becomes canonical, rather than trying to detect duplicates afterwards by comparing text — two people really can send the same three words a minute apart, and a system that treats that as a duplicate loses a real message.

## The key

**What is keyed** — The provider's own message identifier for that inbound message, carried on the canonical row.
**What a repeat does** — Updates the existing message rather than inserting a new one. No second conversation, no second triage, no second decision.
**What is not keyed** — The text, the timestamp, or the sending number. Identical text from the same number is two messages if the provider says it is two messages.
**Where it runs** — At ingest, before the canonical record exists — so nothing downstream ever sees the duplicate.

> **Note** The same rule governs inbound WhatsApp and retried telephony webhooks. It is one idea applied per channel, not three implementations: the record is keyed on the identifier the provider itself will reuse on a retry.

## What defeats it

| Scenario | Same identifier? | Result |
|---|---|---|
| Provider retries after a timeout | Yes | Updated in place; one conversation, one decision |
| Provider retries through a different route or region | Sometimes not | Two canonical messages; a person sees the repeat |
| The sender genuinely sends the same words twice | No | Two messages, correctly — this is not a duplicate |
| The message is forwarded through a second number you own | No | Two messages on two conversations; the identities differ |
| A test harness replays an old payload | Yes | Updated in place, which is why a replay proves nothing about ingest |

Only the second row is a real defect, and it is a provider behaviour rather than a Connect one. If you see it, the useful evidence is whether the two copies carry different provider identifiers; when they do, the conversation to have is with the provider, not with the workspace's settings.

## Why this matters more than it sounds

A duplicate that survives ingest does not stay a cosmetic problem. The agent loop reads canonical messages, so a second copy is a second piece of work: a second triage, potentially a second follow-up booked for the same promise, a second case, and on a channel that could send, a second reply to a customer who wrote once. That is the failure [The same SMS was answered twice](/docs/troubleshooting/sms-duplicate-reply/) exists to diagnose.

On SMS today the visible damage is smaller than it would be, because outbound SMS is not available on the live carrier and no duplicate reply can physically go out by text. That is a limitation doing a safety job by accident, and it is not a reason to treat the dedupe key as optional — the same ingest path is what a provider that does carry SMS would use.

## Questions

### Can I delete one copy if two arrive?

You can remove the duplicate conversation the way you would remove any conversation, and the removal is recorded. What you should not do is delete the follow-ups or cases the duplicate produced without checking which of them the original also produced — closing both leaves a promise nobody is keeping.

### Does deduplication slow ingest down?

It is a keyed lookup on a record that is already being written, so the cost is one indexed read. Ingest latency matters here for a specific reason: the slower Connect acknowledges, the more likely the provider is to retry, so a fast ingest path is itself part of the duplicate defence.

### Is the provider's identifier ever reused for a different message?

Not in any scheme documented here. If a provider did reuse one, the effect would be worse than a duplicate — a genuine second message overwriting the first — which is why the identifier is used as a key and never as a source of meaning about the message.

## Related

- [Receiving SMS](https://connectbyjbrh.com/docs/sms/inbound-sms/)
- [SMS in Connect](https://connectbyjbrh.com/docs/sms/)
- [The same SMS was answered twice](https://connectbyjbrh.com/docs/troubleshooting/sms-duplicate-reply/)
- [An inbound SMS, end to end](https://connectbyjbrh.com/docs/workflows/sms-inbound/)
- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)

## What this page is based on

- `docs-source/sources/CHANNELS.md` §3 — SMS dedupe
- `docs-source/sources/GENERAL.md` §3 — provider tables and the canonical bridge
- Connect capability registry (docs-source/facts.py) — `sms_inbound`
