> ## Documentation Index
> Fetch the complete documentation index at: https://docs.freshtalent.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build your first integration

> Search people, add results to a List, and poll events safely.

This REST walkthrough searches people, creates or reuses a dedicated List, adds up to five matches, then collects that List’s events into a local checkpoint file. It does not send outreach or modify an ATS.

## Before you run

* Use Node.js 22+ and a server-side API key for an organization with active access.
* Keep the key and the generated state file out of git.
* Run only one copy of this script at a time. It writes a List in your organization.

<Warning>Zero events is a valid result. Adding someone to a List does not fabricate a profile change.</Warning>

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
export FRESHTALENT_API_KEY='YOUR_API_KEY'
node first-integration.mjs
```

## Runnable script

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { readFile, writeFile, rename } from 'node:fs/promises';

const key = process.env.FRESHTALENT_API_KEY;
if (!key) throw new Error('Set FRESHTALENT_API_KEY');
const base = 'https://api.freshtalent.ai/v1';
const listName = process.env.FT_LIST_NAME || 'API tutorial';
const stateFile = process.env.FT_STATE_FILE || 'freshtalent-demo-state.json';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

async function api(path, { method = 'GET', body } = {}) {
  // Retry reads only. A timed-out write may have succeeded.
  for (let attempt = 0; ; attempt++) {
    let response;
    try {
      response = await fetch(`${base}${path}`, {
        method,
        headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
        body: body ? JSON.stringify(body) : undefined,
        signal: AbortSignal.timeout(30_000),
      });
    } catch (error) {
      if (method !== 'GET' || attempt === 3) throw error;
      await sleep(500 * 2 ** attempt + Math.random() * 250); continue;
    }
    const text = await response.text();
    let payload; try { payload = text ? JSON.parse(text) : null; } catch { payload = null; }
    if (response.ok) return payload;
    const message = payload?.error?.message || payload?.message || response.statusText;
    const retryable = method === 'GET' && (response.status === 429 || response.status >= 500);
    if (!retryable || attempt === 3) throw Object.assign(new Error(`HTTP ${response.status}: ${message}`), { status: response.status });
    const retryAfter = Number(response.headers.get('retry-after'));
    await sleep(Math.max(Number.isFinite(retryAfter) ? retryAfter * 1000 : 0, 500 * 2 ** attempt) + Math.random() * 250);
  }
}

let state;
try { state = JSON.parse(await readFile(stateFile, 'utf8')); }
catch (error) { if (error.code !== 'ENOENT') throw error; state = { listId: null, checkpoint: null, events: {} }; }

const params = new URLSearchParams({ nl: process.env.FT_QUERY || 'FDEs open-to-work', view: 'compact', limit: '5' });
const search = await api(`/people?${params}`);
console.log('Effective query:', search.query);

// Resolve before creating: list creation is not an upsert.
let list;
try { list = (await api(`/lists/${encodeURIComponent(listName)}`)).data; }
catch (error) {
  if (error.status !== 404) throw error;
  if (!search.data.length) { console.log('No matches. Broaden FT_QUERY.'); process.exit(0); }
  list = (await api('/lists', { method: 'POST', body: { name: listName, description: 'REST tutorial' } })).data;
}
if (list.is_default || (state.listId && state.listId !== list.id)) throw new Error('Use a dedicated list and matching state file');

// Skip existing people: repeating Add members can reset stage and notes.
const existing = (await api(`/lists/${encodeURIComponent(list.id)}`)).data;
const inList = new Set(existing.members.map(member => member.person_linkedin_url));
const slugs = [...new Set(search.data.filter(person => person.slug && !inList.has(person.linkedin_url)).map(person => person.slug))];
if (slugs.length) console.log(await api(`/lists/${encodeURIComponent(list.id)}/members`, { method: 'POST', body: { slugs, stage: 'to-contact' } }));

// One absolute lower bound per pass. Cursors page backward toward older events.
const startedAt = new Date().toISOString();
const prior = state.checkpoint ? Date.parse(state.checkpoint) : Date.now() - 86_400_000;
const since = new Date(prior - 5 * 60_000).toISOString();
let cursor = null;
for (;;) {
  const query = new URLSearchParams({ since, list: list.id, limit: '100' });
  if (cursor) query.set('cursor', cursor);
  const page = await api(`/events?${query}`);
  for (const event of page.data) state.events[event.id] ??= event; // replace with an idempotent ATS write
  if (!page.meta.has_more) break;
  if (!page.meta.next) throw new Error('Missing event cursor');
  cursor = page.meta.next;
}

// Checkpoint only after every event write in the pass succeeds.
state.listId = list.id; state.checkpoint = startedAt;
await writeFile(`${stateFile}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
await rename(`${stateFile}.tmp`, stateFile);
console.log(`Saved checkpoint ${startedAt}`);
```

## Why this is safe to rerun

* The script resolves a List before creating it, so a timed-out create is inspected rather than blindly replayed.
* It skips people already on the List because a repeated add can overwrite stage and notes.
* It uses one fixed `since` while paging; an event cursor is only for older pages in that pass.
* It overlaps each polling window by five minutes and deduplicates on `event.id`. Store deduplication and checkpoints in a database for production.
* It saves the checkpoint only after the whole event pass succeeds. Replace the local sink with an idempotent ATS write or transactional outbox.

## Production checklist

* Do not reuse the final cursor for the next scheduled poll. Start a new pass with the last successful checkpoint minus a small overlap.
* Treat `person.linkedin_url` as the ATS/CRM join key and `event.id` as the activity deduplication key.
* Honor `Retry-After` on `429`; retry bounded GETs on `429`/`5xx`, but reconcile uncertain POST/PATCH requests before retrying.
* Event visibility is evaluated at request time and there is no published retention SLA. Add periodic reconciliation if completeness matters.
* For exact repeat searches, save and replay the echoed `query` on people search. Target discovery uses a different schema and is not an exact replay of the graph search. See [Search query compatibility](/api-reference/targets/create#search-query-compatibility).

Next: [Event polling contract](/api-reference/events/list#ordering-cursors-and-polling), [add members safely](/api-reference/lists/add-members#partial-success-and-repeat-calls), and [ATS/CRM architecture](/connect/ats-crm).
