Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Public API

Every endpoint your plugin may call lives under /public-api/v1 on HomeRunner Central. It is the only HomeRunner data API a plugin can reach: there is no plugin-scoped API, no API key and no per-plugin identity. The identifier in the URL — a feed id, a platform UUID, a property UUID, a widget id — is what authorises the read.

Everything on this page applies to both manifest shapes (single-widget and suite). The API does not know which shape you shipped. The one shape-dependent rule is where you declare externalFetch, covered in Server-side.

Enforcement on this page is an HTTP response, not a publish-time check. No audit inspects the URLs your code builds, so a call with a misspelled parameter passes review and misbehaves silently in production. Each parameter table’s On violation column says what actually happens; advisory (unvalidated) means the value is accepted and then ignored or clamped with no error. The book-wide enforcement legend lives in Limits and error index.

Base URL

Resolve the host at runtime. Never hard-code it — the same plugin bytes are served to production, staging and local rigs, and only the runtime knows which Central it is paired with.

// packages/homerunner-widget-core/src/runtime/api.ts:33-44 (verbatim)
export function homerunnerApiBaseUrl(): string {
  if (typeof window !== "undefined") {
    const fromRuntime = window.HRWidgetRuntime?.apiBaseUrl;
    if (fromRuntime) return fromRuntime;
  }
  const envValue =
    typeof process !== "undefined"
      ? process.env?.NEXT_PUBLIC_HOMERUNNER_BASE_URL
      : undefined;
  if (envValue) return envValue;
  return "https://central.homerunner.io";
}

Resolution order, highest first:

#SourceWhere it is set
1window.HRWidgetRuntime.apiBaseUrlBaked into the env-matched runtime IIFE the host page loads. Authoritative in the browser on customer pages, the dashboard playground, staging and production.
2process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URLVite-injected from your .env.local during npm run dev; also whitelisted into the renderer’s SSR sandbox environment.
3https://central.homerunner.ioHard fallback.

Build every URL as `${homerunnerApiBaseUrl()}/public-api/v1/...`. The helper is exported from @homerunner-next/widget-core/runtime — see SDK reference.

Authentication

There is no token, header or signature. Two middlewares decide whether a request is served, and which one a route uses determines which identifier you must supply.

MiddlewareWhat it resolvesFailure
auth.public.v1The first present of platform_uuidproperty_uuidwidget_idfeed_id, preferring the route segment. Query fallbacks are ?platform=, ?property=, ?widget=, ?feed= (each logs a server-side warning). Confirms the resource exists; a platform must be active.404 NOT_FOUND for an unknown platform, property, widget or feed; 400 BAD_REQUEST with Platform is not active., or with Platform UUID, Property UUID, Widget ID, or Feed ID is required when none is present.
auth.public.feed.v1feed_id only — route segment, else ?feed_id=, else ?feed=.401 UNAUTHENTICATED when it is missing; 404 NOT_FOUND when the feed does not exist.
(none)GET /widgets and GET /plugins/{slug} carry no auth middleware at all. Both enforce their own gate in the controller.See their entries below.

Because auth.public.v1 resolves the first identifier it finds, a request that carries both a route feed_id and a ?platform= UUID resolves as the platform. Prefer route segments over query fallbacks: the query forms work but are logged as suspect and may be withdrawn.

Always spell the query parameter feed_id, never feed. auth.public.feed.v1 accepts either, but the controllers behind it read only feed_id (or the route segment). Sending ?feed= therefore passes authentication and then fails inside the controller with 400 Feed ID is required. — an error that looks like a bug in your code and is not.

Both middlewares then run one shared availability gate over every feed the request touches, not just the identifier that matched. Three states refuse the request:

ConditionStatuserror.codeerror.message
Feed switched off at the edge403FEED_DISABLEDThis feed is no longer being served.
Feed archived403FEED_ARCHIVEDThis feed has been archived.
Owning account suspended403ACCOUNT_SUSPENDEDYour account is suspended. Please contact support.

Treat all three as permanent for the life of the page: do not retry, render your empty or error state. A property attached to several feeds is refused only when every one of them is blocked.

Not supported yet. A plugin has no credential of its own. You cannot prove to Central that a request came from your widget rather than from anyone who read the feed id out of the page source, and you cannot request elevated scope. Everything reachable here is data the feed already publishes. Do not design a feature that depends on privileged reads.

Rate limit

