Hunter in the Woods

Rolling your own HubSpot SDK in Laravel with the HTTP client

The HubSpot logo

So your boss / client / lover? Comes to you and says, “Have you used a CRM before? I think we need one, but I’m not willing to pay any money for it right now and I’ve heard HubSpot is good. Can you integrate it with our website?”. And you take a quick look and see they have an extensive API and you go “Sure, looks ok at face value - I’ll do it…”.

Immediately clouds roll in, lighting and thunder punctuate the sinister organ music that starts up in the distance, possibly below you - complimenting the gloomy wasteland that you have unwittingly stumbled into like the opening credits of Dark Castle. “Oh shiiii….” you say through the misty air, but it’s too late, you said you would do the work so you must now press on.

Lucky for you, I just did this so read on pre-weary developer, and hopefully this can help you navigate around many of the pitfalls that await you.

Disclaimer

This tutorial is really to help you get up and running as the HubSpot API is a bit of a mess - so I will only show you the code that is relevant, skipping over creating forms, validation, nice class abstraction, most templates or prettiness, etc - which might make it difficult if your unfamiliar Laravel. However Laravel is well documented so it should be easy to fill in the blanks and I’ll allude to what is needed in case you need some direction.

Also I’m using PHP + Laravel in these examples, but Hubspots API will be the same in any other language (Python, .Net, Nodejs, etc) so the same problems will be relivent to you, even if the language is not.

Prerequisites

  1. Understanding of what a Customer Relationship Management (CRM) service is used for and what HubSpot is.
  2. You will need a HubSpot account, you can sign up for free here: https://www.hubspot.com/products/get-started
  3. Intermediate level of understanding of Laravel and PHP: https://laravel.com/ and a project all ready to go.
  4. A working queue. Not “I’ll add one later”. You need it, and I’ll explain why below.

Forget the package

I used to recommend a community Laravel package for this. I don’t anymore, and I’d gently suggest you don’t either.

The problem isn’t that the packages are bad, it’s that they’re a layer of abstraction over an API that moves faster than they do. HubSpot has now shifted to date-based API versioning - endpoints look like /crm/objects/2026-03/contacts rather than the old /crm/v3/objects/contacts, and each dated version has an end-of-life date attached to it. Every time that moves, you’re waiting on a maintainer, or you’re pinned to an old version, or you’re reading someone else’s source to work out which method maps to which endpoint. It’s all upside down: you end up debugging the wrapper instead of the API.

The whole surface you actually need is about five endpoints. Laravel’s HTTP client already does retries, timeouts, pooling, logging and fakes for tests. So build the small thing. You’ll understand every line of it at 2am when it breaks, which - trust me - is when you’ll be reading it.

The docs you want are here: https://developers.hubspot.com/docs/api-reference/latest/overview. Keep them open.

The API in ninety seconds

  • Base URL is https://api.hubapi.com.
  • Auth is a bearer token from a private app - Authorization: Bearer <token>. You create it in your HubSpot account settings and tick the scopes it needs. HubSpot recommend rotating it every six months, and you can schedule the old one to expire in 7 days so you get an overlap window.
  • Objects are contacts, companies, deals, tickets, and so on. They also have numeric type IDs (0-1 contacts, 0-2 companies, 0-3 deals) which you’ll need for the associations API and custom objects.
  • Batch endpoints are /crm/objects/2026-03/{object}/batch/{create|read|update|upsert|archive} and take 100 inputs per request.
  • Search is POST /crm/objects/2026-03/{object}/search, limited to about 5 requests per second and 200 records per page.
  • Associations are a separate API, and they are the part that will hurt you. See below.

Config

// config/services.php
'hubspot' => [
    'token'   => env('HUBSPOT_TOKEN'),
    'version' => env('HUBSPOT_API_VERSION', '2026-03'),
],

Version in config, not hardcoded in twelve places. When HubSpot publishes the next dated version you want to flip one env var, run your tests, and go home.

The client

One class. Every call goes through one method, so there is exactly one place to add auth, timeouts, retries and logging.

<?php

namespace App\Services\HubSpot;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;

class HubSpot
{
    public function __construct(
        protected string $token,
        protected string $version,
    ) {}

