Every Field Is Optional: How to Type and Test Secretary of State API Responses by State

September 25, 2026
September 24, 2026
17 Minutes Read
Secretary of State APIblog main image

Executive Summary: Secretary of State API responses carry whatever each state registry exposes, so one normalized field name can be populated in one state, empty in another, and missing from one record in a state that filled it for another. Cobalt's published TypeScript model marks every business-level field optional, and the five documented test values return dummy data, so they exercise your handling of each response shape rather than what any state returns. This guide shows an integration engineer how to type field presence as data, separate absent from null from empty, validate at runtime against recorded per-state fixtures, and build a test matrix that survives a registry redesign.

Why Do Secretary of State API Responses Differ by State and by Record?

Each of the 50 states and D.C. runs its own business registry and decides what it discloses, and an API that reads them can only pass along what each publishes. Cobalt's help center states the rule plainly: the API "always returns all available fields from the state," and the consumer decides which fields to use or store.[1] The documented field list is a union of what states can return, not a guarantee for any single state.

What does a registry publish, and why does the API mirror it?

Cobalt's help center explains, "Address return varies by state and search stage," because some registries show addresses only on a detail page, and "if it's not exposed, it can't be returned."[2] Not all states return entityType.[3] Officer data appears where a state publishes it and is registry information, not validated identity proof.[4] Registered agent details come from nearly all states, with the help center naming New Jersey and D.C. as jurisdictions where availability is limited.[5] Absence says something about the state before it says anything about the borrower.

Which fields came back from every state in Cobalt's September 2026 measurement?

Cobalt's own measurement on September 11, 2026 searched three large national companies by name in all 50 states and D.C., 153 probes with a 180-second ceiling each. Of the 29 fields the probe checked, eight came back at least once from each of the 51 jurisdictions: status, normalizedStatus, filingDate, normalizedFilingDate, sosId, stateOfSosRegistration, title, and confidenceLevel. That counts jurisdictions, not records: one of the two records Arizona returned had neither filingDate nor normalizedFilingDate. Ten more, including agentName, entityType, and the agent and physical address parts, came back from 41 to 49. Three were state dependent: stateOfFormation from 37, officers from 32, and nextReportDueDate from 23. Per state, 12 of the 29 were populated in Pennsylvania and 25 in Arizona, Virginia, and Washington, with a median of 20. The probe never checked url, documents, history, or the model's other keys.

It covered one day and three well-formed names, so it shows what a state can return, not what it never returns, says nothing about match rates on messy intake names, and is not a service expectation. The coverage-by-state checklist covers how to weigh field depth against the states where you fund most.

Why can two records from the same state differ?

In the same measurement, all three New Jersey probes returned status, normalizedStatus, filingDate, and the full registered agent block, but only one returned officers, and it returned 20 of them. That sits in tension with the help-center note on New Jersey agent data[5] and with Cobalt's field-availability spreadsheet, which leaves New Jersey unmarked for agentName,[6] so only a recorded response tells you what a given record carried.

Cobalt also told a prospect's developer on a 2026 demo call that some states return more fields and some fewer, that a field can be present for some records in a state and missing for others, and that optional fields come back undefined or empty rather than absent. Confirm the current behavior with Cobalt before relying on it. Either way, type presence as data.

What Does "Every Field Is Optional" Mean in the Published Type Model?

Cobalt publishes its response types as the npm package cobalt-int-common. Version 1.0.84, published September 1, 2026 under the ISC license, was still the latest release on September 24.[7] It is the public answer to the same developer's question: should every field be treated as optional for type safety? Cobalt said yes and offered to email the model file it uses in its own code. The call did not say whether that file matches the published package, so pin the package and ask Cobalt whether they differ.

What does the cobalt-int-common IBusiness model declare?

The models file declares IBusiness as "the format of data returned from sos-search," and every property carries the optional marker, including title, status, sosId, and filingDate.[8] Its comment on normalizedStatus reads "Will be 'active' or 'inactive'." normalizedFilingDate is an ISO string, messages is a string array for anything unexpected, and alternativeResults is an untyped array. The model marks taxPayerNumber as used by Texas and ubiNumber as Washington specific. Cobalt's public Node SDK imports IBusiness and IResponseBody from this package,[9] so it is the model Cobalt's own client compiles against.

Where are the required keys hiding inside nested arrays?

