Caching & the cache-clear webhook
Hold responses by tag, revalidate with ETags, and let Resi tell you when something changed.
The API is designed to be cached hard by the site and cleared precisely. Three pieces work together: tags say what a response was built from, ETags make re-checking cheap, and a signed webhook names the tags that changed.
What every GET carries
ETag: "4883864bdf38cea483a006ec7e69cce91a6a0a56"
Cache-Control: private, no-cache
X-Resi-Cache-Tags: inventory:0100…0002,property:0100…0002,site"meta": { "cache": { "tags": ["inventory:0100…0002", "property:0100…0002", "site"], "ttl": 3600 } }meta.cache.tags(also inX-Resi-Cache-Tags) — what the payload was built from. Pass them to your cache as-is.meta.cache.ttl— how long, in seconds, to hold the response if no webhook arrives:3600for content that rarely moves,300for inventory,60for/availability.Cache-Control: private, no-cache— the response is per-website and must not be stored by a shared cache or CDN. Cache it in your application, not in front of it.
Tags
| Tag | Changes when |
|---|---|
site | The website, its domains, redirects, settings, property membership, group sets or content types change |
paths | Anything that adds, removes or moves a renderable path |
property:{id} | The property's details, content, amenities, FAQs, galleries, reviews |
inventory:{property_id} | Floor plans, units, availability or pricing |
entries:{type} | A content entry of that type |
integrations:{property_id} | The property's connections |
lead-sources:{property_id} | Lead sources or attribution rules |
forms:{property_id} | Forms attached to the property |
In Next.js, the tags map directly onto fetch:
const res = await fetch(`${process.env.RESI_DELIVERY_API_URL}/units?property=${slug}`, {
headers: {
Authorization: `Bearer ${process.env.RESI_DELIVERY_TOKEN}`,
'X-Resi-Website': process.env.RESI_WEBSITE_ID!,
Accept: 'application/json',
},
next: { tags: [`inventory:${propertyId}`], revalidate: 300 },
});When the tags are not known before the call, read them from the first response and re-tag, or tag by the coarser site.
Conditional requests
Send the last ETag back as If-None-Match. An unchanged payload answers 304 with no body, which still counts as one request against the rate limit but costs almost nothing to serve or receive.
The cache-clear webhook
When data a website rendered changes, Resi POSTs to the site:
POST https://{primary domain}/api/resi/cache
Content-Type: application/json
X-Resi-Event: cache.invalidate
X-Resi-Signature: sha256=5d41402abc4b2a76b9719d911017c592…{
"event": "cache.invalidate",
"website_id": "01a0b9f5-b5bc-711c-9d7b-6c67aec47953",
"tags": ["inventory:01000000-0000-4000-8000-000000000002", "paths"],
"paths": [],
"timestamp": 1789760000
}The URL defaults to /api/resi/cache on the website's primary domain; the website's cache_webhook_url setting overrides it. Either way it must be https to a public host: a private, loopback or link-local address is refused, and a redirect from the site is never followed and counts as a failed delivery.
Who receives it. Only a site built to: one whose provisioning wrote it RESI_CACHE_WEBHOOK_SECRET (every site scaffolded from the current template), or one whose cache_webhook_url names a receiver. Sites on earlier templates are never sent it.
Verifying it
- Read the raw request body before parsing it.
- Compute HMAC-SHA256 of those bytes, keyed with
RESI_CACHE_WEBHOOK_SECRET(unique per website). - Compare, in constant time, with
X-Resi-Signatureafter itssha256=prefix. - Reject a body whose
timestampis more than about five minutes old. The timestamp is inside the signed bytes precisely so a replay cannot refresh it. - Revalidate every tag in
tagsand every path inpaths, then answer2xx.
// app/api/resi/cache/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const raw = await request.text();
const sent = (request.headers.get('x-resi-signature') ?? '').replace(/^sha256=/, '');
const expected = createHmac('sha256', process.env.RESI_CACHE_WEBHOOK_SECRET!).update(raw).digest('hex');
const a = Buffer.from(sent, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('Bad signature', { status: 401 });
}
const body = JSON.parse(raw) as { tags: string[]; paths: string[]; timestamp: number };
if (Math.abs(Date.now() / 1000 - body.timestamp) > 300) {
return new Response('Stale', { status: 401 });
}
body.tags.forEach((tag) => revalidateTag(tag));
body.paths.forEach((path) => revalidatePath(path));
return Response.json({ revalidated: body.tags.length + body.paths.length });
}Delivery
- Changes within about fifteen seconds of each other are sent as one webhook, so an import that touches two hundred units produces one call.
- Anything but a
2xxis retried up to five times, backing off over roughly twenty minutes. The tags are kept across retries. - An edit to an image or video alone does not send a webhook today; it appears when the TTL runs out.
- The webhook is a hint, not a guarantee. A change Resi does not detect, or a delivery that never lands, is picked up when
meta.cache.ttlruns out. Always set arevalidatealongside your tags.
Last updated on