Pattern

How to enrich a user on signup without slowing it down

Call Triguna once at account creation, keep it off the signup request path, backfill asynchronously, and handle the failures that would block a new user.

7 min read Updated 31 July 2026

Enrichment on signup is the most common integration shape, and the most commonly done wrong. The mistake is putting a call that can take tens of seconds inside a request a human is waiting on.

The rule: never on the request path

Responses can be slow. 60 seconds is a safe client timeout, not a typical latency. A signup request must not wait for one.

// POST /signup
export async function signup(input: SignupInput) {
  const user = await db.users.create(input);       // fast, synchronous

  await queue.publish('enrich.user', {             // fire and forget
    userId: user.id,
    companyDomain: domainOf(input.email),
  });

  return user;                                     // the human is done waiting
}

Everything downstream, whether routing, scoring, or personalization, reads enrichment when it exists and degrades gracefully when it does not. If a feature cannot degrade, it does not belong in the signup flow.

Resolve an identifier before you spend a call

Both endpoints key off slugs. If all you have is an email domain, you do not yet have an identifier, and calling with a company display name returns 404 every time.

function companySlug(input: SignupInput): string | null {
  // Best case: the user gave you a company URL.
  if (input.companyUrl) return slugFrom(input.companyUrl, 'company');

  // Otherwise you have a resolution problem, not an enrichment problem.
  return null;
}

The worker

export async function enrichUser(job: { userId: string; companyDomain: string }) {
  const user = await db.users.find(job.userId);
  const slug = companySlug(user);
  if (!slug) {
    await db.users.markEnrichment(user.id, 'unresolvable');
    return; // not a failure, there was nothing to call with
  }

  const outcome = await fetchCompany(slug);   // handles 429 + Retry-After internally

  if (!outcome.ok) {
    if (outcome.kind === 'not_found') {
      await db.users.markEnrichment(user.id, 'not_found');
      return;                                  // terminal: do not retry a slug bug
    }
    if (outcome.kind === 'auth') {
      alert('triguna credentials or credits');  // page someone; retrying will not help
      return;
    }
    throw new Error(outcome.detail);            // let the queue retry server errors
  }

  await db.companies.upsert(mapCompany(outcome.record));
  await db.users.markEnrichment(user.id, 'done');
}

Three distinct terminal states (unresolvable, not_found, done) instead of a boolean. When someone asks “why does this account have no company data”, the answer is already stored.

Idempotency

Queues redeliver. Send an Idempotency-Key derived from the entity, not from the attempt, so a redelivery is not billed twice:

headers: {
  ApiKey: KEY,
  'Idempotency-Key': `signup:${user.id}:company`,
}

Keying on the attempt (crypto.randomUUID()) defeats the point, because every retry becomes a new billable call.

Cap the blast radius

A signup spike is a call spike. Two cheap protections:

  • A per-key credit ceiling. Past the ceiling the API returns 402 instead of silently spending. That is a monitoring event, not an outage.
  • A queue concurrency limit. Pace calls rather than bursting; there is no bulk endpoint, so a thousand signups is a thousand calls whatever you do, and the only question is over how long.

What good looks like in metrics

MetricWatch for
enrich.queued vs enrich.doneA widening gap means the worker is behind, not that the API is down
not_found_rateA step change means your slug extraction broke
rate_limited_rateSustained non-zero means your concurrency is too high
p95 signup latencyMust be unchanged by enrichment. If it moved, something is on the request path

That last row is the one worth alerting on. It is the only failure the user actually feels.

Next

Start with one request

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