The optional rule stops at the element boundary. IOfficer requires name. IDocument requires name and url. IAssumedBusinessName requires title. IUCCData requires fileNumber, debtors, securedParties, type, and filingDate. IHistory requires name, date, and changes.[8] An array may be missing, but an element that exists carries its identity key, and those keys are the assertions your parser can make per element.

Where do the model and the API reference disagree?

Cobalt's public API reference uses the same flat field names, but not the same key list.[10] Its example envelope carries requestId on callback data, nameAvailable, and possibleAlternatives, none of which IResponseBody declares. It marks each result's messages as nullable, which the model does not, and it adds createdAt and updatedAt when liveData is false, keys IBusiness lacks. The model in turn declares keys the reference omits, such as goodStanding for Maryland and taxAmountDue for Delaware.[8] Type the union of both, diff the models file on every upgrade, and let recorded responses settle what your states send. Cobalt's earlier developer field reference types title and status as required and adds keys the reference lacks, such as sosUrl, so check it against both.

How Should You Separate Absent, Null, and Empty Values?

A "missing value" is one of three facts: the key is absent, present with null, or present with an empty string or array. Your domain model must record which.

Where does the public reference show all three?

JSON has a null literal but no undefined: RFC 8259 allows only three literal names, false, null, and true.[11] Whatever the call meant by undefined, the public reference documents all three forms. On a 200 that carries a result, the envelope's message appears only when a live lookup failed or the state was down and recent data was returned; otherwise it is omitted,[10] although the help center's status catalog lists "Business found." for a success.[12] Each result's messages is marked nullable. alternativeResults "may be empty," and a liveData=false search that finds nothing returns an empty results array.[10]

Why does your runtime blur the three by default?

TypeScript reads an optional property as possibly undefined under strictNullChecks, and the handbook's advice is to "handle undefined specially by checking for it."[13] Enable exactOptionalPropertyTypes for the module that types Cobalt responses; the tsconfig reference notes that an in check "would have different behavior with undefined as the key compared to not being defined."[14] Go's encoding/json leaves a plain field unchanged when it meets a JSON null,[15] and Pydantic ignores extra keys by default,[16] so use pointer fields or explicit settings where the distinction matters.

What should your domain model record about presence?

Keep a presence ledger next to the parsed record: for each field you depend on, store present, absent, or not requested. The third value matters because screenshotUrl answers the screenshot request parameter and uccData answers the uccData parameter,[10] and the model's screenshotSucceeded and uccSucceeded flags are returned when those options are requested.[8] An absence you caused is not a data gap. Martin Fowler's Tolerant Reader gives the consumer-side rule: "only take the elements you need, ignore anything you don't."[17]

How Do You Type the Response Envelope and Its Status Values?

The envelope is the one place where the published contract is strict. IResponseBody requires status, typed as an enum of five values: Incomplete, Complete, Failed, Retry Id invalid, and Bad request. Everything else is optional: retryId, statusCode, message, results, alternativeResults, and callbackData.[8]

What does the envelope look like across the five status values?

When a state takes longer than about 20 seconds, Cobalt's help center says the API returns status Incomplete with statusCode 202 and a retryId, which "does not mean the lookup failed."[18] The API reference's description says longer than 30 seconds,[10] so code for the envelope, not the clock. Poll with the retryId, or send a callbackUrl and receive a requestId now and a POST of the completed result later.[19]

The reference documents an example body for each status.[10] Complete arrives with 200: with results, as a near miss carrying a message and alternativeResults but no results key, or with an empty results array. Incomplete arrives with 202 and a retryId. Failed arrives with 500 and a message, sometimes with results the reference describes as matches from Cobalt's database rather than a completed live search. Retry Id invalid arrives with 400 and no message. Bad request arrives with 400 for missing or short parameters, 404 for an unavailable state, and 429 for an exceeded trial limit. The help center's status catalog pairs Bad request and Retry Id invalid with 200 instead,[12] so log statusCode and branch on status.

A request sent with a callbackUrl is acknowledged with 200, a callbackData object, and the status text "Completed request will be sent to callback url."[10] That value sits outside the enum, so give it its own branch in a discriminated union on status, beside a fallback branch, and consume results only on the Complete branch.

How should you handle casing and message text?