300 requests per minute, per client IP, across every /public-api/* endpoint combined.

// homerunner-central/app/Providers/RouteServiceProvider.php:73 (verbatim)
RateLimiter::for('public-api', fn (Request $request) => Limit::perMinute(300)->by($request->ip()));
  • The throttle runs before authentication, so refused requests (bad feed id, archived feed, 404s) consume budget too.
  • Successful responses carry X-RateLimit-Limit and X-RateLimit-Remaining.
  • The bucket is the IP, not the feed or the widget. On a server-rendered page every widget on that page shares the renderer’s IP; one cold property page can easily cost a dozen calls. Batch and cache accordingly, and see Fetching data.
  • A 429 body is the canonical error envelope with error.code TOO_MANY_REQUESTS, but a gateway may answer before Central does — always read res.status before parsing JSON.

Not supported yet. You cannot read your remaining budget from a 429. The throttle raises an exception, and the exception handler rebuilds the response as the canonical envelope — the throttle’s own Retry-After and X-RateLimit-Reset headers do not survive that rebuild. Separately, Central’s CORS configuration sets exposed_headers to an empty list, so browser JavaScript cannot read X-RateLimit-* even on a success. Back off on a schedule of your own; do not wait for a header. Server-side code in the SSR sandbox is not subject to CORS, but in a normal render its GETs go through the sandbox’s cached-fetch wrapper, which rebuilds the response with Content-Type only — so it cannot read the success headers either. They survive only on non-GET requests and in ?dev=1 renders, which bypass the cache.

CORS is otherwise fully open for reads: allowed_origins: ["*"], allowed_methods: ["*"], supports_credentials: false. Never send credentials or cookies — they would be rejected.

Response envelope

Success — two shapes, not one

There is no single success envelope. Older controllers wrap the payload in result, newer ones in data. Check the per-endpoint entry before writing a type.

// Shape A — older controllers (helper: centralApiResponse; injects status_code)
{ "status": "ok", "status_code": 200, "result": { }, "pagination": { } }

// Shape B — newer controllers
{ "success": true, "status_code": 200, "data": { }, "meta": { } }

Shape A endpoints: /details, all /feed/* property and meta routes, /property/{id}, /property/{id}/full, /property-*, /reviews, /feed/widgets, /feed/plugins, /plugins/{slug}, quote reads and writes (result). Shape B endpoints: /widget/{id}, /widgets, /property/{id}/payment-settings, the two Stripe routes, /{quote_id}/checkout-url, /reservations/{id} and its payment-methods POST.

Errors — one canonical envelope

Every /public-api/* error — thrown, forwarded, validation failure, unmatched route, wrong verb, 429 — is funnelled through one helper by the exception handler.

// homerunner-central/app/PublicApi/V1/Helpers/ErrorEnvelope.php:98-109
{
  "success": false,
  "status_code": 404,
  "error": {
    "code": "NOT_FOUND",
    "message": "The requested resource was not found.",
    "details": {}          // ALWAYS a JSON object. {} when there is nothing to say.
  }
}

details is deliberately coerced to an object so {} never arrives as []. On a 422 it is { "<field>": ["<message>", …] }.

Status → error.code and the default error.message, verbatim from ErrorEnvelope::codeForStatus and ErrorEnvelope::defaultMessageForStatus:

Statuserror.codeDefault error.message
400BAD_REQUESTThe request was invalid.
401UNAUTHENTICATEDAuthentication is required to access this resource.
402PAYMENT_FAILEDPayment could not be processed.
403FORBIDDENYou do not have permission to access this resource.
404NOT_FOUNDThe requested resource was not found.
405METHOD_NOT_ALLOWEDThe HTTP method used is not allowed for this endpoint.
409CONFLICTThe request could not be completed due to a conflict.
422VALIDATION_ERRORThe request could not be validated.
429TOO_MANY_REQUESTSRate limit exceeded. Please try again later.
≥ 500INTERNAL_SERVER_ERRORSomething went wrong. Please try again later.
any other 4xxERRORAn error occurred.

Which message you actually see

This is the rule that surprises people. In production (APP_DEBUG off) most of the specific messages a controller throws are discarded and replaced by the status default above. Do not string-match on them.

StatusWhat error.message contains in production
400, 402, 409, 422The throw site’s own message, stripped of ids, emails, URLs and payment-processor tokens — provided it passes a guest-safety screen. A message longer than 300 characters, starting with {, [ or <, or containing any of SQLSTATE, Stack trace, vendor/, .php, Exception:, DOCTYPE, token, secret, credential, whitelist, api key, client_id, authorization server, redis, connection refused, undefined array key, undefined variable, undefined index, call to a member, must be of type, curl error, could not resolve, ssl certificate, {", </, platform= is dropped and replaced by the status default. This is why Platform credentials are missing reaches you as The request was invalid.
401, 403, 404, 405, 429, 5xxAlways the status default. The specific text (Feed not found, This feed has been archived., Quote not found.) exists only when debug mode is on, which production never is.

Field-validation failures are the exception to the exception: a controller that runs a validator builds the envelope itself, so a 422 always arrives verbatim as error.message Validation failed with error.details set to { "<field>": ["<message>", …] }. That map is the only part of any error response you can usefully show a customer.

Endpoint sections below therefore quote a message verbatim only where production really emits it — everywhere else they name the status and error.code.

Nine codes are emitted outside the status table by middleware and controllers that build their response directly, and are therefore always verbatim:

error.codeStatusEmitted by
FEED_DISABLED, FEED_ARCHIVED, ACCOUNT_SUSPENDED403the shared availability gate
MISSING_PARAMETER400GET /widgets with no ids
WIDGET_NOT_FOUND404GET /widget/{id}
NOT_FOUND / The requested plugin was not found.404GET /plugins/{slug}
QUOTE_EXPIRED, QUOTE_EXPIRED_UNAVAILABLE, QUOTE_EXPIRED_PRICE_CHANGED409POST /{quote_id}/reservation

Match on status_code first and treat error.code as a refinement — these responses also serialise details as [], not {}, because they bypass ErrorEnvelope.

Business rejections forwarded from a PMS do survive: a Guesty refusal such as Listing is not available. minimumStay does not meet booking criteria. arrives at 400 with its text intact, and is safe to render.

Pagination

Three different conventions are live. Using the wrong page-size parameter is the single most common plugin bug — it fails silently and you get the default page size.

Endpoint familyPage-size paramDefaultBoundspagination key style
/{feed_id}/feed/properties, /{platform_uuid}/propertieslimit10none enforcedcamelCase
/feed/related-propertieslimit3none enforcedcamelCase
/property-imageslimit501–100, 422 outsideboth styles in one object
/feed/property-nameslimit100clamped 1–500camelCase
/feed/availabilitylimit20clamped 1–50camelCase
/reviewsper_page101–20, 422 outsidesnake_case

Every paginated endpoint takes page (1-based, default 1).

  • camelCase block: { page, pageSize, totalPages, totalItems, hasNextPage, hasPrevPage }.
  • snake_case block: { page, page_size, total_pages, total_items, has_next_page, has_prev_page }.
  • /property-images emits both key sets in the same object.
  • /reviews additionally carries a deprecated pagination.average_rating; read meta.average_rating instead.

per_page is advisory (unvalidated) on the property endpoints — it is accepted, never read, and you silently receive 10 rows. Three of the four published reference plugins (featured-stays, stats-spotlight, amenity-match) ship this bug against /feed/properties today. Use limit there and per_page only on /reviews.

/{feed_id}/feed/collection and /{feed_id}/feed/property-pins are not paginated at all and ignore limit/page entirely.

Identifying a property

A {property_identifier} path segment and the ?property= query parameter are feed slugs, resolved through the feed’s own property table against property_slug or additional_property_slug. A property UUID does not resolve there.

FormResolves byRequires
{property_identifier} in the path, ?property= in the queryfeed slug (or additional slug)a feed_id, always — without it you get 400 Feed ID is required to resolve property.
{property_uuid} in the path (/{property_uuid}/calendar, /property/{property_uuid}/quotes)properties.uuid, resolved by the auth middlewarenothing else
id in a list-endpoint rowuuid ?? id — the public identity of a property is its UUID

A slug resolves only when the property is still active and still a member of that feed; otherwise 404 NOT_FOUND. A feed-scoped payload also overlays the feed’s own overrides (title, guest cap, rating, meta fields, slug) on top of the base row.

Endpoint index

All paths are relative to {base}/public-api/v1. feed = auth.public.feed.v1, v1 = auth.public.v1.

MethodPathAuthPurpose
GET/{feed_id}/detailsv1Feed row
GET/{feed_id}/feed/propertiesv1Property cards, filtered + paginated
GET/{feed_id}/feed/collectionv1Whole catalogue, flat scalar rows, unpaginated
GET/{feed_id}/feed/availabilityv1Multi-property calendar grid
GET/{feed_id}/feed/related-propertiesv1Same-city cards for one source slug
GET/{feed_id}/feed/explorer-metav1Filter vocabulary across the feed
GET/{feed_id}/feed/property-namesv1name + slug suggestions
GET/{feed_id}/feed/property-pinsv1Whole filtered set as map pins
GET/{feed_id}/feed/widgetsv1Widgets configured on the feed
GET/{feed_id}/feed/pluginsv1Plugins installed and enabled on the feed
GET/{platform_uuid}/propertiesv1Property cards for one platform
GET/{platform_uuid}/explorer-metav1Filter vocabulary for one platform
GET/property/{property_identifier}feedOne property, columns only
GET/property/{property_identifier}/fullfeedProperty + images + descriptions + amenities
GET/property/{property_identifier}/payment-settingsfeedPublic payment config
POST/property/{property_identifier}/stripe/setup-intentsfeedStripe SetupIntent
POST/property/{property_identifier}/stripe/verify-cvcfeedServer-side CVC check
GET/property-detailsfeedOne property, columns only, no path segment
GET/property-descriptionsfeedLong-form descriptions
GET/property-amenitiesfeedAmenity list
GET/property-spacesfeedRooms and bed breakdown
GET/property-imagesfeedGallery, paginated
GET/property-calendarfeedAvailability + pricing by date
GET/{property_uuid}/calendarv1Same, addressed by UUID
GET/property/{property_uuid}/calendarv1Same, addressed by UUID
GET/reviewsfeedReviews with filters
POST/quotesfeedCreate a quote
POST/{property_uuid}/quotesv1Create a quote, addressed by UUID
POST/property/{property_uuid}/quotesv1Create a quote, addressed by UUID
GET/quotes/{quote_id}feedRead a quote
POST/quote/updatefeedMutate a quote
GET/{quote_id}/checkout-urlfeedPMS-hosted checkout URL
POST/{quote_id}/reservationfeedConvert a quote to a reservation
GET/reservations/{reservation_id}feedConfirmation lookup (hash-gated)
POST/reservations/{reservation_id}/payment-methodsfeedAttach or retry payment (hash-gated)
GET/widget/{widget_id}v1One widget row
GET/widgets?ids=noneBulk widget rows
GET/plugins/{slug}nonePlugin manifest metadata

An unmatched path returns 404 NOT_FOUND; a wrong verb returns 405 METHOD_NOT_ALLOWED. There is no fallback route.

Feed

GET /{feed_id}/details

Auth auth.public.v1. No parameters.

Returns the feed row as stored, minus its encrypted credentials, plus a computed development_mode_active boolean: { id, user_id, name, feed_url, additional_info, archived_at, development_mode, development_mode_expires_at, edge_disabled_at, development_mode_active, created_at, updated_at } under result.

additional_info.branding is the feed’s theme (colorScheme, lightModeColors.accent, darkModeColors.accent, font.size, font.family) — the values that back the base config fields described in Config schema and UI schema.

The endpoint does not eager-load relations. platforms[], properties_count and slug are absent from the response even though the SDK’s exported Feed type declares the first two. Treat them as optional and never index into them without a guard.

fetchFeed(feedId) from @homerunner-next/widget-core/runtime wraps this endpoint, throwing WidgetFetchError unless res.ok and body.status_code === 200.

Properties

GET /{feed_id}/feed/properties

Auth auth.public.v1. The primary listing endpoint: filtered, sorted, paginated property cards. GET /{platform_uuid}/properties is the same controller scoped to one platform.

Only feed members with status = 1 are returned; being on one of the feed’s platforms is not enough.

Paging and scope

ParamTypeRequiredRuleOn violation
limitintnoPage size. Default 10. No maximum is enforced.advisory (unvalidated) — a non-numeric value is not rejected here
pageintno1-based. Default 1.out-of-range page returns an empty result
platformUUIDnoNarrows to one platform inside the feed’s scope.unknown UUID is ignored, not an error
platform_idintnoSame, by internal id.advisory (unvalidated)
localestringnoCanonical locale (fr_fr). Localises amenity and tag name, and adds a labels map.unknown locale falls back to the PMS name

Filters — all optional, all applied with AND between them. A blank or 0 value reads as absent everywhere (price_min=0, bedrooms=0 and guests=0 do nothing).

ParamTypeRuleOn violation
typescsv intProperty type ids.advisory (unvalidated); unknown ids simply match nothing
amenitiescsv intMatch any of these amenity ids.advisory (unvalidated)
amenities_andcsv intMatch all of these amenity ids.advisory (unvalidated)
tags, locations, groups, citiescsv intTaxonomy term ids.advisory (unvalidated)
state, countrycsv stringFree-text columns, exact match on any listed value.advisory (unvalidated)
featuredbooltrue only. Parsed with FILTER_VALIDATE_BOOLEAN, so "false" correctly disables it.advisory (unvalidated)
petsboolPet-friendly only. Same parsing.advisory (unvalidated)
guestsintmaximum_guest_count >= n.advisory (unvalidated)
bedroomsintbedroom_count >= n.advisory (unvalidated)
bedrooms_eqintExact bedroom count. Overrides bedrooms when both are sent.advisory (unvalidated)
bathroomsintbathroom_count >= n.advisory (unvalidated)
price_fieldbase | lowest | averageChooses which stored price column price_min/price_max compare against and which one sort_by price sorts read. Anything else falls back to base.silently falls back to base
price_min, price_maxfloatInclusive bounds on the price_field column.advisory (unvalidated)
statusactive | inactive | boolProperty status.advisory (unvalidated)
keywordsstringLIKE %value% on properties.name only — not on title.advisory (unvalidated)
excludecsv intExclude these Central property ids.advisory (unvalidated)
ctidcsvRestrict to these ids — matched against either the integer id or the UUID.advisory (unvalidated)
exclude_propertycsv slugExclude these feed slugs. Needs a resolved feed.ignored without a feed id
include_propertycsv slugRestrict to these feed slugs. If none resolve, the result is empty — it does not fall back to the whole feed.ignored without a feed id
map_boundswest,south,east,northGeo box. Properties without both coordinates are excluded.ignored unless exactly 4 numeric parts

Dated availability — supply checkin and checkout to switch the endpoint into availability mode.

ParamTypeRuleOn violation
checkin, checkoutdateBoth required together. Parsed with strtotime.400 Invalid checkin or checkout date.
checkin must be strictly before checkout.400 Checkout date must be after checkin date.
adults, childrenintWhen either is present, guests is recomputed as adults + children, overwriting any guests you sent.advisory (unvalidated)
guestsintCapacity filter. In a dated search with no guest count anywhere, it defaults to 1 so the date filter still applies.advisory (unvalidated)

A dated response adds datesPrice: { total, average, lowest, nights } per card, computed over the requested window.

Sortingsort_by, one of:

featured · title_asc · title_desc · bedrooms_asc · bedrooms_desc · bathroom_asc · bathroom_desc · guests_asc · guests_desc · lowest_price_asc · lowest_price_desc · average_price_asc · average_price_desc · base_price_asc · base_price_desc · created_asc · created_desc · rating_asc · rating_desc · random

Note the asymmetry: bedrooms_* is plural, bathroom_* is singular. sort_by is advisory (unvalidated) — an unrecognised value produces no error and no ordering clause. rating_* is feed-scoped and a no-op on the platform route. random accepts sort_seed (integer); without one the seed is the current date, so the order is stable for a day. A deterministic tiebreaker on property id is always appended last, so paging is repeatable.

Response (Shape A). Each row:

// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2053-2127
{
  "id": "uuid-or-id", "propertyType": "Villa", "title": "Big Fish Lodge",
  "price": 420,                       // integer, base_daily_rate
  "lowestPrice": 380, "averagePrice": 450, "highestPrice": 610,
  "bookableNights": 120,              // null = no forward calendar at all
  "calendarSyncedAt": "2026-09-01T04:00:00+00:00", "calendarStale": false,
  "images": ["https://…"], "altText": "Big Fish Lodge", "tag": "Lakefront",
  "location": { "label": "Asheville", "lat": 35.5951, "lng": -82.5515 },
  "type": { "id": 7, "name": "Villa" }, "city": { "id": 12, "name": "Asheville" },
  "groups": [], "locations": [], "tags": [], "amenities": [],   // {id,name}[]
  "subtitle": { },
  "features": { "guests": 8, "baths": 3, "beds": 4 },
  "max_pets": 2, "updated_at": "2026-08-30T11:02:00.000000Z",
  "slug": "big-fish-lodge",           // present only on feed-scoped calls
  "rating": { },                      // present only on feed-scoped calls
  "datesPrice": { }                   // present only on a dated search
}

slug is the join key for every per-property endpoint. id is uuid ?? id and is what map pins and card dedupe compare against.

GET /{feed_id}/feed/related-properties

Auth auth.public.v1. Cards in the same city as a source property, source excluded.

ParamTypeRequiredRuleOn violation
slugstringyesThe source property’s feed slug. Named slug, not property?property= is consumed by the auth middleware as a UUID and 404s before the controller runs.400 Property slug is required.
limitintnoDefault 3.as /feed/properties
platformUUIDnoNarrows within the feed.ignored if unknown
all /feed/properties filtersnoPassed through. City and source-exclusion are applied first and cannot be overridden.as above

Default order is is_featured desc, created_at desc; sort_by overrides it. A source property with no city returns 200 with an empty result and a zeroed pagination block — not an error. Response shape is identical to /feed/properties.

GET /{feed_id}/feed/collection

Auth auth.public.v1. The feed’s entire active catalogue as flat, scalar-only rows, in one unpaginated response. Filters, sorting, limit and page are all ignored.

Every key is present on every row (null-padded): id, title, slug, property_type, alt_text, tag, price, price_lowest, price_average, price_highest, currency_code, lat, lng, location_label, city, state, country, post_title, meta_title, meta_description, guests, baths, beds, rating_average, rating_count, amenities, tags, locations, image_count, post_excerpt_length, post_excerpt_1…5, image_1…10. Taxonomies are comma-joined name strings; the description is split into five ≤ 2000-character parts with the true length in post_excerpt_length.

This endpoint loads the whole catalogue in one query. Do not call it from a widget render path.

GET /{feed_id}/feed/property-pins

Auth auth.public.v1. The full filtered set reduced to map markers — the answer to “/feed/properties only gives me pins for the current page”.

ParamTypeRequiredRuleOn violation
capintnoServer cap on rows. Default 2000, clamped to 50–2000.silently clamped
platform, platform_id, price_fieldnoAs /feed/properties.as above
/feed/properties filters and dated paramsnoShared code path, so the pin set can never disagree with the card set.as above

map_bounds, sort_by, limit and page are stripped before filtering — the endpoint is never viewport-bounded and never ordered.

Response: result: { pins: [{ id, slug, title, lat, lng, price?, currency? }], total, capped }. total is how many properties matched, not how many were returned; capped is true when the cap truncated the set. price/currency appear only when the stored price is greater than zero. Undated requests are cached server-side for a short window (see Limits and error index); dated ones never are.

GET /{feed_id}/feed/property-names

Auth auth.public.v1. Lightweight { name, slug } suggestions. Rows with no feed slug are skipped.

ParamTypeRequiredRuleOn violation
qstringnoCase-insensitive LIKE on name or title.
limitintnoDefault 100, clamped 1–500.silently clamped
pageintnoDefault 1.

GET /{feed_id}/feed/availability

Auth auth.public.v1. Multi-property calendar rows, one per property.

ParamTypeRequiredRuleOn violation
startdatenoDefaults to today.400 Invalid start or end date.
enddatenoDefaults to start + 30 days. Must be ≥ start.400 End date must be on or after start date.
Span capped at 92 days.400 Date range must not exceed 92 days.
platformUUIDnoMust belong to this feed.404 NOT_FOUND
guestsintnomaximum_guest_count >= n.advisory (unvalidated)
limitintnoDefault 20, clamped 1–50.silently clamped
pageintnoDefault 1.

Each row: { id, slug, title, thumbnail, currency_code, availability }, where availability is keyed by date with the same per-day object as the calendar endpoints (below) and is {} when the property has no rows in the window.

GET /property/{property_identifier} and GET /property-details

Auth auth.public.feed.v1. The same columns-only projection, reachable two ways.

ParamTypeRequiredRuleOn violation
feed_idintyesRequired by auth.public.feed.v1, and again by slug resolution. /property-details treats it as optional in the controller, but the middleware does not.401 UNAUTHENTICATED when absent; 400 Feed ID is required to resolve property. when only ?feed= was sent
propertystring/property-details: yesFeed slug.400 Property identifier is required.

Returns the property’s own columns with credential-bearing relations stripped, plus city: {id,name}, type: {id,name}, max_pets, and — on /property-details with a feed — a rating aggregate. Feed-level overrides (title, guest cap, rating, meta_title, meta_desc, explorer_subtitle, multi_calendar_title, slug) are overlaid where set.

fetchPropertyBySlug(feedId, slug) in @homerunner-next/widget-core/runtime wraps /property-details and returns body.result ?? {}.

GET /property/{property_identifier}/full

Auth auth.public.feed.v1. The one endpoint that returns a property with its amenities, images and descriptions pre-joined.

ParamTypeRequiredRuleOn violation
feed_idintyes400 Feed ID is required.
includecsvnoOpt in to extra blocks: reviews, calendar. Nothing else is recognised.unknown values ignored
image_limitintnoDefault 50.advisory (unvalidated)
description_limitintnoDefault 100.advisory (unvalidated)
review_limitintnoDefault 10. Only with include=reviews.advisory (unvalidated)
from, todatenoCalendar window. Only with include=calendar. Defaults today → today + 1 year.advisory (unvalidated)

Result: { property, images[], descriptions[], amenities[], reviews?: { items[], average_rating }, calendar?: { "YYYY-MM-DD": {…} } }. Descriptions are ordered with the platform’s default locale first — read descriptions[0] only as a fallback.

GET /property-descriptions, /property-amenities, /property-spaces

Auth auth.public.feed.v1. All three require feed_id and property (a feed slug): 400 Feed ID is required. / 400 Property identifier is required.; 404 NOT_FOUND for an unknown feed or a property that is not in it; 403 FORBIDDEN for an archived feed.

EndpointExtra paramsresult
/property-descriptionslimit (default 100){ default_locale, descriptions[] } — default-locale row ordered first
/property-amenities{ amenities: [{ id, name, … }] }
/property-spaces{ spaces: [{ id, name, number, type, privacy, has_private_bathroom, beds, total_bed_count }] }

GET /property-images

Auth auth.public.feed.v1.

ParamTypeRequiredRuleOn violation
property or property_uuidstringyesFeed slug. The legacy property_uuid spelling is accepted and is also read as a slug.401 UNAUTHENTICATED
feed_idintyesRequired by the auth middleware and by slug resolution.401 UNAUTHENTICATED; 400 Feed ID is required to resolve property. when only ?feed= was sent
limitintnoDefault 50. Validated 1–100.422 VALIDATION_ERROR
has_caption0/1/true/falsenoOnly captioned / only uncaptioned.422 VALIDATION_ERROR
position_min, position_maxint ≥ 0noInclusive position window.422 VALIDATION_ERROR
pageintnoDefault 1.

result is [{ url, caption, position }] ordered by position. meta.property.name carries the property name. This is the only endpoint that emits both pagination key styles.

Calendars

GET /property-calendar (auth auth.public.feed.v1, params feed_id + property slug) and GET /{property_uuid}/calendar / GET /property/{property_uuid}/calendar (auth auth.public.v1, addressed by UUID) return the same object.

ParamTypeRequiredRuleOn violation
fromdatenoDefault today.an unparseable date is not validated and surfaces as 500 INTERNAL_SERVER_ERROR
todatenoDefault today + 1 year.as above
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2517-2542
{
  "status": "ok",
  "result": {
    "2026-01-15": {
      "date": "2026-01-15",
      "available": 1, "checkin": 1, "checkout": 0,   // 0 / 1, not booleans
      "min_stay": 2, "max_stay": 30,
      "price": 150.00
    }
  }
}

Dates with no calendar row are simply absent from the map — iterate your own date range and treat a missing key as unknown, not as available.

Explorer metadata

GET /{platform_uuid}/explorer-meta and GET /{feed_id}/feed/explorer-meta, auth auth.public.v1. The feed variant aggregates and de-duplicates across every platform on the feed and returns 404 NOT_FOUND when the feed has none.

ParamTypeRequiredRule
price_fieldbase | lowest | averagenoWhich column the returned priceRange bounds are computed from. Must match the price_field you send to /feed/properties or your slider and your filter disagree.
localestringnoLocalises amenity and tag labels and adds a labels map.
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:798-820
{
  "placeTypes": ["Villa"],                        // name strings
  "priceRange": { "min": 80, "max": 950 },
  "amenities":  ["Hot Tub"],                      // name strings
  "location": { "cities": [{ "id": 12, "name": "…", "title": "…",
                             "description": "", "image": "…" }],
                "states": ["NC"], "countries": ["USA"] },
  "taxonomies": {                                 // id-bearing — use THESE for filters
    "types": [{ "id": 7, "name": "Villa" }],
    "amenities": [], "locations": [], "groups": [], "tags": [], "cities": []
  }
}

Filter parameters take ids, so read taxonomies.*, not the flat name arrays. The location.cities[].image field is a placeholder URL, not real content.

Reviews

GET /reviews

Auth auth.public.feed.v1. Note the middleware: feed_id is what authorises the call.

ParamTypeRequiredRuleOn violation
feed_idintyes (auth)Authorises the request. Does not filter the results.401 UNAUTHENTICATED
property_idintnoA Central integer property id. Cast with (int) before use, so a UUID becomes 0 and the filter is dropped, and a CSV keeps only the first id.advisory (unvalidated)
propertystringnoFeed slug; resolved to a property id. The reliable way to scope to one property.404 NOT_FOUND
platform_idintnoAll properties on that platform. Note: platform (a UUID) is not read here.advisory (unvalidated)
per_pageintnoDefault 10. Validated 1–20.422 VALIDATION_ERROR, error.message Validation failed
pageintnoDefault 1.
ratingcsv intnoExact ratings, e.g. 4,5. Validated only as a string.422 VALIDATION_ERROR if not a string
min_rating, max_ratingintnoValidated 1–5. 0 is rejected — omit the parameter to mean “no filter”.422 VALIDATION_ERROR
source, channelcsv stringnoMax 100 chars. Exact match on any listed value.422 VALIDATION_ERROR
authorstringnoMax 255. Substring; % and _ are escaped.422 VALIDATION_ERROR
date_from, date_todatenodate_to must be ≥ date_from.422 VALIDATION_ERROR
has_responseboolnotrue/false/1/0/on/off. An unrecognised value is dropped before validation.silently ignored
searchstringnoMax 255. Substring on title or content.422 VALIDATION_ERROR
order_byenumnoDefault date_desc. rating_, date_, created_, author_, source_, channel_, property_name_ each _asc/_desc.422 VALIDATION_ERROR

meta.average_rating is computed from the property_id / platform_id scope before the rating, source, date and search filters are applied — it is a stable badge value, not the mean of the rows you received.

Not supported yet. /reviews cannot be scoped to a feed. The feed filter exists in the controller but is commented out, so feed_id authorises the request and contributes no WHERE clause. A call carrying only feed_id returns an unscoped page of reviews, not the feed’s. Always send property, property_id or platform_id — otherwise your widget renders rows that do not belong to the page it is on.

Row shape: { id, property_id, property_uuid, property_name, title, content, response, rating, date, author, source, channel, guests_count, created_at, updated_at }.

Quotes and reservations

These are write endpoints against a live PMS. Rate-limit budget and PMS latency both apply; never call them during SSR.

POST /quotes · POST /{property_uuid}/quotes · POST /property/{property_uuid}/quotes

/quotes uses auth.public.feed.v1 with feed_id in the body; the UUID forms use auth.public.v1.

FieldTypeRequiredRuleOn violation
feed_idintyesBody or query. Required by auth.public.feed.v1 and again by the controller.401 UNAUTHENTICATED when absent; 400 Feed ID is required when only ?feed= was sent; 404 NOT_FOUND for an unknown feed
propertystringyesFeed slug.422 VALIDATION_ERROR
checkindateyesMust be after today.422 VALIDATION_ERROR
checkoutdateyesMust be after checkin.422 VALIDATION_ERROR
adultsintyes, unless guests is sent1–50.422 VALIDATION_ERROR when neither is present
guestsintno1–50. Copied into adults when adults is absent.422 VALIDATION_ERROR
childrenintno0–50.422 VALIDATION_ERROR
infantsintno0–20.422 VALIDATION_ERROR
petsintno0–10.422 VALIDATION_ERROR
additional_guestsintno0–50. Stored on the quote’s additional_info.422 VALIDATION_ERROR
couponstringno≤ 100 chars.422 VALIDATION_ERROR
ip_addressstringnoMust be an IP. Auto-filled from the request when absent.422 VALIDATION_ERROR
referrerstringno≤ 500 chars.422 VALIDATION_ERROR

Success is 201 with Shape A (result): { id, property_id, status, checkin, checkout, guests, adults, children, infants, pets, additional_info, nights, ip_address, referrer, rateplans, coupons, addons, source, expires_at, created_at, updated_at, property: { title, slug } }.

expires_at is real — a quote expires and reservation creation rejects an expired one. Show a countdown or re-quote before submitting.

Money contract on rateplans[]: discount, stayDiscount, couponDiscount and promotionalDiscount are always >= 0 and always reductions; a length-of-stay price increase is reported separately as surcharge (>= 0), never as a negative discount. totalRent is pre-discount, total is post-discount. Invoice line items keep signed amounts for display. This is uniform across PMS integrations — never Math.abs() or sign-guess these fields.

POST /quote/update

Auth auth.public.feed.v1. Only the fields you send change; everything else is carried over from the stored quote.

FieldTypeRequiredRuleOn violation
quote_idstringyes (route or body)404 NOT_FOUND
checkindatenoMust be after today.422 VALIDATION_ERROR
checkoutdatenoMust be after checkin when both are sent.400 Checkout date must be after checkin date
adultsintno1–50.422 VALIDATION_ERROR
childrenintno0–50.422 VALIDATION_ERROR
infantsintno0–20.422 VALIDATION_ERROR
petsintno0–10.422 VALIDATION_ERROR
additional_guestsintno0–50.422 VALIDATION_ERROR
couponstringno≤ 100. Omitting it keeps the existing coupon.422 VALIDATION_ERROR

PMS-level refusals: 400 Method not supported; 400 The request was invalid. when the platform’s credentials are missing (the real message names credentials and is screened out); 503 INTERNAL_SERVER_ERROR when the integration is in maintenance.

GET /quotes/{quote_id}

Auth auth.public.feed.v1. Same result shape as the create call. Pass feed_id to get property.slug resolved; without it the slug is null. 404 NOT_FOUND for an unknown id.

GET /{quote_id}/checkout-url

Auth auth.public.feed.v1. Shape B: data: { url }. Refusals: 404 NOT_FOUND for an unknown quote or a quote whose property is gone; 400 Platform not connected to an integration.; 400 External checkout not supported by this integration.; 400 The request was invalid. when platform credentials are missing (screened out by name).

Check payment-settings.reservation_target first: homerunner means the local checkout flow is supported, anything else means the PMS hosts it and this endpoint is the right call.

POST /{quote_id}/reservation

Auth auth.public.feed.v1. Converts a quote into a reservation. Only properties whose payment-settings.reservation_target is homerunner can be booked here — anything else is refused at 400.

FieldTypeRequiredRuleOn violation
first_name, last_namestringyesGuest identity.400, all validator messages joined into one string
emailstringyesMust be a valid address.400
phonestringyes400
languagestringnoxx or xx_YYYY / xx-YYYY.400
rateplanstringnoThe id of one of the quote’s rateplans[]. An unmatched id silently falls back to rateplans[0].advisory (unvalidated)
reservation_methodinquiry | request | instant | homerunnernoDefaults to the property’s own method.400 Reservation method {value} not supported.
payment_settings.payment_statusstringnopaid or authorized marks the booking pre-paid. The legacy payment_paid boolean is still honoured and takes precedence.advisory (unvalidated)

Success is 201:

// homerunner-central/app/Services/ReservationService.php:978-985
{
  "success": true,
  "status_code": 201,
  "data": {
    "reservation": { /* the reservation row, property and platform stripped */ },
    "confirmation_hash": "$2y$..."
  }
}

