# Entrata API: ground-truth context for the Foothold build

**Probe dates: 2026-07-31, 2026-08-03, and 2026-08-05. Live, read-only, from the VPS.** Everything
in this doc that is labeled verified came off real responses from Liberty Asset Group's production
Entrata org on one of those dates. Nothing here is inferred from the vendor spec alone unless it
says so.

**The 2026-08-03 probe is why several claims in earlier revisions of this file are gone.** Liberty
granted a second round of permissions that day: the service count went 22 → **28**, the
`maintenance` and `communications` groups landed, and `getWorkOrders` is now verified returning
real production data. Writes did not change. If you find a doc anywhere in this repo still saying
"the `maintenance` group is absent" or "`getWorkOrders` is blocked," it is stale; fix it.

**The 2026-08-05 probe closed the last of that grant.** All five remaining new methods were read
serially from the VPS and all five returned HTTP 200 with real data: `getWorkOrderPickLists`,
`getMitsLeases`, `getCalendarAvailability`, `getMarketingPreferencePickList`,
`getMarketingPreferences`. **Nothing on the key is "permissioned but unprobed" any more.** Three
things it overturned, and each one contradicts a claim that survived into the 2026-08-03 revision:
**(1)** `getCalendarAvailability` holds real, maintained configuration, including self-guided tour
hours across all seven days, so the "the calendar is likely to disappoint" warning was wrong; its
window cap is **7 days, not the 60 the vendor spec advertises**. **(2)** Resident date of birth is
**not** on `getMitsLeases` in live data, it is on **`getLeads`**, populated, on a method that has
been on the key since 2026-07-31 (§4.20). **(3)** `getMarketingPreferences` works without the
absent `customers` group, because the customer IDs it needs come out of `getMitsLeases`. Writes did
not change on 2026-08-05 either, and every call in that probe was a read.

**A third pass on 2026-08-05 swept the full lead history, probed the last methods nobody had ever
called, and corrected one of the numbers above.** Earlier revisions said `getLeads` returned
`birthDate` "populated on 122 of 122 records." **That overstated coverage badly and it is retired
everywhere in this file.** Entrata omits null fields entirely, so counting non-empty values against
*occurrences of the key* could only ever return 100%. The honest denominator is distinct customers,
and against it the full historical sweep (2021-01-01 through 2026-08-05) returns **787 distinct
lead-customers of which 286 carry a `birthDate`, which is 36%, not 100%.** Measured against the
current rent roll, **DOB coverage of current residents is 65%** (§4.20). The same pass **proved the
lead-to-resident join works** on `customerId`, established that **`getLeads` accepts a 730-day
window** so the whole 5.5-year history sweeps in 3 calls, found a **Cloudflare edge block that 403s
on some clients' default User-Agent** (§7.13), and probed `getStatus`, `getAccessibleClients`, the
three `oauth` methods, `getLeaseDetails` r2, and the LEAD side of `getMarketingPreferences`. **Every
one of the 28 services on the key has now been called at least once.** All read-only, serial, from
the VPS. Writes did not change.

> **The Foothold system design is NOT finalized.** This document is *input* to that work, not a
> decision record. Where it says "implication," that is a constraint the design has to answer,
> not a decision already made. The binding decisions live in
> [`system-design.html`](../system-design.html) (§2 Constitution, §4 Entrata protocol). If this
> doc and the Constitution ever disagree, the Constitution wins and this doc is the thing that
> needs fixing.

---

## 0. What this is and how to use it

Five files in `reference/` cover Entrata. They do different jobs, so read the right one:

| File | What it is | Use it when |
|---|---|---|
| `entrata-touchpoints.md` | 🟢 **START HERE. The current-state API reference**, written 2026-08-05: transport, traps, the safety boundary, all 22 usable reads, window caps, measured limits, the derived answers. Zero history by design | You need the current answer and nothing else. **Where it and this file disagree, it wins and this file is what needs fixing** |
| `entrata-api-context.md` (this file) | Narrative + real data shapes + design implications, including the dated correction blocks that record how each fact was established | You are designing something and need to know *why*, or the touchpoints file does not cover your case |
| `entrata-method-index.md` | Per-method table: version, required params, read/WRITE, permissioned or not | You need to look up one method's signature or permission status |
| `entrata-openapi-2026-07-28.yaml` | The full vendor OpenAPI spec (2.8 MB, 18 groups, 128 methods, 302 schemas) | You need a field-level schema the others don't cover |
| `entrata-webhooks-2026-03-04.md` | Verbatim vendor webhook documentation, vendor `updated_at` 2026-03-04, captured from the dev portal 2026-07-31 | You are building or scoping the webhook receiver. Summary and consequences are in §6.1 here |

Do not duplicate the method index here. If you're asking "does `getX` need a property ID," that's
the index. If you're asking "what comes back and what can I build with it," that's this file.

**Reading order for a new agent:** the safety rule (§1), then access and identity (§2), then
jump to whatever section answers your question. §10 (design implications) is the point of the
document; if you only read one section, read that one.

**Do not call the API to "check something" unless the task explicitly authorizes it.** Every call
goes from the VPS (Constitution rule 1) and every call is against somebody else's live building.
The shapes below are real; use them.

---

## 1. The safety rule (read this before anything else)

**Constitution rule 2: read-only against ANY live production property. No write calls, ever,
without Brent's explicit written per-property sign-off naming the property and the methods.**

This is a property-*class* rule, not a one-property exception. Owyhee Park today. Aspen the day
it onboards. NPI owning the building does not change it.

Three things make this sharper than it looks:

1. **Owyhee Park is a real, occupied, Liberty-managed building.** Real residents, real prospects,
   real leasing staff. NPI asked Liberty for a test company and got a live building instead.
   There is no sandbox. A test guest card puts a leasing agent on the phone chasing a prospect
   who doesn't exist. A test work order sends a real tech to a real unit. Neither is undoable.
2. **`sendLeads` and `updateLeads` ARE permissioned on this key, and they are still the only two
   writes after the 2026-08-03 grant.** An email asking Liberty to leave the write services
   unchecked was drafted 2026-07-30, **never sent, and deleted on 2026-07-31**. It no longer exists
   and nothing is waiting on it. Liberty enabled two of the four requested writes on 2026-07-31 and
   changed nothing on the write side on 2026-08-03. Any doc claiming "belt-and-suspenders, the
   vendor left writes off" is wrong; fix it where you find it. **Equally, any doc claiming all four
   writes landed is also wrong.** `sendWorkOrders` and `updateWorkOrders` are **not** on the key
   **even though the `maintenance` group now is** (§2.1). That is a sharper fact than the old one:
   until 2026-08-03 their absence was a side effect of the whole group being absent, and it could
   have flipped the moment the group landed. The group landed and they did not. Liberty granted
   exactly the two maintenance *reads* and nothing else, so the absence of the maintenance writes
   is now a deliberate scoping decision on Liberty's side, not an accident of group scope. The
   cleanup ask would have been two checkboxes, not four. **It is never going to be made: Brent
   declined it on 2026-07-31** and is leaving both writes enabled. **Do not recreate that email.**
   See item 3.
3. **There is no vendor-side guard, there never will be, and that is a decision rather than an
   accident.** Brent considered asking Liberty to uncheck `sendLeads` and `updateLeads` and
   **declined it on 2026-07-31**, on the reasoning that the permission being enabled is not what
   causes a write, our code calling a write method is. So **assume write capability is permanently
   present on this key** and treat it as a standing condition, not a gap someone is going to close
   later. **The code-side, deny-by-default, read-method allowlist, enforced at the single function
   that issues requests and failing safe, is the only control that exists and the only one that
   ever will exist.** Not per call site. One choke point. A missing or unparseable flag means
   read-only. This makes the allowlist **more** load-bearing, not less: nothing else stands between
   a bug and a real write against an occupied building. Rule 2 above is unchanged and undiluted.

**Verify a permission landed with `getAccessibleServices` or a `get*` call. Never by issuing a
real write to see if it works.**

Add a second allowlist next to the first one: **property IDs**. The key is org-scoped, not
property-scoped (see §4.8, `getWebsites` leaks 98 other Liberty properties). Only `1311115` is
authorized today. Anything else is refused at the same choke point.

---

## 2. Access and identity

| Fact | Value |
|---|---|
| Org / client | Liberty Asset Group |
| Subdomain (the URL path segment) | `libertyassetgroup` |
| Org ID | 19530 |
| Properties on the key | **exactly one** |
| Property | Owyhee Park Apartments, **PropertyID 1311115** |
| Address | 3044 S Owyhee St, Boise ID 83705 |
| Ownership | Liberty-managed. **Not NPI-owned.** |
| Buildings | 4 (named 3022, 3030, 3044, plus one more) |
| Units | 52 |
| Post months | AR / AP / GL all 07/2026 |
| Office hours | **M-F 9:00 AM to 5:00 PM. No weekend hours.** ⚠️ `getCalendarAvailability` reports agent-guided **tour** hours of **M-F 08:00-16:00 MST**, an hour earlier on both ends, plus **self-guided tour hours 06:00-18:00 all seven days** (§4.17). Two API-readable sources that disagree; unresolved, §9 item 17. |
| Lease terms | 24 active (1 through 24 months), all flagged `IsProspect` and `IsRenewal` |
| Services permissioned | **exactly 28** as of 2026-08-03 (22 on 2026-07-31, 7 plumbing-only on 2026-07-30). 16 verified working on 2026-07-31, `getWorkOrders` verified 2026-08-03, **the remaining 5 verified 2026-08-05. All six of the new reads are now probed; nothing on the key is unprobed.** As of the 2026-08-05 third pass **every one of the 28 has been called at least once**, including the three `oauth` methods (403 / 1403) and `getAppAdditionalServices` (400 / 107). Full roster in §2.1. |

**The subdomain is the CLIENT's, not NPI's.** NPI has no subdomain of its own and never will.
When a second client onboards, the path segment changes.

**Aspen and Summerset are NOT on this key.** Nothing can be built or validated against them
today. See §10.8.

### 2.1 The 28 accessible services, verbatim (captured 2026-08-03)

This roster was never captured before 2026-07-31, which is why earlier notes could not reconcile
"22 services" against "16 working." **It reconciles now.** Liberty granted a second round on
2026-08-03 and the count went to 28. `getAccessibleServices` returned exactly these (🆕 = added
2026-08-03):

| group | services |
|---|---|
| `status` | `getStatus` |
| `oauth` | `getAccessToken`, `getJwtAccessToken`, `getUserInfo` |
| `appinfo` | `getAccessibleClients` (on the `rsync` org path, not `libertyassetgroup`), `getAccessibleServices`, `getAppAdditionalServices` |
| `financial` | `getGlTransactions` |
| `leads` | `getLeadPickLists`, `getLeads`, **`sendLeads` (WRITE)**, **`updateLeads` (WRITE)** |
| `leases` | `getLeaseDetails`, `getLeases`, 🆕 `getMitsLeases` |
| `maintenance` | 🆕 `getWorkOrders`, 🆕 `getWorkOrderPickLists` |
| `communications` | 🆕 `getMarketingPreferences`, 🆕 `getMarketingPreferencePickList` |
| `properties` | `getFloorPlans`, `getProperties`, `getWebsites`, 🆕 `getCalendarAvailability` |
| `propertyunits` | `getAmenities`, `getPropertyUnits`, `getSpecials`, `getUnitsAvailabilityAndPricing`, `getUnitTypes` |

**The accounting:** 28 = the **22** of 2026-07-31 + **6** new reads. The 22 break down as **16**
verified-working reads and plumbing + **2** banned writes + **3** oauth methods (unused, this
integration is `X-Api-Key`) + `getAppAdditionalServices`, which is OAuth-only and returns `400/107`
on an apikey call. **The 6 new services are all reads, and they are exactly the six Brent asked
Alex Costa for on 2026-07-31** (§9). **All six are now probed and all six returned HTTP 200 with
real data:** `getWorkOrders` on 2026-08-03 (§4.14), and `getWorkOrderPickLists` (§4.15),
`getMitsLeases` (§4.16), `getCalendarAvailability` (§4.17), `getMarketingPreferencePickList`
(§4.18) and `getMarketingPreferences` (§4.19) on 2026-08-05. **Nothing on this roster is
"permissioned but unprobed" any more.** Two of the probes changed the design picture rather than
confirming it: `getCalendarAvailability` returned real, maintained tour configuration rather than
the empty calendar §9 item 14 predicted, and `getMitsLeases` came back **without** the resident
birth date the whole ask was made for (§4.20).

**The remaining plumbing was probed in the 2026-08-05 third pass, so the roster is now exhaustively
probed.** These are the rows nobody had bothered to call:

| Method | Result |
|---|---|
| `getStatus` | **HTTP 200**, `{"status":"Success","message":"API service is available and running."}`. Free liveness check, no permissions needed. Mind the 6/minute ceiling (§6) |
| `getAccessibleClients` (path segment `rsync`) | **HTTP 200**, `{"subdomain":"libertyassetgroup","id":19530,"name":"Liberty Asset Group"}` |
| `getAccessToken`, `getJwtAccessToken`, `getUserInfo` (`oauth`) | **HTTP 403 / error 1403, "You're not allowed to access this resource."** ⚠️ Note the code is **1403, not the 107** an OAuth-only method would suggest. On the roster, unusable on an apikey |
| `getAppAdditionalServices` | **HTTP 400 / error 107, "Auth type 'apikey' is not supported."** Unchanged from 2026-07-31 |

**So "on the roster" is not the same as "callable."** Four of the 28 return an error no matter what
you send them. **The usable surface is 24: 22 reads plus the 2 banned writes.** Do not read the
number 28 as 28 things you can do.

**This roster is the permission truth.** If a method is not on it, it is not permissioned, whether
or not anyone ever probed it. Three consequences worth stating out loud:

- **The `maintenance` group is on the key now, but only its two read methods.** This is the fact
  that changed on 2026-08-03, and it cuts both ways. `getWorkOrders` and `getWorkOrderPickLists`
  are granted; **`sendWorkOrders` and `updateWorkOrders` are not.** Before 2026-08-03 their absence
  was explained away by the whole group being absent. That explanation is gone: the group is
  present and Liberty granted only the reads out of it. **The maintenance writes are excluded on
  purpose, method by method.** Do not describe them as "not yet granted along with the group."
- **The `customers` group is still absent in its entirety.** Not "one method returns 1403." No
  customer service is on the key at all. `getCustomers` was probed and returned 1403, which is
  consistent with absence, not a separate fact.
- **`getLeadEvents` is not permissioned.** Only `getLeads` and `getLeadPickLists` are.
  **Mitigation: `getLeads` r1 itself accepts `eventDateFrom`, `eventDateTo`, and `eventTypeIds`
  (max 5) and returns the matching events inline**, so lead-event deltas are still pollable on a
  method we do have. Nuisance, not blocker.

Also absent in their entirety: `applications`, `arcodes`, `arpayments`, `artransactions`,
`leasingcenter`, `pricing`, `queue`, `vendors`. **`queue` matters more than it looks:** the
documented recovery for an async `queueId` response is `getResponse` in the `queue` group, and we
cannot call it. Keep requests small enough that they answer inline.

---

## 3. Request shape and error vocabulary

### The shape (confirmed working)

```
POST https://apis.entrata.com/ext/orgs/libertyassetgroup/v1/<group>
X-Api-Key: <key>
Content-Type: application/json

{"auth":{"type":"apikey"},"requestId":"1",
 "method":{"name":"<methodName>","version":"r1","params":{}}}
```

- **POST only.** GET returns 405 / code 1405.
- EU mirror is `apis.entrata.global`. Not used.
- **The group is the URL path segment. The method name goes in the body.** Getting this wrong is
  the single most common failure and it returns 400 / 1404, which reads like "that method doesn't
  exist" when the method is fine and the path is wrong. Method-to-group map is in
  `entrata-method-index.md` and §4 of `system-design.html`.
- `getAccessibleClients` is the exception: it uses the literal path segment `rsync` instead of
  the subdomain.
- `Authorization: Bearer` returns 401. It's `X-Api-Key` or nothing.
- Missing `Content-Type` returns 400 / 1415.
- ⚠️ **Set an explicit `User-Agent` header. Entrata's edge is Cloudflare and it bans some runtime
  defaults.** A Python `urllib` call on its default UA returned **403 with the bare body
  `error code: 1010`**, which is a Cloudflare browser-signature ban, not an Entrata response. The
  same request through `curl` succeeded. Do not ship on a runtime default UA (§7.13).
- **Serial calls only. Never concurrent.** One gate for the whole account.
- **All Entrata traffic originates from the VPS static IP `5.78.139.104`** (Constitution rule 1).
  Never from a laptop, never from a Worker.

### Error vocabulary

| HTTP | code | Means |
|---|---|---|
| 400 | 1415 | Missing `Content-Type` header |
| 400 | 1404 | Method not found. **Usually the wrong group in the path, not a bad method name.** |
| 400 | 1403 | Web service not permitted on this API user (the permission checkbox is off) |
| 400 | 107 | This method requires OAuth, `apikey` auth is not supported |
| 400 | 308 | **Date range too wide.** Observed on `getCalendarAvailability` r1: "The date difference should not be more than 7 days." Per-method window cap, not a global one (§4.17) |
| 400 | 310 | **Required parameter missing.** Observed on `getLeads` r1 called with no `propertyId` / `fromDate` / `toDate` (§4.10) |
| 401 | 405 | Client exists, key not permissioned for it |
| 403 | 115 | Key valid, that web service is not enabled |
| 403 | 1403 | **Not the same as 400 / 1403 above.** Returned by the three `oauth` methods on an apikey call: "You're not allowed to access this resource." On the roster, not callable (§2.1) |
| 403 | **1010, and the body is NOT JSON** | ⚠️ **Cloudflare edge block, not Entrata.** Bare body reads `error code: 1010`, a browser-signature ban on the client's User-Agent. **Not an auth failure and not a permission failure.** Set an explicit UA (§7.13) |
| 404 | 111 | No such client (bad subdomain) |
| 405 | 1405 | Wrong verb (everything is POST) |

