DefectDojo API v3 (alpha)
Alpha — do not build production dependencies on this yet. API v3 is in alpha: the contract may change at any time. It is mounted at
/api/v3/and stays there through beta and GA — the path never changes, so there is no URL migration. Alpha status is signaled out-of-band, not by the URL: the OpenAPIinfo.versionis3.0.0-alpha, every v3 response carries the headerX-API-Status: alpha, and this banner. Watch those markers to know when the contract has settled before you depend on it.
Overview
API v3 is a new, parallel API that runs next to the existing API v2. v2 is not changed by v3 and remains fully supported — v3 is entirely additive.
The alpha covers seven objects — organization, asset, engagement, test, finding,
location, user — plus a single consolidated import endpoint. Everything else stays on v2 until
v3 reaches parity. (organization and asset are the v3 wire names for what v2 calls product_type
and product — see Naming: organizations and assets.)
What v3 gives you over v2:
- A slim default response with
{id, name}refs, opt-in?expand=to inline related objects, and?fields=to pick columns — instead of v2’s post-serialization?prefetch=. Because expand drives realselect_related/prefetch_related, the query count is constant regardless of how many rows you fetch (v2’s?prefetch=issues per-row queries). - One list envelope for every collection, with optional aggregate totals in
meta. - One documented, versioned filter contract shared across every list.
- RFC 9457
application/problem+jsonerrors with afieldsextension for validation.
Interactive docs
- Interactive API reference (Swagger UI):
/api/v3/docs - Modern API reference (Scalar):
/api/v3/reference - OpenAPI schema:
/api/v3/openapi.json(info.version = 3.0.0-alpha)
The “try it out” flow works with your logged-in session, or paste a token (see below).
The Scalar page serves its UI from DefectDojo’s own static files — the bundle is installed at
image-build time by the existing frontend dependency step (exact version pin, lockfile-verified
integrity) and nothing is fetched from a CDN at runtime. It therefore works on air-gapped
deployments, exactly like Swagger UI at /docs.
Authentication
Two modes are active on every endpoint:
Token — your existing v2 token works unchanged. Send
Authorization: Token <key>. v3 validates against the same token store as v2, so a key that works on/api/v2/works on/api/v3/the same day — no new token, no re-issue.Authorization: Token c8572a5adf107a693aa6c72584da31f4d1f1dcffDjango session + CSRF. A session cookie plus an
X-CSRFTokenheader on unsafe methods (POST/PATCH/PUT/DELETE). This is what the first-party web UI uses.
Anonymous requests get 401 application/problem+json. Authentication is separate from
authorization: after a request authenticates, object access flows through exactly the same
get_authorized_* querysets and RBAC as v2.
The contract in one page
List envelope
Every list returns the same closed envelope — and nothing else:
{
"count": 4321, // exact below a cap; a planner estimate above it (see Known gaps)
"next": "https://.../api/v3/findings?limit=25&offset=50&severity=High",
"previous": null,
"results": [ /* slim objects */ ],
"meta": { } // present only when ?include= adds content
}limit defaults to 25, max 250; offset ≥ 0. Treat next/previous as opaque URLs — do
not construct them yourself.
Cursor (keyset) pagination
For export/sync consumers, opt into forward-only keyset pagination with ?pagination=cursor on any
top-level list endpoint (findings, assets, organizations, engagements, tests, users, locations). The
envelope is unchanged, with two differences:
countis alwaysnull(cursor mode never counts — that is the point: noCOUNTquery, so cost is constant per page regardless of collection size).previousis alwaysnull— cursor mode is forward-only (GitLab-style). Walk a collection by followingnextuntil it isnull; there is no backward cursor.
next is the same URL carrying an opaque, signed cursor= token. Treat it as opaque — do not
decode or construct it. Cursors are tamper-proof; a modified, corrupt, or ordering-mismatched cursor
returns 400 application/problem+json (pagination error type).
Sorting is restricted to keyset-safe orderings — id, created, updated (each with an
optional - prefix for descending), whichever a resource declares. Every ordering has a
deterministic id tiebreaker appended automatically. The default (no o=) is id ascending. A
resource without created/updated columns permits id only. Any other o= value (or more than
one field) returns 400:
| Resource | Keyset-safe orderings (each ± direction) |
|---|---|
| findings, assets, organizations, engagements, tests | id, created, updated |
| users, locations | id |
// GET /api/v3/findings?pagination=cursor&limit=25&o=-updated&severity=High
{
"count": null,
"next": "https://.../api/v3/findings?limit=25&o=-updated&severity=High&pagination=cursor&cursor=eyJ...",
"previous": null,
"results": [ /* slim objects */ ]
}Filters, q=, fields=, expand=, and include=counts all compose with cursor mode exactly as
with offset mode (include=counts still fills meta.counts even though count is null).
Caveat: the cursor encodes only the ordering and position, not your filters — changing a
filter (or the ordering) partway through a walk invalidates the position and yields undefined
results for the rest of the walk. Start a fresh walk if you change any filter.
Refs
Every relation renders by default as a closed ref:
{ "id": 42, "name": "Q3 Pentest" }The relation key tells you the type, so refs carry no type field — except location refs, which
add "type": "<location_type>". On writes you reference a relation by its integer id
("test": 9); on reads you get a ref back. This asymmetry is intentional.
Two update verbs are offered on every writable resource (findings, assets, organizations,
engagements, tests, users; locations stay read-only). PATCH is a partial update — only the
fields you send change. PUT is a full replace: it validates against the same required fields
as create and resets any omitted optional field back to its default, mirroring v2’s
update(partial=False). Both reject unknown fields (400) and share the same permission ladder. An
immutable parent (a finding’s test, a test’s engagement) is never reassignable on either verb.
?expand=, ?fields=, ?include=
| Param | Does | Example |
|---|---|---|
?expand= | Swaps a ref for the target’s slim object inline and drives the queryset. Comma-separated dotted paths, budget-guarded. | ?expand=test.engagement,reporter |
?fields= | Allowlist of fields to return (id always included). The picker facility; on a list it may also request any detail field. | ?fields=id,title,impact |
?include=counts | Adds severity/status totals over the filtered, authorized queryset to meta, in one query — no second round-trip. | ?include=counts |
?fields= on lists may opt up into the detail shape. A list returns the slim shape by default.
?fields= accepts any field in the slim or the detail set (plus expand keys), so a list can pull
a normally detail-only field like impact or references without a second request to the detail
endpoint — GET /findings?fields=id,title,impact. Fields are plain row-columns, so this is a wider
SELECT on the same single query, never a per-row cost: the default list stops fetching the heavy
detail columns from the DB entirely, and naming one in ?fields= un-defers exactly that column.
Guard: only row-columns are eligible. Anything computed per row (SLA/age math, per-row counts)
is permanently out of ?fields= — it would reintroduce the N+1 the slim default removes. An unknown
field name still returns 400.
A finding’s locations are special: ?expand=locations replaces the cheap locations_count with the
full edge rows {location, status, audit_time, auditor}.
Filter grammar
The grammar is fixed and the per-object vocabulary is documented and snapshot-tested:
- exact
field=, and lookupsfield__gte/__lte/__gt/__lt/__in/__icontains/__isnull(__intakes comma-separated values), - multi-sort
o=(comma list,-prefix for descending, e.g.o=-severity,date), - free-text
q=.
Unknown filter params, unknown ?fields=, and unknown/over-budget ?expand= all return 400
application/problem+json.
Errors
{ "type": "https://docs.defectdojo.com/en/api/api-v3-alpha-docs/#error-validation",
"title": "Validation failed", "status": 400,
"detail": "2 fields failed validation",
"fields": { "severity": ["Not a valid choice."], "date": ["Required."] } }401 unauthenticated · 403 forbidden · 404 unknown or unauthorized object (existence is
never leaked) · 400 validation/expand/filter errors · 409 conflict (a unique field value —
an organization or asset name, for example — already exists; retrieve or update the existing
record instead of creating a new one).
v2 → v3 mapping
This table doubles as the rename documentation (D11): product_type → organization,
product → asset on the v3 wire.
| Object | v2 | v3 (alpha) | Notes |
|---|---|---|---|
| Organization (v2 “product type”) | /api/v2/product_types/ | /api/v3/organizations | Renamed (D11); CRUD (PATCH partial + PUT full replace) |
| Asset (v2 “product”) | /api/v2/products/ | /api/v3/assets | Renamed (D11); CRUD |
| Engagement | /api/v2/engagements/ | /api/v3/engagements | CRUD |
| Test | /api/v2/tests/ | /api/v3/tests | CRUD |
| Finding | /api/v2/findings/ | /api/v3/findings | CRUD + ?expand=, ?include=counts |
| Location | /api/v2/endpoints/ (legacy Endpoint) | /api/v3/locations | Concept change (see below); read-only in alpha |
| User | /api/v2/users/ | /api/v3/users | Read + self; admin-only writes |
| Import | /api/v2/import-scan/ + /api/v2/reimport-scan/ | /api/v3/import | Consolidated — one endpoint, mode=auto|import|reimport |
Naming: organizations and assets
v3 speaks the product’s new domain language: what v2 calls a product type is an
organization on the v3 wire, and what v2 calls a product is an asset. This renames
the wire surface consistently and completely (D11, “all-or-nothing”): the paths (/organizations,
/assets), the ref keys (finding.asset, finding.organization, engagement.asset,
asset.organization), the filter params (asset=, organization=, …), the ?expand= keys
(expand=asset, expand=organization), the import form fields (asset_name, organization_name),
the OpenAPI tags and schema classes, and this documentation.
Why now: a brand-new API should not bake the legacy names into a surface that clients will codegen against for years. The alpha is the only time this rename is free — after beta it would mean permanent aliases and deprecations.
Unconditional — the API naming does not follow the UI relabel flag. The web UI’s
product-type→organization / product→asset relabel is governed by
DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL (default on). The v3 API ignores that flag: v3 always
speaks organizations/assets regardless of the deployment’s UI labels. A per-instance API contract
would be poison for code generation, so the wire names are fixed.
Not renamed: the underlying Django models (Product_Type, Product), the database tables, and
the internal module paths are unchanged — the DTO layer is exactly what decouples the wire names from
the models. (A handful of scalar model-column fields keep their names on the wire — the
organization flags critical_product/key_product and the asset user-role ref asset_manager,
whose model column is product_manager; these are model-field passthroughs, not entity references.)
Locations replace endpoints
v3 does not expose the legacy Endpoint model. A finding relates to locations many-to-many, with
the status (Active / Mitigated / FalsePositive / RiskAccepted / OutOfScope) carried on the
edge. Slim findings carry locations_count; the full edge list is a sub-resource:
GET /api/v3/findings/{id}/locations→{ location: {id, name, type}, status, audit_time, auditor }GET /api/v3/assets/{id}/locations→{ location: {id, name, type}, status }
Import in one call
POST /api/v3/import (multipart) covers all three flows. mode=auto resolves an existing
target via asset_name + engagement_name (+ optional organization_name); import and reimport
are explicit. Destructive flags are never implied by mode — if you omit close_old_findings, the
importer default applies and the response echoes the effective value:
{ "mode_resolved": "reimport", "test": { "id": 9, "name": "ZAP Scan" },
"statistics": { "new": 4, "reactivated": 1, "closed": 2, "untouched": 37 },
"close_old_findings": true }One way to do notes, tags and files
Where v2 accreted per-resource actions and mechanisms, v3 has one generic sub-resource for each, attached to every resource whose model stores it (notes/files: finding, engagement, test; tags: those plus asset):
GET / POST /api/v3/<resource>/{id}/notes { "entry": "...", "private": false }
GET / PUT / POST /api/v3/<resource>/{id}/tags { "tags": ["pci"] } # PUT replaces, POST appends
DELETE /api/v3/<resource>/{id}/tags/{tag}
GET / POST /api/v3/<resource>/{id}/files (multipart: file, title)
GET /api/v3/<resource>/{id}/files/{file_id}/downloadAuthorization for a sub-resource is inherited from its parent object.
More worked request/response pairs live in api_v3_examples.md
at the repository root.
CSV export
Every top-level list endpoint has a CSV projection at GET /<resource>/export.csv (findings, assets,
organizations, engagements, tests, users, locations — locations keeps its superuser gate):
GET /api/v3/findings/export.csv?severity=High&o=-date&fields=id,title,impact- Same filter contract as the list.
export.csvtakes the identical filters,o=ordering,q=free-text search, and?fields=(including the detail opt-up) asGET /<resource>. It has no pagination: the response is the whole filtered, authorized set.expand,include,limit,offset,pagination, andcursorare not applicable and return400application/problem+json. - Streamed attachment. The response is
Content-Type: text/csv; charset=utf-8withContent-Disposition: attachment; filename="<resource>-export.csv", streamed row-by-row so memory and query count stay bounded regardless of the number of rows. - Row cap. If the filtered set exceeds
DD_API_V3_EXPORT_MAX_ROWS(default 100 000) the request returns400asking you to narrow the filter — the export is never silently truncated. - Column flattening. The columns are the JSON row’s fields (in schema order, restricted to your
?fields=selection):- a
{id, name}ref becomes two columns<key>_idand<key>_name(a location ref adds<key>_type); tagsbecomes a single semicolon-joined column;- datetimes are ISO-8601
Z, datesYYYY-MM-DD, booleans lowercasetrue/false, andnullrenders as an empty cell. A zero-row export still returns the header row.
- a
- Spreadsheet-injection hardening. Any cell whose text starts with
=,+,-,@, or a tab is prefixed with a single quote so spreadsheet software does not evaluate it as a formula.
Known alpha gaps
The alpha delivers the contract and the seven read/write surfaces above. These are known, deliberate gaps — several exist so the alpha stayed additive and reviewable:
- Note side-effects have v2 parity in the alpha. Creating a note via
POST .../{id}/notesfires the same side-effects as the v2 notes endpoints, per resource: findings get JIRA comment sync (on a linked issue, else the finding-group issue),last_reviewed/last_reviewed_bystamping, and@mentionnotifications; engagements and tests get@mentionnotifications only (their v2 endpoints have neither JIRA sync norlast_reviewedstamping). - Finding writes mirror the v2 serializer, not the UI view. The finding create/update service
reproduces the v2 API serializer semantics (JIRA push, risk acceptance, vuln-ids/CWE). UI-only
behaviors are deferred: auto-mitigation when
activeflips off, false-positive-history reactivation,last_reviewedstamping, finding-group push, GitHub sync, and JIRA link/unlink. - No bulk or workflow actions yet. No bulk operations, no workflow actions
(
close/request_review/mark_duplicate), no aggregation/chart endpoints, no saved views, no XLSX export (CSV export is available — see above), no delete-impact preview. These are recorded as explicit post-alpha work items in the plan’s backlog.
Platform limitations (not v3 gaps)
- Only URL locations carry detail fields.
/api/v3/locationslists every location the instance stores, whatever its type, and?type=filters by it. Open source storesurllocations; DefectDojo Pro adds further types, such asdependency,code,imageandcloud. Every location returnsid,name,typeandtags, but the detail fields (protocol,host,port,path,query,fragment, returned on retrieve) are filled in only for aurllocation and arenullfor every other type. Locations are read-only in the alpha (lifecycle is import-driven).
Design decisions to be aware of
- Counts above the cap are estimates — by design. Below
COUNT_CAP(default 10,000)countis exact. Above it,countis the Postgres planner’s row estimate for the filtered query, clamped to ≥ CAP+1 and flagged"count_exact": falseinmeta. This is the documented count strategy (bounded cost on unbounded tables), not a deficiency awaiting a fix. Estimates depend on planner statistics and may drift; when you page-jump near an estimated end you may get an emptyresultswithnext: null.
See API_V3_PLAN.md in the repository for the full contract specification and decision log.