OSPulse

Consume and triage alerts

An alert is only useful if someone acts on it. This guide covers the full loop: shaping alert rules so the right things fire, pulling and filtering alerts, triaging the underlying vulnerability exposures to a resolution, and routing everything into the tools your team already lives in — without drowning them.

Two related objects

An alert is a notification that something happened — a rule fired. An exposure is the durable record that a specific vulnerability affects a specific repository, and it is the thing you actually work through to a resolution. Alerts get acknowledged; exposures get triaged. See Alerts.

Shape the rules before you consume anything

The most effective filtering happens before an alert exists. Alert rules carry a minSeverity and a businessCriticalityFloor, so you can decide at source that a Medium finding on a retired service never becomes a notification at all.

bash
curl -X POST https://api.ospulse.app/api/v1/alert-rules \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Critical exposures on critical services",
        "trigger": "VulnerabilityExposure",
        "minSeverity": "Critical",
        "businessCriticalityFloor": "High",
        "channelsJson": "{\"slack\":[\"#supply-chain\"]}",
        "suppressionsJson": "{\"quietHours\":\"22:00-07:00Z\"}",
        "workspaceId": null,
        "teamId": null,
        "repositoryId": null,
        "policyId": null,
        "enabled": true
      }'
json
{
  "alertRuleId": "5b7d0e34-91af-4c26-83b1-6ed2f4a09c71",
  "name": "Critical exposures on critical services",
  "trigger": "VulnerabilityExposure",
  "minSeverity": "Critical",
  "businessCriticalityFloor": "High",
  "enabled": true,
  "createdAtUtc": "2026-07-28T11:40:03Z",
  "updatedAtUtc": "2026-07-28T11:40:03Z"
}
triggerstringRequired

The event class the rule listens for. Inspect the values already in use in your tenant with GET /api/v1/alert-rules.

minSeveritystringOptional

Floor for the finding's own severity — Critical, High, Medium, Low.

businessCriticalityFloorstringOptional

Floor for the affected repository's classification. The single most effective noise control you have.

channelsJsonstringOptional

JSON document describing delivery targets, stored as a string. suppressionsJson carries suppression windows the same way.

repositoryIduuidOptional

Scope the rule to one repository. Leave null for tenant-wide. workspaceId, teamId and policyId narrow the scope the same way.

Prefer several narrow rules over one broad one. Separate rules per severity band let you send Critical to PagerDuty and High to a Slack channel, and let you disable one band without losing the other — PATCH /api/v1/alert-rules/{alertRuleId} with "enabled": false is a reversible action; deleting the rule is not.

Classify repositories first

businessCriticalityFloor only works if repositories are classified. If everything in your tenant is at the default, every rule is effectively tenant-wide — see Import a repository.

Pull and filter alerts

bash
curl https://api.ospulse.app/api/v1/alerts \
  -H "Authorization: Bearer $OSPULSE_TOKEN"
json
{
  "items": [
    {
      "alertId": "c8f1a25d-73e0-4b98-a1c7-3f02d69e5a4b",
      "alertRuleId": "5b7d0e34-91af-4c26-83b1-6ed2f4a09c71",
      "trigger": "VulnerabilityExposure",
      "severity": "Critical",
      "status": "Open",
      "subject": "acme-engineering/checkout-api — CVE-2026-31887 in Newtonsoft.Json",
      "createdAtUtc": "2026-07-28T11:44:51Z"
    }
  ],
  "totalEstimate": 12
}

The list endpoint returns the alert collection for your tenant; filter by severity and status in your client, using the severity and status fields on each item. This is not a limitation to work around so much as a hint about where filtering belongs: if you find yourself discarding 90% of what you pull, tighten the rule instead of the client.

bash
curl -s https://api.ospulse.app/api/v1/alerts \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
| jq '[.items[] | select(.status == "Open" and (.severity == "Critical" or .severity == "High"))]'

