API reference
Conventions, errors, and rate limits
Triguna status codes, plain-text error bodies, Retry-After handling, the rate-limit headers, and what each failure means for your credits.
On this page
Every endpoint shares one set of transport conventions.
Overview
| Value | Notes | |
|---|---|---|
| Base URL | https://api.triguna.ai/v1 | |
| Auth | ApiKey: tg_live_… | Custom request header, not Authorization: Bearer. |
| Methods | GET only | No endpoint takes a request body. |
| Content type | application/json | Plain text on error. |
| Versioning | Path segment only (/v1) | No version header. |
| Billing | 1 credit per successful record | Errors and not-founds are never billed. |
Status codes
| Status | Body | Meaning |
|---|---|---|
| 400 | invalid identifier: … | What you sent carries no LinkedIn identifier, usually a display name. Not billed. |
| 401 | invalid api key | Missing, unknown, or revoked key. Not billed. |
| 402 | insufficient credits | Your balance cannot cover the call. Not billed. |
| 404 | profile not found | The identifier parsed but no such record exists. Not billed. |
| 429 | rate limited | You exceeded your per-second allowance. Honour Retry-After. |
| 503 | service busy, retry shortly | Transient capacity or upstream problem. Retryable. |
Only 429 and 503 are worth retrying. 400, 401, 402, and 404 are terminal: retrying
them burns wall-clock time and achieves nothing. Blanket “retry any non-2xx” logic turns a
permanent 401 into a tight loop.
Rate limits
Limits are per account and are set per second, not per month. The default is 10 requests per second, adjustable per account, so a higher tier is a settings change rather than a code change.
Every response, success or 429, carries:
| Header | Type | Meaning |
|---|---|---|
| X-RateLimit-Limit | integer | Requests per second granted to your account. |
| X-RateLimit-Remaining | integer | Tokens left in your bucket right now. |
| Retry-After | seconds | Sent on 429. How long to wait before trying again. |
Behind your per-account bucket there is a global outbound bucket that keeps total traffic inside
the upstream provider’s quota. When that one is saturated your request waits briefly and then
returns a clean 503 rather than queueing indefinitely.
Retries
const RETRYABLE = new Set([429, 503]);
async function withRetry(fn: () => Promise<Response>, max = 2): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await fn();
if (!RETRYABLE.has(res.status) || attempt >= max) return res;
const header = Number(res.headers.get('Retry-After'));
const base = Number.isFinite(header) && header > 0 ? header * 1000 : 5_000;
// Jitter, or a throttled batch retries in lockstep and recreates the burst.
await sleep(base + Math.random() * 500);
}
}
- Honour
Retry-Afterwhen it is present. Fall back to 5 seconds, and give up after about 60. - Watch
X-RateLimit-Remainingand slow down before you hit the wall, rather than after. - Bound your retries. Unbounded retry against a rate limit is how a backfill becomes an outage of your own making.
Timeouts and throughput
Responses can be slow when a record is fetched live, so allow a generous client timeout;
60 seconds is a safe upper bound. If you can tolerate slightly older data, max_age_days
turns most calls into a fast store read. See
provenance and freshness.
- There is no bulk endpoint. Enriching N entities costs N calls and N credits.
- Store the raw JSON, not just the fields you parse today. A stored response lets you pick up unmapped fields later without paying for the call twice.
- Stable keys: for people use
member_urn, for companies usecompany_id. Slugs are user-editable and will break your joins.
A client shape that holds up
type Outcome =
| { ok: true; record: unknown; source: string | null; fetchedAt: string | null }
| { ok: false; kind: 'bad_identifier' | 'not_found' | 'rate_limited' | 'auth' | 'server';
status: number; detail: string };
async function fetchPerson(profileId: string): Promise<Outcome> {
const res = await withRetry(() =>
fetch(`${BASE}/people/profile?profile_id=${encodeURIComponent(profileId)}`, {
headers: { ApiKey: KEY },
signal: AbortSignal.timeout(60_000),
})
);
if (res.ok) {
return {
ok: true,
record: await res.json(),
source: res.headers.get('X-Data-Source'),
fetchedAt: res.headers.get('X-Fetched-At'),
};
}
const detail = await res.text(); // plain text, never JSON
const kind =
res.status === 400 ? 'bad_identifier' :
res.status === 404 ? 'not_found' :
res.status === 429 ? 'rate_limited' :
[401, 402].includes(res.status) ? 'auth' : 'server';
return { ok: false, kind, status: res.status, detail };
}
Each kind has a different owner: bad_identifier and not_found are yours, auth is a
billing or credentials problem, rate_limited is a pacing decision, and server is ours.
Checking your balance
curl -H "ApiKey: $TRIGUNA_API_KEY" https://api.triguna.ai/v1/credits
{ "credits": 4820 }
This call is free and does not count against your rate limit budget in any meaningful way. Poll
it from a dashboard rather than inferring your balance from 402s.