From 70047b3964fe2b169454e3031220ade07e7ebd87 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 19:25:54 -0700 Subject: [PATCH] docs: add ParkSmarter API usage guide + endpoint reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 +- docs/PARKSMARTER_API.md | 321 +++++++++++++++++++++++++++++++++++ parksmarter-client/README.md | 34 ++-- 3 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 docs/PARKSMARTER_API.md diff --git a/README.md b/README.md index d4f4341..0193d45 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/PARKSMARTER_API.md b/docs/PARKSMARTER_API.md new file mode 100644 index 0000000..d0c746f --- /dev/null +++ b/docs/PARKSMARTER_API.md @@ -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. diff --git a/parksmarter-client/README.md b/parksmarter-client/README.md index 667804f..4cc789c 100644 --- a/parksmarter-client/README.md +++ b/parksmarter-client/README.md @@ -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