Integration API
Pull your safety data into a dashboard, push observations in from the field, or keep a roster in sync. One REST API, one key.
The API runs in one direction on purpose: other systems can send us records and read our data. They cannot edit or delete anything.
That means there is never a question about which system holds the truth. Corrections happen in ShieldSphere, where the audit trail lives. A compliance record that a third-party integration could silently rewrite is not much of a record.
An organization owner creates keys under Settings → Developers. You will see the key once, at creation.
Send it on every request:
curl https://www.shieldspheresafety.com/api/v1/profiles \
-H "Authorization: Bearer ss_live_your_key_here"Each key carries specific permissions and can be limited to particular locations, which is useful if a system only covers one site. The Integration API is part of our enterprise plan; talk to us if you do not see the tab.
Almost everything is scoped to a location, so fetch those first and hold onto the ids.
GET /api/v1/profiles
{
"data": [
{
"id": "3f2c...",
"name": "North Plant",
"state": "OH",
"is_corporate": false
}
],
"has_more": false,
"next_cursor": null
}Skip anything with is_corporate: true. Those are reporting umbrellas, not real sites, and they will reject records.
Seven collections, all paginated the same way. Every one except locations can also be read one record at a time by id:
/api/v1/employees and /api/v1/employees/{id}/api/v1/incidents and /api/v1/incidents/{id}: OSHA recordables by default; add report_type=near_miss for near misses. Read by id returns either type./api/v1/assets and /api/v1/assets/{id}/api/v1/chemicals and /api/v1/chemicals/{id}/api/v1/inspections and /api/v1/inspections/{id}: inspections and their findings/api/v1/scans and /api/v1/scans/{id}: hazard scans and findings/api/v1/profilesFollow next_cursor until has_more is false. Cursors are opaque, so pass them back unchanged rather than building your own.
For an incremental sync, pass updated_since with the timestamp of your last run.
GET /api/v1/incidents?updated_since=2026-08-01T00:00:00Z&limit=100Records come back oldest first. That ordering is what makes updated_since reliable for incremental sync. A stable order means a cursor never skips or repeats a row while you page.
id as your identifier, not case_number. OSHA case numbers are sequential per location per year, so two of your sites will both have a 2026-001. Treat case_number as a display label.POST to the same collection paths. Always send an Idempotency-Key. If the request times out and you retry, the same key returns the original record instead of creating a second one.
curl -X POST https://www.shieldspheresafety.com/api/v1/incidents \
-H "Authorization: Bearer ss_live_..." \
-H "Idempotency-Key: your-system-incident-4471" \
-H "Content-Type: application/json" \
-d '{
"location_id": "3f2c...",
"date_of_injury": "2026-08-04",
"description_of_injury": "Laceration to left hand from sheet metal edge",
"employee_name": "J. Rivera",
"classification": "injury",
"days_away_flag": true,
"num_days_away": 3
}'location_id, date_of_injury, description_of_injury, employee_name, and classification are required. Classification is the OSHA 300 column M value: injury, skin_disorder, respiratory_condition, poisoning, hearing_loss, or other_illness.
You do not need the full OSHA 301 up front. Send what you have and finish the record in ShieldSphere; the regulation allows seven days.
If your team already runs inspections somewhere else — a spreadsheet tool, a forms app, your own system — send them over and they land in the dashboard alongside inspections done in ShieldSphere, counting toward the same trends.
An inspection and its findings go in a single request. There is no endpoint for adding findings afterward, on purpose: a record that arrives in pieces shows up half-built in your trend charts while you are still sending it.
curl -X POST https://www.shieldspheresafety.com/api/v1/inspections \
-H "Authorization: Bearer ss_live_..." \
-H "Content-Type: application/json" \
-d '{
"location_id": "3f2c...",
"inspection_date": "2026-08-21",
"inspector_name": "R. Delgado",
"source_app": "smartsheet",
"client_key": "sheet-4471-row-88",
"findings": [
{
"description": "Extension cord run through a doorway in the pack line",
"priority": "high",
"status": "open",
"corrective_action_recommendation": "Reroute overhead or install a floor channel"
},
{
"description": "Eyewash station blocked by pallets",
"priority": "critical",
"external_id": "row-88-b"
}
]
}'location_id and inspection_date are required, and every finding needs a description and a priority. Everything else is optional.
Priority does not have to match our wording. We accept the labels these systems actually use — critical, high, medium, low, plus P1–P4, 1–4, red/amber/green, and words like severe, major, and minor. Finding status is just as forgiving: closed, completed, and fixed all mean resolved.
Dates must be YYYY-MM-DD. We reject 03/04/2026 rather than guessing, since it means March 4 in one country and April 3 in another.
Imported inspections arrive finalized, because they record something that already happened. They count in your trends straight away. Pass "status": "draft" if you are staging something that is not finished yet — drafts stay out of the charts.
Send a client_key — your own row or record id — and a re-run of the same sync returns the original inspection instead of creating a duplicate. Unlike Idempotency-Key, it is stored on the record permanently, so it still protects you weeks later.
Read one back with GET /api/v1/inspections/{id} and you get the inspection with all of its findings. The list endpoint returns completed inspections by default; pass status=all to include drafts.
Send an observation with a photo and we analyze it against OSHA standards, using the same engine behind our scanner.
Analysis takes longer than an HTTP request should, so this returns a job id immediately.
POST /api/v1/observations
{
"image_url": "https://your-system.com/photos/abc.jpg",
"framework": "construction",
"state": "OH",
"location_id": "3f2c...",
"source": "procore",
"external_id": "obs-99812"
}
→ 202 Accepted
{
"data": {
"job_id": "8a1f...",
"status": "queued",
"_links": { "self": "/api/v1/jobs/8a1f..." }
}
}Poll the job until it finishes:
GET /api/v1/jobs/8a1f...
{
"data": {
"status": "succeeded",
"result": {
"scan_id": "c07d...",
"violation_count": 2,
"overall_risk_level": "high",
"violations": [
{
"hazard_category": "Fall Protection",
"description": "Worker on leading edge without personal fall arrest",
"priority": "critical",
"corrective_action": "Provide and require PFAS above 6 feet"
}
]
}
}
}Send source and external_id and we deduplicate for you. Replaying the same observation will not run a second analysis.
Rather than polling, register an endpoint under Settings → Developers and we will POST to it when something happens: a scan finishes, an incident or near miss is recorded, an inspection is finalized, a record is created.
Every event arrives in the same envelope. data is a short notice with ids and a few summary fields, so fetch the full record from the matching read endpoint. source says where the record came from: api, app (someone in ShieldSphere), import, portal, or public (a QR code or public link).
{
"event": "incident.created",
"created_at": "2026-09-29T14:02:11.000Z",
"source": "app",
"data": { "incident_id": "…", "case_number": "2026-014", "location_id": "…" }
}An endpoint receives records created through the API by default. To also receive records created in ShieldSphere, turn on Include records created in ShieldSphere for that endpoint. It is on for new endpoints. Incidents submitted through the employee portal or incident chat are sent when a safety manager approves them, not when they are submitted.
To get the full record, verify the signature, return a 2xx, then read it with an API key that has the matching read scope:
scan.completed → GET /api/v1/scans/{scan_id}inspection.created → GET /api/v1/inspections/{inspection_id}incident.created, near_miss.created → GET /api/v1/incidents/{incident_id}employee.created → GET /api/v1/employees/{employee_id}asset.created → GET /api/v1/assets/{asset_id}chemical.created → GET /api/v1/chemicals/{chemical_id}The key's scopes and location limits still apply, so a webhook can announce a record the key is not allowed to read. That read returns 404, the same as an unknown id.
Verify every request before trusting it:
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.SHIELDSPHERE_WEBHOOK_SECRET)
.update(`${req.headers['x-shieldsphere-timestamp']}.${rawBody}`)
.digest('hex')
// X-ShieldSphere-Signature carries the 'sha256=' prefix, so compare
// against the whole header value.
const presented = req.headers['x-shieldsphere-signature']
// Compare with crypto.timingSafeEqual, and reject anything
// whose timestamp is more than five minutes old.Use the raw request body, not a re-serialized object. Re-encoding changes the bytes and the signature will not match. You can send yourself a real test event from Settings → Developers to check your verification before going live. Return a 2xx quickly; we retry with backoff and pause an endpoint that keeps failing. Every delivery carries a stable X-ShieldSphere-Delivery id, so deduplicate on it: like every webhook system, delivery is at-least-once.
Every error has the same shape. Branch on code, not on the message.
{
"error": {
"code": "validation_failed",
"message": "location_id is required.",
"request_id": "f19c8a3e-..."
}
}Include the request_id if you contact support. It points at the exact request in our logs.
Rate limits are 120 reads and 60 writes per minute per key, in separate buckets, so a bulk sync will not throttle your dashboard. Watch X-RateLimit-Remaining and back off on a 429.
The complete OpenAPI 3.1 spec is at /openapi-v1.json. Point your client generator at it.
npx openapi-typescript https://www.shieldspheresafety.com/openapi-v1.json \
-o src/shieldsphere.d.ts