Skip to main content

Automating Company Domain Matching in Laravel and Node.js

Implement company-name-to-domain matching in Laravel 13 and Node.js with explicit handling for authentication, invalid input, limits, outages, timeouts, no-match results, confidence bands, and review.

Sora

Sora

Digital Guide X LinkedIn Website

Sora guides Elvesora’s voice across data, clarity, and growth. She helps teams navigate company data with a focus on accuracy and transparency.

Aug 18, 2026 10 min read 91 views
Laravel and Node.js company domain lookup failure and decision flow

Company domain matching is easy to call and surprisingly easy to automate badly.

A safe integration must distinguish at least three different outcomes:

  • the request could not run, such as a timeout or temporary service failure;
  • the request ran but no reliable domain was found;
  • a domain was returned, but your application still needs to decide what that result may change.

This guide builds that boundary in Laravel 13 and a supported Node.js LTS release. At the time of writing, Node.js 22 and 24 are the supported LTS lines, and both include the stable global fetch API used below. The examples handle authentication errors, invalid input, rate limits, temporary failures, timeouts, no-match responses, and the current 0–100 confidence bands without converting the score to another scale.

What Elvesora returns—and what your application decides

Company Domain Lookup accepts a required company_name and optional additional_context:

POST https://prospecting.elvesora.com/api/prospecting/company
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
  "company_name": "Acme Corporation",
  "additional_context": "Industrial equipment manufacturer"
}

The Domain Lookup API documentation describes response evidence including found, domain, confidence, is_live, reasons, lower_reasons, and cached.

Elvesora returns that lookup evidence. Your application owns the workflow state:

Condition Application state Retry automatically? Safe next action
401 configuration_error No Alert the service owner; do not expose the token error to the end user.
422 invalid_input No Correct the mapped input before another request.
429 rate_limited Later Pause the job and honor server retry guidance when present.
503 retry_later Later Reschedule with a bounded attempt count.
Connection failure or timeout retry_later Later Preserve the source record and reschedule.
Successful response with found: false no_match Not unchanged Add useful context or send the record to review.
Successful response with a domain matched No Apply an action-specific confidence policy.

The application state describes what happened during the lookup. A decision such as review or accepted_for_low_risk describes what the application may do with a successful match.

Do not put every non-200 response into the same failed bucket. A bad token, bad input, exhausted allowance, and transient lookup failure require different owners and different recovery actions.

Use the current confidence scale

Elvesora publishes these operational starting bands:

Confidence Starting route
85–100 High confidence; eligible for low-risk automation when the other evidence agrees.
60–84 Review before routing or writing trusted fields.
Below 60 Investigate or leave unmatched.

The score is already on a 0–100 scale. Do not multiply values below 1 by 100.

The examples below deliberately require all of these conditions before returning accepted_for_low_risk:

  • found is true;
  • a non-empty domain is present;
  • confidence is at least 85;
  • is_live is true;
  • the result is not marked cached.

That is an example application policy, not an additional Elvesora response field. A cached result may still be useful, but the reachability check should not be treated as fresh merely because is_live is true.

Laravel 13 implementation

Laravel's HTTP client returns a response for ordinary 4xx and 5xx statuses; a timeout or connection problem throws ConnectionException. This class handles both paths explicitly:

declare(strict_types=1);

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

final class CompanyDomainLookup
{
    public function lookup(
        string $companyName,
        ?string $additionalContext = null,
    ): array {
        $companyName = trim($companyName);

        if ($companyName === '') {
            return [
                'state' => 'invalid_input',
                'reason' => 'company_name_required',
            ];
        }

        $apiKey = trim(
            (string) config('services.elvesora.prospecting_key'),
        );

        if ($apiKey === '') {
            return [
                'state' => 'configuration_error',
                'reason' => 'api_key_missing',
            ];
        }

        $payload = ['company_name' => $companyName];
        $additionalContext = trim((string) $additionalContext);

        if ($additionalContext !== '') {
            $payload['additional_context'] = $additionalContext;
        }

        try {
            $response = Http::withToken($apiKey)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(3)
                ->timeout(12)
                ->post(
                    'https://prospecting.elvesora.com/api/prospecting/company',
                    $payload,
                );
        } catch (ConnectionException $exception) {
            return [
                'state' => 'retry_later',
                'reason' => 'connection_or_timeout',
                'retry_after_seconds' => null,
            ];
        }

        return match ($response->status()) {
            401 => [
                'state' => 'configuration_error',
                'reason' => 'unauthorized',
            ],
            422 => [
                'state' => 'invalid_input',
                'reason' => 'api_validation_failed',
                'errors' => $response->json('errors', []),
            ],
            429 => [
                'state' => 'rate_limited',
                'reason' => 'usage_limit',
                'retry_after_seconds' => $this->retryAfterSeconds($response),
            ],
            503 => [
                'state' => 'retry_later',
                'reason' => 'lookup_unavailable',
                'retry_after_seconds' => $this->retryAfterSeconds($response),
            ],
            default => $this->handleResponse($response),
        };
    }

