Skip to content

API reference

7 routes under /api/v1. They let a CI job upload a release, let a script read a product’s releases, status and inventory, and let a key with the right scope record a decision on a vulnerability match.

Authentication. Authorization: Bearer dk_<prefix>_<random>

Create a key under any product’s settings tab. Choose "This product only" for a key bound to that product, or "Every product in this organisation" for one that works for every product the organisation has now or adds later — your CI job still names the product by id in each call’s URL. The key is shown once; Declarable keeps only a hash and the prefix. A key that was revoked, was replaced and is past its hand-over time, or was made by someone who is no longer an owner or admin gets 401.

Scopes. The key form asks what the key may do. A key only opens the routes whose scope it was given. "Record decisions" starts unticked in the key form, so a key has it only when the person making the key ticks it. A key made before triage:write existed does not have it: make a new key with the box ticked.

  • releases:write, “Upload releases” in the form: opens POST /api/v1/products/:id/releases.
  • products:read, “Read status” in the form: opens the GET routes: the product summary, its releases, gaps and open vulnerabilities.
  • triage:write, “Record decisions” in the form: opens POST a decision on an open vulnerability. Unticked unless you tick it.

Agents. The same key also opens the agent door, /api/mcp, which offers six tools over MCP. Every request it receives, the handshake included, takes one from the same bucket of 120 as your CI job, so give an agent its own key. See Use Declarable from your AI agent.

Errors. Every error is JSON shaped { error: { code: string, message: string } }. Every response is JSON and carries Cache-Control: no-store, except a successful GET …/releases/:releaseId/sbom, whose body is the CycloneDX, SPDX 3 or CSV file itself.

Host. The examples use $DECLARABLE_URL for your Declarable address and $DECLARABLE_API_KEY for the key.

POST /api/v1/products/:id/releases

Upload the SBOM files of a new release of a product, usually from a CI job.

POST Needs the releases:write scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Request

Content types: multipart/form-data, application/json. Shape: multipart: version (text field), files[] (one or more files; the notes say which), replace (optional text field, "true" or "1"), releasedAt (optional text field, YYYY-MM-DD), and the optional build fields imageSha256, buildId, commit and supportedUntil (text fields). JSON: { version: string, files: [{ name: string, contentBase64: string }], replace?: true | "true" | "1", releasedAt?: "YYYY-MM-DD", imageSha256?: string, buildId?: string, commit?: string, supportedUntil?: "YYYY-MM-DD" }

curl -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -F version=v1.2.0 \
  -F releasedAt=2026-10-01 \
  -F commit="$(git rev-parse HEAD)" \
  -F supportedUntil=2031-06-30 \
  -F "files[]=@build/spdx/app.spdx" \
  -F "files[]=@build/spdx/zephyr.spdx" \
  "$DECLARABLE_URL/api/v1/products/<product-id>/releases"

# the same upload as JSON, replacing a release that already has this version
curl -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "version": "v1.2.0", "replace": true, "files": [{ "name": "app.spdx", "contentBase64": "<base64>" }] }' \
  "$DECLARABLE_URL/api/v1/products/<product-id>/releases"

Responses

201 — The release was recorded. The answer also carries a Location header with the release url. Shape: { release: { id, version, componentCount, imageSha256: string | null, buildId: string | null, commit: string | null, supportedUntil: string | null, url } }

{
  "release": {
    "id": "<release-id>",
    "version": "v1.2.0",
    "componentCount": 214,
    "imageSha256": null,
    "buildId": null,
    "commit": "9fceb02d0ae598e95dc970b74767f19372d61af8",
    "supportedUntil": "2031-06-30",
    "url": "<host>/app/products/<product-id>/releases/<release-id>"
  }
}

201 — replace was true, the version was taken, and the new release took its place. kept is true when the old release was not deleted because the new one was still being matched: it stays under a new label for you to delete from Releases. Shape: { release: { id, version, componentCount, imageSha256, buildId, commit, supportedUntil, url }, replaced: { id, version, kept } } (the four build fields as above)

