The quickstart produced three things: an asset, a scan, and a set of findings. They are separate objects rather than one report, and the separation is what the rest of the product is built on.
Collapsing any pair loses something specific. If a scan were a finding, a finding could only ever reflect the latest scan and “first seen 40 days ago” would be unrepresentable. If findings lived on scans, triage decisions would sit on a row replaced every night and nothing could be suppressed. If events were fields on the finding, the audit trail would be the current value — which is not an audit trail.

The asset is the thing being protected

An asset is not a build and not a blob. For a container image it is “the production build of this service”. Its identity is registry/repository:tag — not the digest:
If identity were the digest, every rebuild would produce a brand-new asset whose findings all looked new, every triage decision would be discarded on deploy, and “this critical has been open for 40 days” — the single most valuable thing a vulnerability-management product says — would be unrepresentable. The tag is part of identity, because myapp:staging and myapp:production are separately protected things, and alpine:3.18 and alpine:3.21 are separately remediable. A digest-pinned reference with no tag falls back to registry/repository, the most specific durable name available.

Assets are upserted, not created

The key is (organisation, kind, identity), and it is a database uniqueness constraint rather than an application check. So scanning alpine:3.19 a second time does not create a second asset — it finds the existing one and updates it. You never create an asset explicitly; there is no endpoint for it. Requesting a scan is what brings one into existence. The constraint being in the database matters more than it looks. Two concurrent requests can both pass a SELECT before either INSERTs — a webhook redelivery racing a user’s click is not hypothetical — and the result would be two assets for one image, splitting its history permanently. Identity carries no tenant component. Two customers scanning alpine:3.19 get two asset rows with the same identity string, and therefore identical fingerprints for the same CVE. Isolation is enforced by scoped queries, never by identity — see Tenant isolation.

The scan is one observation

202 means accepted, 200 means deduplicated

202 — the scan was queued. Pulling and unpacking an image takes seconds to minutes, so the response describes work accepted, not work done. The scan row is committed before the response is written, which is what makes the wakeup message that follows an optimisation rather than a step that can lose work. 200 — an existing scan was returned. Your Idempotency-Key matched one already reserved, so nothing new was queued. The two codes are distinct on purpose: a client can tell a deduplicated retry from a new scan without inspecting the body. The key is reserved before any work begins, so two concurrent retries — the normal case, because that is what a timed-out client does — cannot both queue a scan. Idempotency keys are scoped per organisation, never global, so one tenant’s key cannot suppress another’s scan. Use your build ID. See Idempotency.

What the response contains

The scan carries target_reference — the canonical, fully-qualified reference actually handed to the engine, not the string you typed. alpine in, index.docker.io/library/alpine:latest out. The scanner is never left to apply its own defaulting rules to a short name, which is how “scan alpine” ends up meaning different things on two differently configured hosts. It also carries trigger (manual, scheduled or system), because “why did this run at 3am” is a question a scan list has to be able to answer, and scanners — what was requested. A container scan runs vulnerability detection unless you ask for more.

Polling to a terminal status

There is deliberately no way to cancel a running scan, and no scan ever reaches a canceled status. A scan you no longer want will finish or fail on its own; it is bounded by a timeout, so it cannot run indefinitely. Poll until the status is terminal. A few seconds between polls is plenty; the work takes seconds to minutes and polling faster only spends your rate-limit budget. Work in queued is never dropped. If a scan sits there, the worker is not running or your per-tenant concurrency limit is saturated — see Scan failures. It has not been lost. A completed scan also carries scanners_run, which may be narrower than what was requested, and warnings, which is where a non-fatal problem is recorded. Read both. A scan can succeed and still be trying to tell you something.

Reading database_state

This is the field to check before trusting a clean result. Vulnerability data ages, and a scan run against an old database succeeds, exits cleanly, and reports nothing — with a recent timestamp and nothing in the result to suggest the answer is wrong. A stale scanner’s silence is indistinguishable from good news. So TRUSTIVAN surfaces the age rather than hiding it: unknown is expected on a brand-new deployment’s first scan and is treated as usable, because refusing it would make every new install look broken. Persistent unknown is worth investigating. If you are gating a pipeline, branch on this field. A build that treats stale the same as fresh has quietly opted out of the guarantee the hard limit exists to provide. A clean result with a state other than fresh is not evidence of a clean image. See Vulnerability database. Note what the scan deliberately does not tell you: the engine name, the engine version and the database version are recorded internally — because “why did this scan miss that CVE?” must be answerable after the fact — but they are stripped before any customer-facing response, and a CI check enforces that the API layer never names the scanning engine. database_state is the part you need, and it names nothing internal.

The findings

A successful scan writes findings against the asset, not against the scan. One row per logical issue per asset, not one row per observation. That is the whole distinction: a scanner emits a fresh report every night with no link to the previous one, and this row is the link. Rescanning the same image matches each finding to its existing row by fingerprint, so a decision you recorded last week is still attached tonight. A finding that was present and is now absent is resolved by that absence; a resolved finding that comes back is reopened rather than created fresh.
Use order=recent for anything that enumerates. It orders by immutable keys, so the traversal is exhaustive: every finding present when you started and not deleted is visited exactly once.

Where to go next