    private function handleResponse(Response $response): array
    {
        if (! $response->successful()) {
            return [
                'state' => $response->serverError()
                    ? 'retry_later'
                    : 'request_failed',
                'reason' => 'unexpected_http_status',
                'status' => $response->status(),
            ];
        }

        $result = $response->json();

        if (! is_array($result)) {
            return [
                'state' => 'request_failed',
                'reason' => 'invalid_json_response',
            ];
        }

        $domain = is_string($result['domain'] ?? null)
            ? trim($result['domain'])
            : '';

        if (($result['found'] ?? false) !== true || $domain === '') {
            return [
                'state' => 'no_match',
                'reason' => 'domain_not_found',
                'evidence' => $result,
            ];
        }

        $confidence = is_numeric($result['confidence'] ?? null)
            ? (float) $result['confidence']
            : null;

        if ($confidence !== null && ($confidence < 0 || $confidence > 100)) {
            return [
                'state' => 'request_failed',
                'reason' => 'confidence_out_of_range',
            ];
        }

        $isLive = is_bool($result['is_live'] ?? null)
            ? $result['is_live']
            : null;
        $cached = ($result['cached'] ?? null) === true;

        $decision = match (true) {
            $confidence === null => 'review',
            $confidence >= 85 && $isLive === true && ! $cached
                => 'accepted_for_low_risk',
            $confidence >= 60 => 'review',
            default => 'investigate',
        };

        return [
            'state' => 'matched',
            'decision' => $decision,
            'domain' => $domain,
            'confidence' => $confidence,
            'is_live' => $isLive,
            'cached' => $cached,
            'reasons' => is_array($result['reasons'] ?? null)
                ? $result['reasons']
                : [],
            'lower_reasons' => is_array($result['lower_reasons'] ?? null)
                ? $result['lower_reasons']
                : [],
        ];
    }

    private function retryAfterSeconds(Response $response): ?int
    {
        $value = $response->header('Retry-After')
            ?? $response->json('retry_after');

        if (is_numeric($value)) {
            return max(0, (int) $value);
        }

        if (! is_string($value)) {
            return null;
        }

        $timestamp = strtotime($value);

        return $timestamp === false
            ? null
            : max(0, $timestamp - time());
    }
}

Add the token to server-side configuration rather than source control:

// config/services.php
'elvesora' => [
    'prospecting_key' => env('ELVESORA_PROSPECTING_KEY'),
],

The client does not retry inside a user-facing request. It returns a state that a queue or worker can reschedule. This avoids sleeping through a rate-limit window or retrying invalid input.

A queued job can use a bounded policy such as:

$result = $lookup->lookup($companyName, $additionalContext);

if (in_array($result['state'], ['rate_limited', 'retry_later'], true)) {
    $delay = max(60, (int) ($result['retry_after_seconds'] ?? 60));
    $this->release($delay);

    return;
}

Set the job's maximum attempts and backoff in the job class. Never release configuration_error or invalid_input unchanged; those states need configuration or data correction.

Node.js implementation

This ECMAScript module uses Node's global fetch and AbortSignal.timeout(). It applies the same states as the Laravel example:

const LOOKUP_URL =
  'https://prospecting.elvesora.com/api/prospecting/company';

function retryAfterSeconds(response, body) {
  const value = response.headers.get('retry-after') ?? body?.retry_after;

  if (value === null || value === undefined || value === '') {
    return null;
  }

  if (Number.isFinite(Number(value))) {
    return Math.max(0, Number.parseInt(value, 10));
  }

  const timestamp = Date.parse(String(value));

  return Number.isNaN(timestamp)
    ? null
    : Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));
}

function matchDecision(body) {
  const domain = typeof body.domain === 'string' ? body.domain.trim() : '';

  if (body.found !== true || domain === '') {
    return {
      state: 'no_match',
      reason: 'domain_not_found',
      evidence: body,
    };
  }

  const confidence =
    typeof body.confidence === 'number' &&
    Number.isFinite(body.confidence)
      ? body.confidence
      : null;

  if (confidence !== null && (confidence < 0 || confidence > 100)) {
    return {
      state: 'request_failed',
      reason: 'confidence_out_of_range',
    };
  }

  const isLive =
    typeof body.is_live === 'boolean' ? body.is_live : null;
  const cached = body.cached === true;

  let decision = 'investigate';

  if (confidence === null) {
    decision = 'review';
  } else if (confidence >= 85 && isLive === true && !cached) {
    decision = 'accepted_for_low_risk';
  } else if (confidence >= 60) {
    decision = 'review';
  }

  return {
    state: 'matched',
    decision,
    domain,
    confidence,
    is_live: isLive,
    cached,
    reasons: Array.isArray(body.reasons) ? body.reasons : [],
    lower_reasons: Array.isArray(body.lower_reasons)
      ? body.lower_reasons
      : [],
  };
}

