Webhooks
A webhook endpoint is a URL of yours that Hometown calls with POST when something happens in your account. For example, an async estimate finished, credits ran low, or a key was rotated. Each call is signed, so you can check that it came from Hometown.
Add an endpoint
Add endpoints in the dashboard, or with a key that has the webhooks:manage scope:
curl https://api.wfskills.com/v1/webhook_endpoints \
-H "Authorization: Bearer $HOMETOWN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/hometown", "description": "production", "events": ["estimate.completed", "estimate.failed", "credits.low"]}'
The answer carries the endpoint's signing secret in secret, starting with hwsec_. It is shown this once, and this call takes no Idempotency-Key, so no copy of the secret is kept for a replay. Store it with your other secrets.
Before it is saved, Hometown checks the URL:
- It uses HTTPS, on port 443 or a port from 1024 to 65535.
- It carries no user name, password or fragment.
- Its host resolves to public addresses only;
localhostand private addresses are rejected. - It answers one
GETwithout a redirect. Any other answer passes, so the route only needs to acceptPOST.
If the URL fails a check, the request answers 422 with the code webhook_url_rejected, and detail says which rule it broke. To test on your own machine, expose it through a public HTTPS tunnel.
An environment has at most 16 active endpoints; one more answers 422 with the code webhook_endpoint_limit. Endpoints belong to an environment. A test key manages test endpoints, and events caused by test keys, sandbox inputs included, go to them. Live keys and live endpoints work the same way.
Event types
| Type | When | data.object |
|---|---|---|
estimate.completed | An async estimate finished with an answer. | The estimate |
estimate.failed | An async estimate could not finish. Nothing was charged. | The estimate, with error |
credits.low | Live credits fell to 20% or less of what your unexpired lots started with. Checked every hour, and sent once until credits are added. | The balance |
credits.exhausted | Live credits reached zero. | The balance |
api_key.rotated | A key was rotated. data.previous says when the old key stops working. | The new key |
api_key.expiring | A key with an expiry date stops working within 14 days. | The key |
api_key.suspicious_activity | A key behaved unlike itself. data.anomaly says which rule fired and whether the key was paused. | The key |
webhook.test | You sent a test event to the endpoint. | The endpoint |
New types may be added. Ignore a type you do not handle.
Every event has the same shape. data.object is the resource as the API returns it, in your account's API version:
{
"id": "evt_01J9Z6X1M4QK2V7H8T3R5N0BCD",
"object": "event",
"type": "estimate.completed",
"api_version": "2026-10-05",
"environment": "live",
"created_at": "2026-10-09T15:04:05+00:00",
"data": { "object": { "id": "est_01J9Z6X1M4QK2V7H8T3R5N0BCE", "object": "estimate", "status": "priced" } }
}
Check the signature
Each call is a POST with Content-Type: application/json, User-Agent: Hometown-Webhooks/1.0 and three headers of its own:
Hometown-Signature:t=<unix seconds>,v1=<signature>.tis the time of this attempt, so each retry carries a new one.Hometown-Event-Id: the event id.Hometown-Version: the API version of the body.
The signature is the hex HMAC-SHA256 of the timestamp, a period and the raw body. The HMAC key is the endpoint's whole secret, hwsec_ prefix included. Check it in four steps:
- Split the header on commas. Read
tand everyv1. - Build the signed text:
t, a period, then the body exactly as received. - Compute the HMAC-SHA256 of that text with your secret.
- Compare it with each
v1in constant time, and refuse atmore than 5 minutes away from your clock.
Use the raw body. A body parsed and serialized again differs in spacing or key order, and its signature does not match.
import crypto from 'node:crypto';
export function verifyHometownSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== 'string') return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && value !== '') signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'));
return signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
import hashlib
import hmac
import time
def verify_hometown_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures)
function verifyHometownSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
In Express, read the raw body with express.raw({ type: 'application/json' }) on the webhook route. In Laravel, use $request->getContent(). In Flask, use request.get_data(). If the check fails, answer 400 and ignore the event.
Answer quickly
Answer with any 2xx status within 10 seconds. Do the work after answering, in a queue of yours. Hometown does not follow redirects: a 3xx counts as a failure.
Retries
A delivery that does not get a 2xx is tried 8 more times over about three days:
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Wait before it | 1 min | 5 min | 30 min | 2 h | 6 h | 12 h | 24 h | 24 h |
The last retry may fail too. If the endpoint answered no other event in those three days, it is disabled and the account owner gets an email. While it is disabled, it gets no events and pending retries stop. Enable it again in the dashboard, or with PATCH /v1/webhook_endpoints/{id} and {"status": "active"}, once it answers. Then send the events you missed again with POST /v1/events/{id}/redeliver.
Because of retries, an event can arrive more than once, and events can arrive out of order. Store the Hometown-Event-Id of the events you handled and skip one you already have. To act on the latest state, read the resource again with the API.
Test an endpoint
POST /v1/webhook_endpoints/{id}/test, or Send a test event in the dashboard, sends webhook.test to that endpoint alone, signed like any other event. It goes even to a disabled endpoint, so you can check a fix before enabling it, and it is tried once, without retries. GET /v1/events/{id} shows what your endpoint answered.
Rotate the secret
POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret. For the next 24 hours each delivery carries two v1 values, one per secret, so a receiver that still has the old secret keeps working. Put the new secret on your servers within those 24 hours. Rotating again within that time ends the older secret at once.
List and redeliver events
GET /v1/events lists the events of the last 30 days, newest first, and type filters them. GET /v1/events/{id} adds every delivery attempt with the status your endpoint answered.
POST /v1/events/{id}/redeliver sends an event again with the same body and Hometown-Event-Id. By default it goes to every active endpoint that listens to its type. With {"endpoint": "whe_..."} it goes only to that endpoint, if it is active. An event older than 30 days answers 422 with the code event_not_redeliverable, or 404 with not_found once Hometown has deleted it.
Allowlist the sender
Deliveries come from 35.209.19.212. If your firewall only lets known addresses in, add that one. Checking the signature is still needed: an address alone does not prove who sent the call.