# Entrata touchpoints: the calls NPI actually uses

**How to use this file.** This is the current-state API reference for the Foothold build: what the key
can call today, what comes back, and what will bite the client. Read it first and build from it. When
you need the reasoning behind a decision or the history of how a fact was established, go to
[`entrata-api-context.md`](entrata-api-context.md). When you need a field-level schema this file does
not cover, go to the vendor spec at `reference/entrata-openapi-2026-07-28.yaml`.

**Everything below was verified by live, read-only probes from the VPS on 2026-08-05.**

---

## 1. Transport and auth

```
POST https://apis.entrata.com/ext/orgs/libertyassetgroup/v1/<group>

Headers:
  X-Api-Key: <key>
  Content-Type: application/json
  User-Agent: <explicit, name it after the service>

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

| Fact | Value |
|---|---|
| Org / client | Liberty Asset Group, subdomain `libertyassetgroup`, org id **19530** |
| Verb | **POST only.** GET returns 405 / 1405 |
| Auth | `X-Api-Key` header **and** `{"auth":{"type":"apikey"}}` in the body. Both. `Authorization: Bearer` returns 401 |
| Group vs method | **The group is the URL path segment. The method name goes in the body.** They are different things and both have to be right |
| Path exception | `getAccessibleClients` uses the literal path segment `rsync`, not the subdomain |
| Version | `version` is not always `r1`. Per-method versions are in the tables below |
| Dates | ⚠️ **Direction-dependent. Do not apply one format both ways.** **REQUESTS take `MM/DD/YYYY`**, and request datetimes use a literal `T`: `MM/DD/YYYYTHH:MM:SS`. **RESPONSES come back ISO `YYYY-MM-DD`** (live samples in `entrata-api-context.md` §4.10 and §4.11: `"createdOn":"2026-07-30"`, `"createdOnDateTime":"2026-07-30T03:05:00"`, `"startDate":"2025-08-30"`, `"leaseApprovedOn":"2025-08-27"`, `"moveInDate":"2023-11-01"`). ⚠️ **Response formats are not uniform either**: `getWorkOrders` returned `03/11/2024 23:58:35.168295 MDT`. **Parse tolerantly, format strictly.** A parser built to send-format on the read path fails on every date it reads |
| Concurrency | **Serial calls only, never concurrent.** One gate for the whole account |
| Source IP | **All traffic from the VPS static IP `5.78.139.104`.** Never a laptop, never a Worker |
| Burst governor | `x-spike-arrest-limit: 200` over a `100ms` slice, separate from the quota. The serial gate already satisfies it |
| Async | A long request can return a `queueId` instead of data. The recovery method (`getResponse`, group `queue`) is **not on the key**, so keep requests small enough to answer inline |
| Maintenance windows | Scheduled Tuesday late night into Wednesday early morning MST, weekly. Unscheduled late evening / early morning Sunday, Monday, Wednesday, Thursday. Treat downtime as a normal operating condition: back off, serve last-good cache, alert only past 2 hours stale |

**Scope: exactly one property.** Owyhee Park Apartments, `propertyId` **1311115**, 52 units, 4
buildings, 4 floor plans. **Aspen and Summerset are NOT on the key.** Every number and shape in this
file is verified against Owyhee Park only. `propertyId` is a parameter everywhere; never hardcode
`1311115`. Note also that `getWebsites` can see 98 other Liberty properties, so the key's reach is
wider than the authorization: enforce a property-ID allowlist alongside the read-method allowlist, at
the same choke point.

---

## 2. Client-build traps

These are the five that will cost hours if the client does not handle them.

1. **Cloudflare's edge blocks default User-Agents.** python-urllib on its stock UA returned **HTTP 403
   with a NON-JSON body reading `error code: 1010`**. The identical request through `curl` returned
   200. **Set an explicit `User-Agent` on every request, and classify a 403 by its body, not its
   status.** A non-JSON 403 never came from Entrata and the error vocabulary below does not apply to
   it. Get this wrong and a bot block reads exactly like a revoked key, which sends somebody to
   Liberty asking for a checkbox that is already ticked.
2. **Window caps are PER METHOD.** `getLeads` accepts **730 days** in one call. `getCalendarAvailability`
   caps at **7 days** (8 or more returns 400 / 308). Same key, same gateway, two orders of magnitude
   apart. **Measure the cap on each method you use and never generalize one.** The vendor spec is not
   reliable here either: it advertises 60 days for the calendar and live behavior is 7.
3. **Entrata omits null fields entirely.** A field that exists in the response schema but is empty in
   the data simply does not appear on the wire. Two consequences: **"present on N of N records" is a
   circular count, never infer coverage from key presence**, and **empty and absent are
   indistinguishable**, so a field that has never shown up can start showing up the moment a PM changes
   a workflow, with no deploy on our side.
4. **All dates and times are Mountain Time.** The VPS runs `America/Los_Angeles`. Parse as explicit MT.
   A lazy parse is a one-hour offset that becomes a **one-day error** on move-in and availability dates
   near midnight, which are exactly the dates that matter.
5. **A missing `Content-Type` header returns 400 / 1415.** Send it on every request.

---

## 3. 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 |
| 400 | 107 | Method requires OAuth, `apikey` auth is not supported |
| 400 | 308 | Date span wider than the method allows |
| 400 | 310 | Required date range missing |
| 401 | 405 | Client exists, key not permissioned for it |
| 403 | 115 | Key valid, that web service is not enabled |
| 403 | 1403 | "You're not allowed to access this resource." Returned by the `oauth` methods. **Not the same as 400 / 1403** |
| 403 | **1010, body is NOT JSON** | **Cloudflare edge block, not Entrata.** Bare body reads `error code: 1010`. Not an auth failure and not a permission failure |
| 404 | 111 | No such client (bad subdomain) |
| 405 | 1405 | Wrong verb. Everything is POST |

**Parse the body before classifying the failure.** Every row above except the Cloudflare one arrives as
a JSON envelope with a `response.error`. **308 and 310 are parameter faults, not permission faults**;
do not read either as a lost service.

---

## 4. The safety boundary

**28 services sit on the key. Only 22 of them can be used. Two are permanently off-limits and four
always error.**

### Banned writes (permissioned, never called, never will be)

| Method | Group |
|---|---|
| `sendLeads` | `leads` |
| `updateLeads` | `leads` |

**Constitution rule 2: Entrata is READ-ONLY against any live production property.** Owyhee Park is an
occupied building with real residents, real prospects, and real leasing staff. A test guest card puts a
leasing agent on the phone chasing a prospect who does not exist. Neither of these methods is ever
called, and there is **no vendor-side guard**: 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.
A missing or unparseable flag means read-only.

`sendWorkOrders` and `updateWorkOrders` are **not on the key at all**, and are barred by the same rule
regardless.

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

### Unusable on this auth (4)

| Method | Group | Result |
|---|---|---|
| `getAccessToken` | `oauth` | 403 / 1403 |
| `getJwtAccessToken` | `oauth` | 403 / 1403 |
| `getUserInfo` | `oauth` | 403 / 1403 |
| `getAppAdditionalServices` | `appinfo` | 400 / 107, `apikey` auth not supported |

On the roster, not callable. Nothing designed here needs them.

---

## 5. The 22 usable reads

Grouped by Entrata group. The group is the URL path segment.

### 5.1 `status` and `appinfo` (plumbing)

| Method | Group | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|---|
| `getStatus` | `status` | r1 | none | 8,640 / 360 / **6** |
| `getAccessibleServices` | `appinfo` | r1 | none | not recorded |
| `getAccessibleClients` | `appinfo` (path segment **`rsync`**) | r1 | none | not recorded |

**`getStatus`** returns `{"status":"Success","message":"API service is available and running."}`. It
needs no permissions at all. **At 6 per minute it is the tightest per-minute limit on the whole API and
cannot be a heartbeat.** Use the availability poll itself, or `getProperties`, as the liveness signal.
If an explicit health check is wanted, cap it at every 15 minutes.

**`getAccessibleServices`** returns the permission roster, 28 entries. **This is how you verify a
permission change.** If a method is not on this roster it is not permissioned.

**`getAccessibleClients`** returns `{"subdomain":"libertyassetgroup","id":19530,"name":"Liberty Asset
Group"}`. Note the literal `rsync` path segment; calling it under the client subdomain fails.

### 5.2 `properties`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getProperties` | r1 | none | 15,000 / 1,000 / 150 |
| `getFloorPlans` | r1 | `propertyId` | 10,000 / 1,000 / 300 |
| `getWebsites` | r1 | `propertyIds` | 1,000 / 100 / 10 |
| `getCalendarAvailability` | r1, r2 | r1: `propertyId`, `fromDate`, `toDate` (**`MM/DD/YYYY`**)<br>r2: adds `tourType` | 48,000 / 2,000 / 120 |

**`getProperties`** returns `response.result.PhysicalProperty.Property[]`, one property. Carries
`PostMonths` (AR / AP / GL all 07/2026), **`PropertyHours` (M-F 9:00 AM to 5:00 PM, no weekend hours)**,
and 24 `LeaseTerms` (1 through 24 months). **Read the hours, never hardcode them**, and read them per
property.

**`getFloorPlans`** is the **authoritative source for square footage**. Four plans, 52 units, floorplan
JPGs CDN-hosted at `medialibrarycf.entrata.com` and usable directly on the site (pin the URLs and
re-check them; a PM edit in Entrata can rotate them).

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

**`getWebsites`** returns three site configs: the main `libertyassetgroup` site, `libertyassetgroup1`,
and a dedicated `owyheeparkapartments` site (id 506). **It enumerates 98 Liberty properties by ID and
name on the main site and 40 on the second**, including buildings owned by other owners. This is the
concrete reason the property-ID allowlist exists. Do not probe another owner's building. Config read at
10/min, not a hot path.

**`getCalendarAvailability`** is tour **configuration**, and it is real and maintained.

| Block | Value |
|---|---|
| `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, 06:00-18:00 MST**, `simultaneousAppointmentLimit` 1 |
| `propertyCalendarAvailability.availableHours.availableHour[]` | Per-date windows. Weekends absent from the agent-guided set |

⚠️ **7-DAY MAXIMUM WINDOW.** An 8-day-or-wider span returns 400 / 308. Covering a 60-day horizon costs
9 calls, not 1. Budget calls per horizon, not per read.

⚠️ **This is a template, not a booking ledger.** Liberty books tours manually, so an open window is not
proof the slot is free. Offer windows, confirm with a human, and keep tour booking a human handoff.

⚠️ **The 120-minute minimum lead time is hard on both tour types.** No "come see it in an hour."

⚠️ **Tour hours (08:00-16:00) disagree with the documented office hours (M-F 9:00-5:00).** Two
API-readable sources, same eight hours shifted an hour earlier. Unresolved. Do not silently pick one and
do not build a rule that assumes they agree.

### 5.3 `propertyunits`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getPropertyUnits` | r1 | `propertyIds` | 15,000 / 2,000 / 150 |
| `getUnitTypes` | r1 | `propertyId` | 10,000 / 2,000 / 100 |
| `getUnitsAvailabilityAndPricing` | r1 | `propertyId` | not recorded / 36,000 / 600 |
| `getAmenities` | r1 | `propertyId` | 10,000 / 1,000 / 60 |
| `getSpecials` | r1, r2, r3, r4 | `propertyId` | 5,000 / 600 / 100 |

**`getPropertyUnits`** returns all 52 units, occupied and vacant: `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, and only the 12-month term is web-visible.** Quote the web-visible term unless a
prospect asks about a specific one; showing a term Liberty deliberately does not publish creates a
discrepancy against Liberty's own site for the same building.

The **property-level `pets` block is populated** and is the pet **policy**: Cat and Dog, $300 deposit,
$50 monthly rent, count 999. The unit-level `maxNumberOccupants` and `maxNumberOfPets` are `0` on every
unit, which means **unpopulated, not "none allowed."** Do not answer "is this building pet friendly"
from the resident pet records, or "does this resident have a dog" from the policy block.

**`getUnitTypes`** returns six unit types with market rent broken out by lease term, plus sold-out flags.
Six types against four floorplans, so unit type is the finer grain. **Display by floorplan, price by unit
type.**

**`getUnitsAvailabilityAndPricing`** is the poller's method. It splits into `Properties` (floorplan
rollup) and `PropertyUnits` (only units actually available), with `AvailableOn` and `MakeReady` dates.
⚠️ **Do not read `SquareFeet` out of this method**: the floorplan-level block returns `Avg: 0, Max: 0,
Min: 1024`, a minimum above a maximum. Use `getFloorPlans`.
⚠️ **Do not derive availability yourself from lease status.** `getLeases` shows 3 Notice leases against
2 available units here. They do not reconcile and **Entrata's availability is the one to trust**;
availability is a computed field with rules we cannot see.

**`getAmenities` and `getSpecials` are permissioned and return ZERO ROWS.** HTTP 200, "No amenities
available for the given request" and a bare property stub respectively. The permission works, the data
does not exist. **A 200 is not evidence of data.** Amenity content and concession display cannot be
synced from the PMS because there is nothing to sync: amenity copy is our own content with a named owner
and a staleness policy, and concessions are an out-of-band conversation with Liberty.

### 5.4 `leads`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getLeads` | r1 | `propertyId`, **`fromDate`**, **`toDate`** | 27,000 / 5,000 / 135 |
| `getLeadPickLists` | r1, r2 | r1: none<br>r2: `propertyId` | 5,000 / 300 / 200 |

**`getLeads`** returns `result.prospects[0].prospect[]`. **This is the highest-PII method on the key and
the only working source of resident date of birth.**

- ⚠️ **`fromDate` and `toDate` are required** even though the vendor spec lists only `propertyId`.
  Omitting them returns **400 / 310**.
- ✅ **730-day window accepted.** The full 2021 to 2026 history sweeps in 3 calls. Historical backfill is
  cheap. Do not carry this number to another method.
- Prospect-level fields: `applicationId`, `status`, `statusId`, `leadSourceId`, `leadSource`,
  `psProductId`, `leasingAgent`, `leasingAgentId`, `screeningResultStatus`, `createdOn`,
  **`createdOnDateTime` (the speed-to-lead clock)**, `lastUpdatedDate`, `lastUpdatedDateTime`,
  `customers`, `customerPreferences`, `lease`.
- Customer object: `customerId`, `applicantId`, **`birthDate`**, `firstName`, `middleName`, `lastName`,
  `namePrefix`, `email`, `cellPhoneNumber`, `personalPhoneNumber`, `phones`, `addresses`, `customerType`,
  `customerRelationshipType` and `customerRelationshipTypeId`, plus **`googleClickId` and
  `googleClientId`** (paid-search attribution plumbing already exists in the PMS; do not build a parallel
  one).
- **126 pet records** on a 3-month window, with `name`, `petType`, `breed`, `color`, `gender`, `age`,
  `weight`, `count`, `id`, `licenseCity`. Deepest pet feed by volume but funnel-stage, so it includes
  prospects who never signed. Use `getLeases` for resident pets.
- `getLeads` r1 also accepts `eventDateFrom`, `eventDateTo`, and `eventTypeIds` (max 5) and returns the
  matching lead events inline, which is how lead-event deltas are polled without `getLeadEvents`.

**`getLeadPickLists`** is the ID vocabulary: **155 `EventTypes`, 62 `LeadStatuses`, 6 `EventResults`, 15
named `LeasingAgents` with IDs, 17 `LeadSources`** each with a `WebVisible` flag (Zillow,
Apartments.com, ApartmentGuide, ApartmentFinder, Apartment List, ForRent, Facebook, Google Ads, Drive
By, Referrer among them). **Pin these.** Any outbox intent built against invented IDs has to be rewritten
before it can be replayed. The 62 `LeadStatuses` are also the first place to look for abandoned-application
status IDs. The 15 named agents are employee PII.

### 5.5 `leases`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getLeases` | r1, r2 | `propertyId` | not recorded / 64,320 / 1,072 |
| `getLeaseDetails` | r1, r2 | r1: `leaseId`, `propertyId`<br>r2: `propertyId` | 50,000 / 5,000 / 1,000 |
| `getMitsLeases` | r1 | `propertyId` | 20,000 / 4,500 / 200 |

**`getLeases` with no status filter returns exactly the current rent roll: 52 leases, 103 customer
records, 88 of them `Current`.** (Roommates and guarantors make customers outnumber leases.)

⚠️ **Trap: filtering `leaseStatusTypeIds: "2"` returns 190 Cancelled leases**, which is historical
application churn, not the roll. **For the rent roll, omit the filter.**

Customer object keys: `id`, `firstName`, `lastName`, `nameFull`, `customerType`, `leaseCustomerStatus`,
`moveInDate`, `phone`, `addresses`, `relationshipName`. Each lease also carries:

- `leaseIntervals[]`: start/end dates, `leaseIntervalTypeName` (Renewal vs New), `leaseApprovedOn`,
  `applicationCompletedOn`, `applicationId`
- `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 this method** against market rent from `getFloorPlans`, and
lease end dates make renewal exposure by month computable without `getExpiringLeases`.

✅ **This is the best pet source on the key: 34 pet records across 30 of the 52 leases**, each with
`name`, `petType`, `breed`, `color`, `gender`, `age`, `weight`, `moveInDate`, `customerId`, and
**`isAssistanceAnimal`**. See §6.2.

❌ **Returns NO birth dates**, on either version.

**`getLeaseDetails`** returns per-lease charge detail: `leaseId`, `name`, `propertyUnitId`, `unitNumber`,
and `scheduledCharges.recurringCharge[]` grouped by lease interval. Each `activeScheduledCharge` carries
`arCodeId`, `chargeCode`, `chargeTiming`, `chargeStartDate`, `chargeEndDate`, `lastPosted`,
`postedThrough`, and a dollar-formatted `amount`. **r1 is the single-lease read; r2 takes `propertyId`
only and is the property-wide sweep** (result keys `currencyCode` and `leases`), which makes r2 the
cheaper shape for charge detail across the roll. ⚠️ **On a Cancelled lease r1 returns a near-empty stub
with no error.** Filter to Current leases first.

**`getMitsLeases`** returns the same lease file in MITS format, and the interesting part is the grain:
**100 `Tenant` records against 52 `LA_Lease` records.** This is the only method that hands over roommates
and guarantors as first-class person records. Use it when you need "everyone who lives in unit X" rather
than "the lease on unit X."

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

✅ **This is where customer IDs come from**, as `Identification` with `IDType: "Customer ID"`, which is
what `getMarketingPreferences` needs. Every `Identification` IDType on these records is an internal
Entrata ID: 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.**

❌ **Returns NO birth dates.**

⚠️ **Do not use it for pets.** Its pet block is `Count` / `PetType` / `Weight` and is **nameless**.

🚫 **It declares `SocialSecurity` in its r1 response schema.** Live data is empty, and that is luck, not
a control. **Strip it on read regardless** (Constitution rule 12, §6.3).

⚠️ **Its blast radius runs past residents.** `Reference` carries emergency contacts, and `GuarantorFor`
and `Spouse` carry people who never signed a lease with anyone. **Read it for customer IDs and household
grain. Do not build a contact list out of it.**

### 5.6 `maintenance`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getWorkOrders` | r1 | `propertyId` | 25,000 / 1,500 / 150 |
| `getWorkOrderPickLists` | r1 | none | **1,000** / 500 / 60 |

**`getWorkOrders`** returns real production work orders. Observed fields: `maintenanceRequestId`,
`propertyId`, `buildingName`, `unitNumber`, `maintenanceStatus`, `maintenancePriority`,
`maintenanceProblem`, `maintenanceLocation`, `completedOn`, `scheduledStartDate` / `scheduledEndDate`,
`maintenanceRequestFrom`, `parentMaintenanceRequestId`, `petInfo`. Timestamps are Mountain Time.

Two design cautions, both load-bearing:

- ⚠️ **Filter on `maintenanceRequestFrom`.** Records with `"Entrata PaaS"` are system-generated (make-readies
  among them), not resident service calls. **A system-generated record must never fire a resident survey
  or a "How'd we do?" touch.**
- ⚠️ **Collapse parent/child before counting or messaging.** `parentMaintenanceRequestId` is populated, so a
  request can be a child of another request. "Is this ticket done" and "is this turn done" are not the
  same query, and child records must not be counted as independent tickets in any volume or turn-progress
  metric.

`petInfo` is the one operational use of pet data: warning a technician there is an animal behind the door
before they open it. Pass it through to whatever the tech sees.

**`getWorkOrderPickLists`** is the maintenance ID vocabulary, and everything comes back **property-scoped**,
so a second property needs its own read.

⚠️ **1,000/day is the TIGHTEST DAILY bucket on the key. Cache it.** One read per property per day is the
shape, not one read per work order.

| List | Contents |
|---|---|
| `Locations` | Work-order locations, each carrying a nested `unitTypes` list |
| `Priorities` | Priority vocabulary |
| `StatusTypes` | Status vocabulary |
| `Categories` | Category vocabulary |
| `Problems` | Problem vocabulary. This is where "Make Ready" comes from |
| `SubMaintenanceProblems` | Second-level detail under `Problems` |
| `WorkOrderTypes` | 5 |
| `CompanyEmployees` | **12. Liberty's maintenance staff roster**, the IDs work orders are assigned to. Employee PII |
| `InspectionStatuses` | 5 |

### 5.7 `communications`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getMarketingPreferences` | r1 | `propertyId`, `customerIds`, `recipientTypeId` | 10,000 / 1,000 / 60 |
| `getMarketingPreferencePickList` | r1 | none | 1,500 / **60** / 10 |

**`getMarketingPreferences`** returns `customers[].preferences[]`, each preference carrying
`communicationChannel`, `recipientType`, `recipientTypeId`, `consentType`, `consentTypeId`, `optIn`, and
`isTransactional`. **This is the consent gate in front of every outbound touch.** Read consent before
sending; do not infer it from having an address, and treat a missing preference as "don't send" rather
than "no objection."

⚠️ **Works for BOTH `recipientTypeId` 1 (LEAD) and 2 (RESIDENT), and the same customer can hold both
sets.** `recipientType` is a **lens on the customer, not a partition of them**, so a one-sided consent
check is an incomplete check. Read both sides for anyone who exists on both, and fail closed on the union.

Customer IDs come from `getMitsLeases` (`IDType: "Customer ID"`) and from `getLeads` (`customerId`). The
absent `customers` group is not needed.

Note that **reading a preference is not recording one.** Writing consent back would be a write and is
barred.

**`getMarketingPreferencePickList`** is the 24-row consent vocabulary: `recipientTypes` LEAD (1) and
RESIDENT (2); channels `email`, `phone`, `postal_mail`, `sms`; plus 5 `availabilityAlertFrequencies`.
Postal mail being a first-class consent channel matters, since most of the recognition engine is physical
mail.

⚠️ **60/hour is the TIGHTEST HOURLY bucket on the key. Cache it and never call it per lead.** One read a
day is generous; one read per lead exhausts the hour in the first sixty leads and throttles the consent
gate itself.

⚠️ **Parse defensively: the rows are not uniformly shaped.** Some rows, the `phone` and `postal_mail` ones
among them, come back with **no `consentType` key at all**. A fixed-shape parser throws on real data.

### 5.8 `financial`

| Method | Ver | Required params | Limits (day / hour / min) |
|---|---|---|---|
| `getGlTransactions` | r1 | `propertyIds` | 10,000 / 6,000 / 150 |

Returns `properties.property[].transactions.transaction[]`, one block per GL account (**90 accounts for
June 2026**). 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`.

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

⚠️ **The financial feed is a PII feed.** GL memo fields read like `Firstname Lastname Unit #XXXX-XXX` on
every AR Payment line. It cannot be treated as "just numbers," dropped into a spreadsheet that gets
emailed around, or pasted into a general AI context.

Budget comparison is not available: `getBudgets`, `getBudgetActuals`, and `getBankAccounts` are not on the
key. NOI-vs-budget sources budget from NPI's own model.

---

## 6. Derived data answers

### 6.1 Resident date of birth comes from `getLeads`, and only from `getLeads`

**DOB is captured at the lead / application stage and never propagates to the lease or the resident
record.** `getLeases` r1, `getLeases` r2, and `getMitsLeases` r1 all declare a birth-date field in their
schema and **all three return zero birth keys** against live data.

**Measured coverage against the current rent roll:**

| Measure | Count |
|---|---|
| Distinct lead-customers in `getLeads`, full 2021 to 2026 history | 787 |
| Of those, carrying a `birthDate` | **286 (36%)** |
| Customer records across the 52 leases | 103 |
| Of those, `Current` | 88 |
| Current residents appearing anywhere in `getLeads` | 70 of 88 |
| Current residents carrying a `birthDate` | 57 of 88 |
| **DOB coverage of current residents** | **65%** |

The 35% gap splits two ways and the halves need different answers: **18 current residents never appear in
`getLeads` at all** (structural, no window recovers them) and **13 appear with an empty field**.

✅ **The join is proven.** `getLeads` `customers.customer[].customerId` and `getLeases` customer `id` are
**the same ID namespace**: 70 of 88 matched on that key alone, no name matching, no email fuzzing, no
heuristics.

✅ **Backfill is cheap.** 730-day windows, so 5.5 years costs 3 calls. Run the sweep once, then poll
forward on a normal cadence.

⚠️ **The Welcome Profile is a REQUIRED component covering the other third, not an optional fallback.**
Birthdays are viable, not solved. **Do not describe birthday coverage as complete** in a status report, a
partner conversation, or a design doc.

**Executed lease documents are a dead end. Never spend an ask on them.** `getLeaseDocuments` and
`getLeaseDocumentsList` are not on the key, **and** all 44 executed Aspen leases were text-extracted (7.4M
characters) with **zero birth dates** in any of them. A lease records the result of screening, never the
applicant's DOB.

### 6.2 Pets come from `getLeases`

34 pet records across 30 of the 52 leases, each with `name`, `petType`, `breed`, `color`, `gender`, `age`,
`weight`, `moveInDate`, `customerId`, and `isAssistanceAnimal`. Pets attach to the resident on
`customerId`, the same key that joins leads to leases, so the birthday feed and the pet feed are one job,
not two. `petType` comes back inline, so no type dictionary is needed.

**Capture the whole pet record**, not just the name. Breed, color, and age are what make Handwrytten copy
specific, and age plus `moveInDate` is what lets a card land on a pet's move-in anniversary. Pet
attributes are not sensitive PII.

🚫 **`isAssistanceAnimal` is a HARD fair-housing exclusion, not a matter of tact.** Assistance and service
animals are not pets. They come out of the pet-gift program and out of every pet-themed marketing touch,
because singling out a resident's assistance animal is singling them out on the basis of a disability.
**A missing or unparseable flag excludes the record.**

**Exception: `petInfo` on a work order SHOULD reach a technician.** That is safety and access, not
marketing, and it is not consent-gated because it is not a message to a resident.

Coverage has no historical hole here: pets are on the current lease, so one `getLeases` read covers every
resident who has one.

### 6.3 PII rules (binding)

1. 🚫 **Never ingest, persist, or log a Social Security number.** Constitution rule 12. The same single
   choke point that enforces the read-method allowlist strips `SocialSecurity`, `SSN`, and `taxId` on
   read, **before** anything is persisted, logged, cached, or passed to a model. Match case-insensitively,
   deny by default, fail safe. A response body already written to a store or a log is a breach surface;
   scrubbing it there is too late. The same rule governs document ingestion.
2. **Birthdays: store month and day only, drop the year.** The feature needs "March 14," not "March 14,
   1978." Truncate at the same transport choke point, so a full DOB never reaches a durable store.
3. **Redact at the transport layer, not at the call site. Never log raw response bodies**, especially from
   `getLeads`, `getMitsLeases`, and `getGlTransactions`.
4. **Never echo the API key.** Not in logs, not in error paths, not in this binder.
5. **No real resident data goes in this repo, ever.** `ops.footholdboise.com` is gated with base64
   obfuscation, which is not security.

**PII by method, highest first:** `getLeads` (names, email, phones, addresses, birth dates, pets, GCLID:
name plus address plus DOB is identity-theft-grade) · `getMitsLeases` (person-level, plus emergency
contacts, guarantors, spouses who never signed anything) · `getLeases` (names, emails, phones, move-in
dates, charges, pet names, `isAssistanceAnimal`) · `getGlTransactions` (**non-obvious**: resident names in
AR Payment memos) · `getLeaseDetails` (medium) · `getMarketingPreferences` (resident-linked consent state)
· `getLeadPickLists` and `getWorkOrderPickLists` (employee data, 15 leasing agents and 12 company
employees). Everything else in §5 is property, unit, or vocabulary data with no personal data in it.

### 6.4 Logging a conversation into Entrata: a lead event can be written, a resident activity cannot

⚠️ **CORRECTION 2026-08-10, and this section is where the error started.** From 2026-08-05 until
2026-08-10 this section said there was **no lead-event write method anywhere in the 128-method spec**.
**That was wrong and it is withdrawn.** It was disproved by reading the vendored spec directly:
`reference/entrata-openapi-2026-07-28.yaml`, `updateLeads-r2-request-schema` (lines 11672-12023) carries
an **`events` array** at line 11923 with `eventId` (11931), `typeId` (11935, "Event type ID"), `date`
(11943, "Event date and time"), `type` (11947, enum at 11951) and a free-text **`comments`** field
(11988, "Event comments"). `sendLeads` r1 carries an `events` array too (line 10604). **Both methods are
permissioned on the key.** The claim propagated from here into six files before it was caught, so treat
this section's history as the standing reminder that the tie-breaker doc can be wrong: **check the spec,
not the summary.** What is corrected is what the API *can* do. **Nothing about what we are *allowed* to
do changed: every one of these is a WRITE and Constitution rule 2 governs all of them.**

Asked 2026-08-05, corrected 2026-08-10. The natural design instinct is "log every lead and resident
conversation onto the Entrata record so Liberty can see it, and email only when something needs
escalating." **The two halves are not equally possible, and the split runs along record type: a LEAD
event has a write mechanism, a RESIDENT / lease activity does not.**

- **Residents / tenants: there is no method ON THE KEY at all, and this half of the old finding
  survives intact.** `sendLeaseActivities` is the ONLY method in all 128 that appends an activity or
  note to a **resident / lease** record, and it is **not on the key** (the `leases` grant is
  `getLeases`, `getLeaseDetails`, `getMitsLeases` only). Its read-back companion `getLeaseActivities` is
  not on the key either, so we could not even verify what we had written. `updateCustomers` and the
  whole `customers` group are absent. `leasingcenter` (`getCallLogs`) is absent, and is a read anyway.
- **Leads: `updateLeads` r2 CAN append an event, and `sendLeads` r1 can too.** Both are on the key. The
  `events` array takes `typeId`, `date`, `type` and a free-text `comments` string, so a conversation can
  be summarised onto the prospect record. ⚠️ **It is a WRITE, so rule 2 governs it completely and
  nothing here authorises a call.** ⚠️ **The real limitation is narrower than "impossible" and it is
  worth stating precisely: the r2 `type` enum has no native text, chat, or note value.** It carries only
  `CallFromProspect`, `CallToProspect`, `Appointment`, `Tour`, `EmailToProspect`, `EmailFromProspect`.
  Logging a text or a chat message means passing a **numeric `typeId`** resolved from `getLeadPickLists`
  (155 event types, readable today), and that path is **unverified and cannot be verified without
  issuing a write.** That is open question **Tier 3 #8**, which is live again rather than moot.
  ✅ **Supporting evidence that the vocabulary exists in Entrata's model:** `sendMitsLeads` r1's
  `EventType` enum includes `Note`, `IncomingText` and `OutgoingText` (spec line 11202-11204). Recorded
  as evidence, **not** as a recommendation to route anything through `sendMitsLeads`.
  ⚠️ **One build trap that survives from the old (wrong) bullet and is still true:** the rest of the
  `updateLeads` payload is the prospect record itself (`FirstName`, `LastName`, `Email`, `Phone`,
  `DesiredRent`, `DesiredUnit`, `TargetMoveInDate`, `LeasingAgentId`, `OriginatingLeadSource`, and so
  on) and it **mutates**, it does not merge. **Appending an event must not clobber prospect data**, so
  whatever payload the outbox builds has to be constructed with that in mind.
- **Getting the RESIDENT half would take two separate things, not one:** Liberty granting
  `sendLeaseActivities` (**a near-term ask, going out with the `sendWorkOrders` ask, not yet
  granted**), **and** Brent's
  written per-property sign-off lifting rule 2 for that specific method. Do not assume the second
  follows from the first. **The grant alone changes nothing about what the code is allowed to call.**

**What to do instead, all buildable today with no permission and no rule change:**
1. **Log the full conversation in Foothold's own store.** Higher fidelity than an Entrata note anyway,
   and it is ours, so nothing is gated on Liberty.
2. **Escalate by email** to Liberty's leasing/manager team with the thread attached (§6 escalation
   address, per-property config). This is the leg that already works.