{
  "release": {
    "id": "<new-release-id>",
    "version": "v1.2.0",
    "componentCount": 216,
    "imageSha256": null,
    "buildId": null,
    "commit": "9fceb02d0ae598e95dc970b74767f19372d61af8",
    "supportedUntil": "2031-06-30",
    "url": "<host>/app/products/<product-id>/releases/<new-release-id>"
  },
  "replaced": {
    "id": "<old-release-id>",
    "version": "v1.2.0",
    "kept": false
  }
}

200 — The version already exists and the files are byte for byte the ones already stored. Nothing is recorded; the answer names the release that is there. Shape: { release: { id, version, componentCount, url }, unchanged: true }

{
  "release": {
    "id": "<release-id>",
    "version": "v1.2.0",
    "componentCount": 214,
    "url": "<host>/app/products/<product-id>/releases/<release-id>"
  },
  "unchanged": true
}

400 — The body cannot be read (not valid multipart or JSON, a files[] field that is not a file, contentBase64 that is not standard base64), or releasedAt is not YYYY-MM-DD, is in the future or is before 1990, or a build field is malformed (imageSha256 that is not 64 hex characters: "A firmware image hash is 64 hex characters."; supportedUntil that is not a real YYYY-MM-DD date: "Give the support date as YYYY-MM-DD."; buildId or commit over 200 characters: "Keep the build id under 200 characters." or "Keep the commit under 200 characters."), or the id in the path is not a UUID. Nothing is recorded. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "The multipart body could not be read."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

402 — The organisation’s plan does not include API upload (reason "not-on-plan"), or its paid plan has lapsed (reason "read-only"). Nothing was stored. This is checked before the body is read. The message is the one the Billing gate sends. Shape: { error: { code: "plan_required", message: string }, reason: "not-on-plan" | "read-only", upgradeTo: "maker" | "studio" | "fleet" | null }

{
  "error": {
    "code": "plan_required",
    "message": "This organisation's plan does not let it upload releases from CI. That is included from Maker up: choose a plan on the Billing page."
  },
  "reason": "not-on-plan",
  "upgradeTo": "maker"
}

402 — The same code and shape for an organisation whose paid plan has lapsed: reason is "read-only". What it has recorded stays visible and exportable. Shape: as above

{
  "error": {
    "code": "plan_required",
    "message": "This organisation's paid plan has lapsed (the subscription was cancelled, or a payment is more than 14 days overdue), so it cannot upload releases from CI until it is on a plan again. Everything it has recorded stays visible and exportable, and Article 14 reporting stays open. Choose a plan on the Billing page."
  },
  "reason": "read-only",
  "upgradeTo": "maker"
}

402 — Your plan’s release storage is full. Nothing was stored. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "quota_exceeded",
    "message": "The message names the limit of your plan’s release storage."
  }
}

403 — The key does not have the releases:write scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the releases:write scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

409 — The version already exists with different files and replace was not sent. Nothing was changed; release is the one that holds that version. Shape: { error: { code: "duplicate_version", message: string }, release: { id, version, url } }

{
  "error": {
    "code": "duplicate_version",
    "message": "A release named v1.2.0 already exists with different files, and nothing was changed. Send replace=true to replace it, or use a different version."
  },
  "release": {
    "id": "<release-id>",
    "version": "v1.2.0",
    "url": "<host>/app/products/<product-id>/releases/<release-id>"
  }
}

413 — Three cases, all 413, nothing stored. More than 10 files: the message says how many were sent. A file over 25 MB: the message names the file and its size. The whole request body over about 251 MB as multipart (about 335 MB as JSON): the message is only "A release takes at most 10 files of 25 MB each." Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "payload_too_large",
    "message": "big.json is 30 MB; a release takes at most 10 files of 25 MB each. Nothing was stored."
  }
}

