Artifacts
An artifact is a point-in-time, organization-scoped export of your First-Mile data — your locations, assets, metrics, facilities, sheds, plots, and assessment slices — packaged as a single, self-describing file you can download and hand to any tool. Instead of paging through the API entity by entity, you ask for one bundle, and Epoch generates it asynchronously and hands you a download.
Today every artifact is a single GeoParquet bundle (type: relational_full, format: geoparquet). Open it in DuckDB, GeoPandas, QGIS, BigQuery, or your warehouse of choice and every logical table is there, discriminated by a table_name column, with geometry as OGC WKB and a full lineage manifest embedded in the file.
This page covers what artifacts are for and how they're created — including the two ways one comes into existence. For the endpoint-by-endpoint reference (request bodies, filter vocabulary, manifest blocks, signed-URL minting, consuming recipes), see the Artifacts endpoint docs.
▶When to use an artifact
Reach for an artifact when you want all of the data, once, rather than a live query against a single entity:
- BI / data-warehouse handoff — pull a self-describing relational snapshot into DuckDB, GeoPandas, BigQuery, or Snowflake. One file, all entities, no join orchestration against the API.
- Regulatory submissions — a deterministic, hash-stamped (
sha256) bundle tied to a specific filter window makes a defensible due-diligence pack. The embedded manifest records lineage and model versions, so the export documents itself. - A snapshot for a moment in time — assessments evolve as new imagery and model runs land. An artifact freezes the data as it stood when generated, so you can reference exactly what was true at submission time.
- Dashboard handoff — a short-lived signed-URL response carries a
links.dashboard_urldeep link that opens the artifact directly in Epoch's dashboard. - Sharing with a person — request a lifetime beyond 7 days and you get a durable, revocable download link that still works weeks later. See short-lived vs. long-lived links.
- Event-driven pipelines — subscribe to
artifact.completed/artifact.failedwebhooks and kick off downstream processing the moment a bundle is ready.
If you only need a few live records, query the Locations or Metrics endpoints directly — artifacts are for the bulk, frozen-snapshot case.
▶Two ways an artifact is created
An artifact can be requested on demand at any time, or created automatically at ingestion as part of an upload.
| Path | Trigger | Scope | Best for |
|---|---|---|---|
| On-demand | POST /artifacts with your own filters | Any org-visible subset you specify | Periodic exports, regulatory packs, arbitrary slices |
| Ingestion fast-path | create_artifact: true on the upload request | Exactly the locations from that upload (assets.task_id) | "Give me a bundle of what I just uploaded" |
Both paths produce the same kind of artifact resource and walk the same lifecycle — they differ only in what triggers generation and how the scope is set.
▶On-demand
Call POST /artifacts with optional filters / filterOverrides and you get back a queued artifact (HTTP 202). It generates asynchronously; poll it, or subscribe to a webhook, until it reaches a terminal state. See Create Artifact for the request body and the Artifacts endpoint docs for the full filter model.
▶Ingestion fast-path
When you upload assets, you can ask Epoch to produce an artifact of just that upload in the same request — no second API call, no filter to author. Set create_artifact: true on the ingestion request:
// POST /ingestions/assets (multipart, or the signed-URL JSON variant)
{
"create_artifact": true,
}
What happens:
- The upload processes as normal. Your assets are staged, validated, and turned into locations exactly as they would be without the flag. The artifact is strictly additive — it never changes how ingestion itself behaves.
- Once the upload's locations exist, the artifact is minted automatically. Epoch stamps a scoped filter equivalent to
assets.task_id = <this upload>, so the bundle contains precisely the locations from this upload and nothing else. You don't write that filter — the upload's task is the scope. - Generation proceeds asynchronously, exactly like an on-demand artifact. It walks
queued → processing → completed. - You're attributed as the creator. Whether the upload was authenticated as a user or via an org API key, that identity is carried onto the artifact.
A few properties worth knowing:
- It fires once. The fast-path dispatch is guarded so a retried or re-run upload doesn't mint a second artifact — if generation is retried, the same artifact is reused.
- It's tied to the upload via
task_id. The resulting artifact carries the upload's task id, so you can correlate the bundle back to the ingestion that produced it. - TTL is configurable. If your upload integration sets a signed-URL TTL, that preference is carried through to the artifact's downloads.
- You still mint signed URLs to download. The fast-path creates and generates the artifact; it does not pre-mint download URLs. Once it's
completed, callPOST /artifacts/:id/signed-urls(or open the dashboard link) exactly as you would for an on-demand artifact.
Use the fast-path when the natural unit of export is "the batch I just sent" — you get a clean, scoped snapshot of each upload without managing filters or a separate generation call.
▶Lifecycle
Artifacts are asynchronous regardless of how they're created. The status field walks one of these paths:
queued → processing → completed → purged
→ failed
→ cancelled
queued— accepted, waiting to start.processing— being generated.completed— terminal success.storage_objects,size_bytes,sha256, androw_countsare populated; signed URLs can now be minted.failed— terminal failure. The response carries a safeerror_messageand anerror_code(e.g.artifact_too_large). On-demand requests can retry with the sameidempotencyKey.cancelled— terminal, caller-initiated viaDELETE /artifacts/:id; only valid from a non-terminal state.purged— the files aged out. The record survives as a tombstone:purged_atsays when,action_requiredsaysregenerate, and the file metadata still describes what the export held. Downloads no longer resolve. See Retention.
API field vs. prose name. In raw API responses the success state is literally
"status": "succeeded". Webhook payloads (artifact.completed) and this documentation say"completed". They are the same terminal state — filter onsucceeded, subscribe oncompleted. See task-and-entity-statuses.md.
▶Retention
Artifacts are generated exports, not a system of record — they don't live forever.
failed/cancelledartifacts are removed 7 days after they reach that state. Both the record and any partial output are deleted, so aGET /artifacts/:idfor one of them returns404afterwards. Read theerror_codewhile it's there; if you need the export, re-request it.completedartifacts age out 180 days after they complete. The files are deleted and the status becomespurged. You are never left guessing: the record stays for a further 90 days carryingpurged_at,action_required: "regenerate", and the original file metadata. After that the record goes too and the id returns404.- A live download link keeps an artifact alive. Retention will not delete the files behind a link that is still in date and not revoked, however old the artifact is — so a link you handed someone works for its full TTL. The clock effectively restarts each time you mint a fresh link. The 180-day window can never be shorter than the maximum link TTL.
- A dead download link is purged 3 days after it dies. A link dies when you revoke it, or when it passes
expires_at. Three days later the stored credential is destroyed and the link moves into thepurgedcount. The entry stays inGET /artifacts/:id/signed-urlsreportingstatus: purgedwithaction_required: remint. A purged link no longer keeps its artifact alive.
Read download_links on the artifact to see why one aged out. The four counts (active, expired, revoked, purged) are mutually exclusive and sum to every link you ever minted for it, and a purged artifact always shows active: 0. Each link is counted under the first thing that ended its life — purged, then revoked, then expired, then active — so a link you revoked stays in revoked even after its expiry passes.
{
"status": "purged",
"purged_at": "2026-07-01T10:00:00.000Z",
"action_required": "regenerate",
"download_links": { "active": 0, "expired": 1, "revoked": 1, "purged": 2 }
}
To find everything that has aged out, filter the list: GET /v1/artifacts?filter[status]=eq:purged.
If you need a bundle to outlive the artifact record, download it and keep the file.
▶Reading a download link's state
GET /artifacts/:id/signed-urls reports each persistent link's status, so you do not have to compare timestamps yourself:
status | Meaning | action_required |
|---|---|---|
active | Resolves normally. | null |
expired | Past expires_at. | remint |
revoked | Deliberately killed. | remint |
purged | Credential destroyed by the sweep. | remint |
All three dead states resolve to 404, and none can be repaired in place — PATCH refuses to un-expire or un-revoke a link. When action_required is remint, POST a fresh link.
status is reported most-terminal-first. A link that is both revoked and past its expiry reads as revoked; any purged link reads as purged.
▶What's in the bundle
Every artifact is one GeoParquet file holding rows from many logical tables, distinguished by a table_name discriminator column:
| Group | Tables |
|---|---|
| Locations & assets | locations, assets, metrics, facilities, sheds, plots |
| Per-plot assessments | plot_deforestation, plot_biodiversity, plot_emissions, plot_water |
| Per-shed assessments | shed_deforestation, shed_biodiversity, shed_emissions, shed_water |
A self-describing manifest is embedded in the file's metadata — data lineage, model versions, per-table keys and column descriptions, and usage constraints — so the export can be understood without further API calls. Geometry is OGC WKB in OGC:CRS84 (longitude, latitude). See the Artifacts endpoint docs for the manifest layout and DuckDB / GeoPandas / QGIS consuming recipes.
▶Images in a bundle
Thumbnail columns (thumbnail_url, thumbnail_url_lc, deforestation_thumbnail_url, diversity_thumbnail_url, and facility_thumbnail_url inside the metrics data) hold a stable Epoch link of the form https://api.epoch.blue/v1/imagery/{source}/{id}/{column}/{digest}, not a signed URL, so they work for as long as the bundle does. Request one with an API key that carries imagery:read: it redirects (302) to a short-lived download URL for the image as it stood when the bundle was generated. Your organization must still have access to the location; otherwise the link returns 404.
An empty thumbnail column means no image has been rendered for that row.
▶Sharing a bundle: short-lived vs. long-lived links
A completed artifact isn't downloadable until you ask for a URL. How long you ask for decides what you get — there's no separate endpoint, just the requested lifetime on POST /artifacts/:id/signed-urls.
| Short-lived (≤ 7 days) | Persistent link (> 7 days, up to 180) | |
|---|---|---|
| What you get | A time-limited URL per file, plus links.dashboard_url | A durable URL per file |
| Dashboard deep link | Yes | No — the file URLs are the handoff |
| After it expires | Gone; mint again | Gone; mint again |
| Can be extended | No | Yes, via PATCH |
| Can be revoked | No | Yes, via DELETE |
| Usage visible | Minting event only | Every download — access_count, last_accessed_at |
Seven days is the ceiling for a directly-signed URL, so anything longer is served by a different mechanism: Epoch issues a durable link that re-signs a fresh short-lived URL behind the scenes on each hit. The link keeps working for its whole lifetime without you re-minting.
Reach for a short-lived URL when a machine consumes the bundle straight away — a pipeline step, a DuckDB query, a one-shot load. Reach for a persistent link when a person is the recipient and may not act on it today: an auditor working through a submission, a customer contact, a colleague who'll open it next week. A seven-day URL handed to a human is usually dead by the time they click it.
▶Managing a link
The persistent URL carries a token that identifies the link. It's returned only once, in the minting response — Epoch stores only a hash of it — so keep the URL if you'll want to manage the link later.
PATCH /artifacts/:id/signed-urls/:token— move the expiry later or earlier, capped at 180 days out.DELETE /artifacts/:id/signed-urls/:token— kill it immediately. Permanent and idempotent.GET /artifacts/:id/signed-urls— see what's outstanding: which links are live, when each lapses, and how often each has been downloaded.
Without the token a link can't be revoked through the API, and will stay live until it expires.
Treat a download link as a credential. It authenticates the holder, not a user — anyone with the URL can download the artifact with no API key. That's the point (it's what makes it shareable), but it means the link should be sent deliberately and revoked if it leaks. Check
access_countto gauge exposure before deciding.
▶Org scoping & security
Artifacts are strictly scoped to your organization. A parent-org caller sees their own artifacts plus those of direct child orgs; the x-acting-as header narrows a request to a single child. Download URLs are never embedded in webhook payloads or in read responses — you mint them explicitly, and minting requires its own artifacts:sign scope, separate from artifacts:write. See scopes.md.
▶Related
- Artifacts endpoint reference — request bodies, full filter model, manifest, signed URLs, consuming recipes.
- Asset Upload — the ingestion flow the fast-path hooks into.
- Webhooks —
artifact.completed/artifact.faileddelivery and HMAC verification. - Assessments Overview — what the assessment slices in the bundle mean.