Loading documentation

Access required

Your account doesn't have access to the documentation. Contact your administrator.

Sign out
Skip to main content

NAICS Calculate

Status: stable Category: naics Added: 2026-04-27 Last 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

FieldValue
MethodPOST
Path/api/naics/calculate
Full URLhttps://engine.tripod-health.com/api/naics/calculate
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentNo (each call is an independent computation; no storage side effect today)
Rate-limitedYes

Authentication

Send a valid API key in the apikey header. See authentication.md.

Request

Headers

HeaderRequiredDescription
apikeyYesConsumer API key.
Content-TypeYesMust be application/json.
X-Request-IDNoOptional 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.

FieldTypeRequiredDescription
naics_codestringNo2–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 {}.
ownershipstringNoOne of private, state_government, local_government. Defaults to private. Ignored when naics_code is omitted.
total_recordable_casesintegerNoOSHA 300 column total. Engine derives any one of total / dart / other from the other two when only two are supplied.
days_away_casesintegerNoDays-away component of DART.
transfer_restriction_casesintegerNoJob-transfer / restricted-duty component of DART.
other_recordable_casesintegerNoTotal recordable minus DART (medical-treatment-only cases).
total_hours_workednumberNoExact payroll hours over the reporting period. Preferred input — most accurate denominator.
num_employeesintegerNoUsed to estimate hours when total_hours_worked is missing: num_employees × avg_hours_per_week × num_weeks.
avg_hours_per_weeknumberNoRequired for hours estimation. Range (0, 168].
num_weeksintegerNoDefaults to 52. Range [1, 52].

Hours: exact vs. estimated

The endpoint accepts hours by either of two paths:

  1. Exacttotal_hours_worked directly. Always preferred when available.
  2. Estimatednum_employees × avg_hours_per_week × num_weeks, used only when total_hours_worked is 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

FieldTypeAlways presentDescription
employer_ratesobjectYesFive-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_ratenumber | nullYesAll recordable cases per 100 FTE.
employer_rates.dart_ratenumber | nullYesDART cases per 100 FTE.
employer_rates.days_away_ratenumber | nullYesDays-away cases per 100 FTE.
employer_rates.transfer_restriction_ratenumber | nullYesTransfer/restriction cases per 100 FTE.
employer_rates.other_recordable_ratenumber | nullYesOther-recordable cases per 100 FTE.
hours_usednumber | nullYesHours that fed into the rate denominator. null when no hours could be resolved.
hours_estimatedbooleanYestrue 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_countnumber | nullYeshours_used / 2000, rounded to one decimal. null when hours_used is null.
benchmarkobject | nullYesThe 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_codestringWhen benchmark != nullEchoes the supplied code.
benchmark.industry_namestringWhen benchmark != nullIndustry name from BLS.
benchmark.ownershipstringWhen benchmark != nullEchoes the requested ownership.
benchmark.total_recordable_ratenumber | nullWhen benchmark != nullNational benchmark rate (per 100 FTE).
benchmark.dart_ratenumber | nullWhen benchmark != nullNational DART benchmark.
benchmark.days_away_ratenumber | nullWhen benchmark != nullNational days-away benchmark.
benchmark.transfer_restriction_ratenumber | nullWhen benchmark != nullNational transfer/restriction benchmark.
benchmark.other_recordable_ratenumber | nullWhen benchmark != nullNational other-recordable benchmark.
comparisonobjectYesOne 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>.differencenumberWhen the key existsEmployer rate minus benchmark rate, rounded to one decimal. Positive = above national.
comparison.<rate>.rationumber | nullWhen the key existsEmployer rate divided by benchmark rate, rounded to two decimals. null when the benchmark rate is zero.
comparison.<rate>.expected_casesnumber | nullWhen the key existsCases the employer would have logged under their hours if their rate matched the national benchmark, rounded to one decimal. null when hours are unavailable.
derivationsarray of stringYesHuman-readable notes describing every value the engine filled in (case counts, estimated hours). Empty when no derivation was needed.
warningsarray of stringYesHuman-readable notes about input gaps or inconsistencies. Empty when the inputs were complete and self-consistent.
request_idstringYesCorrelation ID. Quote in support tickets.
timestampstring (ISO 8601 UTC)YesServer 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

CodeMeaningWhen
200OKComputation ran. May have empty comparison if no benchmark loaded; may have populated warnings if inputs were partial.
400ValidationErrorA 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.
400DeIdentificationViolationThe 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.
401UnauthenticatedMissing or invalid API key.
413PayloadTooLargeBody exceeds 512 KB.
415UnsupportedMediaTypeContent-Type is not application/json.
429RateLimitedPer-consumer rate limit exceeded.
500InternalErrorUnexpected 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/calculate without naics_code to 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 empty comparison.
  • 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 200 response with warnings: [...] 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_rate and other_recordable_rate to derive total_recordable_rate after a request with null rate values. When hours are missing, the engine returns every rate as null. There is no rate to add up; null + null is not zero.

Debugging

  1. Capture request_id. Present in every response body and in the X-Request-ID response header.
  2. All employer_rates are null. Check hours_used — if it is null, neither total_hours_worked nor the (num_employees, avg_hours_per_week) pair was supplied. Add at least one and retry.
  3. benchmark is null even though I sent naics_code. The code is not published in the requested ownership cohort. Try ?ownership=private or call /api/naics/get directly to confirm what BLS publishes for that code.
  4. comparison is empty even though benchmark is populated. Every rate in employer_rates had a null (typically because hours were missing) or every benchmark rate was null (BLS suppression on a narrow code). Both are normal.
  5. Consistency-check warning fired. The supplied counts violate total = DART + other or DART = 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.
  6. fte_count is much lower than your headcount. You sent total_hours_worked directly 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 the num_employees/avg_hours_per_week/num_weeks triple.

Rate limits

See rate-limits.md for the current per-consumer limits.

Change history

DateChange
2026-04-27Initial publication.