Sortd Docs

Poll for new mail

GET /email/updates returns the mail that arrived since your key last asked — Sortd keeps the cursor server-side, so your agent or script holds no state at all. Built for the loop: get new mail → classify → task it → label it.

Needs email.metadata.read. Served from Sortd's own pipeline cache — it costs none of the user's Gmail quota, so polling it on a schedule is cheap by design.

What the feed returns — and what it doesn't

ReturnsDoes NOT return
  • Header envelopes only: subject, snippet, from/to/cc, dates, message_id + thread_id (raw Gmail hex), direction, thread unread/message counts.
  • The mail Sortd's pipeline processes: inbox + sent — the same stream that feeds boards, AI and automations.
  • Mail from the moment the cursor was initialised, forward.
  • Never bodies or attachments — fetch content with GET /email-threads/{id} (needs email.body.read).
  • Spam/trash, Gmail-filtered categories, auto-replies, Sortd's own sync copies — the product's inbox filters apply.
  • Anything while the mailbox is disconnected — you get 403 email_reauth_required, not a silently empty feed.
  • Anything older than the feed window (~30 hours) — see expiry.

Delivery is at-least-once. A message can appear in two consecutive batches — always dedupe on message_id. Freshness tracks Sortd's own mail sync (typically within minutes).

1. First call — initialise the cursor

The first call (or any call with reset=true) sets the cursor to now and returns an empty batch. New mail accumulates from that moment.

curl

curl "https://api.sortd.com/v2/email/updates" -H "Authorization: Bearer sk_live_…"
# -> { "data": { "messages": [], "has_more": false,
#      "cursor_position": "2026-08-01T12:00:00.000Z", "cursor_reset": true } }

JavaScript (fetch)

const res = await fetch("https://api.sortd.com/v2/email/updates", {
  headers: { Authorization: "Bearer sk_live_…" },
});
const { data } = await res.json();
// data -> { messages: [], has_more: false,
//           cursor_position: "2026-08-01T12:00:00.000Z", cursor_reset: true }

Python (requests)

import requests
data = requests.get(
    "https://api.sortd.com/v2/email/updates",
    headers={"Authorization": "Bearer sk_live_…"},
).json()["data"]
# -> { "messages": [], "has_more": False,
#      "cursor_position": "2026-08-01T12:00:00.000Z", "cursor_reset": True }

2. The loop

curl — each call returns what arrived since the previous call

curl "https://api.sortd.com/v2/email/updates?limit=50" \
  -H "Authorization: Bearer sk_live_…"

JavaScript — poll, dedupe, act

const seen = new Set(); // survive across runs? persist it — or rely on your own idempotency
async function poll() {
  const res = await fetch("https://api.sortd.com/v2/email/updates?limit=50", {
    headers: { Authorization: "Bearer sk_live_…" },
  });
  if (res.status === 410) return recover();          // new_mail_expired — see below
  const { data } = await res.json();
  for (const msg of data.messages) {
    if (seen.has(msg.message_id)) continue;           // at-least-once → dedupe
    seen.add(msg.message_id);
    if (msg.direction === "inbound") await triage(msg); // classify / task / label
  }
  if (data.has_more) return poll();                   // more already waiting
}

Python — same loop

import requests
H = {"Authorization": "Bearer sk_live_…"}
seen = set()

def poll():
    while True:
        res = requests.get("https://api.sortd.com/v2/email/updates", headers=H, params={"limit": 50})
        if res.status_code == 410:
            return recover()                    # new_mail_expired — see below
        data = res.json()["data"]
        for msg in data["messages"]:
            if msg["message_id"] in seen:
                continue                        # at-least-once -> dedupe
            seen.add(msg["message_id"])
            if msg["direction"] == "inbound":
                triage(msg)
        if not data["has_more"]:
            return

3. Expiry — and how to recover

Check in at least once a day. A cursor that's merely stale is fine — as long as the cache still covers your gap, the feed just serves it. Only once messages may genuinely have expired from the window (~30 hours) does the call refuse, because Sortd can no longer guarantee the batch is complete — and it refuses to pretend. You hold no cursor state, so the error hands you everything a recovery needs:

HTTP 410
{ "error": { "code": "new_mail_expired",
    "message": "The new-mail cursor is older than the feed window, …",
    "details": {
      "cursor_position": "2026-07-30T09:12:00.000Z",   // where your cursor stood
      "cursor_age_seconds": 183000, "window_seconds": 108000,
      "backfill_query": "after:1785395220",             // ready-made, second-precise, overlap included
      "recovery": [
        "1. GET /email/updates?reset=true — re-baseline the cursor to now (response echoes previous_cursor_position)",
        "2. If the gap matters: GET /email-threads?q=<backfill_query> — backfill by search; dedupe on message_id against the resumed feed"
      ] } } }

The order matters — reset first, backfill second. If you backfill first, mail arriving between your search and your reset falls into neither (a loss). Reset-first means anything in that seam is cached after the new cursor and simply arrives in the feed as well as (possibly) your backfill — a duplicate, which the message_id dedupe you already do absorbs. Losses are impossible; duplicates are routine.

RecoveryWhen it's right
Reset onlyGET /email/updates?reset=trueThe agent only cares about mail from now on (triage, live labelling). One call; the gap is accepted and gone. The response echoes previous_cursor_position in case you change your mind.
Reset, then backfillreset=true first, then GET /email-threads?q=<backfill_query> (a live Gmail search, paginated), deduping on message_idNothing may be missed (SLA tracking, ticket creation). Costs live Gmail calls for the gap only; the feed is already running again while you backfill.

Design your consumer so a 410 is a routine branch, not a failure: poll → on 410 reset → backfill if it matters → continue. One note for quiet mailboxes: an expired cursor doesn't prove mail was missed — if nothing arrived, there was nothing to miss — but past the window Sortd can no longer tell, which is exactly why it asks you instead of guessing.

From an AI assistant (MCP)

The same feed is the get_new_email tool — a scheduled assistant task can run the whole loop with no storage of its own: "check for new mail; classify anything inbound; put renewals on the Sales board; flag urgent threads." The tool description carries the same contract (headers-only, at-least-once, daily check-in, the two recoveries), so the model can handle a new_mail_expired answer by itself — it will ask you (or decide, if you've told it which) between starting fresh and backfilling via search_email_threads. See Connect your AI — and Automate for the schedule recipes.

Why this exists (vs. searching the inbox)

GET /email-threads?q=… is a live Gmail search: powerful, but every call spends the user's Gmail quota — the same budget Sortd's own sync runs on. The updates feed reads Sortd's cache instead, so a polling agent costs the mailbox nothing. Rule of thumb: poll the feed for "what's new"; search the mailbox for "find me X" — and use webhooks (thread.reply_received with filters.scope: all) when you have a server that Sortd can push to (see webhooks).

Next: classify a thread with AI, or create a task from an email.