Errors
The OSPulse API signals failure with an HTTP status code and an RFC 7807 problem details body. The status tells your code what class of failure occurred; the body tells a human which one. This page documents both, plus the retry strategy that keeps a client well behaved under load.
Status codes
Success
| Status | Meaning in this API | What to do |
|---|---|---|
200 OK | The request succeeded and the body carries the result. Used for reads, updates, and actions that complete inline. | Parse the body. |
201 Created | A resource was created. Returned by creation endpoints such as POST /api/v1/policies and POST /api/v1/alert-rules. | Store the identifier from the body. |
202 Accepted | Work was queued and will complete asynchronously. Returned by scan triggers, SBOM import and export, report generation, evidence archives, and warehouse export runs. | Keep the returned identifier and poll the matching detail endpoint. |
204 No Content | The request succeeded and there is nothing to return. Returned by DELETE and by a few PATCH settings endpoints. | Do not attempt to parse a body. |
Failure
| Status | Meaning in this API | What to do |
|---|---|---|
400 Bad Request | The request was malformed or the arguments were unusable — bad JSON, an unparseable query parameter, an out-of-range take, an unsupported export format. | Fix the request. Do not retry unchanged. |
401 Unauthorized | No Authorization header, or the token is malformed, expired, or revoked. | Check the header format and the token's validity. Do not retry unchanged. |
403 Forbidden | The token is valid but not permitted this operation — insufficient role scope, or a feature not enabled for the tenant's plan. | Use a token with the required scope, or check plan entitlements. Do not retry unchanged. |
404 Not Found | The resource does not exist, or it exists in another tenant. The API does not distinguish the two — that would leak the existence of other tenants' data. | Verify the identifier and the tenant the token belongs to. |
409 Conflict | The request is valid but conflicts with current state — cancelling a scan that already finished, downloading a report that has not been generated, triggering a scan that is already running, changing a plan in a way the current subscription forbids. | Re-read the resource's state and decide. Retrying immediately will conflict again. |
413 Payload Too Large | The body exceeded the accepted size for that endpoint. Batch endpoints such as POST /api/v1/ide/verdicts:batch enforce a limit. | Split the payload into smaller batches. |
415 Unsupported Media Type | The Content-Type is not accepted — JSON sent to an upload endpoint, or a file format the endpoint does not parse. | Send the media type documented for the endpoint. |
422 Unprocessable Entity | The request was well formed and parseable, but failed validation or a domain rule. Carries an errors dictionary when the failure is field-level. | Correct the offending fields. Do not retry unchanged. |
429 Too Many Requests | Rate limits were exceeded. | Back off and retry — see Retrying safely and Rate limits. |
500 Internal Server Error | An unhandled failure on our side. Always carries a correlationId. | Retry with backoff. If it persists, report the correlationId. |
502 Bad Gateway | An upstream dependency the endpoint relies on failed — a source-control provider, for instance, when listing available repositories. | Retry with backoff. |
503 Service Unavailable | A dependency is temporarily unavailable or a subsystem is degraded — the billing provider, a worker queue. | Retry with backoff. Check status if it persists. |
400 or 422?
Both indicate a rejected request, and the split follows the parse-then-validate boundary.
400 means we could not make sense of the input at all. 422 means we understood it and it
broke a rule. Handle them with the same code path — neither is retryable without a change.
Problem details body
Every error response uses the same shape, defined by RFC 7807.
typestring | nullOptionalA URI identifying the problem type. Most commonly a link to the relevant section of the HTTP specification. Informational — do not branch on it.
titlestring | nullOptionalA short, human-readable summary of the problem type. Stable for a given type, and safe to show in a UI. Not intended for machine comparison.
statusinteger | nullOptionalThe HTTP status code, repeated in the body. Useful when the response is logged or forwarded without its status line.
detailstring | nullOptionalA human-readable explanation specific to this occurrence. The wording may change between releases — never parse it or match on it.
instancestring | nullOptionalThe path of the request that produced the error, such as
/api/v1/repositories/{repositoryId}/scan with the identifier substituted.
correlationIdstring (uuid) | nullOptionalIdentifies this exact request inside our logs. Log it on every non-2xx response and quote it when you contact support.
errorsobject | nullOptionalPresent on validation failures. Maps a field name to an array of message strings. See Validation errors.
Branch on status, not on prose
title and detail are written for humans and are refined over time. Your control flow
should depend on the HTTP status code and, for validation failures, on the keys of errors.
Validation errors
When one or more fields fail validation, the response is 422 (or 400 for a
structurally invalid request) and adds an errors dictionary. Keys are field names; values
are arrays, because a single field can fail several rules at once.
{
"type": "https://tools.ietf.org/html/rfc4918#section-11.2",
"title": "One or more validation errors occurred.",
"status": 422,
"detail": "The request could not be processed.",
"instance": "/api/v1/api-tokens",
"correlationId": "b7e1c9a4-5f32-4e08-91d2-6c0a8f4b71de",
"errors": {
"name": ["The name field is required."],
"expiresAtUtc": [
"The expiry must be in the future.",
"The expiry must be within the maximum token lifetime."
]
}
}Rules for consuming errors:
- Keys mirror the request body, using the same
camelCasenames. Nested fields use dotted paths (schedule.cadence) and array members use indexes (repositories[0].url). - Never assume a key exists. New validation rules add new keys. Iterate the dictionary rather than reading fixed keys.
- Messages are for display. Show them next to the offending input; do not parse them.
errorsmay be absent when the failure is not field-level — a rule that spans several fields reports throughdetailinstead.
Worked examples
401 — missing or invalid token
{
"type": "https://tools.ietf.org/html/rfc7235#section-3.1",
"title": "Unauthorized",
"status": 401,
"detail": "The supplied API token is missing, expired, or revoked.",
"instance": "/api/v1/packages",
"correlationId": "1c4f7a09-3ee1-4a5b-8d10-9f2b6c3e5a77"
}Check that the header is exactly Authorization: Bearer {token} — a missing Bearer
prefix, a stray newline from a shell variable, or a truncated token all produce this. Then
confirm the token has not expired or been revoked with GET /api/v1/api-tokens.
403 — valid token, insufficient permission
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.3",
"title": "Forbidden",
"status": 403,
"detail": "This token's role scope does not permit creating policies.",
"instance": "/api/v1/policies",
"correlationId": "5a0d2e13-8b47-4c92-a6f3-0e7d1b8c4f25"
}A 403 never becomes a 200 on retry. Either the token's role scope is too narrow — create
a new token with the scope you need — or the operation belongs to a plan tier the tenant is
not on.
404 — unknown or out-of-tenant identifier
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"detail": "Repository '3f8a1c27-9c1b-4e2a-8f6d-2a7c5e9b0d14' was not found.",
"instance": "/api/v1/repositories/3f8a1c27-9c1b-4e2a-8f6d-2a7c5e9b0d14",
"correlationId": "7f2b6c81-4a19-4de0-b3c7-8d5e1f0a9c34"
}A resource belonging to a different tenant returns 404, not 403. If an identifier that
worked yesterday returns 404 today, the resource was probably deleted — check the
audit log.
409 — conflicting state
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.8",
"title": "Conflict",
"status": 409,
"detail": "The scan has already completed and cannot be cancelled.",
"instance": "/api/v1/scans/c41d9b8e-7a52-4f0d-9b3e-1d6a2f7c8e40/cancel",
"correlationId": "e3c8a5b2-0f64-4711-9a2d-5b7c3e1f8d06"
}409 is the API telling you the world moved. Re-read the resource before deciding what to
do; a blind retry produces the same conflict.
429 — rate limited
{
"type": "https://tools.ietf.org/html/rfc6585#section-4",
"title": "Too Many Requests",
"status": 429,
"detail": "The request rate limit has been exceeded.",
"instance": "/api/v1/packages",
"correlationId": "a91f7d06-2b3e-4c58-8f01-6d4a2e9b7c13"
}Rate limits depend on plan; the applicable limits and remaining allowance are reported in
the RateLimit-* response headers, and a Retry-After header is included where one applies.
Honour Retry-After when present, and fall back to exponential backoff when it is not.
Retrying safely
There is no Idempotency-Key header in this API. Safe retries come from choosing the right
verb and reconciling state, not from a deduplication token.
Which requests are safe to repeat
| Verb | Naturally idempotent | Retry policy |
|---|---|---|
GET | Yes | Retry freely on 429, 500, 502, 503. |
PATCH | Yes — the same body produces the same end state | Retry on transient failures. |
PUT | Yes — replaces a value wholesale | Retry on transient failures. |
DELETE | Yes — a repeat returns 404, which you can treat as success | Retry on transient failures; treat a follow-up 404 as "already gone". |
POST (create) | No — a repeat may create a duplicate | Retry only after confirming the first attempt did not succeed. |
POST (action) | Depends — cancelling a scan twice is harmless; triggering one twice queues two | Check the resource's state before repeating. |
Retrying a POST
When a POST fails with a network timeout or a 5xx, the request may still have been
processed — the failure could have occurred on the response leg. Before retrying:
- Read the collection back. List the resource type and look for one matching what you
were creating.
GET /api/v1/policiesafter a failedPOST /api/v1/policies, for example. - If it exists, treat the original as succeeded and adopt the identifier you find.
- If it does not exist, retry with the same body.
- Log the
correlationIdfrom the failed attempt so an ambiguous case can be resolved with support later.
For 202 Accepted work such as scans and exports, the same principle applies: list the
recent runs for the resource before queuing a second one, because a duplicate job costs
quota and produces confusing output.
Backoff with jitter
Retry only on 429, 500, 502, 503, and transport-level failures. Never retry 400,
401, 403, 404, 409, 413, 415, or 422 — the outcome will not change.
Use exponential backoff with full jitter, so a fleet of clients recovering from an outage does not resynchronise into a thundering herd.
const RETRYABLE = new Set([429, 500, 502, 503])
async function request(url, init = {}, attempts = 5) {
for (let attempt = 0; attempt < attempts; attempt++) {
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${process.env.OSPULSE_TOKEN}`,
...init.headers,
},
})
if (res.ok || !RETRYABLE.has(res.status)) return res
if (attempt === attempts - 1) return res
// Honour Retry-After when the server supplies one.
const retryAfter = Number(res.headers.get('retry-after'))
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.random() * Math.min(30_000, 500 * 2 ** attempt) // full jitter
await new Promise((r) => setTimeout(r, backoff))
}
}Cap the total, not just the attempts
Bound retries by wall-clock time as well as attempt count. A background job that retries for minutes is usually better than one that gives up in two seconds; an interactive request that does the same is a hung UI.
Reporting a problem
When an error is not explained by your request, send us:
- The
correlationIdfrom the response body — the single most valuable field. - The method and full path, with identifiers left intact.
- The HTTP status and the approximate UTC timestamp.
- What you expected instead.
Never send the token itself. Email contact@fortitude-omnis.group, or see
Support for the other routes in. If several requests failed, a handful of
correlationId values from across the window is more useful than one.
