Automating daily grant portal polling with Python requests
On this page
- Problem statement
- Prerequisites
- Step-by-step implementation
- Step 1 — Configure compliance-aligned logging and the frozen schema
- Step 2 — Build a session with bounded retries and least-privilege headers
- Step 3 — Fingerprint and validate the payload
- Step 4 — Write atomically so a crash never leaves a half file
- Step 5 — Orchestrate the idempotent daily poll
- Step 6 — Schedule the job
- Schema and field reference
- Verification
- Troubleshooting
- Frequently asked questions
- Related
Problem statement
You need a once-a-day, unattended Python job that pulls award and status records from an external university grant portal, writes them to disk only when they actually change, and produces an immutable audit trail that satisfies federal sponsor data-integrity rules — without a manual download ever entering the chain of custody.
This task sits under API Polling & Portal Integration, part of the broader Automated Ingestion & Data Sync Workflows practice. The polling layer is intentionally narrow: it acquires data deterministically and hands it to the Schema Validation Pipelines and downstream archival layers. It does not interpret compliance state — it captures it, fingerprints it, and surfaces anomalies for human review, consistent with the separation of concerns established in the Grant Lifecycle Architecture Design.
Prerequisites
Before deploying the poller, confirm the following environment and policy configuration:
- Python 3.10+ (the code uses
from __future__-free union syntax anddatetime.timezone.utc). - Libraries:
requests(HTTP),urllib3(bundled withrequests, used for the retry adapter). Install withpip install "requests>=2.31". - Environment variables (never hard-code credentials, per Security Boundary Configuration):
GRANT_PORTAL_BASE_URL— the portal API root, e.g.https://api.university-funding.example/v1.GRANT_PORTAL_API_TOKEN— a least-privilege, read-only bearer token scoped to grant-status endpoints only.
- Policy config: a frozen institutional schema definition (the required-key set below) version-controlled alongside your University Policy Mapping Frameworks, plus a writable state directory (
.compliance_state/) retained per your sponsor’s record-retention schedule (typically 3–7 years for federal awards). - Scheduler:
cronor asystemdtimer to fire the job once per day. The script is idempotent, so an accidental double-run causes no duplicate writes.
Step-by-step implementation
The flow below is enforced by the poller: a daily trigger fetches the endpoint, retries transient failures with backoff, validates the payload against the institutional schema, and writes only when the content hash differs from the last run.
Step 1 — Configure compliance-aligned logging and the frozen schema
Structured logging to a dedicated audit file is the foundation of non-repudiation. Pin the required-key set so schema drift is detected, not silently absorbed.
import os
import json
import logging
import hashlib
import csv
import tempfile
import shutil
from datetime import datetime, timezone
from typing import Dict, List
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Compliance-aligned structured logging: every run appends to an immutable
# audit file AND streams to stdout for the scheduler's job log.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[
logging.FileHandler("grant_poll_audit.log"),
logging.StreamHandler(),
],
)
logger = logging.getLogger("grant_portal_poller")
# Frozen institutional schema — version-control this set with your policy config.
REQUIRED_SCHEMA_KEYS = {"grant_id", "pi_name", "award_amount", "status", "last_updated"}Step 2 — Build a session with bounded retries and least-privilege headers
A single reusable requests.Session isolates authentication state and mounts an HTTPAdapter that retries only the transient status codes (429, 5xx) and only the idempotent GET method — never a write verb.
class GrantPortalPoller:
def __init__(
self,
base_url: str,
api_token: str,
poll_interval_hours: int = 24,
state_dir: str = ".compliance_state",
):
self.base_url = base_url.rstrip("/")
self.state_dir = Path(state_dir)
self.state_dir.mkdir(parents=True, exist_ok=True)
self.poll_interval = poll_interval_hours
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_token}", # token comes from env, never source
"Accept": "application/json",
"User-Agent": "UniversityResearchHub/1.0 (Compliance-Polling)",
})
# Exponential backoff for transient portal failures (429, 5xx).
# allowed_methods is restricted to GET so a retry can never re-POST.
retry_strategy = Retry(
total=4,
backoff_factor=1.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"],
)
self.session.mount("https://", HTTPAdapter(max_retries=retry_strategy))Step 3 — Fingerprint and validate the payload
The SHA-256 fingerprint is computed over a normalized (sorted) representation so cosmetic reordering by the portal never produces a false “changed” signal. Schema validation rejects malformed records before they can reach disk; rejected rows are logged, not committed.
class GrantPortalPoller: # continued
def _compute_payload_hash(self, data: List[Dict]) -> str:
"""Deterministic SHA-256 fingerprint for idempotency checks."""
normalized = json.dumps(sorted(data, key=lambda x: x.get("grant_id")), sort_keys=True)
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def _validate_schema(self, records: List[Dict]) -> List[Dict]:
"""Strict schema validation per institutional compliance standards."""
valid = []
for idx, record in enumerate(records):
missing = REQUIRED_SCHEMA_KEYS - record.keys()
if missing:
logger.warning(f"Schema violation at index {idx}: missing keys {missing}")
continue # quarantine by omission — never write a partial record
valid.append(record)
return validStep 4 — Write atomically so a crash never leaves a half file
Writing to a temp file, fsync-ing, then shutil.move guarantees the output CSV is either the old version or the complete new one — never a truncated artifact an auditor would flag.
class GrantPortalPoller: # continued
def _atomic_write_csv(self, filepath: Path, records: List[Dict]) -> None:
"""Prevents partial writes and ensures audit-safe file operations."""
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".csv", dir=filepath.parent
) as tmp:
writer = csv.DictWriter(tmp, fieldnames=sorted(REQUIRED_SCHEMA_KEYS))
writer.writeheader()
writer.writerows(records)
tmp.flush()
os.fsync(tmp.fileno()) # force bytes to disk before rename
shutil.move(tmp.name, str(filepath)) # atomic on the same filesystemStep 5 — Orchestrate the idempotent daily poll
The driver ties the pieces together: fetch, validate, compare the fingerprint against the saved state, and write only on a genuine change. Network and decode failures return False so the scheduler sees a non-zero exit and can alert — but they never raise a false “data changed” event.
class GrantPortalPoller: # continued
def poll_and_sync(self, endpoint: str, output_csv: str) -> bool:
"""Execute idempotent daily poll with compliance logging."""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
state_file = self.state_dir / f"{endpoint.replace('/', '_')}_state.json"
output_path = Path(output_csv)
try:
response = self.session.get(url, timeout=(10, 30)) # (connect, read)
response.raise_for_status()
payload = response.json()
records = payload if isinstance(payload, list) else payload.get("grants", [])
except requests.exceptions.RequestException as e:
logger.error(f"Network/portal failure: {e}")
return False
except ValueError as e:
logger.error(f"JSON decode failure: {e}")
return False
valid_records = self._validate_schema(records)
if not valid_records:
logger.warning("No valid records after schema validation. Skipping write.")
return True
current_hash = self._compute_payload_hash(valid_records)
previous_state = {}
if state_file.exists():
previous_state = json.loads(state_file.read_text())
if previous_state.get("last_hash") == current_hash:
logger.info("Idempotent check passed: payload unchanged. Skipping write.")
return True
self._atomic_write_csv(output_path, valid_records)
new_state = {
"last_hash": current_hash,
"record_count": len(valid_records),
"synced_at": datetime.now(timezone.utc).isoformat(),
"endpoint": endpoint,
}
state_file.write_text(json.dumps(new_state, indent=2))
logger.info(f"Compliant sync complete: {len(valid_records)} records -> {output_csv}")
return True
# Execution entry point (schedule via cron/systemd)
if __name__ == "__main__":
BASE_URL = os.getenv("GRANT_PORTAL_BASE_URL", "https://api.university-funding.example/v1")
API_TOKEN = os.getenv("GRANT_PORTAL_API_TOKEN")
if not API_TOKEN:
raise RuntimeError("GRANT_PORTAL_API_TOKEN environment variable required")
poller = GrantPortalPoller(base_url=BASE_URL, api_token=API_TOKEN)
success = poller.poll_and_sync(endpoint="grants/active", output_csv="daily_grant_status.csv")
exit(0 if success else 1)Step 6 — Schedule the job
Run the poller once daily at 06:00, capturing both stdout and stderr into a rotating job log. A crontab entry:
# m h dom mon dow command
0 6 * * * GRANT_PORTAL_API_TOKEN=$(cat /etc/grant/token) /opt/grant/.venv/bin/python /opt/grant/poller.py >> /var/log/grant/poll.log 2>&1Because each run re-checks the saved fingerprint, a missed day followed by a catch-up run is safe: the first successful poll reconciles state with no duplicate output.
Schema and field reference
The poller enforces this minimal required-key set. Type and constraint expectations align with sponsor data dictionaries; widen the set in your version-controlled policy config rather than in code.
| Field | Type | Constraint | Source rule |
|---|---|---|---|
grant_id |
string | Non-empty, unique per record; used as the sort key for hashing | NIH/NSF award identifier (sponsor data dictionary) |
pi_name |
string | Non-empty | NIH eRA Commons / NSF Research.gov PI of record |
award_amount |
number | ≥ 0, currency in USD | 2 CFR 200 cost-principle reporting |
status |
string | Enumerated (e.g. active, pending, closed) | Sponsor award lifecycle state |
last_updated |
string | ISO-8601 timestamp, normalized to UTC | Audit-trail timestamp for non-repudiation |
Verification
Confirm a run behaved correctly before trusting its output:
- Audit log:
grant_poll_audit.logcontains anINFOline — eitherCompliant sync complete: N recordsorIdempotent check passed— timestamped inside the scheduled window. - Reproduce the hash: read the current CSV, re-run
_compute_payload_hashon the parsed rows, and confirm it matcheslast_hashin.compliance_state/grants_active_state.json. An equal hash proves the on-disk data matches the recorded fingerprint. - Row-count cross-check: the
record_countin the state file must equal the data-row count ofdaily_grant_status.csv(minus the header) and the count shown in the portal UI snapshot. - Dry-run idempotency: run the script twice back-to-back. The second run must log
Idempotent check passedand leave the CSV’s mtime unchanged.
Troubleshooting
Three failure modes specific to daily portal polling:
- Hash changes every run with no real data change. The portal is returning records in a non-deterministic order or with drifting
last_updatedtimestamps. The sort in_compute_payload_hashhandles reordering, but if timestamps churn, normalize or exclude volatile fields from the fingerprint before hashing. - Persistent
429 Too Many Requestsdespite backoff. Four retries withbackoff_factor=1.5may be too aggressive for a tightly rate-limited portal. Raisebackoff_factorto2.0, stagger the cron minute away from the top of the hour, or request a quota increase through your IT liaison. For high-volume reconciliation, move the workload onto the Async Processing & Queue Management path instead of a single synchronous poll. - Repeated
Schema violationwarnings. The portal changed its payload shape (API version drift). The poller correctly quarantines the rows by omission. Diff the live payload against your frozenREQUIRED_SCHEMA_KEYS, and if the primary endpoint is degraded, divert acquisition through your Fallback Routing Protocols until the schema is reconciled.
Frequently asked questions
Why hash the payload instead of trusting the portal's last_updated field?
last_updated on read or on internal re-indexing without any change to the substantive award data. A SHA-256 fingerprint over the normalized record set is the only reliable signal that the data an auditor cares about actually changed, which is what keeps the daily write idempotent.
Is it safe if the cron job runs twice in the same day?
Idempotent check passed, and skips the write entirely. No duplicate CSV is produced and the audit log records that the data was unchanged.