    protected function http(): PendingRequest
    {
        return Http::baseUrl('https://api.hubapi.com')
            ->withToken($this->token)
            ->acceptJson()
            ->asJson()
            ->connectTimeout(5)
            ->timeout(20)
            ->retry(
                3,
                fn (int $attempt) => $attempt * 1500,          // 1.5s, 3s, 4.5s
                fn (Throwable $e) => $this->worthRetrying($e),
                throw: false,                                   // we'll decide what to throw
            );
    }

    /**
     * Is this a blip, or a real answer we should stop and listen to?
     */
    protected function worthRetrying(Throwable $e): bool
    {
        // Timeouts, DNS, connection reset - always worth another go.
        if ($e instanceof ConnectionException) {
            return true;
        }

        if (! $e instanceof RequestException) {
            return false;
        }

        $status = $e->response->status();

        if ($status === 429) {
            // Don't burn in-process attempts on a daily cap. That's a job for the queue.
            return $e->response->json('policyName') !== 'DAILY';
        }

        return $status >= 500;
    }

    protected function path(string $endpoint): string
    {
        return "/crm/objects/{$this->version}/{$endpoint}";
    }

    public function get(string $endpoint, array $query = []): array
    {
        return $this->handle($this->http()->get($this->path($endpoint), $query));
    }

    public function post(string $endpoint, array $payload = []): array
    {
        return $this->handle($this->http()->post($this->path($endpoint), $payload));
    }

    protected function handle(Response $response): array
    {
        if ($response->failed()) {
            throw HubSpotApiException::fromResponse($response);
        }

        return $response->json() ?? [];
    }
}

And the exception, which carries the one fact the queue needs to know - is it worth coming back later?

<?php

namespace App\Services\HubSpot;

use Illuminate\Http\Client\Response;
use RuntimeException;

class HubSpotApiException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status,
        public readonly ?string $policy = null,
        public readonly array $body = [],
    ) {
        parent::__construct($message, $status);
    }

    public static function fromResponse(Response $response): self
    {
        return new self(
            $response->json('message', 'HubSpot request failed'),
            $response->status(),
            $response->json('policyName'),
            $response->json() ?? [],
        );
    }

    public function isRateLimit(): bool
    {
        return $this->status === 429;
    }

    public function isDailyCap(): bool
    {
        return $this->policy === 'DAILY';
    }

    public function isTransient(): bool
    {
        return $this->status >= 500 || ($this->isRateLimit() && ! $this->isDailyCap());
    }
}

Bind it in a service provider and you’re done:

$this->app->singleton(HubSpot::class, fn () => new HubSpot(
    config('services.hubspot.token'),
    config('services.hubspot.version'),
));

Writing records - always upsert

Here’s the first real trap. If your integration creates records, then any retry - a timeout that actually succeeded, a job that ran twice, a user double-clicking - gives you duplicates. And duplicate contacts in a CRM are a special kind of misery, because sales people will find them and they will tell you about it.

Use the batch upsert endpoint with idProperty, which lets you address records by a natural key instead of HubSpot’s internal Record ID:

public function upsertContact(string $email, array $properties): array
{
    $response = $this->post('contacts/batch/upsert', [
        'inputs' => [[
            'idProperty' => 'email',
            'id'         => $email,
            'properties' => $properties,
        ]],
    ]);

    return $response['results'][0] ?? [];
}

Same call handles 1 record or 100 - the batch endpoints cap at 100 inputs per request, so array_chunk() and move on. Write once, use everywhere, and your retries become harmless.

The pivot table trap

Right. This is the bit I want you to read twice, because it’s where I lost the most time.

In Laravel, a many-to-many is a pivot table and you barely think about it. $contact->companies()->sync([1, 2, 3]) and it’s handled - rows added, rows removed, unique index stopping you doing anything stupid, all inside a transaction.

HubSpot’s associations are a pivot table with none of that. There is no sync(). There is no unique constraint. There is no transaction. There is no cascade. It is a remote join table that you are now personally responsible for maintaining, over a flaky network, and it will not tell you when it’s wrong.

The endpoints you need (all under /crm/objects/{version}/ or /crm/associations/{version}/):

