Polling eRA Commons for submission status updates

A National Institutes of Health (NIH) application that Grants.gov accepted is not yet a submitted application — it still has to pass through eRA Commons, where a second validation layer runs and where the principal investigator gets a short, unforgiving window to view the assembled image and reject it if something is wrong. Missing that window is how a technically-received proposal fails to reach peer review: the errors sat unread until the correction period expired. This guide builds a resilient poller that tracks an NIH submission through its Grants.gov and eRA Commons status transitions, deduplicates the events it sees, and raises an alert the moment an error appears — the tracking half of the submission portal sync loop, picking up exactly where submitting to Grants.gov with the System-to-System API leaves off with a tracking number in hand.

Phase 1 — Map the status lifecycle and set up

Before writing a poller, model the states it can observe, because polling without a state model produces code that cannot tell “still working” from “silently failed.” An NIH submission moves through a small, well-defined lifecycle, and every transition means something operationally distinct.

The lifecycle to model:

  1. Received. Grants.gov has accepted the transmission and issued its tracking number. Nothing about compliance is known yet — this only proves the bytes arrived.
  2. Validated. Grants.gov’s own checks passed and the package is being handed to the agency. A package that fails here never reaches eRA Commons.
  3. Agency Retrieved. eRA Commons has pulled the package and assigned it an accession, and now runs the NIH-specific validation that produces errors and warnings.
  4. Errors present. eRA Commons found a blocking defect. The submission will not advance until the package is corrected and resubmitted — this is a terminal-until-fixed state.
  5. Ready for viewing / no errors. Validation produced warnings at most, the assembled application image is available, and the two-business-day viewing window opens for the signing official to accept or reject the image.

Distinguishing an error from a warning is the entire point of the exercise: errors block, warnings do not, and a poller that treats them alike either cries wolf on every submission or misses a real rejection. Pin the environment so a polling run reproduces if it must be restarted:

bash
python -m venv .venv
source .venv/bin/activate
pip install "requests>=2.31" "pydantic>=2.6" "tenacity>=8.2"

Phase 2 — Build a resilient, deduplicating poller

A production poller reads the current status, normalizes it into a typed model, and records only genuinely new status events — the status service will report the same state many times before it changes, and writing every repeated read to the audit log buries the one transition that matters. The state machine below shows the transitions the poller drives between, and the polling loop that re-reads the transient states with backoff until it reaches a terminal one.

The eRA Commons submission status state machine A state machine tracking an NIH submission. Three transient states run left to right: Received at Grants.gov, then Validated, then Retrieved by eRA Commons. A dashed poll loop arcs back from the Retrieved state to the Received state, labelled poll with backoff and dedup while non-terminal, showing the poller re-reads the status until it changes. From the eRA Commons Retrieved state the machine branches to one of two terminal states: if errors are present it goes up-right to a rejected state that must be fixed and resubmitted; if the validation is clean or warnings-only it goes down-right to a ready-for-review state where the two-business-day viewing window opens. poll · backoff · dedup while non-terminal errors clean Received Grants.gov Validated Grants.gov checks eRA Retrieved NIH validation runs Rejected fix & resubmit Ready for review 2-business-day window
The poller re-reads the transient states with backoff and branches only once eRA Commons validation resolves to a rejection or a clean, viewable image.

The typed status model normalizes whatever wording the status service returns into a stable enum, and separates errors from warnings so downstream logic can branch on severity:

python
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, field_validator

class SubmissionState(str, Enum):
    RECEIVED = "received"
    VALIDATED = "validated"
    RETRIEVED = "retrieved"      # eRA Commons has the package
    REJECTED = "rejected"        # blocking errors present
    READY = "ready_for_review"   # warnings at most; image viewable

TERMINAL = {SubmissionState.REJECTED, SubmissionState.READY}

class StatusEvent(BaseModel):
    """One normalized observation of a submission's status."""
    tracking_id: str
    state: SubmissionState
    observed_at: datetime
    errors: list[str] = Field(default_factory=list)
    warnings: list[str] = Field(default_factory=list)

    @field_validator("state")
    @classmethod
    def _ready_has_no_errors(cls, v: SubmissionState, info) -> SubmissionState:
        # A "ready for review" image must be clean — refuse a READY state that
        # still carries blocking errors so the two cannot be confused downstream.
        if v is SubmissionState.READY and info.data.get("errors"):
            raise ValueError("READY state cannot carry blocking errors")
        return v

    @property
    def is_terminal(self) -> bool:
        return self.state in TERMINAL

    def dedup_key(self) -> tuple[str, SubmissionState]:
        # Two reads of the same state for the same submission are the SAME event.
        return (self.tracking_id, self.state)