Store data.confirmation_hash. It is the ?hash= value the confirmation endpoints below require, and it is returned exactly once — the key is confirmation_hash, not hash.

This is the one endpoint that does not use the canonical error envelope. Its errors are built by the private-API helper, so 401 is spelled UNAUTHORIZED (not UNAUTHENTICATED) and 422 is UNPROCESSABLE_ENTITY (not VALIDATION_ERROR), and every message except the 429 one collapses to An error occurred. Please try again. in production. A stale quote that could not be transparently re-validated returns 409 with one of three machine-readable codes: QUOTE_EXPIRED (request a fresh quote and retry), QUOTE_EXPIRED_UNAVAILABLE (the dates are gone — do not retry) and QUOTE_EXPIRED_PRICE_CHANGED (re-quote and re-consent the guest before charging). Branch on error.code; the message will tell you nothing.

GET /reservations/{reservation_id}

Auth auth.public.feed.v1 plus an in-controller hash check.

ParamTypeRequiredRuleOn violation
hashstringyesQuery string. Verified against the reservation id.400 Confirmation hash is required.; 403 FORBIDDEN on a mismatch

Shape B. data carries { id, type, status, checkin, checkout, guests, adults, children, infants, pets, nights, currency, amount, first_name, property_id, rateplan, payment_settings, property: { id, title, image_url } }.

