Gmail History API Pagination for an AI Digest: The maxResults Default, the 404 You Will Hit, and Five Other Traps

Conveyor belts sorting mail at an automated postal center, representing the stream of changes an inbox sync job has to process in order.

A digest bot that reads Gmail’s history.list endpoint to summarize what changed since the last run looks simple in the quickstart example and breaks in five specific, well-documented ways once a mailbox gets busy. Each one below is a real parameter or quota limit from Google’s own reference, not a guess.

Trap one: trusting the default page size

maxResults defaults to 100 per call, with a documented maximum of 500. A quiet test mailbox never notices this. A mailbox that gets more than 100 message or label changes between digest runs silently loses everything past the first page, because the code never checked for a nextPageToken and never asked for the next one. The fix is a loop, not a bigger maxResults value, since 500 is still a cap:

def list_all_history(service, user_id, start_history_id):
    history_records = []
    page_token = None
    while True:
        resp = service.users().history().list(
            userId=user_id,
            startHistoryId=start_history_id,
            historyTypes=['messageAdded'],
            maxResults=500,
            pageToken=page_token,
        ).execute()
        history_records.extend(resp.get('history', []))
        page_token = resp.get('nextPageToken')
        if not page_token:
            return history_records, resp.get('historyId')

Trap two: treating a 404 as a retryable error

startHistoryId has to come from a real message, a real thread, or a previous history.list response, and Google’s reference for history.list says an outdated or invalid ID typically returns an HTTP 404. A digest job that catches that 404 and simply retries the same request will keep failing forever, because the ID is not temporarily unavailable, it is gone. Google’s own sync guidance is explicit about the response: when startHistoryId falls outside the available range, the client has to fall back to a full sync using messages.list, pull each message with messages.get, and store the historyId of the newest message for the next partial sync. History records are typically retained for at least a week, often longer, but the guidance also warns that window can be shorter in practice, so a digest that goes quiet for more than a few days should expect to hit this and should have the full-sync path actually implemented, not stubbed out.

Trap three: reading the generic messages array instead of the change-specific ones

Each History object in the response includes a messages field, but Google’s own reference recommends using the change-specific arrays instead: messagesAdded, messagesDeleted, labelsAdded, and labelsRemoved. The generic field does not tell you which kind of change happened to a given message, only that something did. A digest built on the generic array can end up summarizing the same message twice, once for an added label and once for an unrelated change, with no way to tell from that field alone which event actually matters for a “what’s new” summary. Reading the specific arrays instead means the digest only reports a new message once, under messagesAdded, and can skip labelsAdded/labelsRemoved entirely if the digest only cares about new mail rather than folder or label housekeeping.

Trap four: requesting a scope that cannot deliver what the digest needs

history.list and messages.get both accept any of four OAuth scopes: https://mail.google.com/, gmail.modify, gmail.readonly, or gmail.metadata. A digest wired up with the metadata scope will authenticate fine and call successfully, but it cannot request the full message body or raw format that a summarizer needs, because that scope restricts what messages.get is allowed to return. This is easy to miss during development against a test account that only ever reads subject lines, and shows up later as a summarizer silently working from headers alone.

Trap five: running the quota math after the mailbox is already busy, not before

Google documents per-method costs in quota units: history.list costs 2 units, messages.list costs 5, and messages.get costs 20. The per-minute-per-user-per-project limit is 6,000 units. The shape of that trade-off, a hard per-minute ceiling that forces you to ration expensive calls, is the same one you run into setting rate limits and pricing tiers against an AI API, just with different unit costs. A naive digest that calls messages.get once for every changed message, inside the same budget as the history.list calls used to find them, has a hard ceiling on how many full messages it can summarize per minute, not just a theoretical slowdown.

MethodQuota cost (units)Theoretical max calls per minute at the 6,000-unit limit
history.list23,000
messages.list51,200
messages.get20300

Leaving headroom for the history.list calls themselves (roughly 2 units times however many pages you paginate through) means the real ceiling on message bodies per minute for one user is a little under 300, not the full 300 the raw division suggests. A digest for a high-volume shared mailbox that tries to fetch every changed message’s full body in one run can hit 429s well before it finishes, and the fix is usually to fetch only snippet-level data for most messages and reserve the full messages.get call for the ones the digest actually surfaces to a human.

