Docs
API guides

Errors, rate limits & reliability

Status codes, error shapes, throttle budgets, retry strategy, and how to handle asynchronous operations.

Error shapes

V2 returns a message:

{ "message": "You do not have permission to create API tokens for this account." }

Validation failures (422) add a per-field errors map:

{
  "message": "The given data was invalid.",
  "errors": {
    "url": ["The url field is required."],
    "attachable_type": ["The selected attachable type is invalid."]
  }
}

The message wording varies between endpoints; errors is the part to read.

V1 uses a different key — error — for failures its endpoints report themselves, such as a property with no tour provider:

{ "error": "No tour connection is configured for this property." }

V1 validation failures use the same message + errors shape as V2. The Sites API uses message (and errors on validation) throughout.

Parse defensively: body.message ?? body.error ?? "Unknown error".

Status codes

CodeMeaningRetryable?
200OK—
201Created — body contains the new resource—
202Accepted for async processing. The work is queued, not done.—
204Success, no body (deletes, token revocation)—
401Missing, invalid, or revoked tokenNo — alert a human
403Authenticated but not permitted, or the token's user has lost access to its accountNo — the role or membership needs fixing
404Not found, or belongs to another account, or outside the user's groups. A path id that is not a UUID is also a 404. The message names the kind of record, such as Unit not found.No
422Validation failed — see errors. Every 422 has an errors object; it is empty when the request as a whole was refused (such as running a disabled connection) rather than one field.No — fix the payload
429Rate limitedYes — with backoff
500Unexpected server errorYes — with backoff, then alert
502Upstream failure (PMS, CRM, tour provider unreachable)Yes — the failure is downstream of Resi

A 502 on tour endpoints is worth calling out: it means Resi reached the property's booking provider and the provider failed. Retrying can succeed; retrying immediately usually will not.

Send Accept: application/json on every request. Without it, a missing or invalid token produces an HTML error page with a 500 status instead of a JSON 401, which a retry loop will mistake for a server fault.

Rate limits

Endpoint groupLimitKeyed by
All /api/v2/*120 requests / minuteAuthenticated user (falls back to IP)
POST /api/v2/media60 / minute, on top of the 120Same — tighter, because each call triggers a download
POST /api/v2/media/batch10 / minute, on top of the 120Same
POST /api/v1/demand-events120 / minuteIP
POST /api/v1/source-observations240 / minuteIP
POST /api/v1/cache/clear30 / minuteTarget property or portfolio id
Sites API reads1,200 / minuteWebsite token
Sites API form submissions, tour availability and reservations, unit price matrix60 / minuteWebsite token

The V2 budget is per user, not per token — two tokens issued by the same user share one bucket, and so do requests that use account_id to reach different accounts. If you run several integrations for one client, ask for tokens created by different users so a bulk job cannot starve a latency-sensitive one.

The two media limits draw on one shared counter per user: single imports count toward the batch limit and batch calls count toward the single-import limit. Likewise, the two V1 analytics endpoints share one counter per IP.

Every throttled response carries the standard headers:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 94
Retry-After: 37                 # on 429 only
X-RateLimit-Reset: 1790000000   # on 429 only, Unix time

Staying under the limit

  • Page at per_page=200. A 5,000-unit portfolio is 25 requests, not 334.
  • Filter server-side. ?is_available=true beats fetching everything and filtering locally, on both latency and quota.
  • Join in memory, don't fetch per row. See Pagination, filtering & sorting.
  • Batch media. One POST /media/batch imports up to 100 items; see Media.
  • Serialize bulk jobs. Concurrency of 2–4 is plenty and leaves headroom; parallel workers hammering the same budget produce 429s that cost more than they save.

Handling 429

Honor Retry-After. When it is absent, use exponential backoff with jitter, and give up after a bounded number of attempts rather than looping forever.

async function request(url, init, attempt = 0) {
  const res = await fetch(url, init);

  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 5) throw new Error(`Giving up after ${attempt} retries: ${res.status}`);
    const retryAfter = Number(res.headers.get("Retry-After"));
    const backoff = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : Math.min(2 ** attempt * 1000, 30_000) * (0.5 + Math.random());
    await new Promise((r) => setTimeout(r, backoff));
    return request(url, init, attempt + 1);
  }

  return res;
}

Retry only 429 and 5xx. Retrying a 422 will fail identically every time.

Asynchronous operations

Two operations return 202 and finish on a queue:

Deleting a property (DELETE /api/v2/properties/{property}) queues a full teardown of the property and its relations:

{ "message": "Property deletion has been queued." }

The property still exists — and still appears in GET /properties — until a worker finishes. To confirm completion, poll GET /properties/{id} until it returns 404, with a sane timeout and a ceiling on attempts.

Running a connection (POST /api/v2/connections/{connection}/run) queues a sync and answers "Connection run has been queued.". Completion is visible in the resulting data, not in the response.

Importing media (POST /api/v2/media) is different: Resi downloads the file during the request and returns 201 with the created record, and thumb_url is ready at once. The full-size derivative behind full_url is generated on a queue and may 404 for a short window after creation. Fall back to url until it resolves.

Designing for resilience

Retry media imports freely. Media imports deduplicate — a file on its source url, then its reference_id; an embed on its parent, embed_type and url — so repeating an import updates or re-attaches the existing record instead of adding a copy.

Be idempotent on your own side for everything else. Other creates do not deduplicate, and V2 has no idempotency keys. A POST that times out may or may not have created a record. Before retrying a create, query for the record — by reference_id where filterable, or by a natural key such as a unit's property_id and number — instead of blindly re-posting.

Never let a lead retry silently drop. Queue lead submissions in your own system, retry on 5xx with backoff, and alert on permanent failure. A lost lead costs a client real money; a duplicated one costs a leasing agent thirty seconds.

Cache reads, but bound the TTL by volatility.

DataSuggested TTL
Unit availability & pricing5–15 minutes
Floor plans, buildings1 hour
Property profile, content, FAQs, galleries6–24 hours
Media assetsUntil the parent record's updated_at changes

Treat unknown fields as additive. New fields can appear in responses without a version change. Deserialize permissively — an unrecognized key must never throw.

Log enough to get support traction, and no secrets. For every failure, record the method, the endpoint path with any secret query values removed, the response status, the X-Resi-Account-Id response header, the UTC timestamp, and the error message (plus the field names in errors for a 422). Those are what make a support ticket actionable.

Never log the Authorization header, a token, or a password. If you keep request or response bodies at all, redact them first: POST /api/v2/tokens returns a plaintext token, connection settings carry the connected system's usernames, passwords and keys in both requests and responses, and lead, tour and user payloads hold people's names, emails and phone numbers. Log field names and ids rather than values.

Health checks

GET /api/v2/me is the cheapest authenticated call and doubles as a credential check — use it as a liveness probe for your integration, not as a poll loop.

Last updated on

On this page