Export data to a warehouse
OSPulse answers supply-chain questions well. It does not know your headcount, your incident tickets, or which team owns which service. This guide covers pushing OSPulse datasets into your own warehouse so you can join them against everything else you hold — plus the lighter alternatives when a full pipeline is more than you need.
When a warehouse export is the right tool
Four reasons come up repeatedly:
- BI and executive reporting. Your board deck is built in your BI tool, not in ours. Exported datasets slot into the models you already have.
- Custom dashboards. Any cut of the data we did not anticipate — exposure by cost centre, drift by team, remediation velocity per squad — is a query once the data is local.
- Long-term retention. Platform retention is configurable
(
GET /api/v1/settings/retention) but finite. A warehouse copy is yours for as long as you keep it. - Joining with internal data. The highest-value questions are joins: which exposed repositories are in scope for the audit, which owners have the most overdue findings.
If none of those apply, skip to lighter alternatives at the end — scheduled reports or the analytics endpoints will cost you far less to operate.
The schema contract
Before configuring anything, read the contract. It is versioned, discoverable, and the thing your downstream queries actually depend on.
curl https://api.ospulse.app/api/v1/analytics/export/schema \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"contractVersion": "1.0.0",
"changelogUrl": "…",
"datasets": [
{
"kind": "Dependencies",
"tableName": "dependencies",
"columns": [
{ "name": "repository_id", "type": "String", "isKey": true, "isNullable": false },
{ "name": "package_id", "type": "String", "isKey": true, "isNullable": false },
{ "name": "declared_version", "type": "String", "isKey": true, "isNullable": false },
{ "name": "ecosystem", "type": "String", "isKey": false, "isNullable": false },
{ "name": "package_name", "type": "String", "isKey": false, "isNullable": false },
{ "name": "resolved_version", "type": "String", "isKey": false, "isNullable": true },
{ "name": "is_direct", "type": "Bool", "isKey": false, "isNullable": false },
{ "name": "first_seen_at", "type": "Timestamp", "isKey": false, "isNullable": false }
]
}
]
}Eight datasets are available. Each is one flat table, keyed for merge-by-key, and joinable
across datasets on repository_id and package_id:
kind | Table | Contents |
|---|---|---|
Repositories | repositories | Enrolled repositories, provider, default branch, last scan |
Dependencies | dependencies | Resolved graph edges, direct and transitive |
VulnerabilityExposures | vulnerability_exposures | Exposure rows with CVE, severity, CVSS, EPSS, KEV flag |
LicenceVerdicts | licence_verdicts | Per-version licence expression and policy verdict |
HealthScores | health_scores | Package health score snapshots with confidence |
PqcFindings | pqc_findings | Post-quantum classification per package version |
CraFindings | cra_findings | CRA classification per component, per product |
PolicyExceptions | policy_exceptions | Granted exceptions with justification and state |
Every table carries tenant_id and _contract_version. Timestamps are UTC. Actor
references are pseudonymised as opaque *_ref values — the export never ships user
identities into your warehouse.
Version your queries against the contract
Additive columns are a minor version bump; a drop, rename, or retype is a major bump with a
changelog entry. Pin your transformations to _contract_version and you get a clean signal
when something breaks rather than a silently wrong dashboard.
Configure a destination
Create the destination
A destination pairs a warehouse endpoint with a dataset selection, a serialisation format, and an optional schedule.
namestringRequiredHuman-readable label.
warehouseTypestringRequiredSnowflake, BigQuery, Synapse, or LocalSql (a Postgres stand-in for development and CI).
endpointConfigJsonstringRequiredWarehouse-specific connection configuration, serialised as a JSON string.
grantReferencestringRequiredYour reference for the credential grant. The secret itself is never stored in this field.
selectedDatasetsstring[]RequiredOne or more kind values from the table above. At least one is required.
formatstringRequiredParquet (default choice) or Csv.
scheduleCronstringOptionalCron expression for scheduled runs. Omit for manual-only.
grantMaterialBase64stringOptionalBase64-encoded credential material. Write-only — never returned by any endpoint.
curl -X POST https://api.ospulse.app/api/v1/analytics/export/destinations \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "analytics-snowflake-prod",
"warehouseType": "Snowflake",
"endpointConfigJson": "{\"account\":\"ex12345.eu-west-1\",\"database\":\"SECURITY\",\"schema\":\"OSPULSE\",\"warehouse\":\"LOAD_WH\"}",
"grantReference": "kv://ospulse/snowflake-loader",
"selectedDatasets": ["Repositories", "Dependencies", "VulnerabilityExposures"],
"format": "Parquet",
"scheduleCron": "0 3 * * *",
"grantMaterialBase64": "…"
}'{
"id": "e5a71c30-8b64-4d29-91f7-0c3e6a84b2d5",
"name": "analytics-snowflake-prod",
"warehouseType": "Snowflake",
"endpointConfigJson": "{\"account\":\"ex12345.eu-west-1\",…}",
"selectedDatasets": ["Repositories", "Dependencies", "VulnerabilityExposures"],
"format": "Parquet",
"scheduleCron": "0 3 * * *",
"enabled": true,
"createdAtUtc": "2026-07-28T11:04:18Z",
"updatedAtUtc": "2026-07-28T11:04:18Z",
"hasGrantMaterial": true
}Note what is not in that response: no grant material, no secret, no credential. You get
hasGrantMaterial as a boolean and nothing more.
Test the connection
Run the same validation a real run performs — SSRF checks and reachability — without writing anything.
curl -X POST "https://api.ospulse.app/api/v1/analytics/export/destinations/$DEST_ID:test" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{ "ok": true, "error": null }A rejected endpoint — a private address range, an unresolvable host — comes back as 400
with an ExportEndpointRejected problem type at create time, so misconfiguration surfaces
before you schedule anything.
Trigger a run
curl -X POST "https://api.ospulse.app/api/v1/analytics/export/destinations/$DEST_ID:run?mode=full" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"id": "b93f07e2-51ac-4d8b-a604-7e21c9f530da",
"destinationId": "e5a71c30-8b64-4d29-91f7-0c3e6a84b2d5",
"trigger": "Manual",
"status": "Pending",
"mode": "Full",
"schemaContractVersion": "1.0.0",
"startedAtUtc": null,
"finishedAtUtc": null,
"error": null,
"correlationId": "4c19a7f0-2d63-4b81-95ec-8f0a3d726b14"
}mode is full or incremental; omit it and the service defaults to incremental. Only one
run per destination may be in flight — a second trigger returns 409 with an
ExportRunInFlight problem type rather than racing.
Read run history
curl "https://api.ospulse.app/api/v1/analytics/export/destinations/$DEST_ID/runs?limit=10" \
-H "Authorization: Bearer $OSPULSE_TOKEN"[
{
"id": "b93f07e2-51ac-4d8b-a604-7e21c9f530da",
"destinationId": "e5a71c30-8b64-4d29-91f7-0c3e6a84b2d5",
"trigger": "Scheduled",
"status": "Succeeded",
"mode": "Incremental",
"schemaContractVersion": "1.0.0",
"startedAtUtc": "2026-07-28T03:00:04Z",
"finishedAtUtc": "2026-07-28T03:02:41Z",
"error": null,
"correlationId": "4c19a7f0-2d63-4b81-95ec-8f0a3d726b14",
"datasets": [
{ "kind": "Repositories", "rowCount": 142, "watermarkAdvancedTo": "2026-07-28T02:58:00Z" },
{ "kind": "Dependencies", "rowCount": 18734, "watermarkAdvancedTo": "2026-07-28T02:58:00Z" },
{ "kind": "VulnerabilityExposures", "rowCount": 903, "watermarkAdvancedTo": "2026-07-28T02:58:00Z" }
]
}
]limit defaults to 50 and is capped at 200. Single runs are readable directly from
GET /api/v1/analytics/export/runs/{runId}. Per-dataset row counts and watermarks are what
you reconcile against — if rowCount for a dataset is zero on an incremental run, nothing
changed, which is a valid answer.
Scheduling and incremental patterns
scheduleCron drives recurring runs; those arrive with trigger: "Scheduled". A sensible
default is a daily incremental with a periodic full re-seed.
How incremental works. Each dataset carries a watermark. An incremental run ships only rows past it and advances the watermark on success. Data lands in the target with a MERGE by natural key, so a re-run converges rather than duplicating.
Failure is safe. A failed run never advances the watermark. Replay it and an incremental resumes exactly from the last successful position:
curl -X POST "https://api.ospulse.app/api/v1/analytics/export/runs/$RUN_ID:replay" \
-H "Authorization: Bearer $OSPULSE_TOKEN"Only a Failed run is replayable — anything else returns 409 with an
ExportRunNotReplayable problem type. A replay is a fresh run against the same destination
and mode, so it appears in history in its own right.
When to force a full run. Re-seed with mode=full after adding a dataset to
selectedDatasets, after a major contract version bump, or if you have truncated the target
tables. Use PATCH /api/v1/analytics/export/destinations/{id} to change the selection,
schedule, format, or enabled flag; DELETE removes the destination together with its grant
reference and material.
A worked example: exposure by owning team
The join that pays for the whole pipeline. With repositories, dependencies, and
vulnerability_exposures landed, join them to your own service catalogue:
SELECT
t.team_name,
COUNT(DISTINCT e.repository_id) AS affected_repos,
COUNT(*) AS open_exposures,
SUM(CASE WHEN e.known_exploited THEN 1 ELSE 0 END) AS kev_exposures,
ROUND(AVG(e.cvss), 2) AS mean_cvss
FROM ospulse.vulnerability_exposures e
JOIN ospulse.repositories r ON r.repository_id = e.repository_id
JOIN internal.service_catalogue t ON t.repo_slug = r.name
WHERE e._contract_version LIKE '1.%'
AND e.severity IN ('High', 'Critical')
AND r.is_active
GROUP BY t.team_name
ORDER BY kev_exposures DESC, open_exposures DESC;known_exploited is the column to sort on. Counting critical CVEs produces a long list
nobody actions; counting exploited ones produces a short list somebody will.
One-off tenant archive
For a point-in-time archive of everything rather than a recurring analytical feed:
curl -X POST https://api.ospulse.app/api/v1/tenants/current/data-export \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"exportId": "d0c47b91-3fa2-4e86-9517-b6082ea3c74f",
"blobUri": "tenant-exports/…/d0c47b913fa24e869517b6082ea3c74f.json",
"integrityHash": "1b8e0f37c2a9…",
"bytes": 41582236,
"rowCounts": {
"repositories": 142,
"dependencies": 18734,
"vulnerabilityExposures": 903
},
"generatedAtUtc": "2026-07-28T11:22:07Z"
}This is the right tool for a migration, a retention snapshot, or a data-portability request —
not for a nightly feed. integrityHash and rowCounts let you verify the archive is
complete before you rely on it.
Lighter alternatives
A warehouse pipeline is real infrastructure. Two cheaper options cover most reporting needs.
Scheduled reports. Define a report once, generate it on a schedule or on demand, and download the artefact:
curl -X POST https://api.ospulse.app/api/v1/reports \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Monthly exposure summary",
"reportType": "ExposureSummary",
"format": "pdf",
"scheduleCron": "0 6 1 * *",
"recipientsJson": "[\"security@example.com\"]",
"filtersJson": "{\"severity\":[\"High\",\"Critical\"]}",
"enabled": true
}'POST /api/v1/reports/{reportId}/generate runs it now; GET /api/v1/generated-reports
lists what has been produced, and GET /api/v1/generated-reports/{generatedReportId}/download
fetches the file. Generated reports carry an integrityHash and a status, and a download
before generation completes returns 409.
Analytics endpoints. For dashboards you want live rather than warehoused,
GET /api/v1/analytics/trends returns the tenant trend series and
GET /api/v1/analytics/trends/{day}/contributors explains a specific day's movement.
GET /api/v1/analytics/benchmark compares you against the anonymised cohort — opt in first
via PUT /api/v1/analytics/benchmark/opt-in. For a periodic narrative,
POST /api/v1/analytics/quarterly-summary queues one and the matching
/download route returns it once ready.
