Export jobs (v1)
POST /api/admin/v1/exports → 202, a job id
GET /api/admin/v1/exports/{id} → status, and a signed download URL once ready
GET /api/admin/v1/exports → recent jobs
GET /api/admin/v1/exports/datasets → the catalogue
GET /api/exports/{id}/download → the bytes (signed URL, no bearer key)Ask for an export, poll until it’s ready, fetch the file. This is what lets you cron a nightly pull of your subscription data into a warehouse — before it existed, the only way out was a human clicking a link in the Export Center.
The bytes come from the same generators the Export Center downloads. Nothing here is a second definition of what a subscription row is; it’s a scheduler and a store wrapped around the exporters the admin already used.
Export jobs are part of the Developer API, so they need a secret key and a Pro plan, and they share the Admin surface’s rate limit. See Pricing & Plans.
Why a job, not a download
The obvious design is GET /exports/subscriptions.csv, streaming the file. It was rejected, and the
reason is the whole shape of this feature.
A large export takes minutes, and a multi-minute streaming HTTP response is not a contract anyone should build a nightly pull on. Any proxy, load balancer, deploy or client timeout in the path turns it into a truncated file — and a truncated CSV is the worst possible failure here, because it parses, it loads, and it reconciles wrong. Nothing alerts. The numbers are just quietly short.
So: ask, poll, fetch. Three calls that are individually fast and individually retryable, and a file that either exists completely or does not exist at all.
Datasets
| Dataset | Scopes required | Customer data | Filters |
|---|---|---|---|
subscriptions | read:subscriptions | no | yes — see below |
plans | read:plans | no | none |
bundles | read:bundles | no | none |
mix_match | read:mixmatch | no | none |
migration | read:subscriptions and read:customers | yes | none |
Every scope listed is required, not any of them.
The rule behind that column: an export requires exactly the scopes that paging the same rows
requires. If an export could be pulled with a narrower scope than the paged read of the same data,
the scope system would be decorative — an integrator who wanted subscription data without
read:subscriptions would simply export it instead.
GET /exports/datasets returns this catalogue at runtime, so a client can discover what it’s
allowed to ask for rather than hard-coding the table.
migration is the one that carries customer data
It’s the importer-shaped round-trip file, and it contains customer email, name, phone and full shipping address — because recreating a contract on another store is impossible without them. That isn’t incidental to the format, it is the format. Three consequences:
- it requires
read:customerson top ofread:subscriptions, exactly as?include=customerdoes on the paged surface; - there is no redacted variant, because a redacted migration file does not import;
- it’s the one dataset that calls Shopify while rendering, so it’s the slowest and the only one that can fail for a reason outside our own database.
subscriptions is the non-PCD alternative — the same contracts, display columns from the local
mirror, no names or email addresses. That’s a property of the mirror’s schema, not a filter
applied on the way out, so it cannot be misconfigured into leaking one.
Creating a job
cURL
curl -sS -X POST "https://app.curobi.com/api/admin/v1/exports" \
-H "Authorization: Bearer $CUROBI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset":"subscriptions","format":"jsonl","filters":{"status":"ACTIVE"}}'202 Accepted
{
"id": "clx8f2k9a0001…",
"dataset": "subscriptions",
"format": "jsonl",
"filters": { "status": "ACTIVE" },
"status": "queued",
"row_count": null,
"byte_size": null,
"created_at": "2026-08-17T09:30:00.000Z",
"started_at": null,
"ready_at": null,
"expires_at": null,
"download_url": null,
"error": null
}format defaults to csv. filters defaults to {}.
The second exception to “v1 is read-only”
And a narrower one than it looks. This POST creates no merchant data — it queues a read of
rows the same key could already page through, in a shape a warehouse can consume. The verb is POST
because the work is asynchronous and the job needs an id, not because anything is being written on
the merchant’s behalf. The authorization it demands is the dataset’s own read scopes.
There is no export whose scopes a key could not already use to fetch the same rows one page at a time. If that ever stops being true, this stops being a read.
Filters
Only subscriptions takes filters. Every other dataset exports everything on the shop.
| Filter | Meaning |
|---|---|
status | Contract status (ACTIVE, PAUSED, CANCELLED, EXPIRED) |
plan | Selling plan GID |
customer_id | Customer GID, or a bare numeric id |
product | Substring match on the cached product title |
frequency | Exact match on the cached frequency label |
amount_min / amount_max | Charge amount bounds |
next_from / next_to | Next-charge date range, YYYY-MM-DD |
q | Free-text across product, order name and the GIDs |
view | renewals for active contracts charging today or later |
These are the same filters the Subscriptions list uses, so an export and the on-screen list can never disagree about which contracts match.
An unknown filter is a 400, never ignored. Silently dropping status=ACTIVE would produce a
file containing every contract on the shop — a successful-looking export that is a far larger
disclosure than the caller asked for. Of the two ways to be wrong here, the permissive one is the
dangerous one.
One job at a time
A shop may have one queued-or-running export. A second request is refused:
409 Conflict
{
"error": {
"code": "export_in_progress",
"message": "This shop already has an export in progress (clx8f2…, subscriptions). Wait for it to finish, then try again.",
"doc_url": "https://docs.curobi.com/api/errors#export_in_progress",
"request_id": "req_01J…"
}
}The id of the job you’re waiting on is in the message, so a client can poll it instead of guessing.
export_in_progress is its own code rather than an
invalid_request because the request was fine — the timing was wrong. Branch on it by waiting
and retrying, not by fixing your payload.
Polling
GET /api/admin/v1/exports/{id}{
"id": "clx8f2k9a0001…",
"dataset": "subscriptions",
"format": "jsonl",
"filters": { "status": "ACTIVE" },
"status": "ready",
"row_count": 4182,
"byte_size": 1048576,
"created_at": "2026-08-17T09:30:00.000Z",
"started_at": "2026-08-17T09:30:02.000Z",
"ready_at": "2026-08-17T09:30:41.000Z",
"expires_at": "2026-08-18T09:30:41.000Z",
"download_url": "https://app.curobi.com/api/exports/clx8f2…/download?expires=1786…&signature=9f3c…",
"download_url_expires_at": "2026-08-17T10:31:00.000Z",
"error": null
}GET /exports lists your recent jobs, same shape.
Statuses
| Status | Meaning | What to do |
|---|---|---|
queued | Accepted, not started | Poll |
running | Rendering | Poll |
ready | Downloadable | Fetch download_url |
failed | Never rendered | Read error, fix, re-create |
expired | Rendered fine, bytes aged out | Re-create, and poll sooner |
failed and expired are deliberately two words. One means re-run it; the other means your
cron polled too slowly. A single status called “gone” would tell a merchant neither.
row_count excludes the header row — so the CSV and the JSONL of the same data report the same
number, and it matches what a loader that skipped the header actually ingested.
When a job fails
"error": { "code": "export_too_large", "message": "This export exceeds the 128 MB limit. …" }error.code | Cause |
|---|---|
export_too_large | The rendered file exceeded 128 MB. Narrow it with filters, or export in parts. |
shop_unavailable | Curobi was uninstalled between queuing the job and running it. |
enqueue_failed | The job could not be handed to the queue. Retry. |
render_failed | Everything else — most often a Shopify error during migration enrichment. |
code is stable and machine-readable; message is human copy and may be reworded. Same split
the error envelope makes, for the same reason.
Downloading
GET /api/exports/{id}/download?expires=…&signature=…This is the one route on the public surface that does not take a bearer key. The consumer is
curl -O in a cron, a warehouse loader that only speaks HTTP, or a browser. Requiring an API secret
on the fetch would mean putting that secret into each of those — and the usual result is a
long-lived credential sitting somewhere far worse than a one-hour URL.
So the signature is the authorization, and it’s built to be a poor thing to keep:
- it names one job, which belongs to one shop — tenancy comes from the stored row, never from the request;
- it covers the expiry as well as the id, so editing
?expires=invalidates it rather than extending it (signing the id alone is the natural mistake, and it verifies perfectly); - it lives one hour, and is re-minted on every
GET /exports/{id}, so nobody has a reason to store one.
Treat a download URL exactly as you would an S3 presigned URL: anyone holding it can fetch the
file until it expires. Fetch it from the same poll that reported ready — if your loader runs
later, poll again for a fresh one rather than persisting it.
Every refusal is the same 404
A bad signature, an expired URL, an unknown id, a failed job and an expired job are five different facts, and this route reports all five identically.
Distinguishing them would make it an oracle: an unauthenticated caller could walk job ids and learn which exist, which succeeded, and when a shop last ran an export. The server log keeps the distinction, because we’re the ones who have to debug it.
Response
Content-Type: text/csv; charset=utf-8 (or application/x-ndjson; charset=utf-8)
Content-Disposition: attachment; filename="subscriptions-2026-08-17.jsonl"
Content-Length: 1048576
Cache-Control: private, no-storeContent-Length is exact — the size was measured while rendering — so a client gets a progress bar
and can detect a truncated transfer instead of trusting that the stream ended on purpose.
Formats
csv
UTF-8 with a BOM (so Excel reads accented characters), CRLF line endings, every field quoted,
inner quotes doubled per RFC 4180. A header row. Empty values are "".
Column order is the contract.
jsonl
One JSON object per line, newline-terminated, no wrapping array. No BOM — it would corrupt the first object for a strict parser. No header line; the column labels become the object keys.
Column name is the contract. Keys are snake_cased from the human labels: "Plan group ID" →
plan_group_id, "Contract #" → contract.
{"product":"Coffee","order":"#1042","status":"Active","amount":"24.00","next_charge":"2026-09-01"}
{"product":"Tea","order":"#1043","status":"Paused","amount":"18.00","next_charge":null}Empty values are null, not "". A BI tool that can’t tell “no next charge” from “the empty
string” will happily aggregate one as the other, and WHERE next_charge IS NULL would return
nothing. Numbers stay numbers; a value the exporter already formatted as a string — a money amount
fixed to two decimals, an ISO date — stays a string.
JSONL rather than JSON because a top-level array has to be complete before it’s valid. A consumer couldn’t begin loading until the last byte arrived, and we couldn’t write it without knowing the row count in advance.
Limits and retention
| Concurrent jobs per shop | 1 |
| Maximum rendered size | 128 MB |
| Bytes downloadable for | 24 hours from ready_at |
| Signed URL valid for | 1 hour from issue |
| Job record kept for | 7 days |
| Rate limit | Shares the Admin surface’s 60 req/min |
The two-stage retention is deliberate. At 24 hours the bytes are dropped and the status becomes
expired; the row survives a further week. That’s what lets a late poll read “this export aged
out” instead of a 404 that reads like the job never existed — the difference between a merchant
fixing their cron’s schedule and a merchant opening a ticket about a job we lost.
A job stuck queued or running for 24 hours is released by the same daily sweep. While it sits
there it holds the shop’s single concurrency slot, so every later POST /exports would be refused
for a job nobody is working on.
What happens to the copy
A rendered export is a copy of your data sitting in our database — and for migration, a copy
of your customers’ names, emails, phone numbers and addresses. It is deleted:
| When | What |
|---|---|
24h after ready_at | Bytes dropped, status → expired |
| 7 days after creation | Job row deleted |
| App uninstalled | Every export for the shop, immediately |
shop/redact | Every export for the shop |
The uninstall purge isn’t redundant with shop/redact. That webhook fires ~48 hours later, and
there’s no reason to keep a downloadable copy of a former customer’s data for two more days once the
merchant has told us to go.
A nightly pull, end to end
Bash
#!/usr/bin/env bash
set -euo pipefail
API="https://app.curobi.com/api/admin/v1"
AUTH="Authorization: Bearer $CUROBI_KEY"
id=$(curl -sf -X POST "$API/exports" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"dataset":"subscriptions","format":"jsonl"}' | jq -r .id)
# Poll. Exports finish in seconds to minutes depending on size.
for _ in $(seq 1 60); do
job=$(curl -sf "$API/exports/$id" -H "$AUTH")
case $(jq -r .status <<<"$job") in
ready) curl -sf -o subscriptions.jsonl "$(jq -r .download_url <<<"$job")"; exit 0 ;;
failed) jq -r .error.message <<<"$job" >&2; exit 1 ;;
*) sleep 10 ;;
esac
done
echo "export did not finish in time" >&2; exit 1Deliberately not here
- No admin page. Export jobs are created by the API and only by the API — there’s nothing for a human to configure, and the human path already exists: the Export Center streams the same datasets as direct downloads.
- No incremental or delta exports. Every export is a full snapshot of what matches its filters.
For
subscriptions,next_from/next_tocover the common “what changed” case. - No scheduling. Curobi does not run your export on a cron; your cron calls Curobi. Scheduling would mean owning retry policy, failure notification and drift for a job whose output we can’t deliver anywhere — and your scheduler already solves all of that.