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.
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:
foundis true;- a non-empty domain is present;
- confidence is at least 85;
is_liveis 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, andretry_lateras 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.