Push API
The push API is how a script, a scraper or a scheduled job sends data to one of your organization’s datasets, without a conversation. It’s checked and added exactly as rows sent from your assistant are: the same rules, the same load modes, the same answers.
Before you start
- The dataset must exist, with an approved structure. Set it up through your assistant first (Quickstart). The push API adds data; it never changes a structure.
- You need a push key. An owner or admin creates one on the dashboard’s API keys page. Name it after the script that will use it. The key is shown once, so copy it then. A push key can only add data to your organization’s datasets: it can’t read anything, and it doesn’t work for connecting an assistant. A revoked key stops working on its next request.
The address
https://api.ontyx.io/v1Every request is a POST and sends the key as a bearer token:
Authorization: Bearer ontyx_live_ak_<keyId>_<secret>| Endpoint | What it does |
|---|---|
POST /v1/datasets/{name}/loads | The request body is the file, up to 10 MB. It’s checked and added before the response comes back. |
POST /v1/datasets/{name}/uploads | For a file up to 100 MB: returns an address to upload it to. |
POST /v1/uploads/{id}/finish | Checks the uploaded file and adds it. |
Requests are limited to 10 a second across the API, with bursts of up to 20.
Options
Each option goes in the query string. For uploads they can also go in a small JSON body, such as {"format": "csv", "mode": "append"}.
| Option | Values |
|---|---|
format | csv (with a header row), json (one list of records), jsonl (one record per line) or parquet. For loads it can come from the Content-Type header instead: text/csv, application/json, application/x-ndjson or application/vnd.apache.parquet. |
mode | replace, append or upsert (needs a dataset with something that identifies each row). Leave it out to use the dataset’s own setting. See Load modes. |
as_of | When the data is true of the world, as a date and time with its offset, such as 2026-10-30T06:00:00Z. Leave it out to use now. |
Send files uncompressed.
Examples
export ONTYX_PUSH_KEY='ontyx_live_ak_<keyId>_<secret>' # your push key
BASE=https://api.ontyx.io/v1
# A CSV export, added or updated by order number
curl --fail-with-body -X POST "$BASE/datasets/orders/loads?mode=upsert" \
-H "Authorization: Bearer $ONTYX_PUSH_KEY" \
-H "Content-Type: text/csv" \
--data-binary @orders.csv
# JSON Lines from a scraper, with the time of the run as the as-of date
curl --fail-with-body -X POST "$BASE/datasets/orders/loads?format=jsonl&as_of=2026-10-30T06:00:00Z" \
-H "Authorization: Bearer $ONTYX_PUSH_KEY" \
--data-binary @orders.jsonl
# A file over 10 MB: ask for an upload address, upload the file, then finish
curl --fail-with-body -s -X POST "$BASE/datasets/orders/uploads?format=csv&mode=append" \
-H "Authorization: Bearer $ONTYX_PUSH_KEY" > upload.json
# upload.json has upload_id, put (url, method, headers) and upload_command:
# a ready curl line that PUTs your file with exactly the headers it needs.
# Run it with your file's path in place of ./your-file, then:
curl --fail-with-body -X POST "$BASE/uploads/$(jq -r .upload_id upload.json)/finish" \
-H "Authorization: Bearer $ONTYX_PUSH_KEY"The upload address works for about 30 minutes. The file must be uploaded with exactly the headers in put.headers, or the upload is refused.
What a load answers
A load that lands answers 200 with what it did. This is the JSON Lines example above, sent to a dataset that already had three orders (checks shortened):
{
"dataset": "orders",
"data_version": 2,
"mode": "upsert",
"rows_loaded": 3,
"rows_added": 1,
"rows_updated": 1,
"rows_already_there": 1,
"total_rows": 4,
"as_of": "2026-10-30T06:00:00.000Z",
"checks": [
{ "check": "key", "status": "passed", "columns": ["order_id"], "rows_checked": 4, "message": "Each of the 4 rows of \"orders\" has its own \"order_id\"; this load's rows were matched against the ones already there." },
{ "check": "freshness", "status": "stale", "as_of": "2026-10-30T06:00:00.000Z", "expected_cadence": "daily", "message": "Out of date: the data is as of 1 day ago (2026-10-30), and new data is expected every day. The rows were loaded; \"orders\" shows as out of date until newer data arrives." }
],
"message": "Loaded 3 rows into \"orders\" (each load updates rows that already exist and adds new ones): 1 updated a row that was already there, 1 was new and added and 1 was already there exactly the same, so it was skipped. It now has 4 rows. …"
}| Field | Meaning |
|---|---|
data_version | The data version this load made. When every row was already there exactly the same, no new version is made and this is the version that stays in force. |
mode | How the rows were added. |
rows_loaded | Rows the request carried. |
rows_added | Rows that were new. |
rows_updated | Rows that replaced a row already there (upsert only). |
rows_already_there | Rows that were already there exactly the same, and were skipped (append or upsert, on a dataset with something that identifies each row). |
identical_rows_combined | Rows repeated exactly within the request, kept once (only when there were some). |
total_rows | Rows in the dataset after the load. |
as_of | The as-of date recorded for this version. |
fields_without_a_column | Fields in the records that have no column yet, kept in the original record (only when the dataset keeps it). |
checks | Everything the load verified, each with a check name, a status and a message: key, required, not_null, unique, accepted_values, relationship and freshness. A relationship that doesn’t hold is reported here; it never stops a load. |
message | All of the above in plain words, with anything that needs attention first. |
finish answers the same, plus file_size.
When data doesn’t fit
A load is checked against the approved structure before anything is written, and a load that breaks a rule is refused whole: nothing is written and no version is made. The answer says why, in the same sentences your assistant would get:
{
"error": {
"code": "ROWS_REFUSED",
"message": "Nothing was loaded: Row 2: column \"ordered_on\" needs a value but is blank. Column \"status\" only allows \"open\", \"shipped\" or \"cancelled\", but 1 row has a value that isn't on that list: \"pending\" (1 row). … 1 of 2 rows have a problem; nothing was loaded.",
"problems": [
"Row 2: column \"ordered_on\" needs a value but is blank.",
"Column \"status\" only allows \"open\", \"shipped\" or \"cancelled\", but 1 row has a value that isn't on that list: \"pending\" (1 row). …",
"1 of 2 rows have a problem; nothing was loaded."
],
"failed_checks": [
{ "check": "required", "columns": ["ordered_on"], "message": "Row 2: column \"ordered_on\" needs a value but is blank." },
{ "check": "accepted_values", "columns": ["status"], "message": "Column \"status\" only allows \"open\", \"shipped\" or \"cancelled\", …" }
]
}
}problems lists up to 20 problems, each naming the row and column. failed_checks names each check the load didn’t pass. Fix the data and send it again. If the data is right and the structure should change (a new column, a new allowed value), ask your assistant to propose the change; once an owner or admin approves it, the same file loads.
The dashboard’s Datasets page shows how each dataset’s last load went, including a refused one and why.
Errors
Errors are JSON: { "error": { "code", "message" } }, plus problems and failed_checks on a refused load.
| Status | Code | When |
|---|---|---|
| 400 | INVALID_REQUEST | A mode or as_of that isn’t valid, or upsert on a dataset with nothing that identifies each row. |
| 401 | UNAUTHENTICATED | The key is missing, unknown or revoked. |
| 403 | FORBIDDEN | The organization’s data is being deleted. |
| 404 | NOT_FOUND | No dataset by that name in the key’s organization, an unknown upload, or an address that doesn’t exist. |
| 405 | METHOD_NOT_ALLOWED | A method other than POST. |
| 409 | CONFLICT | The dataset has no approved structure yet, is marked for deletion, or is built from other datasets (load those instead); the uploaded file hasn’t arrived yet; or the structure changed while the load ran. |
| 413 | TOO_LARGE | A loads body over 10 MB. Use an upload instead. |
| 415 | UNSUPPORTED_FORMAT | The format wasn’t given, isn’t one of the four, or the body is compressed. |
| 422 | ROWS_REFUSED | The data doesn’t fit the structure (above). Nothing was written. |
| 429 | Over the request limit. Wait and try again. | |
| 500 | INTERNAL | Something went wrong on Ontyx’s side and nothing was loaded. Try again in a minute. |
A request turned away before it reaches Ontyx’s own checks (no valid key, or too many requests) gets a short {"message": "…"} answer instead of this shape.
Timing
A load is checked and added before the response comes back, and a request can take at most 30 seconds. A load that waits behind a large one can run out of time and still land. Before re-sending a replace after a timeout, check the dataset (ask your assistant, or look at the dashboard), so you know whether the first one landed. On a dataset with something that identifies each row, re-sending an append or upsert of the same rows is safe: rows already there exactly the same are skipped.