Data Export
Stateway lets you export the data your tenant owns — process definitions, decision models, process instances, and the audit log — as a single downloadable file, whenever you want, without contacting support. This guide covers the export API end to end: requesting an export, checking its status, downloading the artifact, and what is (and isn't) included.
The Async Model
Generating an export can take a while for a tenant with a lot of history, so the API never makes you wait for it. The flow has three steps:
POST /v1/exports— kicks off the export and returns immediately.GET /v1/exports/{id}— poll this until the export reaches a terminal status.GET /v1/exports/{id}/download— oncecompleted, this redirects you to the file.
Request:
curl -X POST https://api.stateway.io/v1/exports \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{}'
Response:
{
"data": {
"id": "018f2a6e-4b2a-7c31-9c3f-8e2b1a6d9f01",
"status": "pending",
"scope": {
"sections": ["definitions", "decisions", "instances", "audit_log"],
"from": null,
"to": null
},
"size_bytes": null,
"checksum_sha256": null,
"counts": null,
"error": null,
"created_at": "2026-09-30T12:00:00.000Z",
"started_at": null,
"completed_at": null,
"expires_at": null
}
}
The response (202 Accepted) always has this shape — the same fields are returned by GET /v1/exports and GET /v1/exports/{id}:
| Field | Description |
|---|---|
id | The export's identifier (UUID) |
status | pending | running | completed | failed | expired |
scope | The resolved scope this export was run with — sections, from, to |
size_bytes | Size of the .ndjson.gz artifact in bytes; null until completed |
checksum_sha256 | SHA-256 checksum of the downloaded .ndjson.gz file; null until completed |
counts | Rows emitted per section; null until completed |
error | Failure cause when status is failed (see Errors below). May also be set on a pending export that was interrupted and automatically requeued — decide the export's outcome by status, not merely by whether error is present. |
created_at | When the export was requested |
started_at | When generation began; null until it does |
completed_at | When the export reached completed or failed; null until then |
expires_at | When the artifact will be removed and the export moves to expired; null until completed |
Status Lifecycle
pending → running → completed → expired
↘ failed
running → pending (interrupted once — requeued automatically)
running → failed (interrupted again after the automatic requeue)
pending and running are transient — a background process picks up the export and generates the artifact. completed and failed are terminal for the export's outcome; a completed export later transitions to expired once its artifact is removed (see Limits). If the process generating an export is interrupted (for example, by a restart), the export automatically returns to pending and is retried once; if it's interrupted again after that retry, it ends in failed rather than being requeued a second time (see Asynchronous Failure Causes).
Requesting an Export
The POST /v1/exports body is entirely optional — an empty body (or {}) exports everything, unbounded:
| Field | Description |
|---|---|
sections | Optional array of "definitions", "decisions", "instances", "audit_log". Defaults to all four when omitted. |
from | Optional ISO 8601 timestamp — inclusive lower bound applied to sections with a time window. |
to | Optional ISO 8601 timestamp — inclusive upper bound applied to sections with a time window. |
from must not be after to, or the request fails with 400 VALIDATION_ERROR. If provided, sections must be a non-empty array — an empty array is also rejected with 400 VALIDATION_ERROR.
Request (a scoped export — only instances and audit log, last 30 days):
curl -X POST https://api.stateway.io/v1/exports \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"sections": ["instances", "audit_log"],
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-09-30T23:59:59.000Z"
}'
Response:
{
"data": {
"id": "018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02",
"status": "pending",
"scope": {
"sections": ["instances", "audit_log"],
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-09-30T23:59:59.000Z"
},
"size_bytes": null,
"checksum_sha256": null,
"counts": null,
"error": null,
"created_at": "2026-09-30T12:05:00.000Z",
"started_at": null,
"completed_at": null,
"expires_at": null
}
}
definitions and decisions are not time-windowed — every version is always included regardless of from/to (see What's Included).
Listing Exports
GET /v1/exports lists your tenant's exports, newest first, paginated by cursor — not by offset:
Request:
curl "https://api.stateway.io/v1/exports?limit=20" \
-H "X-API-Key: sw_live_your_key"
Response:
{
"data": [
{
"id": "018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02",
"status": "completed",
"scope": { "sections": ["instances", "audit_log"], "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-30T23:59:59.000Z" },
"size_bytes": 4213980,
"checksum_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"counts": { "instances": 812, "audit_entries": 5341 },
"error": null,
"created_at": "2026-09-30T12:05:00.000Z",
"started_at": "2026-09-30T12:05:03.000Z",
"completed_at": "2026-09-30T12:06:41.000Z",
"expires_at": "2026-10-02T12:06:41.000Z"
}
],
"meta": { "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTMwVDEyOjA1OjAwLjAwMFoiLCJpZCI6IjAxOGYyYTcxLTlkM2EtN2YyMi1iNmYxLTNhOWMyZTdkNWIwMiJ9" }
}
limit accepts up to 200 (default 50). When there are more results, meta.next_cursor is set — pass it back as ?cursor= to fetch the next page. When next_cursor is null, you've reached the end.
curl "https://api.stateway.io/v1/exports?limit=20&cursor=eyJjcmVhdGVkQXQi..." \
-H "X-API-Key: sw_live_your_key"
Polling Status
curl https://api.stateway.io/v1/exports/018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02 \
-H "X-API-Key: sw_live_your_key"
Returns 200 with the same summary object shown above, whatever the current status is. This is the endpoint to poll — see Email Notification for why you shouldn't rely on anything else to tell you when an export is done.
Downloading the Artifact
curl -i https://api.stateway.io/v1/exports/018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02/download \
-H "X-API-Key: sw_live_your_key"
HTTP/1.1 302 Found
location: https://storage.example.com/stateway-prod/tenants/.../export.ndjson.gz?X-Amz-Signature=...
Each call to this endpoint generates a fresh pre-signed URL — nothing is cached, so it's safe to call it again if the previous URL expired before you used it. The pre-signed URL is only valid for a few minutes, so follow the redirect promptly. That window applies to starting the transfer, not to finishing it: once the download is underway it runs to completion, however long it takes, so a large artifact over a slow connection is not a problem. This only works once the export is completed; see Errors for what happens otherwise.
To fetch the file directly (following the redirect) and peek at its manifest line:
curl -L -o export.ndjson.gz https://api.stateway.io/v1/exports/018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02/download \
-H "X-API-Key: sw_live_your_key"
gunzip -c export.ndjson.gz | head -1
The second command prints the manifest line — see Artifact Format below for its shape.
Artifact Format
The downloaded file is gzip-compressed newline-delimited JSON (.ndjson.gz) — one JSON object per line, decompressed with any standard gzip tool.
The first line is always a manifest, identified by "type": "manifest":
{"type":"manifest","version":1,"tenant_id":"ten_01j...","generated_at":"2026-09-30T12:06:41.000Z","sections":["definitions","decisions","instances","audit_log"],"window":{"from":null,"to":"2026-09-30T12:06:41.000Z"},"counts":{"definitions":14,"decisions":6,"instances":812,"audit_entries":5341},"stateway_api_version":"1.4.0"}
| Manifest field | Description |
|---|---|
version | Manifest format version (currently 1) |
tenant_id | The exporting tenant |
generated_at | When the artifact was generated |
sections | Sections included, always in canonical order: definitions, decisions, instances, audit_log |
window.from / window.to | The effective time window applied to windowed sections |
counts | Rows emitted per section (same shape as the export's counts field) |
stateway_api_version | Stateway API version that generated the file |
Every line after the manifest is a data row, distinguished by its own type: definition, decision, instance, or audit_entry. Sections are always emitted in the canonical order above, regardless of the order you passed in sections.
Note that the audit_log section's counts/manifest key is audit_entries, not audit_log — the other three sections use their own section name as the counts key unchanged (definitions, decisions, instances). Row type is singular for every section (definition, decision, instance, audit_entry).
What's Included in Each Section
| Section | What you get |
|---|---|
definitions | Every version of every process definition, including the original source you deployed. |
decisions | Every version of every decision model, including the original source you deployed. |
instances | Full instance records, including variables. Any file_ref embedded in a variable has its internal storage details removed — the same protection applied to variables everywhere else in the API. The from/to window filters on the instance's start time. |
audit_log | Each entry's payload exactly as stored, plus prev_entry_hash/entry_hash — enough to independently reverify the tenant's hash chain from the exported file alone, without calling the API. The from/to window filters on the entry's created_at. See Hash Chain Algorithm for the algorithm. |
Verifying the hash chain from the export. An exported audit_entry row has no tenant_id of its own — use the manifest's tenant_id as the tenant identifier when recomputing hashes per the algorithm linked above. That page's field names are camelCase; each corresponds 1:1 to this export's snake_case field of the same meaning: created_at ↔ createdAt, actor_type ↔ actorType, resource_type ↔ resourceType, resource_id ↔ resourceId, prev_entry_hash ↔ prevEntryHash, entry_hash ↔ entryHash.
What's Not Included in the Dump
Planned, not yet supported:
- Human tasks
- Webhook subscriptions and delivery history
- Timers
- Service task job records
- Usage records
- API keys
- The bytes of uploaded file attachments (a
file_ref's metadata is exported as part ofinstances, but not the underlying file content)
Permanently excluded: credentials are stored as encrypted secrets and are never part of an export. This is a deliberate, permanent security exclusion — it will not become available in a future release.
Limits
| Limit | Default | Notes |
|---|---|---|
| Artifact retention (TTL) | 48 hours | After this, a completed export moves to expired and its file is removed. Configurable per deployment. |
| Max retained exports per tenant | 5 | Counts pending/running/completed exports. Wait for one to expire (or fail) before requesting another. |
Rate limit on POST /v1/exports | 5 per hour | Applies only to creating new exports — polling and downloading are not rate-limited by this rule. |
| Max rows per export | 500,000 | Total across all included sections. An export whose scope would exceed this fails explicitly rather than silently truncating the file. |
These defaults may be adjusted per deployment; treat them as the current operating limits rather than fixed guarantees.
There is no endpoint to delete an export early — a completed export occupies its retention slot for its entire TTL. If you're at the retained-exports limit, wait for the oldest one to expire (or fail) before requesting a new one.
Errors
Two distinct families of failure apply here: synchronous HTTP errors on the export endpoints themselves, and asynchronous failures of the export job, which surface through the error field of an otherwise successful status check.
Synchronous HTTP Responses
| Status | code | Endpoint(s) | Meaning |
|---|---|---|---|
| 400 | VALIDATION_ERROR | POST /v1/exports | Unknown section name, or from after to |
| 400 | VALIDATION_ERROR | GET /v1/exports | Invalid cursor |
| 409 | EXPORT_LIMIT_REACHED | POST /v1/exports | The tenant already has the maximum number of retained exports |
| 429 | RATE_LIMITED | POST /v1/exports | Rate limit for this endpoint exceeded |
| 404 | NOT_FOUND | GET /v1/exports/{id}, GET /v1/exports/{id}/download | Export not found, or belongs to another tenant |
| 409 | EXPORT_NOT_READY | GET /v1/exports/{id}/download | The export hasn't finished running yet |
| 409 | EXPORT_FAILED | GET /v1/exports/{id}/download | The export failed and has no file to download |
| 410 | EXPORT_EXPIRED | GET /v1/exports/{id}/download | The artifact's retention window passed and the file was removed |
Asynchronous Failure Causes
GET /v1/exports/{id} itself still returns 200 in these cases — it successfully reports that the export failed, via status: "failed" and a populated error field shaped { "code": "...", "message": "..." }:
{
"data": {
"id": "018f2a71-9d3a-7f22-b6f1-3a9c2e7d5b02",
"status": "failed",
"scope": { "sections": ["definitions", "decisions", "instances", "audit_log"], "from": null, "to": null },
"size_bytes": null,
"checksum_sha256": null,
"counts": null,
"error": {
"code": "EXPORT_TOO_LARGE",
"message": "The requested export exceeds the maximum number of records allowed in a single export. Narrow the time window or select fewer sections, then request a new export."
},
"created_at": "2026-09-30T12:05:00.000Z",
"started_at": "2026-09-30T12:05:03.000Z",
"completed_at": "2026-09-30T12:05:10.000Z",
"expires_at": null
}
}
Two causes are worth calling out specifically — both fail the export explicitly rather than silently truncating or partially publishing anything:
- Scope too large (
EXPORT_TOO_LARGE) — the requested scope exceeds the row limit (see Limits). Narrow the time window or select fewer sections, then request a new export. - Storage quota exceeded (
STORAGE_QUOTA_EXCEEDED) — the resulting artifact wouldn't fit within the tenant's storage quota; no bytes are written against your usage. Free up storage or increase your quota, then request a new export.
The full set of codes you may see in error.code:
error.code | Meaning | Suggested action |
|---|---|---|
EXPORT_TOO_LARGE | The requested scope exceeds the maximum number of rows allowed in a single export | Narrow the time window or select fewer sections, then request a new export |
STORAGE_QUOTA_EXCEEDED | The artifact wouldn't fit within the tenant's storage quota; nothing was stored | Free up storage or increase your quota, then request a new export |
STORAGE_UNAVAILABLE | The generated artifact could not be stored | Request a new export |
EXPORT_FAILED | The export failed due to an unexpected error | Request a new export |
EXPORT_ABANDONED | The export was interrupted twice and was not retried again (see Status Lifecycle) | Request a new export |
While status is still pending, error.code may briefly show EXPORT_RECYCLED — this means a previous attempt was interrupted and the export is being retried automatically; no action is needed unless it later reaches failed with EXPORT_ABANDONED.
Email Notification
When an export reaches completed or failed, the tenant's email address on file receives a notification. The email never contains a download link or an embedded access token — it only points you back to the API (GET /v1/exports/{id}, then GET /v1/exports/{id}/download). If the email fails to send, that failure never changes the export's status or error — the export's outcome is already final by the time the notification is attempted; only the notification itself is retried, best-effort.
This notification is best-effort: it may be delayed, or in rare cases not arrive at all. It is a convenience, not a guarantee — GET /v1/exports/{id} remains the only reliable way to know an export's status. Don't build automation that waits for the email instead of polling; poll the endpoint until the export reaches a terminal status.