# Prevent Duplicate iMessages After a Timeout

Canonical: https://figment.so/blog/prevent-duplicate-imessages

A network timeout tells you one thing: you stopped waiting. It says nothing about whether the bridge accepted your request. By the time your client dropped the connection, the Mac worker may already have handed your message to Apple's iMessage infrastructure. Issue a second POST with fresh parameters and the bridge has no basis to link it to the first — it enqueues a second delivery.

The fix is structural: attach a stable `Idempotency-Key` header to every send, then poll the receipt endpoint after any uncertain outcome before you consider retrying.

## Two scopes, one rule about chatGuid

The `messages:send` scope is separate from `messages:read` and must be explicitly requested during the OAuth authorization step. It grants access only to selected existing conversations — a conversation that appears in a read result is not automatically authorized for sends; your send grant must include that specific thread.

Every `chatGuid` value used in a send or read request must come from the chat list API. Do not construct or guess these values manually.

## Generate and persist the key before the first request

Create one key per *intended send* and freeze the exact request payload — both before the first HTTP call — and write them to your own durable store immediately. If your process crashes mid-flight, you need that key to poll the receipt later. A pending action carries no guaranteed server-side retention; your store is the source of truth.

Do not compute the key from a hash of the message body. Two separately intended sends of identical text would share a hash and merge into a single delivery. Encode intent with a unique component:

```
{appId}-{connectionId}-{conversationRef}-{uuid}
```

For example: `app_x9a-con_abc123-thread_home-01JDF3`. Never generate a new key when retrying the same intent. A new key signals a new intent; the bridge treats it as a fresh request, risking a second delivery — failures or validation can stop either request.

## Send with the Idempotency-Key header

```http
POST https://figment.so/api/apple-connect/v1/connections/con_abc123/messages/send
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json
Idempotency-Key: app_x9a-con_abc123-thread_home-01JDF3

{
  "chatGuid": "CHAT_GUID_FROM_LIST_API",
  "text": "Your order shipped."
}
```

The bridge binds the key to this connection and the exact request body. A subsequent POST with the same key and unchanged body returns the original recorded outcome without re-delivering — the response carries `"replayed": true`. If the body changes while the key stays the same, the request is rejected — a changed payload during retry must stop, not silently mint a new key. Use a new key only for an explicitly approved new action, not to handle an uncertain retry.

One example of a first-send response in live mode:

```json
{
  "data": {
    "idempotencyKey": "app_x9a-con_abc123-thread_home-01JDF3",
    "state": "submitted",
    "retrySend": false
  },
  "mode": "live",
  "replayed": false
}
```

`state: submitted` means the Mac accepted the message; it is not proof of delivery to the recipient's device. In sandbox mode, the state is `simulated` and no real message is sent:

```json
{
  "data": {
    "idempotencyKey": "app_x9a-con_abc123-thread_home-01JDF3",
    "state": "simulated",
    "delivery": "simulated",
    "retrySend": false
  },
  "mode": "sandbox",
  "replayed": false
}
```

## Poll the receipt before retrying

After a timeout or an `unknown` response, do not issue a new send. Poll the receipt endpoint with the same `Idempotency-Key`:

```http
GET https://figment.so/api/apple-connect/v1/connections/con_abc123/messages/receipt
Authorization: Bearer ACCESS_TOKEN
Idempotency-Key: app_x9a-con_abc123-thread_home-01JDF3
```

| State | Meaning | Your next action |
|---|---|---|
| `submitted` | Mac is processing | Re-poll; treat as in-flight |
| `delivered` | Delivered to recipient's device | No retry needed |
| `read` | Recipient opened the message | No retry needed |
| `failed` | Send action recorded as failed | Same key returns the recorded failure, not a new attempt; decide independently whether to start a new action under a new key |
| `unknown` | Outcome indeterminate | Re-poll after a brief wait; never resend under a new key |
| `simulated` | Sandbox synthetic receipt | No real delivery occurred |

A client-side timeout with no response is functionally `unknown`; treat it as such and re-poll with the original key.

## Idempotency and receipt reconciliation test cases

The seven scenarios below cover the edge cases that can produce duplicates or unexpected behavior:

| # | Scenario | Same key on retry? | Body changed? | Expected result |
|---|---|---|---|---|
| 1 | First send; clean response | — | — | Live: current receipt state (e.g. `submitted`); sandbox: `simulated`; `replayed: false` |
| 2 | Client timeout; retry with identical request | Yes | No | Same recorded outcome; `replayed: true` |
| 3 | Retry with edited text; same key | Yes | Yes | Request rejected — fix body or generate a new key only for an explicitly approved new action |
| 4 | `unknown`; receipt transitions to `delivered` | — | — | No retry needed |
| 5 | `unknown`; receipt remains `unknown` after sync settles | — | — | Re-poll; check Mac connection readiness before escalating |
| 6 | Two workers race to retry with the same key and body | Yes | No | Concurrent-retry behavior is not independently verified; treat this as an integration test requirement before relying on it in production |
| 7 | New distinct message after confirmed delivery | No — new key | New body | New delivery; generate a fresh key before the first attempt |

Scenario 6 is relevant when retry logic runs across distributed workers. Verify concurrent-retry behavior in your own integration tests; do not assume a specific outcome before testing.

## SMS, MMS, and RCS threads

The same idempotency pattern applies to SMS, MMS, and RCS threads. Messages in iCloud includes forwarding when enabled; the iPhone must be powered on, online, and signed into the same Apple Account ([Apple: Use Text Message Forwarding](https://support.apple.com/en-us/102545)). An `unknown` state on a carrier thread may reflect an iPhone connectivity issue rather than a bridge failure; unknown remains unknown until receipt evidence resolves it.

---

Ready to send iMessages from your app without duplicates? [Connect Apple Account](/apple-connect/developers) to link a Mac and start building. Access starts after setup; sandbox is free, and the developer subscription is $29/month per active live connection.
