Skip to main content

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."
}
}
FieldDescription
codeA stable, machine-readable string identifying the error. Branch on this in code.
messageA 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.codeHTTP statusMeaning
VALIDATION_ERROR400The request body or query parameters were malformed — a missing required field, an invalid value, or a bad pagination parameter.
INVALID_TOKEN401The access token is malformed, expired, or otherwise not accepted.
UNAUTHORIZED401Authentication is required and was not satisfied (missing credentials or an unrecognized service account).
FORBIDDEN403Either the token lacks a scope the endpoint requires, or your plan does not include API integrations access. See 403: scope vs. plan.
NOT_FOUND404The resource does not exist or is not visible to your company.
RATE_LIMIT_EXCEEDED429You have exceeded the per-second rate limit for your company. Honor Retry-After. See Rate limits.
INTERNAL_ERROR500An unexpected error occurred on the Houzz side. Retry with backoff.
UPLOAD_ERROR500A file upload failed partway through — reading, storing, or registering the file. Retry the upload.
SERVICE_UNAVAILABLE503A Houzz dependency needed to authorize the request was briefly unavailable. Transient — honor Retry-After (sent as 5) and retry.
503 is not a permanent denial

SERVICE_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​

StatusMeaningWhat it meansHow to handle
400Bad RequestThe 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.
401UnauthorizedThe access token is missing, malformed, or expired.Request a new token and retry. See Authentication.
403ForbiddenThe 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.
404Not FoundThe resource does not exist, or is not visible to your company.Verify the ID and that the record belongs to your company.
429Too Many RequestsYou 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.
500Server ErrorSomething went wrong on the Houzz side.Retry with backoff. If it persists, contact your Houzz representative.
503Service UnavailableA 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:

CausemessageWhat 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:

HeaderDescription
X-RateLimit-LimitThe bucket's burst capacity (45).
X-RateLimit-RemainingRequests remaining in the bucket right now.
X-RateLimit-ResetUnix 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 429

The 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.

note

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.

This 429 uses a different response shape

The 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, or 404; it will fail the same way. But 429 (and 408, if you see one) are worth retrying after a backoff.
  • 5xx and 503 are server-side and usually transient. Retry with exponential backoff and a capped number of attempts. 503 in particular is a dependency blip, not a permission problem.
  • Branch on the status first, then error.code — and remember the monthly-limit 429 is the one response whose error is a string rather than an object.