Guest contact fields (last_name, email, phone) and the payment-provider block (payment_processor, stripe_publishable_key, has_secure_stripe, payment_provider_id) appear only while the reservation can still be paid — status not closed, and payment_status empty, pending or failed. On a settled reservation they are absent, not null. rateplan is whitelisted to id, title, currency, total, subTotal, totalRent, totalFees, totalTaxes, discount, surcharge, securityDeposit, cleaningFee, invoice_items, invoiceItems.

POST /reservations/{reservation_id}/payment-methods

Auth auth.public.feed.v1 plus the same hash gate. Attaches or retries a payment method. Refused with 409 This reservation is not awaiting payment. when payment_status is anything other than empty, pending or failed. Returns the refreshed reservation in Shape B.

GET /property/{property_identifier}/payment-settings

Auth auth.public.feed.v1. Requires feed_id. Shape B. Public payment configuration only — no secret keys are ever returned.

data: { payment_processor, reservation_method, inquiry_requires_payment, stripe_publishable_key, has_secure_stripe, payment_provider_id, payment_target, reservation_target, currency_code, cancellation_policy, rental_condition, payment_terms[], house_rules[] }.

payment_processor is one of stripe, amaryllis, card, none. payment_terms[] entries are { event, amount_type, amount, time_relation, time_amount, time_unit }.

