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
- Convert the requested local day (Europe/Madrid) into a half-open UTC
interval and hand it to
GET /v3/workflows/{id}/runs/via thedocument_packet_created_afteranddocument_packet_created_beforequery params. That is server-side filtering, one round-trip per page. - Fan out
GET /v3/runs/{run_id}/in parallel to fetch inline results. - 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 linearparse → extractworkflow with nosplitorclassifybranches.
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 honoursRetry-After.
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
+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

