Guide
How to store and refresh Triguna company records
Resolve a company URL into firmographics, normalize headcount and country codes, decide which fields to store versus re-request, and avoid the platform_url mix-up.
Company records are narrower than person records and change more slowly, which makes them the easier half to store, provided you get three normalizations right up front.
The three normalizations
Headcount. staff_count and staff_range are independently nullable, so neither alone is a
reliable number:
export function headcount(staff: StaffInfo | null | undefined): number | null {
if (staff?.staff_count != null) return staff.staff_count;
const r = staff?.staff_range;
if (!r?.start) return null;
return r.end == null ? r.start : Math.round((r.start + r.end) / 2);
}
Store the derived number and a flag for whether it was exact. A segmentation rule that treats an estimated 350 the same as a counted 350 will eventually be questioned, and you want to be able to answer.
Country codes. locations.headquarter.country is a lowercase two-letter code ("pt").
Uppercase it before joining against ISO data, or the join silently returns nothing.
Website URLs. company_url may carry http://, https://, or no scheme at all. Normalize
before storing, and store the host separately if you match on domain:
export function normalizeSite(url: string | null): { href: string; host: string } | null {
if (!url) return null;
const withScheme = /^https?:\/\//i.test(url) ? url : `https://${url}`;
try {
const u = new URL(withScheme);
return { href: u.href, host: u.hostname.replace(/^www\./, '') };
} catch {
return null;
}
}
A table that ages well
create table companies (
company_id text primary key, -- stable id, not the slug
public_id text not null,
name text,
website_host text, -- normalized, www stripped
country_code char(2), -- uppercased
city text,
primary_industry text, -- industries[0].name
headcount integer,
headcount_exact boolean not null default false,
funding_type text, -- last round only
auto_generated boolean not null default false,
source text not null,
fetched_at timestamptz not null,
raw jsonb not null
);
create index on companies (website_host);
industries[0] is the primary industry. The array is ordered, so taking the first entry is
correct rather than arbitrary.
Treat auto_generated as a quality gate
auto_generated: true means the page was created by the platform rather than by the company.
Those records are thin and frequently wrong. Whatever the record feeds, whether scoring, routing, or
display, decide deliberately what happens when this flag is set. Most teams exclude them from
scoring and show them with a caveat.
if (record.auto_generated) {
// Do not score. Surface it, flag it, and move on.
return { usable: false, reason: 'auto_generated_page' };
}
Funding is one round, not a history
funding_data.last_funding_round is the last round only. There is no per-round history in the
response. If you need a funding trajectory, you need another source; if you need “is this company
venture-backed and roughly how late-stage”, the last round answers it.
Two details that break naive parsers:
money_raised.amountis a raw number in whole currency units and can exceed 32-bit range. Store it asbigint.money_raised.currencyis lowercase ("usd"). Uppercase before formatting.
Refresh policy
Company fields move slowly, which means a fixed nightly refresh is almost pure waste:
| Field | Realistic cadence |
|---|---|
company_name, company_url | On demand only |
industries, specialities | Yearly |
| headcount | Quarterly |
funding_data | Quarterly, or on a news trigger |
followers_count | Never store as a fact |
Better than a schedule: refresh on access, with a staleness threshold. If a record is read and its
fetched_at is older than your threshold, queue a refresh. Records nobody reads never cost you
anything.
const STALE_AFTER_DAYS = 90;
export function needsRefresh(row: CompanyRow): boolean {
const age = (Date.now() - row.fetched_at.getTime()) / 86_400_000;
return age > STALE_AFTER_DAYS;
}
Next
- Company details reference: every field.
- Nightly backfill: pacing, ceilings, and retries that do not double-spend.
- Person records: the other half of the join, via
company_id.