| route | status | at build |
|---|---|---|
| GET /accounts/bulk | 404 | deployed |
| POST /accounts/resolve | 404 | deployed |
| POST /accounts/map | 404 | deployed |
This page is generated from the document that specifies these routes. A status above that is not 200 is what the route itself returned when this page was built.
gtm_signals.accounts.account_id, which is acc_ plus 32 hex characters.
That is the id for every account we hold, public company or subsidiary. There is exactly one id space; a subsidiary is not a second class of thing with a different kind of key.
acc_f88b1990cf1942c7998e5f8e56079b3b S&P Global Inc. public acc_4ef7cc624fb14fffaae04a2e9eeaaa47 International Tower Hill Mines Ltd.
It is minted once, never recycled, never reissued to another company, and it does not change when the company renames itself, changes ticker, moves exchange, relists, or is acquired. A row that turns out to be a duplicate is merged with lifecycle_status = 'merged' and resolved_account_id pointing at the survivor, so an id a customer already stored still resolves rather than 404s.
The ticker is a convenience, never the id. Three reasons, all of them things that have already happened in our own data:
- Tickers change. FB became META, FISV became FI. A join on ticker breaks silently on the day of the change and nothing errors.
- Tickers are recycled and share-classed. We hold 5,815 ticker values across 5,321 accounts, plus 856 exchange-qualified tickers; BAC^N and BAC are one company.
- 213,912 of our 219,233 accounts are subsidiaries and have no ticker at all. Andrew's question answers itself here: there is no ticker for a sub account, and there was never going to be one.
A domain is not enough either, which is the other half of what Andrew said. Measured today: only 4,170 of 219,233 accounts carry a domain; 28 domains are claimed by more than one account (informatica.com by 37, brookfield.com by 7, tableau.net by 5); and 61 accounts carry yahoo.com as their primary domain, which is an upstream scrape defect, not an identity. A subsidiary's CRM record almost always carries the parent's website, because whoever enriched it looked the brand up rather than the entity. Section 5 is about that specific trap.
---
Counts are accounts carrying at least one value of that kind, measured 2026-09-19 against the box.
| Identifier | Accounts | What it is, and how far it goes |
|---|---|---|
| cik | 5,173 | SEC filer id. Globally unique among filers, stable for life, never reused. The strongest public id we have for a US filer. |
| lei | 7,404 | GLEIF legal entity identifier, twenty characters. Globally unique, and the only identifier here that covers subsidiaries and non-US entities on the same footing. 4,733 of them are on subsidiaries. |
| ticker | 5,321 | Convenience. See above. |
| exchange_ticker | 856 | NASDAQ:AAPL shaped. Better than a bare ticker, still not an id. |
| domain | 4,170 | Registrable domain. Useful, not sufficient. |
| duns | 0 | We have NO DUNS source. A DUNS a customer sends about its own record is stored against our account with that customer as its source and the record it arrived on as its evidence, so their next import and the next customer's join on it directly at 1.00. We never buy, infer or invent one. docs/DUNS_ROUTES.md is the Needs Andrew. |
| linkedin_company | 4,369 | The company page slug, normalized. Was 3,650 on the morning of 2026-09-19; 1,163 accounts had a URL in gtm_signals.company_linkedin that had never been promoted into account_identifiers, which took public filer coverage from 69.8 to 83.5 percent for zero spend. |
| legal name plus jurisdiction | 219,233 names, 7,421 with a legal jurisdiction, 4,617 with a state of incorporation | A name alone is not an identity. A name plus an agreeing jurisdiction is the standard the GLEIF anchor already uses and is rung 8 here. |
| former names | 2,823 filers carry at least one SEC former name | How a customer's stale record still finds the company after a rename. |
| the EX-21 parent link | 213,804 exhibit names, 195,823 of them with a jurisdiction | A subsidiary name is only an identity inside its parent's exhibit. That is why parent_hint exists. |
Two numbers worth stating plainly rather than burying:
- 10,140 accounts are companies we assert exist: 5,321 public filers plus 4,819 resolved subsidiaries (4,724 through GLEIF, 95 through a legacy proof).
- 209,093 are EX-21 evidence: a name in an exhibit with the filing URL, not yet identified. They are served only when a caller asks for them, they come back with resolution_status: "unresolved" and is_company: false, and they are not a claim that a legal entity of that exact name exists.
---
POST /accounts/resolve on the search service, resolve on the MCP's find_companies, scripts/account_resolver.resolve() in Python. It takes any combination of account_id, cik, lei, duns, ticker, exchange_ticker, domain, linkedin_company, legal_name plus country or state, parent_hint, and a customer's own external_id plus external_system.
Each identifier is evaluated on its own and votes for an account. Strongest first, with the confidence a lone hit earns:
| # | Rung | Confidence |
|---|---|---|
| 0 | account_id | 1.00 |
| 1 | lei | 1.00 |
| 2 | cik | 1.00 |
| 3 | duns (only one a customer has told us before) | 0.97 |
| 4 | exchange_ticker | 0.97 |
| 5 | ticker | 0.95 |
| 6 | domain | 0.92 |
| 7 | linkedin_company | 0.92 |
| 8 | legal_name plus an agreeing country or state | 0.85 |
| 9 | former_name | 0.80 |
| 10 | ex21_parent (the name is an EX-21 child of a parent that itself resolved) | 0.80 |
| 11 | legal_name alone | 0.60, and flagged weak |
Each further rung that agrees adds 0.03, capped at 1.00, and the answer names what corroborated it.
- resolved: every rung that fired agrees on one account. You get account_id, the tree position (parent, ultimate parent, whether it is itself the top), method, confidence, and the evidence list of what was compared and what it hit.
- conflict: two rungs resolved to different accounts. Unresolved, both named. Their row's ticker and their row's domain disagreeing is usually their CRM telling us two records were merged, and guessing in our own favour would bury it.
- ambiguous: one rung matched two or more accounts. Unresolved, candidates returned. Two candidates is ambiguity, never a match, at every rung without exception. A rung stronger than the winning one that could not choose also stops the answer: if a better identifier than the one we are about to trust was unable to decide, neither do we.
- no_match: nothing matched. The nearest names come back as candidates with their trigram score, labelled in words as candidates and not matches.
A fuzzy name score never matches anything by itself. It only ever fills in the candidate list on an answer that has already said no.
And an EX-21 evidence node is never a match. 209,093 of our 219,233 accounts are rows minted from a name in an exhibit that we have never identified. The bulk pull already excluded them (is_company: false), and the resolver did not, so on the first run over the the tenant GBV book 618 of 1,549 matches, forty percent of them, were names in somebody's exhibit: a customer would have been handed an account_id that the same API refuses to serve. They now come back as an explicit no match with the node as a labelled candidate. allow_unresolved is the deliberate opt in for a caller that wants the evidence layer, and every resolved answer carries resolution_status and is_company so the distinction is on the wire.
---
A CRM record for a subsidiary carries the parent's website, because whoever enriched it looked the brand up. So the domain lands on the parent while the row's own name is the subsidiary's, and an answer of "the parent" looks right and is wrong.
Measured on the synthetic book (section 7): three of the four wrong answers were exactly this shape. The guard now is: when the caller gave a name, the winning rung was not a name rung, the matched account's own name (suffix kept) does not agree with it, and that account has subsidiaries whose names are nearer, we do not answer. We hand back the parent and those subsidiaries as candidates and say which trap we think we are in. Precision went from 0.973 to 0.993 and the match rate moved by half a point.
The way through it is an identifier, not a better guess: the subsidiary's LEI (4,733 of them now carry one), or the exact legal name plus a parent_hint.
---
Ours. The customer dedupes on account_id and nothing else.
Ticker collisions, name variants, share classes, a subsidiary sharing its parent's domain: all of that is collapsed onto one account_id before a row leaves the bulk pull. If two of our rows ever carry the same identifier under the same id_type, that is a bug on our side to report, not a merge for a customer to perform. The table's primary key is (id_type, id_value), so the database refuses it.
The bulk row still carries an identifiers map so a customer CAN join on a ticker or a domain if their pipeline already does. They should not have to, and the moment they do, the failure modes in section 1 become theirs.
GET /accounts/bulk says this in its own answer, in a dedupe field, on every page.
---
app.tenant_account_map (migration 064), one row per (tenant_id, external_system, external_id), with the account_id, the method, the confidence, the evidence, first_seen, last_seen and reviewed_by. Once a row exists, every trigger hit, export and answer can carry the customer's own id back, which is the difference between "a signal at Salesforce" and "a signal on the record your rep already owns".
Rows we refuse to guess go to app.tenant_account_map_review with the candidates and the reason (ambiguous, no_match, conflict). A row in the map is a match; nothing else is in the map.
account_id is never silently re-pointed. A database trigger refuses a weaker match overwriting a stronger one unless a person signs it with reviewed_by.
DUNS: we have no DUNS source and we do not buy, infer or invent one. When a customer gives us one on a row that matched some other way, it is stored as an identifier on that account so their next import matches on the DUNS directly. A DUNS another account already claims is reported as a collision and nothing is written.
Doors: POST /accounts/map, /accounts/map/list, /accounts/map/review, /accounts/map/coverage on the search service; scripts/import_tenant_accounts.py for a file; find_companies with external_id plus external_system on the MCP.
---