415 — The Content-Type is neither multipart/form-data nor application/json. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unsupported_media_type",
    "message": "Send multipart/form-data with \"version\" and \"files[]\", or application/json shaped { \"version\": \"v1.2.0\", \"files\": [{ \"name\": \"app.spdx\", \"contentBase64\": \"…\" }] }."
  }
}

422 — The version is missing or over 100 characters; the files are missing, unreadable, list no components or list more than 50,000; a VEX document came with no SBOM ("These files state decisions about CVEs but list no components. Upload them together with the SBOM of this release."); or the product is archived ("This product is archived. Restore it in Settings before uploading a release."). Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_release",
    "message": "The files could not be turned into a release; the message says why."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}

500 — The release itself was already stored, under this version, before this answer: only the four build fields (imageSha256, buildId, commit, supportedUntil) failed to save. The files you sent, the west manifest and the Kconfig among them, were stored with the release. Resending the same request does not retry this — it gets 200 with unchanged: true or 409 duplicate_version, neither of which saves the details. Open the release in the app and edit it to add them. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "The release was recorded but its build details were not saved. Edit the release in the app to add them."
  }
}
  • files[] takes SBOM files in these formats: SPDX 3.0.1 (JSON-LD); SPDX 2.2 and 2.3 (JSON); CycloneDX 1.4 to 1.6 (JSON); CycloneDX 1.1 to 1.6 (XML); SPDX 2.2 and 2.3 (tag-value); Buildroot make show-info (JSON); Buildroot .config (selected packages, versions only where it states them); Arduino project (sketch, library.properties or arduino-cli library list); PlatformIO project (platformio.ini). A release takes at most 10 files of 25 MB each, and at most 50,000 components.
  • Companion build files can go in files[] beside the SBOM: west.yml (Zephyr), a Zephyr .config, and the other build files the platform checks read. They add detail to the components; on their own they are not an SBOM.
  • A VEX document (OpenVEX or CycloneDX VEX) can go in files[] too, and its statements about vulnerabilities are read. Send it together with the SBOM of the same release: a VEX with no SBOM is 422 invalid_release.
  • The JSON form takes standard base64 in contentBase64.
  • After the generic 500 above, send the same request again in a minute. If the first attempt did record the release, the same files get 200 with unchanged: true; different files get 409 duplicate_version. Check the release list before assuming nothing was recorded. This does not apply to the build-details 500 below: that one means the release is already recorded, and a resend only reaches unchanged or duplicate_version, never a retry of the build details — edit the release in the app instead.
  • Uploads go through the same checks and size caps as the upload form in the web app.
  • Re-running a pipeline has three answers. The same files again: 200 with unchanged: true, nothing recorded, and the GitHub Action, the GitLab job and the curl script print "already uploaded with the same files; nothing new was recorded" and go on to check the findings. Different files under the same version: 409 duplicate_version, and the three scripts print "already exists … with different files. Nothing was replaced", name the release and go on to check its findings; set replace (the Action’s replace input, DECLARABLE_REPLACE=true in the curl script) to send replace=true. With replace=true: 201 with replaced, and the scripts print "Replaced release <version> (<id>)".
  • In multipart the files can be sent as files[] or as files; the JSON form takes only its files array.
  • Four optional build fields say which build the release is. imageSha256 is the firmware image hash: 64 hex characters, with or without a "sha256:" prefix, returned in lower case. buildId and commit are free text of at most 200 characters. supportedUntil is the date that build stops getting fixes, YYYY-MM-DD. A malformed one is 400 invalid_request and nothing is recorded. A field you leave out comes back as null in the 201 body, and in GET …/releases. GET …/releases/:releaseId does not carry them. They are stored as sent and not checked against a real build.
  • releasedAt is the day the firmware shipped, as YYYY-MM-DD. It is optional, is not used to tell releases apart, and is returned by GET …/releases.
  • On a retry answered 200 with "unchanged": true, a releasedAt you send is not applied: nothing is recorded, so the stored date stays as it was. A date that is not valid is still 400 before that.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • Both 402 answers mean nothing was stored and sending the same request again will not help. plan_required: print error.message, which says what to do, and fail the job; a person has to choose a plan on the Billing page (upgradeTo names the lowest plan that includes API upload, and is null if none does). A read-only reason means the plan lapsed, not that it lacks the feature. quota_exceeded: free release storage or choose a larger plan, then re-run.
  • An archived product answers 422 before the duplicate check runs, so a re-run of an identical upload to an archived product gets 422, not 200 with unchanged: true. Restore the product in Settings first.
  • A 429 carries a Retry-After header in seconds.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

