Design enrichment pipelines

Enrich records through several APIs with queued, idempotent stages, store where each fact came from, detect schema drift, and refresh stale data on a budget.

  • 45 min
  • Expert

The workflow in the previous lesson answered one question, once, while a user waited. Enrichment is the other half of most multi-API systems: a table of records (leads, accounts, suppliers, domains) that you annotate with facts from several APIs, keep up to date, and query later. The CRM shows the company's tech stack. The risk team filters accounts by domain age. The support tool shows a customer's local time from their phone number.

The difference in time scale changes the design. A request-path workflow lives for two seconds and can afford to start over. An enrichment pipeline lives for years, sees millions of records, survives deploys and outages mid-run, and is asked six months later "where did this value come from, and is it still true?" This lesson is about building one that can answer.

From loop to queue

The first version of every enrichment job is a loop: for each record, call the APIs, write the results. It works for a thousand records and fails at a hundred thousand, for reasons that have nothing to do with the APIs:

  • A crash at record 61,000 means either starting over (paying again for 60,999 records) or hand-editing a resume point.
  • One slow API holds every record behind it, even records that only need the fast ones.
  • A rate-limit pause or exhausted credits stops everything, including stages that would have succeeded.
  • There is nowhere to put a record that will never succeed, so it is retried on every run, forever.

A queue fixes the structure. Each unit of work is a job for one record and one stage, where a stage is one API applied to one input derived from the record:

StageAPIInput from the recordTypical re-check interval
phonephonenumbervalidatorphone, country180 days
whoiswhoislookupdomain of website30 days
stacktechstackdetectorwebsite30 days

The intervals are design choices you make from how fast the underlying facts change, not values any API prescribes. Phone number metadata barely moves. Registrations change hands. Websites change their stack on a quarter's timescale.

Workers pull jobs, run one stage, write the result, and acknowledge the job. Stages for the same record run independently, so a WHOIS outage delays only WHOIS. Failed jobs go back on the queue with a delay. Jobs that can never succeed go to a dead-letter queue where a person can look at them. You can use a managed queue, a database table with a locked_until column, or a library; the design below does not depend on which.

Make every stage idempotent

Almost every queue delivers at least once. A worker that crashes after calling the API but before acknowledging the job will see that job again. Your stages must therefore be safe to run twice: the second run must leave the same state as the first, and ideally should not pay twice.

Two mechanisms give you that.

Key each result by what was asked, not by when. The result of a stage is a pure function of the API and its normalised parameters, so use those as the storage key. A second run computes the same key and overwrites the same row instead of appending a duplicate.

Check before calling. If a fresh result for that key already exists, skip the call. This turns a re-delivered job into a cheap read, and it also deduplicates across records: two leads at the same company share one WHOIS result, one call, one charge.

stage-key.mjs
import { createHash } from 'node:crypto';

export function normalise(stage, params) {
  const out = {};
  for (const k of Object.keys(params).sort()) {
    let v = String(params[k]).trim();
    if (k === 'domain') v = v.toLowerCase().replace(/^www\./, '');
    if (k === 'country') v = v.toUpperCase();
    out[k] = v;
  }
  return out;
}

export function stageKey(stage, api, params) {
  const canonical = JSON.stringify([stage, api, normalise(stage, params)]);
  return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
}

Normalise only what the API genuinely treats as equivalent. Lower-casing a domain is safe. Stripping www. is a judgement you make for your use case: it is right for WHOIS, where the registration belongs to the bare domain, and wrong for a tech-stack scan, where www might be served by a different system. That is why normalise receives the stage.

Store provenance with every fact

A value without a source is a rumour. Every row your stages write should answer five questions on its own: which API produced it, with what input, when, from which version of the endpoint, and at what cost. Store the outcome too, including failures, so that "we tried and the domain is unregistered" is distinguishable from "we never tried".

A table shape that works in any SQL database:

CREATE TABLE enrichment (
  key           TEXT PRIMARY KEY,   -- stageKey(stage, api, params)
  stage         TEXT NOT NULL,
  api           TEXT NOT NULL,
  params        TEXT NOT NULL,      -- normalised JSON
  outcome       TEXT NOT NULL,      -- 'ok' | 'not_found' | 'invalid' | 'error'
  data          TEXT,               -- the API's data object, verbatim
  http_status   INTEGER,
  api_version   TEXT,               -- x-api-version response header
  shape         TEXT,               -- schema fingerprint, see below
  credits       INTEGER,            -- catalog cost when billed, else 0
  fetched_at    TEXT NOT NULL,
  expires_at    TEXT NOT NULL
);
CREATE TABLE record_enrichment (record_id TEXT, stage TEXT, key TEXT, PRIMARY KEY (record_id, stage));

