OSPulse

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

StatusMeaning in this APIWhat to do
200 OKThe request succeeded and the body carries the result. Used for reads, updates, and actions that complete inline.Parse the body.
201 CreatedA 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 AcceptedWork 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 ContentThe 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

StatusMeaning in this APIWhat to do
400 Bad RequestThe 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 UnauthorizedNo Authorization header, or the token is malformed, expired, or revoked.Check the header format and the token's validity. Do not retry unchanged.
403 ForbiddenThe 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 FoundThe 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 ConflictThe 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 LargeThe 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 TypeThe 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 EntityThe 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 RequestsRate limits were exceeded.Back off and retry — see Retrying safely and Rate limits.
500 Internal Server ErrorAn unhandled failure on our side. Always carries a correlationId.Retry with backoff. If it persists, report the correlationId.
502 Bad GatewayAn upstream dependency the endpoint relies on failed — a source-control provider, for instance, when listing available repositories.Retry with backoff.
503 Service UnavailableA 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 | nullOptional

A URI identifying the problem type. Most commonly a link to the relevant section of the HTTP specification. Informational — do not branch on it.

titlestring | nullOptional

A 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 | nullOptional

The HTTP status code, repeated in the body. Useful when the response is logged or forwarded without its status line.

detailstring | nullOptional

A human-readable explanation specific to this occurrence. The wording may change between releases — never parse it or match on it.

instancestring | nullOptional

The path of the request that produced the error, such as /api/v1/repositories/{repositoryId}/scan with the identifier substituted.

correlationIdstring (uuid) | nullOptional

Identifies this exact request inside our logs. Log it on every non-2xx response and quote it when you contact support.

errorsobject | nullOptional

Present 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.

json
{
  "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 camelCase names. 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.
  • errors may be absent when the failure is not field-level — a rule that spans several fields reports through detail instead.

Worked examples

401 — missing or invalid token

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

VerbNaturally idempotentRetry policy
GETYesRetry freely on 429, 500, 502, 503.
PATCHYes — the same body produces the same end stateRetry on transient failures.
PUTYes — replaces a value wholesaleRetry on transient failures.
DELETEYes — a repeat returns 404, which you can treat as successRetry on transient failures; treat a follow-up 404 as "already gone".
POST (create)No — a repeat may create a duplicateRetry only after confirming the first attempt did not succeed.
POST (action)Depends — cancelling a scan twice is harmless; triggering one twice queues twoCheck 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:

  1. Read the collection back. List the resource type and look for one matching what you were creating. GET /api/v1/policies after a failed POST /api/v1/policies, for example.
  2. If it exists, treat the original as succeeded and adopt the identifier you find.
  3. If it does not exist, retry with the same body.
  4. Log the correlationId from 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.

js
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:

  1. The correlationId from the response body — the single most valuable field.
  2. The method and full path, with identifiers left intact.
  3. The HTTP status and the approximate UTC timestamp.
  4. 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.

Next steps