Refusals: 400 Feed ID is required.; 404 NOT_FOUND for an unknown feed or a property that is not in it; 403 FORBIDDEN for an archived feed.

POST /property/{property_identifier}/stripe/setup-intents and /stripe/verify-cvc

Auth auth.public.feed.v1, both require feed_id. setup-intents takes no body and returns the SetupIntent in data. verify-cvc requires payment_method_id in the body (400 Payment method id is required.) and returns data: { cvc_check }.

Widgets and plugins

GET /{feed_id}/feed/widgets

Auth auth.public.v1. Active widget rows for the feed, built-in and plugin:* alike.

ParamTypeRequiredRuleOn violation
expandboolnofalse (default) returns { id, keyword, display_name } only. true returns the full row and, for plugin:* rows, inlines plugin_manifest and plugin_manifest_url.any non-boolean reads as false

A manifest is inlined only when the plugin is approved and enabled on that feed. The inlined manifest already has the feed’s per-widget toggles applied, so a widget switched off for the feed is absent from widgets[]. This is the SSR kill switch — see Install, customers and kill switches.

GET /widget/{widget_id}

Auth auth.public.v1. Shape B: data: { id, status, keyword, user_id, feed_id, display_name, settings, created_at, updated_at }. 404 WIDGET_NOT_FOUND / Widget not found.