Entrata publishes no public error-code table. This mapping is observed behavior, corroborated by
`getAccessibleServices`.

⚠️ **Parse the body before you classify the failure.** Every row above except the Cloudflare one
arrives as a JSON envelope with a `response.error`. **A 403 whose body does not parse as JSON never
came from Entrata at all**, it came from the edge, and treating it as a permission problem sends
somebody to Liberty asking for a checkbox that is already ticked.

### Timezone (this is a trap, not a footnote)

**All Entrata dates and times are Mountain Time unless stated otherwise.** The VPS runs
`America/Los_Angeles`. A lazy parse that lets the runtime assume local time is a one-hour offset
that becomes a **one-day error** on move-in dates and availability dates near midnight. Parse as
explicit MT. Display MT, since Boise is Mountain anyway.

### Maintenance windows

Scheduled: **Tuesday late night into Wednesday early morning MST, weekly.** Unscheduled: late
evening / early morning on Sunday, Monday, Wednesday, Thursday.

Treat downtime as normal operating condition, not an incident. Back off, serve last-good cache,
alert only if the cache goes stale beyond 2 hours.

---

## 4. What's readable today

Of the 22 usable reads, 20 return real, useful data. Two (`getAmenities`, `getSpecials`) return valid-but-empty responses. Everything below
is a real shape from the 2026-07-31 probe, except §4.14 (`getWorkOrders`), which is from the
2026-08-03 probe, and §4.15 through §4.21, which are from the 2026-08-05 probes. (§4.20 and §4.21
are cross-method findings rather than single-method shapes.)

### 4.1 `getProperties` (group: `properties`)

`response.result.PhysicalProperty.Property[]`, one property. Carries `PostMonths`,
`PropertyHours`, and the 24 `LeaseTerms`.

**`PropertyHours` is API-readable.** Do not hardcode office hours anywhere. Read them, per
property, and let the escalation and response-time logic key off the real value (see §10.4).

### 4.2 `getFloorPlans` (group: `properties`)

Four plans, 52 units, floorplan JPGs CDN-hosted at `medialibrarycf.entrata.com`.

| Plan | Sq ft | Units | Market rent | Available |
|---|---|---|---|---|
| 1x1 | 701 | 12 | $1,375 | 0 |
| 2x2 | 998 | 14 | $1,625 | 0 |
| 2x2 End | 1,015 | 13 | $1,675 | 1 |
| 2x2 Mid | 1,024 | 13 | $1,710 | 1 |

**This is the authoritative source for square footage.** `getUnitsAvailabilityAndPricing`
returns a broken sq-ft block (§7.1).

The floorplan images are usable directly on the site. They come from Entrata's CDN, so no asset
pipeline is needed, but pin the URLs and re-check them, since a PM edit in Entrata can rotate
them without notice.

### 4.3 `getPropertyUnits` (group: `propertyunits`)

All 52 units, occupied and vacant. Per unit: `id`, `remotePrimaryKey`, `unitNumber`,
`unitTypeId`, `floorPlanId`, `buildingName`, `floorNumber`, `SquareFeet`, full `unitAddress`, and
`unitSpaces.unitSpace[].rent.termRent[]`.

**`termRent[]` carries a rent for all 24 lease terms**, each with an `isWebVisible` flag. **Only
the 12-month term is web-visible.** If the site or the agent quotes a rent, quote the web-visible
term unless a prospect asks about a specific term, and mirror what Liberty exposes rather than
inventing a display policy.

`maxNumberOccupants` and `maxNumberOfPets` are both `0` on every unit, which means unpopulated,
not "no pets allowed." The **property-level `pets` block IS populated**: Cat and Dog, $300
deposit, $50 monthly rent, count 999. Use the property-level block for any pet answer, and treat
the unit-level zeros as absent data. **That block is the pet *policy*.** For the actual animals
living in the building, names included, see §4.21.

### 4.4 `getUnitsAvailabilityAndPricing` (group: `propertyunits`)

Splits into two blocks: `Properties` (floorplan rollup) and `PropertyUnits` (only the units that
are actually available). This is the poller's method.

Two units available on probe day:

| Available on | Make-ready | Availability | Status |
|---|---|---|---|
| 09/04/2026 | 09/03/2026 | Available | On Notice (Available) |
| 09/08/2026 | 09/07/2026 | Available | On Notice (Available) |

Note the shape of the turn: make-ready is one day before the available date in this data, not the
3-4 days the turn-engine flow assumes. One sample, do not generalize, but check the assumption.

⚠️ **Do not read `SquareFeet` out of this method.** See §7.1.

### 4.5 `getUnitTypes` (group: `propertyunits`)

Six unit types with market rent broken out by lease term, plus sold-out flags. Six types against
four floorplans, so unit type is a finer grain than floorplan. Use floorplan for display and unit
type for pricing lookups.

### 4.6 `getAmenities` (group: `propertyunits`): returns ZERO

HTTP 200, "No amenities available for the given request." The permission works. The data doesn't
exist.

### 4.7 `getSpecials` (group: `propertyunits`): returns ZERO

HTTP 200, property stub only, no specials.

**Design consequence for both:** any amenity list or concession display has to come from
somewhere other than the PMS. See §10.3.

### 4.8 `getWebsites` (group: `properties`)

Three site configs: the main `libertyassetgroup` site, `libertyassetgroup1`, and a dedicated
`owyheeparkapartments` site (id 506).

⚠️ **This method leaks Liberty's full portfolio.** It enumerates **98 properties by ID and name**
on the main site and **40** on `libertyassetgroup1`, including buildings owned by other owners.
Untested whether those property IDs work as params on other methods, and **probing another
owner's building is out of scope, do not do it.** This is the concrete reason the property-ID
allowlist in §1 exists: the key's blast radius is bigger than our authorization.

### 4.9 `getLeadPickLists` (group: `leads`)

The ID vocabulary. Everything you need to eventually write a note or a guest card with real IDs
instead of placeholders.

| List | Count |
|---|---|
| `EventTypes` | 155 |
| `LeadStatuses` | 62 |
| `EventResults` | 6 |
| `LeasingAgents` (per property) | 15 named Liberty staff, with IDs |
| `LeadSources` (per property) | 17, each with a `WebVisible` flag |

Lead sources include Zillow, Apartments.com, ApartmentGuide, ApartmentFinder, Apartment List,
ForRent, Facebook, Google Ads, Drive By, Referrer.