The poller drives the read on an exponential backoff, records only unseen dedup_key events, and stops the instant it reaches a terminal state or raises an alert on a rejection:

python
from tenacity import retry, stop_after_attempt, wait_exponential

class StatusPoller:
    def __init__(self, client, sink, alerter) -> None:
        self._client = client          # returns a raw status; maps to StatusEvent
        self._sink = sink              # append-only audit sink
        self._alerter = alerter        # notifies humans on a rejection
        self._seen: set[tuple[str, SubmissionState]] = set()

    def poll_until_terminal(self, tracking_id: str, max_reads: int = 20) -> StatusEvent:
        last: StatusEvent | None = None
        for _ in range(max_reads):
            event = self._read(tracking_id)
            if event.dedup_key() not in self._seen:
                self._seen.add(event.dedup_key())
                self._sink.append(event)             # only NEW transitions logged
                if event.state is SubmissionState.REJECTED:
                    self._alerter.rejection(event)   # surface errors immediately
            last = event
            if event.is_terminal:
                return event                          # stop on terminal state
        return last                                   # exhausted reads: still pending

    @retry(stop=stop_after_attempt(5),
           wait=wait_exponential(multiplier=2, min=4, max=60))
    def _read(self, tracking_id: str) -> StatusEvent:
        # Transient network/rate-limit failures are retried; a real status is
        # returned to the caller for dedup and terminal-state handling.
        return self._client.get_status(tracking_id)

Deduplication on (tracking_id, state) is what keeps the audit trail readable: the poller may read “retrieved” a dozen times while eRA Commons runs its checks, but only the first observation of each distinct state is recorded, so the log shows a clean progression rather than a wall of repeats. Every recorded event flows into audit logging and provenance so the institution can later prove exactly when each transition was observed.

Phase 3 — Edge cases and agency-specific overrides

Four realities complicate a poller that only walks the happy path.

Errors versus warnings. eRA Commons emits both, and only errors block. A warning — a page that is close to a limit, a non-fatal formatting note — is informational and the application still advances. The poller must carry the two lists separately and alert only on errors; alerting on warnings trains the grant office to ignore the alerts entirely.

The two-business-day viewing window. Once eRA Commons assembles the application image without errors, the signing official has roughly two business days to view it and, if necessary, reject it and submit a corrected package — but only up to the deadline. The poller should compute the window from the retrieval timestamp and escalate as it nears expiry, because an image that is never viewed is accepted by default, defects and all.

Transient failures and rate limits. The status service will occasionally return a 5xx or throttle a client that polls too aggressively. Exponential backoff with a ceiling handles both: it retries transients without hammering the service, and it spaces reads far enough apart that a portfolio of submissions does not trip a rate limit. This is the same backoff-and-jitter discipline documented for high-volume runs in retry and rate-limit strategies for overnight RFP batches.

Polling cadence near the deadline. A submission read once an hour is fine days out but dangerously slow in the final hours, when a rejection leaves almost no time to correct. Tighten the interval as the deadline approaches so a late-breaking error is caught while there is still time to act.

Edge case Signal Poller response
Errors present eRA Commons error list non-empty Alert immediately; treat as terminal-until-fixed
Warnings only Warning list non-empty, no errors Record, do not alert; submission advances
Viewing window Retrieval timestamp + two business days Escalate as expiry nears; default-accept is the risk
Transient / rate limit 5xx or throttling response Exponential backoff with ceiling; do not tighten blindly

Phase 4 — Validation of the poller

Test the poller against a scripted status client, never a live submission. The three properties that make it trustworthy are: it deduplicates repeated states, it stops at a terminal state, and it alerts on a rejection.

python
import pytest
from datetime import datetime, timezone

def _event(tracking: str, state: SubmissionState, errors=None) -> StatusEvent:
    return StatusEvent(tracking_id=tracking, state=state,
                       observed_at=datetime.now(timezone.utc),
                       errors=errors or [])