GET /api/v1/products/:id/releases

The releases of a product, newest first.

GET Needs the products:read scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Responses

200 — Always, for a key that may see the product. The list is empty for a product with no release. Shape: { releases: [{ id, version, status, componentCount, createdAt, releasedAt: string | null, imageSha256: string | null, buildId: string | null, commit: string | null, supportedUntil: string | null, url }], truncated: boolean }

{
  "releases": [
    {
      "id": "<release-id>",
      "version": "v1.2.0",
      "status": "current",
      "componentCount": 214,
      "createdAt": "2026-10-08T09:30:00.000Z",
      "releasedAt": "2026-10-01T00:00:00.000Z",
      "imageSha256": null,
      "buildId": null,
      "commit": "9fceb02d0ae598e95dc970b74767f19372d61af8",
      "supportedUntil": "2031-06-30",
      "url": "<host>/app/products/<product-id>/releases/<release-id>"
    }
  ],
  "truncated": false
}

400 — The id in the path is not a UUID. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "That is not a product id. It is a UUID like 3f2b8c1e-5a47-4d0e-9c1a-7b6e2d4f8a90; copy it from the \"Product id\" card on your product's overview page."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the products:read scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the products:read scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • status is draft, current or superseded. Ordered by createdAt, newest first, at most 100 releases. truncated is true when there are more than that.
  • releasedAt is null for a release uploaded without one. imageSha256, buildId, commit and supportedUntil are null where none is recorded.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

GET /api/v1/products/:id

The status of one product: its details, the current release, the open vulnerability counts and the gaps in the current release.

GET Needs the products:read scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Responses

200 — Always, for a key that may see the product. Shape: { product: { id, name, platform, category, status, markets, supportPeriodEnd }, currentRelease: { id, version, componentCount, createdAt, checked: boolean } | null, releaseCount, openVulnerabilities: { total, byStatus, undecided, bySeverity, byConfidence, exploited } | null, gaps: [{ id, severity, title, why, howToFix, craReference, componentIds? }] | null }

{
  "product": {
    "id": "<product-id>",
    "name": "Sensor hub",
    "platform": "zephyr",
    "category": "default",
    "status": "active",
    "markets": [
      "EU"
    ],
    "supportPeriodEnd": "2031-06-30"
  },
  "currentRelease": {
    "id": "<release-id>",
    "version": "v1.2.0",
    "componentCount": 214,
    "createdAt": "2026-10-08T09:30:00.000Z",
    "checked": true
  },
  "releaseCount": 3,
  "openVulnerabilities": {
    "total": 3,
    "byStatus": {
      "open": 1,
      "in_triage": 1,
      "affected": 1
    },
    "undecided": 2,
    "bySeverity": {
      "critical": 0,
      "high": 2,
      "medium": 1,
      "low": 0,
      "none": 0,
      "unknown": 0
    },
    "byConfidence": {
      "high": 2,
      "medium": 1,
      "low": 0
    },
    "exploited": 0
  },
  "gaps": [
    {
      "id": "gap-id",
      "severity": "major",
      "title": "A short title of the gap.",
      "why": "Why the gap matters.",
      "howToFix": "What to do about it.",
      "craReference": "Annex I Part II (1)"
    }
  ]
}