**Capture and pin these now, while reads are allowed.** The outbox intent payloads (Constitution
rule 4's deferred logging) need real `eventTypeId` / `leadStatusId` / `leadSourceId` values to be
replayable later. Building the outbox against invented IDs guarantees a rewrite.

**Possible resolution to an open question:** `system-design.html` §14 lists "abandoned-app status
IDs" as unresolved because there is no test company to submit a fake application into. The 62
`LeadStatuses` very likely contain them. Check that list before asking Liberty or deferring to
Aspen onboarding.

### 4.10 `getLeads` (group: `leads`)

`result.prospects[0].prospect[]`. **July 2026 returned 109 leads.**

**Source mix (this is the most actionable number in the whole probe):**

| Source | Leads | Share |
|---|---|---|
| Zillow | 87 | 80% |
| Website | 11 | 10% |
| Unknown | 6 | 6% |
| Facebook | 1 | 1% |
| Apartments.com | 1 | 1% |
| Referrer | 1 | 1% |
| Drive By | 1 | 1% |
| null | 1 | 1% |

**Status mix:**

| Status | Count |
|---|---|
| Guest Card Completed | 77 |
| Guest Card Cancelled | 11 |
| Renewal Offer Started | 8 |
| Renewal Lease Approved | 3 |
| Application Started | 2 |
| Application Partially Completed | 2 |
| Renewal Offer Cancelled | 2 |
| Renewal Lease Started / Application Completed / Application Archived / Renewal Offer Approved | 1 each |

Prospect-level fields: `applicationId`, `status`, `statusId`, `leadSourceId`, `leadSource`,
`psProductId`, `leasingAgent`, `leasingAgentId`, `screeningResultStatus`, `createdOn`,
`createdOnDateTime`, `lastUpdatedDate`, `lastUpdatedDateTime`, `customers`,
`customerPreferences`, `lease`.

Customer sub-fields: `applicantId`, `customerId`, `firstName`, `middleName`, `lastName`,
`namePrefix`, `email`, `cellPhoneNumber`, `personalPhoneNumber`, `phones`, `addresses`,
`customerRelationshipType` and `customerRelationshipTypeId`, `customerType`, `birthDate`, `pets`,
and notably **`googleClickId` and `googleClientId`**.

✅ **`pets` is populated, and it is the deepest pet feed on the key by volume.** Verified 2026-08-05
on the same 3-month window: **126 pet records**, carrying `name`, `petType`, `breed`, `color`,
`gender`, `age`, `weight`, `count`, `id`, and `licenseCity`. That is the lead-stage view; the
lease-stage view is `getLeases` (§4.11), and **§4.21 is the section to read before building anything
on either**, because assistance animals are in this data and must be filtered out.

⚠️ **`propertyId`, `fromDate` and `toDate` are all required.** Verified 2026-08-05: omitting them
returns **400 / code 310**. A ~3-month span (05/01/2026 to 08/05/2026) answered inline with no
`queueId`, so windows of that size are safe.

✅ **The window cap is 730 days, so the whole history is 3 calls.** Verified in the 2026-08-05 third
pass: **730-day (2-year) windows were accepted on the first try**, and the entire 5.5-year history,
2021-01-01 through 2026-08-05, swept in **3 calls**. Historical backfill on this method is cheap, not
a burden, and nothing in the plan needs a week-by-week or month-by-month crawl. ⚠️ **Do not carry that
number to another method.** `getCalendarAvailability` caps at **7 days** (§4.17). **Window caps are
per method** and have to be measured one at a time (§7.14).

⚠️ **`birthDate` is present on a MINORITY of lead-customers, and this corrects an earlier claim in
this file.** Previous revisions said `birthDate` was "populated on 122 of 122 records." **That
overstated coverage.** Entrata omits null fields entirely, so counting non-empty values against
*occurrences of the key* could only ever return 100%: the customers without a birth date never carried
the key and were never in the denominator. **The real number, from the full historical sweep: 787
distinct lead-customers, 286 of them carrying a `birthDate`, which is 36%.** This is still the only
working DOB source on the key and it is still the right feed to build on, but it is a **partial** feed.
**Read §4.20 before designing anything that depends on it**: it has the measured coverage against the
current rent roll (65%), the two-way breakdown of the gap, and the proven join. DOB lives at the lead
stage and does not follow the customer onto the lease.

Redacted sample (already scrubbed, safe to copy):

```json
{"applicationId":"199476","status":"Guest Card Completed","statusId":"1",
 "leadSourceId":"67822","leadSource":"Website","psProductId":"2",
 "createdOn":"2026-07-30","createdOnDateTime":"2026-07-30T03:05:00",
 "lastUpdatedDate":"2026-07-30",
 "customers":{"customer":[{"applicantId":189155,"customerId":220852,
   "customerRelationshipType":"Primary","customerType":"Primary",
   "firstName":"[name]","lastName":"[name]","email":"[email]"}]},
 "customerPreferences":[{"targetMoveInDate":"2026-07-30","desiredFloorplan":"1x1",
   "desiredUnit":{"unitType":"1/1.00","unitBedrooms":1,"unitBathrooms":1,
     "minSquareFeet":701,"maxSquareFeet":701,"floorplanId":4714,
     "unitOccupancyStatus":"vacant","unitLeasedStatus":"leased"},
   "desiredNumBedrooms":1,"desiredNumBathrooms":1}]}
```

Note the `createdOnDateTime` of `2026-07-30T03:05:00`. That's 3:05 AM Mountain, roughly 14 hours
before the leasing office opens. See §10.4.

### 4.11 `getLeases` (group: `leases`)

**With NO status filter you get exactly the current rent roll: 52 leases, 49 Current
(`statusTypeId` 4) and 3 Notice (`statusTypeId` 5).**

⚠️ **Counterintuitive trap:** filtering on `leaseStatusTypeIds: "2"` returns **190 leases, all
Cancelled**, which is historical application churn, not anything useful. **For the rent roll,
omit the filter.**

⚠️ **No date of birth, on either version.** The r1 and r2 response schemas both declare a
`dateOfBirth` field, and **both returned zero birth keys against live data on 2026-08-05.** Entrata
omits null fields, so the field is not missing from the API, it is empty on Liberty's records.
Do not plan a birthday touch off this method. See §4.20.

✅ **This is the best pet source on the key, and the only one tied to a current lease.** Verified
2026-08-05: **30 of the 52 leases have pets, and there are 34 individual pet records.** Each pet
carries `name`, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `moveInDate`, `customerId`,
and **`isAssistanceAnimal`**. `customerId` attaches the pet to a resident directly, so no fuzzy
matching is needed. ⚠️ **Read §4.21 before using any of it**: assistance animals are in this data and
are not pets.

Each lease carries:
- `leaseIntervals[]`: start/end dates, `leaseIntervalTypeName` (Renewal vs New),
  `leaseApprovedOn`, `applicationCompletedOn`, `applicationId`
- `customers[]`: names, emails, phones, `moveInDate`, `leaseCustomerStatus`
- `scheduledCharges[]`: `chargeType`, `chargeCode`, `frequency`, `amount`, `taxAmount`
- `leaseActivities[]`: Application, LeaseSigned, Lease From, Lease To, Actual Move In,
  Application Approved On, Lease Generated On, each with a date

**Loss-to-lease is computable straight from the API.** The sample below is in-place $1,500 Base
Rent plus $60 Utilities on a 2x2 whose market (from `getFloorPlans`) is $1,625.

Redacted sample (already scrubbed, safe to copy):

```json
{"id":"29538","leaseStatusTypeId":"4","leaseIntervalStatus":"Current",
 "occupancyType":"Conventional","isMonthToMonth":0,"leaseIntervalId":"108233",
 "buildingId":"4997","buildingName":"3022","floorPlanId":"4712","floorPlanName":"2x2",
 "unitId":"6556","unitNumberSpace":"[unit]","isRenewalBlacklist":"Renewable",
 "leaseIntervals":{"leaseInterval":[{"id":"108233","startDate":"2025-08-30",
   "endDate":"2026-08-29","leaseIntervalTypeName":"Renewal",
   "leaseIntervalStatusTypeName":"Current","leaseApprovedOn":"2025-08-27",
   "applicationCompletedOn":"2025-07-29","applicationId":"99945"}]},
 "customers":{"customer":[{"id":30505,"customerType":"Primary","firstName":"[name]",
   "lastName":"[name]","leaseCustomerStatus":"Current","relationshipName":"Primary",
   "moveInDate":"2023-11-01","addresses":{"address":{"addressType":"Current",
     "email":"[email]"}},"phone":"[phone]"}]},
 "scheduledCharges":{"scheduledCharge":[
   {"id":"189705","chargeCodeId":"80","chargeType":"Base Rent","chargeCode":"Rent",
    "startDate":"2025-08-30","frequency":"Monthly","amount":"1500.00","taxAmount":"0.00"},
   {"id":"189709","chargeCodeId":"700","chargeType":"other","chargeCode":"Utilities",
    "startDate":"2025-08-30","frequency":"Monthly","amount":"60.00"}]}}
```

### 4.12 `getLeaseDetails` (group: `leases`)

Per-lease charge detail: `leaseId`, `name`, `propertyUnitId`, `unitNumber`, and
`scheduledCharges.recurringCharge[]` grouped by lease interval. Each `activeScheduledCharge`
carries `arCodeId`, `chargeCode`, `chargeTiming`, `chargeStartDate`, `chargeEndDate` (values like
"End During Move-Out"), `lastPosted`, `postedThrough`, and a dollar-formatted `amount`.

**On a Cancelled lease it returns a near-empty stub.** Only call it against Current leases.

✅ **r2 probed 2026-08-05: HTTP 200, and it takes `propertyId` only.** r1 needs `leaseId` *and*
`propertyId`; r2 needs just the property. Result keys are `currencyCode` and `leases`. **So r2 is the
property-wide sweep and r1 is the single-lease read**, which makes r2 the cheaper shape when you want
charge detail across the roll rather than for one lease. Limits are roomy either way: 50,000 / 5,000 /
1,000 (§6).

### 4.13 `getGlTransactions` (group: `financial`)

`properties.property[].transactions.transaction[]`, one block per GL account. **June 2026
returned 90 accounts.**

Per block: `@attributes` (`accountId`, `accountName`, `accountNumber`), a `glTotal`
(`openingBalance`, debit/credit opening, debit/credit closing, `netChange`, `closingBalance`), and
`glDetails.glDetail[]` line items with `transactionType`, `transactionDate`, `postMonth`,
`postDate`, `debit`, `credit`, running `balance`, `reference`, `memo`, `unitId`, `unitNumber`,
`buildingId`, `buildingNumber`.

This is a drop-in for NPI's existing monthly reconciliation workflow. Unit-level tagging on the
line items means the reno/capex split and per-unit tracking work without manual mapping.

⚠️ **PII warning, see §8.** GL memo fields read like `Firstname Lastname Unit #XXXX-XXX` on every
AR Payment line. The financial feed is PII-bearing, not just numeric.

### 4.14 `getWorkOrders` (group: `maintenance`): verified working 2026-08-03

**Permissioned AND functional.** Not just on the roster: a read-only call (group `maintenance`,
version `r1`, `propertyId` `1311115`) returned **HTTP 200 with real production work orders**. This
is the single biggest change since 2026-07-31 and it unblocks the turn engine and the service-call
card (§10.2).

Real fields observed on the returned records:

| Field | Observed value |
|---|---|
| `maintenanceRequestId` | 3435, 3436 |
| `parentMaintenanceRequestId` | present. **Work orders carry parent/child structure.** |
| `buildingName` | "3030" |
| `unitNumber` | "3030-201" |
| `maintenanceStatus` | "Completed" |
| `maintenanceProblem` | "Make Ready" |
| `maintenanceRequestFrom` | "Entrata PaaS" |
| timestamps | Mountain Time (MDT), per §3 |

Three things to design around:

- **Parent/child is real.** `parentMaintenanceRequestId` means a request can be a child of another
  request. Do not assume a flat list, and do not count child records as independent tickets in any
  turn-progress or volume metric until you know which grain the data is in.
- **"Make Ready" shows up as a maintenance problem.** That is the turn engine's signal arriving in
  the PMS rather than in a human email, and it is why §10.2 changed. A "Make Ready" work order
  moving to "Completed" is the closest thing to a machine-readable make-ready confirmation this
  integration has.
- **`maintenanceRequestFrom` distinguishes origin.** "Entrata PaaS" here, so at least some records
  are system-generated rather than resident-submitted. Read this field before treating a work order
  as a resident service call worth firing a "How'd we do?" touch on.

**One more field worth surfacing: `petInfo`.** `getWorkOrders` r1 carries it, and it is the one
place pet data has an operational rather than a marketing use: telling a technician there is an
animal behind the door before they open it. Pass it through to whatever the tech sees. See §4.21.

⚠️ **Read-only, like everything else.** `sendWorkOrders` and `updateWorkOrders` are **not** on the
key (§2.1) *and* are barred by Constitution rule 2 regardless. Reading work orders does not create
a path to dispatching a tech, and nothing in this section should be read as opening one.

### 4.15 `getWorkOrderPickLists` (group: `maintenance`): verified working 2026-08-05

**HTTP 200, no params required.** This is the maintenance counterpart to `getLeadPickLists` (§4.9):
the ID vocabulary behind every work-order field. Everything comes back **property-scoped**, so the
lists are per building, not global, and a second property will need its own read.

⚠️ **It has the tightest DAILY bucket on the whole key: 1,000/day, 500/hour, 60/minute** (measured
2026-08-05, §6). That is below `getMarketingPreferencePickList`'s 1,500/day and below everything else
on the key. **Cache it.** Property-scoped plus a 1,000/day ceiling means a multi-property rollout eats
the budget faster than it looks: one read per property per day is the shape, not one read per work
order.

| List | What's in it |
|---|---|
| `Locations` | Work-order locations, **each carrying a nested `unitTypes` list** |
| `Priorities` | Priority vocabulary |
| `StatusTypes` | Work-order status vocabulary |
| `Categories` | Category vocabulary |
| `Problems` | Problem vocabulary. This is where "Make Ready" (§4.14) comes from |
| `SubMaintenanceProblems` | Second-level problem detail under `Problems` |
| `WorkOrderTypes` | 5 |
| **`CompanyEmployees`** | **12. Liberty's maintenance staff roster, the IDs work orders get assigned to** |
| `InspectionStatuses` | 5 |

Two things worth pulling out:

- **`CompanyEmployees` is a real staffing signal, not just an ID list.** 12 named company employees
  are assignable to work orders on this property. Combined with the 15 leasing agents in
  `getLeadPickLists` (§4.9), the API now exposes both halves of Liberty's staffing for Owyhee, which
  is useful for FlightDeck attribution and for routing an escalation to a person rather than a
  mailbox. It is employee PII; §8 applies.
- **Pin these the same way §10.9 says to pin the lead pick lists.** If a maintenance intent ever
  gets queued to the outbox, it needs real `problem` / `priority` / `status` IDs to be replayable.
  Capture them now, while reads are the only thing allowed. **Pinning IDs is not a step toward
  writing work orders**, which stay barred by Constitution rule 2 and are not permissioned anyway.

### 4.16 `getMitsLeases` (group: `leases`): verified working 2026-08-05

**HTTP 200 with `propertyId`.** Returns the lease file in **MITS format**, which is a different
shape from `getLeases` (§4.11), not a different dataset. The interesting part is the grain:

**100 `Tenant` records against 52 `LA_Lease` records.** That is roughly two people per lease, and it
is the first time the API has handed us **roommates and guarantors as first-class records**.
`getLeases` gives a lease with a `customers[]` array; this gives a person-level view with a
`GuarantorFor` link. If anything in the design needs "everyone who lives in unit X" rather than
"the lease on unit X," this is the method.

Keys on each `Tenant`: `@attributes`, `Finances`, `GuarantorFor`, `Identification`, `LeaseID`,
`Name` (`FirstName` / `LastName`), `Phone`, `Reference` (**including `EmergencyContact`**),
`Residence` (`Address`), `Spouse`.

⚠️ **No birth date in live data, even though `BirthDate` IS in the r1 response schema.** This is the
method that was asked for specifically to get resident birthdays (§9 item 4) and **it does not carry
them for Liberty's residents.** Entrata omits null fields, so the field exists and is empty. **The
birthday source is `getLeads`, not this.** Full answer in §4.20.

**Every `Identification` IDType on these records is an internal Entrata ID.** Observed set:
`Charge Code ID`, `Charge ID`, `Customer Contact Id`, `Customer Employer ID`, `Customer ID`,
`Lease ID`, `Primary Customer ID`, `Property ID`, `Property Unit Id`. **No government ID and no
DOB came back in live data**, so this method is not behaving as an SSN or driver's-license surface
today, and it cannot verify identity.

⚠️ **But `SocialSecurity` IS in the r1 response schema, and this is the one method on our key where
that is true.** The live probe on 2026-08-05 returned **no SSN-like keys at all**, which means
Liberty leaves it empty, exactly the way `BirthDate` is empty. **Empty today is not empty forever.**
A field Entrata declares can start populating the moment a PM changes a workflow, and nothing would
notify us. **NPI never ingests, persists, or logs an SSN. Strip it at the transport choke point, on
read, before anything is stored, logged, or shown to a model.** Full rule in §8 item 7; it is
binding, not advisory.

⚠️ **Do not use this method for pets.** Its pet block is thin: `Count`, `PetType`, `Weight`, and
**no name**. `getLeases` (§4.11) carries the full record including the pet's name. See §4.21.

✅ **`Customer ID` here is the join key that unblocks `getMarketingPreferences`** (§4.19). The
`customers` group is absent from the key entirely (§2.1), so customer IDs had to come from
somewhere else, and they come from here.

### 4.17 `getCalendarAvailability` (group: `properties`): verified working 2026-08-05

**HTTP 200 with `propertyId` + `fromDate` + `toDate`, dates in `MM/DD/YYYY`.**

🔄 **This overturns the warning that used to sit here, in §9 item 14 and in §10.4.** Earlier
revisions said the calendar was "likely to disappoint," might "hold nothing real," and had to be
treated as an empty or unmaintained surface because Liberty books tours by hand. **That was wrong.
The calendar holds real, maintained configuration**, and it corroborates rather than contradicts
Alex's answer: **self-guided tours are already configured in Entrata**, exactly as his lock-box
comment implied. What is manual at Liberty is the **booking**, not the **configuration**.

⚠️ **The window is 7 days, not 60.** An 8-day-or-wider span returns **400 / code 308, "The date
difference should not be more than 7 days."** The vendor OpenAPI spec advertises a 60-day range and
**live r1 behavior contradicts it.** The spec is wrong, at least for r1. Treat 7 days as the
operative cap, and page through longer horizons a week at a time. (r2 takes a `tourType` enum and
has not been probed; do not assume its cap differs.) Anywhere in this repo still claiming a 60-day
window is stale.

What actually came back:

| Block | Real values |
|---|---|
| `propertyCalendarSettings.appointmentLength` | 30 |
| `propertyCalendarSettings.selfGuidedTourAppointmentLength` | 30 |
| `propertyCalendarSettings.minLeadTimeTour` | "120 Minutes" |
| `propertyCalendarSettings.selfGuidedTourMinLeadTime` | "120 Minutes" |
| agent-guided `availabilities` | **Mon-Fri 08:00-16:00 MST**, `simultaneousAppointmentLimit` "No Limit" |
| `selfGuidedTourAvailabilities` | **all 7 days including weekends, 06:00-18:00 MST**, `simultaneousAppointmentLimit` 1 |
| `propertyCalendarAvailability.availableHours.availableHour[]` | Per-date windows. **Weekends are absent from the agent-guided set**, consistent with the M-F configuration |

Design consequences:

- **Self-guided tours are the after-hours path, and Entrata already knows about them.** 06:00 to
  18:00, seven days a week, one at a time. That is 84 hours a week of tour capacity against 40 hours
  of leasing office (§10.4), on a tour type that consumes no agent drive time. This is the single
  most useful thing the 2026-08-05 probe found for the leasing flow.
- **A 120-minute minimum lead time is a hard constraint on the agent.** No "come see it in an hour."
  Any slot the agent offers has to be at least two hours out, for both tour types.
- ⚠️ **Residual, and it is the true part of the old warning: this is configuration, not a booking
  ledger.** Liberty schedules by hand, so Entrata's calendar **may not reflect appointments actually
  booked**. Read it for what a property is *set up* to offer, and do not treat an open window as
  proof nobody is standing in that unit. Confirm with Liberty before promising a specific slot to a
  prospect, and keep tour booking a human handoff until that is validated end to end.
- **Note the discrepancy against `PropertyHours`:** tour availability is 08:00-16:00, the documented
  office hours are M-F 9:00-5:00 (§2). Same eight hours, shifted an hour earlier. Recorded, not
  resolved. Do not silently pick one, and do not build a rule that assumes they agree; ask Liberty
  which one governs a real appointment.

### 4.18 `getMarketingPreferencePickList` (group: `communications`): verified working 2026-08-05

**HTTP 200, no params required.** The consent vocabulary. **24 preference rows.**

- **`recipientTypes`: `LEAD` (id 1) and `RESIDENT` (id 2).** Consent is modeled separately for the
  two, which matches the way the engines are split.
- **Channels: `email`, `phone`, `postal_mail`, `sms`.** Postal mail is a first-class consent channel
  in Entrata, which matters given how much of the recognition engine is physical mail.
- Also returns **5 `availabilityAlertFrequencies`** (Daily among them).

⚠️ **This is the tightest HOURLY bucket on the entire key: 1,500/day, 60/hour, 10/minute** (§6).
**Cache it and never call it per lead.** It is a vocabulary that changes when a PM edits a setting,
not per-request data. One read a day is generous. **Say "hourly," not just "tightest":** the tightest
*daily* bucket belongs to `getWorkOrderPickLists` at 1,000/day (§4.15). Two different pick lists, two
different ceilings, and **both need caching.**

⚠️ **Parse defensively: not every row has every key.** Some rows, the `phone` and `postal_mail` ones
among them, come back with **no `consentType` key at all**. A parser that assumes a uniform row
shape will throw on real data.

### 4.19 `getMarketingPreferences` (group: `communications`): verified working 2026-08-05

**HTTP 200 with `propertyId` + `customerIds` + `recipientTypeId`.** Verified against a real Owyhee
resident with `recipientTypeId=2` (RESIDENT).

Returns `customers[].preferences[]`, each preference carrying `communicationChannel`,
`recipientType`, `recipientTypeId`, `consentType`, `consentTypeId`, `optIn`, and `isTransactional`.

✅ **The LEAD side works too. Probed 2026-08-05 third pass: `recipientTypeId=1` returns HTTP 200.**
Both recipient types are readable, and the subtle part is what that implies: **the same customer can
carry BOTH a LEAD preference set and a RESIDENT preference set.** `recipientType` is a **lens on the
customer, not a partition of them.** A resident who was once a prospect has consent state under both
IDs. **Read the recipient type that matches the touch you are about to send**, and do not assume a
person belongs to exactly one bucket or that reading one type tells you anything about the other.

✅ **The `customerIds` blocker is RESOLVED.** The concern was that `customerIds` is required and the
whole `customers` group is absent from the key (§2.1), which looked like it could make the method
unusable. It doesn't: **customer IDs come out of `getMitsLeases`** as `IDType: "Customer ID"`
(§4.16), and `getLeads` carries `customerId` on every prospect (§4.10). **The absent `customers`
group is not required to use this method.** Anywhere this repo says otherwise, fix it.

**Design consequence: consent is readable per resident per channel, so the engines can honor it.**
`optIn` plus `isTransactional` is exactly the pair needed to decide whether a recognition touch or a
review ask is allowed to go out, and to distinguish it from a transactional message that goes out
regardless. **Read consent before sending, do not infer it from having an address.** Note that
reading a preference is not the same as recording one: writing consent back would be a write, and
writes are barred (§1).

### 4.20 Resident date of birth: it's on `getLeads`, not on the lease

**This is the headline of the 2026-08-05 probe** and it reverses a claim that has been in this file
since 2026-07-31: that `getMitsLeases` was the birthday source, and, in the version before that,
that no Entrata birthday source existed and the Welcome Profile was the only path. **Both are
wrong.** DOB is readable today, on a method that has been on the key since the first grant.

**Where the vendor spec says birth dates live.** A scan of the vendored OpenAPI found birth-date
fields in the **response** schema of five methods: `getLeads` r1 (`birthDate`), `getLeases` r1 and
r2 (`dateOfBirth`), `getMitsLeases` r1 (`BirthDate`), `getEvictedLeases` r1, and `getMitsCollections`
r1. Five more carry it **request-side only**, which is irrelevant to us because they are all writes:
`sendApplication`, `updateApplication`, `sendLeases`, `updateCustomers`, `updateLease`.

**What live data actually returned, 2026-08-05:**

| Method | Birth-date field | Live result |
|---|---|---|
| `getLeases` r1 | `dateOfBirth` | **Zero birth keys** |
| `getLeases` r2 | `dateOfBirth` | **Zero birth keys** |
| `getMitsLeases` r1 | `BirthDate` | **Zero birth keys** |
| **`getLeads` r1** | **`birthDate`** | ✅ **Populated, but on a minority: 286 of 787 distinct lead-customers across the full 2021-2026 sweep, which is 36%** |

Entrata omits null fields, so this is not a schema problem. **DOB is simply empty on Liberty's
lease and resident records and populated on some of their lead records.**

**The conclusion, stated plainly: DOB is captured at the LEAD / APPLICATION stage and never
propagates to the lease or the resident record.** Whatever Entrata does at application time, it does
not carry the birth date forward. So the birthday source is the funnel, not the rent roll.

#### ⚠️ CORRECTION: the "122 of 122" figure was wrong and it is retired

It appeared in earlier revisions of this file, in §4.10, in §10.11, and in the method index, and it
was read by everyone downstream as "birthdays are complete." **It is not a real coverage number.**
**Entrata omits null fields entirely** (§7.11), so counting non-empty `birthDate` values against
`birthDate` *occurrences* can only ever return 100%. The customers without a birth date do not carry
the key at all and were never in the denominator. **The honest denominator is distinct
lead-customers.** Anyone re-deriving this number has to use that denominator or they will re-derive
100% again and re-introduce the same error.

#### DOB coverage, measured (2026-08-05 third pass)

Full historical sweep of `getLeads`, 2021-01-01 through 2026-08-05, joined against the current rent
roll from `getLeases`:

| Measure | Count |
|---|---|
| Distinct lead-customers in `getLeads` across the full history | **787** |
| Of those, carrying a `birthDate` | **286, which is 36%** |
| Customer records across the 52 leases on the rent roll | **103** (roommates and guarantors make customers outnumber leases) |
| Of those, `Current` | **88** |
| Current residents who appear anywhere in `getLeads` | **70 of 88** |
| Current residents carrying a `birthDate` | **57 of 88** |
| **DOB coverage of current residents** | **65%** |

**The 35% gap (31 residents) splits two ways, and the halves need different answers:**

- **18 current residents never appear in `getLeads` at all.** They predate the data or were entered
  another way. **No window finds them and no sweep fixes this.** It is a structural miss, not a
  paging problem.
- **13 appear in `getLeads` but carry no birth date.** The lead record exists, the field is empty.

**Practical read: `getLeads` covers about two thirds of the current roll, and the Welcome Profile has
to cover the rest.** **Do not describe birthday coverage as complete** in a status report, a partner
conversation, or a design doc. It is a good feed with a known third missing, and stating that plainly
is what stops the recognition engine from promising a touch it cannot deliver.

#### ✅ The lead-to-resident join is PROVEN

`getLeads` `customers.customer[].customerId` and `getLeases` customer `id` are **the same
namespace.** 70 of the 88 current residents matched on that key alone, with no name matching, no
email fuzzing, no heuristics. **An earlier worry that these were two different ID spaces is
resolved: they are not.** The 18 non-matches are residents with no lead record at all, not join
failures.

Lead customer objects carry: `applicantId`, `birthDate`, `cellPhoneNumber`, `customerId`,
`customerRelationshipType` and `customerRelationshipTypeId`, `customerType`, `email`, `firstName`,
`lastName`, `middleName`, `namePrefix`, `personalPhoneNumber`, `phones`, and `addresses`.

#### ✅ The historical sweep is cheap: 3 calls

`getLeads` accepted **730-day (2-year) windows on the first try**, so 2021-01-01 through 2026-08-05
covered in **three requests** (§4.10). Earlier revisions of this section planned a crawl "week by week
or month by month," which was over-engineering against a cap this method does not have. ⚠️ **Do not
generalize that window to another method:** `getCalendarAvailability` caps at 7 days. **Window caps
are per method** (§7.14).

**For scale, the earliest `moveInDate` on the current roll is 2022-06-16**, so a 5.5-year sweep
comfortably reaches every current resident's lead record if one exists. **The 18 misses are therefore
not a window problem**, and widening the sweep further will not recover them.

**Design path for the recognition engine:**

1. Read `birthDate` from `getLeads` (`propertyId` + `fromDate` + `toDate`, §4.10).
2. Join to the resident on `customerId`, which appears on the lead (§4.10), on the lease
   (`getLeases` customer `id`, same namespace, proven above) and on `getMitsLeases`
   (`IDType: "Customer ID"`, §4.16).
3. ✅ **Run the historical sweep once. It is 3 calls, not a crawl.** 730-day windows are accepted, so
   the full 5.5-year history costs three requests. Then poll forward on a normal cadence.
4. ⚠️ **Coverage after the sweep is 65% of current residents, and the remainder is structural, not a
   backlog.** 18 of 88 current residents are absent from `getLeads` at any window, and 13 more appear
   with no birth date. **The Welcome Profile is the permanent fallback for those 31**, not a temporary
   bridge that the sweep eventually retires. **State the coverage number honestly** wherever the
   feature is described.

⚠️ **PII: this is the most sensitive field the integration touches.** §8 applies in full, plus one
specific recommendation: **store month and day only, drop the year.** Birthday recognition needs
"March 14," not "March 14, 1978." Dropping the year keeps the feature whole and materially cuts what
a breach would expose, since DOB plus name plus address is identity-theft-grade. Entrata's "Secure
PII Delivery, Vendor Integration Guide" requirements apply to whatever store this lands in.

**Executed lease documents are NOT reachable, and don't go looking.** `getLeaseDocuments` (the files)
and `getLeaseDocumentsList` (the metadata) are in the `leases` group but are **not on the 28-service
key** (§2.1). Getting them would mean a fresh ask to Liberty, and **it is not needed** now that
`getLeads` carries the DOB. Do not add them to a permission ask on birthday grounds.

**Proved by counter-example 2026-08-05: a lease document would not have helped anyway.** All 44
executed leases in the Aspen DD set (`00_Deals/Boise - Aspen/10_Deal_Shared/PM_Shared/Seller_DD/
02. Lease and Tenant Info/a. Lease & Rental Agreements/Leases/`) were text-extracted and scanned,
7.4M characters, no OCR needed. **Zero occurrences of "date of birth" or "birth" in any of them.**
What they do carry is one SSN field and one "18 years" clause per lease, which is the standard
adult-occupant term, not verification data. So the executed lease records the *result* of screening,
never the applicant's DOB. That is the same split seen in the API: DOB lives on the application /
lead record and never propagates to the lease. **Do not chase lease documents for DOB at any
property, and do not spend an ask on it.**

### 4.21 Pet data: readable, rich, and it has a fair-housing trap in it

**Probed live 2026-08-05, and this is the second-best find of that round after the birth dates.**
The pet-gift program has been designed against an assumed data gap. There is no gap. Pets are on the
key today, at individual-animal grain, **with names**.

| Method | Pets | Fields per pet |
|---|---|---|
| **`getLeases` r1** (§4.11) | **30 of 52 leases have pets, 34 pet records** | **`name`**, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `moveInDate`, `customerId`, **`isAssistanceAnimal`** |
| `getLeads` r1 (§4.10) | **126 pet records** | `name`, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `count`, `id`, `licenseCity` |
| `getMitsLeases` r1 (§4.16) | thin | `Count`, `PetType`, `Weight`. **No name.** Do not use it for pets |
| `getWorkOrders` r1 (§4.14) | `petInfo` | Operational, not marketing: warn the tech there is an animal in the unit |

**`getLeases` is the source to build on.** It is the only one that is both current-lease-scoped and
name-bearing, which is exactly the shape a recognition touch needs. `getLeads` is deeper by volume
but it is funnel data, so it includes people who never signed and pets that never moved in.

**The join is clean.** Pet records carry `customerId`, so a pet attaches to a resident directly, on
the same key that already joins leads to leases (§4.20). No name matching, no heuristics.

**`petType` comes back inline, so the type dictionary is not needed.** `getPetTypes` (group
`properties`) is **not on the 28-service key** and there is no reason to ask for it. Do not add it to
any permission ask.

⚠️ **`isAssistanceAnimal` is load-bearing, and this is a fair-housing rule, not a matter of tact.**
**Assistance animals and service animals are not pets.** They are not subject to pet rent, pet
deposits, or pet policy, and they exist because of a disability. **Filter them out of the pet-gift
program and out of every pet-themed marketing touch.** Sending a "treats for your pup" package to a
resident whose animal is a service animal singles that resident out on the basis of a disability,
which is a fair-housing exposure for NPI and for Liberty. **Default to excluding the record when the
flag is missing or unparseable**, the same fail-safe posture as everything else in this file.

**Note the grain difference against the property-level pet policy.** §4.3 has Owyhee's policy block
(cats and dogs, $300 deposit, $50/month) out of `getPropertyUnits`. That is what the property allows.
This section is which animals actually live there. Two different questions; do not answer one with
the other.

**Aspen, for comparison: documented but weaker.** The 44 executed leases in the Aspen DD set were
text-extracted and scanned the same day as the DOB scan above. **21 of the 44 carry `breed:` and
`weight:` labels**, so pets are recorded on the paper leases too, but **no lease carries a pet
name.** Entrata is the better source at any property that is on the key, and there is no reason to
parse lease documents for pet data.

Design consequence in §10.12.

---

## 5. What's blocked, and what it costs the design

### Hard-blocked (HTTP 400, code 1403, "The method is not allowed to the requested 'orgs'")

| Method | Group | What the design loses | Asked Liberty? |
|---|---|---|---|
| ~~`getWorkOrders`~~ | ~~maintenance~~ | **✅ NO LONGER BLOCKED. Granted and verified working 2026-08-03** (§4.14, §10.2). Left in this table as a tombstone so nobody re-adds it from an older doc. | ✉️ Asked 2026-07-31, **granted 2026-08-03** |
| `getPropertyMedia` | properties | Property photo library. Floorplan images still come through `getFloorPlans`. | **No, deliberately.** Not urgent, kept off the list to keep the ask short |
| `getExpiringLeases` | leases | Convenience only. `getLeases` with no filter gives lease end dates, so expiries are computable. | **No, deliberately.** `getLeases` covers it |
| `getCustomers` | customers | Standalone resident lookup. Resident data still arrives via `getLeases` and `getLeads`. | **No, dropped entirely.** Its response schema has no birth-date field, so it would not serve the birthday-card use case even if unblocked, **and as of 2026-08-05 that use case is served anyway, by `getLeads` (§4.20)**. Its customer IDs are not needed either; `getMitsLeases` supplies them (§4.16, §4.19) |

**None of the three still-blocked methods appear in `getAccessibleServices`, so this is a
permissions gap on Liberty's side, not a bug in our config.** For the ones we chose to ask about
and the ones we deliberately left off, see §9.

**State the block at the right altitude:** `getCustomers` is not individually switched off, **its
whole group is missing from the key** (§2.1). Asking Liberty to "enable `getCustomers`" is asking
them to grant a group they never granted, which is a bigger checkbox conversation than a typo fix.
Same for anything else in `customers` (`searchCustomers`, `getCustomerTestimonials`). **The
`maintenance` group is no longer an example of this.** It landed 2026-08-03, and the fact that
`sendWorkOrders` and `updateWorkOrders` still did not come with it is now a per-method exclusion,
not a group-level one (§2.1).

**Also not permissioned, and worth knowing before you design around it:** `getLeadEvents`,
`getMitsLeads`, `getLeaseActivities`, `getQuotes`, `getCallLogs`, **`getLeaseDocuments` and
`getLeaseDocumentsList`**, and every other method not on the §2.1 roster. **`getMitsLeases` is no
longer on this list.** It was asked for on 2026-07-31 (for resident birthdays), granted 2026-08-03,
and **verified working 2026-08-05, though it did not carry the birth dates it was asked for**
(§4.16, §4.20). `getLeadEvents` was deliberately left off the ask, see §9 item 5. **The two lease-
document methods are worth naming explicitly** because the executed lease is the obvious next place
someone will go hunting for a birth date: they are in the `leases` group, they are not on the key,
and **there is no reason to ask for them** now that `getLeads` carries DOB (§4.20).

### Fails differently

- **`getAppAdditionalServices`**: 400 / code 107. Requires OAuth, not `apikey`. Not a permissions
  problem. There is an OAuth2 client ID in the portal, but this integration doesn't use that path.

### Absent from the accessible list entirely

`getBudgets`, `getBudgetActuals`, `getBankAccounts`. **The financial group is only partly
enabled.** GL actuals yes, budget comparison no. FlightDeck's "NOI vs budget" tile has to source
budget from NPI's own model, not from Entrata.

### The `getWorkOrders` problem, RESOLVED 2026-08-03

**This was the biggest single gap in the 2026-07-31 picture and it is closed.** Liberty granted
`getWorkOrders` and `getWorkOrderPickLists` on 2026-08-03, and `getWorkOrders` is verified
returning real production work orders, not merely permissioned (§4.14). `system-design.html`
Flow C (turn engine), Flow E (review engine), and Flow F (escalation) all assume maintenance data,
and that assumption is now met on the read side. Where things stand:

- **Notice detection was never the problem and still works.** `getLeases` returns `statusTypeId`
  5 = Notice, and `getUnitsAvailabilityAndPricing` returns `AvailableOn` and `MakeReady` dates.
- **Make-ready progress is now readable.** "Make Ready" arrives as a `maintenanceProblem` with a
  `maintenanceStatus` on it (§4.14), so the turn engine can see a turn move from open to Completed
  instead of waiting on a human. **Residual caveat: keep the human confirmation step with Liberty.**
  One probe is not a validated turn workflow, we have not watched a turn progress end to end
  through this feed, and the parent/child structure means "is this turn done" is not yet a
  one-record question. Treat the API as the primary signal and the Liberty confirmation as the
  check on it, not the other way round, until a full turn has been observed.
- **The service-call card has a trigger again.** The "How'd we do?" card and the work-order-complete
  review ask (Flow E, one of three triggers) can fire off a completed work order. The other two
  triggers, move-in +2 weeks and renewal, both come from `getLeases` and are unaffected.
  **Residual caveat:** filter on `maintenanceRequestFrom`. A system-generated "Entrata PaaS" make-
  ready record is not a resident service call and must not send a resident a survey.
- **Webhooks are now additive rather than a workaround.** `workorder.status.update` fires on
  completion and carries both `CustomerId` and a `MaintenanceRequestId` we **can now resolve**
  (§6.1). The event tells us something changed and `getWorkOrders` tells us what. Push plus read is
  the design; neither is a substitute for the other.
- **The physical card the tech leaves on site stays in the design** (and the fallback email to the
  Liberty PM stays in Flow F). They are no longer the only path, but they are still the path that
  works when the feed is down or the record is ambiguous.

**Keep the maintenance data source pluggable anyway.** The reason changed but the instruction did
not: the source is a third party's live PMS on a key we do not control, and Liberty can revoke a
checkbox as easily as they ticked one. **Still do not build a parallel maintenance system of record
in Supabase.** Entrata is the system of record for work orders; cache and derive, don't duplicate.

⚠️ **Nothing here loosens Constitution rule 2.** `getWorkOrders` is a read. `sendWorkOrders` and
`updateWorkOrders` are neither permissioned (§2.1) nor allowed (§1). Foothold reads maintenance
data; it does not create, update, or dispatch work orders against a live building.

---

## 6. Rate limits and polling implications

Limits come back in `x-ratelimit-limit` headers and are **per method, not per key.**

| Method | Per day | Per hour | Per minute |
|---|---|---|---|
| `getStatus` | 8,640 | 360 | **6** |
| **`getWorkOrderPickLists`** | **1,000** | 500 | 60 |
| `getWebsites` | 1,000 | 100 | 10 |
| **`getMarketingPreferencePickList`** | **1,500** | **60** | **10** |
| `getMarketingPreferences` | 10,000 | 1,000 | 60 |
| `getWorkOrders` | 25,000 | 1,500 | 150 |
| `getMitsLeases` | 20,000 | 4,500 | 200 |
| `getCalendarAvailability` | 48,000 | 2,000 | 120 |
| `getAmenities` | 10,000 | 1,000 | 60 |
| `getUnitTypes` | 10,000 | 2,000 | 100 |
| `getSpecials` | 5,000 | 600 | 100 |
| `getProperties` | 15,000 | 1,000 | 150 |
| `getPropertyUnits` | 15,000 | 2,000 | 150 |
| `getGlTransactions` | 10,000 | 6,000 | 150 |
| `getLeadPickLists` | 5,000 | 300 | 200 |
| `getFloorPlans` | 10,000 | 1,000 | 300 |
| `getLeads` | 27,000 | 5,000 | 135 |
| `getUnitsAvailabilityAndPricing` | (none returned) | 36,000 | 600 |
| `getLeases` | (none returned) | 64,320 | 1,072 |
| `getLeaseDetails` (r1 and r2) | 50,000 | 5,000 | 1,000 |

⚠️ **The trap: `getStatus` at 6/minute is the tightest limit on the entire API, tighter than any
business method. It cannot be a heartbeat.** The obvious "ping status every 30 seconds" design
burns the quota and gets throttled while every real method sits idle. `getWebsites` and
`getMarketingPreferencePickList`, both at 10/minute, are next tightest, so both are config reads,
not hot paths.

⚠️ **Second trap, added 2026-08-05: `getMarketingPreferencePickList` has the tightest hourly bucket
on the key at 60/hour**, on top of 1,500/day and 10/minute. Nothing else on the key is throttled
below 100/hour, and that gap is a deliberate signal from the vendor about how the method is meant to
be used. **It is a vocabulary, not per-request data. Cache it, and never call it per lead or per
resident** (§4.18). One read a day is generous; one read per lead would exhaust the hour in the
first sixty leads. The paired `getMarketingPreferences`, which *is* per-customer, is a normal
10,000 / 1,000 / 60 and can be called in the flow.

⚠️ **Third trap, added 2026-08-05: "the tightest bucket" needs qualifying, because the two pick lists
are tightest in different dimensions.** `getMarketingPreferencePickList` is tightest **hourly**, at
60/hour and 10/minute. **`getWorkOrderPickLists` is tightest DAILY, at 1,000/day**, below the
marketing pick list's 1,500 and below every other method on the key. Anywhere a doc calls the
marketing pick list "the tightest bucket" without a qualifier, refine it: **marketing pick list is
tightest hourly, work-order pick list is tightest daily.** **Cache both.** They are both vocabularies
that only change when a PM edits a setting, and neither belongs in a per-lead, per-resident, or
per-work-order path.

`getMitsLeases` (20,000 / 4,500 / 200) and `getCalendarAvailability` (48,000 / 2,000 / 120) are both
roomy, and `getWorkOrders` (25,000 / 1,500 / 150) is roomy enough that work-order polling is not
quota-constrained either. Note that `getCalendarAvailability`'s real constraint is not the quota, it's the **7-day
window cap** (§4.17): covering a 60-day horizon costs 9 calls, not 1, so budget calls per horizon
rather than per read.

Also on every response:
- `x-spike-arrest-limit: 200` with `x-spike-arrest-slice-period: 100ms`, a burst governor separate
  from the quota. Serial calls (Constitution rule 1) already satisfy this.
- `x-rate-limit-remaining`, which decrements. Log it, alert on a floor.
- `x-read-only`, undocumented: returns `111` on most methods and `100` on the six
  propertyunits/floorplan methods. **Reported as observed. Do not interpret it, do not key
  behavior off it.**

**Polling implications:**

- Availability is effectively free. A 12-minute poll of one property is ~120 calls/day against a
  36,000/hour ceiling. Quota is not the constraint on cadence.
- What *is* the constraint: the serial gate, good-neighbor behavior on somebody else's org, and
  the ~8-12K calls/month volume NPI declared in the access application. Staying near the declared
  number is a good-faith obligation, not a technical one.
- **Constitution rule 6 is the real answer anyway:** never state availability or price without a
  fresh read. Cache cadence sets how stale the site can be; the live re-check at the moment of
  commitment is what keeps the promise. Don't try to solve rule 6 by polling harder.
- **Liveness signal:** use the poll itself, or `getProperties`, not `getStatus`. If you want an
  explicit health check, `getStatus` at most every 15 minutes (4/hr against a 360/hr and 6/min
  budget) leaves headroom for the retry storms that come with maintenance windows.
- **Webhooks do not change the polling design.** Researched 2026-07-31 against Entrata's actual
  webhook documentation: there are **5 published events and none of them is availability or
  pricing**, so the 12-minute availability poll survives unchanged. Full detail in §6.1 and in
  `entrata-webhooks-2026-03-04.md`.

---

## 6.1 Webhooks: what they actually are

**Superseded on 2026-07-31.** Earlier notes in this file and in `system-design.html` said webhooks
were "unprobed" and might "delete most of the polling design." That was speculation and it was
wrong. The vendor documentation is now captured verbatim at
[`entrata-webhooks-2026-03-04.md`](entrata-webhooks-2026-03-04.md) (Entrata's dev-portal Webhooks
page, vendor `updated_at` 2026-03-04). Re-fetch commands are in that file's header comment: the
portal is a Gravitee SPA, but its backing API at `developer-api.entrata.com/portal` is public and
unauthenticated, so the page is fetchable without a login.

### The headline

**There is no availability event and no pricing event.** There is also no documented lead-created
event. **So the 12-minute availability poll stays exactly as designed.** What webhooks add is a
work-order and application layer we do not have today, not a replacement for polling.

### The 5 published events

| Event | Payload fields | Notes |
|---|---|---|
| `lease.completed` | ClientId, PropertyId, LeaseId, CustomerId, ApplicationStatusId, ApplicationStageId | |
| `lease.partially.completed` | same six | |
| `lease.started` | same six | |
| `oa.application.events` | the six above minus CustomerId, plus ProductId, ApplicationId, NewApplicantId, TriggeredByApplicantId, TriggeredByCompanyUserId, EventName | "oa" = online application |
| `workorder.status.update` | ClientId, PropertyId, LeaseId, CustomerId, MaintenanceRequestId, MaintenanceStatus | Statuses are client-defined; status *types* are suspended / completed / cancelled |

### Payload shape

**Amazon SNS shaped.** The POST body carries `Type`, `MessageId`, `TopicArn` (format
`client-4823:workorder.status.update:9`), `Message`, `Timestamp` (Mountain Time), `SignatureVersion`,
`Signature`. **`Message` is a JSON string, so it needs a second parse.** And the important part:
**every event carries IDs and a status, never the record.** A webhook tells you something changed
and hands you a key. It does not hand you the data.

### The consequence that matters most

**Updated 2026-08-03. `workorder.status.update` is now fully usable.** The earlier version of this
section said the event was "useless on its own" because it hands you a `MaintenanceRequestId` that
could not be resolved without `getWorkOrders`. **`getWorkOrders` is on the key now and verified
working** (§2.1, §4.14), so **the ID-only payload resolves.** The event says "maintenance request N
changed to status S," and one read turns that into the actual record: unit, building, problem,
origin, timestamps. The pairing that had to be asked for as a bundle is no longer a bundle, it is
a push notification on top of a read we already have.

Two things that did not change:

- A "work order N completed, here is `CustomerId`" event is **enough to fire a service-call review
  touch** without reading the work-order body at all. That is the review engine's third trigger,
  and it works with or without the read.
- **Every event is still a trigger to re-read, never the state itself.** The webhook carries IDs
  and a status, never the record. Resolving the ID through `getWorkOrders` is exactly the re-read
  posture the rest of this section prescribes, and it satisfies Constitution rule 6 for free.

### Auth, delivery, and the parts the doc gets wrong

- **Signature verification uses an RSA public key** Entrata supplies at provisioning.
  `SignatureVersion` 1 = SHA1withRSA, 2 = SHA256withRSA, base64 in both cases.
- ⚠️ **The doc contradicts itself on what gets signed.** Its prose says the signed string is
  Message + MessageID + Subject (if present) + Type + Timestamp. Its own sample code concatenates
  Message + MessageId + Type + Timestamp + **TopicArn**. **Build to the sample, verify against a
  real message, and expect to try both orderings** before one validates.
- **Delivery is at-least-once**, stated explicitly. **`MessageId` is stable across retries of the
  same message, so dedupe on it.** Deliveries queue and survive receiver downtime.
- **Retry count, backoff, give-up window, ordering, and dead-lettering are all undocumented.**
  Assume no ordering guarantee. **Treat every event as a trigger to re-read, never as the state
  itself.** That posture also satisfies Constitution rule 6 for free.

### Getting subscribed (two gates, both required)

1. **Not self-serve.** No API, no portal UI. Email **apisupport@entrata.com** with the Entrata
   client name, the client subdomain, the event types wanted, and the receiving URL(s). Entrata
   replies with the RSA public key once setup is done.
2. **Liberty has to tick checkboxes too.** In Entrata: Apps → API Access → search the API user's
   company → **Manage** → **Web Service Permission** tab → bottom of the page → select the webhooks
   → Save. Neither gate works without the other.

**Scope is per client/org, per event type, and per property, and the property scope is explicitly
bound to the same property grant as API access.** So adding Aspen and Summerset later extends
webhooks by the same mechanism that extends API access. No second integration to build.

### Receiver requirements

Minimal: **a public HTTPS endpoint that returns 2xx.** Entrata pushes to us, which means **the VPS
outbound-only rule is untouched** (Constitution rule 1). **This belongs on a Cloudflare Worker.**
Plan to handle an SNS-style `SubscriptionConfirmation` message from day one even though the doc
never mentions one; the payload shape is SNS's, so the handshake probably is too.

### Cost

**Not documented anywhere.** Entrata's partner tiers cap **API requests** per month (Incubator is
under 250k) with no statement about whether webhook deliveries count against that. **Treat "free"
as unverified.**

