Docs
Sites API

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 in X-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: 3600 for content that rarely moves, 300 for inventory, 60 for /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

TagChanges when
siteThe website, its domains, redirects, settings, property membership, group sets or content types change
pathsAnything 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

  1. Read the raw request body before parsing it.
  2. Compute HMAC-SHA256 of those bytes, keyed with RESI_CACHE_WEBHOOK_SECRET (unique per website).
  3. Compare, in constant time, with X-Resi-Signature after its sha256= prefix.
  4. Reject a body whose timestamp is more than about five minutes old. The timestamp is inside the signed bytes precisely so a replay cannot refresh it.
  5. Revalidate every tag in tags and every path in paths, then answer 2xx.
// 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 2xx is 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.ttl runs out. Always set a revalidate alongside your tags.

Last updated on

On this page