400 — The id in the path is not a UUID. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "That is not a product id. It is a UUID like 3f2b8c1e-5a47-4d0e-9c1a-7b6e2d4f8a90; copy it from the \"Product id\" card on your product's overview page."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the products:read scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the products:read scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • openVulnerabilities is null in two cases: before the first release, and while the current release has not been checked yet (its vulnerability match has not finished, or failed). currentRelease.checked tells you which: false means not checked yet, true with a total of 0 means checked and nothing open. Not checked is not the same as none found.
  • gaps is null before the first release.
  • total is the open vulnerabilities in the current release, each counted once. byStatus splits it into open, in_triage and affected (they add up to total); undecided is open plus in_triage, the ones nobody has decided on yet. Vulnerabilities decided as not affected or fixed are not counted.
  • bySeverity counts the same vulnerabilities by how bad each is: critical, high, medium, low, none or unknown. byConfidence counts them by how sure the match is (high, medium, low), at each vulnerability’s highest confidence. The two answer different questions. exploited counts those known to be exploited.
  • gaps are what is missing in the current release’s SBOM, each with a severity of blocker, major, minor or info. The upload scripts count them to decide whether the job fails (DECLARABLE_FAIL_ON_GAP in the curl script).
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

GET /api/v1/products/:id/vulnerabilities

The open vulnerability matches in the current release, one entry per vulnerability.

GET Needs the products:read scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Responses

200 — Always, for a key that may see the product. The list is empty when there is no current release. Shape: { release: { id, version } | null, truncated: boolean, vulnerabilities: [{ id, summary, severity: { label, cvss31?, cvss40? }, exploited, publishedAt, matches: [{ component: { name, version, purl, cpe }, confidence, method, evidence, status, firstSeenAt }] }] }

{
  "release": {
    "id": "<release-id>",
    "version": "v1.2.0"
  },
  "truncated": false,
  "vulnerabilities": [
    {
      "id": "CVE-0000-00000",
      "summary": "A short summary of the vulnerability.",
      "severity": {
        "label": "high",
        "cvss31": {
          "score": 7.5,
          "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
        }
      },
      "exploited": false,
      "publishedAt": "2026-09-01T00:00:00.000Z",
      "matches": [
        {
          "component": {
            "name": "mbedtls",
            "version": "3.5.0",
            "purl": "pkg:generic/mbedtls@3.5.0",
            "cpe": null
          },
          "confidence": "high",
          "method": "purl",
          "evidence": "Why this component was matched.",
          "status": "open",
          "firstSeenAt": "2026-09-15T08:00:00.000Z"
        }
      ]
    }
  ]
}

400 — The id in the path is not a UUID. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "That is not a product id. It is a UUID like 3f2b8c1e-5a47-4d0e-9c1a-7b6e2d4f8a90; copy it from the \"Product id\" card on your product's overview page."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the products:read scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the products:read scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • release is null and the list is empty when the product has no current release.
  • severity.label is one of critical, high, medium, low, none or unknown; cvss31 and cvss40 ({ score, vector }) are present only when the source gave that score. summary and publishedAt can be null.
  • The list is capped at 2000 matches; truncated is true when matches were left out.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

POST /api/v1/products/:id/vulnerabilities/:componentVulnId/decision

Record a decision on one vulnerability match, as the API key that sent it. For a CI job or an AI agent that has worked out the answer and should leave it in the record.

POST Needs the triage:write scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Request

Content types: application/json. Shape: { status: "open" | "in_triage" | "not_affected" | "affected" | "fixed" | "mitigated", justification?: string, note?: string | null, fixedInReleaseId?: string }

