NAICS Calculate
Status:
stableCategory:naicsAdded:2026-04-27Last reviewed:2026-04-27
Summary
Computes an employer's recordable-injury incidence rates from OSHA-300-shaped case counts and exposure hours, and — when a NAICS code is supplied — looks up the national BLS benchmark for that industry and returns a side-by-side comparison.
The contract is deliberately permissive. Real OSHA-300 reporting is
messy: payroll hours are sometimes unavailable; not every cell in the
case-count grid is populated; total recordable rarely equals the sum of
its parts on the first try. Rather than reject these inputs, the
endpoint emits derivations (what it filled in for you) and warnings
(what looked off) and returns a 200 with whatever rates it could
compute.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/naics/calculate |
| Full URL | https://engine.tripod-health.com/api/naics/calculate |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (each call is an independent computation; no storage side effect today) |
| Rate-limited | Yes |
Authentication
Send a valid API key in the apikey header. See
authentication.md.
Request
Headers
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | Must be application/json. |
X-Request-ID | No | Optional correlation ID, up to 128 characters. Echoed in the response body's request_id and the X-Request-ID response header. |
Path / query parameters
None.
Body
{
"naics_code": "236220",
"ownership": "private",
"total_recordable_cases": 12,
"days_away_cases": 5,
"transfer_restriction_cases": 3,
"other_recordable_cases": 4,
"total_hours_worked": 250000,
"num_employees": 125,
"avg_hours_per_week": 38.5,
"num_weeks": 52
}
Body fields
All fields are optional. The endpoint computes whatever it can with
what is supplied and surfaces gaps as warnings in the response.
| Field | Type | Required | Description |
|---|---|---|---|
naics_code | string | No | 2–6 digit NAICS code. When supplied, the engine looks up the benchmark server-side and includes a comparison block. When omitted, the response carries computed rates only and comparison is {}. |
ownership | string | No | One of private, state_government, local_government. Defaults to private. Ignored when naics_code is omitted. |
total_recordable_cases | integer | No | OSHA 300 column total. Engine derives any one of total / dart / other from the other two when only two are supplied. |
days_away_cases | integer | No | Days-away component of DART. |
transfer_restriction_cases | integer | No | Job-transfer / restricted-duty component of DART. |
other_recordable_cases | integer | No | Total recordable minus DART (medical-treatment-only cases). |
total_hours_worked | number | No | Exact payroll hours over the reporting period. Preferred input — most accurate denominator. |
num_employees | integer | No | Used to estimate hours when total_hours_worked is missing: num_employees × avg_hours_per_week × num_weeks. |
avg_hours_per_week | number | No | Required for hours estimation. Range (0, 168]. |
num_weeks | integer | No | Defaults to 52. Range [1, 52]. |
Hours: exact vs. estimated
The endpoint accepts hours by either of two paths:
- Exact —
total_hours_workeddirectly. Always preferred when available. - Estimated —
num_employees × avg_hours_per_week × num_weeks, used only whentotal_hours_workedis omitted (or zero) and the three estimation inputs are present.
When estimation kicks in, the response carries
hours_estimated: true, a derivation describing how the number was
computed, and a warning recommending exact payroll hours for production
use.
Case-count derivation
Total recordable = DART + other-recordable (by definition) and DART =
days-away + transfer/restriction. The endpoint will derive any one of
these from the other two. If you supply total and days_away and
transfer_restriction only, it derives DART and other-recordable. If
your three numbers are mutually inconsistent, the request still
succeeds but a consistency-check warning fires.
De-identification requirement
Like every /api/* endpoint, the request body passes through the
de-identification filter before any business logic. The fields named
above carry no PHI; do not add free-form text or identifiers to the
body. See
conventions.md § De-identification boundary.
Response
Success — 200 OK
{
"employer_rates": {
"total_recordable_rate": 9.6,
"dart_rate": 6.4,
"days_away_rate": 4.0,
"transfer_restriction_rate": 2.4,
"other_recordable_rate": 3.2
},
"hours_used": 250000,
"hours_estimated": false,
"fte_count": 125.0,
"benchmark": {
"industry_name": "Commercial Building Construction",
"naics_code": "236220",
"ownership": "private",
"total_recordable_rate": 2.6,
"dart_rate": 1.4,
"days_away_rate": 0.7,
"transfer_restriction_rate": 0.7,
"other_recordable_rate": 1.2
},
"comparison": {
"total_recordable_rate": { "difference": 7.0, "ratio": 3.69, "expected_cases": 3.3 },
"dart_rate": { "difference": 5.0, "ratio": 4.57, "expected_cases": 1.8 },
"days_away_rate": { "difference": 3.3, "ratio": 5.71, "expected_cases": 0.9 },
"transfer_restriction_rate": { "difference": 1.7, "ratio": 3.43, "expected_cases": 0.9 },
"other_recordable_rate": { "difference": 2.0, "ratio": 2.67, "expected_cases": 1.5 }
},
"derivations": [],
"warnings": [],
"request_id": "8f2c3b1d-5e4a-4a9c-9b1e-2a7f1d3e4c5b",
"timestamp": "2026-04-27T17:32:10.812Z"
}
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
employer_rates | object | Yes | Five-key rate object computed from the supplied case counts and hours. Every key is present; values are null when hours could not be resolved. |
employer_rates.total_recordable_rate | number | null | Yes | All recordable cases per 100 FTE. |
employer_rates.dart_rate | number | null | Yes | DART cases per 100 FTE. |
employer_rates.days_away_rate | number | null | Yes | Days-away cases per 100 FTE. |
employer_rates.transfer_restriction_rate | number | null | Yes | Transfer/restriction cases per 100 FTE. |
employer_rates.other_recordable_rate | number | null | Yes | Other-recordable cases per 100 FTE. |
hours_used | number | null | Yes | Hours that fed into the rate denominator. null when no hours could be resolved. |
hours_estimated | boolean | Yes | true when the engine derived hours from num_employees × avg_hours_per_week × num_weeks; false when total_hours_worked was used directly or when no hours were available. |
fte_count | number | null | Yes | hours_used / 2000, rounded to one decimal. null when hours_used is null. |
benchmark | object | null | Yes | The looked-up BLS row when naics_code is supplied and matches; null when the body omitted naics_code or when the supplied code did not match a published row. |
benchmark.naics_code | string | When benchmark != null | Echoes the supplied code. |
benchmark.industry_name | string | When benchmark != null | Industry name from BLS. |
benchmark.ownership | string | When benchmark != null | Echoes the requested ownership. |
benchmark.total_recordable_rate | number | null | When benchmark != null | National benchmark rate (per 100 FTE). |
benchmark.dart_rate | number | null | When benchmark != null | National DART benchmark. |
benchmark.days_away_rate | number | null | When benchmark != null | National days-away benchmark. |
benchmark.transfer_restriction_rate | number | null | When benchmark != null | National transfer/restriction benchmark. |
benchmark.other_recordable_rate | number | null | When benchmark != null | National other-recordable benchmark. |
comparison | object | Yes | One key per rate where both employer and benchmark sides are non-null. Empty {} when no benchmark was loaded or when every rate had a null on at least one side. |
comparison.<rate>.difference | number | When the key exists | Employer rate minus benchmark rate, rounded to one decimal. Positive = above national. |
comparison.<rate>.ratio | number | null | When the key exists | Employer rate divided by benchmark rate, rounded to two decimals. null when the benchmark rate is zero. |
comparison.<rate>.expected_cases | number | null | When the key exists | Cases the employer would have logged under their hours if their rate matched the national benchmark, rounded to one decimal. null when hours are unavailable. |
derivations | array of string | Yes | Human-readable notes describing every value the engine filled in (case counts, estimated hours). Empty when no derivation was needed. |
warnings | array of string | Yes | Human-readable notes about input gaps or inconsistencies. Empty when the inputs were complete and self-consistent. |
request_id | string | Yes | Correlation ID. Quote in support tickets. |
timestamp | string (ISO 8601 UTC) | Yes | Server time when the response was emitted. |
Unknown naics_code
If the body supplies a naics_code that does not match any published
row, the response is still 200. benchmark is null and
comparison is {}; the employer rates are still computed. This
matches the endpoint's permissive posture — the calculation can stand
alone without a benchmark.
If you need a hard failure for unknown codes, look up the code first
with /api/naics/get (which returns 404) and only
call /calculate after confirming the code is published.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Computation ran. May have empty comparison if no benchmark loaded; may have populated warnings if inputs were partial. |
400 | ValidationError | A numeric field is out of its documented range (avg_hours_per_week > 168, num_weeks > 52, negative case counts), or naics_code is malformed, or ownership is not in the allowed set. |
400 | DeIdentificationViolation | The body contained an identifier-shaped value or a blocked field name. NAICS-shaped bodies do not normally carry PHI; this fires only if free-form fields were added by the caller. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
429 | RateLimited | Per-consumer rate limit exceeded. |
500 | InternalError | Unexpected failure. Quote request_id in the ticket. |
Examples
curl
curl -sS -X POST "https://engine.tripod-health.com/api/naics/calculate" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"naics_code": "236220",
"total_recordable_cases": 12,
"days_away_cases": 5,
"transfer_restriction_cases": 3,
"total_hours_worked": 250000
}'
Python (requests)
# Uses the call() helper from quickstart.md.
result = call("POST", "/api/naics/calculate", {
"naics_code": "236220",
"total_recordable_cases": 12,
"days_away_cases": 5,
"transfer_restriction_cases": 3,
"total_hours_worked": 250000,
})
emp = result["employer_rates"]
bench = result["benchmark"]
print(f"Employer TRR: {emp['total_recordable_rate']} Benchmark: {bench['total_recordable_rate']}")
for warning in result["warnings"]:
print(f" warning: {warning}")
Node.js (native fetch)
// Uses the call() helper from quickstart.md.
const result = await call("POST", "/api/naics/calculate", {
naics_code: "236220",
total_recordable_cases: 12,
days_away_cases: 5,
transfer_restriction_cases: 3,
total_hours_worked: 250000,
});
console.log(`TRR: ${result.employer_rates.total_recordable_rate} vs benchmark ${result.benchmark?.total_recordable_rate}`);
for (const warning of result.warnings) {
console.log(` warning: ${warning}`);
}
TypeScript
interface RateBlock {
total_recordable_rate: number | null;
dart_rate: number | null;
days_away_rate: number | null;
transfer_restriction_rate: number | null;
other_recordable_rate: number | null;
}
interface RateComparison {
difference: number;
ratio: number | null;
expected_cases: number | null;
}
interface NaicsCalculateResponse {
employer_rates: RateBlock;
hours_used: number | null;
hours_estimated: boolean;
fte_count: number | null;
benchmark: (RateBlock & {
industry_name: string;
naics_code: string;
ownership: "private" | "state_government" | "local_government";
}) | null;
comparison: Partial<Record<keyof RateBlock, RateComparison>>;
derivations: string[];
warnings: string[];
request_id: string;
timestamp: string;
}
const result = await call<NaicsCalculateResponse>(
"POST",
"/api/naics/calculate",
body,
);
Permissive-body variants
Hours estimated from headcount
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/naics/calculate" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"naics_code": "236220",
"total_recordable_cases": 12,
"days_away_cases": 5,
"transfer_restriction_cases": 3,
"num_employees": 125,
"avg_hours_per_week": 40
}'
Returns 200 with hours_estimated: true, a derivations[] line
showing the hours calculation, and a warning recommending exact
payroll hours.
Calculation only, no benchmark
Send a body without naics_code:
curl -sS -X POST "https://engine.tripod-health.com/api/naics/calculate" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"total_recordable_cases": 12,
"total_hours_worked": 250000
}'
Returns 200 with the employer rates populated, benchmark: null, and
comparison: {}.
Error scenarios
avg_hours_per_week out of range
Send:
curl -sS -X POST "https://engine.tripod-health.com/api/naics/calculate" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Request-ID: docs-naics-calc-bad-hours" \
-d '{ "num_employees": 100, "avg_hours_per_week": 200, "total_recordable_cases": 5 }' \
-w "\nHTTP %{http_code}\n"
Receive (400):
{
"timestamp": "2026-04-27T17:32:10.812Z",
"error": "ValidationError",
"message": "Field 'avg_hours_per_week' must be <= 168",
"request_id": "docs-naics-calc-bad-hours",
"field": "avg_hours_per_week"
}
Intended use
- Workers' comp underwriting and renewal reviews where the underwriter wants the employer's reported numbers placed alongside their NAICS-cohort benchmark in one round trip.
- Safety dashboards that display "you vs. industry" alongside an employer's OSHA-300 logs.
- Pre-screening employer-supplied case counts for internal consistency: a body that triggers consistency warnings has reporting errors worth looking at before you submit it elsewhere.
Mistaken use
- Do not use
/api/naics/calculatewithoutnaics_codeto avoid benchmarks. It works (per the permissive contract), but if the whole point is the comparison, the missing code is almost always a client bug. Catch it client-side rather than discovering it from an emptycomparison. - Do not pass employer-identifying free-form fields in the body.
The de-identification filter rejects
name,email,phone, and similar field names. The endpoint needs case counts and hours; it does not need an employer name to produce a comparison. See conventions.md § De-identification boundary. - Do not retry a
200response withwarnings: [...]as if it were an error. Warnings communicate input quality; the rates have still been computed. Surface them to the user rather than discarding the response. - Do not sum
dart_rateandother_recordable_rateto derivetotal_recordable_rateafter a request withnullrate values. When hours are missing, the engine returns every rate asnull. There is no rate to add up;null + nullis not zero.
Debugging
- Capture
request_id. Present in every response body and in theX-Request-IDresponse header. - All
employer_ratesarenull. Checkhours_used— if it isnull, neithertotal_hours_workednor the (num_employees,avg_hours_per_week) pair was supplied. Add at least one and retry. benchmarkisnulleven though I sentnaics_code. The code is not published in the requested ownership cohort. Try?ownership=privateor call/api/naics/getdirectly to confirm what BLS publishes for that code.comparisonis empty even thoughbenchmarkis populated. Every rate inemployer_rateshad anull(typically because hours were missing) or every benchmark rate wasnull(BLS suppression on a narrow code). Both are normal.- Consistency-check warning fired. The supplied counts violate
total = DART + otherorDART = days_away + transfer_restriction. The engine still computes rates from the supplied counts; the warning is a flag that the source data has a reporting error. fte_countis much lower than your headcount. You senttotal_hours_workeddirectly and it covered fewer than 2000 hours per worker — possibly part-year data being treated as full-year. Either pass the actual full-year hours or use thenum_employees/avg_hours_per_week/num_weekstriple.
Rate limits
See rate-limits.md for the current per-consumer limits.
Related
- naics-search.md — type-ahead search to discover a code by industry name.
- naics-get.md — single-record lookup with full benchmark fields.
- authentication.md, errors.md, rate-limits.md, conventions.md — cross-cutting contract rules.
Change history
| Date | Change |
|---|---|
| 2026-04-27 | Initial publication. |