Scan your first repository
Work through this once, end to end, and you will have a repository connected to OSPulse, a completed scan, and a risk summary you can act on. Every step shows the request and the response you should expect, so you can tell at each point whether you are on track.
What you need
An OSPulse account, an API token exported as OSPULSE_TOKEN (see Authentication), and admin rights on at least one GitHub organisation or personal account. Roughly fifteen minutes, most of it waiting for the scan.
The path is the same regardless of provider: connect, import, scan, read. This walkthrough uses GitHub because its install flow is the one you can complete entirely from the API. GitLab, Azure DevOps and Bitbucket connections are created in the app and then behave identically from here on.
Check for an existing connection
A source-control connection is the link between OSPulse and one organisation on one provider. Before creating anything, see what your tenant already has:
curl https://api.ospulse.app/api/v1/source-control/connections \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"items": [],
"total": 0
}An empty list is expected on a fresh tenant — carry on to the next step. If total is already non-zero, note the connectionId of the connection you want and skip ahead to Step 3.
A populated response looks like this:
{
"items": [
{
"connectionId": "c41b8e70-2d5a-4d33-9f61-7b0c2e9a4d18",
"provider": "GitHub",
"displayName": "acme-engineering",
"organisationId": "24418803",
"organisationName": "acme-engineering",
"installationId": "56102934",
"status": "Active",
"lastSyncAtUtc": "2026-07-28T08:41:12.550Z"
}
],
"total": 1
}status tells you whether the connection is usable. Anything other than an active connection means the installation was removed or its credentials expired on the provider side, and repository listing will fail until it is reinstalled.
Connect GitHub
Install the OSPulse GitHub App on the organisation you want to scan. Do this from the Repositories screen in app.ospulse.app — the same panel that imports repositories also creates the connection. (If your tenant is new, the onboarding wizard walks you through this step first.) GitHub's own consent screen is unavoidable, and it is where you choose whether OSPulse sees all repositories or only a selected subset.
When GitHub redirects back, it hands over an installation_id. Register it with OSPulse:
curl -X POST https://api.ospulse.app/api/v1/source-control/connections/github/install \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"installationId": "56102934",
"displayName": "acme-engineering",
"externalOrganisationId": "24418803",
"externalOrganisationName": "acme-engineering"
}'{
"connectionId": "c41b8e70-2d5a-4d33-9f61-7b0c2e9a4d18",
"provider": "GitHub",
"displayName": "acme-engineering",
"organisationId": "24418803",
"organisationName": "acme-engineering",
"installationId": "56102934",
"status": "Active"
}Only installationId is required; the three display fields are cosmetic and are filled in from GitHub if you omit them. Registering the same installation twice returns 200 with the existing connectionId rather than creating a duplicate.
Keep connectionId to hand — the next two steps both need it.
Least privilege at install time
Grant the app access to the repositories you actually intend to monitor rather than the whole organisation. You can widen the selection in GitHub later and OSPulse will pick up the additions on the next sync; narrowing it afterwards is the awkward direction.
List the repositories you can import
Ask the connection what it can see. This calls the provider live, so it reflects the current installation grant rather than a cached snapshot:
curl "https://api.ospulse.app/api/v1/source-control/connections/c41b8e70-2d5a-4d33-9f61-7b0c2e9a4d18/available-repositories" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"items": [
{
"externalRepositoryId": "781204553",
"name": "checkout-api",
"fullName": "acme-engineering/checkout-api",
"defaultBranch": "main",
"visibility": "private",
"isArchived": false,
"isFork": false,
"language": "TypeScript",
"webUrl": "https://github.com/acme-engineering/checkout-api"
},
{
"externalRepositoryId": "781209914",
"name": "legacy-reports",
"fullName": "acme-engineering/legacy-reports",
"defaultBranch": "master",
"visibility": "private",
"isArchived": true,
"isFork": false,
"language": "Java",
"webUrl": "https://github.com/acme-engineering/legacy-reports"
}
],
"total": 2
}For your first scan, pick a repository with isArchived: false, a manifest OSPulse can read, and a moderate dependency count — a service, not a monorepo. Note its externalRepositoryId; that is the identifier the import takes, not the name.
Because this call reaches out to the provider, it can return 502 or 503 when the provider is unavailable, and 409 when the connection exists but its installation has been revoked on the provider side. Both are transient in the sense that the fix is on GitHub, not here.
Import the repository
Importing registers the repository with your tenant so OSPulse can scan and track it. The call takes an array, so you can import several at once:
curl -X POST https://api.ospulse.app/api/v1/repositories/import \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "c41b8e70-2d5a-4d33-9f61-7b0c2e9a4d18",
"externalRepositoryIds": ["781204553"]
}'{
"requested": 1,
"imported": 1,
"alreadyExisted": 0,
"notFoundUpstream": 0,
"repositories": [
{
"repositoryId": "9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90",
"fullName": "acme-engineering/checkout-api",
"defaultBranch": "main",
"wasNewlyImported": true
}
]
}connectionIdstring (uuid)RequiredThe source-control connection the repositories belong to.
externalRepositoryIdsstring[]RequiredProvider-side repository identifiers, taken from externalRepositoryId in the previous step.
workspaceIdstring (uuid)OptionalOptional workspace to file the repositories under. Defaults to the tenant's default workspace.
teamIdstring (uuid)OptionalOptional owning team. Drives who sees the repository's alerts and who policy violations are routed to.
The counters make the outcome unambiguous: requested is what you asked for, imported is what was created, alreadyExisted is what OSPulse was already tracking, and notFoundUpstream is what the connection could not resolve — almost always a stale externalRepositoryId or a repository that has since been removed from the installation grant.
The import is idempotent. Re-running it returns the same repositoryId with wasNewlyImported: false, so it is safe to put in a provisioning script.
Copy the repositoryId. Every remaining step uses it.
Trigger a scan
Scanning is asynchronous — the request queues the work and returns straight away rather than blocking for the duration. It takes no request body:
curl -X POST \
https://api.ospulse.app/api/v1/repositories/9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90/scan \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"scanId": "1d7e4b60-8c2a-4f95-a0d3-b52e9c771f48",
"repositoryId": "9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90",
"state": "Queued",
"queuedAtUtc": "2026-07-28T09:22:47.331Z"
}The 202 means accepted, not finished. Record scanId — it is how you follow the run.
A 400 here means the repository is not in a scannable state; a 404 means the repositoryId does not belong to your tenant. If you need to abandon a run, POST /api/v1/scans/{scanId}/cancel stops it, returning 409 if it has already finished.
Poll until it completes
Fetch the scan by id to watch it progress. Poll every few seconds, and back off rather than hammering it — a first scan of a repository with no prior history takes longer than subsequent incremental ones, because every package has to be resolved and scored from scratch.
curl https://api.ospulse.app/api/v1/scans/1d7e4b60-8c2a-4f95-a0d3-b52e9c771f48 \
-H "Authorization: Bearer $OSPULSE_TOKEN"A finished run looks like this:
{
"scanId": "1d7e4b60-8c2a-4f95-a0d3-b52e9c771f48",
"repositoryId": "9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90",
"repositoryName": "checkout-api",
"repositoryFullName": "acme-engineering/checkout-api",
"state": "Succeeded",
"trigger": "Manual",
"triggeredByUserId": null,
"queuedAtUtc": "2026-07-28T09:22:47.331Z",
"startedAtUtc": "2026-07-28T09:22:49.004Z",
"completedAtUtc": "2026-07-28T09:24:11.870Z",
"packagesScored": 412,
"resultSummary": "412 packages scored across 2 manifests",
"failureReason": null
}packagesScored is the number that tells you the scan actually found your dependency tree. If it comes back as 0 on a repository you know has dependencies, the manifest was not detected — check that lock files are committed on the default branch.
If state is Failed, failureReason carries the explanation. Read the scan log for the detail behind it:
curl "https://api.ospulse.app/api/v1/scans/1d7e4b60-8c2a-4f95-a0d3-b52e9c771f48/logs?take=50" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"scanId": "1d7e4b60-8c2a-4f95-a0d3-b52e9c771f48",
"state": "Succeeded",
"queuedAtUtc": "2026-07-28T09:22:47.331Z",
"startedAtUtc": "2026-07-28T09:22:49.004Z",
"completedAtUtc": "2026-07-28T09:24:11.870Z",
"packagesScored": 412,
"resultSummary": "412 packages scored across 2 manifests",
"failureReason": null,
"entries": [
{
"timestampUtc": "2026-07-28T09:22:49.004Z",
"action": "ScanStarted",
"actorType": "System",
"actorUserId": null,
"correlationId": "7c9b2e14-05af-4d6b-8e12-3a4f7d905c66",
"beforeJson": null,
"afterJson": "{\"state\":\"Running\"}"
}
],
"totalReturned": 1,
"truncated": false
}Pass sinceUtc to fetch only entries after a timestamp you have already seen — that is how you tail a running scan without re-reading the whole log. truncated: true means take cut the response short.
Read the risk summary
With the scan succeeded, the repository has a scored dependency set. Start with the summary — one call, the whole picture:
curl https://api.ospulse.app/api/v1/repositories/9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90/risk-summary \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"repositoryId": "9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90",
"dependencyCount": 412,
"overallHealthScore": 74,
"confidenceScore": 88,
"riskBreakdown": {
"minimal": 301,
"low": 62,
"medium": 34,
"high": 12,
"critical": 3
}
}Read the two scores together. overallHealthScore is the aggregate health of the dependency set; confidenceScore is how much evidence that number rests on. A high health score with low confidence means OSPulse found little signal — common for packages with sparse public history — and should be treated as provisional rather than reassuring. Health scoring explains what feeds each component.
The critical and high counts in riskBreakdown are your work queue. Three critical dependencies out of 412 is a tractable morning; it is also exactly the kind of thing that is invisible without a scan.
Drill into the dependencies
The summary tells you how many; this tells you which:
curl https://api.ospulse.app/api/v1/repositories/9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90/dependencies \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"items": [
{
"dependencyId": "5b3f9d21-6c48-4a70-9f2e-81d0c7a4b3e5",
"packageId": "a70e2c19-4d83-4f51-bb06-2e9c5d70a184",
"name": "left-pad",
"ecosystem": "Npm",
"version": "1.3.0",
"dependencyType": "Transitive",
"isDirect": false,
"manifestPath": "services/api/package-lock.json",
"health": {
"overallHealthScore": 21,
"confidenceScore": 76,
"riskLevel": "Critical"
}
}
],
"totalEstimate": 412
}Three fields decide what you do about each entry:
isDirect— a direct dependency is yours to bump. A transitive one has to be pulled through whatever depends on it, which is a different and usually slower conversation.manifestPath— tells you where in the repository it entered, which matters once a repo has more than one manifest.health.riskLevel— the banding thatriskBreakdowncounted. Sort by it and start at the top.
Take a packageId from anything critical and follow it into the package endpoints: GET /api/v1/packages/{packageId}/health for the component score breakdown, /vulnerabilities for known CVEs, /evidence for the raw signals behind the score, and /replacement-candidates when the answer is to stop using it rather than to upgrade it.
See how it fits together
The dependency graph shows the paths — how a critical transitive package actually got into your build, and through which direct dependency:
curl "https://api.ospulse.app/api/v1/repositories/9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90/dependency-graph?maxEdges=500" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"repositoryId": "9a2f0c85-31d4-4b17-b6e8-cc4a7f2e5d90",
"nodes": [
{
"packageId": "3c85f1a0-9e27-4b63-8d41-0a6f2c9e7b58",
"name": "express",
"ecosystem": "Npm",
"status": "Minimal"
},
{
"packageId": "a70e2c19-4d83-4f51-bb06-2e9c5d70a184",
"name": "left-pad",
"ecosystem": "Npm",
"status": "Critical"
}
],
"versions": [
{
"id": "b40d6e21-7a19-4c58-9f03-5d2b8a1e6c74",
"packageId": "3c85f1a0-9e27-4b63-8d41-0a6f2c9e7b58",
"version": "4.21.2"
},
{
"id": "e2b7c930-1f45-4a86-9d20-7c4e8b510a6f",
"packageId": "a70e2c19-4d83-4f51-bb06-2e9c5d70a184",
"version": "1.3.0"
}
],
"edges": [
{
"parentPackageId": "3c85f1a0-9e27-4b63-8d41-0a6f2c9e7b58",
"parentPackageVersionId": "b40d6e21-7a19-4c58-9f03-5d2b8a1e6c74",
"childPackageId": "a70e2c19-4d83-4f51-bb06-2e9c5d70a184",
"childPackageVersionId": "e2b7c930-1f45-4a86-9d20-7c4e8b510a6f",
"depth": 3
}
],
"edgeCount": 1,
"totalEdgeCount": 1,
"truncated": false,
"maxEdges": 500,
"renderingHint": "force-directed"
}nodes are packages, versions are the resolved versions, and edges join a parent version to a child version with the depth at which it appears. Walk the edges backwards from a critical child to find the direct dependency responsible — that is the one you upgrade.
Large repositories will exceed maxEdges; when they do, truncated is true and totalEdgeCount tells you the real size. Raise maxEdges if you are processing the graph programmatically, or leave it capped if you are drawing it.
You are done
You have a connected provider, an imported repository, a completed scan, and a scored dependency set with the risky packages identified. Everything after this is about making it continuous rather than manual.
Making it continuous
A one-off scan is a snapshot. The value comes from the next one, and the one after that:
- Rescan on change. The inbound webhook endpoints (
/api/v1/webhooks/github,/gitlab,/azure-devops,/bitbucket) let a push trigger a scan, so the picture stays current without anyone remembering to refresh it. - Fail the build on what matters. Policy gates turn a risk level into a merge decision instead of a dashboard someone might look at.
- Get told, don't go looking. Alert rules notify you when a score drops or a new vulnerability lands in something you already ship.
- Watch the ones you care about.
POST /api/v1/packages/{packageId}/watchputs a package on your watchlist so its changes surface even when your own dependency set has not moved.