curl -X POST -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "not_affected", "justification": "code_not_reachable", "note": "The TLS server code is not built into this image." }' \
  "$DECLARABLE_URL/api/v1/products/<product-id>/vulnerabilities/<match-id>/decision"

Responses

200 — The decision was recorded on the match, and an audit entry names the key that made it. The answer is the match as it is now stored. Shape: { decision: { id, status, justification: string | null, note: string | null, decidedAt, via: { keyName, prefix } } }

{
  "decision": {
    "id": "<match-id>",
    "status": "not_affected",
    "justification": "code_not_reachable",
    "note": "The TLS server code is not built into this image.",
    "decidedAt": "2026-10-13T09:30:00.000Z",
    "via": {
      "keyName": "Triage agent",
      "prefix": "1a2b3c4d5e6f"
    }
  }
}

400 — The body is not JSON, is not an object, has no status, or has a field of the wrong type; or the product id in the path is not a UUID. Nothing was changed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "The body is not valid JSON. Send { \"status\": \"not_affected\", \"justification\": \"code_not_reachable\", \"note\": \"why\" }."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the triage:write scope. A key has it only when "Record decisions" was ticked as the key was made; a key made earlier needs replacing with one that has it. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the triage:write scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

404 — The match id is not a match of this product: it belongs to another product or organisation, was removed with its release, or is not an id at all. The answer is the same for each. Nothing was changed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No vulnerability match with that id on this product."
  }
}

409 — The match already has the status you sent. Nothing was changed, the justification and note included, and no audit entry was written. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unchanged",
    "message": "This match already has the status not_affected. Nothing was changed."
  }
}

413 — The body is over 64 KB. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "payload_too_large",
    "message": "A decision is at most 64 KB of JSON. Nothing was changed."
  }
}

415 — The Content-Type is not application/json. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unsupported_media_type",
    "message": "Send application/json shaped { \"status\": \"not_affected\", \"justification\": \"code_not_reachable\", \"note\": \"why\" }."
  }
}

422 — A word is not in the vocabulary, and the message lists the words that are: a status that is not one of the six; a justification that is not one of the nine; not_affected sent without a justification. Also a note over 4,000 characters, and fixedInReleaseId sent with a status other than fixed or naming a release that is not the match’s own or a later one of this product. Nothing was changed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_decision",
    "message": "\"wontfix\" is not a status. Use one of: open, in_triage, not_affected, affected, fixed, mitigated."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • :componentVulnId is the id of one match: matches[].id in GET …/vulnerabilities. A vulnerability matched on two components is two matches, and each takes its own decision.
  • status is one of open, in_triage, not_affected, affected, fixed or mitigated: the words the triage drawer in the app uses. What each means: open, nobody has looked; in_triage, someone is working on it; not_affected, the vulnerability cannot be used against this product; affected, it is real and not yet fixed; fixed, a release fixes it; mitigated, not fixed, but something else stops it being used.
  • justification is required with not_affected and is one of CycloneDX’s nine: code_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control. OpenVEX’s five words (vulnerable_code_not_in_execute_path and the rest) are not taken: send the CycloneDX word. With any other status a justification is checked and then not stored.
  • note is free text of at most 4,000 characters, kept with the decision. Leave it out to keep the note the match already has; send null or "" to clear it.
  • fixedInReleaseId goes only with fixed: the id of the release that fixes the match, which must be the match’s own release or a later release of this product.
  • The audit entry records the key’s name and its visible prefix, the status and the product. It never records the key. It records no person: the decision is the key’s, so give a key that records decisions a name that says what runs it.
  • After a 500, read the match before sending again: the decision may have been stored. If it was, the same request answers 409 unchanged.
  • Each call counts against the same hourly limit as the key’s other requests.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • A 429 carries a Retry-After header in seconds.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

GET /api/v1/products/:id/releases/:releaseId

One release: its details, the enrichment summary, the open vulnerability counts and the addresses to download its inventory.

