Metadata-Version: 2.4
Name: hometown-estimator
Version: 2026.10.1
Summary: Price home-service jobs from text, photos or a conversation with the Hometown Estimate API.
Project-URL: Documentation, https://developers.wfskills.com/docs/sdks/python
Author: Hometown Workforce, LLC
License-Expression: FSL-1.1-MIT
License-File: LICENSE.md
Keywords: api,estimate,home services,hometown,pricing
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# Hometown estimates for Python

Price home-service jobs from text, photos or a conversation with the [Hometown Estimate API](https://developers.wfskills.com).

## Install

```bash
pip install hometown-estimator
```

It needs Python 3.11 or newer and has no dependencies.

## Make your first estimate

Create a test key in the [dashboard](https://wfskills.com/portal/developer), then price a fence from the sandbox. A test key never spends credits.

```python
import os

from hometown_estimator import Hometown

hometown = Hometown(os.environ["HOMETOWN_API_KEY"])

estimate = hometown.estimates.create({"input": {"text": "sandbox:priced"}})

print(estimate["status"])  # priced
print(estimate["price"])  # {'min_cents': 103680, 'max_cents': 155520}
for step in estimate["steps"]:
    print(step["text"])
```

Objects are plain dicts with the keys of the API. Their types live in `hometown_estimator.types`, so an editor and mypy check every key:

```python
from hometown_estimator.types import CreateEstimate, Estimate
```

Every call that takes your key has a method: `hometown.estimates.retrieve(estimate_id)`, `hometown.customers.list(limit=20)`, `hometown.records.put("jobs", "job-1042", {...})` and so on. The [API reference](https://developers.wfskills.com/docs/reference/hometown-workforce-estimate-api) lists them all.

## Idempotency and retries

`estimates.create`, `estimates.follow_up` and `customers.create` send an `Idempotency-Key` on their own, and keep it when they retry. Pass `idempotency_key=` 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 error is raised and you decide. Change both with `Hometown(key, max_retries=..., max_retry_wait=...)`. Nothing else is retried. `timeout=` is how long to wait at each step of an attempt: connecting, and each read of the answer.

## Errors

Every error of the API raises a subclass of `HometownError`, one per code. Each carries `status`, `code`, `title`, `detail`, `param`, `errors` and the `request_id` to quote when you write to support.

```python
from hometown_estimator import HometownError, InsufficientCreditsError

try:
    estimate = hometown.estimates.create({"input": {"text": "Paint a 12 by 14 ft bedroom"}})
except InsufficientCreditsError:
    ...  # buy credits in the dashboard
except HometownError as error:
    print(error.code, error.request_id)
```

A code newer than this version arrives as a plain `HometownError` with `code` set. When the request never gets an answer, `APIConnectionError` is raised instead.

<!-- errors:start -->

| Error | HTTP | Code | Meaning |
| --- | --- | --- | --- |
| `ApiKeyExpiredError` | 401 | `api_key_expired` | API key expired |
| `ApiKeyInvalidError` | 401 | `api_key_invalid` | API key invalid |
| `ApiKeyMissingError` | 401 | `api_key_missing` | API key missing |
| `ApiKeyPausedError` | 403 | `api_key_paused` | API key paused |
| `ApiKeyRevokedError` | 401 | `api_key_revoked` | API key revoked |
| `BillingProfileIncompleteError` | 422 | `billing_profile_incomplete` | Billing profile incomplete |
| `ContentRejectedError` | 422 | `content_rejected` | Content rejected |
| `DailyQuotaReachedError` | 429 | `daily_quota_reached` | Daily quota reached |
| `EventNotRedeliverableError` | 422 | `event_not_redeliverable` | Event not redeliverable |
| `IdempotencyConflictError` | 409 | `idempotency_conflict` | Idempotency conflict |
| `IdempotencyKeyReusedError` | 422 | `idempotency_key_reused` | Idempotency key reused |
| `InsufficientCreditsError` | 402 | `insufficient_credits` | Insufficient credits |
| `InternalErrorError` | 500 | `internal_error` | Internal error |
| `InvalidRequestError` | 400 | `invalid_request` | Invalid request |
| `IpNotAllowedError` | 403 | `ip_not_allowed` | IP address not allowed |
| `MethodNotAllowedError` | 405 | `method_not_allowed` | Method not allowed |
| `ModelUnavailableError` | 503 | `model_unavailable` | Estimator unavailable |
| `MonthlyCapReachedError` | 402 | `monthly_cap_reached` | Monthly cap reached |
| `NotFoundError` | 404 | `not_found` | Not found |
| `PackUnknownError` | 422 | `pack_unknown` | Unknown pack |
| `PayloadTooLargeError` | 413 | `payload_too_large` | Payload too large |
| `RateLimitedError` | 429 | `rate_limited` | Rate limited |
| `RecordsCapReachedError` | 422 | `records_cap_reached` | Records cap reached |
| `SandboxDailyCapReachedError` | 429 | `sandbox_daily_cap_reached` | Sandbox daily cap reached |
| `SandboxInputOnLiveError` | 422 | `sandbox_input_on_live` | Sandbox input on a live key |
| `ScopeMissingError` | 403 | `scope_missing` | Scope missing |
| `ServiceUnavailableError` | 503 | `service_unavailable` | Service unavailable |
| `TenantSuspendedError` | 403 | `tenant_suspended` | Account suspended |
| `TermsNotAcceptedError` | 403 | `terms_not_accepted` | Terms not accepted |
| `TestKeyOnLiveRouteError` | 403 | `test_key_on_live_route` | Live key required |
| `UnsupportedInRegionError` | 422 | `unsupported_in_region` | Unsupported in region |
| `UnsupportedMediaTypeError` | 415 | `unsupported_media_type` | Unsupported media type |
| `ValidationFailedError` | 422 | `validation_failed` | Validation failed |
| `WebhookEndpointLimitError` | 422 | `webhook_endpoint_limit` | Webhook endpoint limit reached |
| `WebhookUrlRejectedError` | 422 | `webhook_url_rejected` | Webhook URL rejected |

<!-- errors:end -->

## Webhooks

Check each delivery before acting on it. Pass the body exactly as it arrived:

```python
from hometown_estimator import WebhookSignatureError, verify_webhook

try:
    event = verify_webhook(request.body, request.headers.get("Hometown-Signature"), endpoint_secret)
except WebhookSignatureError:
    return 400
```

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](LICENSE.md).
