Blog
Provenance lives in the headers: how to tell where enrichment data came from and how old it is
Provenance on an enrichment response lives in the headers, not the body. Here is what the six response headers tell you and the decision each one should drive.
Two enrichment responses can carry byte-for-byte identical JSON and still mean different things. One was read at source a second ago. The other is a stored copy, handed back during an outage because the live read was failing. The body cannot tell them apart. The headers can.
Every answered lookup returns a small set of headers. Read them and you know where the data came from, how old it is, what it cost, and how close you are to a rate limit. Ignore them and you are trusting a JSON blob with no idea whether it is a fresh read or a week-old fallback.
Provenance is in the headers, never the body
A successful GET /v1/people/profile or GET /v1/companies/details looks like this on the wire:
HTTP/1.1 200 OK
X-Data-Source: store
X-Fetched-At: 2026-07-28T09:14:22Z
X-Data-Age-Seconds: 331088
X-Credits-Charged: 1
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
There is no "source" field inside the JSON body, and there is no "fetched_at" key next to the person’s name. Provenance is metadata about the retrieval, so it rides in the headers where it belongs. That is a deliberate contract: the body is the record, the headers describe how you got it.
In code you read them off the response, not out of the payload:
const res = await fetch(
'https://api.triguna.ai/v1/people/profile?entity_urn=ACoAAB1xExample',
{ headers: { ApiKey: process.env.TRIGUNA_API_KEY } }
);
const source = res.headers.get('X-Data-Source'); // live, store, or stale_fallback
const ageSeconds = Number(res.headers.get('X-Data-Age-Seconds'));
const charged = Number(res.headers.get('X-Credits-Charged'));
X-Data-Source tells you how much to trust the row
X-Data-Source is one of three values, and each one is a different level of confidence.
| Value | What it means | What you do |
|---|---|---|
live | Read at source for this request | Trust it fully |
store | Served from the store, under 24 hours old | Trust it; a live read would return the same bytes |
stale_fallback | The live read was failing, so a stored copy came back instead of an error | Use it, but flag the record to re-read once the source recovers |
The first two are the normal path. A store response is not a downgrade: within the 24 hour window a live fetch would return the identical payload, which is exactly why leaving use_cache on costs you nothing in freshness. The one that deserves handling is the third.
if (source === 'stale_fallback') {
// The live source was failing, so this is a stored copy of unknown age.
// Use it now, but queue the record for a fresh read later.
queueReread(entityUrn);
}
X-Data-Age-Seconds is the freshness clock, with one honest caveat
X-Data-Age-Seconds is how many seconds ago the row was fetched. X-Fetched-At is the same moment as a timestamp. Together they let you make a read-time freshness decision without a separate lookup: if the age is past your tolerance for that field, re-read with use_cache=false.
The caveat matters, so here it is plainly. X-Data-Age-Seconds measures how long ago the retrieval happened, not how old the underlying data is. A live response can still carry data that was cached at source up to 24 hours earlier. Only use_cache=false guarantees a genuine read at source. If your decision turns on knowing the true freshness of a field rather than the freshness of your copy, that distinction is the whole game, and it is the same reasoning behind setting a refresh cadence by field, not by calendar.
X-Credits-Charged is your billing receipt, per call
Every answered lookup bills one credit, and X-Credits-Charged states it on the response itself. A store hit charges one credit because it is the same data delivered faster. A 404 charges one credit because searching for an identifier that turns out to have no record is a real retrieval. Sum this header across a run and you have reconciled your bill before the invoice arrives, which is the method laid out in what an enrichment run costs.
Free statuses charge nothing, so you will see X-Credits-Charged: 0 on a 429 or a 402. If you are debugging a surprise bill, this header is the ground truth, and the Person API and Company API both return it on every call. Per-credit pricing is on the pricing page.
X-RateLimit-Remaining and Retry-After keep you under the limit
Two more headers exist so you can pace yourself instead of driving into errors:
X-RateLimit-Limit: 10 # requests per second granted to you
X-RateLimit-Remaining: 7 # tokens left right now
Watch X-RateLimit-Remaining and self-throttle. X-RateLimit-Remaining can read higher than X-RateLimit-Limit; that surplus is a burst allowance, a bucket of spare tokens on top of the steady per-second rate. When you do exceed the limit, the 429 carries a Retry-After in seconds, and honoring it is the correct backoff:
if (res.status === 429) {
const waitMs = Number(res.headers.get('Retry-After')) * 1000;
await sleep(waitMs);
// then retry the same request
}
The error body is plain text, so branch on the status code
One more reason to read the status and headers rather than the body: error bodies are plain text, not JSON. Parsing them for control flow breaks the moment the wording changes. A real one looks like this:
HTTP/1.1 402 Payment Required
content-type: text/plain; charset=utf-8
insufficient credits
Branch on the status code. Retry 429, 502, and 503 with backoff; never retry 400, 401, 402, or 404, because they will not change on their own and a retried 404 costs a credit every time. That last trap, and the identifiers that cause it, are covered in why lookups return 404 when the identifier looks correct.
Log the headers next to the record
The headers are only useful if you keep them. When you store an enrichment result, write the provenance alongside the payload:
alter table people
add column data_source text, -- live, store, or stale_fallback
add column fetched_at timestamptz, -- from X-Fetched-At
add column credits_charged int; -- from X-Credits-Charged
Now every row can answer, months later, where it came from and how old it was. That is the audit case behind storing the raw payload: the body is the record, and these three columns are the receipt that proves how you got it. It is also what makes a first-touch enrichment defensible when you enrich on signup and someone later asks why an account was routed the way it was.
Provenance on every response is a design choice, not an add-on, and it is one of the principles Triguna is built on. The full header reference lives in the caching and freshness docs; you can start reading them off real responses with five free credits at signup.
Next
- How often should you re-enrich a record? sets the freshness tolerance these headers let you enforce.
- What an enrichment run costs reconciles
X-Credits-Chargedinto a bill. - Enrich on signup is the pattern where provenance at first touch pays off.
- The Person API and Company API return these headers on every call.