# TX Franchise Tax Entity Status Delta (`titan_coder/tx-franchise-tax-entity-status-delta`) Actor

Watches named Texas businesses in the Comptroller Franchise Tax registry, alerting only on a genuine SOS status or right-to-transact change (Forfeited, delinquent, reinstated). For lenders, title companies, due-diligence, procurement compliance. Free when nothing changes.

- **URL**: https://apify.com/titan\_coder/tx-franchise-tax-entity-status-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 entity status changeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## TX Franchise Tax Entity Status Delta

Durable monitor for the official **Active Franchise Taxpayers** registry, published by the Texas
Comptroller of Public Accounts as open data on `data.texas.gov`. Watch specific business entities by
their Texas Taxpayer Number and get notified only when their SOS status or right-to-transact-business
status genuinely changes. No API key needed, no login, no captcha.

### Source

`https://data.texas.gov/resource/9cir-efmm.json` — the Socrata Open Data API for "Active Franchise
Taxpayers" (dataset id `9cir-efmm`). Confirmed live 13.09.2026: **3,454,402 rows**, table last refreshed
2026-09-12T10:10:15Z (daily refresh). Despite the dataset's name, it is NOT limited to currently-active
businesses — it publishes the full disciplinary spectrum: `sos_status_code` (A=Active 2,803,556, R=344,483,
**F=Forfeited 57,789**, plus rarer codes, and 240,573 rows with no SOS status at all — legitimate for
taxpayers never registered with the Secretary of State) and `right_to_transact_business_code` (A=OK
3,258,835+, D=Delinquent 20,303, N=Not in good standing 129,757, present on 100% of rows).

`taxpayer_number` (Texas Comptroller Taxpayer Number, **not** the Secretary of State file number and
**not** an EIN) is the dataset's identifier for one taxpayer — confirmed live: RECORDS live **11 digits**
long, no leading zero observed across all 3.45M rows. It is almost, but not perfectly, unique:
`count(distinct taxpayer_number)` = 3,454,371 vs `count(*)` = 3,454,402 — **31 values are duplicated**
(exact-content duplicate rows, confirmed live on one such value). The Secretary of State file number was
evaluated as an alternative key and rejected: `count(distinct ...)` = 3,213,780 (240,622 short of total) —
too many blank/colliding values to serve as a primary key.

### How it works