What Call
Read a record’s associations GET /crm/objects/2026-03/{from}/{id}/associations/{to}
Create default association PUT /crm/objects/2026-03/{from}/{id}/associations/default/{to}/{toId}
Create labelled association PUT /crm/objects/2026-03/{from}/{id}/associations/{to}/{toId} with a body of [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 279}]
Delete association DELETE /crm/objects/2026-03/{from}/{id}/associations/{to}/{toId}
Batch create POST /crm/associations/2026-03/{from}/{to}/batch/associate/default - up to 2,000 inputs
Batch archive POST /crm/associations/2026-03/{from}/{to}/batch/archive - 100 unique “from” records

A few things that bit me:

Associations are typed and directional. associationTypeId 279 is contact-to-company; the reverse direction is a different ID again. There’s a full list of the HubSpot-defined IDs in the docs, and custom labels get their own USER_DEFINED IDs. Don’t sprinkle magic numbers through your code - pull them once, cache them, name them in a constant or an enum.

Nothing stops you associating the same pair twice, or a contact with fifteen companies. Re-run your importer and you can quietly end up with a mess that looks fine in the API response and looks insane in the HubSpot UI.

Deleting a record does not tidy up after itself the way a foreign key would. You are the foreign key now.

So write the sync() that HubSpot didn’t give you. Read what’s there, diff it against what should be there, add the missing, archive the extra:

/**
 * The sync() we wish HubSpot shipped. Read, diff, reconcile.
 */
public function syncAssociations(string $from, string $fromId, string $to, array $desiredIds): void
{
    $current = collect($this->get("{$from}/{$fromId}/associations/{$to}")['results'] ?? [])
        ->pluck('toObjectId')       // check this key against your API version - it has moved before
        ->map(fn ($id) => (string) $id)
        ->all();

    $desired = array_map('strval', array_filter($desiredIds));

    foreach (array_diff($desired, $current) as $id) {
        $this->http()->put($this->path("{$from}/{$fromId}/associations/default/{$to}/{$id}"))->throw();
    }

    foreach (array_diff($current, $desired) as $id) {
        $this->http()->delete($this->path("{$from}/{$fromId}/associations/{$to}/{$id}"))->throw();
    }
}

Two warnings on that snippet. First, dump the association read response once and check the field names for your dated version - the shape of this response has changed between versions and it’s the sort of thing that fails silently by returning an empty collection, which your diff will happily interpret as “delete everything”. Guard it. Second, if you’re reconciling more than a handful, use the batch endpoints instead of a loop - a loop of PUTs is the fastest way to meet the rate limiter.

And if you’re mirroring HubSpot IDs into your own tables, write a reconciliation command that walks both sides and reports drift. You will have drift. Better you find it on a Tuesday than a sales manager finds it on a Friday.

Everything goes through the queue

Now the rule that makes the whole thing survivable:

No web request ever talks to HubSpot directly.

Not the signup form, not the profile update, not the “quick” admin button. The user’s request writes to your database and dispatches a job. That’s it. Because if you call HubSpot inline, then HubSpot’s bad afternoon becomes your 500 error, your abandoned signup, your incident.

<?php

namespace App\Jobs;

use App\Models\Customer;
use App\Services\HubSpot\HubSpot;
use App\Services\HubSpot\HubSpotApiException;
use DateTimeInterface;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Throwable;

class SyncCustomerToHubSpot implements ShouldQueue
{
    use Queueable;

    public int $tries = 8;

    public function __construct(public Customer $customer) {}

    public function middleware(): array
    {
        return [
            new RateLimited('hubspot'),
            // Two jobs writing the same contact is how you get duplicate associations.
            (new WithoutOverlapping("hubspot-customer-{$this->customer->id}"))
                ->releaseAfter(30)
                ->expireAfter(180),
        ];
    }

    /**
     * Exponential-ish backoff, in seconds. Give a wobbling API room to recover.
     */
    public function backoff(): array
    {
        return [10, 30, 120, 600, 1800];
    }

    public function retryUntil(): DateTimeInterface
    {
        return now()->addHours(12);
    }

