Initial commit: parksmarter-client + BigBrainParking app

Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a
de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS,
VisionCamera QR kiosk scanning with save/share, local session-expiry reminders,
UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 08:33:47 -07:00
commit 1dede50995
39 changed files with 3671 additions and 0 deletions

View file

@ -0,0 +1,7 @@
{
"phoneNumber": "5551234567",
"password": "your-parksmarter-password",
"environment": "prodv2",
"lat": 40.4406,
"lng": -79.9959
}

4
parksmarter-client/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.creds.json
capture/

View file

@ -0,0 +1,195 @@
# parksmarter-client
An unofficial, typed **TypeScript** client for the ParkSmarter (IPS Group) parking
API. It was reverse-engineered from the official Android app
`com.ipsgroupinc.parksmarter` 4.4.0 — a React Native / Hermes build — by decompiling
the JS bundle and recovering every endpoint path, header, app token, and
request/response field name.
TypeScript was chosen because the target is "an alternative Android app or web app":
the same package runs unchanged in a browser, in React Native (Expo/bare), and in
Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifically
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
directly onto Retrofit/OkHttp — ask and it can be ported.
## Install / build
```bash
cd parksmarter-client
npm install
npm run build # emits dist/
```
Import from source (`src/index.ts`) or the built `dist/`.
## Quick start
```ts
import { ParkSmarterClient } from './src';
const ps = new ParkSmarterClient({ environment: 'prodv2' }); // default env
// 1) Bootstrap — seeds a ParkSmarter_SessionId and returns feature flags.
const validity = await ps.getApplicationValidity();
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
// 2) Log in — Auth_Token + SessionId are stored automatically.
await ps.loginWithPhone({ phoneNumber: '5551234567', password: 'hunter2' });
// 3) Use authenticated endpoints.
const me = await ps.getUserDetail();
const nearby = await ps.getMetersByLocation({ latitude: 40.44, longitude: -79.99 });
// 4) Start a session.
await ps.startParkingSession({
creditCardId: me.CreditCardDetails![0].CCID!,
vehicleId: me.VehicleDetails![0].VehicleID!,
zoneId: nearby.Zones![0].ZoneId!,
spaceId: nearby.Zones![0].Spaces![0].SpaceID!,
customerId: nearby.Zones![0].CustomerID!,
meterTypeId: nearby.Zones![0].MeterTypeId!,
startTime: new Date().toISOString(),
endTime: new Date(Date.now() + 3600_000).toISOString(),
minutesToPurchase: 60,
parkingCost: 2.0,
transactionFee: 0.35,
});
```
### React Native / Node < 18
Pass a `fetchImpl` if the global `fetch` isn't present, and use persistent storage
for tokens:
```ts
import { ParkSmarterClient, TokenStore } from './src';
import * as SecureStore from 'expo-secure-store';
const tokens: TokenStore = {
getAuthToken: () => SecureStore.getItemAsync('ps_auth'),
setAuthToken: (t) =>
t ? SecureStore.setItemAsync('ps_auth', t) : SecureStore.deleteItemAsync('ps_auth'),
getSessionId: () => SecureStore.getItemAsync('ps_session'),
setSessionId: (s) =>
s ? SecureStore.setItemAsync('ps_session', s) : SecureStore.deleteItemAsync('ps_session'),
};
const ps = new ParkSmarterClient({ tokens /*, fetchImpl: fetch */ });
```
## Authentication model
This API does **not** use OAuth/Bearer. Auth is carried in custom headers:
| Header | Meaning | Source |
| --- | --- | --- |
| `Application_Token` | Identifies the app build. Required on **every** request. | Per-environment constant (baked in). |
| `X-Request-Id` | Fresh UUID per request. | Generated by the client. |
| `Auth_Token` | The logged-in user's token. | Returned in the **body** of `POST /api/Auth` as `Auth_Token`; sent as a header on authenticated calls. |
| `ParkSmarter_SessionId` | Server session id. | Seeded by `GET /api/ApplicationValidity` and login; echoed on some responses. |
| `Content-Type: application/json` | On POST/PUT only. | — |
The client persists `Auth_Token` and `SessionId` into the `TokenStore` automatically.
Every request also carries a `localeCode` query param (default `en`).
> Note: a `POST /api/Auth` returning HTTP **201** is treated by the app as a
> "needs verification / not a success" case, not a normal login. Handle 201 distinctly
> if you see it.
## Environments
| Name | Base URL | Notes |
| --- | --- | --- |
| `dev` | `https://dev-parksmarter-api.ipsmeters.com` | |
| `stage` | `https://staging-parksmarter-api.ipsmeters.com` | |
| `test` | `https://testing-parksmarter-api.ipsmeters.com` | |
| `prodv1` | `https://api.parksmarter.com` | |
| `prodv2` | `https://apiv2.parksmarter.com` | **default** |
| `prodv3` | `https://apiv3.parksmarter.com` | |
Application tokens for each are in `src/environments.ts`.
## Endpoint reference
Every method maps to one endpoint. Method is inferred from the app's naming.
| Client method | HTTP | Path |
| --- | --- | --- |
| `getApplicationValidity()` | GET | `/api/ApplicationValidity` |
| `loginWithPhone()` / `loginWithApple()` | POST | `/api/Auth` |
| `logoutAllDevices()` | POST | `/api/Auth/Logout` |
| `signUp()` | POST | `/api/User` |
| `getUserDetail()` | GET | `/api/User` |
| `updateProfile()` | PUT | `/api/User` |
| `requestDeleteUser()` | DELETE | `/api/User` |
| `isEmailRegistered()` / `isPhoneRegistered()` | GET | `/api/User` |
| `requestResetPassword()` | POST | `/api/Password` |
| `updatePassword()` | PUT | `/api/Password` |
| `requestVerifyUser()` | POST | `/api/UserVerification` |
| `verifyUser()` | GET | `/api/UserVerification` |
| `updateDeviceToken()` | PUT | `/api/Device` |
| `addVehicle()` | POST | `/api/Vehicle` |
| `updateVehicle()` | PUT | `/api/Vehicle` |
| `deleteVehicle()` | DELETE | `/api/Vehicle` |
| `addCard()` / `updateCard()` | POST | `/api/Card` |
| `setCardDefault()` | PUT | `/api/Card` |
| `deleteCard()` | DELETE | `/api/Card` |
| `getMetersByLocation()` | GET | `/api/Meter` (`Lat`,`Long`) |
| `getLimitedMetersByLocation()` | GET | `/api/MeterList` (`Lat`,`Long`) |
| `getMetersByZoneName()` / `searchMetersByZoneOrSpace()` | GET | `/api/Meter` (`ZoneName`/`Query`) |
| `getMetersBySerialNumber()` / `getMetersByScannerCode()` | GET | `/api/Meter` (`TerminalSerNo`/`ScannerCode`) |
| `getParkingLots()` | GET | `/api/ParkingLogix` |
| `getParkingEstimateMulti()` | GET | `/api/ParkingEstimateMulti` |
| `getParkingEstimateSingle()` | GET | `/api/ParkingEstimate` |
| `getParkingEstimateItems()` | GET | `/api/ParkingEstimateItems` |
| `startParkingSession()` | POST | `/api/Session` |
| `getActiveParkingSessions()` | GET | `/api/ParkingSession` |
| `getPastParkingSessions()` | GET | `/api/Session` |
| `getParkingReceipt()` | GET | `/api/ParkingReceipt` |
| `emailParkingReceipt()` | POST | `/api/ParkingReceipt` |
| `getNotificationSettings()` | GET | `/api/Setting` |
| `setNotificationSettings()` | POST | `/api/Setting` |
| `getStates()` | GET | `/api/State` |
| `getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()` | GET | `/api/ParkSmarter*` |
## Field-name conventions
The server uses PascalCase ("PSJSON"). This client accepts friendly camelCase inputs
and maps them to the wire format for you; **responses are returned as the raw server
PascalCase JSON** and typed accordingly in `src/types.ts`. Response interfaces include
an index signature because not every optional field is guaranteed on every call — the
well-known fields are typed explicitly.
## Accuracy & verification status
Request shapes were recovered from the app code and are exact. Response models were then
**verified against production** by logging in and calling every read-only endpoint (see
`sweep.mjs`, which records field-names+types only — no PII). Each interface in
`src/types.ts` is annotated CONFIRMED or UNCONFIRMED.
- **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.
Two behaviors worth knowing (both confirmed live):
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.
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
device/emulator is also an option if you'd rather see the app's own traffic.
## Legal
For interoperability/research with your own account. Not affiliated with or endorsed by
IPS Group / ParkSmarter. Respect their Terms of Service and applicable law.
```

30
parksmarter-client/package-lock.json generated Normal file
View file

@ -0,0 +1,30 @@
{
"name": "parksmarter-client",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "parksmarter-client",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View file

@ -0,0 +1,25 @@
{
"name": "parksmarter-client",
"version": "0.1.0",
"description": "Unofficial TypeScript client for the ParkSmarter (IPS Group) parking API. Reverse-engineered from the official Android app 4.4.0.",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src", "README.md"],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": ["parksmarter", "parking", "ips", "api-client"],
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"
}
}

View file

@ -0,0 +1,713 @@
/**
* ParkSmarterClient a typed wrapper over the ParkSmarter (IPS Group) REST API.
*
* Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0,
* a React Native / Hermes build). All endpoint paths, header names, the app-token
* scheme, and request/response field names were recovered from the app bundle.
*
* Auth model (NOT OAuth bearer):
* - `Application_Token` : identifies the app build; required on every call.
* - `Auth_Token` : the logged-in user's token; returned by POST /api/Auth
* (login) in the body field `Auth_Token`, then sent as a
* header on authenticated calls.
* - `ParkSmarter_SessionId` : a server session id; seeded by
* GET /api/ApplicationValidity and login, echoed on some
* responses. Sent as a header when present.
* - `X-Request-Id` : a fresh UUID per request.
*
* The client persists Auth_Token and SessionId into the provided TokenStore
* automatically after login / bootstrap.
*/
import {
DEFAULT_ENVIRONMENT,
ENVIRONMENTS,
Environment,
EnvironmentName,
} from './environments.js';
import {
HttpClient,
MemoryTokenStore,
ParkSmarterResponse,
TokenStore,
} from './http.js';
import * as T from './types.js';
export interface ParkSmarterClientOptions {
/** Named environment (default 'prodv2') or a fully custom Environment. */
environment?: EnvironmentName | Environment;
/** Token persistence. Defaults to in-memory. Use secure storage in real apps. */
tokens?: TokenStore;
/** Locale sent as the `localeCode` query param (default 'en'). */
localeCode?: string;
timeoutMs?: number;
/** Provide a fetch impl for React Native / Node < 18 / tests. */
fetchImpl?: typeof fetch;
uuid?: () => string;
}
function resolveEnvironment(
env: EnvironmentName | Environment | undefined,
): Environment {
if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT];
if (typeof env === 'string') return ENVIRONMENTS[env];
return env;
}
export class ParkSmarterClient {
readonly http: HttpClient;
readonly tokens: TokenStore;
constructor(options: ParkSmarterClientOptions = {}) {
this.tokens = options.tokens ?? new MemoryTokenStore();
this.http = new HttpClient({
environment: resolveEnvironment(options.environment),
tokens: this.tokens,
localeCode: options.localeCode,
timeoutMs: options.timeoutMs,
fetchImpl: options.fetchImpl,
uuid: options.uuid,
});
}
/** Set the locale used for the `localeCode` query param on subsequent calls. */
setLocale(localeCode: string): void {
this.http.setLocaleCode(localeCode);
}
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
return p.then((r) => r.data);
}
/* ============================================================== */
/* Bootstrap / app validity */
/* ============================================================== */
/**
* GET /api/ApplicationValidity call this first. Returns feature flags,
* maintenance/upgrade status, and a SessionId that is stored automatically.
* Public (no Auth_Token required).
*/
async getApplicationValidity(): Promise<T.ApplicationValidityResponse> {
const res = await this.http.request<T.ApplicationValidityResponse>({
method: 'GET',
path: '/api/ApplicationValidity',
query: { ApplicationToken: this.http.environment.appToken },
includeAuthToken: false,
});
return res.data;
}
/* ============================================================== */
/* Auth */
/* ============================================================== */
/** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: { UserName: params.phoneNumber, Password: params.password },
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
}
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
async loginWithApple(params: T.LoginWithAppleParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: {
EmailAddress: params.emailAddress,
AppleUserId: params.appleId,
ProviderAuth: params.providerAuth,
ProviderIdentity: params.providerIdentity,
},
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
}
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */
async loginWithCachedToken(authToken: string): Promise<void> {
await this.tokens.setAuthToken(authToken);
}
/** POST /api/Auth/Logout — invalidate the token on all devices. */
async logoutAllDevices(): Promise<void> {
await this.http.request({ method: 'POST', path: '/api/Auth/Logout' });
}
/** Clear local tokens (client-side sign out). */
async logoutLocal(): Promise<void> {
await this.tokens.setAuthToken(null);
await this.tokens.setSessionId(null);
}
/* ============================================================== */
/* Sign up, password, verification */
/* ============================================================== */
/** POST /api/User — create an account. Public. */
async signUp(params: T.SignUpParams): Promise<boolean> {
await this.http.request({
method: 'POST',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.mobilePhone,
Password: params.password,
},
includeAuthToken: false,
});
return true;
}
/** POST /api/Password — request a password reset. Public. */
async requestResetPassword(
params: T.RequestResetPasswordParams,
): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Password',
body: {
ResetPasswordType: params.reqType,
PersonalEmailAddress: '',
PersonalPhone: params.phoneNumber,
},
includeAuthToken: false,
});
}
/** PUT /api/Password — change password (authenticated). */
async updatePassword(params: T.UpdatePasswordParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/Password',
body: { OldPassword: params.oldPassword, NewPassword: params.newPassword },
});
}
/** POST /api/UserVerification — request an SMS verification code. */
async requestVerifyUser(params: T.RequestVerifyUserParams): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/UserVerification',
body: { PhoneNumber: '1' + params.phoneNumber },
});
}
/** GET /api/UserVerification — confirm an SMS code. */
async verifyUser(params: T.VerifyUserParams): Promise<void> {
await this.http.request({
method: 'GET',
path: '/api/UserVerification',
query: {
PhoneNumber: '1' + params.phoneNumber,
ConfirmationCode: params.code,
},
});
}
/** GET /api/User?EmailAddress=… — is this email already registered? */
async isEmailRegistered(emailAddress: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: emailAddress },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalEmailAddress);
}
/** GET /api/User?PhoneNumber=… — is this phone already registered? */
async isPhoneRegistered(phoneNumber: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { PhoneNumber: phoneNumber },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalPhone);
}
/* ============================================================== */
/* User profile */
/* ============================================================== */
/** GET /api/User — full profile incl. vehicles & cards (authenticated). */
getUserDetail(): Promise<T.UserDetail> {
return this.unwrap(
this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: '', PhoneNumber: '' },
}),
);
}
/** PUT /api/User — update email/phone (authenticated). */
async updateProfile(params: T.UpdateProfileParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.phoneNumber,
},
});
}
/** DELETE /api/User — request account deletion (authenticated). */
async requestDeleteUser(): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/User',
query: { DeviceType: 'Android' },
});
}
/** PUT /api/Device — register/update the push device token. */
async updateDeviceToken(
params: T.UpdateDeviceTokenParams,
): Promise<T.UpdateDeviceTokenResponse> {
return this.unwrap(
this.http.request<T.UpdateDeviceTokenResponse>({
method: 'PUT',
path: '/api/Device',
body: {
DeviceID: params.pushNotificationsToken,
IMEINumber: params.imeiNumber,
DeviceType: params.deviceType ?? '1',
Language: params.language,
},
}),
);
}
/* ============================================================== */
/* Vehicles */
/* ============================================================== */
/** POST /api/Vehicle — add a vehicle. */
async addVehicle(params: T.AddVehicleParams): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'POST',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */
async updateVehicle(
params: T.UpdateVehicleParams,
): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'PUT',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleID: params.id,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** DELETE /api/Vehicle?VehicleID=… */
async deleteVehicle(vehicleId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Vehicle',
query: { VehicleID: vehicleId },
});
}
/* ============================================================== */
/* Credit cards */
/* ============================================================== */
/** POST /api/Card — add a card. */
async addCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** POST /api/Card — update a card (full replace). */
async updateCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** PUT /api/Card — set (or unset) a card as default. */
async setCardDefault(params: T.SetDefaultCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'PUT',
path: '/api/Card',
body: { CCID: params.id, CCDefault: params.isDefaultCard },
}),
);
}
/** DELETE /api/Card?CCID=… */
async deleteCard(cardId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Card',
query: { CCID: cardId },
});
}
private buildCardBody(p: T.AddCardParams) {
return {
CCNumber: p.cardNumber,
CCAlias: p.alias,
CCExpDate: p.expDate,
CCZip: p.zipCode,
CCDefault: p.isDefaultCard ?? false,
ParentCCID: p.id,
};
}
/* ============================================================== */
/* Meters / zones */
/* ============================================================== */
/** GET /api/Meter?Lat=&Long= — meters near a coordinate. */
getMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */
getLimitedMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/MeterList',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/Meter?ZoneName=… */
getMetersByZoneName(zoneName: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ZoneName: zoneName },
}),
);
}
/** GET /api/Meter?Query=… — zone or space name search. */
searchMetersByZoneOrSpace(query: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Query: query },
}),
);
}
/** GET /api/Meter?TerminalSerNo=… */
getMetersBySerialNumber(serialNumber: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { TerminalSerNo: serialNumber },
}),
);
}
/** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */
getMetersByScannerCode(scannerCode: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ScannerCode: scannerCode },
}),
);
}
/** GET /api/ParkingLogix — nearby parking lots with occupancy. */
getParkingLots(): Promise<T.ParkingLotsResponse> {
return this.unwrap(
this.http.request<T.ParkingLotsResponse>({
method: 'GET',
path: '/api/ParkingLogix',
}),
);
}
/* ============================================================== */
/* Estimates */
/* ============================================================== */
/** GET /api/ParkingEstimateMulti — multi-duration price ladder. */
getParkingEstimateMulti(
params: T.MultiEstimateParams,
): Promise<T.MultiEstimateResponse> {
const query: Record<string, unknown> = {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
MinCreditAmount: params.minCreditAmount,
VehicleID: params.vehicleId,
};
if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes;
return this.unwrap(
this.http.request<T.MultiEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateMulti',
query,
}),
);
}
/** GET /api/ParkingEstimate — single-duration price. */
getParkingEstimateSingle(
params: T.SingleEstimateParams,
): Promise<T.SingleEstimateResponse> {
return this.unwrap(
this.http.request<T.SingleEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimate',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
ParkingDuration: params.durationInMinutes,
VehicleID: params.vehicleId,
CCID: params.creditCardId,
},
}),
);
}
/** GET /api/ParkingEstimateItems — item-based price options. */
getParkingEstimateItems(
params: T.ItemsEstimateParams,
): Promise<T.ItemsEstimateResponse> {
return this.unwrap(
this.http.request<T.ItemsEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateItems',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
VehicleID: params.vehicleId,
},
}),
);
}
/* ============================================================== */
/* Parking sessions */
/* ============================================================== */
/** POST /api/Session — start/pay for a parking session. */
startParkingSession(
params: T.StartParkingSessionParams,
): Promise<T.StartParkingSessionResponse> {
const fee = params.transactionFee ?? 0;
const body: Record<string, unknown> = {
CCID: String(params.creditCardId),
Amount: (params.parkingCost + fee).toFixed(2),
SpaceID: params.spaceId,
StartTime: params.startTime,
EndTime: params.endTime,
CustomerID: params.customerId,
VehicleID: String(params.vehicleId),
TimePurchased: String(params.minutesToPurchase),
ParkingCost: params.parkingCost.toFixed(2),
TransactionFee: fee.toFixed(2),
ZoneID: params.zoneId,
MinCreditAmount:
params.minCreditAmount != null
? params.minCreditAmount.toFixed(2)
: undefined,
MeterTypeId: params.meterTypeId,
};
if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
return this.unwrap(
this.http.request<T.StartParkingSessionResponse>({
method: 'POST',
path: '/api/Session',
body,
}),
);
}
/** GET /api/ParkingSession — currently active sessions. */
getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {
return this.unwrap(
this.http.request<T.ActiveSessionsResponse>({
method: 'GET',
path: '/api/ParkingSession',
}),
);
}
/** GET /api/Session — past sessions (paged). */
getPastParkingSessions(
params: T.PastSessionsParams = {},
): Promise<T.PastSessionsResponse> {
return this.unwrap(
this.http.request<T.PastSessionsResponse>({
method: 'GET',
path: '/api/Session',
query: {
CurrentPage: params.currentPage ?? '',
PageSize: params.pageSize ?? '',
},
}),
);
}
/* ============================================================== */
/* Receipts */
/* ============================================================== */
/** GET /api/ParkingReceipt?TransactionID=… */
getParkingReceipt(transactionId: number | string): Promise<T.ReceiptResponse> {
return this.unwrap(
this.http.request<T.ReceiptResponse>({
method: 'GET',
path: '/api/ParkingReceipt',
query: { TransactionID: transactionId },
}),
);
}
/** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */
async emailParkingReceipt(receiptId: number | string): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/ParkingReceipt',
query: { Id: receiptId },
});
}
/* ============================================================== */
/* Notification settings */
/* ============================================================== */
/** GET /api/Setting — raw notification settings bitmask. */
getNotificationSettings(): Promise<T.RawSettingsResponse> {
return this.unwrap(
this.http.request<T.RawSettingsResponse>({
method: 'GET',
path: '/api/Setting',
}),
);
}
/**
* POST /api/Setting update notification settings.
* `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings.
*/
async setNotificationSettings(userSettings: string | number): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Setting',
body: { UserSettings: String(userSettings) },
});
}
/* ============================================================== */
/* Static content */
/* ============================================================== */
/**
* GET /api/State?ID= states/regions under a parent id.
* The `ID` param is required the route 404s without it (confirmed against prod).
*/
getStates(id: number | string): Promise<T.StatesResponse> {
return this.unwrap(
this.http.request<T.StatesResponse>({
method: 'GET',
path: '/api/State',
query: { ID: id },
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterAbout — `{ Value }`. */
getAbout(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterAbout',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */
getFAQ(): Promise<T.FAQResponse> {
return this.unwrap(
this.http.request<T.FAQResponse>({
method: 'GET',
path: '/api/ParkSmarterFAQ',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */
getPrivacyPolicy(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterPrivacyPolicies',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterTerms — `{ Value }`. */
getTerms(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterTerms',
includeAuthToken: false,
}),
);
}
}

View file

@ -0,0 +1,58 @@
/**
* ParkSmarter backend environments.
*
* Recovered from the official app (com.ipsgroupinc.parksmarter 4.4.0).
* Each environment pairs a base URL with an application token that the server
* requires on every request via the `Application_Token` header.
*
* `prodv2` is the environment the shipping app defaults to.
*/
export type EnvironmentName =
| 'dev'
| 'stage'
| 'test'
| 'prodv1'
| 'prodv2'
| 'prodv3';
export interface Environment {
name: EnvironmentName;
baseUrl: string;
appToken: string;
}
export const ENVIRONMENTS: Record<EnvironmentName, Environment> = {
dev: {
name: 'dev',
baseUrl: 'https://dev-parksmarter-api.ipsmeters.com',
appToken: 'ED09B2F6-BE30-4C92-9204-7D02026CAAE7',
},
stage: {
name: 'stage',
baseUrl: 'https://staging-parksmarter-api.ipsmeters.com',
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
},
test: {
name: 'test',
baseUrl: 'https://testing-parksmarter-api.ipsmeters.com',
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
},
prodv1: {
name: 'prodv1',
baseUrl: 'https://api.parksmarter.com',
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
},
prodv2: {
name: 'prodv2',
baseUrl: 'https://apiv2.parksmarter.com',
appToken: 'B66EEDDA-B618-4926-B8A0-F5B58397EEBA',
},
prodv3: {
name: 'prodv3',
baseUrl: 'https://apiv3.parksmarter.com',
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
},
};
/** The environment the official app ships pointed at. */
export const DEFAULT_ENVIRONMENT: EnvironmentName = 'prodv2';

View file

@ -0,0 +1,252 @@
/**
* Transport layer for the ParkSmarter API.
*
* This mirrors the request pipeline used by the official app:
* - Base URL + query string are concatenated onto the endpoint path.
* - Query params are serialized as `?k=encodeURIComponent(v)&...`.
* - Headers:
* Application_Token always (identifies the app build)
* X-Request-Id always (a fresh UUID per request)
* Content-Type: application/json on POST/PUT
* Auth_Token when the endpoint requires an authenticated user
* ParkSmarter_SessionId when a server session id is available
* - The user auth token and session id are NOT HTTP bearer tokens; they are
* custom headers named exactly `Auth_Token` and `ParkSmarter_SessionId`.
*/
import { Environment } from './environments.js';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface TokenStore {
/** Current user auth token, or null if signed out. */
getAuthToken(): string | null | Promise<string | null>;
setAuthToken(token: string | null): void | Promise<void>;
/** Current server session id, or null. */
getSessionId(): string | null | Promise<string | null>;
setSessionId(sessionId: string | null): void | Promise<void>;
}
/** Simple in-memory token store. Swap for expo-secure-store / localStorage in real apps. */
export class MemoryTokenStore implements TokenStore {
private authToken: string | null = null;
private sessionId: string | null = null;
getAuthToken() {
return this.authToken;
}
setAuthToken(token: string | null) {
this.authToken = token;
}
getSessionId() {
return this.sessionId;
}
setSessionId(sessionId: string | null) {
this.sessionId = sessionId;
}
}
export interface RequestOptions {
method: HttpMethod;
/** Endpoint path, e.g. `/api/Auth`. */
path: string;
query?: Record<string, unknown> | undefined;
body?: unknown;
/** Send the `Auth_Token` header (default: true except for auth/public endpoints). */
includeAuthToken?: boolean;
/** Send the `ParkSmarter_SessionId` header when available (default: true). */
includeSessionId?: boolean;
/** Abort signal (also drives the per-request timeout). */
signal?: AbortSignal;
}
export interface ParkSmarterResponse<T> {
status: number;
data: T;
headers: Headers;
requestId: string;
}
/** Thrown for non-2xx responses. `body` is the parsed server payload when JSON. */
export class ParkSmarterApiError extends Error {
status: number;
body: unknown;
requestId: string;
/** Server-provided message when present (server uses PascalCase `Message`). */
serverMessage?: string;
constructor(status: number, body: unknown, requestId: string) {
const serverMessage =
body && typeof body === 'object'
? (body as Record<string, unknown>).Message ??
(body as Record<string, unknown>).message
: undefined;
super(
`ParkSmarter API error ${status}` +
(serverMessage ? `: ${serverMessage}` : ''),
);
this.name = 'ParkSmarterApiError';
this.status = status;
this.body = body;
this.requestId = requestId;
if (typeof serverMessage === 'string') this.serverMessage = serverMessage;
}
}
export interface HttpClientConfig {
environment: Environment;
tokens: TokenStore;
/** BCP-47-ish locale code sent as the `localeCode` query param (default: 'en'). */
localeCode?: string;
/** Per-request timeout in ms (default: 30000). */
timeoutMs?: number;
/** Override fetch (e.g. for React Native or tests). Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Override UUID generation. Defaults to crypto.randomUUID when available. */
uuid?: () => string;
}
function defaultUuid(): string {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
// RFC4122-ish fallback (non-crypto) for older runtimes.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function serializeQuery(query: Record<string, unknown>): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
export class HttpClient {
private cfg: Required<Omit<HttpClientConfig, 'environment' | 'tokens'>> &
Pick<HttpClientConfig, 'environment' | 'tokens'>;
constructor(config: HttpClientConfig) {
this.cfg = {
environment: config.environment,
tokens: config.tokens,
localeCode: config.localeCode ?? 'en',
timeoutMs: config.timeoutMs ?? 30000,
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
uuid: config.uuid ?? defaultUuid,
};
if (!this.cfg.fetchImpl) {
throw new Error(
'No fetch implementation available. Pass config.fetchImpl (e.g. node-fetch, or a polyfill).',
);
}
}
get environment(): Environment {
return this.cfg.environment;
}
get tokens(): TokenStore {
return this.cfg.tokens;
}
setLocaleCode(localeCode: string) {
this.cfg.localeCode = localeCode;
}
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
const { method } = opts;
const requestId = this.cfg.uuid();
// The app appends `localeCode` to the query params of essentially every call.
const query: Record<string, unknown> = {
localeCode: this.cfg.localeCode,
...(opts.query ?? {}),
};
const url =
this.cfg.environment.baseUrl + opts.path + serializeQuery(query);
const headers: Record<string, string> = {
Application_Token: this.cfg.environment.appToken,
'X-Request-Id': requestId,
Accept: 'application/json',
};
if (method === 'POST' || method === 'PUT') {
headers['Content-Type'] = 'application/json';
}
const includeAuthToken = opts.includeAuthToken ?? true;
const includeSessionId = opts.includeSessionId ?? true;
if (includeAuthToken) {
const authToken = await this.cfg.tokens.getAuthToken();
if (authToken) headers['Auth_Token'] = authToken;
}
if (includeSessionId) {
const sessionId = await this.cfg.tokens.getSessionId();
if (sessionId) headers['ParkSmarter_SessionId'] = sessionId;
}
// Timeout wired through an AbortController, honoring any caller-supplied signal.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
if (opts.signal) {
if (opts.signal.aborted) controller.abort();
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
let res: Response;
try {
res = await this.cfg.fetchImpl(url, {
method,
headers,
body:
opts.body !== undefined && opts.body !== null
? JSON.stringify(opts.body)
: undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
const text = await res.text();
let data: unknown = undefined;
if (text && text.trim().length) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
// Keep the token store fresh from what the server echoes back:
// - SessionId is returned on bootstrap/login.
// - A fresh Auth_Token may be returned either at the top level (login) or
// inside the common `Response: { Auth_Token, Message, Status }` envelope
// (a rolling token-refresh mechanism). Only overwrite on a non-empty value.
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
const envelope = obj.Response as Record<string, unknown> | undefined;
const sessionId = obj.SessionId;
if (typeof sessionId === 'string' && sessionId) {
await this.cfg.tokens.setSessionId(sessionId);
}
const refreshed =
(typeof obj.Auth_Token === 'string' && obj.Auth_Token) ||
(envelope && typeof envelope.Auth_Token === 'string' && envelope.Auth_Token);
if (refreshed) {
await this.cfg.tokens.setAuthToken(refreshed);
}
}
if (!res.ok) {
throw new ParkSmarterApiError(res.status, data ?? text, requestId);
}
return { status: res.status, data: data as T, headers: res.headers, requestId };
}
}

View file

@ -0,0 +1,20 @@
export { ParkSmarterClient } from './client.js';
export type { ParkSmarterClientOptions } from './client.js';
export {
ENVIRONMENTS,
DEFAULT_ENVIRONMENT,
} from './environments.js';
export type { Environment, EnvironmentName } from './environments.js';
export {
HttpClient,
MemoryTokenStore,
ParkSmarterApiError,
} from './http.js';
export type {
TokenStore,
RequestOptions,
ParkSmarterResponse,
HttpMethod,
HttpClientConfig,
} from './http.js';
export * from './types.js';

View file

@ -0,0 +1,624 @@
/**
* ParkSmarter API data models.
*
* Two casing worlds exist:
* - This client's *inputs* use friendly camelCase (see the `*Params` types).
* - The server speaks PascalCase ("PSJSON"). Response models below therefore use
* the exact server field spellings recovered from the app. Response interfaces
* carry an index signature because not every field is guaranteed on every call;
* the well-known fields are typed explicitly. Run a live capture to lock down
* the optional tail for a given endpoint if you need exhaustiveness.
*/
/**
* The common status/token-refresh envelope embedded in most authenticated
* responses as `Response`. `Auth_Token` here is usually null but, when present,
* is a rolling refresh of the user token (the client stores it automatically).
* CONFIRMED via live capture.
*/
export interface ResponseEnvelope {
Auth_Token: string | null;
Message: string | null;
Status: string | null;
}
/**
* Casing note (CONFIRMED via live capture): the meter/zone *response* uses
* `ZoneId` / `SpaceId` / `CustomerId` (lowercase "d"), while estimate & session
* *requests* expect `ZoneID` / `SpaceID` / `CustomerID` (capital "ID"). When you
* feed a captured Zone into an estimate/session, read `zone.CustomerId` /
* `space.SpaceId` and pass them as the client's camelCase params the client
* emits the capital-ID request spelling for you.
*/
/* ------------------------------------------------------------------ */
/* Auth & account */
/* ------------------------------------------------------------------ */
export interface LoginWithPhoneParams {
/** Phone number, digits only (no country code prefix). */
phoneNumber: string;
password: string;
}
export interface LoginWithAppleParams {
emailAddress: string;
appleId: string;
providerAuth: string;
providerIdentity: string;
}
/**
* POST /api/Auth response (login). CONFIRMED: `{ Status, Message, Auth_Token }`.
* Auth_Token is persisted automatically; SessionId is delivered separately (by
* bootstrap / headers) and also persisted.
*/
export interface AuthResponse {
Status?: string;
Message?: string;
Auth_Token?: string;
[key: string]: unknown;
}
export interface SignUpParams {
emailAddress: string;
/** Phone number, digits only. */
mobilePhone: string;
password: string;
}
export interface RequestResetPasswordParams {
/** Server `ResetPasswordType`; the app passes a numeric/string reason code. */
reqType: string | number;
/** Phone number, digits only. */
phoneNumber: string;
}
export interface UpdatePasswordParams {
oldPassword: string;
newPassword: string;
}
export interface UpdateProfileParams {
emailAddress: string;
/** Phone number, digits only. */
phoneNumber: string;
}
export interface RequestVerifyUserParams {
/** Phone number, digits only. The client prefixes country code "1". */
phoneNumber: string;
}
export interface VerifyUserParams {
/** Phone number, digits only. The client prefixes country code "1". */
phoneNumber: string;
/** SMS confirmation code. */
code: string;
}
export interface UpdateDeviceTokenParams {
/** Push token (server field `DeviceID`). */
pushNotificationsToken: string;
imeiNumber?: string;
/** Defaults to '1' (Android) as the app hardcodes. */
deviceType?: string;
/** BCP-47 language, defaults to the client locale. */
language?: string;
}
export interface UpdateDeviceTokenResponse {
Status?: string;
Message?: string;
[key: string]: unknown;
}
/** GET /api/User response. CONFIRMED via live capture. */
export interface UserDetail {
PersonalEmailAddress?: string;
/** Digits-only phone as a string. */
PersonalPhone?: string;
VehicleDetails?: VehicleDetail[];
CreditCardDetails?: CreditCardDetail[];
/** Numeric flag (0/1), not a boolean. */
OffersOptIn?: number;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Vehicles */
/* ------------------------------------------------------------------ */
/** CONFIRMED via live capture. Note `IsDefault` is a string ("true"/"false"), not a boolean. */
export interface VehicleDetail {
VehicleID?: number;
VehiclePlate?: string;
VehicleState?: string;
VehicleAlias?: string;
/** Server returns this as a string, e.g. "true" / "false". */
IsDefault?: string;
/** Per-item status fields, usually null on reads. */
Status?: string | null;
Message?: string | null;
[key: string]: unknown;
}
export interface AddVehicleParams {
plate: string;
state: string;
vehicleAlias?: string;
isDefaultVehicle?: boolean;
}
export interface UpdateVehicleParams extends AddVehicleParams {
id: number | string;
}
export interface VehicleMutationResponse {
Status?: string;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Credit cards */
/* ------------------------------------------------------------------ */
/**
* CONFIRMED via live capture. The PAN is never returned only `CCFirstSix` +
* `CCLastFour`. `CCDefault` comes back as a string ("true"/"false"). Note the
* response uses `ParentPBPCardId`, whereas the add/update *request* uses `ParentCCID`.
*/
export interface CreditCardDetail {
CCID?: number;
CCAlias?: string;
/** First 6 digits (BIN), numeric string. */
CCFirstSix?: string;
/** Last 4 digits, numeric string. */
CCLastFour?: string;
CCExpDate?: string;
CCZip?: string;
/** String "true"/"false", not a boolean. */
CCDefault?: string;
ParentPBPCardId?: number | null;
/** Per-item status fields, usually null on reads. */
Status?: string | null;
Message?: string | null;
[key: string]: unknown;
}
export interface AddCardParams {
cardNumber: string;
alias?: string;
/** Expiry, format as the app sends it (e.g. "MM/YY"). */
expDate: string;
zipCode: string;
isDefaultCard?: boolean;
/** Present when editing an existing card (server `ParentCCID`). */
id?: number | string;
}
export interface SetDefaultCardParams {
id: number | string;
isDefaultCard: boolean;
}
/* ------------------------------------------------------------------ */
/* Meters / zones */
/* ------------------------------------------------------------------ */
export interface LatLng {
latitude: number;
longitude: number;
}
/** A rate/time policy slot within a space. CONFIRMED via live capture. */
export interface SpacePolicy {
CurrentSlot?: boolean;
DayNumber?: number;
DisplayString?: string;
StartDateTime?: string;
EndDateTime?: string;
StartTimeDisplay?: string;
EndTimeDisplay?: string;
EventRateFlag?: boolean;
MaxTime?: number;
MessageHeader?: string;
MessageText?: string;
Rate?: number;
RateType?: string;
[key: string]: unknown;
}
/**
* A single space within a zone. CONFIRMED via live capture.
* Note the id is `SpaceId` (lowercase "d") here; estimate/session requests want
* the capital-ID `SpaceID` (the client maps camelCase params to that spelling).
*/
export interface Space {
SpaceId?: number;
SpaceName?: string;
SSPMSpaceId?: number;
MeterPurchaseType?: number;
OccupancyStatus?: number;
Policies?: SpacePolicy[];
[key: string]: unknown;
}
/**
* A parking zone/meter as returned inside `Zones`. CONFIRMED via live capture.
* `ScannerCode` is the value encoded in kiosk QR codes; `TerminalSerNo` is the
* printed serial. Both drive the scan / lookup features.
*/
export interface Zone {
ZoneId?: number;
ZoneGuid?: string;
ZoneName?: string;
ZoneLocation?: string;
ZonePlates?: unknown;
/** Printed serial number (numeric string). */
TerminalSerNo?: string;
/** QR-encoded scanner code. */
ScannerCode?: string;
Lat?: number;
Long?: number;
CustomerId?: number;
CustomerName?: string;
/** Minutes offset / IANA-less numeric zone id as returned. */
TimeZone?: number;
DSTAdjust?: number;
CityLogo?: string;
DepartmentLogo?: string;
BackgroundColor?: string;
/** Returned as a numeric string. */
ForegroundColor?: string;
MinimumAmount?: number;
MaxTime?: number;
MeterType?: string;
MeterTypeId?: number;
/** Base rate, returned as a string. */
Rate?: string;
ProgressiveRate?: number;
PercentageFull?: number;
IsAllowOverAir?: boolean;
IsAllowBLE?: boolean;
IsExtension?: boolean;
IsPaid?: boolean;
Spaces?: Space[];
[key: string]: unknown;
}
/** GET /api/Meter and /api/MeterList. CONFIRMED via live capture. */
export interface MetersResponse {
Zones?: Zone[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Parking lots (ParkingLogix) */
/* ------------------------------------------------------------------ */
/** One level/section within a ParkingLogix lot. CONFIRMED via live capture. */
export interface ParkingLotDetail {
ParkingLogixLotDetailId?: number;
ParkingLogixLotId?: number;
Level?: number;
TotalSpaces?: number;
FreeSpaces?: number;
[key: string]: unknown;
}
/** CONFIRMED via live capture. Per-level occupancy lives in ParkingLogixLotDetails. */
export interface ParkingLot {
ParkingLogixLotId?: number;
LotName?: string;
LocationAddress?: string;
Lat?: number;
Long?: number;
TotalSpaces?: number;
DefaultFree?: boolean;
ParkingLogixLotDetails?: ParkingLotDetail[];
[key: string]: unknown;
}
export interface ParkingLotsResponse {
ParkingLogixLots?: ParkingLot[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Estimates */
/* ------------------------------------------------------------------ */
export interface MultiEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
/** Zone minimum credit (server `MinCreditAmount`). */
minCreditAmount?: number | string;
vehicleId: number | string;
/** Optional BLE encrypted bytes string. */
bleEncBytes?: string;
}
export interface SingleEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
/** Server `ParkingDuration`, in minutes. */
durationInMinutes: number;
vehicleId: number | string;
/** Server `CCID` — credit card id. */
creditCardId: number | string;
}
export interface ItemsEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
vehicleId: number | string;
}
/**
* One priced duration option. CONFIRMED via live capture.
* Money/id fields come back as numeric *strings* here.
*/
export interface ParkingDetail {
Minutes?: number;
StartTime?: string;
EndTime?: string;
ParkingCost?: string;
TransactionFee?: string;
CurrentAmountPaid?: number;
CustomerID?: string;
CustomerName?: string;
Zone?: string;
ZoneID?: string;
Space?: string;
SpaceID?: string;
VehicleID?: string;
VehicleNumber?: string | null;
CCNumber?: string | null;
PricingToken?: string | null;
TimeZone?: number;
DSTAdjust?: number;
[key: string]: unknown;
}
/** GET /api/ParkingEstimateMulti. CONFIRMED via live capture (a full price ladder). */
export interface MultiEstimateResponse {
ParkingDetail?: ParkingDetail[];
MinTime?: number;
MaxTime?: number;
TimeRemaining?: number;
JumpRateEnabled?: boolean;
JumpRateThreshold?: number;
JumpRateSetValue?: number;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/ParkingEstimate. CONFIRMED via live capture (a single priced option). */
export interface SingleEstimateResponse {
ParkingDetail?: ParkingDetail;
MinTime?: number;
MaxTime?: number;
CurrentCustomerDateTime?: string;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/ParkingEstimateItems. CONFIRMED via live capture. */
export interface ItemsEstimateResponse {
ZoneID?: number;
ZoneName?: string;
SpaceID?: number;
SpaceName?: string;
CustomerID?: number;
CustomerName?: string;
PurchaseMode?: string;
Items?: unknown[] | null;
TimeStampUTC?: number;
TimeZone?: number;
DSTAdjust?: number;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Parking sessions */
/* ------------------------------------------------------------------ */
export interface StartParkingSessionParams {
creditCardId: number | string;
spaceId: number | string;
customerId: number | string;
zoneId: number | string;
vehicleId: number | string;
/** ISO/formatted start time as the server expects. */
startTime: string;
endTime: string;
/** Minutes purchased (server `TimePurchased`). */
minutesToPurchase: number;
/** Base cost, dollars (server `ParkingCost`). */
parkingCost: number;
/** Fee, dollars (server `TransactionFee`). Defaults to 0. */
transactionFee?: number;
/** Zone minimum credit (server `MinCreditAmount`). */
minCreditAmount?: number;
meterTypeId: number | string;
/** Optional BLE encrypted bytes string. */
bleEncBytes?: string;
}
export interface StartParkingSessionResponse {
BleEncBytes?: string;
[key: string]: unknown;
}
/** UNCONFIRMED element shape (no active sessions on the test account). */
export interface ActiveSession {
TransactionID?: number | string;
ZoneName?: string;
SpaceName?: string;
Space?: string;
StartTime?: string;
EndTime?: string;
StartTimeDisplay?: string;
EndTimeDisplay?: string;
TimeRemaining?: number | string;
VehiclePlate?: string;
VehicleNumber?: string;
Amount?: number | string;
IsExtension?: boolean;
[key: string]: unknown;
}
/** GET /api/ParkingSession. Envelope CONFIRMED; element shape UNCONFIRMED (no active sessions in test account). */
export interface ActiveSessionsResponse {
ParkingSession?: ActiveSession[] | null;
CurrentCustomerTime?: string | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** UNCONFIRMED element shape (no past sessions on the test account). */
export interface PastSession {
TransactionID?: number | string;
ZoneName?: string;
StartTime?: string;
EndTime?: string;
Amount?: number | string;
PaymentType?: string;
PaymentDisplay?: string;
VehiclePlate?: string;
IsPaid?: boolean;
[key: string]: unknown;
}
/** GET /api/Session. Envelope CONFIRMED; element shape UNCONFIRMED (no past sessions in test account). */
export interface PastSessionsResponse {
Session?: PastSession[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
export interface PastSessionsParams {
currentPage?: number | string;
pageSize?: number | string;
}
/* ------------------------------------------------------------------ */
/* Receipts */
/* ------------------------------------------------------------------ */
export interface ParkingReceipt {
TransactionID?: number | string;
ZoneName?: string;
StartTime?: string;
EndTime?: string;
Amount?: number | string;
Total?: number | string;
TotalCost?: number | string;
TransactionFee?: number | string;
PaymentType?: string;
VehiclePlate?: string;
[key: string]: unknown;
}
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
export interface ReceiptResponse {
ParkingReceipt?: ParkingReceipt;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Notification settings */
/* ------------------------------------------------------------------ */
/** One toggle group within notification settings. */
export interface NotificationSetting {
messagesEnabled?: boolean;
emailsEnabled?: boolean;
messages?: boolean;
emails?: boolean;
}
/** Client-side view of the settings (server sends a numeric bitmask in `Settings`). */
export interface NotificationSettings {
parkingReceipt?: NotificationSetting;
overnight?: NotificationSetting;
tenMin?: NotificationSetting;
fifteenMin?: NotificationSetting;
sessionEnded?: NotificationSetting;
sessionStart?: NotificationSetting;
}
/** GET /api/Setting. CONFIRMED: `{ Response, Settings }`. */
export interface RawSettingsResponse {
/** Server bitmask as a numeric string. Decode per the app's expandSettings logic. */
Settings?: string;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/State?ID=…. CONFIRMED wrapper `{ StatesResult }` (requires an ID; 404s without). */
export interface StatesResponse {
StatesResult?: StateItem[];
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Content / misc */
/* ------------------------------------------------------------------ */
/** Terms/Privacy/About all return `{ Value: string }`. */
export interface ValueResponse {
Value?: string;
[key: string]: unknown;
}
export interface FAQItem {
Question?: string;
Answer?: string;
[key: string]: unknown;
}
export interface FAQResponse {
FAQs?: FAQItem[];
[key: string]: unknown;
}
/** GET /api/ApplicationValidity — feature flags + maintenance + session bootstrap. */
export interface ApplicationValidityResponse {
SessionId?: string;
RecommendUpgrade?: boolean;
ForceUpgrade?: boolean;
IsParkingEstimateMultiEnabled?: boolean;
Config?: {
IsInMaintenanceMode?: boolean;
ExtendedVariablePBPZoneIds?: unknown;
ExtendedVariableRateMaxDayMinutes?: number;
ExtendedVariableRateMaxDay?: number;
ExtendedVariableMultiDayValueThreshold?: number;
ExtendedVariableRateSingleHourRate?: number;
ExtendedVariableRateMaxDayJump?: number;
ExtendedVariableRateMaxDayValueDollar?: number;
ExtendedVariableRateMaxMode?: unknown;
UseLimitedMetersFetch?: boolean;
UseLoadMetersButton?: boolean;
UseReloadMetersOnResume?: boolean;
UseApplePay?: boolean;
UseGooglePay?: boolean;
ApplePayHostAddress?: string;
UseSearchBySpace?: boolean;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface StateItem {
[key: string]: unknown;
}

View file

@ -0,0 +1,176 @@
/**
* sweep.mjs confirm real API response shapes WITHOUT running the app and
* WITHOUT leaking your personal data.
*
* It logs into the ParkSmarter API with the library, calls the safe READ-ONLY
* endpoints, and writes a "schema skeleton" (field names + value *types* only,
* never the values) to ./capture/. Share ./capture/ freely it contains no PII.
*
* It NEVER calls anything that spends money or mutates your account
* (no start-session, add/update/delete card or vehicle, profile edits, etc.).
*
* Setup:
* 1) npm run build (already done if dist/ exists)
* 2) Create ./.creds.json (gitignored):
* {
* "phoneNumber": "5551234567", // digits only, no country code
* "password": "…",
* "environment": "prodv2", // optional
* "lat": 40.4406, // optional: a coordinate near real meters
* "lng": -79.9959 // optional
* }
* 3) node sweep.mjs
*/
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { ParkSmarterClient } from './dist/index.js';
/* ---- load creds (file or env) ---- */
let creds = {};
if (existsSync('./.creds.json')) {
creds = JSON.parse(readFileSync('./.creds.json', 'utf8'));
}
const phoneNumber = creds.phoneNumber ?? process.env.PS_PHONE;
const password = creds.password ?? process.env.PS_PASS;
const environment = creds.environment ?? process.env.PS_ENV ?? 'prodv2';
const lat = creds.lat ?? (process.env.PS_LAT ? Number(process.env.PS_LAT) : undefined);
const lng = creds.lng ?? (process.env.PS_LNG ? Number(process.env.PS_LNG) : undefined);
if (!phoneNumber || !password) {
console.error(
'Missing credentials. Create ./.creds.json {phoneNumber, password} or set PS_PHONE/PS_PASS.',
);
process.exit(1);
}
/* ---- schema skeleton: keep KEYS and TYPES, drop VALUES (no PII) ---- */
function skeleton(v, depth = 0) {
if (v === null) return 'null';
if (Array.isArray(v)) {
if (v.length === 0) return ['<empty>'];
// merge keys across up to 5 elements so we don't miss sparse fields
const sample = v.slice(0, 5).map((e) => skeleton(e, depth + 1));
if (typeof sample[0] === 'object' && sample[0] !== null) {
const merged = {};
for (const s of sample) Object.assign(merged, s);
return [merged, `<len:${v.length}>`];
}
return [sample[0], `<len:${v.length}>`];
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v).sort()) out[k] = skeleton(v[k], depth + 1);
return out;
}
// primitives: report type only, plus a coarse hint for strings
if (typeof v === 'string') {
if (/^\d{4}-\d{2}-\d{2}/.test(v)) return 'string<date>';
if (/^-?\d+(\.\d+)?$/.test(v)) return 'string<numeric>';
return 'string';
}
return typeof v; // number | boolean
}
const capDir = './capture';
mkdirSync(capDir, { recursive: true });
const ps = new ParkSmarterClient({ environment, timeoutMs: 20000 });
const results = {};
async function grab(name, fn) {
try {
const data = await fn();
const skel = skeleton(data);
results[name] = { ok: true, schema: skel };
writeFileSync(`${capDir}/${name}.json`, JSON.stringify(skel, null, 2));
const top = skel && typeof skel === 'object' ? Object.keys(skel) : skel;
console.log(`${name}:`, JSON.stringify(top));
} catch (e) {
results[name] = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
console.log(`${name}: ${e.name} ${e.status ?? ''} ${e.message}`);
}
}
/* ---- run ---- */
console.log(`\nEnvironment: ${environment}`);
await grab('applicationValidity', () => ps.getApplicationValidity());
console.log('\nLogging in…');
try {
const auth = await ps.loginWithPhone({ phoneNumber, password });
results.__login = { ok: true, schema: skeleton(auth) };
writeFileSync(`${capDir}/_authResponse.json`, JSON.stringify(skeleton(auth), null, 2));
console.log('✓ login: keys =', JSON.stringify(Object.keys(auth)));
} catch (e) {
console.log(`✗ login FAILED: ${e.name} ${e.status ?? ''} ${e.message}`);
console.log(' (a 201 here means the account needs verification — see README note.)');
process.exit(1);
}
/* authenticated READ-ONLY sweep */
await grab('userDetail', () => ps.getUserDetail());
await grab('notificationSettings', () => ps.getNotificationSettings());
await grab('activeSessions', () => ps.getActiveParkingSessions());
await grab('pastSessions', () => ps.getPastParkingSessions({ currentPage: 1, pageSize: 10 }));
await grab('parkingLots', () => ps.getParkingLots());
await grab('states', () => ps.getStates());
await grab('states_withId', () => ps.getStates(1));
let firstZone = null;
if (lat != null && lng != null) {
const meters = await (async () => {
try {
return await ps.getMetersByLocation({ latitude: lat, longitude: lng });
} catch (e) {
results.metersByLocation = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
console.log(`✗ metersByLocation: ${e.name} ${e.status ?? ''} ${e.message}`);
return null;
}
})();
if (meters) {
writeFileSync(`${capDir}/metersByLocation.json`, JSON.stringify(skeleton(meters), null, 2));
results.metersByLocation = { ok: true, schema: skeleton(meters) };
console.log('✓ metersByLocation:', JSON.stringify(Object.keys(meters)));
firstZone = meters?.Zones?.[0] ?? null;
}
await grab('limitedMetersByLocation', () =>
ps.getLimitedMetersByLocation({ latitude: lat, longitude: lng }),
);
} else {
console.log('\n(skip meters-by-location: add "lat"/"lng" to .creds.json to include them)');
}
/* estimates are read-only price quotes — try them from a real zone + the user's vehicle */
try {
const me = await ps.getUserDetail();
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
const zone = firstZone;
const space = zone?.Spaces?.[0];
const zoneId = zone?.ZoneId ?? zone?.ZoneID;
const spaceId = space?.SpaceID ?? space?.SpaceId;
const customerId = zone?.CustomerID ?? zone?.CustomerId;
if (zoneId != null && spaceId != null && customerId != null && vehicleId != null) {
const common = { zoneId, spaceId, customerId, vehicleId };
await grab('estimateItems', () => ps.getParkingEstimateItems(common));
await grab('estimateMulti', () => ps.getParkingEstimateMulti(common));
await grab('estimateSingle', () =>
ps.getParkingEstimateSingle({ ...common, durationInMinutes: 60, creditCardId: 0 }),
);
} else {
console.log('(skip estimates: no zone/space/vehicle available to quote against)');
}
} catch (e) {
console.log(`(skip estimates: ${e.message})`);
}
/* try a receipt shape from a past session id, if any (read-only) */
try {
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
const tid = past?.Session?.[0]?.TransactionID;
if (tid != null) await grab('parkingReceipt', () => ps.getParkingReceipt(tid));
} catch {}
/* public content shapes */
await grab('states_public', () => ps.getStates());
writeFileSync(`${capDir}/_summary.json`, JSON.stringify(results, null, 2));
console.log('\nDone. Schema skeletons (no PII) written to ./capture/. Share that folder with me.');

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}