Skip to main content
A batch is your own loop, one symbol per request: check coverage first to skip the symbols that cannot answer, fetch a snapshot for the rest, and deepen only the shortlist. The rate that loop runs at is set by your plan’s per-minute limit, and the code below reads the rate-limit headers to stay under it.

Check coverage before you spend the batch

1,125 of the 5,628 covered symbols are foreign private issuers that file 20-F/40-F/6-K, so /v2/filings and /v2/insider return unsupported for them; ETFs return unsupported for earnings and other stock-only families. Resolve those gaps with one cheap /v2/coverage call per symbol before you fan out the expensive routes (see coverage and gaps for the full universe). Dropping a symbol you know cannot answer is the cheapest request you never make. For a 2–12 symbol comparison, do not hand-roll a batch at all. /v2/compare?symbols=AAPL,MSFT,NVDA does it in one call and returns fields_omitted_by_symbol for anything a symbol cannot supply.

Concurrency follows the minute limit

Pick your in-flight count from the plan’s per-minute limit, then leave headroom, because bursts and retries eat into it. The plans and limits page owns the exact numbers; the mapping for batches:
PlanMinute limitIn-flight
Free30/min1–2
Starter60/min2–3
Builder300/min5 or more
On Free, the plan also bounds which routes you can batch: only /v2/search, /v2/coverage, /v2/snapshot, and /v2/valuation are reachable. Fanning fundamentals, filings, or any other route over a Free key returns 403 PLAN_UPGRADE_REQUIRED per request, before quota ever matters. Then mind the daily quota: 100 requests a day disappears in one watchlist refresh of 30–40 symbols once you add a coverage call and a snapshot call each. Free batch work needs caching or a small list, and there is no header that counts the daily budget down: the X-RateLimit-* headers track the minute bucket, so they look healthy right up until the quota 429 lands. Count your own daily requests. A quota 429 is recognizable by its size: Retry-After jumps from seconds to hours (the time until the window resets). The quota math lives on plans and limits. A bounded worker pool keeps you at that in-flight count. Both versions below read X-RateLimit-Remaining to slow down early and honor Retry-After on a 429 instead of hammering through it.
batch.py
import asyncio
import httpx

async def fetch(client, sem, symbol, key):
    async with sem:  # cap in-flight at the semaphore's size
        res = await client.get(
            "https://api.stockcontext.com/v2/snapshot",
            params={"symbol": symbol},
            headers={"X-API-Key": key},
        )
        if res.status_code == 429:
            retry_after = float(res.headers.get("Retry-After", 5))
            if retry_after > 120:
                # Quota 429: Retry-After is the time to the window reset
                # (hours, not seconds). Stop the batch instead of sleeping.
                raise RuntimeError(f"quota exhausted; window resets in {retry_after:.0f}s")
            await asyncio.sleep(retry_after)
            return await fetch(client, sem, symbol, key)
        if int(res.headers.get("X-RateLimit-Remaining", 1)) < 5:
            await asyncio.sleep(1)  # near the ceiling, ease off
        return symbol, res.json()

async def run(symbols, key, in_flight=2):  # Free: 1–2, Builder: 5+
    sem = asyncio.Semaphore(in_flight)
    async with httpx.AsyncClient(timeout=15) as client:
        return await asyncio.gather(*(fetch(client, sem, s, key) for s in symbols))
The 120-second cutoff that separates a minute 429 from a quota 429 is a heuristic, not a contract; pick one threshold and reuse it everywhere so this loop and the one in errors and retries agree. For the full retry semantics (attempt caps, total-wait budget, retrying only retryable: true), wrap each request with that loop. The minimal Retry-After handling above keeps a batch from drowning, but it is not the complete policy.

SEC routes do not belong in a tight loop

Filing and insider routes read from EDGAR and can be slow cold, so they are the wrong thing to fan out across a list. Run them in a background job, cache filing metadata and section text hard (it does not change), and only pull them for the shortlist a user actually opened. The same applies to /v2/insider, which scans the latest 200 Form 4 filings per symbol.

Where batches go wrong

  • Calling fundamentals, filings, and insider for every watchlist row instead of snapshot-first, then shortlisting.
  • Treating PLAN_UPGRADE_REQUIRED as retryable and looping on it mid-batch.
  • Continuing to send after a 429 instead of waiting Retry-After.
  • Retrying unsupported ETF or foreign-issuer states; they are stable 200s, not failures.
  • Making every missing field look like a zero. Carry the reason through.

Production patterns

Caching, key safety, and SEC loading states around the loop.

Compare basket

The one-call scorecard for 2–12 symbols.