The test values are camelCase, retryIdInvalid and badRequest, while the documented statuses read "Retry Id invalid" and "Bad request" with spaces.[10] Never reuse one as the other. Cobalt's SDK branches on the exact string Incomplete when deciding to keep polling,[9] so map the raw string to your own enum at the wire boundary. Documented messages embed the state or the search query, so log them rather than branch on them, including on Complete: the help center lists fallback messages such as "We were unable to complete a live search for this business. We do have recent results, however." for results served from recent data.[12]

What belongs in the typed model versus the domain model?

The typed model is the wire contract and stays as loose as the package. The domain model is your decision object, as strict as your rules require: an entity status from normalizedStatus with the raw status beside it, a registration date from normalizedFilingDate, an agent block rebuilt from the flat agent fields, an officers list that defaults to empty, the presence ledger, your own call identifier, and any retryId or requestId the response carried. Cobalt's SDK waits 10 seconds between polls, and once its retry count passes 70 it returns an object with only a message and no status,[9] exactly the shape a strict status check should route to unresolved.

The illustrative, customer-owned sketch below shows the union and the ledger in TypeScript, with every IBusiness field left optional.

import type { IBusiness } from 'cobalt-int-common';

// Illustrative and customer-owned, not Cobalt's schema.
type Presence = 'present' | 'absent' | 'not_requested';
type PresenceLedger = { [K in keyof IBusiness]?: Presence };
type ParsedRecord = { record: IBusiness; ledger: PresenceLedger };

type SosEnvelope =
  | { status: 'Complete'; statusCode?: number; message?: string; requestId?: string;
      results?: IBusiness[]; alternativeResults?: unknown[] }
  | { status: 'Incomplete'; statusCode?: number; message?: string; retryId?: string }
  | { status: 'Completed request will be sent to callback url.'; statusCode?: number;
      callbackData?: { requestId: string; callbackUrl: string } }
  | { status: 'Failed' | 'Retry Id invalid' | 'Bad request'; statusCode?: number; message?: string }
  | { status: 'unresolved'; raw: unknown }; // any other body, labeled by your parser

Only the Complete branch declares results, so reading them from a Failed body fails to compile.

How Should Runtime Validation Sit Beside Compile-Time Types?

Why is a TypeScript interface not a validator?

Types are erased at compile time, so an all-optional declaration protects nothing at runtime unless a validator enforces it. Zod's optional, nullable, and nullish modifiers accept undefined, null, or both, and safeParse reports a success flag, with issues on its error when validation fails.[20] Pydantic guarantees that a validated model's fields conform to their declared types.[16] Use one at the boundary, and let the interface describe what the validator proved.

Strip, strict, or loose: how should your parser treat unknown keys?

Zod's object schemas strip unrecognized keys by default, throw under strictObject, and pass them through under looseObject.[20] Strict is wrong for a registry-mirroring response, because the first state that adds a field breaks your parser. Strip is safer but discards evidence. Persist the raw body before parsing, parse the keys you use with tolerance, and log unknown keys as drift, not as errors. RFC 1122 states the same rule for network hosts.

"Be liberal in what you accept, and conservative in what you send"[21]

Ian Robinson's consumer-driven contracts essay likewise names "just enough" validation of received messages as a strategy that protects consumers from changes to a provider contract.[22]

Which fields deserve strict parsing, and which deserve tolerance?

Be strict about the fields your rules consume. On the branch where you read results, status must be Complete and normalizedStatus must be present as a string. When present, normalizedFilingDate must parse as the ISO date the model promises, confidenceLevel must fall between 0 and 1, the scale Cobalt's threshold guidance uses,[23] and sosId must be non-empty. The illustrative, customer-owned gate below expresses those rules and nothing else.

{
  "title": "Illustrative customer-owned gate for consuming SOS search results, not Cobalt's schema",
  "type": "object",
  "required": ["status", "results"],
  "properties": {
    "status": { "const": "Complete" },
    "results": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["normalizedStatus"],
        "properties": {
          "normalizedStatus": { "type": "string" },
          "normalizedFilingDate": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}" },
          "confidenceLevel": { "type": "number", "minimum": 0, "maximum": 1 },
          "sosId": { "type": "string", "minLength": 1 }
        }
      }
    }
  }
}

It sets no additionalProperties rule, so JSON Schema's defaults supply the tolerance: "By default any additional properties are allowed," and listed properties are "not required" unless named in required.[24] In Zod, the same gate uses looseObject so unknown keys pass through, with .optional() on each result field except normalizedStatus. The model types normalizedStatus as a plain string,[8] so the gate accepts any string. Cobalt's September 11 measurement recorded "Active" 105 times, "Inactive" 7 times, and "No status found" once, in Delaware, never the lowercase values the model's comment names. After the gate, map active and inactive in any casing to your own enum, and route anything else to review. Stay tolerant elsewhere. entityType values "come directly from each Secretary of State site," some as acronyms and some spelled out, and Cobalt advises mapping them to your own categories.[25] alternativeResults is untyped in the model, and its documented elements carry businessName rather than title,[10] so validate each element before reading it. aiConfidenceLevel is a beta signal Cobalt says not to base production logic on yet.[26]

What Do the Five Test Modes Prove, and What Do They Not Prove?

Cobalt supports a sandbox mode in which you send test parameters and receive dummy responses to validate requests and integration flow before live use.[27] The prospect's developer asked whether test output is properly structured dummy data. The public reference answers part of that: the test parameter "will return dummy data," and its accepted values are complete, incomplete, failed, retryIdInvalid, and badRequest.[10]

What does each test value exercise?

The values line up by name with the five status members, but the reference does not say which example body each returns, so record each value's sandbox response once and compare it with the documented example.

• complete. The documented Complete bodies carry results, a near miss with alternativeResults, or an empty results array. It exercises your Complete branch, presence ledger, and domain mapping.

• incomplete. The documented 202 body carries a retryId and a message. It exercises retryId capture, polling cadence, and never reading results before Complete.

• failed. The documented 500 bodies come with or without database matches in results. It exercises the rule that a Failed body is neither the requested record nor a negative finding.

• retryIdInvalid. The documented 400 body carries no message. It exercises telling an expired identifier from a genuine no-result answer.

• badRequest. The documented bodies use 400, 404, and 429. It exercises parameter validation and keeps malformed requests out of your state-outage metrics.

Each value tests one envelope branch, not what a state puts inside results.

What can only a live call show?

Per-state presence, the keys one registry adds, which documented shape a state produces on a near miss, the statusCode real requests carry, latency, and the polls a slow state needs are live-only facts. The sandbox evaluation checklist puts it in one line: test mode is for response shapes, live calls for state behavior.

How do you turn recorded live responses into per-state fixtures?

Run one live lookup per priority state, persist the raw body, and promote it to a fixture. Pact describes contract testing as checking each application in isolation "to ensure the messages it sends or receives conform to a shared understanding that is documented in a 'contract'."[28] Give each fixture a customer-owned manifest: state, live or test source, recording time, envelope status, pinned model version, the presence map from your ledger, unknown keys, and a pointer to the raw body. A presence map that changes on re-recording is drift you want to see in review, not in production.

How Do You Build a Test Matrix That Survives State Changes?

Which cases belong in the matrix?

Organize the matrix by what can vary.

• Envelope by status. One case per test value, plus live Incomplete cases resolved by polling and by callback, and the callback acknowledgment.

• Presence tiers by state. A fixture per priority state that records presence without asserting it, for the eight fields every jurisdiction returned at least once and for the rarer tiers alike.

• Absent, null, and empty. Three fixtures from one record, with a field removed, set to null, and set to empty, and the ledger asserting the right state each time.

• Nested identity keys. An officers element without a name and a documents element without a url, each rejected at the element level without failing the record.

• Unknown key drift. A synthetic extra key that is logged, kept in the raw body, and never a parse failure.

• Status strings and vocabulary. The test value badRequest beside the status Bad request, Active and active in normalizedStatus mapping to one value while No status found goes to review, and entityType as an acronym and spelled out, both mapping to one category.

• Conditional fields. A request without screenshot asserting not_requested, and one with screenshot where screenshotSucceeded is false and the ledger records absent.

• Alternatives. A near miss with no results key and each alternativeResults element validated before use, plus one with an empty array.

Run the matrix in CI against fixtures and the live subset on a schedule.

How do you detect drift when a state changes its site?

Three signals catch drift early: a scheduled canary lookup per priority state compared against the recorded presence baseline, a diff of the models file on every package upgrade, and the messages array, which the model reserves for anything unexpected, logged in full and reviewed weekly.[8]

Which invariants should never fail?

Some assertions sit above the matrix, because one failure means the integration is wrong. Absence is never scored as a negative finding. A record without normalizedStatus is never a decision input. Nothing is read from results unless the envelope status is Complete. The raw body is persisted before parsing, keyed by an identifier you generate. Every consumed officer has a name and every consumed document has a url. A lookup that exhausts its polling budget is recorded as unresolved, not as failed and not as clear.

Which Data Source Makes This Code Smallest?

Everything above is code you own, whichever vendor sits behind it. The remaining question is which data source keeps that code small, and each of three routes moves the typing problem somewhere different.

What do the alternatives ask you to type?

Building your own state readers gives you full control of the schema and ownership of 51 parsers. In Texas, one public lookup is the Comptroller's franchise tax account status search, which accepts a taxpayer number, an entity name of 2 to 50 characters, or a Secretary of State file number,[29] and every other jurisdiction has its own equivalent. A full-service KYB platform returns its own object instead. Middesk's API reference defines its Business object's status as "The current status of the business lifecycle," one of open, pending, in_audit, in_review, approved, or rejected, and lists known registrations in a separate registrations array.[30] You branch first on the platform's lifecycle, with registry detail one level down. A secondary-source aggregator offers a fixed schema whose stability comes from its refresh cycle, not from the registry today.

Where does Cobalt fit?

Cobalt's Secretary of State API sits in the data layer: one normalized vocabulary across all 50 states and D.C., a published type package its own SDK compiles against, and documentation of the test values, an example body per status, and field behavior. Its downloadable spreadsheet of field-level availability by state, covering address, registered agent, and officer data, is where your presence tiers should start.[31] For foreign filers, the help center says a difference between stateOfFormation and stateOfSosRegistration likely marks a foreign registration,[32] but stateOfFormation came back from 37 of 51 jurisdictions in the September 11 measurement, so record its absence instead of inferring a domestic filing. The integration guide covers connection basics, and the top 34 questions hub collects common questions about the API.

The limitations belong beside the pitch. The field list is a superset, not a per-state guarantee, and officers came back from 32 of 51 jurisdictions in that measurement. Long lookups resolve asynchronously, so a fully synchronous integration will not cover every state. Screenshot and document links expire, so store what you rely on. The model and the reference differ in places, the alternatives array is untyped, and Cobalt says not to base production logic on the beta AI confidence field yet. And Cobalt is a data source, not a decisioning engine: it returns the fields, and you own the rules that read them.

To walk your fixture set and presence ledger through the states in your book, schedule a workflow and integration review.

References

1. Can I limit the fields returned by the API?, Cobalt Intelligence

2. Why didn't some results include address fields we expected?, Cobalt Intelligence

3. Why is entityType sometimes missing?, Cobalt Intelligence

4. How should we treat officer/ownership info from SOS?, Cobalt Intelligence

5. Do you provide registered agent information?, Cobalt Intelligence

6. Fields Available by State, v05272026, Cobalt Intelligence

7. cobalt-int-common, npm

8. cobalt-int-common 1.0.84, dist/models.d.ts, Cobalt Intelligence

9. cobalt-int-sdk, src/sosApi.ts, Cobalt Intelligence

10. Secretary of State API, Cobalt Intelligence

11. The JavaScript Object Notation (JSON) Data Interchange Format, RFC 8259, T. Bray

12. What do the different API response statuses and messages mean?, Cobalt Intelligence

13. Object Types, TypeScript

14. exactOptionalPropertyTypes, TypeScript

15. encoding/json, Go

16. Models, Pydantic

17. Tolerant Reader, Martin Fowler

18. How should we handle long-running SOS searches? What does 'Incomplete' mean?, Cobalt Intelligence

19. What happens if a lookup takes too long to return a response? How do retryId and callbackUrl work?, Cobalt Intelligence

20. Defining schemas, Zod

21. Requirements for Internet Hosts, Communication Layers, RFC 1122, R. Braden

22. Consumer-Driven Contracts: A Service Evolution Pattern, Ian Robinson

23. What confidence thresholds should we use?, Cobalt Intelligence

24. object, JSON Schema

25. Why do entity types sometimes appear differently?, Cobalt Intelligence

26. What is aiConfidenceLevel and should we use it?, Cobalt Intelligence

27. Do you provide a sandbox or test environment for API integration?, Cobalt Intelligence

28. Pact Docs, Pact Foundation

29. Franchise Tax Account Status Search, Texas Comptroller of Public Accounts

30. Business object, Middesk

31. Does Cobalt provide any guidance on which states return which fields?, Cobalt Intelligence

32. Does the API identify foreign corporations?, Cobalt Intelligence