Tutorials

Native PHP 8.3: Auto-Enrich CRM Leads with Website Data API

Native PHP 8.3: Auto-Enrich CRM Leads with Website Data API

A salesperson should not have to copy a company name, telephone number, public email address, and staff details from a website before creating a lead. The website already contains much of that information; the useful engineering problem is turning it into a reliable, reviewable draft.

This tutorial builds that workflow in native PHP 8.3. A CRM sends one website URL to an internal endpoint, which calls the Website to Company data service, validates its response, and returns five enrichment fields: company, contact, email, phone, and people. The salesperson reviews the result before saving it, so enrichment improves data entry without silently overwriting human decisions.

Prerequisites

  • PHP 8.3 or newer with the cURL and JSON extensions
  • Composer
  • A public company website to use during manual verification
  • An existing CRM screen capable of posting a website and applying the returned draft

The implementation uses native cURL rather than a framework or general-purpose HTTP package. Its transport remains behind an interface, which gives production code explicit timeout control and gives tests a deterministic fake.

Get access and copy the service token

First, register an account, or sign in if you already have one.

Open the Website to Company data service page. Choose the available Free, Plus, or Pro plan that fits the intended workload, then complete its activation.

Next, open the official service documentation. Find the Service token panel and copy the service-scoped token shown there. Regenerating this token revokes the previously active token, so token rotation must update every deployed instance that uses it.

This service does require a token; it has no tokenless calling mode. Authentication uses the token={serviceToken} query parameter, not an Authorization header.

Confirm the exact request

The supplied contract uses GET at https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Send both token and website as query parameters. Before writing application code, make one bounded test request:

curl --get \
  --connect-timeout 3 \
  --max-time 15 \
  --data-urlencode "token=YOUR_SERVICE_TOKEN" \
  --data-urlencode "website=https://example.com" \
  "https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"

Replace the example website with a public company site when verifying real enrichment. Keep the placeholder in documentation, fixtures, screenshots, and source control.

Shape the application boundary

The CRM should never depend directly on an unvalidated remote JSON document. Our boundary accepts only a JSON object and maps the contract’s five named fields into a domain DTO. Values may be scalar, structured, or null; the mapper deliberately avoids guessing undocumented nested fields.

The project is intentionally small:

crm-enrichment/
├── .env
├── .gitignore
├── composer.json
├── public/
│   └── index.php
├── src/
│   └── WebsiteCompany.php
└── tests/
    └── WebsiteCompanyClientTest.php

Create the configuration and install PHPUnit 11, which supports this PHP 8.3 project:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  }
}
composer install
composer dump-autoload
printf '%s\n' '.env' >> .gitignore

Place the copied credential in .env. PHP does not load this file automatically; the front controller below loads it for this standalone project. In production, the same variable can be injected by the process manager or secret store instead.

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN

Build the bounded native cURL transport

The transport disables redirects, applies separate connection and total deadlines, captures response headers, and throws a transport-specific exception without including the credential-bearing URL in its message.

<?php
// src/WebsiteCompany.php

interface HttpTransport
{
    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse;
}

final readonly class TransportResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers = []
    ) {}
}

final class TransportException extends RuntimeException {}

final class CurlTransport implements HttpTransport
{
    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse {
        $headers = [];
        $requestUrl = $url . '?' . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        $handle = curl_init($requestUrl);
        if ($handle === false) {
            throw new TransportException('Could not initialize HTTP transport');
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
            CURLOPT_TIMEOUT_MS => (int) ($responseTimeout * 1000),
            CURLOPT_USERAGENT => 'crm-website-enrichment/1.0',
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
        ]);

        try {
            $body = curl_exec($handle);
            if ($body === false) {
                throw new TransportException(
                    'Remote request failed with cURL error ' . curl_errno($handle)
                );
            }

            return new TransportResponse(
                (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
                $body,
                $headers
            );
        } finally {
            curl_close($handle);
        }
    }
}

Map the response and classify failures

The client retries network failures, HTTP 429 responses, and server-side 5xx responses at most three times. It honors a numeric Retry-After value but caps the wait at two seconds. Authentication and ordinary validation failures are never retried: another identical request would only waste quota and delay the user.

<?php
// Append to src/WebsiteCompany.php

final readonly class LeadEnrichment
{
    public function __construct(
        public mixed $company,
        public mixed $contact,
        public mixed $email,
        public mixed $phone,
        public mixed $people
    ) {}

    public static function fromPayload(array $payload): self
    {
        $read = static function (string $key) use ($payload): mixed {
            $value = $payload[$key] ?? null;

            if (
                $value !== null
                && !is_scalar($value)
                && !is_array($value)
            ) {
                throw new IntegrationFailure(
                    'malformed_response',
                    null,
                    "Unsupported value for {$key}"
                );
            }

            return $value;
        };

        return new self(
            $read('company'),
            $read('contact'),
            $read('email'),
            $read('phone'),
            $read('people')
        );
    }

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }
}

