ovidens

Automation API

This guide walks the whole loop with commands you can paste. The exact shape of every call is in the API reference, generated from the contract the API publishes.

This section is English only

The contract it describes - field names, scope names, error codes - is English, so this guide and the reference are not translated.

What the Automation API is

The machine door of the platform, at https://api.ovidens.com. An unattended system authenticates with a credential of its own, pushes data into a dataset, runs a pipeline and pulls the result.

Its surface is deliberately narrow: read the workspace, write dataset rows, run a pipeline, export a result. It cannot create or delete apps, change a pipeline, edit a Visualisation, invite people or change workspace settings.

  • Use the Automation API when a system acts on its own schedule with its own credential - a nightly ERP export, a warehouse loader, a scheduled reconciliation.
  • Use MCP when a PERSON's AI assistant should act as that person: a different door with a different authorization model, in Connect your own AI. The choice between the two is laid out in Integrations.

Everything a connected system WRITES appears in the workspace's audit trail, attributed to the integration by its credential's name rather than to the administrator who issued it. Reads are not audited; an export leaves an export record instead.

Getting a credential

A workspace administrator issues one on the Access page's API credentials tab. Ask for a credential named after the system you are building, its client id (ovc_...) and client secret (ovs_...) - the secret is shown once, at creation, and nothing can show it again - and the access it should hold, granted as named scopes:

ScopeWhat it grants
workspace:readList apps, dataset and output nodes, and their configuration. Read import status, and the status of runs of PUBLISHED configuration.
data:writeStage, upload and commit a CSV import into a dataset node.
pipeline:runRun an app's PUBLISHED configuration.
pipeline:run_draftRun unpublished configuration nobody has reviewed, and see the runs of it - a draft run is invisible to a credential without this scope, including one an editor started in the app. Not granted by default.
exports:readGenerate and download an export of a run result.
indicators:writeReserved. Nothing checks it today.
admin:publishReserved. Nothing checks it today.

A new credential starts with workspace:read, data:write, pipeline:run and exports:read; the other three are an explicit choice. Narrowing a credential's access - or revoking it - binds on its very next request, including on a token it is already holding.

The administrator's side is Credentials.

Minting a token

OAuth 2.1, client_credentials. The authorization server is https://id.ovidens.com; the API is a separate resource server that only validates tokens.

POST /oauth/token
curl -sS -X POST https://id.ovidens.com/oauth/token \
  -u "ovc_YOUR_CLIENT_ID:ovs_YOUR_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  -d "resource=https://api.ovidens.com" \
  -d "scope=workspace:read data:write pipeline:run exports:read"
  • The credential goes in HTTP Basic (-u). Form fields also work; Basic is the registered method.
  • resource=https://api.ovidens.com is REQUIRED. Omitting it, or naming any other resource, is a 400 with error: "invalid_target".
  • scope is optional. Omit it for the credential's whole granted set. Naming a scope the credential does not hold fails with 400 invalid_scope and mints nothing.
  • Cache the token for its expires_in rather than minting one per request; the token endpoint is rate limited by source address.
  • A credential that has expired or is suspended is refused here too, with the OAuth error invalid_client and, once the secret itself was right, a code naming which: credential_expired or credential_attestation_overdue (see Error codes). A wrong secret never carries a code.

Present it on every API call as Authorization: Bearer <access_token>. Three endpoints need no token, so you can read them before you hold a credential: https://api.ovidens.com/.well-known/oauth-protected-resource (where to authenticate), https://api.ovidens.com/v1/openapi.json (the contract) and https://api.ovidens.com/v1/docs (a redirect to this guide).

Confirming it worked

GET /v1/whoami
curl -sS https://api.ovidens.com/v1/whoami -H "Authorization: Bearer $TOKEN"
{
  "clientId": "ovc_7f3a9c1d2e4b6a8c0d5e7f91",
  "name": "ERP nightly import",
  "tenantId": "3f1c9a52-4d7e-4b60-9a1f-2c8d5e7b0a34",
  "workspaceName": "Contoso",
  "scopes": ["workspace:read", "data:write", "pipeline:run"],
  "credentialScopes": ["workspace:read", "data:write", "pipeline:run", "exports:read"],
  "tokenExpiresAt": "2026-08-02T09:30:00.000Z",
  "credentialExpiresAt": "2027-06-30T09:30:00.000Z",
  "attestationDueAt": "2026-10-01T09:30:00.000Z"
}

