# 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.28, longitude: -116.55 }); // ~downtown Sandpoint 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.