Integration API

Connect ShieldSphere to the systems you already use

Pull your safety data into a dashboard, push observations in from the field, or keep a roster in sync. One REST API, one key.

How it works

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.

Getting a key

An organization owner creates keys under Settings → Developers. You will see the key once, at creation.

Send it on every request:

curl https://app.shieldsphere.ai/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.

Start with locations

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.

Reading data

Seven collections, all paginated the same way:

  • /api/v1/employees
  • /api/v1/incidents: OSHA recordables
  • /api/v1/assets
  • /api/v1/chemicals
  • /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/profiles

Follow 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=100
Archived and unreviewed records never appear. Deleted incidents, submissions still awaiting a safety manager's review, retired assets, and terminated employees are all filtered out by default. Privacy-case incidents come back with the employee name withheld, as required by OSHA.

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

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

Sending records in

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://app.shieldsphere.ai/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.

Idempotency matters most on incidents. OSHA case numbers are sequential within a year, so a duplicate consumes a number that cannot be reclaimed. Deleting the extra record leaves a permanent gap in the 300 log.

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.

Sending inspections in

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://app.shieldsphere.ai/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 P1P4, 14, red/amber/green, and words like severe, major, and minor. Finding status is just as forgiving: closed, completed, and fixed all mean resolved.

A priority we do not recognize fails the whole request. We never guess a middle value, because a mis-mapped column that silently files every row as medium looks like it worked — and by the time the trend chart looks wrong, the history is already bad. You will see the rejection on your first row, while the mapping is still easy to fix.

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.

Hazard analysis from the field

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.

The image URL has to be directly fetchable. We do not follow redirects, so a signed URL that 302s to the real file will be rejected, because a redirect can point somewhere the original URL did not. It must return the image bytes on the first request, over https, from a public host, under 20MB, as JPEG, PNG, GIF, or WebP. If your files sit behind a redirect or need auth headers, resolve the URL on your side first.
Findings are AI-generated. Treat them as a trained observer's first pass, not a compliance determination. Have someone review before acting.

Webhooks

Rather than polling, register an endpoint under Settings → Developers and we will POST to it when something happens: a scan finishes, an incident is recorded, a record is created.

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.

Errors

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.

Full reference

The complete OpenAPI 3.1 spec is at /openapi-v1.json. Point your client generator at it.

npx openapi-typescript https://app.shieldsphere.ai/openapi-v1.json \
  -o src/shieldsphere.d.ts