This is the one call that needs a valid token and no scope, so it separates a bad secret from a missing permission. scopes is what THIS token can do (what you asked for, intersected with what the credential holds now); credentialScopes is what the administrator granted. On an unexpected 403 insufficient_scope: in credentialScopes but not in scopes means your token asked for too little, so mint a new one; in neither means the administrator has to widen the credential.

The last two fields say when the CREDENTIAL stops working unless a person acts. Every credential expires (credentialExpiresAt, at most a year after it was issued or last rotated), and a workspace administrator must confirm every 90 days that it still needs its access (attestationDueAt); a credential whose confirmation is more than 14 days overdue is suspended. A job that reads whoami can warn its owner well before either date instead of discovering it as a refusal.

Discovery

Discovery
curl -sS https://api.ovidens.com/v1/apps -H "Authorization: Bearer $TOKEN"
curl -sS https://api.ovidens.com/v1/apps/sales_analytics/datasets -H "Authorization: Bearer $TOKEN"
curl -sS https://api.ovidens.com/v1/apps/sales_analytics/outputs -H "Authorization: Bearer $TOKEN"

Every app, node and column is addressed by its technicalId, a stable snake_case identifier that survives a rename. There is no numeric id on this door.

A dataset node's declared column schema IS the integration contract. listDatasets reports, per column, its technical id, its type, whether a value is required, its permitted values when it is an enumeration, and which CSV value options apply. Your file's header must name exactly those columns. Build the file from this response rather than from a sample, and a schema change becomes a diff you detect instead of an import rejected at three in the morning.

A cell of a dropdown or multi_select column may carry the option's LABEL or its ID, and either way what the platform stores is the id. enumValues lists the labels; beside it, options reports the same options in the same order, each with the stable id and the description its author wrote. A multi_select cell packs several of them separated by , or ;. Write the label when a person authors the file and it should read like a spreadsheet; write the id when a system generates it, because a label can be renamed and an id cannot. Anything that is neither is refused with the row named.

listOutputs is the mirror image: the output nodes of the app's latest successful published run, with the columns an export will carry. An app that has never published a successful run answers 200 with publishedRun: null and an empty list - a normal state to poll through, not an error.

The import loop

createImport  ->  PUT the file  ->  commitImport  ->  poll getImport  ->  getImportErrors

1. Stage the import. The dialect is validated and fully resolved here, before a byte moves.

POST /v1/apps/sales_analytics/datasets/customers/imports
curl -sS -X POST \
  https://api.ovidens.com/v1/apps/sales_analytics/datasets/customers/imports \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mode":"replace","filename":"customers.csv","csv":{"delimiter":";","decimalSeparator":","}}'

