- 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>
321 lines
16 KiB
Markdown
321 lines
16 KiB
Markdown
# 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.
|