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.

8 min read Updated 31 July 2026

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

FieldChangesRe-request
first_name, last_nameRarelyOnly on demand
headline, current titleEvery 1 to 3 yearsQuarterly, or on a trigger
locationOccasionallyQuarterly
skills[], education[]Slowly, additivelyRarely
network_info.followers_countContinuouslyDo 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.
  • 200 with a thin record. Resolved, but sources held little. Nothing to fix; downstream logic must tolerate nulls.
  • 200 with contact_info absent. You forgot include_contact_info=true. The flags gate whole sections.

Next

Start with one request

Free credits on signup, no card required. Usage-based pricing after that.