Underwriting — Basis
Status:
betaCategory:underwritingAdded:2026-10-06Last reviewed:2026-10-06
Summary
Returns the actuarial basis for an employer's workers' compensation programme: how many claims they open a year, what injuries cost them directly and indirectly, how that cost spreads across five claim sizes, and how much of it a care-management programme saves at the savings rates you send. You price your own deals on this basis; the engine returns no prices.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/underwriting/basis |
| Full URL | https://engine.tripod-health.com/api/underwriting/basis |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | Yes |
| Rate-limited | Yes |
| Credits | 450 per successful call, fixed |
Purpose
Anyone building a workers' compensation offer for an employer needs the same four answers before any commercial decision: the employer's claim volume, what those claims cost, where that cost sits by claim size, and what share of it is realistically movable. This operation supplies those four answers from whatever the employer has told you, and says how sure it is.
What it returns
- Claims a year, stated or estimated from the employer's industry injury rate, plus that industry rate whenever one is published for the code you send.
- The employer's cost, split into claim costs, administration and premium.
- Indirect cost (lost productivity, overtime, hiring and training) on top of claim costs, using OSHA's published indirect-cost ratios.
- Five claim sizes, each with its share of claims, typical cost, share of claim spend and the savings rate it was priced on.
- A blended savings rate across the employer's claim spend: your rates, or the engine's defaults when you send none.
- Readiness and confidence: how complete the input is, and which request fields would most improve the answer.
What it deliberately does not return
No costs of delivering a programme, no margins, no fees, no contract terms, no
monthly series and no price. The request accepts none of those either. Your
costs, overhead, margin targets, contract length, ramp-up and settlement rules
are your own business decisions; apply them to this basis in your own
application. How much your programme saves is your decision too: send it as
savings_rates (see Savings rates are yours).
Authentication
Every request must include a valid API key in the apikey header. Keys are
issued per consumer; see authentication. A missing or
unknown key returns 401 Unauthenticated at the gateway before the request
reaches the engine.
Request
Headers
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | Must be application/json. |
Path parameters
None.
Query parameters
None.
Body
Every field is optional. A flat object; no nesting.
{
"covered_employees": 850,
"naics_code": "238220",
"states": 2,
"claim_costs_annual": 410000,
"administration_annual": 52000,
"premium_annual": 118000,
"savings_rates": [0.7, 0.59, 0.48, 0.36, 0.25]
}
Body fields
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
covered_employees | integer | No | > 0 | Employees the programme would cover. The denominator for every per-employee figure, and the exposure base when claims are estimated. |
naics_code | string | No | 2-6 digits | The employer's industry. Drives the claims estimate when claim_count_annual is absent. |
ownership | string | No | private, state_government, local_government | BLS ownership class used with naics_code. Absent means private. |
states | integer | No | > 0 | Number of states the employer operates in. Carried for the record; no figure in the basis depends on it. |
claim_costs_annual | number | No | >= 0, dollars a year | What the employer pays on claims in a typical year: medical, wage replacement and legal, combined. |
administration_annual | number | No | >= 0, dollars a year | What the employer pays a year to have claims administered. |
premium_annual | number | No | >= 0, dollars a year | Insurance premium a year: excess, stop-loss or guaranteed cost. |
total_cost_annual | number | No | >= 0, dollars a year | Claim costs, administration and premium as one figure. Fills whichever parts you did not send; ignored when all three parts are present. |
claim_count_annual | number | No | >= 0, claims a year | Claims opened in a typical year. |
savings_rates | number[5] | No | Exactly five, each 0 to 1 | Per claim size, smallest first: the fraction of that size's cost you are confident your programme saves once it has taken hold. Absent means the engine's defaults. See Savings rates are yours. |
opaque_correlation_id | string | No | UUID, or 8-64 hex characters | Your own token for matching a response to your records. Echoed in provenance.correlation_id; the engine attaches no meaning to it. |
Any field not in this table is rejected with 400 ValidationError.
Every money field is dollars a year. Do not send monthly figures, multi-year totals or per-employee amounts.
De-identification requirement
All request bodies are scanned for identifiers on the way in. This body is
entirely numbers, lists of numbers, digit strings and enumerations: there is no employer name,
address, contact, date or identifier field, and none may be added. If you
need to match a response to your own records, use opaque_correlation_id,
which accepts a UUID or a hex string and nothing else.
A body containing an identifier returns 400 DeIdentificationViolation. See
conventions.md § De-identification boundary.
Response
Success — 200 OK
The response to the body above.
{
"operation": "basis",
"claims": {
"count_annual": 25.5,
"source": "industry_rate",
"industry": {
"naics_code_used": "23822",
"industry_name": "Plumbing, heating, and air-conditioning contractors",
"year": 2024,
"total_recordable_rate": 3.0
},
"injuries_annual": 72.86
},
"cost": {
"claim_costs_annual": 410000.0,
"administration_annual": 52000.0,
"premium_annual": 118000.0,
"total_annual": 580000.0,
"source": "stated"
},
"indirect": {
"osha_ratio": 1.2735,
"indirect_annual": 522116.89
},
"movable_annual": 932116.89,
"claim_sizes": [
{
"size": 1,
"label": "Minor",
"share_of_claims": 0.755184,
"typical_medical_cost": 731.0,
"medical_from": 0.0,
"medical_to": 3000.0,
"wage_replacement": 0.05,
"legal": 0.01,
"typical_direct_cost": 774.86,
"osha_ratio": 4.5,
"share_of_claim_spend": 0.049391,
"claim_spend_annual": 20250.47,
"savings_rate": 0.7
},
{
"size": 2,
"label": "Moderate",
"share_of_claims": 0.098481,
"typical_medical_cost": 4584.0,
"medical_from": 3000.0,
"medical_to": 7000.0,
"wage_replacement": 0.4,
"legal": 0.05,
"typical_direct_cost": 6646.8,
"osha_ratio": 1.2,
"share_of_claim_spend": 0.055251,
"claim_spend_annual": 22652.92,
"savings_rate": 0.59
},
{
"size": 3,
"label": "Significant",
"share_of_claims": 0.122459,
"typical_medical_cost": 18946.0,
"medical_from": 7000.0,
"medical_to": 50000.0,
"wage_replacement": 0.7,
"legal": 0.12,
"typical_direct_cost": 34481.72,
"osha_ratio": 1.1,
"share_of_claim_spend": 0.356414,
"claim_spend_annual": 146129.78,
"savings_rate": 0.48
},
{
"size": 4,
"label": "Serious",
"share_of_claims": 0.022309,
"typical_medical_cost": 92556.0,
"medical_from": 50000.0,
"medical_to": 250000.0,
"wage_replacement": 0.9,
"legal": 0.25,
"typical_direct_cost": 198995.4,
"osha_ratio": 1.1,
"share_of_claim_spend": 0.374713,
"claim_spend_annual": 153632.19,
"savings_rate": 0.36
},
{
"size": 5,
"label": "Catastrophic",
"share_of_claims": 0.001557,
"typical_medical_cost": 531769.0,
"medical_from": 250000.0,
"medical_to": null,
"wage_replacement": 1.0,
"legal": 0.35,
"typical_direct_cost": 1249657.15,
"osha_ratio": 1.1,
"share_of_claim_spend": 0.164231,
"claim_spend_annual": 67334.65,
"savings_rate": 0.25
}
],
"savings": {
"rate": 0.4142,
"source": "caller"
},
"plausibility": [
{ "key": "claim_costs_annual", "per_employee_per_month": 40.1961, "range": [0.5, 60.0], "within": true },
{ "key": "administration_annual", "per_employee_per_month": 5.098, "range": [0.1, 15.0], "within": true },
{ "key": "premium_annual", "per_employee_per_month": 11.5686, "range": [0.05, 20.0], "within": true }
],
"readiness": {
"completeness": 0.9286,
"release": "directional_only",
"still_needed": [
{ "key": "claim_count_annual", "tier": 2, "blocks_pricing": false }
]
},
"confidence": {
"overall": 0.85,
"tier": "high",
"breakdown": {
"evidence": null,
"input_completeness": 0.9285714285714286,
"imputation_variance": null,
"model_certainty": null
}
},
"notes": [
"BLS publishes no rate for NAICS 238220; the nearest published code, 23822 (Plumbing, heating, and air-conditioning contractors), was used.",
"Claims estimated from the 2024 BLS total recordable rate for NAICS 23822, 3 per 100 full-time workers, with covered employees standing in for full-time equivalents and 1 claim per recordable. Send claim_count_annual to replace the estimate.",
"Confidence is capped at 0.85. A complete submission is still priced on a typical claim mix rather than this employer's own claims, and on savings rates whose uncertainty nothing here measures."
],
"provenance": {
"calibration_versions": {
"underwriting_claim_costing": "2026-10-06-caller-savings",
"underwriting_completeness": "2026-10-06-basis"
},
"naics_year": 2024,
"catalog_version": "0.37.0",
"content_hash": "cb9832171a0a46df91b01178e0523a8b6405dc9f8ed567cc039098218d2e8545",
"correlation_id": null
},
"request_id": "3f6b2a1e-8c4d-4e7a-9b1f-2d5c8e0a7b34",
"timestamp": "2026-10-06T04:00:00.000000+00:00"
}
Two labels to read every figure by
Every number in the response is one of these, and the field name says which:
- Annual versus per employee per month.
*_annualfields are dollars a year for the whole employer;typical_*_cost,medical_fromandmedical_toare dollars per claim.per_employee_per_monthis an annual figure divided bycovered_employeesand by 12; onlyplausibilityuses it. - Rate versus dollars.
share_of_*,wage_replacement,legal,osha_ratio,savings_rateandsavings.rateare rates (a fraction or a multiple), never dollars. Multiply a savings rate by the dollars it applies to.
Rounding
Dollar figures are rounded to cents; count_annual and injuries_annual to
two places; indirect.osha_ratio, savings.rate and
readiness.completeness to four; per_employee_per_month to four;
share_of_claim_spend to six. Every figure is computed at full precision first
and rounded only on output, so recomputing from rounded figures can differ in
the last place.
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
operation | string | Yes | Always basis. |
claims.count_annual | number | null | Yes | Claims opened a year. null when claims.source is none. |
claims.source | string | Yes | Where the count came from: stated, industry_rate, default or none. See Claims a year. |
claims.industry | object | null | Yes | The published BLS rate found for naics_code, whichever source set the count. null when no naics_code was sent or no rate is published for it or any code above it. |
claims.industry.naics_code_used | string | When industry is present | The code whose rate was found. Shorter than the code sent when the full code has no published rate. |
claims.industry.industry_name | string | When industry is present | Industry name for naics_code_used. |
claims.industry.year | integer | When industry is present | Survey year of the published rate. |
claims.industry.total_recordable_rate | number | When industry is present | Recordable injuries per 100 full-time workers a year. |
claims.injuries_annual | number | null | Yes | Injuries a year, including those that never become a claim: count_annual / 0.35. null when count_annual is. |
cost.claim_costs_annual | number | null | Yes | Claim costs, dollars a year: stated, split from the total, or estimated from claims. |
cost.administration_annual | number | null | Yes | Administration, dollars a year: stated or split from the total. Never estimated from claims. |
cost.premium_annual | number | null | Yes | Premium, dollars a year: stated or split from the total. Never estimated from claims. |
cost.total_annual | number | null | Yes | The three parts summed, dollars a year; null when any part is unknown. |
cost.source | string | Yes | stated, split_from_total, estimated_from_claims or none. See Cost parts. |
indirect.osha_ratio | number | Yes | Indirect dollars per direct dollar, blended over the claim-spend mix. |
indirect.indirect_annual | number | null | Yes | Indirect cost, dollars a year: claim_costs_annual x osha_ratio. |
movable_annual | number | null | Yes | Direct plus indirect claim cost, dollars a year: the cost a programme can act on. Excludes administration and premium. |
claim_sizes | object[] | Yes | Exactly five entries, smallest first. |
claim_sizes[].size | integer | Yes | 1 to 5. |
claim_sizes[].label | string | Yes | Minor, Moderate, Significant, Serious or Catastrophic. |
claim_sizes[].share_of_claims | number | Yes | Fraction of claims that fall in this size. |
claim_sizes[].typical_medical_cost | number | Yes | Typical medical cost of one claim in this size, dollars. |
claim_sizes[].medical_from | number | Yes | Lower bound of the size's medical-cost range, dollars per claim. |
claim_sizes[].medical_to | number | null | Yes | Upper bound, dollars per claim. null on size 5, which is open-ended. |
claim_sizes[].wage_replacement | number | Yes | Wage replacement as a multiple of medical cost. |
claim_sizes[].legal | number | Yes | Legal cost as a multiple of medical cost. |
claim_sizes[].typical_direct_cost | number | Yes | All direct cost of one typical claim, dollars: medical x (1 + wage_replacement + legal). |
claim_sizes[].osha_ratio | number | Yes | OSHA's indirect-cost ratio at typical_direct_cost. |
claim_sizes[].share_of_claim_spend | number | Yes | Fraction of claim dollars in this size. Differs from share_of_claims because large claims cost more. |
claim_sizes[].claim_spend_annual | number | null | Yes | This size's claim costs, dollars a year: claim_costs_annual x share_of_claim_spend. |
claim_sizes[].savings_rate | number | Yes | The fraction of this size's cost the basis assumes the programme saves: your savings_rates entry, or the engine's default. |
savings.rate | number | Yes | The per-size savings_rate values weighted by share_of_claim_spend. A fraction, not dollars. |
savings.source | string | Yes | caller when you sent savings_rates, default when the engine's defaults were used. See Savings rates are yours. |
plausibility | object[] | Yes | One entry per cost part you sent. Empty when you sent none, or sent no covered_employees. Advisory only; never blocks. |
plausibility[].key | string | Yes | The request field checked: claim_costs_annual, administration_annual or premium_annual. |
plausibility[].per_employee_per_month | number | Yes | The figure you sent divided by covered_employees and by 12, dollars. |
plausibility[].range | number[2] | Yes | The plausible per-employee-per-month range for that part, dollars. |
plausibility[].within | boolean | Yes | false flags a likely unit error or an unusual employer. |
readiness.completeness | number | Yes | 0 to 1. Tier-weighted share of the request fields supplied. See Readiness. |
readiness.release | string | Yes | released, directional_only, internal_only or suppressed. |
readiness.still_needed | object[] | Yes | Missing tier 1 and tier 2 fields, tier 1 first, then by key. Empty when none is missing. |
readiness.still_needed[].key | string | Yes | The request field name. Stable. |
readiness.still_needed[].tier | integer | Yes | 1 or 2. Tier 3 fields are never listed. |
readiness.still_needed[].blocks_pricing | boolean | Yes | true when tier is 1, except for a missing cost part while total_cost_annual is sent: the total already prices it, so it is still listed (the real figure replaces the split) but does not block. |
confidence.overall | number | Yes | 0 to 1, capped. See Confidence is capped. |
confidence.tier | string | Yes | low, medium or high. |
confidence.breakdown.input_completeness | number | Yes | The completeness term, at full precision. The only term that runs here. |
confidence.breakdown.evidence / .imputation_variance / .model_certainty | null | Yes | Always null on this operation: there is no question bank, imputation or model, only arithmetic. |
notes | string[] | Yes | Plain-English statements of what was estimated, assumed, split or capped, and why. Read these. |
provenance.calibration_versions | object | Yes | The calibration versions this answer was computed on. |
provenance.naics_year | integer | null | Yes | Survey year of the industry rate found; null when none was found. |
provenance.catalog_version | string | Yes | The engine catalog version that served the call. |
provenance.content_hash | string | Yes | Hash over the request, the calibration versions, the industry rate year and, when you sent no savings_rates, the default rates used. See Determinism. |
provenance.correlation_id | string | null | Yes | Your opaque_correlation_id, echoed; null when you sent none. |
request_id | string | Yes | Correlation ID. Echo back in support tickets. |
timestamp | string (ISO 8601) | Yes | Server timestamp at response emission. |
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Request succeeded. Including when the body was sparse or empty, and when a cost figure is implausible. |
400 | ValidationError | A supplied field breaks its own constraint: malformed JSON, a field not in the body table, covered_employees or states not a whole number above zero, a negative money figure or claim_count_annual, a naics_code that is not 2-6 digits, an ownership off the list, a savings_rates that is not exactly five numbers between 0 and 1, or an opaque_correlation_id that is not a UUID or 8-64 hex characters. field names the offender. Never returned for a missing field. |
400 | DeIdentificationViolation | Request body contained an identifier. field names the offending path. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | Out of credits, or a per-key or account budget is exhausted. See credits. |
404 | NotFound | Path does not exist or the method is wrong. |
413 | PayloadTooLarge | Body exceeds the 512 KB limit. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
429 | RateLimited | Per-consumer rate limit exceeded. See Retry-After. |
500 | InternalError | Unexpected server-side failure. Include request_id when reporting. |
504 | UpstreamTimeout | The analytical layer timed out. |
Error envelope
All non-2xx responses use a uniform envelope:
{
"timestamp": "2026-10-06T04:00:00.000Z",
"error": "ValidationError",
"message": "covered_employees: Input should be greater than 0",
"request_id": "3f6b2a1e-8c4d-4e7a-9b1f-2d5c8e0a7b34",
"field": "covered_employees"
}
See errors for the full list of error codes and extras.
The arithmetic
Every figure is plain arithmetic over your input, the active calibration and,
when you send naics_code, the published BLS rate. No model, no randomness.
Claims a year
The first of these that applies:
- Stated.
claim_count_annual, as sent.sourceisstated. - Industry rate.
covered_employeesx the industry's total recordable rate / 100 x claims per recordable injury. Claims per recordable injury is currently1: an injury recordable under OSHA needs treatment beyond first aid, which almost always opens at least a medical-only claim.sourceisindustry_rate. - Default.
covered_employeesx 4 / 100 (four claims per 100 employees a year).sourceisdefault. - None. No claim count and no covered employees:
count_annualandinjuries_annualarenull.sourceisnone.
The industry rate. When you send naics_code, the engine looks up the
latest published BLS total recordable rate for that code and ownership. When
BLS publishes no rate for the full code, it drops the last digit and tries
again (238220, 23822, 2382, 238, 23) and uses the first published
rate; a note names the code it used. The manufacturing, retail and
transportation sectors, which BLS publishes as 31-33, 44-45 and 48-49,
are matched at two digits. The rate found is returned in claims.industry
even when claim_count_annual set the count, so you can see the industry rate
beside your own figure.
The BLS rate is per 100 full-time workers. Covered employees stand in for
full-time equivalents, and a note says so; for a heavily part-time workforce,
send claim_count_annual instead.
injuries_annual is count_annual / 0.35: roughly one injury in three becomes a
claim, the rest resolve without one.
Cost parts
A part you send is never overwritten. The first of these that applies:
- All three parts sent (
claim_costs_annual,administration_annual,premium_annual): used as sent,total_annualis their sum, andtotal_cost_annualis ignored.sourceisstated. - Some parts missing, and
total_cost_annualsent:total_cost_annualminus the parts you sent is split over only the missing parts, in proportion to their calibrated weights (claim costs 0.70, administration 0.12, premium 0.18). If the parts you sent already exceed the total, the missing parts are0and a note says so.sourceissplit_from_total. - No total,
claim_costs_annualsent, another part missing: the parts you sent are returned, the missing ones arenull, andtotal_annualisnull.sourceisstated. - No claim costs and no total, but the claim count is known (stated or
estimated):
claim_costs_annual=count_annualx the typical direct cost of an average claim (each size'sshare_of_claimsx itstypical_direct_cost, summed: about $11,847 per claim). Administration and premium are returned only if you sent them, otherwisenull; they are never estimated from claims.sourceisestimated_from_claims. - Otherwise claim costs are
null, and so is every figure built on them.sourceisnone.
Examples of case 2, with the weights renormalised over what is missing:
- Only
total_cost_annual= 1,000,000: claim costs 700,000, administration 120,000, premium 180,000. claim_costs_annual= 600,000 andtotal_cost_annual= 900,000: the remaining 300,000 splits 0.12 : 0.18, so administration 120,000 and premium 180,000.
Per claim size
-
typical_direct_cost=typical_medical_costx (1 +wage_replacement+legal). -
osha_ratiocomes from OSHA's indirect-cost table at that direct cost, rounded to the whole dollar:Direct cost of a claim Indirect dollars per direct dollar up to $2,999 4.5 $3,000 - $4,999 1.6 $5,000 - $9,999 1.2 $10,000 and over 1.1 -
share_of_claim_spend=share_of_claimsxtypical_direct_cost, divided by the same product summed over all five sizes. -
claim_spend_annual=claim_costs_annualxshare_of_claim_spend.
Indirect and movable cost
indirect.osha_ratio= each size'sosha_ratioweighted by itsshare_of_claim_spend.indirect_annual=claim_costs_annualxosha_ratio.movable_annual=claim_costs_annualx (1 +osha_ratio).
Administration and premium are never movable: a care-management programme changes injuries and claims, not what an insurer or administrator charges.
Savings
Each size's savings_rate is the matching entry of your savings_rates, or the
engine's default for that size when you sent none. savings.rate is each size's
savings_rate weighted by its share_of_claim_spend. Because large claims
carry most of the dollars, the blended rate follows the rates you set for the
large sizes far more than the rate you set for minor claims.
Worked check against the example
Claims: 850 x 3.0 / 100 x 1 = 25.5; injuries 25.5 / 0.35 = 72.86. With
claim_costs_annual = 410,000:
| Size | typical_direct_cost | share_of_claims x direct | share_of_claim_spend | osha_ratio | claim_spend_annual |
|---|---|---|---|---|---|
| 1 | 731 x 1.06 = 774.86 | 585.16 | 0.049391 | 4.5 | 20,250.47 |
| 2 | 4,584 x 1.45 = 6,646.80 | 654.58 | 0.055251 | 1.2 | 22,652.92 |
| 3 | 18,946 x 1.82 = 34,481.72 | 4,222.60 | 0.356414 | 1.1 | 146,129.78 |
| 4 | 92,556 x 2.15 = 198,995.40 | 4,439.39 | 0.374713 | 1.1 | 153,632.19 |
| 5 | 531,769 x 2.35 = 1,249,657.15 | 1,945.72 | 0.164231 | 1.1 | 67,334.65 |
| 11,847.45 | 1 | 1.2735 blended | 410,000.01 (cents rounding) |
indirect_annual= 410,000 x 1.27345583 = 522,116.89.movable_annual= 410,000 x 2.27345583 = 932,116.89.savings.rate= 0.049391 x 0.70 + 0.055251 x 0.59 + 0.356414 x 0.48 + 0.374713 x 0.36 + 0.164231 x 0.25 = 0.4142.plausibilityfor claim costs: 410,000 / 850 / 12 = 40.1961, inside 0.5 to 60.readiness.completeness: 13 of 14 weight points (see Readiness) = 0.9286.
What you do with it
Pricing is your arithmetic over this response. A typical shape: apply
savings.rate to movable_annual (or to claim_costs_annual if you price on
direct cost only), then apply your own ramp-up, costs, margin and contract
terms. None of those enter the engine.
Savings rates are yours
How much of each claim size's cost a programme saves is a judgement about your
own programme, so the engine takes it as input rather than supplying it. Send
savings_rates: five fractions, smallest claim size first, each the share of
that size's cost you are confident your programme saves once it has taken hold.
Every savings figure in the response is priced on them, and savings.source is
caller.
When you send no savings_rates, the engine prices on its defaults and
savings.source is default:
| Size | Label | Default savings_rate |
|---|---|---|
| 1 | Minor | 0.70 |
| 2 | Moderate | 0.59 |
| 3 | Significant | 0.48 |
| 4 | Serious | 0.36 |
| 5 | Catastrophic | 0.25 |
The defaults are a starting point, not evidence: they are not sourced to published outcome data, and a note says so on every response that uses them. The claim-size shares and typical medical costs are observed; the wage-replacement and legal multiples are modelled. Send your own rates for anything you price on.
Readiness
Each request field has a tier:
| Tier | Weight | Fields | Meaning |
|---|---|---|---|
1 | 3 | covered_employees, claim_costs_annual, administration_annual, premium_annual | The spend every figure is measured on and the headcount every per-employee figure divides by. blocks_pricing: true, except a cost part that a sent total_cost_annual already prices. |
2 | 1 | claim_count_annual, naics_code | Sets the claim count. blocks_pricing: false. |
3 | 0 | total_cost_annual, ownership, states | Not counted toward completeness and never listed in still_needed. total_cost_annual still fills missing cost parts when sent. |
readiness.completeness is the weight of the fields present divided by the
total weight, 14. total_cost_annual counts as present whenever all three cost
parts are. readiness.release bands it:
release | Completeness | Meaning |
|---|---|---|
released | >= 0.95 | Fit to price on. |
directional_only | >= 0.85 | Fit to show as a direction, not a figure to commit to. |
internal_only | >= 0.70 | Fit for your own analysis only. |
suppressed | below 0.70 | Too little to rely on. |
readiness.still_needed[].key values are the request field names in this
document. They are stable: map your own questions to them by key. A rename
would be a breaking change and would be announced as one.
Partial information never fails
No field is required. A body with one number in it returns 200, and so
does {}. Missing input lowers readiness.completeness and
confidence.overall, lists the missing fields in readiness.still_needed, and
leaves the figures that depend on it null. claims.source and cost.source
say exactly how each figure was obtained, and notes say what was estimated or
split.
A cost figure far outside its plausible range is reported in plausibility
with within: false and still returns 200. The only 400s are a malformed
body, an unknown field, and a supplied field that breaks its own
constraint.
Confidence is capped
confidence.overall is the completeness term, capped at 0.85. A complete
submission is still priced on a typical claim mix rather than the employer's
own claims, and on savings rates whose uncertainty nothing here measures; the
cap is the honest statement of that, and a note reports it whenever it
applies. In the example, completeness 0.9286 is capped to 0.85. Sending
savings_rates does not change completeness or confidence.
Determinism
The same body, against the same calibration versions and the same BLS survey
year, returns the same response apart from request_id and timestamp. There
is no randomness and no dependence on the time of the call.
provenance.content_hash is computed over the request (your savings_rates
included), the calibration versions, the BLS survey year and, when you sent no
savings_rates, the default rates used: store it with any price you build, and
an unchanged hash on a later call means the basis under that price has not
moved.
Examples
curl — success
curl -sS -X POST "https://engine.tripod-health.com/api/underwriting/basis" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"covered_employees": 850,
"naics_code": "238220",
"states": 2,
"claim_costs_annual": 410000,
"administration_annual": 52000,
"premium_annual": 118000,
"savings_rates": [0.7, 0.59, 0.48, 0.36, 0.25]
}'
Returns the response shown under Success.
curl — a sparse body still returns 200
curl -sS -X POST "https://engine.tripod-health.com/api/underwriting/basis" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"covered_employees": 2400}'
Returns 200 with:
claims.sourcedefault,count_annual96(2,400 x 4 / 100);cost.sourceestimated_from_claims,claim_costs_annual1137354.9(96 x 11,847.45), administration, premium andtotal_annualnull;plausibilityempty, since no cost part was sent;savings.sourcedefault, since nosavings_rateswere sent, with a note saying so;readiness.completeness0.2143(3 of 14),releasesuppressed, andstill_neededlistingadministration_annual,claim_costs_annual,premium_annual(tier 1) thenclaim_count_annual,naics_code(tier 2).
curl — validation error
curl -sS -X POST "https://engine.tripod-health.com/api/underwriting/basis" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"covered_employees": 0}' \
-w "\nHTTP %{http_code}\n"
Returns 400 ValidationError with field set to covered_employees, as shown
under Error envelope.
Python — requests
import os, requests
resp = requests.post(
"https://engine.tripod-health.com/api/underwriting/basis",
headers={"apikey": os.environ["ENGINE_API_KEY"]},
json={
"covered_employees": 850,
"naics_code": "238220",
"claim_costs_annual": 410000,
"administration_annual": 52000,
"premium_annual": 118000,
"savings_rates": [0.7, 0.59, 0.48, 0.36, 0.25],
},
timeout=30,
)
resp.raise_for_status()
basis = resp.json()
rate = basis["savings"]["rate"]
movable = basis["movable_annual"]
if movable is not None:
print(f"Planned savings: ${movable * rate:,.0f} a year")
for item in basis["readiness"]["still_needed"]:
print(f" still needed: {item['key']} (tier {item['tier']})")
Node — fetch
const resp = await fetch(
"https://engine.tripod-health.com/api/underwriting/basis",
{
method: "POST",
headers: {
apikey: process.env.ENGINE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
covered_employees: 850,
naics_code: "238220",
total_cost_annual: 580000,
}),
},
);
if (!resp.ok) throw new Error(`Engine ${resp.status}: ${await resp.text()}`);
const basis = await resp.json();
console.log(basis.cost.source); // "split_from_total"
Intended use
- Scoping a workers' compensation programme for an employer from whatever they have told you, before any commercial decision.
- The input to your own pricing: send your savings rates, then apply your
costs, margins, ramp-up and contract terms to
movable_annual,claim_sizesandsavings. - Driving an intake questionnaire: call with what you have, ask the
employer for the keys in
readiness.still_needed, call again. - Catching unit errors before they reach a price: a
plausibilityentry withwithin: falseusually means a monthly or multi-year figure was sent as annual. - Detecting a stale price: compare a stored
provenance.content_hashto a fresh call's.
Mistaken use
- Do not read a savings rate as dollars. It is a fraction; multiply it by
movable_annualorclaim_costs_annual. - Do not price on the default savings rates. They are unsourced starting
points; send your own
savings_ratesinstead. - Do not send savings rates as percentages.
70fails validation; send0.7instead. - Do not apply savings to
cost.total_annual. Administration and premium are not movable; usemovable_annualorclaim_costs_annualinstead. - Do not send monthly, per-employee or multi-year figures in the
*_annualfields. Convert to dollars a year first;plausibilityjudges only the parts you send, and only against a broad range. - Do not read an empty
plausibilitylist as a pass. Split and estimated parts are not judged; checkcost.sourceinstead. - Do not put an employer name or any identifier in the body. Use
opaque_correlation_idinstead. - Do not use this operation to compare an employer's injury rate with its
industry. Use
POST /api/lookup/naics/calculateinstead. - Do not use this operation to cost a single claim. Use
POST /api/claimcost/classifyinstead. - Do not retry a sparse
200as if it failed. Readreadiness.still_needed, collect the missing fields, and call again.
Debugging
- Capture the
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). Every support ticket starts here. 400 ValidationErrornaming an unexpected field — the body accepts exactly the fields listed under Body fields; remove any other key.400 DeIdentificationViolation—fieldnames the offending path.opaque_correlation_idaccepts only a UUID or 8-64 hex characters.claims.sourceisnone— sendclaim_count_annualorcovered_employees.cost.sourceisnone— send cost figures, or a claim count (orcovered_employees) so claim costs can be estimated.claims.industryisnullthough you sentnaics_code— no rate is published for the code or any code above it under thatownership. A note says so. Check the code withGET /api/lookup/naics/get, or sendclaim_count_annual.plausibility[].withinisfalse— check the units of that field before pricing on it.402 PaymentRequired— fund the balance or raise the budget; see credits.5xxwith no clear cause — file a ticket with therequest_id, the UTC timestamp from the response, and a minimal reproducer body.
Credits
Each successful call costs 450 credits, fixed regardless of how complete the
body is. Failed calls are not billed. See credits for metering,
the X-Credits-* headers and 402.
Rate limits
Per-consumer limits are enforced at the gateway. See the rate limits reference.
Related
- lookup-naics-calculate.md — an employer's OSHA incidence rates against its industry benchmark.
- lookup-naics-get.md — the BLS benchmark for one NAICS code.
- claimcost-classify.md — the likely cost tier of a single claim.
- credits.md, errors.md, rate-limits.md, conventions.md — cross-cutting contract rules.
Change history
| Date | Change |
|---|---|
| 2026-10-06 | Initial publication. |
| 2026-10-06 | Savings rates are now sent by the caller as savings_rates, with engine defaults when absent. claim_sizes[].savings is replaced by claim_sizes[].savings_rate, and the blended savings range and provisional flag by savings.rate and savings.source. |
| 2026-10-14 | The engine assigns every request ID; an X-Request-ID request header is not used. The X-Request-ID response header is the gateway's trace ID for the request and does not match request_id; quote request_id to support. |