### ⚠️ Do not trust third-party integrator docs on this vendor

Tray.ai and Nango both publish claims that Entrata has **no native webhooks**. They are **wrong and
stale**, most likely written against the gateway Entrata deprecated 2025-04-15. The vendored vendor
doc supersedes them. **General lesson for Entrata: the integrator ecosystem's public documentation
lags Entrata's platform badly.** Go to `developer.entrata.com` (or its portal API) first, and treat
anything else as a rumor to verify.

### The highest-value unresolved question

**Does any event fire on lead or guest-card creation?** Entrata models a lead and an application as
the same object: the lease events carry `ApplicationStageId` and `ApplicationStatusId`, and
`getLeadEvents` treats "Application/Lead ID" as a single identifier. So **`lease.started` might fire
at the guest-card stage.** That is not documented anywhere and it has to be asked. If it does, the
speed-to-lead clock starts on a push instead of a poll, which is the single biggest thing webhooks
could buy this build.

---

## 7. Known bugs and traps

### 7.1 `getUnitsAvailabilityAndPricing` returns wrong square footage

The floorplan-level `SquareFeet` block returns `Avg: 0, Max: 0, Min: 1024`. That is wrong on its
face (a minimum above a maximum). **Use `getFloorPlans` for square footage.** Do not paper over it
with a max/min swap; the values are not trustworthy.

