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:
| # | Source | Where it is set |
|---|---|---|
| 1 | window.HRWidgetRuntime.apiBaseUrl | Baked into the env-matched runtime IIFE the host page loads. Authoritative in the browser on customer pages, the dashboard playground, staging and production. |
| 2 | process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL | Vite-injected from your .env.local during npm run dev; also whitelisted into the renderer’s SSR sandbox environment. |
| 3 | https://central.homerunner.io | Hard 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.
| Middleware | What it resolves | Failure |
|---|---|---|
auth.public.v1 | The first present of platform_uuid → property_uuid → widget_id → feed_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.v1 | feed_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:
| Condition | Status | error.code | error.message |
|---|---|---|---|
| Feed switched off at the edge | 403 | FEED_DISABLED | This feed is no longer being served. |
| Feed archived | 403 | FEED_ARCHIVED | This feed has been archived. |
| Owning account suspended | 403 | ACCOUNT_SUSPENDED | Your 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-LimitandX-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
429body is the canonical error envelope witherror.codeTOO_MANY_REQUESTS, but a gateway may answer before Central does — always readres.statusbefore 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 ownRetry-AfterandX-RateLimit-Resetheaders do not survive that rebuild. Separately, Central’s CORS configuration setsexposed_headersto an empty list, so browser JavaScript cannot readX-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 withContent-Typeonly — so it cannot read the success headers either. They survive only on non-GET requests and in?dev=1renders, 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:
| Status | error.code | Default error.message |
|---|---|---|
| 400 | BAD_REQUEST | The request was invalid. |
| 401 | UNAUTHENTICATED | Authentication is required to access this resource. |
| 402 | PAYMENT_FAILED | Payment could not be processed. |
| 403 | FORBIDDEN | You do not have permission to access this resource. |
| 404 | NOT_FOUND | The requested resource was not found. |
| 405 | METHOD_NOT_ALLOWED | The HTTP method used is not allowed for this endpoint. |
| 409 | CONFLICT | The request could not be completed due to a conflict. |
| 422 | VALIDATION_ERROR | The request could not be validated. |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded. Please try again later. |
| ≥ 500 | INTERNAL_SERVER_ERROR | Something went wrong. Please try again later. |
| any other 4xx | ERROR | An 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.
| Status | What error.message contains in production |
|---|---|
400, 402, 409, 422 | The 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, 5xx | Always 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.code | Status | Emitted by |
|---|---|---|
FEED_DISABLED, FEED_ARCHIVED, ACCOUNT_SUSPENDED | 403 | the shared availability gate |
MISSING_PARAMETER | 400 | GET /widgets with no ids |
WIDGET_NOT_FOUND | 404 | GET /widget/{id} |
NOT_FOUND / The requested plugin was not found. | 404 | GET /plugins/{slug} |
QUOTE_EXPIRED, QUOTE_EXPIRED_UNAVAILABLE, QUOTE_EXPIRED_PRICE_CHANGED | 409 | POST /{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 family | Page-size param | Default | Bounds | pagination key style |
|---|---|---|---|---|
/{feed_id}/feed/properties, /{platform_uuid}/properties | limit | 10 | none enforced | camelCase |
/feed/related-properties | limit | 3 | none enforced | camelCase |
/property-images | limit | 50 | 1–100, 422 outside | both styles in one object |
/feed/property-names | limit | 100 | clamped 1–500 | camelCase |
/feed/availability | limit | 20 | clamped 1–50 | camelCase |
/reviews | per_page | 10 | 1–20, 422 outside | snake_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-imagesemits both key sets in the same object./reviewsadditionally carries a deprecatedpagination.average_rating; readmeta.average_ratinginstead.
per_pageis 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/propertiestoday. Uselimitthere andper_pageonly 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.
| Form | Resolves by | Requires |
|---|---|---|
{property_identifier} in the path, ?property= in the query | feed 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 middleware | nothing else |
id in a list-endpoint row | uuid ?? 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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /{feed_id}/details | v1 | Feed row |
| GET | /{feed_id}/feed/properties | v1 | Property cards, filtered + paginated |
| GET | /{feed_id}/feed/collection | v1 | Whole catalogue, flat scalar rows, unpaginated |
| GET | /{feed_id}/feed/availability | v1 | Multi-property calendar grid |
| GET | /{feed_id}/feed/related-properties | v1 | Same-city cards for one source slug |
| GET | /{feed_id}/feed/explorer-meta | v1 | Filter vocabulary across the feed |
| GET | /{feed_id}/feed/property-names | v1 | name + slug suggestions |
| GET | /{feed_id}/feed/property-pins | v1 | Whole filtered set as map pins |
| GET | /{feed_id}/feed/widgets | v1 | Widgets configured on the feed |
| GET | /{feed_id}/feed/plugins | v1 | Plugins installed and enabled on the feed |
| GET | /{platform_uuid}/properties | v1 | Property cards for one platform |
| GET | /{platform_uuid}/explorer-meta | v1 | Filter vocabulary for one platform |
| GET | /property/{property_identifier} | feed | One property, columns only |
| GET | /property/{property_identifier}/full | feed | Property + images + descriptions + amenities |
| GET | /property/{property_identifier}/payment-settings | feed | Public payment config |
| POST | /property/{property_identifier}/stripe/setup-intents | feed | Stripe SetupIntent |
| POST | /property/{property_identifier}/stripe/verify-cvc | feed | Server-side CVC check |
| GET | /property-details | feed | One property, columns only, no path segment |
| GET | /property-descriptions | feed | Long-form descriptions |
| GET | /property-amenities | feed | Amenity list |
| GET | /property-spaces | feed | Rooms and bed breakdown |
| GET | /property-images | feed | Gallery, paginated |
| GET | /property-calendar | feed | Availability + pricing by date |
| GET | /{property_uuid}/calendar | v1 | Same, addressed by UUID |
| GET | /property/{property_uuid}/calendar | v1 | Same, addressed by UUID |
| GET | /reviews | feed | Reviews with filters |
| POST | /quotes | feed | Create a quote |
| POST | /{property_uuid}/quotes | v1 | Create a quote, addressed by UUID |
| POST | /property/{property_uuid}/quotes | v1 | Create a quote, addressed by UUID |
| GET | /quotes/{quote_id} | feed | Read a quote |
| POST | /quote/update | feed | Mutate a quote |
| GET | /{quote_id}/checkout-url | feed | PMS-hosted checkout URL |
| POST | /{quote_id}/reservation | feed | Convert a quote to a reservation |
| GET | /reservations/{reservation_id} | feed | Confirmation lookup (hash-gated) |
| POST | /reservations/{reservation_id}/payment-methods | feed | Attach or retry payment (hash-gated) |
| GET | /widget/{widget_id} | v1 | One widget row |
| GET | /widgets?ids= | none | Bulk widget rows |
| GET | /plugins/{slug} | none | Plugin 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_countandslugare absent from the response even though the SDK’s exportedFeedtype 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
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
limit | int | no | Page size. Default 10. No maximum is enforced. | advisory (unvalidated) — a non-numeric value is not rejected here |
page | int | no | 1-based. Default 1. | out-of-range page returns an empty result |
platform | UUID | no | Narrows to one platform inside the feed’s scope. | unknown UUID is ignored, not an error |
platform_id | int | no | Same, by internal id. | advisory (unvalidated) |
locale | string | no | Canonical 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).
| Param | Type | Rule | On violation |
|---|---|---|---|
types | csv int | Property type ids. | advisory (unvalidated); unknown ids simply match nothing |
amenities | csv int | Match any of these amenity ids. | advisory (unvalidated) |
amenities_and | csv int | Match all of these amenity ids. | advisory (unvalidated) |
tags, locations, groups, cities | csv int | Taxonomy term ids. | advisory (unvalidated) |
state, country | csv string | Free-text columns, exact match on any listed value. | advisory (unvalidated) |
featured | bool | true only. Parsed with FILTER_VALIDATE_BOOLEAN, so "false" correctly disables it. | advisory (unvalidated) |
pets | bool | Pet-friendly only. Same parsing. | advisory (unvalidated) |
guests | int | maximum_guest_count >= n. | advisory (unvalidated) |
bedrooms | int | bedroom_count >= n. | advisory (unvalidated) |
bedrooms_eq | int | Exact bedroom count. Overrides bedrooms when both are sent. | advisory (unvalidated) |
bathrooms | int | bathroom_count >= n. | advisory (unvalidated) |
price_field | base | lowest | average | Chooses 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_max | float | Inclusive bounds on the price_field column. | advisory (unvalidated) |
status | active | inactive | bool | Property status. | advisory (unvalidated) |
keywords | string | LIKE %value% on properties.name only — not on title. | advisory (unvalidated) |
exclude | csv int | Exclude these Central property ids. | advisory (unvalidated) |
ctid | csv | Restrict to these ids — matched against either the integer id or the UUID. | advisory (unvalidated) |
exclude_property | csv slug | Exclude these feed slugs. Needs a resolved feed. | ignored without a feed id |
include_property | csv slug | Restrict 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_bounds | west,south,east,north | Geo 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.
| Param | Type | Rule | On violation |
|---|---|---|---|
checkin, checkout | date | Both 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, children | int | When either is present, guests is recomputed as adults + children, overwriting any guests you sent. | advisory (unvalidated) |
guests | int | Capacity 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.
Sorting — sort_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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
slug | string | yes | The 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. |
limit | int | no | Default 3. | as /feed/properties |
platform | UUID | no | Narrows within the feed. | ignored if unknown |
all /feed/properties filters | no | Passed 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”.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
cap | int | no | Server cap on rows. Default 2000, clamped to 50–2000. | silently clamped |
platform, platform_id, price_field | no | As /feed/properties. | as above | |
/feed/properties filters and dated params | no | Shared 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
q | string | no | Case-insensitive LIKE on name or title. | — |
limit | int | no | Default 100, clamped 1–500. | silently clamped |
page | int | no | Default 1. | — |
GET /{feed_id}/feed/availability
Auth auth.public.v1. Multi-property calendar rows, one per property.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
start | date | no | Defaults to today. | 400 Invalid start or end date. |
end | date | no | Defaults 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. | |||
platform | UUID | no | Must belong to this feed. | 404 NOT_FOUND |
guests | int | no | maximum_guest_count >= n. | advisory (unvalidated) |
limit | int | no | Default 20, clamped 1–50. | silently clamped |
page | int | no | Default 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | Required 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 |
property | string | /property-details: yes | Feed 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | 400 Feed ID is required. | |
include | csv | no | Opt in to extra blocks: reviews, calendar. Nothing else is recognised. | unknown values ignored |
image_limit | int | no | Default 50. | advisory (unvalidated) |
description_limit | int | no | Default 100. | advisory (unvalidated) |
review_limit | int | no | Default 10. Only with include=reviews. | advisory (unvalidated) |
from, to | date | no | Calendar 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.
| Endpoint | Extra params | result |
|---|---|---|
/property-descriptions | limit (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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
property or property_uuid | string | yes | Feed slug. The legacy property_uuid spelling is accepted and is also read as a slug. | 401 UNAUTHENTICATED |
feed_id | int | yes | Required by the auth middleware and by slug resolution. | 401 UNAUTHENTICATED; 400 Feed ID is required to resolve property. when only ?feed= was sent |
limit | int | no | Default 50. Validated 1–100. | 422 VALIDATION_ERROR |
has_caption | 0/1/true/false | no | Only captioned / only uncaptioned. | 422 VALIDATION_ERROR |
position_min, position_max | int ≥ 0 | no | Inclusive position window. | 422 VALIDATION_ERROR |
page | int | no | Default 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
from | date | no | Default today. | an unparseable date is not validated and surfaces as 500 INTERNAL_SERVER_ERROR |
to | date | no | Default 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.
| Param | Type | Required | Rule |
|---|---|---|---|
price_field | base | lowest | average | no | Which 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. |
locale | string | no | Localises 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes (auth) | Authorises the request. Does not filter the results. | 401 UNAUTHENTICATED |
property_id | int | no | A 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) |
property | string | no | Feed slug; resolved to a property id. The reliable way to scope to one property. | 404 NOT_FOUND |
platform_id | int | no | All properties on that platform. Note: platform (a UUID) is not read here. | advisory (unvalidated) |
per_page | int | no | Default 10. Validated 1–20. | 422 VALIDATION_ERROR, error.message Validation failed |
page | int | no | Default 1. | — |
rating | csv int | no | Exact ratings, e.g. 4,5. Validated only as a string. | 422 VALIDATION_ERROR if not a string |
min_rating, max_rating | int | no | Validated 1–5. 0 is rejected — omit the parameter to mean “no filter”. | 422 VALIDATION_ERROR |
source, channel | csv string | no | Max 100 chars. Exact match on any listed value. | 422 VALIDATION_ERROR |
author | string | no | Max 255. Substring; % and _ are escaped. | 422 VALIDATION_ERROR |
date_from, date_to | date | no | date_to must be ≥ date_from. | 422 VALIDATION_ERROR |
has_response | bool | no | true/false/1/0/on/off. An unrecognised value is dropped before validation. | silently ignored |
search | string | no | Max 255. Substring on title or content. | 422 VALIDATION_ERROR |
order_by | enum | no | Default 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.
/reviewscannot be scoped to a feed. The feed filter exists in the controller but is commented out, sofeed_idauthorises the request and contributes noWHEREclause. A call carrying onlyfeed_idreturns an unscoped page of reviews, not the feed’s. Always sendproperty,property_idorplatform_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.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | Body 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 |
property | string | yes | Feed slug. | 422 VALIDATION_ERROR |
checkin | date | yes | Must be after today. | 422 VALIDATION_ERROR |
checkout | date | yes | Must be after checkin. | 422 VALIDATION_ERROR |
adults | int | yes, unless guests is sent | 1–50. | 422 VALIDATION_ERROR when neither is present |
guests | int | no | 1–50. Copied into adults when adults is absent. | 422 VALIDATION_ERROR |
children | int | no | 0–50. | 422 VALIDATION_ERROR |
infants | int | no | 0–20. | 422 VALIDATION_ERROR |
pets | int | no | 0–10. | 422 VALIDATION_ERROR |
additional_guests | int | no | 0–50. Stored on the quote’s additional_info. | 422 VALIDATION_ERROR |
coupon | string | no | ≤ 100 chars. | 422 VALIDATION_ERROR |
ip_address | string | no | Must be an IP. Auto-filled from the request when absent. | 422 VALIDATION_ERROR |
referrer | string | no | ≤ 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.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
quote_id | string | yes (route or body) | 404 NOT_FOUND | |
checkin | date | no | Must be after today. | 422 VALIDATION_ERROR |
checkout | date | no | Must be after checkin when both are sent. | 400 Checkout date must be after checkin date |
adults | int | no | 1–50. | 422 VALIDATION_ERROR |
children | int | no | 0–50. | 422 VALIDATION_ERROR |
infants | int | no | 0–20. | 422 VALIDATION_ERROR |
pets | int | no | 0–10. | 422 VALIDATION_ERROR |
additional_guests | int | no | 0–50. | 422 VALIDATION_ERROR |
coupon | string | no | ≤ 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.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
first_name, last_name | string | yes | Guest identity. | 400, all validator messages joined into one string |
email | string | yes | Must be a valid address. | 400 |
phone | string | yes | 400 | |
language | string | no | xx or xx_YYYY / xx-YYYY. | 400 |
rateplan | string | no | The id of one of the quote’s rateplans[]. An unmatched id silently falls back to rateplans[0]. | advisory (unvalidated) |
reservation_method | inquiry | request | instant | homerunner | no | Defaults to the property’s own method. | 400 Reservation method {value} not supported. |
payment_settings.payment_status | string | no | paid 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
401is spelledUNAUTHORIZED(notUNAUTHENTICATED) and422isUNPROCESSABLE_ENTITY(notVALIDATION_ERROR), and every message except the429one collapses toAn error occurred. Please try again.in production. A stale quote that could not be transparently re-validated returns409with one of three machine-readable codes:QUOTE_EXPIRED(request a fresh quote and retry),QUOTE_EXPIRED_UNAVAILABLE(the dates are gone — do not retry) andQUOTE_EXPIRED_PRICE_CHANGED(re-quote and re-consent the guest before charging). Branch onerror.code; the message will tell you nothing.
GET /reservations/{reservation_id}
Auth auth.public.feed.v1 plus an in-controller hash check.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
hash | string | yes | Query 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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
expand | bool | no | false (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.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
ids | csv UUID | yes | Comma-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/propertiescall and accept its card projection.
Not supported yet.
sort_by,price_fieldand every taxonomy filter are unvalidated. A typo produces a200with 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.