1. Each `watch` names exactly **one** business entity by `taxpayerNumber` (the Texas Comptroller Taxpayer
   Number, exactly 11 digits) — look it up at
   [mycpa.cpa.state.tx.us/coa/](https://mycpa.cpa.state.tx.us/coa/) (Texas Taxable Entity Search) if you
   only have a company name. One watch = one point query (`$where=taxpayer_number='<taxpayerNumber>'`) =
   one HTTP request per run.
2. The first check of a new watch establishes a baseline (no charge). Every later check compares the
   current pair `(sosStatusCode, rightToTransactCode)` against the durable record of what it was last
   time.
3. Billing is tied **only to that pair** — not to name, address, NAICS code, or any other administrative
   field that can change without affecting legal standing. A status that changes and later reverts (e.g.
   Forfeited, then reinstated, then Forfeited again for a new lapse) bills every genuine transition, not
   just the first one — never silently deduplicated against an earlier occurrence of the same status
   value.
4. Genuinely new (first found) or status-changed records are pushed to the dataset and billed once each
   (`entity-status-changed`); a check that finds nothing new costs nothing beyond the fixed platform run
   cost.

### Input

```json
{
  "monitorId": "my-tx-franchise-watch",
  "watches": [
    { "watchId": "vendor-abc", "taxpayerNumber": "32095750629" }
  ],
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

Add more businesses later under the same `monitorId` — each watch keeps its own independent history. A
`watchId` is permanently bound to the `taxpayerNumber` it first saw; pointing the same `watchId` at a
different taxpayer number later fails the run instead of silently mixing histories.

`socrataAppToken` is optional — `data.texas.gov` does not require a key for this dataset, but a free
Socrata app token from your own account raises the anonymous request-rate ceiling if you run many watches
across many monitors.

### Output row (per change)

`watchId, taxpayerNumber, changeType ("new"|"status_changed"), sosStatusCode, previousSosStatusCode,
rightToTransactCode, previousRightToTransactCode, taxpayerName, taxpayerAddress, taxpayerCity,
taxpayerState, taxpayerZip, taxpayerCountyCode, taxpayerOrganizationalType, recordTypeCode,
responsibilityBeginningDate, secretaryOfStateFileNumber, sosCharterDate, sosStatusDate,
currentExemptReasonCode, exemptBeginDate, naicsCode, monitorId, runId, discoveredAt, eventId, billed`

### Billing

Pay-per-event: `entity-status-changed` — charged only for a watch's first found record (baseline is free)
or a genuine transition of the `(sosStatusCode, rightToTransactCode)` pair since the previous check.
Failed/blocked checks are never charged.

### Important — read before relying on this for any lending/due-diligence/procurement decision

**This registry is a publication of Comptroller franchise-tax and SOS-status data, not a certification of
good standing or a complete corporate/legal record.** The Texas Secretary of State and Comptroller
maintain their own authoritative records; the public dataset reflects what has been published, which can
lag real-world filings. **This actor is an informational monitor of CHANGES to that public publication —
it is NOT a current "good standing" certification, NOT a full UCC/lien/litigation check, and NOT legal
advice.** Always confirm directly at
[mycpa.cpa.state.tx.us/coa/](https://mycpa.cpa.state.tx.us/coa/) or SOSDirect before acting on any single
entry — especially for lending, title/closing, vendor onboarding, or procurement compliance decisions.

### Delivery guarantee: at-most-once (we would rather lose an alert than bill you twice)

Each computed change is delivered to the dataset and charged **at most once**, for as long as the
monitor's claim log exists (see the boundary below). Before any irreversible step (writing the row,
charging the event) the run takes an **atomic claim** on that exact change, using the only atomic
primitive the Apify platform offers: a request queue's unique-key insert. Exactly one run can win that
claim for a given change. The claim log is never consumed, deleted or rotated by this actor; it is a
permanent record of what was already attempted, and `coverage.claimJournalSize` reports its size each run
so you can watch it grow (the platform's counter is eventually consistent, so treat it as a lagging
estimate, not an exact count).

**Where that guarantee ends — the honest boundary.** The claim log lives in a *named* request queue
(`<prefix>-<monitorId>-claims`) in your own account. The at-most-once guarantee holds as long as that
queue keeps existing. If you — or any process holding your account credentials — delete, rename or
re-create it from the Console or API, the log starts empty and previously delivered changes can be
delivered and charged again. That is the unavoidable boundary of *any* durable storage, not a loophole in
the protocol. For the same reason, the actor's storage prefix and internal claim namespace are frozen
after release: changing either would create a fresh, empty log with exactly the same effect.

The response the platform returns for each claim is interpreted **strictly**: only a real boolean `false`
grants the right to write and charge, only a real boolean `true` denies it, and anything else — a missing
field, `null`, `0`, an empty string, a changed SDK response shape — aborts the run's delivery for that
item with `claim_protocol_error` **before** any row or charge. An answer we do not fully understand is
never read as "you may charge".

One thing we deliberately do **not** claim: the monitor's lease makes overlapping runs a fail-closed
exception rather than a fact of life, but between the moment a run verifies it still holds the lease and
the moment the dataset write or charge actually lands there is an unavoidable time gap (the platform
offers no fencing token for datasets or billing). So "a run that lost the lease can never write another
row" would be an overstatement. What actually protects your money is the claim above: the key is already
taken, so even a ghost run cannot charge for the same change twice.

The honest consequence, stated plainly: **if a run dies after taking the claim but before finishing, that
one change is lost**. It is recorded as `dataset_unknown` or `charge_unknown` and it is **not**
re-delivered on the next run — the next run moves on to the entity's next status change. We deliberately chose possible loss
of one alert over the possibility of charging you twice for the same event. This is *at-most-once*
delivery, not *exactly-once*; any actor that claims exactly-once over a store without compare-and-swap is
overstating what the platform can do.

Practically this only happens if the Apify run is killed mid-delivery (platform abort, timeout, migration).
Every such case is visible: the run's `coverage` and `run_summary` report it, and `run_summary.eventsBilled`
plus Apify's own billing ledger remain the source of truth for what you actually paid for.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.**
  Each row is written to the durable dataset first, then mirrored to the run's default dataset before
  billing proceeds for that row. If the durable write succeeds but the default-dataset mirror write
  fails (e.g. transient Apify storage error), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the
  run is not retried into re-creating that exact row. The durable dataset can therefore end up with a
  small number of orphan rows that were never mirrored and never billed. The **default dataset is the
  canonical log of rows successfully written to this run's output** (see its `run_summary` row) — but a
  default-dataset row does not by itself prove the row was billed: the row is written before
  `Actor.charge()` runs, so if charging then fails or comes back `charge_unknown`, the row is present but
  not confirmably paid. **`run_summary.eventsBilled` and Apify's own billing ledger are the source of
  truth for confirmed payment**, not the presence of a row in either dataset.
- **One watch = one business entity, one request.** There is no bulk/roster mode — to track a portfolio,
  add one watch per entity (up to 30 per run). This keeps the network cost fixed and predictable
  regardless of the registry's total size (3.45M+ rows), and keeps each entity's history independently
  auditable.
- **A "not found" result on the very first check of a watch is reported honestly, not as an error** —
  `coverage.watches[].matched: false`. The registry does not delete records (confirmed live 13.09.2026:
  entities forfeited in 1974/1978/1980, and entities chartered in 1843/1860/1866, are all still returned
  by a direct point query), so a typo'd `taxpayerNumber` will simply never match; it costs nothing and is
  safe to correct and retry under the same `watchId`.
- **A `taxpayerNumber` that WAS found on a previous check but is NOT found on a later one** is treated as
  `source_access_limited` for that watch this run — no baseline/history update, no billing. Since the
  registry doesn't delete rows (see above), this should never happen from a genuine data change; it's the
  honest fallback if the source ever answers unexpectedly.
- **A known, rare defect of this specific source, documented rather than silently patched over:** 296 of
  3,454,402 rows (confirmed live 13.09.2026) carry `taxpayer_number` with one trailing space baked into
  the value itself (e.g. `"10101858008 "` instead of `"10101858008"`). Socrata does exact string matching
  — a clean 11-digit query for that value returns **zero rows**; only a query including the literal
  trailing space finds it. Because `taxpayerNumber` input is validated as exactly 11 digits, these 296
  entities (0.009% of the registry) cannot be watched by their number as printed elsewhere — this is a
  source data-quality gap, not an actor bug, and is called out here rather than worked around with a fuzzy
  match that would weaken the exact-id guarantee below.
- **Every response is checked that the raw `taxpayer_number` value returned equals, byte-for-byte, the
  value that was requested — before any trimming.** This is not a theoretical concern for this source: see
  the trailing-space defect above. If the source were ever to answer a clean query with a padded or
  otherwise different raw id (a matching-behavior change on Socrata's end), that mismatch is treated as
  `source_access_limited` for this watch, not silently accepted.
- **`taxpayerNumber` is a near-unique key, not a perfectly unique one.** 31 of 3,454,402 values are
  duplicated (exact-content duplicate rows, confirmed live). A watch on one of those 31 values reports
  `source_access_limited` (2+ rows on what should be a unique key) rather than silently picking one row.
- **Billing tracks only the pair `(sosStatusCode, rightToTransactCode)`**, deliberately excluding name,
  address, NAICS code, and date fields that can change administratively (a move, a rebrand, an updated
  annual report) without any change to the entity's legal standing before the state. Those fields are
  still delivered in every row for context.
- **`sosStatusCode` can legitimately be absent** — confirmed live: 240,573 of 3,454,402 rows carry no
  `sos_status_code` at all, typically taxpayers never registered with the Secretary of State (sole
  proprietors, certain partnerships). This is not an error; the watch simply reports `null` until/unless
  the entity later registers.
- We don't invent data: if the API ever returns something other than a bare JSON array, more than one row
  for a single `taxpayer_number` (violating the dataset's own near-uniqueness), or a row missing
  `taxpayer_number`, the run reports it honestly (`source_access_limited`) instead of guessing.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars).

## `watches` (type: `array`):

1-30 objects: {"watchId": "vendor-abc", "taxpayerNumber": "32095750629"}. taxpayerNumber is the Texas Comptroller's 11-digit Taxpayer Number as published in the Franchise Tax registry (not the Secretary of State file number, not the EIN) — look it up at mycpa.cpa.state.tx.us/coa/ (Texas Taxable Entity Search) if you only have a company name. One watch = one business entity. New businesses can be added later under the same monitorId.

## `socrataAppToken` (type: `string`):

Optional. data.texas.gov does not require a key for this dataset, but a free Socrata app token (from your own data.texas.gov account) raises the anonymous request-rate ceiling if you run many watches across many monitors. Leave empty for normal use — a handful of watches per run works fine without one.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when new/changed paid status changes were delivered; always — post it every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a digest of delivered (paid) status changes as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-tx-franchise-watch",
  "watches": [
    {
      "watchId": "example-forfeited-entity",
      "taxpayerNumber": "32095750629"
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

## `results` (type: `string`):

Every row this run produced. Key fields: watchId, taxpayerNumber, changeType (new|status\_changed), sosStatusCode, previousSosStatusCode, rightToTransactCode, previousRightToTransactCode, taxpayerName, taxpayerCity, taxpayerState. Informational only — not legal advice or a good-standing certification.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch status/reason/matched, records delivered and billed, requested/attempted/succeeded/failed watch counts. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "monitorId": "my-tx-franchise-watch",
    "watches": [
        {
            "watchId": "example-forfeited-entity",
            "taxpayerNumber": "32095750629"
        }
    ],
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/tx-franchise-tax-entity-status-delta").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "monitorId": "my-tx-franchise-watch",
    "watches": [{
            "watchId": "example-forfeited-entity",
            "taxpayerNumber": "32095750629",
        }],
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/tx-franchise-tax-entity-status-delta").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "monitorId": "my-tx-franchise-watch",
  "watches": [
    {
      "watchId": "example-forfeited-entity",
      "taxpayerNumber": "32095750629"
    }
  ],
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/tx-franchise-tax-entity-status-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/tx-franchise-tax-entity-status-delta"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/jIAn2xX4pYFbU7zOB/builds/ncoALwLA1jbwRJ5x7/openapi.json