### 7.2 `getLeases` status filter inverts what you expect

No filter gives the current rent roll (52). `leaseStatusTypeIds: "2"` gives 190 Cancelled leases,
which is historical application churn. See §4.11.

### 7.3 Wrong group returns "method not found"

400 / 1404 reads like a bad method name and is almost always a bad path. Check the group map
first.

### 7.4 Mountain Time

See §3. One-hour parse error becomes a one-day error on dates near midnight, and move-in dates
near midnight are exactly the ones that matter.

### 7.5 `getStatus` rate limit

See §6. Tightest limit on the API.

### 7.6 Unit-level occupancy and pet fields are unpopulated, not zero

`maxNumberOccupants: 0` and `maxNumberOfPets: 0` on every unit. Property-level `pets` is
populated. Treat 0 as absent.

### 7.7 Three leases on Notice, two units available

`getLeases` shows 3 Notice leases. `getUnitsAvailabilityAndPricing` shows 2 available units.
**They don't reconcile, and Entrata's availability is the one to trust.** One on-notice unit is
presumably already pre-leased or otherwise withheld. **Do not derive availability yourself from
lease status.** Availability is a computed field with rules we can't see; read it, don't
recompute it.

### 7.8 `getLeaseDetails` on a Cancelled lease returns a stub

Near-empty response, no error. Filter to Current leases before calling.

### 7.9 `getAmenities` and `getSpecials` return 200 with nothing

A 200 is not evidence of data. Check for content, not status.

### 7.10 `getCalendarAvailability` caps the window at 7 days, and the vendor spec says 60

An 8-day-or-wider span returns 400 / code 308. The OpenAPI spec advertises 60. **Live behavior
wins; page a week at a time.** See §4.17. Any doc in this repo still saying 60 days is stale.

### 7.11 A field in the schema is not a field in the data

The sharpest instance is the birth date: `dateOfBirth` / `BirthDate` are declared on `getLeases`
r1, `getLeases` r2, and `getMitsLeases` r1, and **all three return zero birth keys against Liberty's
live data** (§4.20). Entrata omits null fields entirely rather than sending them empty, so **the
absence of a key means "not populated here," not "not supported."** Generalize the lesson: never
promise a feature off a schema scan. Read the method before designing on the field. `getAmenities`
and `getSpecials` (§7.9) are the same failure in a different costume.

⚠️ **The rule runs the other way too, and that direction is the dangerous one.** `getMitsLeases` r1
declares `SocialSecurity` and returns nothing today (§4.16). Because Entrata omits nulls, **"empty"
and "absent" are indistinguishable on the wire**, so a field that has never appeared can start
appearing the moment a PM changes a workflow, silently and without a deploy on our side. **Never
design a data-handling control around a field being empty in one probe.** Handle every field the
schema declares, and strip the ones we must not hold (§8 item 7) whether or not they showed up.

### 7.12 `getMarketingPreferencePickList` rows are not uniformly shaped

Some rows, the `phone` and `postal_mail` ones among them, come back with **no `consentType` key at
all**. Parse defensively; a fixed-shape parser throws on real data. Also see the 60/hour ceiling in
§6 before calling it in any loop.

### 7.13 Cloudflare fronts the API and 403s on some clients' default User-Agent

⚠️ **Build-critical, found 2026-08-05.** A Python `urllib` request on its default User-Agent returned
**HTTP 403 with the bare body `error code: 1010`**, which is a **Cloudflare browser-signature ban and
not an Entrata error at all.** No JSON envelope, no `response.error`, no Entrata code. **The identical
request issued through `curl` succeeded**, which is the tell: the key, the path, the body and the
permissions were all fine and the edge rejected the client.

Two rules follow, and both belong in the client:

- **Set an explicit `User-Agent` header on every request.** Do not ship on a runtime default. This is
  a one-line fix that costs nothing and a multi-hour misdiagnosis if it is missing.
- **Classify a non-JSON 403 body as an edge block, not an auth or permission failure.** Do not retry
  it as though the key were wrong, do not surface it to an operator as "permission denied," and above
  all do not go asking Liberty to tick a checkbox that is already ticked. Attempt the JSON parse
  first; if it fails, the response never came from Entrata and the error vocabulary in §3 does not
  apply to it.

### 7.14 Window caps are per method, so never generalize one

`getCalendarAvailability` r1 caps at **7 days** (§7.10). `getLeads` r1 accepts **730** (§4.10). Same
key, same gateway, two orders of magnitude apart. **Measure the cap on each method you use.** Do not
inherit a number from a neighboring method, and do not trust the vendor spec for it either, since the
spec claims 60 days for the calendar and live behavior says 7. The failure mode is asymmetric: too
wide returns a loud 400 / 308, too narrow just silently costs calls nobody counts.

---

## 8. PII map

Which methods return personal data, and which surprise you.

| Method | Resident PII | What exactly |
|---|---|---|
| **`getLeads`** | **Highest on the key** | First/middle/last name, email, cell phone, personal phone, phone array, addresses, **`birthDate` (present on 36% of lead-customers, §4.20)**, pets, `googleClickId`, `googleClientId`. **Name + address + DOB together is identity-theft-grade; this is the most sensitive response the integration reads.** Note that a historical sweep multiplies the exposure: **787 distinct customers across the full history**, against ~109 in a single month. |
| **`getMitsLeases`** | **High** | Person-level: names, phones, `Residence` address, `Finances`, `GuarantorFor`, `Spouse`, and **`Reference` including `EmergencyContact`**: third-party PII for people who are not even residents. 100 person records vs 52 leases. No DOB and no government ID **in live data**, but ⚠️ **`SocialSecurity` is in its r1 response schema** and could start populating without notice (§4.16, item 7 below). |
| `getLeases` | **High** | Resident names, emails, phones, move-in dates, unit numbers, charge amounts. No DOB in live data (§4.20). Also **pet records including the pet's name and `isAssistanceAnimal`** (§4.21), which is disability-adjacent data, not trivia. |
| `getLeaseDetails` | Medium | Charges tied to a named lease, `name` field on the lease |
| **`getGlTransactions`** | **High, non-obvious** | **Memo fields read like `Firstname Lastname Unit #XXXX-XXX` on every AR Payment line** |
| `getMarketingPreferences` | Medium | Per-customer consent state keyed to a real `customerId`. No names, but it is resident-linked behavioral data and it is the record that says what we're allowed to send |
| `getLeadPickLists` | Employee data | 15 named Liberty leasing agents with IDs. Not resident PII, still personal data. |
| `getWorkOrderPickLists` | Employee data | **12 named Liberty company employees with IDs** (`CompanyEmployees`, §4.15). Same treatment as the leasing agents. |
| `getCalendarAvailability`, `getMarketingPreferencePickList` | None | Configuration and vocabulary only |
| `getProperties`, `getFloorPlans`, `getPropertyUnits`, `getUnitsAvailabilityAndPricing`, `getUnitTypes`, `getAmenities`, `getSpecials`, `getWebsites` | None | Property and unit facts only |

**Implications:**

1. **The financial feed is a PII feed.** Anything ingesting GL data (the monthly reconciliation
   workflow included) is ingesting resident names. It cannot be treated as "just numbers," cannot
   be dropped into a spreadsheet that gets emailed around, and cannot be pasted into a general
   AI context without thought.
2. **Redact at the transport layer, not at the call site.** Never log raw response bodies. The
   single choke point that issues requests is also the right place to strip PII from anything
   headed for a log.
3. **Never echo the API key.** Constitution rule 11. Not in logs, not in error paths, not in this
   binder.
4. NPI made representations about PII handling in the Entrata access application and the executed
   API Access Agreement. Entrata publishes an "API Security Requirements" page and a "Secure PII
   Delivery, Vendor Integration Guide" in the dev portal. Read both before any resident data
   lands in a durable store.
5. `ops.footholdboise.com` is gated with base64 obfuscation, not security. **No real resident data
   goes in this repo, ever**, including in this file.
6. **Store the birthday as month/day and drop the year** (added 2026-08-05, §4.20). The recognition
   engine needs "March 14," not a full date of birth. Truncating at ingest keeps the feature intact
   and removes the field that turns a name-and-address leak into an identity-theft leak. **Do the
   truncation at the same transport choke point as item 2**, so a full DOB never reaches a durable
   store in the first place, rather than being written and later scrubbed.
7. 🚫 **NEVER ingest, persist, or log a Social Security number. Brent's rule, 2026-08-05, and it is
   binding.** NPI has no use for an SSN and does not want the liability of holding one. **This sits
   beside item 6, and it is stricter: item 6 truncates a field, this one drops it entirely.**
   - **The schema exposes it.** A scan of the vendored OpenAPI found an SSN / tax-ID field in the
     **response** schema of `getMitsCollections` r1 (`SSN`), `getVendors` r1 (`taxIdNumber`), and
     **`getMitsLeases` r1 (`SocialSecurity`), which is the one that is actually on our key** (§4.16).
     The other two are unpermissioned today and that is not a control we own.
   - **Live data is clean, and that is not the reason the rule exists.** The 2026-08-05 probe of
     `getMitsLeases` returned **no SSN-like keys at all**. Liberty leaves the field empty, so there
     is no exposure today. **Do not read that as safety.** Entrata omits null fields (§7.11), so an
     empty field and an absent one look identical, and the day a PM changes a workflow the field
     starts arriving with no warning and no deploy on our side.
   - **The control is a transport-layer scrub, not a downstream cleanup.** **The same single request
     choke point that enforces the read-method allowlist (§1 item 3) must strip `SocialSecurity`,
     `SSN`, and `taxId` on read**, before the response is persisted, logged, cached, or passed to an
     AI model. Deny by default: match the field names case-insensitively, drop anything that looks
     like one, and fail safe if the scrub cannot run. A response body that has been written to a
     store or a log is already a breach surface; scrubbing it there is too late.
   - **The same rule governs documents, not just the API.** Aspen's 44 executed leases each carry
     one SSN field (§4.20). **Any lease-document or due-diligence ingestion drops it at extraction.**
     Do not parse it, do not index it, do not put it in a RAG store, do not paste it into a model
     context. The rule is about the number, not about which pipe it arrived through.
8. **`getMitsLeases` widens the blast radius past residents** (added 2026-08-05, §4.16). Its
   `Reference` block carries **emergency contacts**, and `GuarantorFor` and `Spouse` carry people who
   never signed a lease with anyone. They did not opt into anything Foothold does. **Read that method
   for the customer IDs and the household grain; do not build a contact list out of it.**

---

## 9. Open questions for Liberty

> ### ✅ Status, 2026-08-03: the permission asks below are ANSWERED AND GRANTED
>
> **Brent sent them to Alex Costa (alex@libertyassetgroup.com, cc Taylor Grange) on 2026-07-31**, in
> the "Entrata API user for Liberty" thread. **Alex replied 2026-08-03 at 08:47 AM PT: "Glad to hear
> it's working! I just added those permissions too."** All six requested methods landed (§2.1), and
> he answered both operating questions in the same reply (items 14 and 15). Items **1, 4, and 13 are
> closed**; items **14 and 15 are answered** and now carry design consequences rather than
> questions. **Nothing in this section is awaiting Alex except item 16**, which Brent asked on
> 2026-08-03 and which is still open. Do not re-send the closed asks, and do not widen the list
> without reading **"Deliberately NOT asked"** at the end of this section first. Four methods were
> left off on purpose and one was dropped from consideration entirely; the reasons are recorded
> there so nobody helpfully adds them back.
>
> **Update 2026-08-05: all six granted methods are now probed, and two of the answers below changed
> as a result.** Item 14's prediction that `getCalendarAvailability` would return an unmaintained
> calendar was **wrong.** It holds real configuration, including self-guided tour hours seven days a
> week (§4.17). Item 4's premise that `getMitsLeases` carries resident birth dates was **also wrong**:
> it doesn't, `getLeads` does (§4.20). Both items are rewritten below. **No new ask went to Alex on
> 2026-08-05, and item 16 is still the only thing waiting on him.** Two follow-up questions surfaced
> that are worth folding into the *next* natural exchange rather than a fresh email: the 08:00-16:00
> tour window vs the M-F 9-5 office hours (item 17), and whether the Entrata calendar reflects
> actually-booked appointments (item 14).

Grouped by whether they block anything.

**Blocking a designed engine:**
1. ✅ **CLOSED. GRANTED 2026-08-03. Read access to `getWorkOrders` and `getWorkOrderPickLists`.**
   This was the one that cost the most, and it is done. The `maintenance` group landed with both
   read methods on it, `getWorkOrders` is verified returning real production work orders (§4.14),
   and the turn engine and service-call review trigger now have a real PMS data source (§10.2).
   **`getWorkOrderPickLists` is verified working too, as of 2026-08-05** (§4.15): property-scoped
   locations, priorities, statuses, categories, problems and sub-problems, 5 work-order types, 5
   inspection statuses, and **12 named Liberty company employees** who are the assignable techs.
   **Note what did NOT come with the group:
   `sendWorkOrders` and `updateWorkOrders` are still off** (§2.1), which is fine, they are barred
   by Constitution rule 2 anyway. **The `workorder.status.update` webhook (§6.1) is now worth
   subscribing to on its own merits** rather than as half of a bundle: the `MaintenanceRequestId`
   it carries is resolvable today.