settings is the raw stored object. It is not validated, not defaulted and not the same as what your component receives — see Config schema and UI schema for parseWidgetConfig.

fetchWidget(widgetId) from @homerunner-next/widget-core/runtime wraps this endpoint and throws WidgetFetchError unless res.ok and body.success.

GET /widgets?ids=

No auth middleware. The feed-availability gate runs in the controller instead, and refuses the whole batch — not a filtered subset — if any widget belongs to a blocked feed.

ParamTypeRequiredRuleOn violation
idscsv UUIDyesComma-separated widget ids.400 MISSING_PARAMETER / The ids parameter is required

Shape B, data is an array. Unknown ids are simply absent — the response is not padded and carries no error, so compare lengths yourself.

GET /plugins/{slug}

No auth middleware. Approved plugins only.

Shape A: result: { plugin: { slug, name, status, featured, manifest_url, manifest_cache } }. manifest_cache is the published manifest; manifest_url is the absolute URL it was fetched from and is the base for resolving path-only ssr.url and assets.* entries — see Keywords, assets and URLs.

404 with error.code NOT_FOUND and error.message The requested plugin was not found. for an unknown slug and for a pending or suspended one — suspension takes a plugin’s assets offline through this endpoint.

GET /{feed_id}/feed/plugins

Auth auth.public.v1. Plugins installed and enabled on the feed, approved only, ordered by sort_order.

