SimpliSafe from the shell
SimpliSafe's API is reachable server-side — no browser bridge, no extension,
no signed-in tab. Auth is a bearer token minted from a long-lived OAuth refresh
token, so everything here is plain curl + jq.
Requires curl and jq.
One-time setup
SimpliSafe issues no API keys. The credential is an OAuth refresh token, minted by a browser login you do once:
node ~/git/simplisafe-mcp/scripts/bootstrap-auth.mjs # prints an authorize URL
# sign in, then copy the com.simplisafe.mobile://... URL the browser fails to open
node ~/git/simplisafe-mcp/scripts/bootstrap-auth.mjs "<that URL>"
That writes SIMPLISAFE_REFRESH_TOKEN to ~/git/simplisafe-mcp/.env (mode 0600).
SimpliSafe does not rotate refresh tokens, so this survives indefinitely —
until you sign out of all devices in the SimpliSafe app, which revokes it.
To capture the code from the browser: open DevTools → Network → tick Preserve
log before signing in, then find the failed navigation to
com.simplisafe.mobile://…?code=… and copy its link address. The code is
single-use and expires in ~2 minutes.
Core pattern
source ~/git/simplisafe-mcp/skills/simplisafe-api/references/ss-helpers.sh
ss_api GET /api/authCheck | jq # who am I
SID=$(ss_sid) # the single active system id
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" | jq
ss_api <METHOD> <PATH> [JSON_BODY] attaches auth, prints the body on stdout,
and on a non-2xx prints to stderr and returns 1 — so a failure never looks
like an empty result. Access tokens are cached in $TMPDIR (0600) and re-minted
only when stale; the helpers never write to the repo's .env.
Resolve first
Almost every path needs a system id (sid), and getting one takes two calls
(authCheck → userId, then the subscriptions route). ss_sid does both, and
fails loudly if the account has more than one system rather than guessing —
pass the sid explicitly in that case.
Note two routing traps:
- The system version that decides
ss3/routing is atlocation.system.version, not the top-levelsystemVersion(a different number entirely). - Events and doorlock control are NOT under the
ss3/prefix. Everything else is.
Reads
# Is it armed? -> OFF | HOME | AWAY | HOME_COUNT | AWAY_COUNT | ALARM
ss_api GET "/users/$(ss_api GET /api/authCheck | jq -r .userId)/subscriptions?activeOnly=true" \
| jq -r '.subscriptions[0].location.system.alarmState'
# Anything wrong with a sensor?
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" \
| jq '[.sensors[] | select(.flags.offline or .flags.lowBattery) | {name, type, flags}]'
# Recent activity
ss_api GET "/subscriptions/$SID/events?numEvents=10" \
| jq -r '.events[] | "\(.eventTimestamp|todate) \(.info)"'
More recipes, including lock state and base-station health: references/recipes.md.
Writes — read this before running one
These commands act on a physical security system. Disarming leaves a house unmonitored; arming can trip a siren and a monitoring-center dispatch; unlocking opens a real door. Confirm intent with the user before running any of them, and never run one speculatively.
ss_api POST "/ss3/subscriptions/$SID/state/away" # or /home, /off — no body
ss_api POST "/doorlock/$SID/<serial>/state" '{"state":"lock"}' # or "unlock"
A 2xx is not proof it worked. Re-read and check one field:
sleep 3
ss_api GET "/users/$(ss_api GET /api/authCheck | jq -r .userId)/subscriptions?activeOnly=true" \
| jq -r '.subscriptions[0].location.system.alarmState'
Four things that make naive verification lie:
- Arming reports
AWAY_COUNT/HOME_COUNTwhile the exit delay runs, settling toAWAY/HOME. That is success in progress, not failure. - Do not diff whole objects —
alarmStateTimestamp,stateUpdatedandlastUpdatedadvance on their own, so any call would look successful. - Re-read locks with
forceUpdate=true. The cached payload lags by minutes and has been seen reporting a jammed lock as cleanlyunlocked. - A lock takes ~5 s to report, so a 3-second check calls a successful unlock a failure. Poll, don't sleep once.
Arming also LOCKS your doors. Each lock's setting has home/away flags
that auto-lock on arm, and homeToOff/awayToOff controlling whether disarming
unlocks them. With homeToOff: 0 (common), arm-then-disarm leaves the doors
locked — so it is not a safe way to test arming. Check with:
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" \
| jq -r '.sensors[] | select(.type==16) | "\(.name)\t\(.setting)"'
A lock reporting lockJamState: 1 will not respond to commands at all: the POST
still returns 200, and nothing moves. That is a hardware fault, not a bad
request.
PINs are cleartext
/ss3/subscriptions/$SID/settings/normal returns settings.pins — the master,
duress and named-user alarm codes, in cleartext, in the same payload as the
harmless settings. Project .settings.normal unless the user explicitly wants
the codes:
ss_api GET "/ss3/subscriptions/$SID/settings/normal?forceUpdate=false" | jq '.settings.normal'
Notes
forceUpdate=truemakes the base station re-poll its devices — slower and harder on the hardware. Leave itfalseunless freshness matters.- Lock state encoding is counter-intuitive:
lockState1 = locked, 2 = unlocked, andlockJamStateoverrides both. - Device type ids
21,23and24appear on real systems but aren't in any public enum — handle unknown types gracefully. - Avoid tight polling; the API rate-limits.
Full verified endpoint reference: ~/git/simplisafe-mcp/docs/SIMPLISAFE-API.md.