2. ❌ **CLOSED. DECLINED BY BRENT 2026-07-31. Do not ask, do not re-raise, do not re-draft the
   email.** The ask would have been "uncheck `sendLeads` and `updateLeads` on the API user" (two
   checkboxes, not four: `sendWorkOrders` and `updateWorkOrders` were never granted, and **were
   still not granted on 2026-08-03 when the rest of the `maintenance` group landed**, §2.1, so
   there was never anything to turn off there). **The email carrying it was drafted 2026-07-30, never
   sent, and deleted on 2026-07-31.** It does not exist anywhere and there is **no pending action
   here.**
   **Brent is accepting the risk and leaving both write permissions enabled**, because the permission
   being on is not what causes a write, our code calling a write method is, and it isn't worth a
   second round-trip with the PM. **Rule 2 does not move an inch:** no writes against a live
   production property, ever, without Brent's written per-property sign-off naming the property and
   the methods. What moves is the mitigation posture, and it moves onto us. See §1 item 3: the
   code-side allowlist is now permanently the only control, so treat it accordingly.

**Blocking or shaping a feature:**
3. ❌ **NOT ASKED, on purpose. `getCustomers`, `getExpiringLeases`, `getPropertyMedia`.** All three
   were considered and all three were left off the 2026-07-31 email to keep the ask short and
   answerable. **`getCustomers` is dropped from consideration entirely**, not just deferred: its
   response schema has no birth-date field, so it cannot serve the birthday-card use case even if
   Liberty unblocked it, and resident data already arrives through `getLeases` and `getLeads`.
   `getExpiringLeases` is redundant (`getLeases` with no filter carries lease end dates).
   `getPropertyMedia` is a convenience for ILS photo scoring, not urgent. Add any of them to a
   future ask only if something concrete starts depending on them.
4. ✅ **CLOSED. GRANTED 2026-08-03, PROBED 2026-08-05, and the premise of the ask turned out to be
   wrong.** `getMitsLeases` was asked for because the recognition engine needs a birth date on a
   *resident*, and the spec shows a `BirthDate` field on it. **The method works, and it returns no
   birth dates.** Zero, on live data, and the same on `getLeases` r1 and r2 (§4.16, §4.20). The
   field is in the schema and empty in Liberty's records.
   ✅ **The good news is bigger than the bad news: `getLeads` r1 DOES carry `birthDate`** (§4.10).
   **DOB is captured at the lead/application stage and never propagates to the lease.** So the
   birthday source exists, it has been on the key since the first grant, and **no further ask to
   Liberty is needed.** Do not ask for `getLeaseDocuments` / `getLeaseDocumentsList` on birthday
   grounds (§5). ⚠️ **But it is a partial feed, and the 2026-08-05 third pass measured how partial:
   36% of lead-customers carry a birth date, and coverage of CURRENT residents is 65%** (§4.20). An
   earlier revision of this item claimed "122 of 122," which was an artifact of counting non-empty
   values against occurrences of a field Entrata omits when null. **The Welcome Profile drops from
   primary source to permanent fallback** for the ~31 current residents the feed cannot reach, not to
   a temporary bridge. Full design path, coverage table, and the month/day-only PII recommendation are
   in §4.20.
   **`getMitsLeases` still earned its place on the key**, just not for the reason it was asked for:
   it is the only method that returns the household at person grain (100 people against 52 leases,
   with guarantors and roommates) and it supplies the `Customer ID` that makes
   `getMarketingPreferences` usable (§4.19).
5. ❌ **NOT ASKED, on purpose. `getLeadEvents`.** Not permissioned, and left off deliberately: the
   need is already covered, because `getLeads` r1 takes `eventDateFrom` / `eventDateTo` /
   `eventTypeIds` and returns the matching events inline (§2.1). It would mean fewer calls and
   cleaner deltas, which is not worth spending a line of the ask on. Do not add it back without a
   reason `getLeads` cannot cover.
6. **Financial group is partial.** `getBudgets`, `getBudgetActuals`, `getBankAccounts` are absent.
   Is budget data available at all through the API? FlightDeck's NOI-vs-budget tile depends on the
   answer, or falls back to NPI's model.
7. **Zero amenities and zero specials.** Data-entry gap or genuinely nothing configured? If
   Liberty runs concessions, where do they live and how does Foothold learn about them?
8. **Webhooks (§6.1).** No longer a research question, it's a provisioning question, and it has two
   gates. Entrata's side: email apisupport@entrata.com with client name, subdomain, event types,
   and receiving URLs. **Liberty's side: Apps → API Access → our company → Manage → Web Service
   Permission tab → bottom of the page → select webhooks → Save.** Both are required. **Also ask
   the one thing the vendor doc does not answer: does any event fire on lead or guest-card
   creation?** Entrata treats a lead and an application as the same object, so `lease.started` may
   fire at the guest-card stage. Nobody has confirmed it.

**Operational:**
9. **Office hours:** confirm M-F 9-5 with no weekend coverage is current and not seasonal. The
   whole after-hours speed-to-lead argument rests on it.
10. **Property scope:** what does adding Aspen and Summerset to this key require, and what's the
    lead time? Same question for North River when it moves onto Entrata. **Webhook scope rides
    along:** it is bound to the same property grant (§6.1), so one ask covers both.
11. **The 15 leasing agents:** which ones cover Owyhee, and do they want lead routing mapped to
    specific agents or to a general pool?
12. **Abandoned-application status IDs:** check the 62 `LeadStatuses` from `getLeadPickLists`
    first (§4.9). Only ask Liberty if that list doesn't answer it.

**Sent 2026-07-31, answered by Alex 2026-08-03** (numbered from 13 so the numbering above stays
stable):

13. ✅ **CLOSED. GRANTED 2026-08-03, ALL THREE PROBED AND WORKING 2026-08-05.**
    `getCalendarAvailability` (group `properties`, for tour-slot reads) **and**
    `getMarketingPreferences` **+** `getMarketingPreferencePickList` (group `communications`, for
    per-channel consent state). All three are on the key, the `communications` group is no longer
    absent (§2.1), and all three returned real data:
    - `getCalendarAvailability` → real tour configuration, **7-day window cap**, self-guided tours
      configured seven days a week (§4.17). Read item 14, which is now rewritten.
    - `getMarketingPreferencePickList` → 24 preference rows, LEAD and RESIDENT recipient types, four
      channels including postal mail. **Tightest hourly limit on the key, cache it** (§4.18).
    - `getMarketingPreferences` → per-customer consent with `optIn` and `isTransactional`, verified
      against a real resident. **The `customerIds` requirement is not a blocker** despite the
      `customers` group being absent: the IDs come from `getMitsLeases` (§4.19).
14. ✅ **ANSWERED 2026-08-03. How do Liberty's agents book tours today? MANUALLY.** Alex: agents take
    a call or an email and **schedule the tour themselves**, because each agent manages a portfolio
    and has to arrange their own drive time between properties, or hand out lock box information for
    a self-guided tour.
    🔄 **Design implication, REWRITTEN 2026-08-05 after the probe. The earlier version of this item
    was wrong and has been replaced.** It said `getCalendarAvailability` was "permissioned over a
    calendar they don't actually use," that we should "NOT assume it returns meaningful slots," and
    that "the likely outcome is that it returns an empty or unmaintained calendar." **It returns
    real, maintained configuration** (§4.17): 30-minute appointments, a 120-minute minimum lead time,
    agent-guided availability Mon-Fri 08:00-16:00 MST, and **self-guided tour availability across all
    seven days, 06:00-18:00 MST, one at a time.**
    **This corroborates Alex rather than contradicting him.** His lock-box comment said self-guided
    tours are part of Liberty's repertoire, and Entrata is configured for exactly that. **What is
    manual is the booking, not the configuration.**
    ⚠️ **The residual point from the old warning is still true and still binding:** Liberty schedules
    by hand, so **Entrata's calendar may not reflect appointments actually booked.** Read it as "what
    this property is set up to offer," never as "this slot is free." Tour booking stays a human,
    out-of-band handoff until someone validates that a real appointment shows up in this feed. That
    validation is worth doing; it is the difference between offering a prospect a window and offering
    them a slot.
    **Self-guided lock box tours are the standout finding.** 84 hours a week of tour capacity that
    consumes no agent drive time, against 40 hours of leasing office (§10.4), and Entrata already
    knows the hours. That is the one tour type an after-hours agent could plausibly arrange.
    **Brent's forward-looking note, 2026-08-03 (explicitly NOT critical and NOT a blocker):** he
    wants to explore self-service tour scheduling later, Calendly-style, with drive-time buffers
    blocked on either side of each slot and an AI agent nudging leads toward adjacent slots for
    routing efficiency. Record it as a future direction, do not sequence work against it, and do not
    let it hold up anything in the current build.
15. ✅ **ANSWERED 2026-08-03. If a prospect wants to talk to a person, route to Liberty's direct
    leasing/manager team by EMAIL.** Alex: **`adrienne@libertyassetgroup.com` is the correct address
    today.** They use Google/Breakroom chats internally but **prefer direct email** for this. **The
    assistant manager / leasing team is being restructured and more addresses are coming later**, so
    treat the recipient as configuration, not a constant: one env var or config row per property,
    changeable without a deploy. The two-leg escalation design itself was already settled; this
    closes the channel question. See §10.4 for how the escalation interacts with office hours.

**Asked 2026-08-03, still open:**

16. ✉️ **ASKED 2026-08-03, awaiting Alex. Does each property have an application link so tenants
    can apply anytime?** Brent asked this in his 2026-08-03 02:41 PM PT reply. **This is the same
    question as the standing open item for the per-property ProspectPortal apply URL** (the apply
    link is not discoverable through the API, no hosted-application URL field exists anywhere in
    the spec, so it is a per-property config item collected from Liberty). Getting Owyhee's URL
    confirms the pattern and unblocks building the application handoff; Aspen and Summerset slot
    into the same pattern when they onboard. **This is the only item in §9 currently waiting on
    Alex.**

**Surfaced 2026-08-05 by the probe, NOT yet asked (fold into the next natural exchange, do not send
a fresh email for them):**

17. ❓ **Which hours govern a real tour: the calendar's 08:00-16:00 or `PropertyHours`' M-F 9:00-5:00?**
    `getCalendarAvailability` returns agent-guided availability of 08:00 to 16:00 MST (§4.17), while
    `getProperties` returns office hours of 9:00 to 5:00 (§2). Same eight hours, shifted one hour
    earlier. Both are API-readable and they disagree, so the agent cannot silently pick one. Low
    stakes, but it is exactly the kind of thing that produces a prospect standing at a locked door.
    **Related and worth asking in the same breath: does the Entrata calendar reflect appointments
    Liberty actually books, or only the availability template?** Item 14 turns on the answer.

### Deliberately NOT asked (recorded so nobody re-adds them without thinking)

| Left off | Why |
|---|---|
| `getLeadEvents` | The `getLeads` r1 `eventDateFrom` / `eventDateTo` / `eventTypeIds` filters cover the need (§2.1). Item 5. |
| `getExpiringLeases` | `getLeases` with no filter carries lease end dates, so expiries are computable. Item 3. |
| `getPropertyMedia` | Not urgent. ILS photo-completeness scoring is a convenience, not a dependency. Item 3. |
| The testimonial methods (`getCustomerTestimonials`, `sendCustomerTestimonials`, `updatePropertyResponse`) | Not urgent. Foothold's review targets are Google and Apartments.com; the Entrata testimonial object is an Entrata-internal surface. |
| `getCustomers` | **Dropped from consideration entirely**, not deferred. Its response schema has **no birth-date field**, so it would not serve the birthday-card use case even if unblocked, and resident data already arrives via `getLeases` and `getLeads`. Item 3. **Reinforced 2026-08-05:** the birthday need is met by `getLeads` (§4.20) and the customer IDs it would have supplied come from `getMitsLeases` (§4.19). Nothing depends on it. |
| `getLeaseDocuments`, `getLeaseDocumentsList` | **Added to this table 2026-08-05, before anyone asks.** They are in the `leases` group and **not on the key**, and the executed lease is the obvious next place to hunt for a birth date once `getMitsLeases` comes back empty. **Don't.** `getLeads` carries the DOB (§4.20), so the ask has no purpose and would mean a third round-trip to Liberty for a document surface with real PII in it. |
| "Uncheck `sendLeads` and `updateLeads`" | ❌ **Declined by Brent 2026-07-31.** Item 2. Closed, not pending. |

---

## 10. Design implications

This is the section that matters. Each item is a constraint the finalized design has to answer.

### 10.1 Zillow is 80% of top-of-funnel, and that's the whole Foothold thesis in one number

87 of 109 July leads came from Zillow. Website was 11. Everything else was noise.

- **Foothold's premise is demand-side value-add: own the demand instead of renting it.** This
  number is the before picture, measured, on a comparable Boise Class C building. It's the
  denominator for every "brand premium" and "direct demand" claim in the Overview and the KPI
  scorecard.
- **10% direct is the floor to beat.** Those 11 website leads came from a building with no
  Foothold brand, no relocation content, no waitlist, and no Insider list. Whatever the demand
  engine produces has to be measured against that, not against zero.
- **Single-channel concentration is a real operating risk, not just a marketing story.** At 80%,
  a Zillow pricing change or ranking-algorithm change moves occupancy directly. That's the
  business case for the ILS playbook and the direct funnel stated in numbers a partner will
  accept.
- **Design the lead intake for ILS-shaped leads.** A Zillow lead arrives as name, email, phone,
  desired floorplan, target move-in date. It does not arrive with conversational context. The
  agent's first message has to work from thin input, and the speed-to-lead clock starts at an
  API-visible `createdOnDateTime`, which we can read on the poll.
- **Attribution today has to be reconciled, not written.** Tagging a lead as Foothold-originated
  means `sendLeads` or `updateLeads`, both blocked. So the near-term design keeps Foothold
  attribution in GHL and reconciles against `getLeads` by email and phone. Build the reconciler
  now; it stays useful even after writes are authorized, as the audit path.

### 10.2 `getWorkOrders` landed 2026-08-03, so both engines have a real PMS maintenance feed

**The constraint this section used to describe is lifted.** Liberty granted `getWorkOrders` and
`getWorkOrderPickLists` on 2026-08-03 and `getWorkOrders` is verified returning real production
work orders (§4.14). Covered in detail in §5. The short version for a designer:

- Turn engine: notice detection works as before (`getLeases` Notice status, `AvailableOn` /
  `MakeReady` dates), and **make-ready progress is now readable too**. "Make Ready" comes back as a
  `maintenanceProblem` with a `maintenanceStatus`, so the engine can watch a turn move to Completed
  rather than waiting on a person. **Residual caveat: keep the Liberty confirmation step.** It is
  now a check on the feed, not the only signal, and it stays until a full turn has actually been
  observed end to end through this data.
- Review engine: **all three triggers work.** Move-in +2 weeks and renewal come from `getLeases`;
  the work-order-complete trigger now fires off real work-order data. **Residual caveat: filter on
  `maintenanceRequestFrom`.** System-generated "Entrata PaaS" records (make-readies, for one) are
  not resident service calls and must never trigger a "How'd we do?" text to a resident. The
  physical service-call card the tech leaves on site stays in the design as the offline path.
- Flow F escalation: **unchanged.** Service tickets route to Liberty by email (now
  `adrienne@libertyassetgroup.com`, §9 item 15), not by `sendWorkOrders`. **Two independent blocks
  still stack here:** `sendWorkOrders` is barred by Constitution rule 2 *and* is still not
  permissioned even though its group is now on the key (§2.1). Even if the rule were lifted
  tomorrow, the method would still 1403. Reading work orders creates no path to writing them.
- **Watch for parent/child.** `parentMaintenanceRequestId` is populated (§4.14), so "is this ticket
  done" and "is this turn done" are not the same query. Do not count child records as independent
  tickets in any turn-progress or volume metric until the grain is understood.
- **The ID vocabulary is readable too, as of 2026-08-05.** `getWorkOrderPickLists` (§4.15) returns
  the property-scoped locations, priorities, statuses, categories, problems and sub-problems behind
  every work-order field, plus **12 named Liberty employees who are the assignable techs**. That
  turns "Make Ready" and "Completed" from strings we matched on into IDs we can key off, and it
  gives FlightDeck a real attribution dimension for turn work. Pin the lists the way §10.9 says to
  pin the lead pick lists. **This is still reading; nothing here creates or assigns a work order.**
- **Keep the maintenance source pluggable, and still do not build a parallel maintenance record in
  Supabase.** Entrata is the system of record for work orders; cache and derive from it. The
  permission can be revoked as easily as it was granted, so the adapter should degrade to the
  human-confirmation path rather than to a second source of truth.

### 10.3 Amenities and specials do not exist in the PMS

`getAmenities` and `getSpecials` both return valid-but-empty. Consequences:

- **The site's amenity content is our content**, curated in `foothold-web`, with a named owner and
  a review date. It cannot be "synced from the PMS" because there is nothing to sync.
- **The agent needs a property-facts store** for questions like "is there a dog park," "is parking
  covered," "is it pet friendly." Pets are the exception: the property-level `pets` block in
  `getPropertyUnits` is populated ($300 deposit, $50/mo, cats and dogs), so pet answers can be
  API-sourced and should be.
- **Concessions are an out-of-band conversation with Liberty.** If they run a special and it isn't
  in Entrata, the agent will quote list price and be wrong. Either get specials entered in
  Entrata, or build an explicit concession field the PM can set, and make the agent's pricing
  answer read it.
- Note the asymmetry against Constitution rule 6: price and availability get a fresh read, but
  amenity answers come from a human-curated store with no freshness guarantee. That store needs
  its own staleness policy, or the agent will confidently describe a laundry room that closed.