Shape A: result: { plugins: [{ plugin_id, slug, name, featured, enabled, config, sort_order, manifest_url, manifest }] }. manifest has both the platform-wide kill switch and the feed’s own widget toggles already applied.

Calling from a plugin

Client-side

Ordinary browser fetch under ordinary CORS. No host restrictions apply.

// Corrected from hr-plugins/stats-spotlight/src/data.ts:27-37 — the shipped
// version sends `per_page`, which /feed/properties ignores. `PropertiesResponse`
// is declared alongside it in the same file.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";

export async function fetchProperties(
  feedId: number,
  opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
  const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
  url.searchParams.set("limit", String(opts.limit ?? 12));
  if (opts.platform) url.searchParams.set("platform", opts.platform);

  const res = await fetch(url.toString());
  // Read the status BEFORE parsing: a 429 may not be JSON at all.
  if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
  return res.json();
}

Throw WidgetFetchError rather than a bare Error, so isRateLimitError, isPermanentError and retryTransient classify your failures the same way they classify the SDK’s own. Every helper named on this page — homerunnerApiBaseUrl, fetchFeed, fetchWidget, fetchPropertyBySlug, WidgetFetchError, isRateLimitError, isPermanentError, retryTransient, getProxiedImageUrl — exists in every publishable widget-core (0.10.0+). Full signatures are in SDK reference; retry and query-key discipline are in Fetching data.

