Search by

hometown / estimator

jasonv-tech

Price home-service jobs from text, photos or a conversation with the Hometown Estimate API.

v2026.10.1 2026-10-14 22:10 UTC

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.