Skip to Content
Your subscription data now goes where you need it: the Curobi Developer API feeds your warehouse, ERP or CRM, and webhooks tell you the moment something changes — read the API docs →
Developer APIExport Jobs

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

DatasetScopes requiredCustomer dataFilters
subscriptionsread:subscriptionsnoyes — see below
plansread:plansnonone
bundlesread:bundlesnonone
mix_matchread:mixmatchnonone
migrationread:subscriptions and read:customersyesnone

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:customers on top of read:subscriptions, exactly as ?include=customer does 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 -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.

FilterMeaning
statusContract status (ACTIVE, PAUSED, CANCELLED, EXPIRED)
planSelling plan GID
customer_idCustomer GID, or a bare numeric id
productSubstring match on the cached product title
frequencyExact match on the cached frequency label
amount_min / amount_maxCharge amount bounds
next_from / next_toNext-charge date range, YYYY-MM-DD
qFree-text across product, order name and the GIDs
viewrenewals 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

StatusMeaningWhat to do
queuedAccepted, not startedPoll
runningRenderingPoll
readyDownloadableFetch download_url
failedNever renderedRead error, fix, re-create
expiredRendered fine, bytes aged outRe-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.codeCause
export_too_largeThe rendered file exceeded 128 MB. Narrow it with filters, or export in parts.
shop_unavailableCurobi was uninstalled between queuing the job and running it.
enqueue_failedThe job could not be handed to the queue. Retry.
render_failedEverything 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-store

Content-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 shop1
Maximum rendered size128 MB
Bytes downloadable for24 hours from ready_at
Signed URL valid for1 hour from issue
Job record kept for7 days
Rate limitShares 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:

WhenWhat
24h after ready_atBytes dropped, status → expired
7 days after creationJob row deleted
App uninstalledEvery export for the shop, immediately
shop/redactEvery 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

nightly-export.sh
#!/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 1

Deliberately 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_to cover 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.
Last updated on