An alert's detail view adds the evidence payload, the delivery attempts made for it, and who acknowledged it:

json
{
  "alertId": "c8f1a25d-73e0-4b98-a1c7-3f02d69e5a4b",
  "severity": "Critical",
  "status": "Open",
  "subject": "acme-engineering/checkout-api — CVE-2026-31887 in Newtonsoft.Json",
  "summaryJson": "{\"exposureId\":\"71c0b4e9-...\",\"cveId\":\"CVE-2026-31887\"}",
  "deliveryAttemptsJson": "[{\"channel\":\"slack\",\"status\":\"Delivered\"}]",
  "acknowledgedByUserId": null,
  "acknowledgedAtUtc": null,
  "createdAtUtc": "2026-07-28T11:44:51Z"
}

summaryJson and deliveryAttemptsJson are JSON documents carried as strings — parse them in a second step. deliveryAttemptsJson is where you look when someone says they never got the notification.

Acknowledging an alert takes it out of the open queue:

bash
curl -X PATCH https://api.ospulse.app/api/v1/alerts/c8f1a25d-73e0-4b98-a1c7-3f02d69e5a4b \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "Acknowledged" }'

Acknowledging says "seen, and owned". It does not fix anything — the exposure behind it is still open.

Triage the exposures

Alerts are the doorbell; exposures are the work. GET /api/v1/vulnerability-exposures returns every known exposure with the scoring you need to order them.

bash
curl "https://api.ospulse.app/api/v1/vulnerability-exposures?take=50&skip=0" \
  -H "Authorization: Bearer $OSPULSE_TOKEN"
json
{
  "items": [
    {
      "exposureId": "71c0b4e9-2a8d-4f13-b607-95ce80a4172f",
      "repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
      "vulnerabilityId": "3ae8c150-6d7b-45f2-9c81-0b4de7213a66",
      "isDirect": true,
      "isTransitive": false,
      "exploitabilityScore": 8.4,
      "businessImpactScore": 9.1,
      "priorityScore": 8.8,
      "status": "Open",
      "vulnerabilitySeverity": "Critical",
      "vulnerabilityCveId": "CVE-2026-31887",
      "vulnerabilityTitle": "Deserialisation of untrusted data",
      "vulnerabilityCvssScore": 9.8,
      "vulnerabilityEpssScore": 0.61,
      "vulnerabilityKnownExploited": true
    }
  ],
  "totalEstimate": 214,
  "take": 50,
  "skip": 0
}

Order the queue properly

Do not work down by CVSS. CVSS describes the vulnerability in the abstract; it knows nothing about your estate. Use the fields in this order:

  1. vulnerabilityKnownExploited — being exploited in the wild trumps everything else.
  2. priorityScore — OSPulse's combination of exploitability and your business impact.
  3. isDirect — a direct dependency you can bump today beats a transitive one that needs an upstream release.
  4. vulnerabilityEpssScore — likelihood of exploitation in the near term, for breaking ties between otherwise similar items.

GET /api/v1/vulnerability-exposures/{exposureId} adds depth, isReachable, assignedToUserId and createdTicketUrl. isReachable is the highest-value field in the whole payload when it is populated: a vulnerable function you never call is a different conversation from one on your request path.

Move it through states

Assign and link a ticket

Record the owner and the ticket on the exposure itself, so the OSPulse record and your tracker do not drift apart:

bash
curl -X PATCH https://api.ospulse.app/api/v1/vulnerability-exposures/71c0b4e9-2a8d-4f13-b607-95ce80a4172f \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "status": "InProgress",
        "assignedToUserId": "aa17f3d2-60b8-4c19-9d43-2e0587fa6c31",
        "ticketUrl": "https://acme.atlassian.net/browse/SEC-4412"
      }'
