:::endpoint GET /v1/me/mail/changes

The call to make when a client comes back, instead of fetching the mailbox
again. Over a mobile connection the difference between four rows and two hundred
is the difference between an app that opens and one that spins.

Two halves, because a cache can be wrong in two ways. Something changed, which
is an ordinary row. Or something disappeared, which is an absence and can never
turn up in a list of rows.

## Request

```bash
curl "https://api.proppertrading.com/v1/me/mail/changes?since=2026-08-19T12:00:00Z&limit=50" \
  -H "Authorization: Bearer ptat_9f2c1a44_3b8e7d2f5c9a1b4e6d8f0a2c4e6b8d1f"
```

| Parameter | Description                                                                             |
| --------- | --------------------------------------------------------------------------------------- |
| `since`   | Required. An ISO 8601 timestamp the first time, then the `next_since` of your last sync |
| `limit`   | Up to 100. Defaults to 20. Applies to `updated` only                                    |

## Response

```json
{
    "data": {
        "object": "mail_changes",
        "updated": [
            {
                "object": "mail_message",
                "id": 1552,
                "folder": "trash",
                "is_read": true
            }
        ],
        "deleted": [1579],
        "has_more": true,
        "next_since": "MjAyNi0wOC0xOSAxODoyMDo0Ni4wMDAwMDB8MTU1Mg"
    },
    "meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
```

| Field        | Description                                                                                                                      |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `updated`    | Messages that changed, in the same shape as [a list row](/docs/api/mail/messages). New mail is in here too: arriving is a change |
| `deleted`    | Ids of messages that no longer exist. Nothing else about them is kept                                                            |
| `has_more`   | There are more changes waiting. Call again straight away with `next_since`                                                       |
| `next_since` | Where to resume. Hand it back untouched                                                                                          |

## Walking it

Oldest change first, so a client that gets halfway and loses signal resumes
exactly where it stopped rather than starting over. Keep calling while
`has_more` is true, then store `next_since` until the next time.

`next_since` is a position, not a clock reading. It carries the id as well as
the timestamp, so two messages that changed in the same second cannot straddle a
page boundary and have one of them skipped.

`deleted` is not paged. Bare ids are small, and a client with more deletions than
fit in one response has a much worse problem than the size of it.

## When a client has been away too long

Deletion records are kept for 45 days. A `since` older than that answers
`sync_window_expired`, because past that point we cannot promise the list of
deletions is complete, and a sync that quietly misses things is worse than being
told to start over. Fetch [the message list](/docs/api/mail/messages) again and
begin a fresh sync from now.

## Errors

| Status | Code                    | Meaning                                         |
| ------ | ----------------------- | ----------------------------------------------- |
| 400    | `missing_since`         | No `since` was sent                             |
| 400    | `invalid_since`         | Not a timestamp and not one of our handles      |
| 400    | `sync_window_expired`   | Older than the 45 day window. Rebuild the cache |
| 400    | `invalid_limit`         | Not a positive integer                          |
| 403    | `insufficient_scope`    | This device does not hold `mail:read`           |
| 403    | `mailbox_not_permitted` | The account may not open a mailbox at all       |