Trap six: not persisting the new historyId until the whole run succeeds

The response includes the mailbox’s current historyId, separate from any pageToken. If a digest job crashes partway through a multi-page pagination loop and never writes down the new ID, the next run starts from the old one and re-reports changes that were already summarized last time. Writing the final historyId only after every page has been processed successfully, rather than after the first page, avoids silently duplicating the same message in two consecutive digests. This matters even more once the job runs unattended on a cron schedule rather than by hand, since nobody is watching the first run fail.

A pagination loop with the full-sync fallback built in

from googleapiclient.errors import HttpError

def run_digest(service, user_id, stored_history_id):
    history_records = []
    page_token = None
    next_history_id = stored_history_id
    try:
        while True:
            resp = service.users().history().list(
                userId=user_id,
                startHistoryId=stored_history_id,
                historyTypes=['messageAdded'],
                maxResults=500,
                pageToken=page_token,
            ).execute()
            history_records.extend(resp.get('history', []))
            next_history_id = resp.get('historyId', next_history_id)
            page_token = resp.get('nextPageToken')
            if not page_token:
                break
    except HttpError as e:
        if e.resp.status == 404:
            # startHistoryId expired: fall back to a full sync
            msg_list = service.users().messages().list(userId=user_id).execute()
            messages = msg_list.get('messages', [])
            if messages:
                newest = service.users().messages().get(
                    userId=user_id, id=messages[0]['id'], format='minimal'
                ).execute()
                next_history_id = newest['historyId']
            history_records = []  # full sync: nothing to diff against
        else:
            raise

    added = [h for rec in history_records for h in rec.get('messagesAdded', [])]
    # ... summarize `added`, then only after summarizing succeeds:
    save_stored_history_id(next_history_id)
    return added

This handles traps one, two, and six in about fifteen lines: it pages through every result instead of stopping at the first batch, catches the specific 404 that signals an expired startHistoryId and falls back to messages.list rather than retrying blindly, and only commits the new historyId after the whole run finishes without an unhandled error.

Where this fits with the rest of an AI mailbox pipeline

A digest and a triage classifier read the same history.list endpoint but ask different questions of it: a triage system cares about new mail arriving and needs it fast, while a digest can batch and summarize on a schedule, which is exactly where the quota math above matters more. Either one benefits from treating the OAuth scope as a deliberate choice made once, rather than whatever scope happened to be granted during initial setup, since narrowing it later means re-authorizing every connected account.

Trap seven: leaving historyTypes unset and assuming it is already filtered

The historyTypes[] parameter accepts messageAdded, messageDeleted, labelAdded, and labelRemoved, and Google’s reference does not document a default value for it. That is a reason to set it explicitly rather than a reason to leave it alone: a digest that only wants to report new mail should pass historyTypes=messageAdded on every call, instead of pulling back every label and deletion event and filtering client-side after the fact. Filtering server-side costs nothing extra in quota, since the cost is per call, not per record returned, and it means the pagination loop above processes fewer, smaller pages for the same useful result.

Testing this without waiting for real mailbox volume

None of these traps show up in a quick manual test, because a developer testing against their own inbox rarely generates more than a handful of changes between runs. The practical way to exercise the pagination and 404-handling paths before they happen in production is to seed a test mailbox with a burst of changes right before a digest run: send or label more than 100 messages in a short window to force a second page, and separately, store a startHistoryId, wait past Gmail’s retention window (or simply pass a historyId far older than anything in the mailbox) to force the 404 path on purpose. A digest that has never actually exercised its own fallback path has an untested fallback path, which is functionally the same as not having one.

A cheap way to catch a silent regression

Logging the count of history records processed and the final historyId on every run, even when nothing looked wrong, turns an invisible failure into a visible one a few runs later: a sudden drop in the processed count, or a historyId that stops advancing, shows up in a log line before it shows up as a user complaint about a missing email in their summary.

What this adds up to for a digest versus a one-off script

Every one of these traps is invisible in a script that runs once against a quiet test account and visible only once the same code runs on a schedule against a mailbox that actually changes while nobody is watching it run. That gap, between what a quick manual test shows and what a scheduled job encounters a week later, is exactly where an AI email automation built on this endpoint tends to quietly start dropping or duplicating messages without ever throwing an error a monitoring system would catch.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top