### 10.4 Office hours are M-F 9 to 5, no weekend coverage

`PropertyHours` says the leasing office is open 40 hours a week. Leads arrive 168 hours a week,
and the one sampled lead came in at 3:05 AM Mountain.

- **76% of the week has no human response.** That is the speed-to-lead gap, and it's the single
  clearest place the agent creates measurable value on day one. It's also a number a partner
  understands without explanation.
- **Weekend is the sharpest edge.** ILS lead volume skews to weekends and evenings, exactly when
  Liberty is closed. Any response-time KPI should be reported split by in-hours and out-of-hours,
  because the aggregate hides the whole story.
- **Do not promise what the office can't deliver.** The agent can respond at 3 AM. It cannot book
  a Saturday tour into an office that's closed. Tour scheduling has to read `PropertyHours` and
  offer only real slots, and it must read them per property rather than hardcoding, because Aspen
  and Summerset will differ.
- **Hours are only half the constraint. Agent drive time is the other half.** Alex confirmed
  2026-08-03 (§9 item 14) that **Liberty books tours manually**: an agent takes a call or email and
  schedules it themselves, because each agent covers a portfolio and has to arrange drive time
  between properties, or hands out lock box info for a self-guided tour. So a slot inside office
  hours is not automatically a bookable slot.
- 🔄 **`getCalendarAvailability` DOES hold real configuration. Corrected 2026-08-05.** This bullet
  used to say the method "must not be assumed to reflect any of this" and that Liberty "does not
  appear to run scheduling through Entrata's calendar at all." **The probe says otherwise** (§4.17):
  30-minute appointments, a **120-minute minimum lead time on both tour types**, agent-guided
  availability **Mon-Fri 08:00-16:00 MST**, and **self-guided tour availability all seven days,
  06:00-18:00 MST, one at a time.** Read the calendar; don't hardcode hours and don't assume it's
  empty. ⚠️ **Still true: it is a template, not a booking ledger.** Liberty books by hand, so an open
  window is not proof the slot is free. Offer windows, confirm with a human, and note that the
  calendar's 08:00-16:00 disagrees with `PropertyHours`' 9:00-5:00 (§9 item 17).
- **Self-guided lock box tours are the exception that changes the after-hours math.** They don't
  consume agent drive time, Entrata is already configured for them across all seven days, and that
  is **84 hours a week of tour capacity against 40 hours of leasing office.** The 76%-no-human gap
  above is a response gap, not necessarily a tour gap. This is the one tour type an after-hours
  agent could plausibly arrange, and it is the most useful thing the 2026-08-05 probe found for
  leasing.
- **The handoff rule interacts with hours.** Constitution rule 10 gives ownership the conversation
  through tour-booked or application-submitted, and LAG everything after. A Friday-night lead that
  hands off at 10 PM is handing off to nobody until Monday. Either the agent holds the
  conversation until the office opens, or the handoff needs an explicit business-hours queue.
  Decide this deliberately; it's a real design fork, not an implementation detail. **The human-
  escalation channel is settled** (email to `adrienne@libertyassetgroup.com`, §9 item 15), but an
  email sent at 10 PM Friday is still read Monday morning, so the queue question is unaffected by
  having the address.

### 10.5 Availability polling is not quota-constrained, so pick the cadence for other reasons

36,000/hour on availability and 64,320/hour on leases means the poller could run every minute and
not notice. So the cadence question is settled by:

1. Constitution rule 6 (fresh read at the moment of commitment) does the correctness work, not the
   poll.
2. The declared ~8-12K calls/month in the access application is a good-faith number worth
   honoring.
3. The serial gate means every extra poll competes with real work.

Recommended posture: keep the 10-15 minute baseline for cache freshness, always live-re-check at
commitment, and spend the headroom on the daily `getLeases` and `getGlTransactions` pulls instead
of on faster availability polling.

**Settled 2026-07-31: webhooks do not change this.** Entrata publishes 5 webhook events and **none
of them covers availability or pricing** (§6.1). The poll is not a placeholder waiting to be
replaced, it is the design. What webhooks would add is a lease/application event stream and a
work-order status event, neither of which is what the poller exists for. **Stop treating the
polling cadence as provisional.**

### 10.6 Loss-to-lease and the full rent roll are computable today, with no PM report

`getLeases` (no filter) gives 52 in-place leases with `scheduledCharges`. `getFloorPlans` and
`getUnitTypes` give market rent by floorplan and term. `getGlTransactions` gives 90 GL accounts of
actuals with unit-level tagging.

- **FlightDeck's asset-management tiles are buildable now**, against real data, before any write
  permission exists. In-place vs market by unit, loss-to-lease in dollars and percent, renewal
  exposure by month from lease end dates, and NOI actuals from the GL.
- **NOI vs budget is half-blocked.** GL actuals yes, `getBudgets` no. Budget comes from NPI's
  model until Liberty enables it.
- **This is the fastest path to something Brent and a partner can look at.** It requires no
  writes, no agent, no Twilio, no GHL. It's pure read, and it's the piece of the system with the
  least dependency risk. Consider sequencing it earlier than the phased build order implies.
- The GL shape is a drop-in for the existing NPI monthly reconciliation workflow, with the PII
  caveat in §8.

### 10.7 `googleClickId` already exists in the lead record

Entrata's customer object carries `googleClickId` and `googleClientId`. Paid-search attribution
plumbing exists in the PMS.

- **Do not build a parallel attribution system.** When Foothold runs paid search and writes are
  eventually authorized, GCLID rides through Entrata to the lease, which is the full
  click-to-lease loop most operators never close.
- **Today it's read-only and only populated by Liberty's own funnels.** Populating it for
  Foothold-originated leads needs `sendLeads`, which is blocked. So the design should carry the
  field end to end (site → GHL → outbox intent) and simply not deliver the last hop yet.
- Building the outbox intent payload with the field already in it costs nothing now and saves a
  schema migration later.

### 10.8 Only Owyhee Park is on the key, so nothing can be built against Aspen or Summerset

- **Everything validated in the next phase is validated against a building NPI does not own and
  cannot write to.** That's a fine test bed for reads and a poor one for the full loop. Be honest
  in status reporting about which parts are proven and which are only designed.
- **`propertyId` is a parameter everywhere. Never hardcode `1311115`.** Not in config defaults,
  not in KV key construction, not in test fixtures that leak into prod paths. `ENTRATA_PROPERTY_IDS`
  is already the right env var shape; keep it a list.
- **Onboarding a property is a Liberty-side permission change plus a `getProperties` re-read**, not
  a code change. Build the adapter so a new property ID is configuration.
- **Aspen does not get writes automatically.** Constitution rule 2 is per-class: the day Aspen
  onboards it is read-only too, until Brent signs off in writing naming the property and the
  methods. Do not design a flow that quietly becomes live-writing at onboarding.
- **The permission surface is org-wide, the authorization is one property.** `getWebsites` proves
  the key can see 98 Liberty properties. The property-ID allowlist (§1) is what keeps a bug from
  reading, or eventually writing to, somebody else's building.

### 10.9 Pin the ID vocabulary now, while reads are the only thing allowed

`getLeadPickLists` returns 155 event types, 62 lead statuses, 6 event results, 15 leasing agents,
17 lead sources. Constitution rule 4's deferred logging obligation means every 1:1 touch queues an
outbox intent that gets replayed into Entrata once writes are authorized.

**An outbox full of intents built against invented IDs is an outbox that has to be rewritten
before it can be replayed.** Capture the real pick lists, pin them in the repo or in Supabase,
build the intent payloads against real `eventTypeId` and `leadStatusId` values, and version them,
since a PM can add a status at any time.

### 10.10 Pricing display should mirror what Liberty exposes

24 lease terms exist. Only the 12-month term is `isWebVisible`. Six unit types map onto four
floorplans, and market rent varies by term.

Quote the web-visible term by default. Where a prospect asks about a different term, the data
exists, but showing a term Liberty deliberately doesn't publish creates a discrepancy between the
Foothold site and Liberty's own site for the same building. Match, don't improvise.

### 10.11 The recognition engine has a real birthday source, and a real consent gate

Added 2026-08-05. Two probes landed on the same engine from opposite directions.

- **DOB is readable today, from `getLeads`, not from the lease** (§4.20). Earlier revisions of this
  file said `getMitsLeases` was the source, and before that that no Entrata birthday source existed
  and the Welcome Profile was the only path. **Both are superseded.** `getLeases` r1, `getLeases` r2
  and `getMitsLeases` all return zero birth keys; `getLeads` returns real ones. **Entrata captures DOB
  at application and drops it on the floor at lease.**
- ⚠️ **The feed is partial, and the 2026-08-05 sweep measured it. This corrects a number that was in
  this bullet.** It used to say "122 of 122 lead records in a 3-month window carried a populated
  `birthDate`," which read as complete coverage and was not: Entrata omits null fields, so that count
  never included the customers who had no birth date. **Real numbers: 286 of 787 distinct
  lead-customers carry a `birthDate` (36%), and 57 of the 88 current residents do, which is 65%
  coverage of the rent roll.** Of the 31 misses, **18 are residents with no `getLeads` record at all**
  and **13 have a record with an empty field.**
- **So the recognition engine's birthday feed is a lead-side read joined to the resident on
  `customerId`**, not a resident-side read. ✅ **That join is proven**, same ID namespace on both
  methods, 70 of 88 matched with no fuzzy matching (§4.20). ✅ **The historical sweep is 3 calls**, not
  a crawl: `getLeads` takes 730-day windows, so 5.5 years costs three requests. **Keep the Welcome
  Profile for the remaining third permanently**, and **state the 65% honestly in any status report**
  rather than implying the feed is complete.
- ⚠️ **Store month and day. Drop the year.** §8 item 6. The feature needs a date, not an identity
  document, and the year is the part that turns a leak into a serious one.
- **Consent is now checkable before a touch goes out** (§4.19). `getMarketingPreferences` returns
  `optIn` and `isTransactional` per customer per channel, including **postal mail**, which is most
  of what the recognition engine does. **Read consent before sending, and treat a missing preference
  as "don't send" rather than "no objection."** The pick list that decodes it is cached, not called
  per resident (§4.18, and mind the 60/hour ceiling in §6).
- **Reading consent is not recording consent.** Writing a preference back to Entrata would be a
  write, and writes are barred by Constitution rule 2 (§1). If Foothold ever collects an opt-in of
  its own, it lives in GHL and queues an outbox intent, exactly like lead attribution in §10.1.

### 10.12 The pet-gift program has a real data source, and a fair-housing filter it must respect

Added 2026-08-05. `pet-gift.html` has been designed against an assumed data gap, on the premise that
knowing which residents have pets would take a Welcome Profile or a PM spreadsheet. **It doesn't.**
Pet records are on the key today at individual-animal grain, with names (§4.21).

- **The feed is `getLeases`, joined on `customerId`.** 30 of 52 leases at Owyhee have pets, 34 pet
  records, each with `name`, `petType`, `breed`, `weight`, and the rest. **The pet's name is the
  whole point of the gift**, and it is the field `getMitsLeases` does not have, which is why
  `getLeases` is the source and not the MITS view.
- **This is the same shape as the birthday feed in §10.11 and should be built as one job**, not two.
  Both are a read joined to the resident on `customerId`, both feed the recognition engine, both are
  consent-gated through `getMarketingPreferences` (§4.19), and postal mail is a first-class consent
  channel there (§4.18). One recognition-data pull, two touch types.
- ⚠️ **`isAssistanceAnimal` is a hard filter, not a preference.** Assistance and service animals are
  not pets under fair housing. **They come out of the pet-gift list and out of every pet-themed
  campaign**, and a missing or unparseable flag excludes the record rather than including it.
  Singling out a resident's assistance animal is a fair-housing exposure for NPI and for Liberty, not
  a question of tone.
- **Unlike the birthday feed, coverage has no historical hole.** DOB has to be swept out of the lead
  funnel because it never reaches the lease (§4.20); pets are on the current lease, so a single
  `getLeases` read covers every resident who has one. No backfill, no Welcome Profile fallback.
- **The tech-facing use is separate and also real.** `getWorkOrders` carries `petInfo` (§4.14), which
  belongs on whatever a technician sees before entering a unit. That is an operations feature, not a
  marketing one, and it is not consent-gated because it is not a message to a resident.
- **Property pet *policy* still comes from `getPropertyUnits`** (§4.3). Do not answer "is this
  building pet friendly" out of the resident pet records, or "does this resident have a dog" out of
  the policy block.

---

## 11. Provenance

| Date | What |
|---|---|
| 2026-07-20 | API Access Agreement fully executed. Key issued. |
| 2026-07-28 | Auth shape established (gateway + `X-Api-Key` + POST + JSON envelope). Full OpenAPI spec captured. |
| 2026-07-30 | Subdomain confirmed `libertyassetgroup`, org 19530, via `getAccessibleClients`. Web-service permissions found to be unchecked (only 7 plumbing services). Owyhee Park confirmed as a **live** building, not a test company. |
| 2026-07-31 | Liberty enabled 22 services, **two writes included** (`sendLeads`, `updateLeads`). Constitution rule 2 broadened to all live production properties. **This probe.** 22 services permissioned, 16 returning data. |
| 2026-07-31 | **Full `getAccessibleServices` roster captured** and written into §2.1. Closes the "22 vs 16" accounting. Corrects the record: `sendWorkOrders` and `updateWorkOrders` were **never** permissioned, and the `maintenance`, `customers`, and 9 other groups are absent from the key in their entirety. `getLeadEvents` is not permissioned either. **[Partly superseded 2026-08-03: the `maintenance` and `communications` groups are now ON the key. `customers`, `getLeadEvents`, and the maintenance writes are still off.]** |
| 2026-07-31 | **Webhook research.** Entrata's dev-portal Webhooks page found and vendored verbatim to `entrata-webhooks-2026-03-04.md`. New §6.1. **5 events, no availability event, so the polling design survives unchanged.** Replaces the earlier "unprobed, could delete most of the polling design" speculation in this file and in `system-design.html`. |
| 2026-07-31 | **Brent declined the write-permission cleanup** (§1 items 2 and 3, §9 item 2). The ask to have Liberty uncheck `sendLeads` and `updateLeads` **will not be made.** Both stay enabled on the key, permanently and by choice, on the reasoning that the permission is not what causes a write, our code calling a write method is. **Constitution rule 2 is unchanged:** read-only against any live production property, no writes without Brent's written per-property sign-off. **There is now no vendor-side guard and there never will be**, so the code-side deny-by-default read-method allowlist at the single request choke point is the sole control, forever. Do not re-raise the ask. |
| 2026-07-31 | **The 2026-07-30 retraction draft was deleted.** It was never sent and it no longer exists. Every doc describing it as "sitting unsent in Gmail drafts" was stale and has been corrected. **There is no pending email and nobody should recreate one.** |
| 2026-07-31 | ✉️ **The permission asks went out.** Brent **sent** an email to Alex Costa (cc Taylor Grange) in the "Entrata API user for Liberty" thread asking for `getWorkOrders` + `getWorkOrderPickLists` (maintenance), `getCalendarAvailability` (properties), `getMitsLeases` (leases), and `getMarketingPreferences` + `getMarketingPreferencePickList` (communications), plus two operating questions: how Liberty's agents book tours today, and how a prospect who wants a person should get in front of Liberty's team. **In flight, awaiting his reply.** Deliberately left off, with reasons recorded in §9: `getLeadEvents`, `getExpiringLeases`, `getPropertyMedia`, the testimonial methods, and `getCustomers` (dropped outright, no birth-date field). **[Answered and granted 2026-08-03, see below.]** |
| 2026-08-03 | ✅ **The second permission grant landed. `getAccessibleServices` returns 28 services, up from 22.** Probe run read-only from the VPS. **All six requested methods were granted, exactly the six asked for on 2026-07-31**: `getWorkOrders` + `getWorkOrderPickLists` (`maintenance`), `getCalendarAvailability` (`properties`), `getMitsLeases` (`leases`), `getMarketingPreferences` + `getMarketingPreferencePickList` (`communications`). **The `maintenance` and `communications` groups are no longer absent.** Every claim in this file that they were has been corrected. §2.1 roster and accounting rewritten: 28 = 22 previous + 6 new reads. Five of the six are permissioned but **not yet individually probed**. |
| 2026-08-03 | 🚫 **Writes are UNCHANGED: still exactly two, `sendLeads` and `updateLeads`.** `sendWorkOrders` and `updateWorkOrders` are **still not on the key even though the `maintenance` group now is.** Their absence used to be a side effect of the whole group being absent; the group landed and they did not, so **the maintenance writes are now a deliberate per-method exclusion on Liberty's side.** Stronger fact, same posture. **Constitution rule 2 is unchanged and undiluted:** read-only against any live production property, no writes without Brent's written per-property sign-off. The code-side deny-by-default read-method allowlist at the single request choke point remains the only control that exists or ever will. **Brent's 2026-07-31 decision not to ask Liberty to remove `sendLeads`/`updateLeads` still stands. The retraction email drafted 7/30 was deleted, never sent, and must never be recreated.** |
| 2026-08-03 | ✅ **`getWorkOrders` verified FUNCTIONAL, not merely permissioned.** Read-only call (group `maintenance`, version `r1`, `propertyId` `1311115`) returned **HTTP 200 with real production work orders**: `maintenanceRequestId` 3435 and 3436, `buildingName` "3030", `unitNumber` "3030-201", `maintenanceStatus` "Completed", `maintenanceProblem` "Make Ready", `maintenanceRequestFrom` "Entrata PaaS", timestamps in MDT, and a populated `parentMaintenanceRequestId` (**work orders carry parent/child structure**). New §4.14. **§10.2 flipped from blocked to resolved:** the turn engine and the service-call card have a real PMS maintenance feed. **The §6.1 webhook note was corrected:** `workorder.status.update`'s ID-only payload **is** resolvable now, so the event is a genuine push trigger rather than an ID that goes nowhere. |
| 2026-08-03 | ✉️ **Alex Costa answered the two operating questions** (email 08:47 AM PT, "Glad to hear it's working! I just added those permissions too"). **(1) Tours are booked MANUALLY**: agents take a call or email and schedule it themselves, because each agent manages a portfolio and must arrange drive time, or hands out lock box information for a self-guided tour. **Design implication recorded in §9 item 14 and §10.4: `getCalendarAvailability` is permissioned, but Liberty does not appear to run tour scheduling through Entrata's calendar at all, so do NOT assume it returns meaningful slots. Validate it against real data first.** **(2) Human escalation goes by EMAIL to Liberty's direct leasing/manager team**, `adrienne@libertyassetgroup.com` today. They use Google/Breakroom chats but prefer direct email. The assistant manager / leasing team is being restructured and **more addresses will come later**, so treat the recipient as per-property config, not a constant. |
| 2026-08-03 | ✉️ **Brent's reply, 02:41 PM PT.** Two things. **(1) Future direction, explicitly NOT critical and NOT a blocker:** he wants to explore self-service tour scheduling later, Calendly-style, with drive-time buffers blocked either side of each slot and an AI agent nudging leads into adjacent slots for routing efficiency. Recorded in §9 item 14; do not sequence work against it. **(2) New open question, still unanswered:** does each property have an application link so tenants can apply anytime? This is the standing per-property **ProspectPortal apply URL** item, now §9 item 16, and it is **the only thing in §9 currently awaiting Alex.** |