Server-side

Inside getInitialData / dehydrateState the renderer hands your module a wrapped fetch, not the platform one. Central’s host is always allowed. Any other host must be declared in externalFetch — on the widget’s summary entry for a suite, on the root manifest for a single-widget plugin — or the call throws before the socket opens. Loopback, private, link-local and cloud-metadata hosts are refused even when declared. Redirects are followed manually and every hop is re-validated. The exact block messages, the host versus host:port form and the redirect cap live in Component and SSR module.

Server-side GETs are additionally served from a short in-process cache (duration in Limits and error index), so data you fetch during SSR can be slightly stale. That is deliberate: composed pages are cached for hours anyway, and one uncached property page fans out to many public-api calls against a per-IP budget.

Images

getProxiedImageUrl(url, width?, height?) from @homerunner-next/widget-core/utils rewrites any image URL from the API through HomeRunner’s resizing proxy (https://hrimgs.co/w:{w}/h:{h}/rt:fill/plain/{url}), defaulting to 64 × 64. Scheme-less and protocol-relative inputs get https://; a URL with no image extension gets -homerunner.jpg appended so the proxy treats it as JPEG. Empty input is returned unchanged.

Known gaps

Not supported yet. There is no write API beyond the quote and reservation flow. You cannot create, update or delete feeds, properties, widgets or plugin state from a widget.

Not supported yet. There are no webhooks, no subscriptions and no change feed. Every update is a poll, and every poll is charged against the shared per-IP rate limit.

Not supported yet. There is no cursor pagination, no fields/sparse-fieldset parameter and no batch endpoint that takes a list of slugs. To hydrate N properties you make N calls to /property/{slug}/full, or one wide /feed/properties call and accept its card projection.

Not supported yet. sort_by, price_field and every taxonomy filter are unvalidated. A typo produces a 200 with a silently different result set, so there is nothing to catch in an error handler. Assert on the shape of what you got back instead.