Guide
How to map a Triguna person record to your own schema
Resolve a profile URL into a person record and map it onto your own person model: current title, tenure, seniority, and the fields worth storing versus re-requesting.
A person record is wide. Most teams need six or seven fields from it, and the work is deciding which those are, extracting them correctly, and knowing which ones go stale.
Start from the decision, not the schema
Write down the decision the record drives before you design a table. “Route enterprise signups to sales” needs company size and job seniority. “Personalize onboarding” needs a first name and maybe a role. Neither needs education history.
Storing the whole record is still worth doing, as raw JSON in one column, but your model should hold only what you query on.
create table people (
member_urn text primary key, -- stable key, never the slug
public_id text not null, -- lookup input only
full_name text,
current_title text,
current_company text,
company_id text, -- join key for company records
country_code text,
source text not null,
fetched_at timestamptz not null,
raw jsonb not null -- the whole payload
);
Extract the current title correctly
This is where most integrations get it wrong. The title is not on the experience group. It is on the first position inside it.
type Position = { title?: string | null; date_range?: DateRange | null };
type ExperienceGroup = {
company_name?: string | null;
company_id?: string | null;
positions?: Position[] | null;
};
export function currentRole(record: PersonRecord) {
const group = record.experience?.[0];
const position = group?.positions?.[0];
// A current role has no end date. If it has one, the person has left.
const isCurrent = position?.date_range?.end == null;
return {
title: position?.title ?? null,
company: group?.company_name ?? null,
companyId: group?.company_id ?? null,
isCurrent,
};
}
Derive tenure rather than storing it
Tenure is a function of a date and today, so storing it means storing something that is wrong
tomorrow. Compute it from date_range.start at read time:
export function tenureMonths(position: Position): number | null {
const start = position.date_range?.start;
if (!start?.year) return null;
const from = new Date(start.year, (start.month ?? 1) - 1, 1);
const to = position.date_range?.end
? new Date(position.date_range.end.year, (position.date_range.end.month ?? 1) - 1, 1)
: new Date();
return Math.max(0, (to.getFullYear() - from.getFullYear()) * 12 + to.getMonth() - from.getMonth());
}
Note start.month ?? 1. Partial dates are the norm, not the exception. { year: 2018, month: null }
is a perfectly ordinary value.
Seniority is your judgement, not a field
Titles are free text and wildly inconsistent across companies. If you need a seniority level, derive it explicitly and keep the rules in your codebase where you can see them fire:
const LEVELS: [RegExp, Seniority][] = [
[/\b(chief|c[teofm]o|founder|president)\b/i, 'executive'],
[/\b(vp|vice president|head of)\b/i, 'vp'],
[/\b(director)\b/i, 'director'],
[/\b(staff|principal|lead|senior|sr\.?)\b/i, 'senior'],
[/\b(intern|graduate|junior|jr\.?)\b/i, 'junior'],
];
export const seniority = (title: string | null): Seniority =>
LEVELS.find(([re]) => title && re.test(title))?.[1] ?? 'unknown';
Two things this buys you: unknown stays visible instead of silently defaulting to mid-level, and
when someone disputes a routing decision you can point at the rule that matched.
Which fields go stale, and how fast
| Field | Changes | Re-request |
|---|---|---|
first_name, last_name | Rarely | Only on demand |
headline, current title | Every 1 to 3 years | Quarterly, or on a trigger |
location | Occasionally | Quarterly |
skills[], education[] | Slowly, additively | Rarely |
network_info.followers_count | Continuously | Do not store it as a fact |
Every re-request is a billed call, so refresh selectively. A nightly refresh of every stored person is the most expensive possible policy and almost never the one you need. See nightly backfill for a shape that costs less.
Handle the empty cases explicitly
Three distinct outcomes look similar in a naive client and need different handling:
404. Your identifier is wrong. Never billed. Log it against the slug so you can audit extraction.200with a thin record. Resolved, but sources held little. Nothing to fix; downstream logic must tolerate nulls.200withcontact_infoabsent. You forgotinclude_contact_info=true. The flags gate whole sections.
Next
- Person profile reference: every field, with parsing notes.
- Company records: the other half of the join.
- Handling partial records: designing for nulls.