| 2026-08-05 | ✅ **All five remaining new methods PROBED AND VERIFIED. Nothing on the key is unprobed any more.** Read-only, serial, from the VPS; all HTTP 200. **`getWorkOrderPickLists`** (§4.15): property-scoped `Locations` (with nested `unitTypes`), `Priorities`, `StatusTypes`, `Categories`, `Problems`, `SubMaintenanceProblems`, 5 `WorkOrderTypes`, 5 `InspectionStatuses`, and **12 `CompanyEmployees`** (Liberty's assignable maintenance staff). **`getMitsLeases`** (§4.16): MITS format, **100 `Tenant` records against 52 `LA_Lease`**, so roommates and guarantors arrive as first-class records with `GuarantorFor`, `Spouse`, and `Reference`/`EmergencyContact`; all `Identification` IDTypes are internal Entrata IDs, **no government ID**. **`getMarketingPreferencePickList`** (§4.18): 24 preference rows, recipient types **LEAD (1)** and **RESIDENT (2)**, channels `email` / `phone` / `postal_mail` / `sms`, 5 `availabilityAlertFrequencies`; rows are not uniformly shaped, some lack `consentType`. **`getMarketingPreferences`** (§4.19): per-customer `communicationChannel`, `consentType`, `optIn`, `isTransactional`, verified against a real resident at `recipientTypeId=2`. **`getCalendarAvailability`** (§4.17): see the next row. **Writes did not change:** still exactly `sendLeads` and `updateLeads`, every 2026-08-05 call was a read, and **Constitution rule 2 is unchanged and undiluted.** |
| 2026-08-05 | 🔄 **`getCalendarAvailability` OVERTURNS the "the calendar will disappoint" warning, and the 60-day window is wrong.** The 2026-08-03 revision predicted an empty or unmaintained calendar because Liberty books tours by hand. **It returned real, maintained configuration** (§4.17): `appointmentLength` 30, `selfGuidedTourAppointmentLength` 30, `minLeadTimeTour` and `selfGuidedTourMinLeadTime` both "120 Minutes", agent-guided availability **Mon-Fri 08:00-16:00 MST** with `simultaneousAppointmentLimit` "No Limit", and **`selfGuidedTourAvailabilities` across all seven days including weekends, 06:00-18:00 MST**, limit 1. **Self-guided tours are already configured in Entrata**, corroborating Alex's lock-box answer: what is manual is the *booking*, not the *configuration*. ⚠️ **The window cap is 7 DAYS, not the 60 the vendor spec advertises.** An 8+ day span returns **400 / code 308**, now in the §3 error table along with **400 / 310** (missing required params on `getLeads`). §9 item 14 and §10.4 rewritten. **Residual caveat kept:** Liberty schedules by hand, so the calendar may not reflect actually-booked appointments; it is a template, not a ledger. **New discrepancy recorded:** tour hours 08:00-16:00 vs documented office hours M-F 9:00-5:00 (new §9 item 17, not yet asked). |
| 2026-08-05 | ✅ **THE BIRTHDAY ANSWER: DOB is on `getLeads`, populated, and it is NOT on the lease.** New §4.20. A scan of the vendored OpenAPI found birth-date fields in the **response** schema of `getLeads` r1 (`birthDate`), `getLeases` r1+r2 (`dateOfBirth`), `getMitsLeases` r1 (`BirthDate`), `getEvictedLeases` r1, and `getMitsCollections` r1 (request-side only, all writes, irrelevant: `sendApplication`, `updateApplication`, `sendLeases`, `updateCustomers`, `updateLease`). **Against live data: `getLeases` r1 zero, `getLeases` r2 zero, `getMitsLeases` zero. `getLeads` r1 → populated.** Entrata omits null fields, so DOB is simply empty on Liberty's lease and resident records. **[Corrected later the same day: this row originally read "122 non-empty of 122 occurrences," which was an artifact of the denominator, not a coverage figure. The measured numbers are 36% of lead-customers and 65% of current residents. See the third-pass row below and §4.20.]** **Conclusion: DOB is captured at the LEAD/APPLICATION stage and never propagates to the lease or resident record.** `getLeads` requires `propertyId` + `fromDate` + `toDate` (omitting them returns 400 / 310); a 3-month span worked. **This reverses two earlier claims: that `getMitsLeases` is the birthday source, and that no Entrata birthday source exists and the Welcome Profile is the only path.** Design path in §10.11: read DOB from `getLeads`, join on `customerId`, sweep historically for long-tenured residents, Welcome Profile as fallback for the remainder. ⚠️ **PII: store month/day only, drop the year** (§8 item 6). **Executed lease documents are NOT reachable.** `getLeaseDocuments` and `getLeaseDocumentsList` are in the `leases` group but not on the 28-service key, and **should not be asked for**, since `getLeads` already carries the DOB. |
| 2026-08-05 | 🚫 **NEW BINDING DATA RULE FROM BRENT: never ingest, persist, or log an SSN.** NPI has no use for one and does not want the liability. New §8 item 7. **The schema exposes it:** an SSN / tax-ID field sits in the **response** schema of `getMitsCollections` r1 (`SSN`), `getVendors` r1 (`taxIdNumber`), and **`getMitsLeases` r1 (`SocialSecurity`), which IS on our key** (§4.16). **Live data is clean:** the 2026-08-05 `getMitsLeases` probe returned **no SSN-like keys at all**, so Liberty leaves it empty and there is no exposure today. **The rule stands anyway, because empty today is not empty forever** and Entrata omits null fields, so an empty field and an absent one are indistinguishable (§7.11). **The control is a transport-layer scrub at the same single request choke point that enforces the read-method allowlist** (§1 item 3): strip `SocialSecurity` / `SSN` / `taxId` **on read**, before anything is persisted, logged, cached, or passed to an AI model. Deny by default, fail safe. Not a downstream cleanup. **Same rule for documents:** Aspen's 44 executed leases each carry one SSN field, so any lease-document or DD ingestion drops it at extraction rather than parsing, indexing, or embedding it. |
| 2026-08-05 | ✅ **PET DATA IS ON THE KEY, at individual-animal grain, with names.** New §4.21, design consequence in §10.12. **`getLeases` r1 is the source: 30 of 52 leases have pets, 34 pet records**, each carrying `name`, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `moveInDate`, `customerId`, and **`isAssistanceAnimal`**. `getLeads` r1 carries **126 pet records** (`name`, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `count`, `id`, `licenseCity`), which is deeper but funnel-stage. **`getMitsLeases` is thin and unusable for this**: `Count`, `PetType`, `Weight`, **no name**. `getWorkOrders` r1 carries **`petInfo`**, an operations signal for warning a technician about an animal in the unit. **Pets join to the resident on `customerId`**, the same key as the birthday feed, so the recognition engine pulls both in one job. **`getPetTypes` is not on the key and is not needed**, since `petType` comes back inline; do not add it to a permission ask. ⚠️ **`isAssistanceAnimal` is a hard fair-housing filter:** assistance and service animals are **not** pets, and they come out of the pet-gift program and out of all pet-themed marketing. A missing or unparseable flag excludes the record. **Aspen for comparison:** 21 of its 44 executed leases carry `breed:` and `weight:` labels but **no pet name**, so Entrata is the better source wherever a property is on the key. **The pet-gift program's assumed data gap does not exist.** |
| 2026-08-05 | ✅ **`getMarketingPreferences` is unblocked: the `customerIds` requirement is not a dead end.** The concern was that the method requires `customerIds` while the whole `customers` group is absent from the key. **Customer IDs come from `getMitsLeases`** (`IDType: "Customer ID"`, §4.16) and from `getLeads` (`customerId`, §4.10), so **the absent `customers` group is NOT required** and `getCustomers` stays dropped (§9 item 3). **New rate limits captured into §6** for all four newly probed methods with headers: `getMitsLeases` 20,000/4,500/200, `getCalendarAvailability` 48,000/2,000/120, `getMarketingPreferences` 10,000/1,000/60, and **`getMarketingPreferencePickList` 1,500/60/10, the tightest hourly bucket on the key.** Cache the pick list; never call it per lead. New traps §7.10 (7-day window), §7.11 (a field in the schema is not a field in the data), §7.12 (non-uniform preference rows). |
| 2026-08-05 | ⚠️ **CORRECTION plus the measured DOB coverage. Third pass, full historical sweep, read-only and serial from the VPS.** The "**122 of 122**" figure recorded earlier the same day **overstated coverage and is retired everywhere.** Entrata omits null fields, so counting non-empty `birthDate` values against *occurrences of the key* could only ever return 100%; the customers without one were never in the denominator. **Swept 2021-01-01 through 2026-08-05: 787 distinct lead-customers, 286 carrying a `birthDate`, which is 36%.** Against the rent roll: **103 customer records across the 52 leases, 88 of them `Current`** (roommates and guarantors make customers outnumber leases), **70 of the 88 appear anywhere in `getLeads`**, **57 of the 88 carry a `birthDate`**, so **DOB coverage of current residents is 65%.** The 35% gap (31 residents) splits two ways: **18 current residents never appear in `getLeads` at all** (structural, no window recovers them) and **13 appear with an empty field.** **Do not describe birthday coverage as complete.** The Welcome Profile is now a **permanent** fallback for the remainder, not a temporary bridge. §4.10, §4.20, §8, §9 item 4 and §10.11 all corrected. |
| 2026-08-05 | ✅ **The lead-to-resident join is PROVEN, and the historical backfill is cheap.** `getLeads` `customers.customer[].customerId` and `getLeases` customer `id` are **the same ID namespace**: 70 of 88 current residents matched on that key alone, no name or email fuzzing. **An earlier worry that they were different ID spaces is resolved.** Lead customer objects carry `applicantId`, `birthDate`, `cellPhoneNumber`, `customerId`, `customerRelationshipType(Id)`, `customerType`, `email`, `firstName`, `lastName`, `middleName`, `namePrefix`, `personalPhoneNumber`, `phones`, `addresses`. **`getLeads` accepts a 730-day (2-year) window on the first try**, so the entire 5.5-year history swept in **3 calls**; the earlier "week by week or month by month" plan was over-engineering. ⚠️ **New general lesson, new §7.14: window caps are PER METHOD.** `getCalendarAvailability` caps at 7 days, `getLeads` at 730. Do not generalize one method's cap to another, and do not trust the spec for it. **Earliest `moveInDate` on the roll is 2022-06-16**, so the sweep reaches every current resident who has a lead record. |
| 2026-08-05 | 🚨 **NEW BUILD-CRITICAL TRAP, new §7.13: Entrata's edge is Cloudflare and it 403s on some clients' default User-Agent.** A Python `urllib` request on its default UA returned **HTTP 403 with the bare body `error code: 1010`**, a Cloudflare browser-signature ban, **not an Entrata JSON error at all**: no envelope, no `response.error`, no Entrata code. **The identical request via `curl` succeeded.** Two rules, both in the client: **set an explicit `User-Agent` header on every request**, and **treat a non-JSON 403 body as an edge block rather than an auth or permission failure**. Do not retry it as a bad key and do not ask Liberty for a checkbox that is already ticked. Added to the §3 error vocabulary and to the request-shape rules. |
| 2026-08-05 | ✅ **THE LAST UNPROBED METHODS ARE PROBED. Every one of the 28 services on the key has now been called at least once.** `getStatus` → **HTTP 200**, `{"status":"Success","message":"API service is available and running."}`, a free liveness check needing no permissions. `getAccessibleClients` (on the `rsync` org path) → **HTTP 200**, `{"subdomain":"libertyassetgroup","id":19530,"name":"Liberty Asset Group"}`. `getAccessToken` / `getJwtAccessToken` / `getUserInfo` (`oauth`) → **HTTP 403 / error 1403, "You're not allowed to access this resource."** ⚠️ **Note that is 1403, NOT the 107** an OAuth-only method would suggest; they are on the roster and unusable on an apikey. `getAppAdditionalServices` → **HTTP 400 / error 107**, unchanged. `getLeaseDetails` **r2** → **HTTP 200**, takes `propertyId` only (r1 needs `leaseId` too), result keys `currencyCode` + `leases`. `getMarketingPreferences` **LEAD side (`recipientTypeId=1`)** → **HTTP 200**; note **the same customer can carry BOTH a LEAD and a RESIDENT preference set**, so recipientType is a lens on the customer, not a partition of them. **Consequence for §2.1: "on the roster" is not "callable." Four of the 28 always error, so the usable surface is 24 (22 reads + 2 banned writes).** |
| 2026-08-05 | ✅ **New rate limits into §6, and the "tightest bucket" claim refined.** Newly measured: **`getWorkOrders` 25,000/day, 1,500/hour, 150/minute**; **`getWorkOrderPickLists` 1,000/day, 500/hour, 60/minute**; `getLeads` 27,000 / 5,000 / 135; `getLeases` 64,320/hour, 1,072/minute; `getLeaseDetails` r2 50,000 / 5,000 / 1,000. ⚠️ **`getWorkOrderPickLists` at 1,000/day is the TIGHTEST DAILY bucket on the key**, below `getMarketingPreferencePickList`'s 1,500. So the claim needs a qualifier wherever it appears: **marketing pick list is tightest hourly (60/hour, 10/minute), work-order pick list is tightest daily (1,000/day). Cache both.** Neither belongs in a per-lead, per-resident or per-work-order path. `getStatus` at 6/minute is still the tightest per-minute limit on the API and still cannot be a heartbeat. |
| 2026-08-10 | ⚠️ **CORRECTION: "there is no lead-event write method anywhere in the 128-method spec" was FALSE and is withdrawn everywhere it appeared.** **Origin:** `reference/entrata-touchpoints.md` §6.4, written 2026-08-05, which is the doc this corpus treats as the tie-breaker. **It propagated into six files** before it was caught (`entrata-touchpoints.md`, `foothold-design-scope.md`, `system-design.html`, `lead-comms-spec.html`, `design-map.html`, `CLAUDE.md`). **Disproved by direct inspection of the vendored spec** `reference/entrata-openapi-2026-07-28.yaml`: `updateLeads-r2-request-schema` (lines **11672-12023**) carries an **`events` array at 11923** with `eventId` (11931), `typeId` (11935), `date` (11943), `type` (11947, enum at 11951), `callData` (11958) and a free-text **`comments`** field (**11988**). `sendLeads-r1-request-schema` carries an `events` array too (**10604**). **Both methods are permissioned on this key** (§2.1: `sendLeads` and `updateLeads` are the two writes). ✅ **Evidence, not a recommendation:** `sendMitsLeads` r1's `EventType` enum includes `Note`, `IncomingText`, `OutgoingText` (**11202-11204**). ⚠️ **The accurate limitation is narrower: the r2 `type` enum has no text, chat, or note value**, so logging one means a numeric `typeId` off `getLeadPickLists` (155 `EventTypes`, §4.13), which is **unverified and unverifiable without issuing a write**. 🚫 **Constitution rule 2 is unchanged and undiluted.** Appending a lead event is a **WRITE**; this corrects what the API can do, not what we are allowed to do, and no write is authorised by it. **Standing lesson: check the spec, not the summary. The tie-breaker doc was wrong once.** |
| 2026-08-10 | ⚠️ **CORRECTION: the blanket "dates are `MM/DD/YYYY`" rule was build-breaking and is now stated by DIRECTION.** `entrata-touchpoints.md` and `entrata-method-index.md` both presented one format as universal. **Requests take `MM/DD/YYYY`** (datetimes `MM/DD/YYYYTHH:MM:SS`, literal `T`); **responses come back ISO `YYYY-MM-DD`**, per the live samples already in this file (§4.10 `"createdOn":"2026-07-30"`, `"createdOnDateTime":"2026-07-30T03:05:00"`; §4.11 `"startDate":"2025-08-30"`, `"leaseApprovedOn":"2025-08-27"`, `"moveInDate":"2023-11-01"`). ⚠️ **Response formats are not uniform across methods either:** `getWorkOrders` returned `03/11/2024 23:58:35.168295 MDT`. **Format strictly on the way out, parse tolerantly on the way in.** Mountain Time handling (§3) is unchanged and was already correct. |

**Related:** `system-design.html` (§2 Constitution, §4 Entrata protocol, §14 open questions) ·
`reference/entrata-webhooks-2026-03-04.md` (verbatim vendor webhook doc) ·
memory `entrata-api-auth-shape` · memory `entrata-access-application` · memory
`foothold-entrata-vps` · Drive `Marketing/30_Foothold/CONTEXT.md`.

**When you learn something new from a live call, update this file and add a row above.**
