A PHP client for the Close CRM REST API.
By Simon Hampel
Replaces hampel/close, which is abandoned. This is a new package rather than a
new major version of that one: it shares no code, no namespace and no API with
it, so a version number implying a lineage would be a fiction.
Requires PHP 8.3 or later.
composer require hampel/close-api
The package talks to any PSR-18 HTTP client rather than bundling one. If you have no preference, install Guzzle and it will be found automatically:
composer require guzzlehttp/guzzle
use Hampel\CloseApi\Close;
$close = Close::withApiKey($apiKey);
$lead = $close->leads()->get('lead_abc123');
echo $lead['name'];
echo $lead['custom.cf_xyz']; // custom fields are literal keysEvery call returns a Response: array access, iteration and count() over the
decoded body, plus the status and whatever rate limit state came back with it.
There are no per-endpoint response classes — Close's own spec leaves more than
half its responses untyped, and a class built from a single example is a guess
wearing a contract's clothing.
$leads = $close->leads()->list(['_limit' => 50, '_fields' => ['id', 'name']]);
foreach ($leads->data() as $lead) {
// ...
}
$close->leads()->create(['name' => 'Wayne Enterprises', 'status_id' => 'stat_x']);
$close->leads()->update('lead_abc123', ['description' => 'Updated']);
$close->contacts()->list(['lead_id' => 'lead_abc123']);
$close->notes()->create(['lead_id' => 'lead_abc123', 'note' => 'Called back']);
$close->tasks()->create(['_type' => 'lead', 'lead_id' => 'lead_abc123', 'text' => 'Follow up']);To wire the transport yourself rather than letting discovery find a client:
use Hampel\CloseApi\Auth\ApiKey;
use Hampel\CloseApi\Http\Transport;
$close = new Close(new Transport(
auth: new ApiKey($apiKey),
httpClient: $httpClient, // any PSR-18 client
requestFactory: $factory, // any PSR-17 factory
streamFactory: $factory,
));Close publishes 302 operations and most of them are its own UI's features. The resources here cover what applications actually use; everything else is one call away, and that is a supported route rather than a workaround:
$close->transport()->get('playbook/');
$close->transport()->post('webhook/', ['url' => '...']);spec/ENDPOINTS.md is the generated inventory of the whole API.
Close paginates two different ways and caps both, so neither paginator offers a plain "fetch everything".
foreach ($close->leads()->paginate(['status_id' => 'stat_x']) as $lead) {
// pages fetched as needed
}Offset pagination has a maximum _limit and a maximum _skip, per resource,
neither of which Close publishes. Cross one and a later page returns a bare 400;
this package turns that into a DeepPaginationException saying how far the walk
got and what to do instead — chunk the query by date_created, or use the Export
API.
Searching uses cursors:
$results = $close->search()->paginate([
'type' => 'and',
'queries' => [
['type' => 'object_type', 'object_type' => 'contact'],
// ...
],
]);Two constraints there are documented and both are traps. A query returns at most
10,000 objects — reaching that raises PaginationLimitException rather than
handing back a truncated answer that looks complete. And cursors expire after
30 seconds, which means the budget is spent between page fetches: a loop doing
real work per record succeeds on a small result set and fails on a large one.
Buffer each page before processing it.
Every exception implements Hampel\CloseApi\Exception\CloseApiException, so one
catch covers the package. Below that, each status Close documents has its own
class, so you can catch the case you can do something about:
use Hampel\CloseApi\Exception\NotFoundException;
use Hampel\CloseApi\Exception\RateLimitException;
use Hampel\CloseApi\Exception\CloseApiException;
try {
$lead = $close->leads()->get($id);
} catch (NotFoundException) {
return null; // an ordinary answer, not a failure
} catch (RateLimitException $e) {
$retryIn = $e->waitSeconds();
} catch (CloseApiException $e) {
// anything else this package can throw
}ResponseException carries the status, the decoded body, the raw body and the
request that caused it. TransportException means the request never reached
Close at all — there is no status and no body to inspect.
Close enforces rate limits per endpoint group, per API key and per organization,
and publishes none of those groupings. This package does not pretend to model
them. It reads the RateLimit header off each response and exposes it, and when
a 429 arrives it waits exactly as long as Close asked and tries again.
$close->transport()->lastRateLimit()?->remaining;
// or, per response
$leads = $close->leads()->list();
$leads->rateLimit?->remaining;Retries are the default and are deliberately narrow: a 429 is always retried, because a rate limit is applied before the request is processed and nothing happened. A 5xx or a connection failure is retried only for idempotent methods — a POST that failed this way may already have been applied, and repeating it is how duplicate records get created.
Pass your own policy to change any of that:
Close::withApiKey($apiKey, retryPolicy: new DefaultRetryPolicy(
maxAttempts: 5,
baseDelay: 1.0,
maxDelay: 30.0,
));maxDelay is a ceiling on any single wait rather than a target. If Close asks
for longer than it, the request fails with the reason instead of blocking.
Pass any PSR-3 logger. Requests and responses are logged at debug, retries at
warning, and unreachable-host failures at error. Credentials are never
logged — an API key is described by its prefix and length only.
Note that debug will include request URIs and payload sizes, and a URI can
carry a search term. Choose the level accordingly.
Pre-1.0, and honestly so: the whole package is verified against Close's OpenAPI spec, its documentation and a mock PSR-18 client, and not once against the live API. The suite being green means the requests are built the way this package intends. It does not mean Close agrees.
The places where this package currently infers rather than knows — the error
body's shape most of all — are listed at the end of DESIGN.md.
MIT — see LICENSE.md.