The API
Authenticating, choosing a workspace, the ten v1 endpoints, and what each refusal means.
Authenticating
Create a key in Settings → API keys, choose the smallest set of scopes the integration needs, and copy the secret. It is shown once — we store a hash of it, so we cannot show it again and cannot recover it.
Keys look like `sfk_live_…` or `sfk_test_…`. The prefix is deliberate and the pattern is published at /v1/meta, so you can add it to your own secret scanning and be told when one of ours ends up in a commit.
There is no key that never expires. A year is the longest, because a key with no expiry outlives the integration it was made for, the person who made it, and eventually the laptop it was copied to. You are warned 14 days and 3 days before one expires, rather than after.
A first call
curl https://api.secureflow.ruah.ai/v1/findings \
-H 'Authorization: Bearer sfk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'X-SecureFlow-Workspace: ws_01hxyzab9k'HTTP/1.1 200 OK
{
"findings": [
{
"id": "sf_fnd_01hxyzab9k",
"severity": "High",
"title": "SQL built by string concatenation",
"status": "open"
}
],
"total": 1,
"cursor": null,
"facets": { "severity": { "High": 1 } }
}Which workspace
Findings, scans, targets, fixes and proofs all belong to a workspace. A person picks one in the switcher and it stays picked; a key has no switcher, so it names one on each call with `X-SecureFlow-Workspace`.
A key scoped to exactly one workspace may leave the header out — there is nothing to choose between. A key that may act in several and sends no header is refused rather than given one of them: picking the first would work in every test and quietly answer with the wrong workspace in an account that has two.
Without the header, on a key that may act in several workspaces
curl https://api.secureflow.ruah.ai/v1/findings -H 'Authorization: Bearer sfk_live_…'HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://secureflow.ruah.ai/problems/workspace-not-selected",
"title": "Choose a workspace",
"detail": "That endpoint reads one workspace. Name it with the X-SecureFlow-Workspace header, or use a key scoped to a single workspace."
}What a key may do
A key is a narrowing of one person’s access, never a grant of its own. Two things are checked on every call: that a scope on the key covers the action, and that the person the key belongs to still has the permission. The second is why a key stops working when its owner’s role changes or they leave — not whenever somebody remembers to revoke it.
You cannot give a key more than you have. A scope you do not hold yourself is refused at creation rather than silently dropped, because a key that looks like what you asked for and fails on the one call that needed the missing scope fails days later, in somebody’s CI.
Every scope says the worst thing somebody holding it can do. That sentence is on the creation screen too, and it is the same sentence — a list of verbs is not enough to decide by.
| Scope | What it allows | Worst case |
|---|---|---|
| findings:read | List and read findings, with their severity, location and history. | A complete inventory of your unfixed vulnerabilities, with file paths. This is the most sensitive read scope in the product — treat a key holding it the way you would treat a database dump. |
| findings:write | Change a finding’s status: triage, suppress, accept the risk, mark a false positive. | Every open finding marked a false positive, which makes the dashboard green without changing anything about your systems. The audit log records who did it and the findings can be restored. |
| scans:read | List scans and read their status and results. | Knowledge of what is scanned, how often, and what was found. |
| scans:run | Start a scan on an authorised target, and cancel one. | Your monthly assessment allowance spent, and load on the systems being scanned. It cannot scan a target you have not authorised — that gate is separate and this scope does not lift it. |
| targets:read | List the applications, repositories and endpoints being monitored. | A map of your estate: what you run, where, and how critical you consider it. |
| targets:write | Add, update and archive targets. | A target archived, which stops it being monitored. Nothing already found is deleted, and archiving is reversible. |
| fixes:read | List fixes and read their proposed changes and state. | The proposed patches, which describe the vulnerability precisely enough to exploit it. |
| fixes:write | Request a fix, and approve or decline a proposed one. | A fix approved, which opens a pull request against your repository. It cannot merge it — that is your repository’s own protection, and we deliberately do not ask for the permission that would let us. |
| proofs:read | List and download proofs of fix, including their manifests. | Your evidence chain, which is designed to be shared — a proof is verifiable by anybody and reveals what was fixed rather than what is broken. |
| reports:read | List and download generated reports. | Your executive and compliance reports, which summarise your posture. |
| reports:create | Generate a report on demand. | Generated reports you did not ask for. Noise rather than harm. |
| packages:read | Read the package policy, its decisions and the exception list. | Knowledge of which packages you allow and which exceptions you have granted. |
| packages:evaluate | Ask whether an exact package version may be installed, as the CI gate action does, and report what went through while SecureFlow was unreachable. | Entries in the package activity feed, attributed to CI. It cannot bypass the policy or grant an exception, and it reads no screen. |
| tickets:read | List service tickets and read their messages and SLA state. | The conversation with our engineers, including what is currently broken. |
| tickets:write | Raise a ticket and reply to one. | Tickets raised in your name. Noise, and a response clock started. |
| billing:read | Read the plan, usage and invoices. | What you pay and what you use. No payment method is ever readable through the API. |
The v1 endpoints
v1 is read-only, and this is the whole of it. The list is generated from the same OpenAPI document the server is tested against, so it cannot describe a path that does not answer.
Some scopes permit writes — running a scan, approving a fix — because they describe the shape the API is growing into. A key holding one is refused with `refusal: "not_in_v1"` and a sentence saying so, rather than a 403 that reads like a permissions problem you could fix by ticking another box.
| Path | Needs | Returns |
|---|---|---|
| GET /v1/targets | target.read | List targets |
| GET /v1/targets/{id} | target.read | Get a target |
| GET /v1/scans | scan.read | List scans |
| GET /v1/scans/{id} | scan.read | Get a scan |
| GET /v1/findings | finding.read | List findings |
| GET /v1/findings/{id} | finding.read | Get a finding |
| GET /v1/fixes | fix.read | List fixes |
| GET /v1/fixes/{id} | fix.read | Get a fix |
| GET /v1/evidence/proofs | proof.read | List proofs |
| GET /v1/evidence/proofs/{id} | proof.read | Get a proof |
Paging
**Findings are paged; the other four lists are not.** `/findings` takes `limit` up to 200 and returns a `cursor` — pass it back as `cursor` until it comes back null. Targets, scans, fixes and proofs return everything that matches, because a workspace holds tens of those rather than tens of thousands.
The asymmetry is visible in the response rather than hidden behind a uniform envelope, because a client written to follow a cursor that is never there works by accident, and one written to expect a full list from findings quietly misses most of them.
A cursor is opaque. Do not construct one, and do not assume it survives a change of `limit`. Cursors rather than offsets, because the list changes while you are reading it: a scan finishes, somebody triages three. An offset skips or repeats rows when that happens, and there is nothing in the response to tell you it did.
The second page of findings
curl 'https://api.secureflow.ruah.ai/v1/findings?limit=100&cursor=eyJpZCI6InNmX2ZuZF8wMWh4In0' \
-H 'Authorization: Bearer sfk_live_…' \
-H 'X-SecureFlow-Workspace: ws_01hxyzab9k'What each list returns
Every response is an envelope with a named key rather than a bare array, and some carry something alongside the list that the screen needs: how much of your plan’s target capacity is used, how many assessments remain this period.
This table is generated from the same OpenAPI document the server is tested against, and that test runs in both directions — a key the server sends that the document omits fails, and so does one the document promises and the server does not send.
| Path | Returns | Paged |
|---|---|---|
| GET /v1/targets | `targets`, `capacity` | No — the whole list |
| GET /v1/targets/{id} | `target` | No — the whole list |
| GET /v1/scans | `scans`, `allowance` | No — the whole list |
| GET /v1/scans/{id} | `scan`, `stages`, `progress` | No — the whole list |
| GET /v1/findings | `findings`, `total`, `cursor`, `facets` | Yes, by `cursor` |
| GET /v1/findings/{id} | `finding`, `history`, `comments`, `related` | No — the whole list |
| GET /v1/fixes | `fixes` | No — the whole list |
| GET /v1/fixes/{id} | `fix`, `proposal`, `history`, `held`, `options` | No — the whole list |
| GET /v1/evidence/proofs | `proofs` | No — the whole list |
| GET /v1/evidence/proofs/{id} | `proof`, `shares` | No — the whole list |
Rate limits
Two limits, and both apply: 10 requests a second per key with a burst of 50, and 50 a second across every key in your organisation with a burst of 200. Making another key does not raise the second one, which is rather the point of having it.
The headers are on every response, not only on a 429. A client that only learns its budget when it runs out hammers until it is refused and then backs off — which is the behaviour the limit exists to prevent, caused by the limit.
A 429 says how long to wait. Wait that long; retrying sooner spends budget you do not have.
What every response carries
curl -i https://api.secureflow.ruah.ai/v1/findings -H 'Authorization: Bearer sfk_live_…'HTTP/1.1 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 8
X-RateLimit-Reset: 1Errors, and what each refusal means
Errors are RFC 9457 problem documents: a stable machine-readable `type` you can branch on, and a `detail` written for a person. Branch on the `type`, never on the wording — the wording gets improved, the type does not change.
A 403 from a key carries a `refusal` saying which of three things happened, because the three have different remedies and a single "Not permitted" sends you to the wrong one.
| refusal | What happened | What to do |
|---|---|---|
| out_of_scope | No scope on this key covers that action. | Create a key with the scope. You can only give a key scopes you hold yourself. |
| not_in_v1 | The scope covers it, and v1 serves no endpoint that does it. | Nothing, for now. Do it in the application; the endpoint is coming. |
| needs_a_person | The action requires somebody to re-authenticate. | Do it in the application. A key cannot satisfy a second factor, and that is deliberate. |
A write scope on a read-only API
curl -X POST https://api.secureflow.ruah.ai/v1/scans -H 'Authorization: Bearer sfk_live_…'HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://secureflow.ruah.ai/problems/forbidden",
"title": "Not permitted",
"detail": "The public API is read-only in v1. This key holds the scope for that action, but no v1 endpoint performs it.",
"refusal": "not_in_v1"
}Versioning
The version is in the path. v1 will not have a field removed or a meaning changed under it — anything new is additive, so a client written today keeps working.
When a version is retired it gets twelve months and a `Sunset` header on every response, from the day it is announced rather than the month before it goes.
Every response carries a request id. Quote it if you need to ask us what happened.