Scale Sense — API Surface¶
Base URL: https://api.scalesense.com/v1
Auth: Cognito JWT in Authorization: Bearer <token> header
All responses: JSON. Errors: { error: string, code: string }
URL encoding: tap IDs (loc-001#TAP#07) and beer IDs (old format loc-001#xxxxxxxx) contain # and MUST be encodeURIComponent-encoded in URL paths. All Lambda handlers call decodeURIComponent on path parameters. New beer IDs use - separator and are safe unencoded, but encode anyway for consistency.
/locations¶
GET /locations
→ Location[] (locations the authenticated user has access to)
GET /locations/{loc_id}
→ Location (detail: name, address, timezone, settings)
GET /locations/{loc_id}/taps
→ TapCardViewModel[] (all taps + current state + alerts)
GET /locations/{loc_id}/alerts
→ Alert[] (active alerts for location)
GET /locations/{loc_id}/inventory/export?format=pdf|csv
→ { url: string, expires_in: 900 } (presigned S3 URL)
Roles: Manager+
/taps¶
GET /taps/{tap_id}
→ Tap (full tap detail: beer, keg, state, device)
PATCH /taps/{tap_id}
Body: { beer_id?, keg_id?, keg_type?, full_lbs?, empty_lbs? }
→ Tap
Roles: Manager+
POST /taps/{tap_id}/command
Body: { command: string, ...params }
→ { queued: true, command_id: string }
Commands:
"tare_empty" → execute tare empty on scale
"tare_full" → execute tare full on scale
"report_now" → request immediate reading
"maintenance_on" → enter maintenance mode (source: virtual)
"maintenance_off" → exit maintenance mode
"cal_zero" body also: { known_weight_lbs: number } → record empty baseline
"cal_weight" → complete calibration (uses known_weight from cal_zero)
"identify" → flash LED on scale
Roles: Staff+ for tare/maintenance/report; Manager+ for calibrate
GET /taps/{tap_id}/history?range=1h|24h|7d|30d
→ { labels: string[], data: number[], unit: "pct"|"gallons" }
(Timestream query, binned by range)
GET /taps/{tap_id}/report
→ { pour_rate_gal_hr, hours_remaining, total_poured_7d, keg_pct_used }
/beers¶
GET /beers?q={search}
→ Beer[] (search name, brewery, style; returns global library + location custom)
GET /beers/{beer_id}
→ Beer (full profile including keg weight table)
POST /beers
Body: { name, brewery, style, abv, specific_gravity?, keg_weights? }
→ Beer (adds to location's custom beer list)
Roles: Manager+
PATCH /beers/{beer_id}
Body: { specific_gravity? }
→ Beer
Roles: Manager+ (only editable on custom beers, not global library)
/kegs¶
GET /kegs/{keg_id}
→ Keg (meta + tare record + status)
POST /kegs
Body: { beer_id, keg_type, loc_id, full_lbs, empty_lbs, sg }
→ Keg (creates new keg record when tapping a fresh keg)
Roles: Manager+
PATCH /kegs/{keg_id}
Body: { tare_empty_offset?, full_ref?, tare_status? }
→ Keg (update tare references after tare procedure)
Roles: Manager+
GET /kegs/{keg_id}/readings?range=7d
→ time series data from Timestream for this keg's active period
/devices¶
GET /devices/{device_id}
→ Device (meta + telemetry + calibration summary + firmware)
GET /devices/{device_id}/telemetry
→ { battery_v, battery_pct, rssi, fw_version, last_seen, status }
GET /devices/{device_id}/calibration
→ {
current: { scale_factor, zero_offset, method, drift_pct, calibrated_at },
history: CalibrationRecord[]
}
POST /devices/{device_id}/calibration
Body: { known_weight_lbs: number }
→ { queued: true } (triggers calibration job via IoT Core)
Roles: Manager+
POST /devices/{device_id}/ota
Body: { firmware_version?: string } (defaults to latest stable)
→ { job_id: string }
Roles: Admin only
PATCH /devices/{device_id}
Body: { tap_id?, display_name? }
→ Device
Roles: Manager+
TypeScript types (packages/api-client/src/types.ts)¶
type KegType = "half_barrel" | "quarter_barrel" | "sixth_barrel" | "slim_quarter"
type Location = {
id: string
name: string
address: string
timezone: string
settings: LocationSettings
}
type LocationSettings = {
low_keg_warning_pct: number // default 20
low_keg_critical_pct: number // default 10
low_keg_warning_hours: number // default 8
low_keg_critical_hours: number // default 2
offline_warning_secs: number // default 300
offline_critical_secs: number // default 1800
battery_warning_pct: number // default 20
battery_critical_pct: number // default 10
cal_drift_warn_pct: number // default 2
quiet_hours_start: string | null // "02:00"
quiet_hours_end: string | null // "14:00"
email_alerts: boolean
push_alerts: boolean
auto_export_schedule: AutoExportConfig | null
pour_rate_window_hours: number // default 6
publish_interval_secs: number // default 60
firmware_channel: "stable" | "beta" | "manual"
staged_rollout: boolean
auto_rollback: boolean
}
type Tap = {
id: string // "loc-001#TAP#07"
number: number // 7
beer: Beer | null
keg: Keg | null
state: TapState | null
device: DeviceSummary
}
type TapState = {
level_pct: number | null // null when tare_status == "pending"
gallons: number | null
weight_lbs: number | null
updated_at: string // ISO timestamp
is_stale: boolean // true if > 5 min old
tare_status: "pending" | "complete"
}
type Beer = {
id: string
name: string
brewery: string
style: string
abv: number
specific_gravity: number // default 1.0
keg_weights: Record<KegType, { full_lbs: number, empty_lbs: number }>
is_custom: boolean // true = location-added, false = global library
}
type Keg = {
id: string
type: KegType
tapped_at: string
tare: TareRecord | null
}
type TareRecord = {
empty_offset: number // raw HX711 counts at zero
full_ref: number // lbs reference
tared_by: string // user ID
tared_at: string // ISO timestamp
}
type DeviceSummary = {
id: string // "SCALE-0042"
firmware: string // "1.4.2"
battery_pct: number
rssi: number
status: "online" | "offline" | "maintenance"
calibration: CalibrationSummary
}
type CalibrationSummary = {
scale_factor: number
calibrated_at: string
method: "factory" | "field"
drift_pct: number
}
type Alert = {
id: string
tap_id: string
loc_id: string
type: "low_keg" | "scale_offline" | "low_battery" | "stale_reading" | "calibration_drift"
severity: "warning" | "critical"
state: "firing" | "active" | "escalated" | "acknowledged" | "resolved"
fired_at: string
acknowledged: boolean
ack_by: string | null
resolved_at: string | null
context: Record<string, unknown>
}
type TapCardViewModel = {
tap: Tap
alert: Alert | null
hours_remaining: number | null
pour_rate: number | null // gal/hr
trend: "rising" | "falling" | "stable"
}
Command flow (POST /taps/{id}/command)¶
Client POST /taps/{id}/command { command: "tare_empty" }
→ API Gateway (Cognito auth)
→ Lambda commands.ts
1. Validate role (Staff+ for tare)
2. Look up tap → get device_id
3. Check device is online (throw if offline)
4. Publish retained MQTT to keg/command/{device_id}/tare_empty
5. Write pending command record to DynamoDB (TTL: 5 min)
6. Return { queued: true, command_id: uuid }
Scale receives command, executes, publishes to keg/response/{device_id}
→ IoT rule → Lambda ingest-reading
1. Parse response
2. Update DynamoDB (tare record, etc)
3. Clear pending command record
4. Push WebSocket event: { type: "tare_updated", tap_id, tare }
Client receives WebSocket event — dashboard/app updates live
Inventory export flow¶
GET /locations/{id}/inventory/export?format=pdf
Lambda:
1. Fetch all taps + states from DynamoDB
2. Enrich with hours_remaining from Timestream
3. Generate PDF (ReportLab) or CSV
4. Upload to s3://scale-sense-exports-prod/{loc_id}/inventory-{ts}.{ext}
5. Generate presigned URL (900s = 15 min)
6. Return { url, expires_in: 900 }
Client:
window.open(url, "_blank") // browser downloads directly from S3