final class IntegrationFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status,
        string $message
    ) {
        parent::__construct($message);
    }
}

final class WebsiteCompanyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';

    private Closure $sleep;

    public function __construct(
        private readonly string $token,
        private readonly HttpTransport $transport,
        ?Closure $sleep = null
    ) {
        if (trim($token) === '') {
            throw new InvalidArgumentException('Service token is missing');
        }

        $this->sleep = $sleep ?? static fn (int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function enrich(string $website): LeadEnrichment
    {
        $website = trim($website);
        $parts = parse_url($website);

        if (
            filter_var($website, FILTER_VALIDATE_URL) === false
            || !is_array($parts)
            || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || empty($parts['host'])
            || isset($parts['user'])
            || isset($parts['pass'])
        ) {
            throw new InvalidArgumentException('Enter a valid HTTP or HTTPS website');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    self::ENDPOINT,
                    ['token' => $this->token, 'website' => $website],
                    3.0,
                    15.0
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new IntegrationFailure(
                        'network',
                        null,
                        'Enrichment service is unreachable'
                    );
                }

                ($this->sleep)($this->backoff($attempt));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new IntegrationFailure(
                        'malformed_response',
                        $response->status,
                        'Enrichment service returned invalid JSON'
                    );
                }

                if (!is_array($payload) || array_is_list($payload)) {
                    throw new IntegrationFailure(
                        'malformed_response',
                        $response->status,
                        'Enrichment response must be a JSON object'
                    );
                }

                return LeadEnrichment::fromPayload($payload);
            }

            $retryable = $response->status === 429
                || $response->status >= 500;

            if ($retryable && $attempt < 3) {
                $retryAfter = $response->headers['retry-after'] ?? null;
                $delay = is_string($retryAfter) && ctype_digit($retryAfter)
                    ? min(2000, (int) $retryAfter * 1000)
                    : $this->backoff($attempt);

                ($this->sleep)($delay);
                continue;
            }

            $kind = match (true) {
                in_array($response->status, [401, 403], true) => 'authentication',
                $response->status === 429 => 'rate_limited',
                $response->status >= 500 => 'upstream',
                default => 'invalid_request',
            };

            throw new IntegrationFailure(
                $kind,
                $response->status,
                'Enrichment request was not completed'
            );
        }

        throw new LogicException('Retry loop ended unexpectedly');
    }

    private function backoff(int $attempt): int
    {
        return min(2000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }
}

The DTO preserves the five documented top-level values. A CRM-specific adapter can subsequently turn a structured company or people value into its own fields. Keeping that interpretation outside the API client prevents undocumented assumptions from spreading through the application.

Expose an internal CRM endpoint

The front controller accepts POST /lead/enrich with {"website":"https://..."}. It returns a draft rather than writing to the database. That separation makes review, cancellation, and correction ordinary UI actions.

<?php
// public/index.php

require dirname(__DIR__) . '/vendor/autoload.php';

$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
    $values = parse_ini_file($envFile, false, INI_SCANNER_RAW) ?: [];
    foreach ($values as $name => $value) {
        if (getenv($name) === false) {
            putenv("{$name}={$value}");
        }
    }
}

header('Content-Type: application/json');
$requestId = bin2hex(random_bytes(8));
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || $path !== '/lead/enrich') {
    http_response_code(404);
    echo json_encode(['error' => ['kind' => 'not_found']]);
    exit;
}