GET Needs the products:read scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Responses

200 — The release is one of this product’s. The release object has the same fields as one entry of GET …/releases except the four build fields. Shape: { release: { id, version, status, componentCount, createdAt, releasedAt: string | null, url }, summary: { componentsIn, identifiersAdded, subsystemsAdded, blobsFlagged, bootloaderAdded, unresolved } | null, openVulnerabilities: { total, byStatus, undecided, bySeverity, byConfidence, exploited } | null, downloads: { cyclonedx, spdx3, csv } }

{
  "release": {
    "id": "<release-id>",
    "version": "v1.2.0",
    "status": "current",
    "componentCount": 214,
    "createdAt": "2026-10-08T09:30:00.000Z",
    "releasedAt": "2026-10-01T00:00:00.000Z",
    "url": "<host>/app/products/<product-id>/releases/<release-id>"
  },
  "summary": {
    "componentsIn": 198,
    "identifiersAdded": 41,
    "subsystemsAdded": 3,
    "blobsFlagged": 2,
    "bootloaderAdded": true,
    "unresolved": 7
  },
  "openVulnerabilities": {
    "total": 3,
    "byStatus": {
      "open": 1,
      "in_triage": 1,
      "affected": 1
    },
    "undecided": 2,
    "bySeverity": {
      "critical": 0,
      "high": 2,
      "medium": 1,
      "low": 0,
      "none": 0,
      "unknown": 0
    },
    "byConfidence": {
      "high": 2,
      "medium": 1,
      "low": 0
    },
    "exploited": 0
  },
  "downloads": {
    "cyclonedx": "<host>/api/v1/products/<product-id>/releases/<release-id>/sbom?format=cyclonedx",
    "spdx3": "<host>/api/v1/products/<product-id>/releases/<release-id>/sbom?format=spdx3",
    "csv": "<host>/api/v1/products/<product-id>/releases/<release-id>/sbom?format=csv"
  }
}

400 — The id in the path is not a UUID. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "That is not a product id. It is a UUID like 3f2b8c1e-5a47-4d0e-9c1a-7b6e2d4f8a90; copy it from the \"Product id\" card on your product's overview page."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the products:read scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the products:read scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

404 — The release id is not one of this product’s releases, or is not a release id at all. This is the only 404 that names a release. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No release with that id on this product."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • summary is null when the files were read but enrichment did not run on them. openVulnerabilities is null while this release has not been checked yet, and is counted the same way as on GET …/products/:id, but for this release rather than only the current one. Not checked is not the same as none found.
  • downloads holds the full address of the SBOM route below for each format. Send the same key to it.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

GET /api/v1/products/:id/releases/:releaseId/sbom?format=cyclonedx|spdx3|csv

The release’s inventory as Declarable stores it, with its identifiers added and corrections applied, as a file to attach to your build artefacts.

GET Needs the products:read scope. Limit: a bucket of 120 requests per API key, refilled at two a minute.

Responses

200 — format=cyclonedx. The body is a CycloneDX JSON document, not the JSON envelope the other routes send. Content-Disposition: attachment; filename="<product-slug>-<version>.cdx.json". Content-Type: application/vnd.cyclonedx+json; charset=utf-8. Shape: a CycloneDX JSON document

curl -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -o inventory.cdx.json \
  "$DECLARABLE_URL/api/v1/products/<product-id>/releases/<release-id>/sbom?format=cyclonedx"

200 — format=spdx3. The body is an SPDX 3 JSON-LD document. Content-Disposition: attachment; filename="<product-slug>-<version>.spdx3.json". Content-Type: application/ld+json; charset=utf-8. Shape: an SPDX 3 JSON-LD document

curl -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -o inventory.spdx3.json \
  "$DECLARABLE_URL/api/v1/products/<product-id>/releases/<release-id>/sbom?format=spdx3"

