Build a property website
Use the Sites API for a new server-rendered property or leasing site, and V1 for WordPress sites and widgets.
Who this is for: agencies and web partners building property marketing sites, and internal teams building custom front ends.
What you'll build: a property site, or a centralized leasing site for a portfolio, that renders every section from Resi and stays current without redeploys.
Pick the API first
| You are building | Use |
|---|---|
| A new Next.js (or other server-rendered) property website or centralized leasing site | Sites API |
| An existing WordPress site, or a widget embedded on a page | V1 |
The two are not interchangeable. The Sites API authenticates one website with its own token, is called only from the site's server, addresses records by slug, and tells you when to revalidate. V1 needs no credential, is addressed by property UUID, and is safe to call from a browser. See Which API should I use?
New sites: the Sites API
Base URL: https://v2.getresi.com/api/sites/v1
A token belongs to one website, and every response is limited to that website's own properties, floor plans, units, content, forms and integrations. A property website sees its one property; a centralized leasing site sees its whole portfolio through the same endpoints.
Server-side only. The website token reads data that is not public and can create leads. Call the API from route handlers, server components or build steps. It must never reach a browser.
A typical build
GET /site: domains, tracking, redirects, URL patterns and a card per property.GET /paths: every path to generate statically, and the sitemap.- Per page:
GET /properties/{property},GET /floor-plans,GET /units,GET /entries/{type}/{slug}. - Per property:
GET /integrations,GET /lead-sources,GET /forms. - At request time:
GET /resolvefor paths the build did not generate,GET /availabilityfor what moves, and the POST endpoints for form submissions, tours and analytics.
async function resi<T>(path: string, tags: string[], revalidate: number): Promise<T> {
const res = await fetch(`${process.env.RESI_DELIVERY_API_URL}${path}`, {
headers: {
Authorization: `Bearer ${process.env.RESI_DELIVERY_TOKEN}`,
'X-Resi-Website': process.env.RESI_WEBSITE_ID!,
Accept: 'application/json',
},
next: { tags, revalidate },
});
if (!res.ok) throw new Error(`Resi ${path}: ${res.status}`);
return res.json();
}
const units = await resi(`/units?property=${slug}`, [`inventory:${propertyId}`], 300);Staying current
Every GET carries meta.cache.tags (also in X-Resi-Cache-Tags) and a suggested meta.cache.ttl. Cache responses in your application under those tags. When data a site rendered changes, Resi POSTs a signed cache-clear webhook naming the tags that changed to any site set up to receive it (every site scaffolded from the current template is); verify the signature and revalidate those tags. You do not need to poll or rebuild on a timer. See Caching & the cache-clear webhook.
Leads, tours and analytics
Forms render from GET /forms, and submissions go through POST /forms/{form}/submissions from your server, with an Idempotency-Key so a retry after a timeout cannot create a second lead. Tours use POST /tours/availability and POST /tours/reservations. Writes are limited to 60 a minute per website, reads to 1,200. See Forms, tours & analytics.
Where to go next
- Authentication & website scoping
- Conventions: envelope, filters, errors, limits
- Bootstrap, routing & content
- Properties and Inventory & pricing
- Lead sources & integrations
- Generating types
WordPress sites and widgets: V1
The public V1 delivery API returns pre-composed payloads per property. It needs no credential, so a WordPress theme, a plugin or a widget can call it directly, including from the browser.
Base URL: https://v2.getresi.com/api/v1 · See Website & content data for the full endpoint map.
Page-by-page fetch plan
| Page | Endpoints |
|---|---|
| Home | /property/{id}, /galleries, /announcements, /content-groups, /panels |
| Floor plans | /floor-plans, /unit-types |
| Availability | /units, /floor-plans |
| Unit detail | /units, /unit/{unit}/price-matrix (lazy) |
| Amenities | /amenities, /galleries |
| Neighborhood | /neighborhood |
| Reviews | /reviews |
| FAQ | /faqs |
| Contact / Tour | /forms, /integrations |
const base = `https://v2.getresi.com/api/v1/property/${propertyId}`;
const [property, galleries, announcements, content, panels] = await Promise.all([
fetch(base).then((r) => r.json()),
fetch(`${base}/galleries`).then((r) => r.json()),
fetch(`${base}/announcements`).then((r) => r.json()),
fetch(`${base}/content-groups`).then((r) => r.json()),
fetch(`${base}/panels`).then((r) => r.json()),
]);These are independent, so fire them concurrently.
What to cache, and for how long
| Data | When to fetch |
|---|---|
| Property profile, amenities, galleries, FAQs, neighborhood, reviews, content, panels | Cache for hours; refresh at least daily |
| Announcements | Cache for an hour at most |
| Floor plans, unit types | Revalidate hourly |
| Units, availability, pricing | Request time, or a cache of 5–15 minutes |
| Price matrix | Request time, on unit detail only, cached per unit per day |
| Forms, integrations | Revalidate hourly |
Baking availability into a long-lived cache is the classic mistake: the site shows a unit that leased three days ago, and the leasing office fields the call.
V1 returns only announcements that are active at request time, and the payload carries no start or end dates. A page cached for a day keeps showing an announcement that ended this morning, so keep that cache short.
Purging the site's host cache. POST /api/v1/cache/clear purges the page cache of the website host connected to one property (property_id) or one portfolio (portfolio_id). That is a Kinsta or WP Engine connection, plus the site of any merged parent property:
curl -X POST https://v2.getresi.com/api/v1/cache/clear \
-H "Content-Type: application/json" \
-d '{"property_id": "019dd604-8468-7388-9a52-4c31a2e1209a"}'It always answers 202, whether or not anything was purged: a property with no Kinsta or WP Engine connection has nothing to clear. It does not reach any other page cache or CDN, so clear those alongside it. Rate limit: 30 a minute per target.
Rendering guidance
Images. Image objects carry thumb_url, full_url, alt_text and caption. full_url is a resized copy generated on a queue, so for a short time after an import it can return a 404; thumb_url exists as soon as the import finishes. Fall back to it on error. Use alt_text as your alt attribute; where it is empty, prefer an empty alt="" on decorative images over inventing a description.
Availability display. /units returns the property's enabled units. Show a unit only when unitAvailable is true and unitModel and unitGuestSuite are false. Group by floor plan for the standard "3 available in the A1 layout" presentation, and use /unit-types for the bed/bath summary rollups.
Pricing. unitMinTmlp (also unitPrice) is the total monthly leasing price: base rent plus the monthly equivalent of mandatory fees. unitMinBaseRent is base rent alone. Follow the property's pricingDisplaySettings: pricing_display_mode says whether to show the total, the base, or both, and tmlp_label labels the total. A price can come back null. Render hidden_price_label when the property hides all pricing, no_availability_price_label when it hides prices with nothing available, and no_price_label for any other null price. Never compute your own total from base rent plus fees. Where the maximum is null, show a single price rather than a range with an empty side.
Content groups and panels carry the client's configured page sections. Render them in the order returned. Treat an unrecognized block type as a no-op rather than an error, since new types get added without a version bump.
Lead form and tour widget
Render the form from /forms rather than hardcoding fields; clients change forms without telling their agency. Submit to POST /api/v1/leads and display the returned message. See Capture leads from your own site or CRM.
For tours, use the flow in Build a tour booking experience, and hide the tour UI entirely when /integrations lists no connection with category: "tour".
Add your own spam protection in front of the lead form. Resi does not rate-limit V1 lead submission, and it has no idempotency: a double submit creates two leads.
SEO and structured data
The V1 property payload has everything needed for schema.org/ApartmentComplex:
const jsonLd = {
"@context": "https://schema.org",
"@type": "ApartmentComplex",
name: property.name,
description: property.description,
url: property.website,
telephone: property.phone,
image: property.imageFull,
address: {
"@type": "PostalAddress",
streetAddress: property.street,
addressLocality: property.city,
addressRegion: property.state,
postalCode: property.zipcode,
},
geo: {
"@type": "GeoCoordinates",
latitude: property.latitude,
longitude: property.longitude,
},
numberOfAvailableAccommodationUnits: property.availableUnitsCount,
};Reviews from /reviews can populate aggregateRating, but only publish ratings you are permitted to syndicate. Some review sources prohibit re-display; confirm with the client before marking them up.
Content privacy
V1 delivers published marketing content to anyone who asks, so treat every field it returns as public. If a client keeps internal notes or staging URLs in a rendered content field, those travel to the page. Worth raising once during onboarding so internal notes stay in internal fields.
Checklist
- Sites API for new server-rendered sites; V1 for WordPress and widgets
- Sites API token kept on the server
- Sites API responses cached by tag and cleared by the webhook
- V1: static content cached long, availability at request time, announcements short
- Concurrent fetches, not sequential
- Image fallback when
full_urlis not ready -
alt_textused foralt - Marketability flags applied before rendering a unit
- Prices shown per the property's display settings, never a self-computed total
- Unknown content block types ignored, not fatal
- Lead form rendered from
/formswith spam protection - Tour UI hidden when no tour connection exists
Last updated on