---
title: "Bulk import with dry-run"
method: POST
path: "/api/v1/households/import"
tags: ["import"]
---

# Bulk import with dry-run

`POST /api/v1/households/import`

## Request

- Base URL: `https://worthune.com`
- URL: `https://worthune.com/api/v1/households/import`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-Api-Key`

Direct rows or template + records. EVERY row is validated; a run with invalid rows still imports the valid ones and answers 422 with the per-row worklist. importKey makes re-runs idempotent; imports never overwrite. No webhooks fire.

## Request body

- union: Send either households[] (direct) or template + records (mapped), never both. At most 200 rows per batch — split larger books.
  - object
    - `households` ImportRow[], required
      - `importKey` string, required: Your own stable id (CRM id, GUID, legacy key). Unique per batch and per org.
      - `label` string: Truncated to 128 chars.
      - `advisorRef` string: Whoever owns the relationship, in your vocabulary. "" is treated as absent. Truncated to 128 chars.
      - `household` Household, required: Household object model v0.2.0 (docs/household-schema-spec.md). Validation rejects, never clamps, and returns every issue with a path. Cross-field rules: ids are unique per collection; every ownerId references a member; exactly one primary member and at most one spouse (a spouse requires married_joint); costBasis only on taxable accounts; holdings fractions sum to 1; a socialSecurity block only on social_security streams; endYear never precedes startYear.
        - `schemaVersion` '0.2.0', required
        - `filingStatus` 'single' | 'married_joint', required: MFS and head-of-household are not modeled (the tax module approximates them with Single ceilings and discloses it).
        - `state` 'AL' | 'AK' | 'AZ' | 'AR' | 'CA' | 'CO' | 'CT' | 'DE' | 'FL' | 'GA' | 'HI' | 'ID' | 'IL' | 'IN' | 'IA' | 'KS' | 'KY' | 'LA' | 'ME' | 'MD' | 'MA' | 'MI' | 'MN' | 'MS' | 'MO' | 'MT' | 'NE' | 'NV' | 'NH' | 'NJ' | 'NM' | 'NY' | 'NC' | 'ND' | 'OH' | 'OK' | 'OR' | 'PA' | 'RI' | 'SC' | 'SD' | 'TN' | 'TX' | 'UT' | 'VT' | 'VA' | 'WA' | 'WV' | 'WI' | 'WY' | 'DC': Two-letter USPS code (or DC). Optional: without it the household gets federal-only tax treatment, stated in the response assumptions.
        - `members` Member[], required
          - `id` string, required: Unique within members.
          - `birthYear` integer, required
          - `relationship` 'primary' | 'spouse' | 'dependent', required
          - `sex` 'male' | 'female': Needed only for longevity-aware Monte Carlo (NCHS sex-specific life table) and for pension-election / claiming strategies; a run that needs it rejects (never guesses) when an adult member lacks it.
        - `accounts` Account[], required
          - `id` string, required: Unique within accounts.
          - `wrapper` 'taxable' | 'traditional' | 'roth' | 'hsa' | 'plan529' | 'cash' | 'annuity', required
          - `ownerId` string, required: Must reference an existing member id (joint taxable accounts: the primary member).
          - `balance` number, required
          - `holdings` Holdings: Asset-class fractions that must sum to 1 (tolerance 1e-9). Omitted means the account is projected as one blended asset.
            - `us_equity` number
            - `intl_equity` number
            - `bonds` number
            - `cash` number
            - `real_assets` number
            - `other` number
          - `costBasis` number: Taxable accounts only (rejected on any other wrapper): total cost basis for gains treatment.
        - `liabilities` Liability[], required
          - `id` string, required: Unique within liabilities.
          - `kind` 'mortgage' | 'auto' | 'student' | 'credit_card' | 'personal', required
          - `balance` number, required
          - `annualRate` number, required: Annual nominal rate as a fraction (0.065 = 6.5%).
          - `monthlyPayment` number, required
        - `incomes` IncomeStream[], required
          - `id` string, required: Unique within incomes.
          - `ownerId` string, required: Must reference an existing member id.
          - `kind` 'salary' | 'self_employment' | 'rental' | 'pension' | 'social_security', required
          - `annualAmount` number, required
          - `startYear` integer: Calendar year, inclusive. Omitted = already flowing.
          - `endYear` integer: Calendar year, inclusive; must not precede startYear. Omitted = lifelong.
          - `growthRate` number: Annual nominal growth as a fraction. Omitted = the run's defaultIncomeGrowth.
          - `socialSecurity` SocialSecurityBlock: Only on kind = social_security. When present the engine COMPUTES the benefit from the §215(a) formula (the ssa-pia model): annualAmount is ignored and startYear defaults to the year the owner reaches claimAge.
            - `aime` number, required: Average Indexed Monthly Earnings, USD/month.
            - `claimAge` integer, required: Whole-year claiming age.
            - `fullRetirementAge` 66 | 67, required
        - `expenses` ExpenseStream[], required
          - `id` string, required: Unique within expenses.
          - `kind` 'living' | 'goal', required
          - `label` string: The only free-text field in the schema. Stripped before computing on the keyless routes (/project, /book-scan) so no identity reaches the engine or the record hash.
          - `annualAmount` number, required
          - `startYear` integer
          - `endYear` integer: Must not precede startYear.
          - `inflationAdjusted` boolean, required: True: the amount is in today's dollars and rides the inflation assumption.
    - `dryRun` boolean: Validate everything, write nothing.
  - object
    - `template` MappingTemplate, required: Declarative mapping template (docs/data-migration.md), executed server-side over raw exported records.
      - `templateVersion` '0.1.0', required
      - `importKey` union, required: A mapping-template node: { "const": v } for a fixed value; { "column", "parse"?, "optional"?, "map"?, "default"? } to read a source column; an array (elements may add omitIfEmpty: true to be dropped when every referenced column is empty); or a plain object built recursively. Bare literals are template-author errors.
        - object
          - `const` unknown, required
        - object
          - `column` string, required
          - `parse` 'string' | 'number' | 'usd' | 'integer' | 'year': Default string (trimmed). Percent parsing is deliberately absent — rates arrive as fractions via number.
          - `optional` boolean: Empty/absent column omits the key instead of erroring.
          - `map` object: Source value → output value; an unmapped value without a default is an error, never a guess.
          - `default` unknown
        - MappingNode[]
        - object
      - `label` union: A mapping-template node: { "const": v } for a fixed value; { "column", "parse"?, "optional"?, "map"?, "default"? } to read a source column; an array (elements may add omitIfEmpty: true to be dropped when every referenced column is empty); or a plain object built recursively. Bare literals are template-author errors.
        - object
          - `const` unknown, required
        - object
          - `column` string, required
          - `parse` 'string' | 'number' | 'usd' | 'integer' | 'year': Default string (trimmed). Percent parsing is deliberately absent — rates arrive as fractions via number.
          - `optional` boolean: Empty/absent column omits the key instead of erroring.
          - `map` object: Source value → output value; an unmapped value without a default is an error, never a guess.
          - `default` unknown
        - MappingNode[]
        - object
      - `advisorRef` union: A mapping-template node: { "const": v } for a fixed value; { "column", "parse"?, "optional"?, "map"?, "default"? } to read a source column; an array (elements may add omitIfEmpty: true to be dropped when every referenced column is empty); or a plain object built recursively. Bare literals are template-author errors.
        - object
          - `const` unknown, required
        - object
          - `column` string, required
          - `parse` 'string' | 'number' | 'usd' | 'integer' | 'year': Default string (trimmed). Percent parsing is deliberately absent — rates arrive as fractions via number.
          - `optional` boolean: Empty/absent column omits the key instead of erroring.
          - `map` object: Source value → output value; an unmapped value without a default is an error, never a guess.
          - `default` unknown
        - MappingNode[]
        - object
      - `household` object, required: Template tree producing a Household document.
    - `records` SourceRecord[], required
    - `dryRun` boolean

## Response `200`

Every row valid (dry or real).

- ImportResponse
  - `ok` boolean, required: True only when every row is valid.
  - `dryRun` boolean, required
  - `summary` object, required: created / alreadyImported are absent on dry runs.
    - `total` integer, required
    - `valid` integer, required
    - `invalid` integer, required
    - `created` integer
    - `alreadyImported` integer
  - `rows` ImportRowResult[], required: Every row, sorted by index.
    - union
      - object
        - `index` integer, required
        - `importKey` string, required
        - `ok` unknown, required
        - `action` 'valid' | 'created' | 'already-imported', required: "valid" on dry runs; "already-imported" rows are returned untouched (imports never overwrite).
        - `householdId` integer: Absent on dry runs.
      - object
        - `index` integer, required
        - `importKey` string, nullable, required
        - `ok` unknown, required
        - `errors` ValidationIssue[], required: Paths are households[i].… or records[i].…-prefixed; mapping failures name the source column.
          - `path` string: JSON-path-ish locator into the request, e.g. "accounts[2].balance".
          - `message` string, required

## Other responses

- `400`: Malformed JSON or a batch-shape problem (not an array, too many rows, both shapes at once, non-string cells).
- `401`: No key presented (Authorization: Bearer wk_… or X-Api-Key).
- `402`: Unrecognized or revoked API key.
- `403`: The key's role is below the operation's floor (viewer < developer < owner), the key predates organization support, or the plan lacks the feature. On a sandbox key, also: the cap of 5 active households on create and import, the cap of 1 active Lodestar mandate, and the white-label surfaces (tenant config, embed tokens) and narration or mapping drafts on Worthune's model (they run on your own: a model assigned at /api/v1/model-policy, or x-model-key), which the sandbox does not carry.
- `422`: At least one row needs fixing — the body still carries every per-row result.
- `429`: Hourly per-org stateful quota, a per-route durable limit, or the 120 requests/minute per-IP burst backstop.
- `503`: Billing is not configured on this deployment; household resources are disabled rather than served without tenancy (production fail-closed).

---

[API](https://skmtc.dev/worthune/apis/worthune-api.md) · [All operations](https://skmtc.dev/worthune/apis/worthune-api/llms.txt) · [OpenAPI document](https://skmtc.dev/worthune/apis/worthune-api/revisions/9f2241072555?raw)
