Two different audiences, two different field names

The Field Reference, JSON and XML pages describe what a provider sends us — a properties array keyed by a plain id. This page describes what this REST API returns to a consumer reading the catalogue back out. The two use different field names for the same identifiers (id in the feed → externalListingId here) — see Naming differences.

Base URL & authentication

Errors

Every error is JSON, with the same envelope:

{
  "error": {
    "code": "not_found",
    "message": "No property with externalListingId 'NOPE'",
    "correlationId": "req_uw5cyixy2yx1g1lztop9"
  }
}
HTTP statuserror.codeWhen
400invalid_cursorThe cursor parameter is not one this API issued, or is malformed.
400invalid_requestupdated_since is not a parseable ISO-8601 timestamp.
401unauthorizedX-API-Key is missing or does not match a configured key.
404not_foundNo active listing matches the given externalListingId.
429rate_limitedToo many requests for this key (see Rate limits). Carries a Retry-After header, in seconds.
500server_errorUnexpected failure. Quote correlationId when reporting it.

Rate limits

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining headers — these two are the number that actually governs you, currently 60 requests per minute per caller (IP-based), enforced ahead of this API by the application's shared HTTP layer.

A second, higher limit exists but is not the one you will hit

This API also carries its own per-key limiter (300/min by default, configurable per key in Settings → API), returning 429 rate_limited + Retry-After when exceeded — but it never sets the X-RateLimit-* headers, and the shared 60/min limit ahead of it is reached first regardless of what a key is configured for. If Phase 2 needs a real per-key budget above 60/min, the platform team needs to exempt /api/havho/v1/* from the shared limiter, not just raise the per-key setting. Flagged here rather than changed silently.

Pagination

List endpoints (properties, agents, agencies, property-deletions) share one cursor scheme:

Query paramNotes
limitPage size. Default 50, maximum 200.
cursorOpaque; copy pagination.nextCursor from the previous page. Omit for the first page. An unrecognised or stale cursor → 400 invalid_cursor.
"pagination": {
  "limit": 50,
  "count": 50,
  "hasMore": true,
  "nextCursor": "eyJhZnRlciI6NTd9"
}

count is how many records are in this page, not a total — for the total, use activePropertyCount from sync status. Keep paging while hasMore is true; a cursor is stable only within one sweep (results can shift between separate sweeps as the catalogue changes).

Incremental retrieval

Add ?updated_since= (ISO-8601 UTC) to any list endpoint to get only records changed at or after that time:

Endpoints

GET /properties

Active listings only. Paginated, supports updated_since.

{
  "data": [
    {
      "externalListingId": "CPT-S-1842",
      "externalAgentId": "AGT-1049",
      "externalAgencyId": "AGY-77",
      "providerId": "havho-sandbox",
      "sourceUpdatedAt": "2026-08-31T09:12:00Z",
      "status": "active",
      "type": "sale",
      "propertyType": "apartment",
      "title": "Sea-facing apartment in Green Point",
      "price": 4250000,
      "description": "Renovated two-bedroom apartment with open-plan living areas and sea views.",
      "address": "14 Ocean View Drive, Green Point, Cape Town, Western Cape",
      "suburb": "Green Point",
      "city": "Cape Town",
      "province": "Western Cape",
      "beds": 2,
      "baths": 2,
      "garage": 1,
      "parking": 1,
      "unitSize": 98,
      "photoUrls": [ "https://cdn.example.com/listing/1842/image1.jpg" ],
      "features": [ "Pool", "Security Estate" ],
      "agency": {
        "id": "AGY-77",
        "name": "Atlantic Edge Properties",
        "providerId": "havho-sandbox",
        "sourceUpdatedAt": "2026-08-31T09:12:00Z"
      },
      "agent": {
        "id": "AGT-1049",
        "providerId": "havho-sandbox",
        "fullName": "Naledi Mokoena",
        "email": "naledi.mokoena@example.co.za",
        "phone": "+27 82 555 0142",
        "sourceUpdatedAt": "2026-08-31T09:12:00Z",
        "location": "Cape Town, Atlantic Seaboard",
        "photoUrl": "https://cdn.example.com/agents/agt-1049.jpg",
        "ffcNumber": "FFC-2026-114829"
      }
    }
  ],
  "pagination": { "limit": 50, "count": 1, "hasMore": false, "nextCursor": null },
  "nextUpdatedSince": "2026-08-31T09:12:00Z",
  "updatedSince": null,
  "generatedAt": "2026-09-22T06:45:39Z"
}

Field meanings are in the Field Reference (same data, feed field names). A rental listing additionally carries availableNow and availableFrom. photoUrls is in gallery order.

A property's agent / agency can be absent

The feed contract requires every property to carry an agent and agency, but a small number of legacy or partially-imported records can exist without one (missing agentId/agencyId). When that happens the agent and/or agency key is simply omitted from the response, never sent as null. Consumers must treat both as optional rather than assume they are always present, and should not read their absence as a removal signal — only property-deletions and disappearance from this list mean that.

GET /properties/{externalListingId}

One active listing, identical shape to a properties list item. 404 not_found if the id does not match a currently active listing (including one that was just removed — check property-deletions).

GET /agents

The distinct agents referenced by active listings — not a separate table, derived from the properties themselves, so an agent who has no active listing does not appear here (they still appear nested in history only via the property responses that referenced them while active). Paginated, supports updated_since.

{
  "externalAgentId": "AGT-1049",
  "providerId": "havho-sandbox",
  "fullName": "Naledi Mokoena",
  "email": "naledi.mokoena@example.co.za",
  "phone": "+27 82 555 0142",
  "externalAgencyId": "AGY-77",
  "agencyName": "Atlantic Edge Properties",
  "sourceUpdatedAt": "2026-08-31T09:12:00Z",
  "location": "Cape Town, Atlantic Seaboard",
  "photoUrl": "https://cdn.example.com/agents/agt-1049.jpg",
  "ffcNumber": "FFC-2026-114829"
}

providerId is always the agent's agency's providerId — an agent has no namespace of its own. If the same agent appears on several properties, the most recently changed one wins.

GET /agencies

The distinct agencies referenced by active listings, same derivation as agents. Paginated, supports updated_since.

{
  "externalAgencyId": "AGY-77",
  "name": "Atlantic Edge Properties",
  "providerId": "havho-sandbox",
  "sourceUpdatedAt": "2026-08-31T09:12:00Z"
}

GET /property-deletions

Listings removed since updated_since (compared against deletedAt), or the full retained history when omitted. Paginated.

{
  "deletionId": "del_9f2c7a4e1b3d5f60",
  "providerId": "havho-sandbox",
  "externalListingId": "CPT-S-1502",
  "deletedAt": "2026-08-29T16:40:00Z",
  "reason": "sold"
}

A distinct deletion event is the combination of providerId + externalListingId + deletedAt; deletionId is a stable hash of those three, so a consumer can de-duplicate even across separate pulls. The same event is never returned twice by this endpoint (the importer collapses exact duplicates on the way in). A listing that is later re-listed and removed again produces a separate event with its own deletedAt — do not treat externalListingId alone as the removal's identity.

GET /sync/status

Health of the import pipeline behind this API. Not paginated.

{
  "sourceHealth": "healthy",
  "snapshot": "complete",
  "lastCompletedSnapshotAt": "2026-09-22T11:38:54Z",
  "activePropertyCount": 3458
}
FieldValuesMeaning
sourceHealthhealthy | degraded | unavailableunavailable: no active feed is configured. degraded: a feed is configured but has never completed a snapshot.
snapshotcomplete | building | failedbuilding while a fresh full pull is in progress; a pull in progress does not affect data already served by the other endpoints.
lastCompletedSnapshotAtISO-8601 or nullWhen any property was last written. Not a per-feed value if more than one feed is configured.
activePropertyCountintegerThe true total — use this, not a list endpoint's pagination.count, to know how many active listings exist.

This reports import health, not delivery health. A complete snapshot means the catalogue was read correctly from the source; it says nothing about whether Firebase or any other downstream consumer has since applied it. Track your own consumption checkpoint (e.g. the last nextUpdatedSince you stored) separately.

Naming differences from the feed contract

The feed you send us and the API you read back use different names for the same identifiers — this trips people up more than anything else here:

ConceptIn a submitted feedIn this REST API
Listing ididexternalListingId (top level); the nested objects still use id
Agent idagent.idexternalAgentId (on a property or in /agents); agent.id when nested in a property
Agency idagency.idexternalAgencyId (on a property or in /agencies); agency.id when nested in a property
Removal identityn/a — removal is automaticdeletionId on /property-deletions, derived from providerId + externalListingId + deletedAt

These REST names are a fixed contract kept stable for existing integrations; they will not be renamed to match the feed's id-based naming in this version.

providerId is one fixed value per feed — confirm it, don't infer it

providerId is set once, deliberately, on each feed's configuration — it is agreed between us and the consuming application, not derived from an internal id that can change. It appears the same on every property, agent, agency and deletion event that feed produces; a consumer should filter by that exact value and treat a mismatch as a signal to re-confirm it with us, not as something to infer or guess from sample data. On this instance the live feed's current providerId is havho-plusgroup.

Delivery guarantees worth relying on

GuaranteeHow it holds
Repeated delivery never duplicates a listingEvery property is upserted by its permanent id; re-importing the same data is a no-op (compared by content hash).
An older update never overwrites a newer stateAn incoming record whose sourceUpdatedAt is older than what is already stored is skipped entirely, not merged.
A failed or partial pull never causes removalA listing is only ever treated as removed after a fully, cleanly re-read snapshot of the whole feed; a network error, an unreadable page or hitting the page-count safety limit leaves every existing listing in place.
An explicit removal is never delivered twiceDistinct by providerId + externalListingId + deletedAt; see property-deletions.

Postman collection

A ready-to-run collection covering every endpoint above, cursor paging, an incremental sweep, the auth failure cases and the sandbox feed is available from the admin at Settings → API → Postman collection (requires an admin login).