Errors
The Houzz Pro API uses conventional HTTP status codes to indicate the outcome of a request, and returns a single, predictable JSON envelope for every error.
Error envelope
Every error response has the same shape:
{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to perform this action."
}
}
| Field | Description |
|---|---|
code | A stable, machine-readable string identifying the error. Branch on this in code. |
message | A human-readable explanation. Useful for logs and debugging; do not parse it. |
Always check the HTTP status code first, then use error.code for
programmatic handling. Treat error.message as informational — its wording may
change.
Error codes
Branch your handling on error.code (paired with the HTTP status), never on the
wording of error.message. The message is human-readable and may be reworded at
any time; the codes below are stable.
error.code | HTTP status | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | The request body or query parameters were malformed — a missing required field, an invalid value, or a bad pagination parameter. |
INVALID_TOKEN | 401 | The access token is malformed, expired, or otherwise not accepted. |
UNAUTHORIZED | 401 | Authentication is required and was not satisfied (missing credentials or an unrecognized service account). |
FORBIDDEN | 403 | Either the token lacks a scope the endpoint requires, or your plan does not include API integrations access. See 403: scope vs. plan. |
NOT_FOUND | 404 | The resource does not exist or is not visible to your company. |
RATE_LIMIT_EXCEEDED | 429 | You have exceeded the per-second rate limit for your company. Honor Retry-After. See Rate limits. |
INTERNAL_ERROR | 500 | An unexpected error occurred on the Houzz side. Retry with backoff. |
UPLOAD_ERROR | 500 | A file upload failed partway through — reading, storing, or registering the file. Retry the upload. |
SERVICE_UNAVAILABLE | 503 | A Houzz dependency needed to authorize the request was briefly unavailable. Transient — honor Retry-After (sent as 5) and retry. |
503 is not a permanent denialSERVICE_UNAVAILABLE means we could not verify your access, not that you lack it.
Treat it like a 5xx and retry — do not treat it as an authorization failure or disable
your integration's credentials in response to it.
Status codes
| Status | Meaning | What it means | How to handle |
|---|---|---|---|
400 | Bad Request | The request was malformed — a missing required field, an invalid value, or a bad query parameter. | Fix the request. The message usually names the offending field. Do not retry unchanged. |
401 | Unauthorized | The access token is missing, malformed, or expired. | Request a new token and retry. See Authentication. |
403 | Forbidden | The token is valid but does not carry a scope this endpoint requires. | Use a token with the required scope. Re-authenticating with the same scopes will not help. |
404 | Not Found | The resource does not exist, or is not visible to your company. | Verify the ID and that the record belongs to your company. |
429 | Too Many Requests | You have exceeded either the per-second rate limit or your monthly usage allowance. | Honor the Retry-After header, back off, and retry. See Rate limits. |
500 | Server Error | Something went wrong on the Houzz side. | Retry with backoff. If it persists, contact your Houzz representative. |
503 | Service Unavailable | A dependency needed to authorize the request was briefly unavailable. | Transient. Honor Retry-After (5) and retry. Not an authorization failure. |
403: scope vs. plan
A 403 carries the FORBIDDEN code for two distinct reasons, and they need different
fixes:
| Cause | message | What to do |
|---|---|---|
| The token is missing a scope the endpoint requires. | "You do not have permission to perform this action." | Request a token that carries the required scope. |
| The Houzz Pro plan on the account does not include API integrations access. | "Your plan does not include API integrations access." | No token change will help. Contact your Houzz representative about plan entitlements. |
In neither case does re-authenticating with the same scopes help. Because both share the
FORBIDDEN code, use the message to tell them apart when triaging — but do not branch
code on it, since wording may change.
Rate limits
Per-second rate limit
The API rate-limits requests per company using a token bucket with a burst capacity of 45 requests that refills at 15 requests/second. The bucket is shared by every token your company holds — issuing additional credentials does not raise your rate.
Send the burst at once if you need to; once the bucket is empty, requests are paced at the refill rate. A 500-request backfill completes in roughly 30 seconds rather than failing.
Responses carry these headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | The bucket's burst capacity (45). |
X-RateLimit-Remaining | Requests remaining in the bucket right now. |
X-RateLimit-Reset | Unix timestamp (seconds) at which the bucket is full again. Present on every response, not only on a 429. |
Use X-RateLimit-Remaining to pace yourself. When you are throttled, retry after
Retry-After, not X-RateLimit-Reset — Retry-After is when your next request will be
allowed, usually under a second, while X-RateLimit-Reset is when the bucket is fully
refilled, several seconds later.
X-RateLimit-Remaining may show requests available on a 429The per-company bucket is not the only limit a request passes. If a 429 arrives while
X-RateLimit-Remaining still shows requests available, treat it exactly as any other 429:
honor Retry-After and retry.
When you exceed the limit, the API responds with 429 Too Many Requests and a
Retry-After header (seconds to wait), using the standard error envelope:
{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Please retry after the reset time." } }
Honor the Retry-After header: back off for the indicated number of seconds, then retry.
Rate-limit headers may be absent on a small number of responses when the limiter cannot be consulted. The limiter fails open in that case — the request is served rather than rejected — so treat missing headers as "no information", not as "no quota left".
Monthly usage limit
Accounts may also carry a monthly API request allowance. Exhausting it returns 429
for the remainder of the calendar month; the allowance is a plan setting, so raising it
means contacting your Houzz representative rather than backing off.
429 uses a different response shapeThe monthly-limit response does not use the standard error envelope. Its error field
is a plain string, not an object:
{ "error": "monthly_limit_exceeded", "message": "You have reached your monthly API usage limit." }
Code that assumes error.code on every error will read undefined here — or throw, if it
indexes into error as an object. Guard for it when handling 429:
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const isMonthly = body.error === 'monthly_limit_exceeded';
// isMonthly: stop and alert — backing off will not help until next month.
// otherwise: per-second limit — honor Retry-After and retry.
}
This inconsistency is a known defect and the response will be moved onto the standard envelope in a future release. Branch defensively on both shapes for now.
Handling errors
A general-purpose pattern: branch on the status code, retry the transient classes with backoff, and surface the rest.
async function callApi(url, accessToken) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
return res.json();
}
const body = await res.json().catch(() => ({}));
const code = body?.error?.code;
const message = body?.error?.message;
switch (res.status) {
case 401:
// Token missing or expired — refresh and retry once.
throw new Error(`Unauthorized (${code}): re-authenticate`);
case 403:
// Missing scope, or the plan lacks API access — do not retry either way.
throw new Error(`Forbidden (${code}): ${message}`);
case 429: {
// Two distinct cases with two distinct response shapes.
if (body?.error === 'monthly_limit_exceeded') {
throw new Error('Monthly API allowance exhausted — backoff will not help');
}
const retryAfter = Number(res.headers.get('retry-after') ?? 1);
throw new RetryableError(`Rate limited; retry in ${retryAfter}s`, retryAfter);
}
case 400:
case 404:
// Client-side problem — fix the request; do not retry unchanged.
throw new Error(`Request error ${res.status} (${code}): ${message}`);
default:
// 5xx and 503 — transient; retry with exponential backoff.
throw new RetryableError(`Server error ${res.status}: ${message}`);
}
}
Rules of thumb
- 4xx errors are client-side — with two exceptions. Fix the request before retrying an
unchanged
400,403, or404; it will fail the same way. But429(and408, if you see one) are worth retrying after a backoff. - 5xx and
503are server-side and usually transient. Retry with exponential backoff and a capped number of attempts.503in particular is a dependency blip, not a permission problem. - Branch on the status first, then
error.code— and remember the monthly-limit429is the one response whoseerroris a string rather than an object.