Skip to main content

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.

note

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​

  1. Discover available triggers — GET /v1/webhookTriggers returns the full catalog of (resourceName, eventType) pairs you can subscribe to.
  2. Create a subscription — POST /v1/webhookSubscriptions with your delivery URL and the triggers you want. The response includes a one-time secret.
  3. 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.
  4. 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

FieldTypeRequiredDescription
urlstringyesHTTPS URL that receives deliveries.
triggersarraynoTriggers to subscribe to, each { resourceName, eventType }.
descriptionstringnoHuman-readable label for the subscription.
maxAttemptsintegernoMaximum 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.
headersobjectnoCustom 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..."
}
}
Store the secret now

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:

EntityresourceNameEvent types
Client / Contactclientcreate, update, delete
Projectprojectcreate, update, delete
Leadleadcreate, update, delete
Tasktaskcreate, update, delete
Milestonemilestonecreate, update, delete
Vendorvendorcreate, update
Filefilecreate, update, delete
Team memberteam-membercreate, update
Subcontractorsubcontractorcreate, update
Daily logdaily-logcreate, update, delete
Contact groupcontact-groupcreate, 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.

Archive is an update, not a delete

Several 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 update and inspect the resource's status fields in data.
  • Files are archived to Trash first, which arrives as an update. The file.delete event 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 the secret returned 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:

  1. 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.
  2. Build the signed payload {timestamp}.{rawBody} using the X-Webhook-Timestamp value.
  3. Compute sha256= + HMAC-SHA256(signedPayload, secret) as hex.
  4. Compare it, using a constant-time comparison, against the X-Webhook-Signature header.
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
}
FieldDescription
event_type<entity_type>.<eventType> (e.g. project.update).
company_idThe company the event belongs to.
entity_type / entity_idThe affected resource and its ID.
occurred_atISO 8601 UTC timestamp of when the change happened, not when we sent it. Unchanged across retries. See Event ordering.
updated_fieldsNames of the resource fields that changed, as they appear in the REST schema. Present on create and update; omitted on delete.
dataThe current resource object.
actorWho triggered the change ({ "type": "user", "id": <id> }).
delivery_idUUID identifying this event's delivery. Stable across every retry and replay — this is the idempotency key to de-duplicate on.
attempt_numberThe delivery attempt, starting at 1 and incrementing on each retry or replay.
testPresent and true only for test deliveries sent from the Houzz Pro UI. Absent on real events.
data may be empty

data 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 2xx within 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 attemptRetried?
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
Return the right status

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.

AttemptWait since previous failureElapsed 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 1 to 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: 0 will 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:

StatusMeaning
pendingQueued, not yet attempted.
pending_retryAn attempt failed retryably; the next one is scheduled.
successYour endpoint returned 2xx.
failedPermanently 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 status flips to disabled and deliveries stop. Re-enable it with PATCH /v1/webhookSubscriptions/{id} setting status back to active once your endpoint is healthy.

Poll consecutiveFailures on GET /v1/webhookSubscriptions/{id} if you want to alert before you hit the limit.

Pausing stops retries in flight

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 a delivery_id UUID that stays the same across every retry and replay of that event, and differs between distinct events. Record the delivery_id values you have committed and skip any you have already processed — this is the intended idempotency key and needs no derivation.
  • Use attempt_number to recognize retries. The payload's attempt_number starts at 1 and increases on each retry/replay, so any value greater than 1 means we previously failed to get a 2xx from 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 update can land before the create, 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:

  1. 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.
  2. Create a subscription pointing at the tunnel's HTTPS URL (see Create a subscription). Save the returned secret.
  3. Trigger an event — for example, create or update a project in Houzz Pro — and watch the POST arrive at your local handler.
  4. 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 status to paused to temporarily stop deliveries, or disabled to turn the subscription off. Set it back to active to resume.
  • To replace the subscription's entire trigger set, send the new triggers array together with updateTriggers: 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 }