Webhooks
Webhooks let your integration react to changes in Houzz Pro in real time. Instead of
polling the API for new projects or updated leads, you register an HTTPS endpoint once
and Houzz Pro sends an HTTP POST to it whenever a subscribed event occurs.
Overview
A webhook subscription ties together three things:
- A delivery URL — the HTTPS endpoint on your server that receives events.
- One or more triggers — the
(resourceName, eventType)pairs you care about, such as("project", "create")or("lead", "update"). - A signing secret — a one-time HMAC secret returned when the subscription is created, used to verify that each delivery genuinely came from Houzz Pro.
When a resource event fires (for example, a project is created) and it matches an
active trigger on one of your subscriptions, Houzz Pro delivers a signed POST to that
subscription's URL.
Subscriptions are scoped to your company. A subscription only receives events for data that belongs to the company your service account represents.
Webhook subscription and trigger endpoints are authenticated with your Bearer token but are not gated by a named scope. Any valid token issued to your service account can manage subscriptions.
The subscribe flow
- Discover available triggers —
GET /v1/webhookTriggersreturns the full catalog of(resourceName, eventType)pairs you can subscribe to. - Create a subscription —
POST /v1/webhookSubscriptionswith your delivery URL and the triggers you want. The response includes a one-timesecret. - Store the secret — you will use it to verify the signature on every delivery. It is returned only at creation and cannot be retrieved again.
- Verify deliveries — for each incoming
POST, recompute the signature with your secret and confirm it matches before trusting the payload.
1. List available triggers
curl --request GET \
--url https://pro-api.houzz.com/api/v1/webhookTriggers \
--header 'Authorization: Bearer <access_token>'
Each entry in the returned data array has this shape:
{
"resourceName": "project",
"eventType": "create",
"label": "Project Created",
"description": "Fires when a project is created."
}
2. Create a subscription
POST /v1/webhookSubscriptions
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | HTTPS URL that receives deliveries. |
triggers | array | no | Triggers to subscribe to, each { resourceName, eventType }. |
description | string | no | Human-readable label for the subscription. |
maxAttempts | integer | no | Maximum delivery attempts for a single event, including the first. Defaults to 5; values above 10 are capped at 10. Set 1 to disable retries. See Retries and backoff. |
headers | object | no | Custom HTTP headers (key/value pairs) sent with every delivery — useful for a shared token your endpoint checks. |
curl --request POST \
--url https://pro-api.houzz.com/api/v1/webhookSubscriptions \
--header 'Authorization: Bearer <access_token>' \
--header 'Content-Type: application/json' \
--data '{
"url": "https://example.com/hooks/houzz",
"description": "Sync projects and leads",
"triggers": [
{ "resourceName": "project", "eventType": "create" },
{ "resourceName": "project", "eventType": "update" },
{ "resourceName": "lead", "eventType": "create" }
]
}'
Response (201 Created):
{
"data": {
"id": 42,
"companyId": 100123,
"url": "https://example.com/hooks/houzz",
"status": "active",
"description": "Sync projects and leads",
"maxAttempts": 5,
"headers": {},
"consecutiveFailures": 0,
"createdBy": 5567,
"createdAt": "2026-04-30T18:00:00Z",
"updatedAt": "2026-04-30T18:00:00Z",
"triggers": [
{ "id": 1, "resourceName": "project", "eventType": "create", "isActive": true },
{ "id": 2, "resourceName": "project", "eventType": "update", "isActive": true },
{ "id": 3, "resourceName": "lead", "eventType": "create", "isActive": true }
],
"secret": "whsec_9f2b1c..."
}
}
The secret is returned only in this create response. Save it in a secrets manager
immediately. If you lose it, delete the subscription and create a new one to obtain a new
secret.
Trigger catalog
Most resources fire create, update, and delete, but not all — see the Event types
column. The full catalog returned by GET /v1/webhookTriggers:
| Entity | resourceName | Event types |
|---|---|---|
| Client / Contact | client | create, update, delete |
| Project | project | create, update, delete |
| Lead | lead | create, update, delete |
| Task | task | create, update, delete |
| Milestone | milestone | create, update, delete |
| Vendor | vendor | create, update |
| File | file | create, update, delete |
| Team member | team-member | create, update |
| Subcontractor | subcontractor | create, update |
| Daily log | daily-log | create, update, delete |
| Contact group | contact-group | create, update, delete |
Subscribe by referencing a row's resourceName and one of its eventType values —
for example, { "resourceName": "project", "eventType": "update" }. The event-type
list is per resource, so future resources may expose additional, feature-specific
events beyond create / update / delete.
update, not a deleteSeveral resources have no delete trigger because removing them does not delete the
record — it changes its status, which reaches you as an update:
- Vendors, team members, and subcontractors are only ever archived or
deactivated. Watch
updateand inspect the resource's status fields indata. - Files are archived to Trash first, which arrives as an
update. Thefile.deleteevent fires only on a permanent delete — emptying the file from Trash.
If you rely solely on delete to detect removals, you will miss these.
Verifying delivery signatures
Every delivery is signed so you can prove it came from Houzz Pro and was not tampered with in transit. Two headers are sent on each delivery:
X-Webhook-Signature—sha256=<hex>, the HMAC-SHA256 of the signed payload, keyed with thesecretreturned when you created the subscription.X-Webhook-Timestamp— the delivery time as a unix timestamp in seconds.
The signed payload is {timestamp}.{rawBody} — the value of X-Webhook-Timestamp,
a literal period, then the raw request body.
To verify a delivery:
- Read the raw request body exactly as received — do not re-serialize the parsed JSON, because whitespace and key ordering would change the bytes and break the HMAC.
- Build the signed payload
{timestamp}.{rawBody}using theX-Webhook-Timestampvalue. - Compute
sha256=+HMAC-SHA256(signedPayload, secret)as hex. - Compare it, using a constant-time comparison, against the
X-Webhook-Signatureheader.
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['x-webhook-timestamp'];
const received = headers['x-webhook-signature']; // "sha256=<hex>"
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
Always compute the HMAC over the raw request body bytes, before JSON parsing. Once the
signature is verified, acknowledge quickly with a 2xx and process the event
asynchronously.
Delivery payload
Each delivery is a JSON POST with this envelope:
{
"event_type": "project.update",
"company_id": 12345,
"entity_type": "project",
"entity_id": 6789,
"occurred_at": "2026-08-26T18:38:03.121Z",
"updated_fields": ["status", "budget"],
"data": { "id": 6789, "name": "Maple Street Remodel", "status": { "id": 2, "name": "In Progress" } },
"actor": { "type": "user", "id": 555 },
"delivery_id": "9c1e7f4a-3b2d-4e58-9a7c-0f5b81d6e2aa",
"attempt_number": 1
}
| Field | Description |
|---|---|
event_type | <entity_type>.<eventType> (e.g. project.update). |
company_id | The company the event belongs to. |
entity_type / entity_id | The affected resource and its ID. |
occurred_at | ISO 8601 UTC timestamp of when the change happened, not when we sent it. Unchanged across retries. See Event ordering. |
updated_fields | Names of the resource fields that changed, as they appear in the REST schema. Present on create and update; omitted on delete. |
data | The current resource object. |
actor | Who triggered the change ({ "type": "user", "id": <id> }). |
delivery_id | UUID identifying this event's delivery. Stable across every retry and replay — this is the idempotency key to de-duplicate on. |
attempt_number | The delivery attempt, starting at 1 and incrementing on each retry or replay. |
test | Present and true only for test deliveries sent from the Houzz Pro UI. Absent on real events. |
data may be emptydata is resolved at delivery time. If the resource cannot be read at that moment, the
event is still delivered with an empty data object rather than being dropped — treat an
empty data as "fetch the resource yourself with entity_type + entity_id", not as
"the resource has no fields".
Event ordering
We don't guarantee that events arrive in the order they occurred. Events for the same
resource are dispatched in parallel, and a delivery that fails is retried later — so a
client.create that needed a retry can arrive after the client.update that followed it.
Don't build logic that depends on receiving events in a specific order. data carries the
full resource on both create and update, so the simplest approach is to upsert
whatever arrives and let repeated events converge on the same result.
Where you do need to reason about sequence, use occurred_at: it records when the change
happened rather than when we sent it, and it doesn't change between retries. Treat it as a
hint rather than a strict clock — events generated close together can carry very similar
timestamps. If you need certainty about the current state, fetch the resource from the REST
API using entity_type and entity_id.
delete deserves particular care. Deletes ship an empty data, so occurred_at is the
only ordering information they carry, and a delete applied out of order won't self-correct
the way an upsert does. If you receive a create or update whose occurred_at predates a
delete you've already applied, ignore it.
Delivery expectations
- Acknowledge within 30 seconds. Respond
2xxwithin 30 seconds to acknowledge a delivery — as soon as you have verified the signature and safely persisted or enqueued the event. Do the real work asynchronously; any non-2xx response, a connection error, or exceeding the 30-second timeout marks the attempt failed. - Failed deliveries are retried automatically with exponential backoff. See Retries and backoff below.
- A subscription is automatically disabled after 10 consecutive failures, so monitor your endpoint health.
- Design for idempotency. Retries and replays mean your endpoint may receive the same
event more than once. De-duplicate on
delivery_id— see Reliability & idempotency. - Do not assume ordering. See Event ordering.
Retries and backoff
When a delivery attempt fails, Houzz Pro re-attempts it on a fixed backoff schedule. You do not need to do anything to opt in — retries are on by default for every active subscription.
What gets retried
Only failures that are plausibly transient are retried. Everything else describes the request itself and would fail identically on every attempt, so it is failed immediately without burning attempts against your endpoint.
| Outcome of an attempt | Retried? |
|---|---|
| Connection error, DNS failure, TLS failure | ✅ Yes |
| No response within the 30-second timeout | ✅ Yes |
408 Request Timeout | ✅ Yes |
429 Too Many Requests | ✅ Yes |
Any 5xx | ✅ Yes |
Any 2xx | — Success, no retry |
Any other 4xx (400, 401, 403, 404, 410, 422, …) | ❌ No — failed permanently |
Subscription is paused or disabled | ❌ No — pending retries are abandoned |
A 4xx other than 408/429 tells us the event will never be accepted, and we stop
immediately. If your endpoint is temporarily unable to process an event — a dependency is
down, a queue is full — return 503 or 429, not 400. Returning 400 for a
transient problem permanently drops the event.
The backoff schedule
Waits are measured from the moment an attempt fails. The first attempt is immediate; each subsequent attempt waits one rung further down the ladder, which caps at 6 hours.
| Attempt | Wait since previous failure | Elapsed since the event |
|---|---|---|
| 1 | — (immediate) | 0 |
| 2 | ~1 minute | ~1 minute |
| 3 | ~5 minutes | ~6 minutes |
| 4 | ~30 minutes | ~36 minutes |
| 5 | ~2 hours | ~2 hours 36 minutes |
| 6 | ~6 hours | ~8 hours 36 minutes |
| 7 and beyond | ~6 hours each | +6 hours per attempt |
With the default maxAttempts of 5, an event that keeps failing is given up on after
roughly 2 hours 36 minutes. At the maximum maxAttempts of 10, the window extends
to roughly 32 hours.
Each wait has up to 20% jitter subtracted — never added — so a rung is an upper bound, not an exact time. Jitter spreads out the backlog that builds during an outage: without it, every delivery queued while your endpoint was down would return in the same instant and knock it over again.
Controlling the attempt count
maxAttempts on the subscription is the total number of attempts for a single event,
including the first one:
- Default:
5. - Hard maximum:
10. Higher values are silently capped. - Set
1to disable retries — one attempt, then the delivery is failed.
curl --request PATCH \
--url https://pro-api.houzz.com/api/v1/webhookSubscriptions/42 \
--header 'Authorization: Bearer <access_token>' \
--header 'Content-Type: application/json' \
--data '{ "maxAttempts": 8 }'
Asking for a specific delay with Retry-After
If your endpoint returns a Retry-After header on a retryable failure, it overrides
the ladder for that attempt. This is the cleanest way to shed load: return 429 with a
Retry-After and we back off exactly as long as you asked.
- Both RFC 9110 forms are accepted — delta-seconds (
Retry-After: 120) and an HTTP-date (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT). - The value is clamped to between 1 second and 6 hours. A
Retry-After: 0will not busy-loop, and you cannot park a delivery indefinitely. - An unparseable or missing header falls back to the ladder.
Delivery status and auto-disable
Every attempt is recorded against the delivery, which moves through these states:
| Status | Meaning |
|---|---|
pending | Queued, not yet attempted. |
pending_retry | An attempt failed retryably; the next one is scheduled. |
success | Your endpoint returned 2xx. |
failed | Permanently failed — a non-retryable response, or retryable but out of attempts. |
The subscription's consecutiveFailures counter drives auto-disable, and it counts
events, not attempts:
- An event that exhausts its whole retry ladder increments the counter by 1 — not once per attempt. A brief outage will not auto-disable a healthy subscription.
- Any successful delivery resets the counter to
0. - At 10 consecutive failures the subscription's
statusflips todisabledand deliveries stop. Re-enable it withPATCH /v1/webhookSubscriptions/{id}settingstatusback toactiveonce your endpoint is healthy.
Poll consecutiveFailures on GET /v1/webhookSubscriptions/{id} if you want to alert
before you hit the limit.
Setting status to paused or disabled abandons deliveries that were waiting to be
retried — they are marked failed rather than resuming when you reactivate. Reactivating
a subscription resumes new events only. If you need previously-failed deliveries
re-sent, contact your Houzz representative to have them replayed.
Reliability & idempotency
Webhook delivery is at-least-once: a successfully-processed event may still be delivered to you more than once. Retries and replays re-send the same event (not a new one), so your endpoint must be safe to call repeatedly. Make your handler idempotent — processing the same event twice must have the same effect as processing it once.
- De-duplicate on
delivery_id. Every delivery carries adelivery_idUUID that stays the same across every retry and replay of that event, and differs between distinct events. Record thedelivery_idvalues you have committed and skip any you have already processed — this is the intended idempotency key and needs no derivation. - Use
attempt_numberto recognize retries. The payload'sattempt_numberstarts at1and increases on each retry/replay, so any value greater than1means we previously failed to get a2xxfrom you for this event — usually because your handler errored or timed out, possibly after it had already done its work. - Do not assume ordering. Events may arrive out of order — an
updatecan land before thecreate, or an older state after a newer one. Upsert rather than branching on the event type, and see Event ordering.
Testing your endpoint locally
You don't need a public server to develop against webhooks — expose your local endpoint through a tunnel while you iterate:
- Start a tunnel to your local server with a tool like
ngrok or cloudflared.
Both give you a public HTTPS URL that forwards to
localhost. - Create a subscription pointing at the tunnel's HTTPS URL (see
Create a subscription). Save the returned
secret. - Trigger an event — for example, create or update a project in Houzz Pro —
and watch the
POSTarrive at your local handler. - Verify the signature on the delivery before trusting it, exactly as in production (see Verifying delivery signatures).
Respond 2xx quickly — acknowledge as soon as you have verified the signature
and persisted or enqueued the event, then do any heavy work asynchronously. A slow
handler risks the delivery timeout even while you are testing.
Managing subscriptions
List subscriptions
GET /v1/webhookSubscriptions — supports limit, offset, and a status filter
(active, paused, or disabled).
curl --request GET \
--url 'https://pro-api.houzz.com/api/v1/webhookSubscriptions?status=active' \
--header 'Authorization: Bearer <access_token>'
Get a subscription
GET /v1/webhookSubscriptions/{id} — returns a single subscription. The secret is
not included (it is only returned at creation).
Update a subscription
PATCH /v1/webhookSubscriptions/{id} — change the url, description, maxAttempts,
headers, status, or the set of triggers.
- Set
statustopausedto temporarily stop deliveries, ordisabledto turn the subscription off. Set it back toactiveto resume. - To replace the subscription's entire trigger set, send the new
triggersarray together withupdateTriggers: true.
curl --request PATCH \
--url https://pro-api.houzz.com/api/v1/webhookSubscriptions/42 \
--header 'Authorization: Bearer <access_token>' \
--header 'Content-Type: application/json' \
--data '{
"status": "active",
"updateTriggers": true,
"triggers": [
{ "resourceName": "project", "eventType": "create" },
{ "resourceName": "project", "eventType": "update" },
{ "resourceName": "project", "eventType": "delete" }
]
}'
Delete a subscription
DELETE /v1/webhookSubscriptions/{id} — permanently removes the subscription and stops
all deliveries to its URL.
curl --request DELETE \
--url https://pro-api.houzz.com/api/v1/webhookSubscriptions/42 \
--header 'Authorization: Bearer <access_token>'
{ "deleted": true }