class ScriptedClient:
    """Yields a fixed sequence of states, repeating the last one."""
    def __init__(self, states: list[SubmissionState]) -> None:
        self._states, self._i = states, 0
    def get_status(self, tracking_id: str) -> StatusEvent:
        state = self._states[min(self._i, len(self._states) - 1)]
        self._i += 1
        errs = ["E01: missing biosketch"] if state is SubmissionState.REJECTED else []
        return _event(tracking_id, state, errs)

class ListSink:
    def __init__(self) -> None: self.events: list[StatusEvent] = []
    def append(self, e: StatusEvent) -> None: self.events.append(e)

class RecordingAlerter:
    def __init__(self) -> None: self.alerts: list[StatusEvent] = []
    def rejection(self, e: StatusEvent) -> None: self.alerts.append(e)

def test_poller_dedups_repeated_states() -> None:
    client = ScriptedClient([SubmissionState.RECEIVED, SubmissionState.RECEIVED,
                             SubmissionState.VALIDATED, SubmissionState.READY])
    sink, alerter = ListSink(), RecordingAlerter()
    StatusPoller(client, sink, alerter).poll_until_terminal("T-1")
    logged = [e.state for e in sink.events]
    assert logged == [SubmissionState.RECEIVED, SubmissionState.VALIDATED,
                      SubmissionState.READY]          # no duplicate RECEIVED

def test_poller_stops_at_terminal_state() -> None:
    client = ScriptedClient([SubmissionState.READY, SubmissionState.RECEIVED])
    sink = ListSink()
    result = StatusPoller(client, sink, RecordingAlerter()).poll_until_terminal("T-2")
    assert result.state is SubmissionState.READY       # never read past terminal

def test_poller_alerts_on_rejection() -> None:
    client = ScriptedClient([SubmissionState.RETRIEVED, SubmissionState.REJECTED])
    alerter = RecordingAlerter()
    StatusPoller(client, ListSink(), alerter).poll_until_terminal("T-3")
    assert len(alerter.alerts) == 1
    assert alerter.alerts[0].errors                    # errors carried to the alert

A manual checklist covers the rest: confirm the viewing-window clock starts at the retrieval timestamp, that warnings never trigger an alert, that the polling interval tightens near the deadline, and that a throttled read backs off rather than retrying immediately. Only a poller that passes all four should be trusted to watch a real submission unattended.

Polling eRA Commons well converts the anxious wait after a submit into an observable, alerting process: every transition is captured once, a blocking error reaches a human the moment it appears, and the two-business-day window is never lost to an unread status. That is what turns a received package into a submission that actually reaches peer review.

Frequently asked questions

What is the difference between an eRA Commons error and a warning?

An error blocks — the submission will not advance to peer review until the package is corrected and resubmitted. A warning is informational and non-blocking; the application proceeds with it noted. The poller carries the two lists separately and alerts only on errors, because alerting on warnings trains the grant office to ignore the notifications that actually matter.

Why does the poller deduplicate on tracking id and state?

The status service reports the same state repeatedly while eRA Commons runs its checks — you may read “retrieved” a dozen times before it changes. Recording every read buries the real transitions in noise. Deduplicating on the (tracking_id, state) pair records only the first observation of each distinct state, so the audit log shows a clean progression a reviewer can actually follow.

What is the two-business-day viewing window and why does it matter?

After eRA Commons assembles the application image without errors, the signing official has roughly two business days — bounded by the deadline — to view that image and reject it if something is wrong. An image that is never viewed is accepted by default. The poller computes the window from the retrieval timestamp and escalates as expiry nears, so a defect is never accepted silently because nobody looked.

How fast should I poll?

Use exponential backoff with a ceiling so transient failures and rate limits are absorbed without hammering the service, and tighten the interval as the deadline approaches. Reading once an hour is fine days out but far too slow in the final hours, when a rejection leaves almost no time to correct and resubmit.

Does a clean Grants.gov validation mean the NIH submission succeeded?

No. Grants.gov acceptance is only the first layer. eRA Commons then retrieves the package and runs NIH-specific validation that can still produce blocking errors. The tracking number from the Grants.gov submit is what you poll on, but it is the eRA Commons result — errors, warnings, and the viewable image — that determines whether the application reaches review.

Up one level: Submission Portal Sync