export async function lookupCompanyDomain({
  companyName,
  additionalContext,
  apiKey,
}) {
  const name = String(companyName ?? '').trim();
  const token = String(apiKey ?? '').trim();

  if (name === '') {
    return {
      state: 'invalid_input',
      reason: 'company_name_required',
    };
  }

  if (token === '') {
    return {
      state: 'configuration_error',
      reason: 'api_key_missing',
    };
  }

  const payload = { company_name: name };
  const context = String(additionalContext ?? '').trim();

  if (context !== '') {
    payload.additional_context = context;
  }

  let response;

  try {
    response = await fetch(LOOKUP_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(12_000),
    });
  } catch (error) {
    return {
      state: 'retry_later',
      reason:
        error?.name === 'TimeoutError'
          ? 'timeout'
          : 'connection_error',
      retry_after_seconds: null,
    };
  }

  const body = await response.json().catch(() => ({}));

  if (response.status === 401) {
    return {
      state: 'configuration_error',
      reason: 'unauthorized',
    };
  }

  if (response.status === 422) {
    return {
      state: 'invalid_input',
      reason: 'api_validation_failed',
      errors: body.errors ?? {},
    };
  }

  if (response.status === 429) {
    return {
      state: 'rate_limited',
      reason: 'usage_limit',
      retry_after_seconds: retryAfterSeconds(response, body),
    };
  }

  if (response.status === 503) {
    return {
      state: 'retry_later',
      reason: 'lookup_unavailable',
      retry_after_seconds: retryAfterSeconds(response, body),
    };
  }

  if (!response.ok) {
    return {
      state:
        response.status >= 500 ? 'retry_later' : 'request_failed',
      reason: 'unexpected_http_status',
      status: response.status,
    };
  }

  return matchDecision(body);
}

Pass the token from server-side process configuration:

const result = await lookupCompanyDomain({
  companyName: event.companyName,
  additionalContext: event.companyContext,
  apiKey: process.env.ELVESORA_PROSPECTING_KEY,
});

If result.state is rate_limited or retry_later, reschedule the durable job. Do not keep a webhook request open while waiting, and use the upstream event ID or organization ID to make the job idempotent.

Persist evidence and decision separately

The response does not decide whether to merge accounts, overwrite a trusted domain, or start enrichment. Store application decisions separately from lookup evidence so the workflow remains explainable later.

{
  "source_record_id": "crm-account-4831",
  "submitted_company_name": "Acme Corporation",
  "lookup": {
    "state": "matched",
    "domain": "acme.com",
    "confidence": 92,
    "is_live": true,
    "cached": false,
    "reasons": ["Company name matches website brand"],
    "lower_reasons": []
  },
  "policy": {
    "version": "company-domain-policy-2026-07",
    "requested_action": "prepare_enrichment",
    "decision": "accepted_for_low_risk"
  }
}

source_record_id, policy, requested_action, and decision are application-owned fields. They are not additional fields returned by Company Domain Lookup.

Once the application accepts a domain for enrichment, send that domain to Elvesora Enrichment. A company lookup is evidence for that handoff, not permission to perform unrelated record merges. If enrichment is the next step, keep domain resolution as a separate identity stage before company data is attached.

Test the states before connecting production

Fake the HTTP client and cover at least:

Test fixture Expected result
401 configuration_error; no retry
422 invalid_input; no retry
429 with retry guidance rate_limited; delayed retry
503 retry_later; delayed retry
timeout/connection exception retry_later
200, found: false no_match; domain remains blank
confidence 59 investigate
confidence 60 review
confidence 84 review
confidence 85, live, not cached accepted_for_low_risk
confidence 85, cached or not live review
missing confidence review

Also assert that no test sends a real request. Laravel provides Http::fake() and Http::preventStrayRequests() for this purpose.

Security and operational checklist

  • Keep the bearer token in server-side configuration.
  • Never log the token or full request headers.
  • Preserve the source record when transport fails.
  • Set maximum job attempts and a dead-letter or failed-job path.
  • Make webhook and batch work idempotent.
  • Do not retry 401 or 422 unchanged.
  • Honor server retry guidance when available; otherwise use bounded backoff.
  • Keep no_match, review, and retry_later as different states.
  • Track outcomes by source: match, review, no-match, timeout, rate limit, and corrected decision.

A reliable integration keeps transport failures, no-match results, and application decisions separate. That boundary makes retries safer, reporting clearer, and downstream automation easier to control.

Sora

Sora

Digital Guide

Sora guides Elvesora’s voice across data, clarity, and growth. She helps teams navigate company data with a focus on accuracy and transparency.

Related reading