try {
    $input = json_decode(
        file_get_contents('php://input') ?: '',
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (!is_array($input) || !is_string($input['website'] ?? null)) {
        throw new InvalidArgumentException('website must be a string');
    }

    $token = getenv('WEBSITE_COMPANY_SERVICE_TOKEN');
    if (!is_string($token) || $token === '') {
        throw new RuntimeException('Service token is not configured');
    }

    $client = new WebsiteCompanyClient($token, new CurlTransport());
    $draft = $client->enrich($input['website']);

    echo json_encode([
        'data' => $draft->toArray(),
        'request_id' => $requestId,
    ], JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException|JsonException $exception) {
    http_response_code(422);
    echo json_encode([
        'error' => ['kind' => 'invalid_input', 'message' => $exception->getMessage()],
        'request_id' => $requestId,
    ]);
} catch (IntegrationFailure $exception) {
    $status = match ($exception->kind) {
        'rate_limited' => 429,
        'invalid_request' => 422,
        'malformed_response' => 502,
        default => 503,
    };

    http_response_code($status);
    error_log(json_encode([
        'event' => 'lead_enrichment_failed',
        'kind' => $exception->kind,
        'upstream_status' => $exception->status,
        'request_id' => $requestId,
    ]));

    echo json_encode([
        'error' => ['kind' => $exception->kind],
        'request_id' => $requestId,
    ]);
} catch (Throwable) {
    http_response_code(500);
    error_log(json_encode([
        'event' => 'lead_enrichment_failed',
        'kind' => 'internal',
        'request_id' => $requestId,
    ]));

    echo json_encode([
        'error' => ['kind' => 'internal'],
        'request_id' => $requestId,
    ]);
}

The CRM page now needs only to post the salesperson’s website entry, bind data.company, data.contact, data.email, and data.phone into editable fields, and present data.people as selectable suggestions. Existing user values should win unless the salesperson explicitly accepts a replacement.

Test retries without making network calls

A fake transport makes status sequences and failure paths reproducible. This test proves that 429 is retried, all five fields cross the boundary, and authentication failure is not retried.

<?php
// tests/WebsiteCompanyClientTest.php

use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse {
        $this->calls++;
        $next = array_shift($this->responses);

        if ($next instanceof Throwable) {
            throw $next;
        }

        return $next;
    }
}

final class WebsiteCompanyClientTest extends TestCase
{
    public function testRetriesRateLimitThenMapsContractFields(): void
    {
        $fake = new FakeTransport([
            new TransportResponse(429, '{}', ['retry-after' => '0']),
            new TransportResponse(200, json_encode([
                'company' => 'Example Company',
                'contact' => null,
                'email' => '[email protected]',
                'phone' => null,
                'people' => [],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new WebsiteCompanyClient(
            'test-token',
            $fake,
            static fn (int $milliseconds) => null
        );

        $result = $client->enrich('https://example.com');

        self::assertSame(2, $fake->calls);
        self::assertSame('Example Company', $result->company);
        self::assertSame('[email protected]', $result->email);
        self::assertSame([], $result->people);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new TransportResponse(401, '{}'),
        ]);

        $client = new WebsiteCompanyClient(
            'expired-token',
            $fake,
            static fn (int $milliseconds) => null
        );

        try {
            $client->enrich('https://example.com');
            self::fail('Expected IntegrationFailure');
        } catch (IntegrationFailure $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public

curl --request POST \
  --header "Content-Type: application/json" \
  --data '{"website":"https://example.com"}' \
  "http://127.0.0.1:8080/lead/enrich"

Security, observability, and deployment

Because the required authentication parameter appears in the query string, never log the complete upstream URL. The implementation logs only a failure category, upstream status, and generated request ID. Configure reverse proxies and application-performance tools to redact query strings as an additional safeguard.

Protect /lead/enrich with the CRM’s existing authentication, authorization, and CSRF controls. Apply a per-user application limit as well as handling upstream HTTP 429 responses. Avoid storing raw enrichment responses unless the business genuinely needs them; contact and people data may require retention, access, and deletion rules.

For deployment, point the web server’s document root at public/, run composer install --no-dev --classmap-authoritative, and inject WEBSITE_COMPANY_SERVICE_TOKEN through the platform’s secret manager or PHP-FPM environment. Do not bake it into an image. During rotation, update the deployment immediately after regeneration because the old active token is revoked.

Track request counts, latency, outcomes by failure kind, and retry counts. Do not label every empty field as a failure: a public site may simply omit a phone number or named contact. Health checks should verify the application locally without consuming enrichment quota.

Common failures

  • Authentication failures: confirm that the service plan is active and the deployed token is the current service-scoped token.
  • HTTP 429: preserve the salesperson’s entered website, show a retryable state, and avoid immediate browser retry loops.
  • Malformed JSON or changed value types: treat the response as an upstream failure instead of partially guessing at its meaning.
  • Repeated 5xx or network failures: stop after the bounded retry budget and let the user retry later.
  • Empty enrichment fields: keep them empty and editable; absence is not permission to fabricate data.

Final verification checklist

  • The active Free, Plus, or Pro plan is enabled.
  • The service token exists only in environment-backed configuration.
  • The request uses the exact GET endpoint with token and website query parameters.
  • Connection and total response timeouts are bounded.
  • Only network, 429, and 5xx failures receive bounded retries.
  • The boundary maps company, contact, email, phone, and people.
  • Automated tests pass without contacting the real service.
  • The internal route is protected by CRM authentication and CSRF controls.
  • Logs contain request IDs and failure categories, but no token or full upstream URL.
  • The salesperson can review every prefilled value before saving the lead.

The strongest enrichment workflow is not the one that fills the most boxes without supervision. It is the one that turns a single website into a useful draft, fails predictably, protects its credential, and leaves the final business decision with the person creating the lead.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.