json
{
  "exposureId": "71c0b4e9-2a8d-4f13-b607-95ce80a4172f",
  "repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
  "vulnerabilityId": "3ae8c150-6d7b-45f2-9c81-0b4de7213a66",
  "isDirect": true,
  "depth": 0,
  "isReachable": true,
  "priorityScore": 8.8,
  "status": "InProgress",
  "assignedToUserId": "aa17f3d2-60b8-4c19-9d43-2e0587fa6c31",
  "createdTicketUrl": "https://acme.atlassian.net/browse/SEC-4412"
}

Send all three request fields — the transition replaces them as a set, so omitting assignedToUserId clears it.

Fix, then confirm with a scan

Bump the dependency, merge, and trigger a scan so the exposure is re-evaluated against the new manifest. Do not close it by hand before the scan agrees with you — see Scan in CI/CD.

Close it, or record why not

Once a scan confirms the fix, transition the exposure to its resolved state. If you are accepting the risk instead, that belongs in the policy layer as an expiring exception (POST /api/v1/policy-violations/{violationId}/exception) rather than as a quietly closed exposure. An exception has an owner, a reason and an expiry date; a closed record has none of those.

Acknowledge the originating alert

Close the loop with PATCH /api/v1/alerts/{alertId} so the alert queue reflects reality. A queue that only ever grows stops being read.

A 422 means the transition is not allowed

PATCH on an alert or an exposure returns 422 when the requested status is not a legal move from the current one — for example resolving something already resolved. Re-read the record before retrying rather than looping.

Route alerts into Slack, Jira or PagerDuty

There are two ways to get alerts out of OSPulse and into your tools.

Managed integrations, for the common destinations. OSPulse delivers them for you:

bash
curl -X POST https://api.ospulse.app/api/v1/integrations/slack/connect \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "displayName": "Supply chain channel",
        "webhookUrl": "https://hooks.slack.com/services/T000/B000/XXXX",
        "defaultChannel": "#supply-chain"
      }'

Microsoft Teams (/integrations/teams/connect) and email (/integrations/email/configure) work the same way. Verify any integration end-to-end before you rely on it with POST /api/v1/integrations/{integrationId}/test, which returns a status, a lastVerifiedAtUtc and a message describing the delivery attempt. GET /api/v1/integrations lists everything configured, with the same health fields.

Outbound webhooks, for everything else — Jira, PagerDuty, ServiceNow, your own router. Register a signed endpoint and OSPulse posts to it:

bash
curl -X POST https://api.ospulse.app/api/v1/integrations/webhooks \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "displayName": "Incident router",
        "url": "https://hooks.acme.internal/ospulse",
        "signingSecret": "'"$WEBHOOK_SIGNING_SECRET"'",
        "eventFilters": ["alert.created"]
      }'

Always verify the signature on delivery — see Webhooks for the scheme. An unauthenticated endpoint that pages your on-call is an outage waiting to be triggered by anyone who finds the URL.

A worked router

Where a managed integration does not exist, write a small relay: pull alerts, drop duplicates, and fan out by severity. This is the shape most teams end up with.