    public function handle(HubSpot $hubspot): void
    {
        try {
            $contact = $hubspot->upsertContact($this->customer->email, [
                'firstname' => $this->customer->first_name,
                'lastname'  => $this->customer->last_name,
            ]);

            $this->customer->forceFill([
                'hubspot_id'        => $contact['id'],
                'hubspot_synced_at' => now(),
            ])->save();

            $hubspot->syncAssociations(
                'contacts',
                $contact['id'],
                'companies',
                $this->customer->companies->pluck('hubspot_id')->filter()->all(),
            );
        } catch (HubSpotApiException $e) {
            // A daily cap won't clear in ten seconds. Come back tomorrow.
            if ($e->isDailyCap()) {
                $this->release(now()->addHours(6));

                return;
            }

            throw $e; // transient - let backoff() do its thing
        }
    }

    public function failed(?Throwable $e): void
    {
        // Don't lose the record. Flag it so a nightly command can pick it up.
        $this->customer->forceFill(['hubspot_sync_failed_at' => now()])->save();
    }
}

The rate limiter, registered in a service provider:

RateLimiter::for('hubspot', fn () => Limit::perMinute(540));

Be honest with yourself about that one though. HubSpot’s limit is measured in a 10 second window - 100 requests for free and Starter accounts, 190 on Professional and Enterprise, 250 if you’ve bought a limit increase, plus a daily cap (250,000 / 625,000 / 1,000,000 respectively). A per-minute limiter smooths the average but does nothing about a burst, so if you’re running a dozen workers you can still put 540 calls through in four seconds and get slapped. The simplest correct answer is to give HubSpot its own queue and run it with a single worker:

php artisan queue:work --queue=hubspot

Boring, sequential, and it stays under the burst limit without you thinking about it. Scale it up only when you’ve measured that you need to.

429s, 504s and other weather

One thing I observed early and often is that the HubSpot endpoints return Gateway Time-out and other 5xx errors quite often - especially on the heavy calls where you’re doing a lot at once. Usually just running it again works, which tells you everything about what your error handling needs to look like: retry the transient stuff, don’t panic, don’t page anyone.

Rate limiting is more interesting than a plain 429 though, because HubSpot tells you which limit you hit:

{
  "status": "error",
  "message": "You have reached your daily limit.",
  "errorType": "RATE_LIMIT",
  "policyName": "DAILY"
}

That policyName is the whole game. A burst limit clears in ten seconds - sleep and go again. A DAILY cap does not clear until tomorrow, and hammering it just burns your remaining attempts to no purpose. Branch on it, as in the job above.

Responses also carry X-HubSpot-RateLimit-Max, X-HubSpot-RateLimit-Remaining, X-HubSpot-RateLimit-Interval-Milliseconds and the daily equivalents - worth logging when they get low, so you find out you’re near the cap before your integration stops. One gotcha: the search endpoints don’t return those headers at all, so don’t build anything that assumes they’re always present.

Reading a lot of data

Two ways in, and you want the right one:

  • Search (POST /{object}/search) for “give me the records matching X”. Roughly 5 requests per second, 200 per page, and deep paging is capped - so for a full sync don’t try to page through everything. Filter on hs_lastmodifieddate and shard your queries into date windows.
  • Batch read (POST /{object}/batch/read) for “I already know the IDs”. 100 at a time, and it takes idProperty too, so you can look records up by email rather than storing HubSpot IDs everywhere.

For an ongoing sync, store a hubspot_synced_at watermark on your side and only ask for what changed since. Full syncs are for the first run and for the reconciliation command you’ll inevitably write.

Testing it

The nice payoff of owning the client: Http::fake() works, no package internals to mock.

Http::fake([
    'api.hubapi.com/*' => Http::sequence()
        ->push(['message' => 'Gateway Time-out'], 504)
        ->push(['results' => [['id' => '12345']]], 200),
]);

Write the test where the first call 504s and the second succeeds. Write the one where it 429s with policyName: DAILY. Write the one where the associations read comes back empty and prove your sync doesn’t delete the lot. Those three tests are worth more than the rest of your suite here, because those are the cases that will actually happen in production and are miserable to reproduce by hand.

End

So by no means complete or extensive tutorial, however this is packed full of useful solutions to many pain points you will face. Hopefully this helps you navigate through the worst of it and I very much hope it will help you as much as it will help me the next time I need to do this!

Best of luck :)

Made with since 2015