BigBrainParking/docs/PARKSMARTER_API.md
Hank 70047b3964 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>
2026-07-13 19:25:54 -07:00

16 KiB

ParkSmarter API — usage guide & endpoint reference

A commented guide to the ParkSmarter (IPS Group) API as exposed by 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; 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.

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 (requestVerifyUserverifyUser).
  • 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.

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()
// 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.
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:

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

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).

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.