mode is replace (the file becomes the dataset's contents) or append. The response carries the importId, the uploadUrl and both expiry times.

2. Upload the file with a plain PUT. Send no Authorization header - the URL carries its own signature, and a bearer token makes the upload fail.

PUT {uploadUrl}
curl -sS -X PUT --upload-file customers.csv -H "Content-Type: text/csv" "$UPLOAD_URL"

3. Commit. Validates the file against the declared columns and, if every row parses, applies it.

POST /v1/apps/sales_analytics/datasets/customers/imports/imp_8c2f4a1d9e63/commit
curl -sS -X POST \
  https://api.ovidens.com/v1/apps/sales_analytics/datasets/customers/imports/imp_8c2f4a1d9e63/commit \
  -H "Authorization: Bearer $TOKEN"
  • Validation is all or nothing. One unparsable row applies nothing and leaves the dataset as it was.
  • Committing the same import id twice is safe. The first commit answers 202; a repeat answers 200 with the import's current record and writes nothing, so retrying a timed-out commit is correct rather than a second write.
  • Only one import per dataset node validates at a time; a second is 409 import_in_progress.

4. Poll the import until status leaves validating:

GET /v1/apps/sales_analytics/datasets/customers/imports/imp_8c2f4a1d9e63
curl -sS \
  https://api.ovidens.com/v1/apps/sales_analytics/datasets/customers/imports/imp_8c2f4a1d9e63 \
  -H "Authorization: Bearer $TOKEN"

applied reports rowsParsed, rowsApplied and rowsRejected. failed reports errorCode, likelyCause, a grouped errorGroups summary and, when the header was wrong, a structure block naming the expected, detected, unknown and missing columns.

A file with no data rows - a header alone, or no bytes at all - fails with errorCode: "no_data_rows" in either mode and leaves the dataset exactly as it was; a file whose every row is invalid fails with row_validation_failed and leaves it as well. An import never empties a dataset, because an empty file is far more often a failed export than an intent; check the export that produced it before you retry.

A row longer than 2,000,000 characters fails the import with errorCode: "row_too_long" and leaves the dataset as it was. The file is read row by row and one row is never held whole past that length, so the limit is per row, not per file: shorten or split the longest values on the row the message names, then stage the file again.

This read needs only workspace:read, so a monitoring job can watch an integration without being able to feed it. If the calling token ALSO holds data:write and the import is still awaiting its file, the response carries a fresh uploadUrl. That is how you recover an expired upload URL - re-read the import, do not stage a new one.

5. Get the full error report when the grouped summary is not enough: the same path with /errors appended answers a short-lived downloadUrl for a CSV of every rejected row. There is no report for an import that applied cleanly.

Codes that matter here: upload_missing (nothing staged, or the staged file expired - stage a fresh import), file_too_large, import_in_progress, app_locked, and the four csv_dialect_* codes.

The run loop

POST /v1/apps/sales_analytics/runs
curl -sS -X POST https://api.ovidens.com/v1/apps/sales_analytics/runs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target":"published"}'

target is required. published needs pipeline:run; draft runs unpublished configuration and needs pipeline:run_draft, which a credential does not hold by default. This door never falls back between the two: an app with no configuration of the target you asked for is refused with 409 no_runnable_version. The 202 echoes the target it accepted.

Only one run per app may be active. A second trigger answers 409 app_locked, and when the blocker is another run your credential may read, the detail carries activeRunId. On app_locked WITH an activeRunId, poll that run instead of starting another; without one, back off. That is also the recovery when a POST timed out but succeeded: this endpoint is not idempotent, so a blind retry starts a second run.

An editor previewing unpublished work in the app locks it the same way, and that run is a draft one: it blocks your trigger, but without pipeline:run_draft you cannot read it, so no activeRunId is named and backing off is the answer. You are never denied the id of a run you could have polled.

Then poll:

GET /v1/apps/sales_analytics/runs/4821
curl -sS https://api.ovidens.com/v1/apps/sales_analytics/runs/4821 \
  -H "Authorization: Bearer $TOKEN"

The run is finished when status is no longer running. Every node starts pending, so a poll taken mid-run reports that for most of the array; on failure, error names the node and the message. GET /v1/apps/sales_analytics/runs is the paged history, and every run reports triggeredBy - an object naming the credential when a system started it, null when a person or the workspace assistant did.

Both reads need workspace:read, and both show runs of published configuration. A run whose target is draft is a preview of work nobody has reviewed: it is listed, counted in total and readable by its id only for a credential that also holds pipeline:run_draft. Without that scope, the run id of a draft answers 404 not_found, the same as an id that names nothing - so a run you started yourself is always readable, because a credential that cannot run a draft cannot have started one.

The export loop

POST /v1/apps/sales_analytics/outputs/revenue_by_region/exports
curl -sS -X POST \
  https://api.ovidens.com/v1/apps/sales_analytics/outputs/revenue_by_region/exports \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"runId":4821,"compression":"gzip"}'

Every field is optional and the body itself may be omitted, which means "the latest published run, default dialect".

Omit the Content-Type header along with the body

An empty payload declared as application/json is refused by the JSON parser as a 400 invalid_request before the endpoint sees it, so dropping only the -d fails. Send either no body and no Content-Type, or an explicit {} with the header. This is a property of the HTTP layer, so it applies to any call you strip the body from - if your client sets a JSON content type for every request, send {}.

POST /v1/apps/sales_analytics/outputs/revenue_by_region/exports (defaults)
curl -sS -X POST https://api.ovidens.com/v1/apps/sales_analytics/outputs/revenue_by_region/exports \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Whether you name a runId is the decision that matters. With none, the file comes from the latest successful published run, and that answer can move between two otherwise identical requests - right for "the current numbers", wrong for a reproducible load. Naming the runId you got from startRun pins the file to an immutable run snapshot, so the same load a month later produces the same bytes. The 202 echoes the resolved runId and a runPinned flag.

Then poll and download:

GET /v1/apps/sales_analytics/exports/exp_5b1e7d0a3c92
curl -sS https://api.ovidens.com/v1/apps/sales_analytics/exports/exp_5b1e7d0a3c92 \
  -H "Authorization: Bearer $TOKEN"

When status is ready the response carries a downloadUrl; fetch it with no Authorization header. rowLimitReached says the file was cut off at the export size limit and holds only part of the output; runPinned says whether the run was yours or resolved. An export writes nothing to the audit trail, but it does leave an export record the administrator can see: which app, which output, which run, how many rows, how large.

Codes that matter here: no_published_run (nothing published successfully yet - run the pipeline), run_data_unavailable (the run exists but its outputs have been removed by retention - run it again), and 503 compute_unavailable.

The CSV dialect

One dialect object describes both directions: what an import PARSES with, an export WRITES with. It is validated at request time, so a bad option is a 400 with nothing uploaded or generated.

Structural options are file-level only.

OptionDefaultNotes
delimiter,One character. Must differ from every other single-character option.
quote"One character.
escape"One character, and in v1 it must EQUAL quote. A distinct escape character is refused rather than mis-parsed.
encodingutf-8utf-8 or windows-1252 on import. Export accepts utf-8 only.
hasHeadertrueOnly true. Every other option is validated against the declared column names.

Value options are file-level defaults with an optional per-column override, under csv.columns.<columnTechnicalId>. An override may name only value options, and only ones relevant to that column's type: an irrelevant one is csv_dialect_irrelevant_option, an undeclared column is csv_dialect_unknown_column.

OptionDefaultApplies to
decimalSeparator.integer, decimal, percent, currency columns
thousandsSeparatoremptythe same numeric columns
dateFormat%Y-%m-%ddate columns
timestampFormat%Y-%m-%dT%H:%M:%SZtimestamp columns

The supported date and timestamp tokens are %Y, %m, %d, %H, %M and %S. Anything else is csv_dialect_unsupported at request time. Month names are a later addition.

Two collisions are refused because they would produce plausible-looking WRONG data: a thousandsSeparator equal to the delimiter (one value becomes two fields), and one equal to the decimalSeparator (1.5 would become 15).

Beyond the separators, a value in a numeric column may carry a leading + or -, a parenthesised negative ((1234) is -1234), surrounding whitespace, a $, €, £, ¥ or % symbol, and scientific notation (2E10). A TRAILING minus sign is not recognised.

On the way OUT, the writer refuses what it cannot produce: encoding other than utf-8, any decimal or thousands separator outside the set it emits, and a date or timestamp pattern STARTING with a spreadsheet formula trigger (=, +, -, @, tab or carriage return) - a pattern is a per-column constant, so it would land a live formula in every cell.

Error codes

Every failure answers the same body, whatever the endpoint:

{
  "type": "https://ovidens.com/problems/insufficient_scope",
  "title": "The access token does not carry a scope this operation requires",
  "status": 403,
  "code": "insufficient_scope",
  "requestId": "944debf6-805a-400c-bde6-4a1f437ee42d",
  "detail": { "requiredScope": "data:write" }
}

Branch on code, never on title. The code is the contract; the title is the English sentence beside it and may be corrected. type is a stable identifier, not a URL to fetch. requestId is what to quote to support - it is also on every response as the x-request-id header.

detail is keyed by the code, and every key in it is optional. Read code first: the keys are not shared between codes, and a given code may answer with no detail at all.

Codedetail keys, when present
insufficient_scoperequiredScope
app_lockedreason, plus activeRunId when the blocker is a run this credential may read
no_runnable_versiontarget
quota_exceededresource, for the three platform-metered ceilings
credential_expiredexpiredAt
credential_attestation_overdueattestationDueAt, suspendedAt
compute_unavailableresource, unless the pause names no ceiling
file_too_largebytes, max
rate_limitedretryAfterSeconds
csv_dialect_conflictfields, character, plus column for a per-column override
csv_dialect_unsupportedfield; then token and supportedTokens for an unrecognised date pattern, or reason plus quote or supported for a restriction; plus column for a per-column override
csv_dialect_unknown_columncolumn, declaredColumns
csv_dialect_irrelevant_optioncolumn, option, columnType, relevantOptions

Every other code answers with no detail.

The contract declares the WHOLE code vocabulary on every failure response rather than a per-endpoint subset, so this table, not the contract, is where you learn what a code means:

CodeStatusWhat it meansWhat to do
invalid_request400The body, a parameter or the content type is not valid.Fix the request. Retrying it unchanged cannot work.
invalid_token401The token is missing, malformed, expired, or its credential no longer exists.Mint a fresh token. If that also fails, the credential was revoked.
invalid_client401The client id or secret was not accepted at the token endpoint.Check the credential. A rotated secret may have been retired.
credential_expired401The credential itself has expired. detail.expiredAt says when.Stop and ask a workspace administrator to rotate the credential, which sets a new expiry and issues a new secret. No token will be accepted until then.
credential_attestation_overdue401The credential is suspended because its periodic confirmation is more than 14 days overdue. detail gives the due date and when the suspension began.Stop and ask a workspace administrator to attest the credential on the Access page. It works again the moment they do; the secret does not change.
invalid_target400The resource indicator is missing or names something this grant may not request.Send resource=https://api.ovidens.com.
insufficient_scope403The token's effective scope set is too narrow for this operation.Call whoami and compare scopes with credentialScopes.
not_found404No such resource. Covers unknown, archived, wrong-type and other workspaces' identifiers alike.Re-run discovery; do not guess identifiers.
conflict409A state conflict with no more specific code.Re-read the resource and decide from its current state.
quota_exceeded422A workspace limit was reached. detail.resource names it for a metered ceiling; the open-import and open-export slot limits name none.Tell the workspace administrator which limit. Retrying will not clear it.
rate_limited429Too many requests for this credential.Wait Retry-After seconds. Do not tighten the loop.
internal_error500An unexpected fault.Retry once with backoff, then escalate with the requestId.
csv_dialect_conflict400Two dialect options claim the same character. detail.fields names both.Change one of them.
csv_dialect_unsupported400The dialect asks for something this version cannot do (an unknown strptime token, a distinct escape, a non-writable export option).Read detail.field, then the rest of detail for the restriction it hit.
csv_dialect_unknown_column400A per-column override names a column the dataset does not declare.Re-read listDatasets; the schema has changed.
csv_dialect_irrelevant_option400A per-column override is not relevant to that column's type.Drop the override, or move it to the right column.
upload_missing409No staged file is available - none was uploaded, or the staged file expired.Stage a fresh import and upload again.
file_too_large400The staged file exceeds the maximum upload size.Split the file.
app_locked409A run or an agent job owns the app. detail.activeRunId names the run when it is one you may read.Poll activeRunId if present; otherwise back off and retry.
import_in_progress409Another import for this dataset node is still validating.Wait for it to finish, then commit.
no_runnable_version409The app holds no configuration of the requested target.Ask the workspace to publish. The identical request then succeeds.
compute_unavailable503The platform is deliberately paused, or a platform-wide ceiling was reached.Back off and retry. This is NOT a fault to escalate.
no_published_run409The app has no successful published run to export.Run the pipeline, then export.
run_data_unavailable409The run exists but its output data has been removed by retention.Run the pipeline again and export the new run.

Back-off. 429 carries a Retry-After header and three X-RateLimit-* headers reporting the allowance, what is left and when the window resets - respect them rather than guessing. 503 compute_unavailable means wait, not page someone; no workspace administrator can lift it. 422 quota_exceeded is the opposite: retrying never helps, somebody in the workspace has to act. So are the two credential codes: credential_expired and credential_attestation_overdue are 401s, but unlike invalid_token minting a new token cannot clear them, so a job that meets one should stop and alert a person rather than retry. A queued import or export whose credential lapses before it runs fails with the same code as its errorCode. One whose app was archived or moved into somebody's personal space before it ran fails with errorCode: "app_not_found" and writes nothing - the same answer a new request for that app gets. Retrying does not help; ask a workspace administrator where the app went.

Limits and lifetimes

ThingValue
Requests per credential120 per minute by default; a workspace can be raised by an operator. 429 carries Retry-After.
Maximum upload5 GB per staged file.
Upload URLValid 1 hour. Re-read the import to mint a fresh one.
Staged fileKept 7 days, then the import moves to expired.
Download URL (export, error report)Valid 15 minutes. Re-read the record to mint a fresh one.
Generated export fileKept 7 days.
Import and export recordsKept 90 days after the file is gone.
CredentialExpires at most 365 days after it was issued or last rotated (credentialExpiresAt on whoami).
Credential confirmationDue every 90 days (attestationDueAt); suspended 14 days after a missed one.

An expired URL never means an expired object. Every record read mints a fresh URL, so the recovery is always "read the record again", never "start over".

A worked example

The whole loop as one script: mint a token, replace a dataset, run the published pipeline, export an output and download the file, polling at each step and failing loudly.

automation-loop.sh
#!/usr/bin/env bash
set -euo pipefail

API="https://api.ovidens.com"
ISSUER="https://id.ovidens.com"
CLIENT_ID="${OVIDENS_CLIENT_ID:?set OVIDENS_CLIENT_ID}"
CLIENT_SECRET="${OVIDENS_CLIENT_SECRET:?set OVIDENS_CLIENT_SECRET}"
APP="sales_analytics"
DATASET="customers"
OUTPUT="revenue_by_region"
FILE="customers.csv"

# 1. Mint a token: HTTP Basic, the resource indicator, and the scopes this job needs.
TOKEN=$(curl -sS -X POST "$ISSUER/oauth/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  -d "resource=$API" \
  -d "scope=workspace:read data:write pipeline:run exports:read" \
  | jq -r .access_token)
AUTH=(-H "Authorization: Bearer $TOKEN")

# 2. Prove the credential works before doing anything with it.
curl -sS "${AUTH[@]}" "$API/v1/whoami" | jq '{name, scopes, credentialScopes}'

# 3. Stage the import.
STAGED=$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \
  -d '{"mode":"replace","filename":"customers.csv","csv":{"delimiter":";"}}' \
  "$API/v1/apps/$APP/datasets/$DATASET/imports")
IMPORT_ID=$(jq -r .importId <<<"$STAGED")
UPLOAD_URL=$(jq -r .uploadUrl <<<"$STAGED")

# 4. Upload the bytes. NO Authorization header.
curl -sS -X PUT --upload-file "$FILE" -H "Content-Type: text/csv" "$UPLOAD_URL"

# 5. Commit.
curl -sS -X POST "${AUTH[@]}" \
  "$API/v1/apps/$APP/datasets/$DATASET/imports/$IMPORT_ID/commit" >/dev/null

# 6. Poll the import.
while :; do
  IMPORT=$(curl -sS "${AUTH[@]}" "$API/v1/apps/$APP/datasets/$DATASET/imports/$IMPORT_ID")
  STATUS=$(jq -r .status <<<"$IMPORT")
  [ "$STATUS" = "validating" ] || [ "$STATUS" = "awaiting_upload" ] || break
  sleep 5
done
if [ "$STATUS" != "applied" ]; then
  echo "import $STATUS: $(jq -r '.errorCode // "unknown"' <<<"$IMPORT")" >&2
  curl -sS "${AUTH[@]}" \
    "$API/v1/apps/$APP/datasets/$DATASET/imports/$IMPORT_ID/errors" | jq -r .downloadUrl >&2
  exit 1
fi
echo "applied $(jq -r .rowsApplied <<<"$IMPORT") rows"

# 7. Run the PUBLISHED pipeline. On 409 app_locked, adopt the active run.
RUN=$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \
  -d '{"target":"published"}' "$API/v1/apps/$APP/runs")
