Async jobs
The 202-plus-poll pattern behind refresh, backfill and export.
Anything that has to walk a platform API takes longer than a request should. Those
operations return 202 Accepted immediately and run in the background.
The pattern
Start the job
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" \
-H "X-API-Key: $KEY"Returns 202 with the job or state payload.
Watch it — poll or stream
Poll the matching status route:
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh/status" \
-H "X-API-Key: $KEY"The /refresh/status routes report cache freshness, not job progress — they
return {success, cache: {…}} with no status or phase. To see whether a job is
still running, use GET /refresh/active, or subscribe to
server-sent events and watch refresh.progress /
refresh.complete. The dashboard uses SSE.
Read the result
The job writes into the cache, so the result is just the corresponding /cached
read — see Cached reads. Exports instead produce a
downloadable ZIP.
The jobs
| Start | Freshness check | What it does |
|---|---|---|
POST /accounts/{of_user_id}/subscribers/refresh | …/subscribers/refresh/status | Delta-sync the subscriber cache |
POST /accounts/{of_user_id}/transactions/refresh | …/transactions/refresh/status | Delta-sync the transaction cache |
POST /accounts/{of_user_id}/campaigns/refresh | …/campaigns/refresh/status | Delta-sync campaigns and claimers |
POST /accounts/{of_user_id}/backfill | via /refresh/active | Backfill historical data |
POST /accounts/{of_user_id}/exports | …/exports/{job_id} | Build a data export ZIP — see Data exports |
Two panel-wide helpers:
GET /refresh/active # every running refresh job in the panel
POST /accounts/{of_user_id}/refresh/{kind}/clear # clear a stuck job's stateOne at a time, per account per kind
Starting a job while one of the same kind is already running is not an error.
You get 202 with already_running: true and the in-flight job — state for
refresh and backfill, job for exports.
{ "success": true, "already_running": true, "state": { "…": "…" } }Treat it as success. Retrying on it just spins.
A stale job is superseded. The two clocks differ, and it matters: a refresh is abandoned after 10 minutes with no recorded progress — a healthy long job keeps resetting that clock, so it never looks stale — while an export is abandoned 30 minutes after it started, regardless of progress. The stale job is marked failed and yours starts fresh, so a crashed worker never blocks you permanently.
Exports behave the same way; only the payload key differs:
{ "success": true, "already_running": true, "job": { "job_id": "f01152d3…", "status": "running" } }Design for at-most-once, not exactly-once
Treat already_running: true as success — something is already doing the work you
wanted. No refresh or export route returns 409 for this; the 409s that exist
are for a different class of mistake, like downloading an export before it has
finished.
Don't poll tightly
Refresh jobs finish in seconds to minutes depending on account size; exports with messages and media can take many minutes. Poll every few seconds at most, or use SSE and stop polling entirely.
Status polls are free — they never touch the platform and carry no monthly quota cost. They do count against the per-minute rate limit, so do not spin on them.