200 — format=csv. One row per component, with the header Name, Version, Type, Supplier, PURL, CPE, Licenses, Confidence; several licences are joined with "; ". Content-Disposition: attachment; filename="<product-slug>-<version>.csv". Content-Type: text/csv; charset=utf-8. Shape: CSV, lines ended with CRLF

curl -H "Authorization: Bearer $DECLARABLE_API_KEY" \
  -o inventory.csv \
  "$DECLARABLE_URL/api/v1/products/<product-id>/releases/<release-id>/sbom?format=csv"

400 — The format query parameter is missing or is not one of the three, or the product id in the path is not a UUID. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "invalid_request",
    "message": "Give format=cyclonedx, format=spdx3 or format=csv."
  }
}

401 — The key is missing, malformed or unknown; it was revoked; it was replaced and its hand-over time has passed; or the person who made it is no longer an owner or admin of the organisation. The answer is the same for every cause. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "unauthorized",
    "message": "Send a valid, unrevoked API key as \"Authorization: Bearer dk_…\". A key also stops working when the person who made it is no longer an owner or admin of the organisation; make a new one in the product's Settings tab."
  }
}

403 — The key does not have the products:read scope. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key does not have the products:read scope."
  }
}

403 — The key is tied to one product and the id is any other id, including one that does not exist. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "forbidden",
    "message": "This key belongs to a different product."
  }
}

404 — Only an organisation-wide key can get this: the id is not one of its organisation’s products (the sample product included, which never appears to the API). A key bound to one product never reaches this check — any id but its own, real or not, gets 403 first. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No product with that id in this key's organisation."
  }
}

404 — Two cases, one code. The release id is not one of this product’s releases (the message is "No release with that id on this product."), or the release has no components to export (the message is "This release has no components to export."). Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "not_found",
    "message": "No release with that id on this product."
  }
}

413 — The release has more than 50,000 components. Nothing is sent. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "payload_too_large",
    "message": "This release has more than 50,000 components, which is too many to export in one file."
  }
}

429 — The key has used its 120 requests for the hour. The answer carries a Retry-After header. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "rate_limited",
    "message": "This key is over its limit of 120 requests an hour. Try again in 60 seconds."
  }
}

500 — An error on our side. Wait a minute and send the request again; the answer does not say what failed. Shape: { error: { code: string, message: string } }

{
  "error": {
    "code": "server_error",
    "message": "Something went wrong on our side. Try again in a minute."
  }
}
  • This is the inventory Declarable holds for the release, not the files you uploaded. It is the file to send to Dependency-Track or to keep with the build; /docs/ci#dependency-track shows the request.
  • On success the body is the file itself, not JSON. Only the error answers are the JSON shape above. Every answer carries Cache-Control: no-store.
  • GET …/releases/:releaseId gives the address of each format in its downloads field.
  • A key bound to one product is tied to it: for any id that is not its own, whether or not that id exists, it gets 403 "This key belongs to a different product.", so it can never learn which other products exist. An organisation-wide key has no such binding: it reaches any id that is one of its organisation’s products, and gets 404 "No product with that id in this key's organisation." for one that is not — so it can tell an existing product id in its organisation apart from one that is not, but learns nothing about other organisations.
  • 403 forbidden, before anything else and on every route, when the key's organisation is suspended: "Declarable has suspended this key's organisation. Nothing was uploaded or read. The organisation's owner can see why in the app."

What this API does not do

Left out on purpose, so you do not have to find out.

  • It does not export to Dependency-Track. Declarable reads SBOM files in; it does not push findings or an SBOM out to Dependency-Track. The CI guides show how to upload our files to Dependency-Track yourself (/docs/ci#dependency-track).
  • It is not a Transparency Exchange API. There is no endpoint that publishes your SBOMs for others to fetch by that standard.
  • It cannot file an Article 14 report. ENISA’s reporting platform is a web form; Declarable prepares the text for you to copy into it.