RUN_ID=$(jq -r '.runId // .detail.activeRunId // empty' <<<"$RUN")
[ -n "$RUN_ID" ] || { echo "no run: $RUN" >&2; exit 1; }

# 8. Poll the run.
while :; do
  DETAIL=$(curl -sS "${AUTH[@]}" "$API/v1/apps/$APP/runs/$RUN_ID")
  RUN_STATUS=$(jq -r .run.status <<<"$DETAIL")
  [ "$RUN_STATUS" = "running" ] || break
  sleep 10
done
[ "$RUN_STATUS" = "success" ] || { echo "run $RUN_ID failed" >&2; exit 1; }

# 9. Export the output, PINNED to the run we just made: that is what makes it reproducible.
BODY=$(jq -nc --argjson runId "$RUN_ID" '{runId: $runId, compression: "gzip"}')
EXPORT=$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \
  -d "$BODY" "$API/v1/apps/$APP/outputs/$OUTPUT/exports")
EXPORT_ID=$(jq -r .exportId <<<"$EXPORT")

# 10. Poll the export, then download (again, no Authorization header).
while :; do
  RECORD=$(curl -sS "${AUTH[@]}" "$API/v1/apps/$APP/exports/$EXPORT_ID")
  EXPORT_STATUS=$(jq -r .status <<<"$RECORD")
  [ "$EXPORT_STATUS" = "generating" ] || break
  sleep 5
done
[ "$EXPORT_STATUS" = "ready" ] || { echo "export $EXPORT_STATUS" >&2; exit 1; }
curl -sS -o "$OUTPUT.csv.gz" "$(jq -r .downloadUrl <<<"$RECORD")"
echo "downloaded $(jq -r .rowCount <<<"$RECORD") rows"

Next: the API reference for the exact shape of any call, or the live contract at https://api.ovidens.com/v1/openapi.json.

Was this page helpful?

On this page