js
// Relay OSPulse alerts to PagerDuty (Critical) and Slack (everything else).
// Run on a schedule; keep the state file on a volume that survives restarts.
import { readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
 
const API = 'https://api.ospulse.app/api/v1'
const STATE = process.env.STATE_FILE ?? './.ospulse-routed.json'
const AUTH = { Authorization: `Bearer ${process.env.OSPULSE_TOKEN}` }
const JSON_HEADERS = { ...AUTH, 'Content-Type': 'application/json' }
 
const post = (url, body, headers = { 'Content-Type': 'application/json' }) =>
  fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
 
// Dedupe on the meaning of the alert, not its id: a rule that re-fires for the
// same subject on the next scan produces a new alertId but identical content.
const fingerprint = (a) =>
  createHash('sha256')
    .update(`${a.alertRuleId}|${a.severity}|${a.subject}`)
    .digest('hex')
    .slice(0, 16)
 
const res = await fetch(`${API}/alerts`, { headers: AUTH })
if (!res.ok) {
  const problem = await res.json().catch(() => ({}))
  throw new Error(`/alerts -> ${res.status} (correlationId ${problem.correlationId})`)
}
 
const seen = new Map(
  Object.entries(JSON.parse(await readFile(STATE, 'utf8').catch(() => '{}'))),
)
const open = (await res.json()).items
  .filter((a) => a.status === 'Open' && ['Critical', 'High'].includes(a.severity))
  .sort((a, b) => a.createdAtUtc.localeCompare(b.createdAtUtc))
 
let routed = 0
for (const alert of open) {
  const key = fingerprint(alert)
  if (seen.has(key)) continue // already told someone about this
 
  if (alert.severity === 'Critical') {
    await post('https://events.pagerduty.com/v2/enqueue', {
      routing_key: process.env.PAGERDUTY_ROUTING_KEY,
      event_action: 'trigger',
      dedup_key: key, // PagerDuty groups on this too
      payload: {
        summary: alert.subject,
        severity: 'critical',
        source: 'ospulse',
        custom_details: { alertId: alert.alertId, trigger: alert.trigger },
      },
    })
  } else {
    await post(process.env.SLACK_WEBHOOK_URL, {
      text: `*${alert.severity}* · ${alert.subject}`,
    })
  }
 
  // Acknowledge only after delivery succeeded, so a failed post retries next run.
  await fetch(`${API}/alerts/${alert.alertId}`, {
    method: 'PATCH',
    headers: JSON_HEADERS,
    body: JSON.stringify({ status: 'Acknowledged' }),
  })
  seen.set(key, Date.now())
  routed += 1
}
 
// Expire fingerprints after a fortnight so the state file stays bounded and a
// genuinely recurring problem is re-raised rather than suppressed forever.
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000
for (const [key, at] of seen) if (at < cutoff) seen.delete(key)
 
await writeFile(STATE, JSON.stringify(Object.fromEntries(seen), null, 2))
console.log(`routed ${routed} of ${open.length} open alerts`)

Three details in that script are the ones that matter:

  • Fingerprint on meaning, not id. Rule, severity and subject identify the problem. Deduplicating on alertId would page your on-call again on every scan.
  • Acknowledge only after a successful delivery. Otherwise a Slack outage silently swallows the alert.
  • Expire the dedupe state. Permanent suppression is how a real, unfixed problem disappears. Fourteen days re-surfaces anything still open.

Avoiding alert fatigue

The failure mode is not missing an alert. It is a channel so noisy that nobody reads it, and then missing the alert.

  • Route by severity, not by volume. Only Critical should interrupt a human. Everything else belongs in a queue read during working hours — paging on Medium trains people to ignore the pager, which costs you the next Critical.
  • Set businessCriticalityFloor on every rule. Most noise comes from repositories nobody would page for.
  • Use suppressionsJson for quiet hours on non-paging rules, rather than letting people mute the channel — a muted channel is muted permanently.
  • Digest the low bands. For Medium and below, one scheduled summary beats a stream of individual messages. The digest pattern in Monitor packages continuously applies directly.
  • Delete rules that have never produced an action. If a rule's alerts are consistently acknowledged with no matching exposure work, the rule is wrong — re-scope it or remove it.

Common pitfalls

Deduplicating on alertId. Every re-fire is a new id. Fingerprint the content.

Treating alerts as the system of record. Acknowledgement is a queue action. The exposure is the record — if it is still Open, the problem is still real, regardless of how tidy the alert list looks.

Closing exposures instead of granting exceptions. Accepted risk with no owner and no expiry re-surfaces as a surprise at audit time. Use POST /api/v1/policy-violations/{violationId}/exception.

Ignoring deliveryAttemptsJson. When someone insists they were never told, that field settles it in seconds.

Next steps