The second table links records to results. Many records can point at one result, which is how deduplication shows up in the data model. Store the API's data object verbatim, and derive the columns your product reads from it in a separate step. When you later realise you needed a field you did not extract, it is already there and costs nothing to backfill from storage.

Each APIVerve response carries x-api-version, the version of the endpoint that served it. It is one of the headers exposed to browsers as well as servers, so any client can read it. Store it with the result. If the response you are processing does not carry it, store null rather than a guess.

Here is the worker that ties those pieces together:

worker.mjs
import { stageKey, normalise } from './stage-key.mjs';
import { shapeOf } from './shape.mjs';

const CREDITS = { phonenumbervalidator: 10, whoislookup: 8, techstackdetector: 30 };
const TTL_DAYS = { phone: 180, whois: 30, stack: 30 };

export class Retry extends Error {}
export class PauseQueue extends Error {}

export async function runStage(job, db) {
  const params = normalise(job.stage, job.params);
  const key = stageKey(job.stage, job.api, params);

  const existing = await db.getResult(key);
  if (existing && new Date(existing.expires_at) > new Date()) {
    await db.link(job.recordId, job.stage, key);           // re-delivery or duplicate: no call
    return existing;
  }

  const url = new URL(`https://api.apiverve.com/v1/${job.api}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  let res;
  try {
    res = await fetch(url, { headers: { 'x-api-key': process.env.APIVERVE_API_KEY }, signal: AbortSignal.timeout(15_000) });
  } catch {
    throw new Retry(`${job.api}: network or timeout`);
  }
  const body = await res.json().catch(() => null);

  if (res.status === 429) {
    if (res.headers.get('x-api-remaining-credits') === '0') throw new PauseQueue('credits exhausted');
    throw new Retry('rate limited');
  }
  if (res.status === 401 || res.status === 403) throw new PauseQueue(`${job.api}: HTTP ${res.status}, check the key`);
  if (res.status >= 500 || (res.ok && body?.status !== 'ok')) throw new Retry(`${job.api}: HTTP ${res.status}`);

  const outcome = res.ok ? 'ok' : res.status === 404 ? 'not_found' : res.status === 400 ? 'invalid' : 'error';
  if (outcome === 'error') throw new Retry(`${job.api}: HTTP ${res.status}`);
  const row = {
    key, stage: job.stage, api: job.api, params: JSON.stringify(params), outcome,
    data: outcome === 'ok' ? JSON.stringify(body.data) : null,
    http_status: res.status,
    api_version: res.headers.get('x-api-version'),
    shape: outcome === 'ok' ? shapeOf(body.data) : null,
    credits: outcome === 'ok' ? CREDITS[job.api] : 0,
    fetched_at: new Date().toISOString(),
    expires_at: new Date(Date.now() + TTL_DAYS[job.stage] * 864e5).toISOString(),
  };
  await db.upsertResult(row);                                 // same key, same row: idempotent
  await db.link(job.recordId, job.stage, key);
  return row;
}

The error classification is the part most pipelines get wrong:

  • 400 and 404 are answers. Bad input will be bad on every retry, and "nothing found" is a fact about the input. Both are stored as outcomes with an expiry, so the record is not retried on every run, and a person can query them.
  • Rate limits and 5xx are transient. Throwing Retry sends the job back with a delay; your queue's backoff does the rest.
  • Exhausted credits stop the queue, not the job. Every job on the key will fail the same way. PauseQueue should stop all workers until the cycle renews or someone tops up. The check reads the header as a string, so a missing header (null) can never be mistaken for zero.
  • 401 and 403 are configuration problems: a wrong or revoked key, or a key scoped away from this API. Retrying will not fix them and the data is not at fault, so they pause the queue and should alert a person, just as exhausted credits do.
  • Failures never overwrite good data. The upsertResult only runs for real outcomes. A transient error leaves last month's valid result in place until a fresh one replaces it.
Check yourself

A worker calls whoislookup, writes the result row, then crashes before acknowledging the job. The queue redelivers it. With the design above, what happens?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales