Skip to main content

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; localhost and private addresses are rejected.
  • It answers one GET without a redirect. Any other answer passes, so the route only needs to accept POST.

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​

TypeWhendata.object
estimate.completedAn async estimate finished with an answer.The estimate
estimate.failedAn async estimate could not finish. Nothing was charged.The estimate, with error
credits.lowLive 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.exhaustedLive credits reached zero.The balance
api_key.rotatedA key was rotated. data.previous says when the old key stops working.The new key
api_key.expiringA key with an expiry date stops working within 14 days.The key
api_key.suspicious_activityA key behaved unlike itself. data.anomaly says which rule fired and whether the key was paused.The key
webhook.testYou 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>. t is 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:

  1. Split the header on commas. Read t and every v1.
  2. Build the signed text: t, a period, then the body exactly as received.
  3. Compute the HMAC-SHA256 of that text with your secret.
  4. Compare it with each v1 in constant time, and refuse a t more 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.

Node.js
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);
});
}
Python
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)
PHP
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:

Retry12345678
Wait before it1 min5 min30 min2 h6 h12 h24 h24 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.