3. **Close Liberty's visibility gap deliberately**, since they will not see our touches in their own
   system: a scheduled digest to the property inbox, or a read-only Foothold view they can open. This is
   a real gap created by the read-only posture and it deserves an explicit decision rather than silence.

**Split the ask by record type, because the two halves are not equally possible:**

- **Resident / lease-level touches** (birthday card sent, pet card sent, feedback received, service
  follow-up): `sendLeaseActivities` exists and would do exactly this. It is **not on the key**, so it is
  a **new, well-scoped ask to Liberty**, and it is **on the near-term ask list** (Brent, 2026-08-05). It
  rides in the same conversation as the `sendWorkOrders` ask: that one is the larger and more
  consequential write grant, so putting the low-blast-radius activity note in the same message costs one
  round trip instead of two. Frame it the way it actually helps them: it puts Foothold's touches in their
  system **instead of their inbox**, so it reduces the email volume rather than adding to it. It still
  needs Brent's written per-property sign-off under rule 2, and the blast radius of an activity note is
  genuinely low (it dispatches nobody), but that is his call to make in writing, not an inference anyone
  else gets to draw.
- **Lead-level touches (an AI leasing chat): a write mechanism exists, so this is a rule 2 question and
  a vocabulary question, not a capability question.** ⚠️ **Corrected 2026-08-10**, see the note at the
  top of this section. `updateLeads` r2 and `sendLeads` r1 both append events with a free-text
  `comments` field and both are on the key. **What is unresolved is the event type for a text or a
  chat**, because the r2 enum has none and the numeric-`typeId` path off `getLeadPickLists` is
  unverified (Tier 3 #8). `getLeadEvents`, the read-back, is **not** granted, so we could not confirm
  what we wrote either. **Until rule 2 lifts for a property and the type question is settled, the lead
  conversation lives in Foothold's store and reaches Liberty by digest or a shared view**, which is also
  the permanent answer at Owyhee Park. **Do not read "it is possible" as "it is allowed."**

### 6.4a Rule 2 is scheduled to lift on NPI-owned properties (decision, 2026-08-05)

**It is not lifted today and nothing below is authorised yet.** Brent's decision: the read-only rule
**will** be lifted on **NPI-owned** properties when Foothold goes live on them (Aspen, then Summerset).
Three conditions must all hold before any write is issued: **NPI owns the property, Foothold is live on
it, and Brent has signed off in writing naming that property and those methods.**

**Owyhee Park is not covered and is not expected to be.** It is Liberty's building. Everything in this
reference was measured there, so **a working probe is not a licence to write.**

**Consequence for the client:** the deny-by-default allowlist becomes **per-property** rather than
disappearing. Same choke point, same fail-safe, but a named property can carry a named set of permitted
write methods. A missing, unparseable, or unrecognised property config still means read-only. **Build it
per-property from the first line of code**; retrofitting a per-property gate onto a global bar is where
the accident happens.

**On the ask list:** `sendLeaseActivities` (logging birthday cards, pet cards, feedback received into
Entrata) **is a near-term ask to Liberty** (Brent, 2026-08-05, reversing his own earlier call the same
day). It goes in the same conversation as the `sendWorkOrders` ask, since that is the larger grant and
one message beats two. ⚠️ **The grant is not the authorization.** It is a write, so rule 2 governs it
exactly like the others: NPI-owned, live, and signed off in writing naming that property and that
method. **At Owyhee Park it stays unused even if granted**, because Owyhee is not covered by the lift.

### 6.5 Resident service requests: observe at Owyhee, file at owned properties once live

Measured 2026-08-05 on Owyhee Park's 785 work orders:

| `maintenanceRequestFrom` | count | share |
|---|---|---|
| **Resident Portal** | **390** | **50%** |
| Entrata Core (staff-entered) | 388 | 49% |
| Entrata PaaS (system make-readies) | 7 | 1% |

**Half of all maintenance already arrives natively through Entrata's Resident Portal, and all 71
work-order locations are resident-portal-enabled.** Residents have a working intake path today that
lands directly in the system Liberty's techs work from, with their whole workflow behind it.

**TARGET STATE (decision, Brent, 2026-08-05): on NPI-owned properties, once live, the Foothold agent
files the ticket itself when a resident reaches out, after verifying the resident.** That is the design
to build toward. It needs two things that are not true yet, and both are hard gates:

- **`sendWorkOrders` is NOT on the key.** The whole `maintenance` group arrived on 2026-08-03 with
  exactly two reads and neither write. **This is a new ask to Liberty that has never been made**, and it
  is required for owned properties. It is separate from, and larger than, the `sendLeaseActivities` ask
  that now rides in the same conversation (§6.4a): same email, two named methods, different weight.
- **Rule 2 must be lifted for that property** per §6.4a: NPI-owned, live, signed off in writing.

**VERIFY THE RESIDENT BEFORE FILING.** Brent's requirement, and it protects the building: an unverified
ticket sends a real technician to a real unit. Available identifiers, best first:
- **Phone on file is the strongest and cheapest.** `getLeases` customer records carry `phone`, and
  `getMitsLeases` carries `Phone`. An inbound text or call from a number that matches the lease is a
  strong match with **zero friction**, and coverage is far better than DOB.
- **Unit number plus one more factor** for an unrecognised number.
- **DOB is a weak primary and must not be the only gate: coverage is 65%** (§6.1), so a third of
  residents could not pass it at all. Use it as a secondary factor, never the sole one.
- **Fail closed.** No match means hand off to a human, never file the ticket anyway.

**UNTIL BOTH GATES CLEAR, and permanently at Owyhee Park, point residents at the Resident Portal and do
not take the request.** The reason is not tidiness:

1. **We cannot file it.** Taking the request means it sits in a queue while the resident believes it has
   been filed. Office hours are M-F 9:00-5:00 with **no weekend coverage**, so a Friday night intake
   could sit unread for two days. That is a habitability and liability problem, not a UX one.
2. It would create a split brain against a system that already handles half the building correctly.
3. The follow-up is available today and needs no write: read completion from `getWorkOrders`, fire the
   service-call card and the review ask.

**The after-hours problem does not go away when the agent can file.** A ticket filed at 11pm Friday
still waits for Monday unless someone is on call. **Genuine emergencies get the phone path, never a chat
handoff and never an email**, before and after the rule lifts.

**Two facts for whoever builds the maintenance-driven engines:**
- **Priority is effectively unused: 780 of 785 tickets are `Medium`** (3 Very High, 1 High, 1 Very Low).
  **Do not triage on `maintenancePriority`,** it carries almost no signal. Use `maintenanceProblem` and
  `maintenanceLocation` instead.
- **312 of 785 tickets are children** (they carry `parentMaintenanceRequestId`). Collapse parent/child
  before counting anything or sending anything, or one turn generates several resident touches.

---

## 7. Not available at all

Not on the key, in whole or in part. Do not design against any of these.

| Not available | Note |
|---|---|
| The entire `customers` group, including `getCustomers` | Group-level absence, not a per-method switch. Resident data arrives via `getLeases` and `getLeads`, and the customer IDs it would have supplied come from `getMitsLeases` |
| `getLeadEvents` | `getLeads` r1 accepts `eventDateFrom` / `eventDateTo` / `eventTypeIds` (max 5) and returns events inline. Nuisance, not blocker |
| `getPropertyMedia` | Property photo library. Floorplan images still arrive via `getFloorPlans` |
| `getExpiringLeases` | Redundant. `getLeases` carries lease end dates |
| `getLeaseDocuments`, `getLeaseDocumentsList` | Executed lease documents are unreachable. See §6.1 |
| `getInspections`, `getInspectionTemplates` | Inspections are unreadable |
| `getBudgets`, `getBudgetActuals`, `getBankAccounts` | The `financial` group is only partly enabled. GL actuals yes, budget comparison no |
| `getPetTypes` | Not needed. `petType` comes back inline on the pet records |
| Groups `applications`, `arcodes`, `arpayments`, `artransactions`, `leasingcenter`, `pricing`, `queue`, `vendors` | Absent in their entirety. **`queue` matters more than it looks**: the documented recovery for an async `queueId` response is `getResponse` in that group, so keep requests small enough to answer inline |

---

**Related:** [`entrata-api-context.md`](entrata-api-context.md) (reasoning and exploration history) ·
[`entrata-method-index.md`](entrata-method-index.md) (all 128 spec methods, permissioned or not) ·
`entrata-openapi-2026-07-28.yaml` (vendor spec, field-level fallback) ·
[`entrata-webhooks-2026-03-04.md`](entrata-webhooks-2026-03-04.md) (verbatim vendor webhook doc) ·
[`system-design.html`](../system-design.html) §2 Constitution, §4 Entrata protocol.
