Four property-accounting vendor APIs — Entrata, Yardi Voyager, RealPage/OneSite, MRI — mocked faithfully enough to build real integrations against, because none of them hand out sandboxes. Push utility-bill spreadsheets through each vendor's actual wire dialect (JSON envelopes, SOAP, OData-ish), inspect the exact bytes, inject the failures each vendor is known for, and lock verified behavior in with captured-traffic regression fixtures.
Every implemented API method is transcribed from the vendor's official
documentation — sources (archived official pages and vendor-published
PDFs) and known gaps are tracked per vendor in docs/<vendor>/FIDELITY.md.
Nothing is guessed; where the docs are silent (e.g. error envelopes) the
gap is documented.
Two parts:
- Mock server (
app/) — impersonates vendor APIs with real state: charges you push land in a shared SQLite property database and can be queried back through any dialect (push via Yardi, read the same ledger via Entrata). All four vendors are implemented: Entrata, MRI, RealPage/OneSite, and Yardi Voyager (Billing & Payments). - Pusher (
pusher/) — CLI that reads an Excel workbook, maps rows to API payloads via a YAML profile, validates them against the same schemas the server enforces, and pushes them (to the bench, or to a real endpoint).
The /demo page is the fastest tour — one click pushes the sample workbook
through a vendor dialect and shows per-row results plus the exact wire
traffic (/demo?auto=yardi runs one on load):
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
# start the mock server
.\.venv\Scripts\python -m uvicorn app.main:create_app --factory --port 8000
# in another terminal: generate a sample workbook and push it
.\.venv\Scripts\python -m pusher sample bills.xlsx
.\.venv\Scripts\python -m pusher push bills.xlsx --profile pusher/profiles/entrata-utility-bills.r1.yaml
# or push the same workbook through the MRI or Yardi dialect instead:
.\.venv\Scripts\python -m pusher push bills.xlsx --profile pusher/profiles/mri-utility-bills.yaml
.\.venv\Scripts\python -m pusher push bills.xlsx --profile pusher/profiles/yardi-utility-bills.yaml
# see what landed
curl http://localhost:8000/admin/state/ar-transactionsRun tests: .\.venv\Scripts\python -m pytest
Config env vars: BENCH_DB (SQLite path, default bench.db),
BENCH_USER/BENCH_PASS (Basic-auth creds, default testuser/testpass).
Where the real API is https://YOUR_DOMAIN.entrata.com/api/v1/{resource},
the mock is http://localhost:8000/entrata/api/v1/{resource} (the older
documented /api/{resource} base also works). HTTP Basic auth; the request
envelope follows the official template:
{
"auth": {"type": "basic"},
"requestId": 15,
"method": {
"name": "sendLeaseArTransactions",
"version": "r1",
"params": {
"transaction": [{
"transactionId": "UB-202606-3001-WATER",
"leaseId": "3001",
"arCodeId": "2001",
"transactionAmount": "42.15",
"transactionDate": "06/30/2026",
"arPostMonth": "06/2026",
"description": "Water service 06/01/2026-06/30/2026"
}]
}
}
}Implemented methods (all r1, matching the official docs; see GET / for the
live list):
| Resource | Method | Purpose |
|---|---|---|
| artransactions | sendLeaseArTransactions | push resident charges (per-transaction results in Transactions.Transaction[].@attributes) |
| artransactions | getLeaseArTransactions | full documented ledger structure: leases.lease.ledgers.ledger.transactions.transaction |
| arcodes | getArCodes | AR codes with debit/credit GL account ids |
| properties | getProperties | MITS-style PhysicalProperty.Property records with post months |
| vendors | getVendors | AP payees with locations |
| vendors | sendInvoices | AP invoice batches (ApBatch.ApHeaders.ApHeader[].ApDetails.ApDetail[]) |
Documented behavior you can test against (see FIDELITY.md for the full list):
- Duplicate
transactionIdrejected ("Unique Transaction ID of client system") — exercise idempotency and retry logic. - Per-line failures — invalid lease, AR code, GL account, or payee location fails that line/header while the rest of the batch succeeds.
- The XML-to-JSON traps: single-element collections arrive as an object,
not a one-element array; empty descriptions arrive as
[]; write results hide in@attributes; some methods return everything as strings while others use native types; response envelopes differ per method. If your integration survives the mock, it's handling the real API's sharp edges. - Schema validation — params are validated against JSON Schemas transcribed from the official parameter tables; violations return error code 300 with the JSON path.
Versioning: schemas live in app/vendors/entrata/schemas/{method}.{version}.json.
Adding a version (e.g. sendInvoices r2, which exists officially but wasn't in
the archived docs) means dropping in a schema file and registering a handler —
the dispatch machinery never changes.
Where the real gateway is https://[MRIWebDomain]/mriapiservices/api.asp?$api=[APIName],
the mock is http://localhost:8000/mri/mriapiservices/api.asp?$api=...
(the /mriweb/mriapiservices base also works). Mechanics follow MRI's
official Integrations Guide / Design Guide / MIX Quick Reference
(docs/mri/): HTTP Basic auth with the 4-part
clientID/database/username/partnerkey username (defaults are the docs' own
example creds, P123456/MYDATABASE/WEBUSER/ZYXWVUTSRQ / p455w0rD; override
via BENCH_MRI_USERNAME/BENCH_MRI_PASSWORD), GET/POST/PUT/MERGE plus
X-HTTP-Method-Override, response shaping via $format=atom|json|xml|csv
(AtomPub default, $format trumps headers), $metadata discovery,
$top/$skip paging with NextPageLink, and save responses that echo every
entry with per-entry Error/Message failures.
| API | Verbs | Source |
|---|---|---|
| Vendors | GET, POST, PUT/MERGE | MIX Quick Reference samples |
| Units | GET (paged, PROPERTYID=) |
Design Guide scenario |
| income-categories | GET, POST, PUT/MERGE | Integrations Guide examples |
| ResidentCharges | GET, POST | placeholder custom API — swap in your manifest's real definition (app/vendors/mri/apis.py) |
MRI's model is that most APIs are defined per client via APIDesign, so
ResidentCharges stands in for your company's actual charge API until its
manifest definition is transcribed — see docs/mri/FIDELITY.md.
SOAP dialect built from RealPage's official ODE Vendor Technical
Specifications plus RealPage's own server-generated Unit.asmx templates
(archived WSDL + operation pages; sources in docs/realpage/). Both URL
generations are served:
- ODE:
POST /realpage/os-lr/webservices/availabilityandpricing/unit.asmx(case sensitive, small caps only — as documented) with theapikeyheader (default = the spec's example key, overrideBENCH_REALPAGE_APIKEY) - Legacy:
POST /realpage/WebServices/CrossFire/AvailabilityAndPricing/Unit.asmxwith UserName/Password inside theUserAuthInfoSOAP header (BENCH_REALPAGE_USER/BENCH_REALPAGE_PASS, default testuser/testpass)
GetUnitsByProperty is implemented against the official template — SOAP 1.1
and 1.2, UserAuthInfo SiteID routing (SiteID = property id in the seed
data), WebServiceLogHeader response header, and the documented namespace
trap where UnitObject's children carry xmlns="". No GL/charges service is
faked — that surface was never public; docs/realpage/FIDELITY.md explains
how to add it from your RealPage Exchange clone/partner spec.
POST /yardi/webservices/itfresidenttransactions20.asmx — the Billing &
Payments interface. Provenance differs from the other vendors: Yardi's
docs are licensed per client and were never public, so this adapter is
cross-validated from three independent open-source production integrations
that agree on the contract (see docs/yardi/FIDELITY.md — and verify against
a real Voyager WSDL when one becomes available).
SOAP 1.1 with credentials in the operation body (UserName, Password,
ServerName, Database, Platform, InterfaceEntity, InterfaceLicense — override
via BENCH_YARDI_* env vars), _Login action names, and MITS
ResidentTransactions documents:
| Operation | Purpose |
|---|---|
| ImportResidentTransactions_Login | push charges (TransactionXml MITS document, one Charge/Detail per transaction) |
| GetResidentTransactions_Login | read a property's ledger back as MITS |
| GetResidentTransactions_ByChargeDate_Login | same, filtered by FromDate/ToDate |
Auth failures reproduce Yardi's evidenced behavior: HTTP 200 with
"Login failed." or "Invalid Interface Entity" in the body — the strings
production integrations actually grep for. Other Itf interfaces return a
not-implemented SOAP fault.
| Endpoint | Purpose |
|---|---|
PUT /admin/scenario/{vendor} |
inject failures: {"mode": ..., "delay_ms": 0, "fail_rate": 0.0} |
GET /admin/requests?vendor=&limit= |
request log (every request/response recorded) |
GET /admin/requests/{id} |
full request + response bodies |
GET /admin/state/ar-transactions |
what actually landed (also /ap-invoices, /leases) |
POST /admin/reset |
reseed database, clear log and scenarios |
Generic modes (every vendor): normal, auth_fail (rejects even valid
credentials), http_500, malformed (truncated body), timeout (holds the
connection 30s so client timeouts fire). delay_ms adds latency in any
mode; fail_rate (0–1) makes a random fraction of requests 500 — good for
testing retry behavior.
Vendor-shaped modes (no-op on other vendors) fail the way that vendor actually fails:
| Mode | Vendor | Behavior |
|---|---|---|
api_error |
entrata | HTTP 200 carrying the code-300 error envelope with the request's own requestId echoed back |
invalid_entity |
yardi | HTTP 200 whose body contains the evidenced literal Invalid Interface Entity |
not_in_manifest |
mri | the documented manifest-validation 500 (Failed to validate API...) — MRI's classic misconfiguration |
python -m pusher sample bills.xlsx # sample workbook matching seed data
python -m pusher validate bills.xlsx --profile <yaml> # map + schema-check, no network
python -m pusher push bills.xlsx --profile <yaml> [--url ...] [--dry-run]validate and push accept .xlsx or .csv (CSV needs a header row).
Ready-made test files — clean, broken cells, unknown ids, duplicates,
unicode, credits — live in examples/. The
/demo page has the same flow in the browser: upload your own file, pick a
vendor, and Validate (every bad cell reported) or Push (refused until the
file maps cleanly).
A profile (pusher/profiles/*.yaml) declares the target
vendor/method/version, the column-to-field mapping with types (str, int,
money = 2-dp string, number, date with output format), optional row
grouping, and where per-line results live in the response. A new spreadsheet
layout or API version is a new profile, no code. --dry-run prints the built
payloads; exit code is 0 only if every line succeeded, so it drops straight
into CI.
--url/--user/--password point anywhere, so once a flow passes against
the bench you can aim the same file at a real Entrata endpoint.
The bench is only as faithful as its sources, and captured traffic from a
real endpoint beats any documentation. The capture package closes that
loop:
# recording reverse proxy: forwards to the real host, saves sanitized
# request/response pairs to fixtures/<vendor>/
python -m capture record --upstream https://client.entrata.com --vendor entrata
# point your integration (or the pusher) at http://localhost:8100
# replay fixtures against the bench and diff the responses
python -m capture replay [fixtures/entrata] [--url http://localhost:8000]Credentials are scrubbed at record time (headers, Yardi/RealPage body auth
fields), so fixtures are safe to commit; replay re-injects the bench's own
defaults. The diff masks volatile values (server-assigned ids, UUIDs,
timestamps) — everything else must match exactly. Fixtures marked
golden: true are enforced on every test run by tests/test_fixtures.py;
see fixtures/README.md for the golden-vs-reference distinction and the
workflow when a live capture contradicts the bench (fix the handler, update
FIDELITY.md).
Three properties — 100001 Maple Court Apartments (leases 3001–3005),
100002 Birchwood Flats (leases 3006–3010), and 100003 Château Élan Lofts
(leases 3011–3013), which is deliberately messy: accents in every string
field, an apostrophe surname, and two leases on one unit (Past + Current) —
the traps clean test data hides. Shared utility AR codes (2001 water,
2002 sewer, 2003 trash, 2004 electric, 2005 gas, 2006 late fee), GL accounts
(income 42xx / expense 62xx, AR 1200), and AP payees 501/502 with locations
9501/9502. Edit app/store.py to change it; POST /admin/reset reseeds.
- Get the official documentation first — for Entrata, archived pages cover every method (see FIDELITY.md for the CDX query); for Yardi/RealPage/MRI the specs are behind partner portals, so use real captured traffic or partner docs. Do not guess payload shapes.
- Save the extracted spec under
docs/<vendor>/. - Transcribe the parameter table into a JSON Schema under
app/vendors/<vendor>/schemas/and register a handler with@register(vendor, resource, method, version, schema_path, envelope=...). - For a new vendor, add a routes module that speaks its wire dialect (SOAP
envelope parsing for Yardi/RealPage, OData-ish for MRI) and include it in
app/main.py. The store, scenario injection, request log, and admin plane are shared.
.\.venv\Scripts\python -m pytest tests/ -q # full suite, incl. golden-fixture replay
.\.venv\Scripts\python -m ruff check . # lint (CI enforces both)Layout: app/ mock server (one module per vendor dialect under
app/vendors/), pusher/ Excel/CSV CLI, capture/ record & replay,
fixtures/ committed golden captures, docs/<vendor>/FIDELITY.md the
per-vendor source-and-gaps ledger.
This is an independent testing tool with no affiliation to, or endorsement
by, Entrata, Yardi, RealPage, or MRI Software — all product names and
trademarks belong to their owners. The mocks are transcribed from publicly
available vendor documentation (sources per vendor in
docs/<vendor>/FIDELITY.md) and contain no vendor code. Fidelity varies by
vendor and every known gap or inference is documented — treat the bench as
a development aid, and verify against a real environment before shipping an
integration. All credentials in the repo are documentation examples or
made-up test values.
MIT licensed — see LICENSE.
