Skip to content

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