hometown / estimator
Price home-service jobs from text, photos or a conversation with the Hometown Estimate API.
Requires
- php: ^8.2
- ext-curl: *
- ext-json: *
Requires (Dev)
- laravel/pint: 1.32.1
- phpstan/phpstan: 2.2.16
- phpstan/phpstan-deprecation-rules: 2.0.5
- phpstan/phpstan-phpunit: 2.0.19
- phpstan/phpstan-strict-rules: 2.0.12
- phpunit/phpunit: 11.5.57
- rector/rector: 2.6.7
- shipmonk/composer-dependency-analyser: 1.8.4
- shipmonk/dead-code-detector: 1.4.2
- slevomat/coding-standard: 8.31.1
- squizlabs/php_codesniffer: 4.0.4
- tomasvotruba/cognitive-complexity: 1.2.0
- tomasvotruba/type-coverage: 2.4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 22:10:54 UTC
README
Price home-service jobs from text, photos or a conversation with the Hometown Estimate API.
Install
composer require hometown/estimator
It needs PHP 8.2 or newer with ext-curl and ext-json, and no other package.
Make your first estimate
Create a test key in the dashboard, then price a fence from the sandbox. A test key never spends credits.
use Hometown\Estimator\Hometown; $hometown = new Hometown(getenv('HOMETOWN_API_KEY')); $estimate = $hometown->estimates->create(['input' => ['text' => 'sandbox:priced']]); echo $estimate['status'], "\n"; // priced echo $estimate['price']['min_cents'], "\n"; // 103680 foreach ($estimate['steps'] ?? [] as $step) { echo $step['text'], "\n"; }
Objects are arrays with the keys of the API. Their shapes are declared for PHPStan, Psalm and your editor in Hometown\Estimator\Types, so a typo in a key shows up before it runs:
/** @phpstan-import-type Estimate from \Hometown\Estimator\Types */
Every call that takes your key has a method: $hometown->estimates->retrieve($id), $hometown->customers->list(limit: 20), $hometown->records->put('jobs', 'job-1042', [...]) and so on. The API reference lists them all.
Idempotency and retries
estimates->create, estimates->followUp and customers->create send an Idempotency-Key on their own, and keep it when they retry. Pass ['idempotency_key' => ...] as the last argument to tie the call to your own order id, so a retry from your side is charged once too.
A 429 or 503 that carries Retry-After is retried up to twice, waiting what it says. A wait longer than 60 seconds is not waited for: the exception is thrown and you decide. Change both with new Hometown($key, maxRetries: ..., maxRetryWait: ...). Nothing else is retried.
Errors
Every error of the API throws a subclass of HometownException, one per code. Each carries $status, $errorCode, $title, $detail, $param, $errors and the $requestId to quote when you write to support.
use Hometown\Estimator\Exception\HometownException; use Hometown\Estimator\Exception\InsufficientCreditsException; try { $estimate = $hometown->estimates->create(['input' => ['text' => 'Paint a 12 by 14 ft bedroom']]); } catch (InsufficientCreditsException) { // buy credits in the dashboard } catch (HometownException $error) { error_log("{$error->errorCode} {$error->requestId}"); }
A code newer than this version arrives as a plain HometownException with $errorCode set. When the request never gets an answer, ConnectionException is thrown instead.
| Exception | HTTP | Code | Meaning |
|---|---|---|---|
ApiKeyExpiredException |
401 | api_key_expired |
API key expired |
ApiKeyInvalidException |
401 | api_key_invalid |
API key invalid |
ApiKeyMissingException |
401 | api_key_missing |
API key missing |
ApiKeyPausedException |
403 | api_key_paused |
API key paused |
ApiKeyRevokedException |
401 | api_key_revoked |
API key revoked |
BillingProfileIncompleteException |
422 | billing_profile_incomplete |
Billing profile incomplete |
ContentRejectedException |
422 | content_rejected |
Content rejected |
DailyQuotaReachedException |
429 | daily_quota_reached |
Daily quota reached |
EventNotRedeliverableException |
422 | event_not_redeliverable |
Event not redeliverable |
IdempotencyConflictException |
409 | idempotency_conflict |
Idempotency conflict |
IdempotencyKeyReusedException |
422 | idempotency_key_reused |
Idempotency key reused |
InsufficientCreditsException |
402 | insufficient_credits |
Insufficient credits |
InternalErrorException |
500 | internal_error |
Internal error |
InvalidRequestException |
400 | invalid_request |
Invalid request |
IpNotAllowedException |
403 | ip_not_allowed |
IP address not allowed |
MethodNotAllowedException |
405 | method_not_allowed |
Method not allowed |
ModelUnavailableException |
503 | model_unavailable |
Estimator unavailable |
MonthlyCapReachedException |
402 | monthly_cap_reached |
Monthly cap reached |
NotFoundException |
404 | not_found |
Not found |
PackUnknownException |
422 | pack_unknown |
Unknown pack |
PayloadTooLargeException |
413 | payload_too_large |
Payload too large |
RateLimitedException |
429 | rate_limited |
Rate limited |
RecordsCapReachedException |
422 | records_cap_reached |
Records cap reached |
SandboxDailyCapReachedException |
429 | sandbox_daily_cap_reached |
Sandbox daily cap reached |
SandboxInputOnLiveException |
422 | sandbox_input_on_live |
Sandbox input on a live key |
ScopeMissingException |
403 | scope_missing |
Scope missing |
ServiceUnavailableException |
503 | service_unavailable |
Service unavailable |
TenantSuspendedException |
403 | tenant_suspended |
Account suspended |
TermsNotAcceptedException |
403 | terms_not_accepted |
Terms not accepted |
TestKeyOnLiveRouteException |
403 | test_key_on_live_route |
Live key required |
UnsupportedInRegionException |
422 | unsupported_in_region |
Unsupported in region |
UnsupportedMediaTypeException |
415 | unsupported_media_type |
Unsupported media type |
ValidationFailedException |
422 | validation_failed |
Validation failed |
WebhookEndpointLimitException |
422 | webhook_endpoint_limit |
Webhook endpoint limit reached |
WebhookUrlRejectedException |
422 | webhook_url_rejected |
Webhook URL rejected |
Webhooks
Check each delivery before acting on it. Pass the body exactly as it arrived:
use Hometown\Estimator\Exception\WebhookSignatureException; use Hometown\Estimator\Webhook; try { $event = Webhook::verify(file_get_contents('php://input'), $_SERVER['HTTP_HOMETOWN_SIGNATURE'] ?? null, $secret); } catch (WebhookSignatureException) { http_response_code(400); exit; }
During a secret rotation, the old and the new secret both verify for 24 hours.
Versions
Each release sends the Hometown-Version it was built for, so the answers keep the shape this package's types describe. Versions follow YEAR.MONTH.PATCH: a release that follows a breaking change of the API moves to a new month.
License
Source available under the Functional Source License (FSL-1.1-MIT); each version becomes MIT two years after release. See LICENSE.md.