docs: add ParkSmarter API usage guide + endpoint reference

- docs/PARKSMARTER_API.md: narrative guides (bootstrap/auth, finding parking,
  estimates, initiating a transaction, sessions/receipts, account) + per-endpoint
  reference + a behaviors/gotchas table (200-on-error, stale-token empty body,
  multi-estimate fallback, free windows, flat-rate zones, no free check-in).
- client README: link the guide; promote PastSession/ParkingReceipt to CONFIRMED
  (ActiveSession still pending — no active session at capture time).
- Main README: mark "Start a paid session" as working (confirmed live end-to-end).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 19:25:54 -07:00
parent 8a2a87a1a9
commit 70047b3964
3 changed files with 346 additions and 13 deletions

View file

@ -38,8 +38,8 @@ bigbrainparking/
| "Near my last location" proximity search | ✅ wired | caches last GPS fix |
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
| Active / past sessions | ✅ wired | list views |
| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
| Active / past sessions | ✅ wired | list views + tap for full receipt |
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |

321
docs/PARKSMARTER_API.md Normal file
View file

@ -0,0 +1,321 @@
# ParkSmarter API — usage guide & endpoint reference
A commented guide to the ParkSmarter (IPS Group) API as exposed by
[`parksmarter-client`](../parksmarter-client). It reverse-engineers the official
Android app (`com.ipsgroupinc.parksmarter` 4.4.0) and has been verified against
production. For install/quick-start and the auth-header model, see the
[client README](../parksmarter-client/README.md); this document focuses on **how to
actually use the API** — searching for parking and running a transaction — plus a
per-endpoint reference and the behavioral quirks worth knowing.
> Everything here is for interoperability/research with **your own** account. All
> methods below are on a `ParkSmarterClient` instance (`const ps = new ParkSmarterClient(...)`).
---
## Mental model
- **Zone** — a metered area (e.g. Sandpoint `DSB3`, `DL`). Has an id (`ZoneId`), a
`CustomerId` (the operator/city, e.g. `217` = "Sandpoint, ID"), and one or more **Spaces**.
- **Space** — an individual stall/segment within a zone (`SpaceId`). Estimates and
sessions are always for a specific `(zone, space)`.
- **Policy** — a zone/space's schedule: a list of time slots, each with a `RateType`
(`Free`, `Hour`, `Variable`, `No Parking`, `Prepay`), a `Rate`, a `MaxTime`, and a
`CurrentSlot` flag marking the one in effect now. Policies are **descriptive** — they
tell you when/what it costs; there is no "check-in", grace-token, or free-session
concept in the API.
- **Estimate** — a price quote for parking a given `(zone, space, vehicle)` for some
duration. Three flavors (multi / single / items) — see below.
- **Session** — a paid parking transaction (`POST /api/Session`). Requires a card and a
charge; there is **no $0 / free session**. Free parking is simply unmetered time.
---
## 1. Bootstrap & authentication
Auth is **custom-header**, not OAuth. Call `getApplicationValidity()` first (seeds a
`ParkSmarter_SessionId`), then log in. The client stores `Auth_Token` + `SessionId`
automatically and refreshes the rolling `Auth_Token` from the `Response` envelope.
```ts
const ps = new ParkSmarterClient({ environment: 'prodv2' });
const validity = await ps.getApplicationValidity();
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
await ps.loginWithPhone({ phoneNumber: '5551234567', password: '…' });
// From here, authenticated endpoints work. Auth_Token is sent as a header.
```
**Gotchas**
- A **failed login returns HTTP 200** with `Status: "Error"` + null `Auth_Token`. The
client throws `LoginError` for you; if you call `/api/Auth` yourself, check both.
- A `POST /api/Auth` returning **201** means the account needs SMS verification — not a
normal login. Treat it distinctly (`requestVerifyUser``verifyUser`).
- **Validating a stored token:** a stale/expired `Auth_Token` does **not** 401 —
`GET /api/User` just returns an **empty body** (`getUserDetail()` resolves to
`undefined`). To check "am I really signed in?", require real fields:
`const u = await ps.getUserDetail(); const ok = !!(u && (u.PersonalPhone || u.PersonalEmailAddress));`
- Persist tokens across launches with a `TokenStore` (see README's RN example) so you
don't have to log in every time.
---
## 2. Finding parking (search)
There are several ways in, all returning the same `MetersResponse` shape
(`{ Zones: Zone[], Response }`). **Meter search requires a valid `Auth_Token`** — these
lookups 401 when signed out, despite being "data" reads.
| You have… | Use | Sends |
| --- | --- | --- |
| A map location | `getMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
| A map location (lighter list) | `getLimitedMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
| A zone name (e.g. `"DL"`) | `getMetersByZoneName('DL')` | `ZoneName` |
| Free-text zone/space | `searchMetersByZoneOrSpace('Main St')` | `Query` |
| A meter serial number | `getMetersBySerialNumber('…')` | `TerminalSerNo` |
| A scanned QR/barcode | `getMetersByScannerCode('DSB13')` | `ScannerCode` |
| Gated lots w/ occupancy | `getParkingLots()` | — |
```ts
// By location (only a single coordinate is ever sent — the point you're searching):
const res = await ps.getMetersByLocation({ latitude: 48.2754, longitude: -116.5478 });
const zones = res.Zones ?? [];
// Pick a zone + space to act on:
const zone = zones[0];
const space = zone.Spaces?.[0];
const base = {
zoneId: zone.ZoneId!,
spaceId: space!.SpaceId!,
customerId: zone.CustomerId!,
};
// Is it free / paid right now? Read the CurrentSlot policy:
const current = space!.Policies?.find((p) => p.CurrentSlot);
const free = (current?.RateType ?? '').toLowerCase().includes('free');
```
**Notes**
- `Zone.Lat`/`Long` in results is the **meter's** location (for map pins), not yours.
- QR codes on kiosks are often a generic `parksmarter.com/home/processQR` URL with **no
zone in it** — fall back to prompting for the printed Zone ID and
`getMetersByScannerCode`/`getMetersByZoneName`.
- Scanner codes map to zones: e.g. `DSB13` → zone `113150`.
---
## 3. Getting a price (estimates)
Before a transaction you quote a price for a `(zone, space, vehicle)`. Three endpoints:
| Method | Path | Use |
| --- | --- | --- |
| `getParkingEstimateMulti(base)` | `/api/ParkingEstimateMulti` | A **ladder** of durations → prices (the app's default). |
| `getParkingEstimateSingle({…, durationInMinutes, creditCardId})` | `/api/ParkingEstimate` | One duration → one price. |
| `getParkingEstimateItems(base)` | `/api/ParkingEstimateItems` | Item/product-style options. |
```ts
const vehicleId = (await ps.getUserDetail()).VehicleDetails![0].VehicleID!;
const q = { ...base, vehicleId };
const multi = await ps.getParkingEstimateMulti(q);
const ladder = multi.ParkingDetail ?? []; // [{ Minutes, StartTime, EndTime, ParkingCost, TransactionFee }, …]
```
**This is where most of the quirks live:**
- **Multi is not universal.** Some zones (e.g. short-term `DL` spaces) reject
`ParkingEstimateMulti` with HTTP 200 + `Response.Status: "Error"`
("Unable to process your request…") even though **single works**. Detect the error
envelope and fall back to `getParkingEstimateSingle` across the zone's `MinTime..MaxTime`.
- **Free windows signal via `MaxTime: 0` / `$0.00`.** During a free period the single
estimate returns `MinTime: 5, MaxTime: 0, ParkingCost: "0.00"` and multi returns an
empty/error ladder. Treat `MaxTime <= 0` (helper: `isFreeEstimate`) or an all-`$0.00`
ladder (`ladderAllFree`) as "currently free — just park", **not** a purchasable option.
- **Flat-rate zones.** Some zones charge one flat price for any duration up to a cap
(e.g. `DL` = **$0.10 flat** until the ~5 PM boundary). Every single-estimate duration
returns the same `ParkingCost`; `MaxTime` is *minutes remaining until the paid-window
boundary* and shrinks through the day. Offer one option ("park to MaxTime"), not a
ladder of identical prices.
- **`ParkingDetail.Minutes` may be `0`** on single estimates — the real duration is in
`StartTime`/`EndTime`. Trust the duration you requested.
- **Estimates need auth** (like meter search).
---
## 4. Initiating a transaction (start a session)
`startParkingSession()` (`POST /api/Session`) is the **only** session-creating call, and
it **charges the card**. Build it from a chosen estimate rung:
```ts
const rung = ladder[selectedIndex]; // from a multi/single estimate
const res = await ps.startParkingSession({
creditCardId: card.CCID!,
vehicleId,
zoneId: base.zoneId,
spaceId: base.spaceId,
customerId: base.customerId,
meterTypeId: zone.MeterTypeId!,
startTime: rung.StartTime!,
endTime: rung.EndTime!,
minutesToPurchase: rung.Minutes!,
parkingCost: Number(rung.ParkingCost ?? 0),
transactionFee: Number(rung.TransactionFee ?? 0),
minCreditAmount: zone.MinimumAmount, // optional
});
```
**Request body** carries `CCID, Amount (cost+fee), SpaceID, StartTime, EndTime,
CustomerID, VehicleID, TimePurchased, ParkingCost, TransactionFee, ZoneID,
MinCreditAmount?, MeterTypeId` (and `BleEncBytes` for BLE meters). **No device location
is sent.**
**Gotchas**
- **A decline still returns HTTP 200**, with `Response.Status: "Error"` and often
`OriginalErrorMessage: "DECLINED"`. The client throws on this envelope so you don't get a
phantom success — surface `serverMessage` to the user.
- **You cannot buy during a free window.** The app blocks purchase
("Purchases are currently not allowed. Parking is currently free.") and the API rejects
zero-minute / zero-cost sessions (`unableToPurchaseZeroMinutes`, `minTimeNotReached`).
- **No free check-in.** There is no way to register a $0 session; free time is just
unmetered — you park, enforcement (LPR/chalk) handles the limit.
- Schedule your own **local** expiry reminder from `EndTime`; the API has no per-session
reminder push.
---
## 5. Sessions & receipts
```ts
const active = (await ps.getActiveParkingSessions()).ParkingSession ?? [];
const past = (await ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 })).Session ?? [];
// Full receipt for a past session (auth code, payment, amounts):
const tid = past[0]?.TransactionID;
const receipt = (await ps.getParkingReceipt(tid!)).ParkingReceipt;
await ps.emailParkingReceipt(tid!); // emails it to the account holder
```
- `PastSession` uses `Zone`/`Description` (not `ZoneName`) for the zone label, and echoes
`Lat`/`Long` of the meter.
- `ParkingReceipt` has `PaymentType`, `PaymentDisplay` ("MASTERCARD"), masked `CC`,
`AuthCode`, `Vehicle` (plate), `Amount`/`TransactionFee`/`Total` (display strings) plus
`*Value` numerics.
---
## 6. Account management
| Task | Method(s) |
| --- | --- |
| Vehicles | `addVehicle`, `updateVehicle`, `deleteVehicle` (list via `getUserDetail().VehicleDetails`) |
| Cards | `addCard`, `updateCard`, `setCardDefault`, `deleteCard` (list via `getUserDetail().CreditCardDetails`) |
| Profile | `updateProfile`, `requestDeleteUser` |
| Password | `requestResetPassword`, `updatePassword` |
| Notifications | `getNotificationSettings`, `setNotificationSettings` |
Mutations return a `{ Status, Message }` envelope — treat `Status === 'Success'` as the
success signal. Card numbers are only ever **sent** (add/update); responses mask them.
---
## Behaviors & gotchas (quick reference)
| Behavior | Detail |
| --- | --- |
| 200-on-error | Failed login / declined payment / bad request often return **HTTP 200** with `Response.Status: "Error"`. Always check the envelope, not just the status code. |
| Stale token ≠ 401 | An expired `Auth_Token` yields an **empty** `/api/User` body, not a 401. Validate by requiring real user fields. |
| Rolling token refresh | Non-empty `Response.Auth_Token` (or top-level) on any response replaces your token — the client stores it. |
| Meter/estimate search needs auth | `/api/Meter`, `/api/MeterList`, and estimates 401 when signed out. |
| Multi estimate unsupported on some zones | Falls back to single; detect `Response.Status: "Error"`. |
| Free window = `MaxTime 0` / `$0.00` | Show "currently free", don't offer a $0 ladder. |
| Flat-rate zones | One price for any duration; `MaxTime` = minutes to the paid-window boundary. |
| No free check-in | Only paid sessions exist; free parking is unmetered time. |
| `localeCode` | Every request carries `localeCode` (default `en`). |
| Non-prod is IP-restricted | `dev`/`stage`/`test` return 403 "Web App - Unavailable" from the public internet. |
---
## Endpoint reference
All methods are on `ParkSmarterClient`. Friendly camelCase inputs are mapped to the wire
format; responses are the raw server PascalCase JSON (typed in
[`src/types.ts`](../parksmarter-client/src/types.ts)).
### Bootstrap
- **`getApplicationValidity()`** → `GET /api/ApplicationValidity` — feature flags,
maintenance/upgrade, seeds `SessionId`. Public. Call first.
### Auth
- **`loginWithPhone({ phoneNumber, password })`** → `POST /api/Auth` — persists token.
- **`loginWithApple({ emailAddress, appleId, providerAuth, providerIdentity })`** → `POST /api/Auth`.
- **`loginWithCachedToken(authToken)`** — set a stored token (then bootstrap).
- **`logoutAllDevices()`** → `POST /api/Auth/Logout` — server-side invalidate.
- **`logoutLocal()`** — clear local tokens only.
### Sign-up / password / verification
- **`signUp({ emailAddress, mobilePhone, password })`** → `POST /api/User`. Public.
- **`requestResetPassword({ reqType, phoneNumber })`** → `POST /api/Password`. Public.
- **`updatePassword({ oldPassword, newPassword })`** → `PUT /api/Password`.
- **`requestVerifyUser({ phoneNumber })`** → `POST /api/UserVerification` — SMS code.
- **`verifyUser({ phoneNumber, code })`** → `GET /api/UserVerification`.
- **`isEmailRegistered(email)` / `isPhoneRegistered(phone)`** → `GET /api/User`. Public boolean.
### Profile / device
- **`getUserDetail()`** → `GET /api/User` — profile incl. `VehicleDetails` + `CreditCardDetails`. (Empty body ⇒ token stale.)
- **`updateProfile({ emailAddress, phoneNumber })`** → `PUT /api/User`.
- **`requestDeleteUser()`** → `DELETE /api/User`.
- **`updateDeviceToken({ pushNotificationsToken, imeiNumber, deviceType?, language })`** → `PUT /api/Device`. (BigBrainParking does **not** call this — avoids sending device id/IMEI.)
### Vehicles
- **`addVehicle({ plate, state, vehicleAlias, isDefaultVehicle? })`** → `POST /api/Vehicle`.
- **`updateVehicle({ id, plate, state, vehicleAlias, isDefaultVehicle? })`** → `PUT /api/Vehicle`.
- **`deleteVehicle(vehicleId)`** → `DELETE /api/Vehicle`.
### Cards
- **`addCard({ cardNumber, alias, expDate, zipCode, isDefaultCard? })`** → `POST /api/Card`.
- **`updateCard({ id, … })`** → `POST /api/Card` (full replace).
- **`setCardDefault({ id, isDefaultCard })`** → `PUT /api/Card`.
- **`deleteCard(cardId)`** → `DELETE /api/Card`.
### Meters / zones
- **`getMetersByLocation({ latitude, longitude })`** → `GET /api/Meter?Lat=&Long=`.
- **`getLimitedMetersByLocation({ latitude, longitude })`** → `GET /api/MeterList?Lat=&Long=`.
- **`getMetersByZoneName(zoneName)`** → `GET /api/Meter?ZoneName=`.
- **`searchMetersByZoneOrSpace(query)`** → `GET /api/Meter?Query=`.
- **`getMetersBySerialNumber(serial)`** → `GET /api/Meter?TerminalSerNo=`.
- **`getMetersByScannerCode(code)`** → `GET /api/Meter?ScannerCode=`.
- **`getParkingLots()`** → `GET /api/ParkingLogix` — gated lots + occupancy.
### Estimates
- **`getParkingEstimateMulti({ zoneId, spaceId, customerId, vehicleId, minCreditAmount?, bleEncBytes? })`** → `GET /api/ParkingEstimateMulti` — duration ladder.
- **`getParkingEstimateSingle({ …, durationInMinutes, creditCardId })`** → `GET /api/ParkingEstimate` — one duration.
- **`getParkingEstimateItems(base)`** → `GET /api/ParkingEstimateItems`.
- Helpers: **`isFreeEstimate(probe)`**, **`ladderAllFree(rungs)`** (pure; exported).
### Sessions / receipts
- **`startParkingSession({ creditCardId, vehicleId, zoneId, spaceId, customerId, meterTypeId, startTime, endTime, minutesToPurchase, parkingCost, transactionFee, minCreditAmount?, bleEncBytes? })`** → `POST /api/Session`. **Charges the card.** Throws on the `Status:Error` decline envelope.
- **`getActiveParkingSessions()`** → `GET /api/ParkingSession`.
- **`getPastParkingSessions({ currentPage, pageSize })`** → `GET /api/Session`.
- **`getParkingReceipt(transactionId)`** → `GET /api/ParkingReceipt`.
- **`emailParkingReceipt(id)`** → `POST /api/ParkingReceipt` — email to account holder.
### Settings / content
- **`getNotificationSettings()` / `setNotificationSettings(...)`** → `/api/Setting`.
- **`getStates()`** → `GET /api/State`. (Note: seen returning 404 on prodv2 — pass an id form if needed.)
- **`getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()`** → `GET /api/ParkSmarter*` — static content.
---
## Verification status
Response models are verified against production (`sweep.mjs` records field-names+types
only, no PII). **CONFIRMED live:** login, `UserDetail`, vehicles, cards,
`Zone`/`Space`/`SpacePolicy`, parking lots, all three estimates, and — from a real DL-zone
session — `PastSession` and `ParkingReceipt`. **Still UNCONFIRMED:** `ActiveSession` (no
active session existed on the account at capture time). Re-run `sweep.mjs` (schema diff) or
`capture-dl.mjs` (full DL request/response record) to refresh.
Not affiliated with or endorsed by IPS Group / ParkSmarter.

View file

@ -12,6 +12,11 @@ Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifical
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
directly onto Retrofit/OkHttp — ask and it can be ported.
> **Usage guide:** for narrative walk-throughs (searching, quoting a price, running a
> transaction) plus per-endpoint commentary and the behavioral quirks, see
> **[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)**. The tables below are the
> quick reference.
## Install / build
```bash
@ -170,19 +175,26 @@ Request shapes were recovered from the app code and are exact. Response models w
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
states wrapper, the password-reset flow, and the shared `Response` envelope.
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
account with at least one past/active session to confirm.
states wrapper, the password-reset flow, the shared `Response` envelope, and — from a real
DL-zone session — `PastSession` and `ParkingReceipt`.
- **UNCONFIRMED:** `ActiveSession` (no *active* session existed on the account at capture
time — field names are from static analysis; capture while parked to confirm).
- `capture-dl.mjs` records a full DL-zone request/response set; `sweep.mjs` refreshes the
PII-free schema skeletons.
Two behaviors worth knowing (both confirmed live):
Behaviors worth knowing (all confirmed live — full list in
[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)):
1. **Meter search requires auth.** `/api/Meter` and `/api/MeterList` return **401** without a
valid `Auth_Token`, despite being data lookups.
2. **`Response` envelope + token refresh.** Most authenticated responses embed
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
it automatically.
1. **200-on-error.** Failed login, declined payment, and some bad requests return **HTTP
200** with `Response.Status: "Error"`. Check the envelope, not just the status code.
2. **Meter/estimate search requires auth.** `/api/Meter`, `/api/MeterList`, and estimates
**401** without a valid `Auth_Token`, despite being data lookups.
3. **Rolling token refresh.** A non-empty `Response.Auth_Token` (or top-level) replaces your
user token — the client stores it automatically.
4. **Stale token ≠ 401.** An expired token makes `GET /api/User` return an **empty body**
(not a 401); validate by requiring real user fields before trusting the session.
5. **Estimate quirks.** Multi is unsupported on some zones (falls back to single);
`MaxTime: 0`/`$0.00` means a free window; some zones are flat-rate.
To capture the remaining UNCONFIRMED models, re-run `sweep.mjs` on an account that has a
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted