Skip to main content
This recipe exports one local day of a workflow’s runs as a flat CSV, one row per run. It runs no nodes of its own: it reads runs of any workflow that ends in an Extract node, and those runs were billed when they ran.
One processed run
statusprocessed
invoice_number
total_amount
line_items
One CSV row
NodesCredits0You get

When to use this

  • v2 to v3 migration. v2 exposed a per-workflow CSV download and v3 does not, so reproduce it in a few lines of SDK code.
  • Daily reporting jobs. Grab yesterday’s runs on a cron and drop the CSV in a bucket, a warehouse or an email.
  • Ad-hoc pulls. Analysts asking for “everything we processed on 2026-07-14 in Madrid time”.

What it does

  1. Convert the requested local day (Europe/Madrid) into a half-open UTC interval and hand it to GET /v3/workflows/{id}/runs/ via the document_packet_created_after and document_packet_created_before query params. That is server-side filtering, one round-trip per page.
  2. Fan out GET /v3/runs/{run_id}/ in parallel to fetch inline results.
  3. Emit one CSV row per run: run_id, packet_id, then one column per top-level scalar field. Nested list-of-object fields such as line items are skipped, as v2’s CSV also flattened only scalars. The script assumes a linear parse → extract workflow with no split or classify branches.
Concurrency and rate limiting, stdlib only:
  • asyncio.Semaphore(CONCURRENCY) caps in-flight requests.
  • A sliding-window token bucket at 55 req/min takes one acquire() per outbound HTTP call. That sits under the API’s 60 req/min ceiling with headroom for a 429 retry, which honours Retry-After.
Progress is logged to stderr at INFO: every request with its query params, the computed local to UTC window, page sizes, and per-run status. Read it to verify the timezone conversion at a glance.

End-to-end

Run it

Expected stderr (abridged):
Check that the Madrid to UTC offset for the day looks right: +02:00 in summer (CEST) with UTC bounds at 22:00, +01:00 in winter with 23:00. Spain observes DST, so the exact UTC hours shift by month.
This is a starting point, not a production template.

When it goes wrong

The line items are missing from the CSV. scalar_cells keeps only top-level scalars, because v2’s CSV flattened only scalars too. An object field never becomes a column. Iterate the object list at extraction["fields"]["line_items"] (a list[dict[str, {"value": ...}]]) and emit one row per child, or write a second CSV. The same function reads extractions[0] alone, so a workflow with a Split or a Classify branch needs one row per extraction, not one per run. The CSV has a header and no columns. The typed result.fields view comes back silently empty on any wire drift, which lands as a CSV with run_id and packet_id and nothing else. scalar_cells reads result.raw["extractions"][0]["fields"] for that reason. Keep it reading the raw envelope. You cannot tell which rows to trust. value is one of three keys on every cell: confidence and evidence sit beside it. Append a <field>_confidence column per field, or fold the evidence text in, and sort the export by the lowest confidence in the row. The export stops partway with a 429. The API ceiling is 60 requests a minute. RATE_PER_MIN is set to 55 so the single retry has room, and gated() honours Retry-After. Raise it only if your limit was raised, and keep it strictly under the ceiling. A run that fails anyway takes the whole export with it, because asyncio.gather(...) propagates the first exception: pass return_exceptions=True and log the per-run failures instead. The day is off by an hour. The window is built in Europe/Madrid and converted to UTC, and Spain observes DST, so the UTC bounds move between summer and winter. Read the logged window before you trust the file. For a range wider than a day, swap parse_day for a --from and --to pair and pass the wider range through the same document_packet_created_{after,before} params.

Next steps

Runs & results

Run lifecycle, statuses, and the results envelope

Document packets

How packets group files and relate to runs