The Sandpoint lat/lng from .creds.json had been baked into the API-doc example and the integration test. Doc now uses a rounded ~downtown point; the test reads the coordinate from the gitignored .creds.json (rounded, non-personal fallback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
ParkSmarterClientinstance (const ps = new ParkSmarterClient(...)).
Mental model
- Zone — a metered area (e.g. Sandpoint
DSB3,DL). Has an id (ZoneId), aCustomerId(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), aRate, aMaxTime, and aCurrentSlotflag 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"+ nullAuth_Token. The client throwsLoginErrorfor you; if you call/api/Authyourself, check both. - A
POST /api/Authreturning 201 means the account needs SMS verification — not a normal login. Treat it distinctly (requestVerifyUser→verifyUser). - Validating a stored token: a stale/expired
Auth_Tokendoes not 401 —GET /api/Userjust returns an empty body (getUserDetail()resolves toundefined). 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() |
— |
// 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/Longin results is the meter's location (for map pins), not yours.- QR codes on kiosks are often a generic
parksmarter.com/home/processQRURL with no zone in it — fall back to prompting for the printed Zone ID andgetMetersByScannerCode/getMetersByZoneName. - Scanner codes map to zones: e.g.
DSB13→ zone113150.
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
DLspaces) rejectParkingEstimateMultiwith HTTP 200 +Response.Status: "Error"("Unable to process your request…") even though single works. Detect the error envelope and fall back togetParkingEstimateSingleacross the zone'sMinTime..MaxTime. - Free windows signal via
MaxTime: 0/$0.00. During a free period the single estimate returnsMinTime: 5, MaxTime: 0, ParkingCost: "0.00"and multi returns an empty/error ladder. TreatMaxTime <= 0(helper:isFreeEstimate) or an all-$0.00ladder (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 sameParkingCost;MaxTimeis 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.Minutesmay be0on single estimates — the real duration is inStartTime/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 oftenOriginalErrorMessage: "DECLINED". The client throws on this envelope so you don't get a phantom success — surfaceserverMessageto 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
PastSessionusesZone/Description(notZoneName) for the zone label, and echoesLat/Longof the meter.ParkingReceipthasPaymentType,PaymentDisplay("MASTERCARD"), maskedCC,AuthCode,Vehicle(plate),Amount/TransactionFee/Total(display strings) plus*Valuenumerics.
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, seedsSessionId. 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 theStatus:Errordecline 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.