Exporting Submissions¶
One authenticated route writes a filtered set of submissions as a downloadable file, in JSON or in CSV.
curl -OJ -G "http://127.0.0.1:8000/submissions/export" \
-H "Authorization: Bearer $HYMICAL_KEY" \
--data-urlencode "endpoint_id=contact-form" \
--data-urlencode "received_after=2026-08-01T00:00:00Z"
The filters are the same three the listing takes, with the same exclusive bounds. See Browsing submissions.
JSON¶
The default. One object with one key:
{
"submissions": [
{
"id": "sub_48984534f33749c49a88de2d59400dce",
"endpoint_id": "contact-form",
"received_at": "2026-08-25T10:00:00Z",
"fields": {
"email": ["dev@example.com"],
"topics": ["billing", "api"]
}
}
]
}
fields is exactly what the detail route returns, and exactly what is stored.
The document is written as the rows arrive rather than built in memory first, so an export of thousands of submissions starts sending immediately.
CSV¶
curl -OJ -G "http://127.0.0.1:8000/submissions/export" \
-H "Authorization: Bearer $HYMICAL_KEY" \
--data-urlencode "format=csv"
submission_id,endpoint_id,received_at,email,topics
sub_48984534f33749c49a88de2d59400dce,contact-form,2026-08-25T10:00:00Z,"[""dev@example.com""]","[""billing"",""api""]"
Three fixed metadata columns come first. After them there is one column per field name, and the set of columns is the union of every field name in the export, in the order each was first met.
That ordering means a CSV of one endpoint's submissions comes out roughly in the order that endpoint's form asks its questions. Sorting alphabetically would be just as deterministic and would read worse.
Why every value cell is a JSON array¶
A form field can be submitted more than once, so a cell has to be able to hold several values. Almost every separator you might reach for, a comma, a semicolon, a pipe, can also appear inside somebody's answer, and then the cell is ambiguous and nothing can tell the two apart afterwards.
So a cell holds a JSON array:
| Submitted | Cell |
|---|---|
topics=billing&topics=api |
["billing","api"] |
email=dev@example.com |
["dev@example.com"] |
phone= |
[""] |
| field not submitted at all | empty cell |
A field submitted once is still a one-element array, so a column has one shape rather than two. An absent field and a field submitted empty stay distinguishable, which a shared separator could not manage.
Escaping happens twice and neither layer is hand-written: JSON quotes the values
inside the cell, and Python's csv module quotes the cell inside the row. A
comma, a double quote, a newline or a tab in an answer survives both and parses
back to exactly what was submitted.
Spreadsheet formulas¶
A spreadsheet reads a cell beginning with =, +, - or @ as a formula
rather than as text. Exports get opened in spreadsheets, and both field names and
field values are written by whoever filled the form in.
Any cell that would begin with one of those characters, or with a tab or a carriage return, is written with a leading apostrophe. An apostrophe is what a spreadsheet itself writes to mean "this is text", so the cell displays the original characters and is not evaluated.
In practice a value cell never needs it, because every value cell is a JSON
array and so begins with [. A field name is another matter: a form is free
to call a field =cmd(), and that name becomes a header cell. The rule is
applied to every cell so that the property holds regardless.
Only the export representation is changed, and only by prefixing. Nothing stored is altered, nothing is dropped, and the original characters are still there to be read. The API's JSON responses and the webhook payload are untouched.
Reading a CSV back¶
Parse the metadata columns as strings and every other cell as JSON:
import csv, json
with open("hymical-submissions-2026-08-25.csv", newline="", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
fields = {
name: json.loads(cell)
for name, cell in row.items()
if name not in ("submission_id", "endpoint_id", "received_at") and cell
}
print(row["submission_id"], fields)
If you enabled the spreadsheet escaping path on a field name, strip a single leading apostrophe from the header before matching on it.
Size limit¶
An export returns the whole filtered set, so the set has to be bounded. A filter
matching more than FORMS_EXPORT_MAX_SUBMISSIONS, which defaults to 10000, is
refused:
{
"error": {
"code": "export_too_large",
"message": "This filter matches more than 10000 submissions, ...",
"details": { "limit": 10000 }
}
}
Refused rather than truncated, on purpose. A silently shortened export is a file somebody archives and only discovers is incomplete much later.
To export more than the limit, narrow the range and take it in parts:
for month in 01 02 03; do
curl -OJ -G "http://127.0.0.1:8000/submissions/export" \
-H "Authorization: Bearer $HYMICAL_KEY" \
--data-urlencode "received_after=2026-${month}-01T00:00:00Z" \
--data-urlencode "received_before=2026-${month}-28T00:00:00Z"
done
Raising FORMS_EXPORT_MAX_SUBMISSIONS is the other option. Bear in mind what it
buys: a CSV is built in one pass before it is sent, because a CSV header is the
union of every field name in the export and that is only known once the last row
has been read. The limit is what bounds how much that pass holds. JSON has no
such dependency and streams.
Filenames¶
Both formats are sent with Content-Disposition: attachment and a generated
name:
curl -OJ and every browser will use it. The date is the day the export was
requested, in UTC.
The filename is generated entirely by this service. No endpoint identifier and no
filter value goes into it, so there is no caller input in it to sanitise and
nothing that could break out of the header. It also means two exports taken on
the same day share a name; rename them, or pass -o yourself.