{"id":3107,"date":"2026-10-21T08:45:56","date_gmt":"2026-10-21T03:15:56","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3107"},"modified":"2026-10-21T08:47:56","modified_gmt":"2026-10-21T03:17:56","slug":"sms-account-security-audit-trail-activity-logs-connected-devices","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-account-security-audit-trail-activity-logs-connected-devices\/","title":{"rendered":"SMS Account Security Audit Trail: Activity Logs, Login Alerts and Connected Devices via API"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Your messaging account already records logins, key changes, webhook edits and credit purchases, and it lists every live session. Here&#8217;s how to copy that activity log without missing or doubling rows, watch sessions for new devices, keep login alerts from being quietly switched off, and run an incident response that actually closes the door.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices-1024x584.webp\" alt=\"Abstract chained ledger of teal bars beside three device outlines, one flagged in orange\" class=\"wp-image-3103\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-account-security-audit-trail-activity-logs-connected-devices.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">An audit trail is a chain of records plus a watch on who is signed in right now.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#three-surfaces\">Three Security Surfaces at a Glance<\/a><\/li>\n\n\n\n<li><a href=\"#activity-log\">What the Activity Log Gives You<\/a><\/li>\n\n\n\n<li><a href=\"#offset-drift\">Why Offset Paging a Newest-First Log Drifts<\/a><\/li>\n\n\n\n<li><a href=\"#incremental-sync\">An Incremental Sync That Never Misses or Doubles<\/a><\/li>\n\n\n\n<li><a href=\"#storing-rows\">Storing Activity Rows: Raw First, Hash-Chained<\/a><\/li>\n\n\n\n<li><a href=\"#classifying\">Classifying Free-Text Activity Without Breaking<\/a><\/li>\n\n\n\n<li><a href=\"#session-list\">Reading the Connected Devices List<\/a><\/li>\n\n\n\n<li><a href=\"#session-tokens\">Never Store a Session Token<\/a><\/li>\n\n\n\n<li><a href=\"#timestamps\">Timestamps You Can and Can&#8217;t Trust<\/a><\/li>\n\n\n\n<li><a href=\"#new-devices\">Spotting New Devices and Unexpected IPs<\/a><\/li>\n\n\n\n<li><a href=\"#logging-out\">Logging Out One Device or All the Others<\/a><\/li>\n\n\n\n<li><a href=\"#what-logout-misses\">What Logging Out Doesn&#8217;t Revoke<\/a><\/li>\n\n\n\n<li><a href=\"#notification-config\">Notification Settings as Checked Config<\/a><\/li>\n\n\n\n<li><a href=\"#read-vs-update\">Read and Update Speak Different Shapes<\/a><\/li>\n\n\n\n<li><a href=\"#incident-runbook\">An Incident Runbook That Uses All Three<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Implementation Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your SMS account gives you three security APIs. <code>SMSApi\/activity\/read<\/code> returns the account&#8217;s activity history, newest first, up to 200 rows a call. <code>SMSApi\/security\/connectedDevices<\/code> lists every live session, and two POST calls log out one session or all the others. <code>SMSApi\/notifications\/read<\/code> and <code>\/update<\/code> control who gets emailed about logins, low balance and usage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of them pushes anything to you, so you build the audit trail by polling. Sync the activity log by walking pages from offset 0 and stopping at the first row you already have, de-duplicating on <code>activityId<\/code>. Diff the session list against the last poll and alert on new devices and on API sessions from IPs you don&#8217;t own. Read the notification settings back every day and compare them to what you expect, because the first thing an intruder wants to switch off is the login alert. And remember that logging a session out doesn&#8217;t touch your API key or password. A real incident needs all three.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Three surfaces, all pull-only.<\/strong> Activity log (read-only), connected devices (read plus two logout writes), notification settings (read plus update).<\/li>\n\n\n\n<li><strong>The activity log is newest-first with offset paging.<\/strong> New events shift every page while you walk it. Advance the offset by the rows you actually got, de-duplicate on <code>activityId<\/code>, and stop at the first row you already stored.<\/li>\n\n\n\n<li><strong>Keep the raw <code>activity<\/code> text.<\/strong> There&#8217;s no event-type field, so classification is your job. Rules that fall through to <code>unclassified<\/code> survive new wording; rules that throw don&#8217;t.<\/li>\n\n\n\n<li><strong>Hash-chain your copy.<\/strong> The platform&#8217;s log can&#8217;t be edited through the API, but your copy can be edited by anyone with database access. A SHA-256 chain makes tampering visible.<\/li>\n\n\n\n<li><strong>The device list includes a <code>sessionToken<\/code> for every session.<\/strong> Drop it the moment you parse. Log out by <code>sessionId<\/code>.<\/li>\n\n\n\n<li><strong>Trust the activity log&#8217;s ISO 8601 timestamps.<\/strong> Session times are zone-free strings plus an epoch you should sanity-check against your own clock.<\/li>\n\n\n\n<li><strong>Alert on two things:<\/strong> a session fingerprint you haven&#8217;t seen, and any <code>loginMethod<\/code> of <code>API<\/code> from an IP outside your egress list.<\/li>\n\n\n\n<li><strong>Logging out isn&#8217;t revoking.<\/strong> Sessions end; the API key and password still work. Rotate both during an incident.<\/li>\n\n\n\n<li><strong>Notification settings are config.<\/strong> Read them back, diff them against a desired state, and alert when someone changes who receives login alerts.<\/li>\n\n\n\n<li><strong>Read and update use different names.<\/strong> <code>loginAlerts.enabled<\/code> on read is <code>enableLoginAlert<\/code> on update; <code>latencyAlert<\/code> keeps its name but drops the nesting. Map them explicitly.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-surfaces\" class=\"wp-block-heading\">Three Security Surfaces at a Glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">All five endpoints live on <code>https:\/\/unify.smsgateway.center\/<\/code>, take <code>userid<\/code> with either <code>password<\/code> or the <code>apikey<\/code> header, and accept <code>output=json<\/code>. Here&#8217;s what each one is for.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Surface<\/th><th>Endpoint<\/th><th>Method<\/th><th>Changes state?<\/th><th>Main identifier<\/th><th>Error codes<\/th><\/tr><\/thead><tbody><tr><td>Activity log<\/td><td><code>SMSApi\/activity\/read<\/code><\/td><td>POST, GET<\/td><td>No<\/td><td><code>activityId<\/code> (integer)<\/td><td>482 to 484<\/td><\/tr><tr><td>Connected devices<\/td><td><code>SMSApi\/security\/connectedDevices<\/code><\/td><td>POST, GET<\/td><td>No<\/td><td><code>sessionId<\/code> (string)<\/td><td>500<\/td><\/tr><tr><td>Log out one device<\/td><td><code>SMSApi\/security\/logoutDevice<\/code><\/td><td>POST<\/td><td>Yes<\/td><td><code>sessionId<\/code> or <code>sessionToken<\/code><\/td><td>448 to 453<\/td><\/tr><tr><td>Log out all others<\/td><td><code>SMSApi\/security\/logoutAllOtherDevices<\/code><\/td><td>POST<\/td><td>Yes<\/td><td><code>currentSessionId<\/code> in the reply<\/td><td>454 to 456<\/td><\/tr><tr><td>Notification settings<\/td><td><code>SMSApi\/notifications\/read<\/code> and <code>\/update<\/code><\/td><td>read POST, GET; update POST<\/td><td>update only<\/td><td>none<\/td><td>475 to 481<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-security-surfaces-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-security-surfaces-table.svg\" alt=\"Table comparing the activity log, device list, logout one, logout others and notification settings endpoints by path, methods, whether they change state, time format, key identifier and error codes\" class=\"wp-image-3104\"\/><\/a><figcaption class=\"wp-element-caption\">Only the two logout calls and the notification update change anything. Everything else is safe to poll as often as you like.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A few things jump out of that table before you write any code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The activity log is the only one with history. The device list is a snapshot of right now: once a session expires or gets logged out, it disappears from the list and the only trace left is whatever the activity log recorded. So if you want to answer &#8220;who was signed in last Tuesday?&#8221;, you need to have been polling last Tuesday.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Only two calls are dangerous. Polling the log and the device list can&#8217;t break anything. <code>logoutDevice<\/code> and <code>logoutAllOtherDevices<\/code> can lock people out, including you, so they belong behind a deliberate action, not inside a loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The settings endpoint has no identifier at all. It&#8217;s one document per account. That makes it easy to treat as config: one desired state, one actual state, one diff.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every reference page for these endpoints shows the parameters with <code>userid<\/code> and <code>password<\/code>. Use the <code>apikey<\/code> header with <code>userid<\/code> instead, as you would on every other endpoint. If you&#8217;re also rotating that key on a schedule, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-key-rotation-sms-api-without-dropping-messages\/\">API key rotation guide<\/a> explains why the job that rotates it should authenticate with <code>userid<\/code> and <code>password<\/code> rather than the key it&#8217;s replacing. The same reasoning applies to the incident runbook further down.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"activity-log\" class=\"wp-block-heading\">What the Activity Log Gives You<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/activity\/read<\/code> returns the authenticated user&#8217;s own activity, newest first. You pass <code>limit<\/code> (1 to 200, default 50) and <code>offset<\/code> (default 0). Here&#8217;s the shape of a successful reply, trimmed to two rows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"activity\",\n    \"action\": \"read\",\n    \"status\": \"success\",\n    \"msg\": \"Activity logs retrieved successfully\",\n    \"code\": 200,\n    \"count\": 1250,\n    \"data\": {\n      \"logs\": &#91;\n        {\n          \"activityId\": 12345,\n          \"activity\": \"User logged in successfully\",\n          \"ipAddress\": \"192.168.1.100\",\n          \"timestamp\": \"2025-12-04T18:30:45+05:30\",\n          \"formattedTime\": \"04 Dec 2025, 06:30 PM\"\n        },\n        {\n          \"activityId\": 12343,\n          \"activity\": \"API Key created via user panel\",\n          \"ipAddress\": \"192.168.1.100\",\n          \"timestamp\": \"2025-12-04T17:15:20+05:30\",\n          \"formattedTime\": \"04 Dec 2025, 05:15 PM\"\n        }\n      ],\n      \"pagination\": {\n        \"limit\": 50,\n        \"offset\": 0,\n        \"total\": 1250,\n        \"returned\": 50,\n        \"hasMore\": true\n      }\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Five fields per row. <code>activityId<\/code> is an integer, <code>timestamp<\/code> is ISO 8601 with an offset, and <code>formattedTime<\/code> is the same moment for display. You only want the first one of those two. <code>ipAddress<\/code> is where the action came from. And <code>activity<\/code> is a free-text sentence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That last field carries all the meaning, and it&#8217;s prose. There&#8217;s no <code>type<\/code>, no <code>actor<\/code>, no <code>target<\/code>. &#8220;API Key created via user panel&#8221; tells you the action and the channel in one sentence. &#8220;SMS sent to +1234567890 &#8211; Delivered successfully&#8221; carries a phone number. The reference page groups activities into categories (authentication, SMS operations, API calls, account management, contact management, credit transactions, support, configuration), but those categories aren&#8217;t in the payload. You&#8217;ll derive them yourself, and the <a href=\"#classifying\">classification section<\/a> shows how to do that without breaking the day the wording changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two counters in the reply mean the same thing: <code>count<\/code> at the top and <code>pagination.total<\/code>. Both are the total number of rows available, not the number on this page. For the page size, count <code>logs<\/code> yourself. The reference sample says <code>returned: 50<\/code> while showing five rows, which is fine for a trimmed example and a good reason not to trust a counter when the array is right there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The log is read-only through the API. A create, update or delete attempt gets error 484. That&#8217;s useful: nobody holding your API key can quietly remove the row that says they logged in. It doesn&#8217;t make the log tamper-proof in any stronger sense, and it doesn&#8217;t tell you how long rows are kept. Both of those push you toward keeping your own copy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Scope matters too. The log only shows the authenticated user&#8217;s own activities. If you run sub-users or reseller child accounts, as described in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-brand-messaging-architecture-accounts-sub-users\/\">multi-brand architecture guide<\/a>, plan on one sync per login, each with its own credentials and its own high-water mark.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"offset-drift\" class=\"wp-block-heading\">Why Offset Paging a Newest-First Log Drifts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Offset paging on a list that grows at the front is the classic way to double-count. Here&#8217;s why.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Say the account has 450 events. You ask for <code>offset=0, limit=200<\/code> and get events 450 down to 251. While you&#8217;re storing them, the account logs 30 new events: a few logins, a campaign, some API calls. You ask for <code>offset=200<\/code>. The list now has 480 rows, and position 200 is no longer event 250. It&#8217;s event 280. So page two starts with 30 rows you already have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now flip it. If rows vanish from the old end, because a retention job purged them, the list shrinks from the back. That doesn&#8217;t shift anything you haven&#8217;t read yet, so newest-first paging is mostly safe there. But if rows ever disappeared from the middle, everything after them would slide up and you&#8217;d skip rows without noticing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put plainly:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>What happens between two page calls<\/th><th>Effect on the next page<\/th><th>What saves you<\/th><\/tr><\/thead><tbody><tr><td>New events arrive at the front<\/td><td>Overlap: you re-read rows from the previous page<\/td><td>De-duplicate on <code>activityId<\/code><\/td><\/tr><tr><td>Old rows are purged from the back<\/td><td>Nothing you still need moves<\/td><td>Nothing needed<\/td><\/tr><tr><td>Rows disappear from the middle<\/td><td>Gap: rows slide past you unread<\/td><td>Compare your stored count to <code>total<\/code> after a backfill<\/td><\/tr><tr><td>Nothing changes<\/td><td>Clean pages<\/td><td>Nothing needed<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The first case is the normal one. On a busy account it happens on almost every backfill. The fix is boring and complete: treat every row as an upsert keyed on <code>activityId<\/code>, and advance the offset by the number of rows you actually received.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"incremental-sync\" class=\"wp-block-heading\">An Incremental Sync That Never Misses or Doubles<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The trick is to stop thinking about pages and start thinking about a boundary. You already have some rows. The log is newest first. So walk from offset 0 until you hit a row you already have, and everything before it is new.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-activity-sync-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-activity-sync-flow.svg\" alt=\"Three-panel flow: fetch a page from offset zero, keep rows until the first already-stored activityId, then insert oldest first with a hash chain, classify and alert\" class=\"wp-image-3105\"\/><\/a><figcaption class=\"wp-element-caption\">The sync walks back in time until it touches something it already has. That one property makes the overlap from offset drift harmless.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This relies on exactly one documented property: rows come back in reverse chronological order. It doesn&#8217;t assume <code>activityId<\/code> values are sequential or that they go up by one, which matters because nothing promises either. Here&#8217;s the whole thing in Python, with SQLite standing in for your database:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib, json, sqlite3\nfrom datetime import datetime, timezone\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS account_activity (\n  activity_id   INTEGER NOT NULL,\n  account       TEXT NOT NULL,\n  occurred_at   TEXT NOT NULL,      -- normalised to UTC for sorting\n  timestamp_raw TEXT NOT NULL,      -- exactly as received, used in the hash\n  ip_address    TEXT,\n  activity_raw  TEXT NOT NULL,\n  category      TEXT NOT NULL DEFAULT 'unclassified',\n  fetched_at    TEXT NOT NULL,\n  chain_seq     INTEGER NOT NULL,   -- insertion order; the chain follows this\n  prev_hash     TEXT NOT NULL,\n  row_hash      TEXT NOT NULL,\n  PRIMARY KEY (account, activity_id)\n);\n\"\"\"\n\ndef row_hash(prev_hash, row):\n    body = json.dumps(&#91;row&#91;\"activityId\"], row&#91;\"timestamp\"], row.get(\"ipAddress\"), row&#91;\"activity\"]],\n                      separators=(\",\", \":\"), ensure_ascii=True)\n    return hashlib.sha256((prev_hash + body).encode(\"utf-8\")).hexdigest()\n\ndef sync_activity(db, account, fetch_page, now_iso, page_size=200, max_pages=500):\n    \"\"\"fetch_page(offset, limit) returns the parsed response dict.\n    Walks newest-first pages and stops at the first row already stored.\"\"\"\n    known = {r&#91;0] for r in db.execute(\n        \"SELECT activity_id FROM account_activity WHERE account = ?\", (account,))}\n    fresh, offset = &#91;], 0\n    for _ in range(max_pages):\n        resp = fetch_page(offset, page_size).get(\"response\") or {}\n        if resp.get(\"status\") != \"success\":\n            raise RuntimeError(\"activity\/read failed: keep the old high-water mark\")\n        data = resp.get(\"data\") or {}\n        logs = data.get(\"logs\") or &#91;]\n        hit_known = False\n        for row in logs:\n            if int(row&#91;\"activityId\"]) in known:\n                hit_known = True\n                break\n            fresh.append(row)\n        if hit_known or not logs or not (data.get(\"pagination\") or {}).get(\"hasMore\"):\n            break\n        offset += len(logs)          # advance by what you got, not by \"returned\"\n    # de-duplicate rows seen twice because the list shifted under us\n    seen, ordered = set(), &#91;]\n    for row in reversed(fresh):      # oldest first, so the hash chain runs forward in time\n        aid = int(row&#91;\"activityId\"])\n        if aid not in seen:\n            seen.add(aid)\n            ordered.append(row)\n    last = db.execute(\"SELECT chain_seq, row_hash FROM account_activity WHERE account = ? \"\n                      \"ORDER BY chain_seq DESC LIMIT 1\", (account,)).fetchone()\n    seq, prev = (last&#91;0], last&#91;1]) if last else (0, \"0\" * 64)\n    with db:\n        for row in ordered:\n            h = row_hash(prev, row)\n            utc = datetime.fromisoformat(row&#91;\"timestamp\"]).astimezone(timezone.utc).isoformat()\n            seq += 1\n            db.execute(\"INSERT INTO account_activity (activity_id, account, occurred_at, \"\n                       \"timestamp_raw, ip_address, activity_raw, fetched_at, chain_seq, \"\n                       \"prev_hash, row_hash) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n                       (int(row&#91;\"activityId\"]), account, utc, row&#91;\"timestamp\"], row.get(\"ipAddress\"),\n                        row&#91;\"activity\"], now_iso, seq, prev, h))\n            prev = h\n    return len(ordered)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A few choices in there are worth calling out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The chain follows insertion order, not timestamps.<\/strong> Two events in the same second can come back in either order, so <code>chain_seq<\/code> records the order you hashed them in, and verification walks that column. Sorting by time for the chain would fail on the first same-second pair. The plain <code>INSERT<\/code> (not <code>INSERT OR IGNORE<\/code>) is deliberate too: every row reaching it is new by construction, so a key collision means the boundary logic is broken and you want the transaction to fail loudly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The first run is a backfill and needs no special code.<\/strong> With an empty table, nothing is &#8220;known&#8221;, so the loop walks until <code>hasMore<\/code> is false. Thirty new events arriving mid-backfill just show up twice in <code>fresh<\/code> and get folded by the de-duplication pass. Simulate it if you want proof: a 450-row log with 30 and then 25 events arriving between page calls. The backfill stores all 450 with no gaps, the next run picks up the 55 that arrived meanwhile, and the hash chain verifies end to end.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A failed page aborts the whole run.<\/strong> If page three errors, you don&#8217;t store pages one and two and move on. The next run starts from offset 0 again and finds the same boundary. Partial progress is fine to lose; a boundary in the wrong place isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The primary key is <code>(account, activity_id)<\/code>, not <code>activity_id<\/code> alone.<\/strong> Nothing says <code>activityId<\/code> is unique across accounts, and if you sync several logins into one table, a collision would silently drop a row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>max_pages<\/code> is a safety valve.<\/strong> At 200 rows a page, 500 pages is 100,000 rows. If a sync ever walks that far, something&#8217;s wrong with the boundary and you want it to stop and shout rather than hammer the API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">How often to poll comes down to how quickly you want to hear about a login. Every five minutes is a reasonable default for most accounts. On a quiet account each poll is one call that returns the row you already have and stops. On a busy one, the loop simply keeps paging until it meets the boundary, so you can&#8217;t fall behind by polling too slowly. You only get later alerts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After the first backfill, compare your stored row count with <code>total<\/code> from the last page. If you&#8217;re short, re-run the backfill once; the upsert makes that safe. If you&#8217;re still short, log it. You&#8217;ve probably hit a retention window and the oldest rows are gone.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"storing-rows\" class=\"wp-block-heading\">Storing Activity Rows: Raw First, Hash-Chained<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Store exactly what you received. Normalise the timestamp into a UTC column for sorting, but keep <code>timestamp_raw<\/code> too, and never rewrite <code>activity_raw<\/code>. Classification, masking and enrichment go into other columns or other tables. When you&#8217;re reading an incident timeline at 2 a.m., you want the sentence the platform wrote, not your parser&#8217;s opinion of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The hash chain is cheap tamper evidence. Each row&#8217;s <code>row_hash<\/code> is SHA-256 over the previous row&#8217;s hash plus a canonical JSON of the four fields that matter. Change any stored row, delete one from the middle, or reorder them, and every hash after that point stops matching. Checking it is a single pass:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def verify_chain(db, account):\n    prev = \"0\" * 64\n    for aid, ts, ip, raw, stored_prev, stored_hash in db.execute(\n            \"SELECT activity_id, timestamp_raw, ip_address, activity_raw, prev_hash, row_hash \"\n            \"FROM account_activity WHERE account = ? ORDER BY chain_seq\", (account,)):\n        expected = row_hash(prev, {\"activityId\": aid, \"timestamp\": ts,\n                                   \"ipAddress\": ip, \"activity\": raw})\n        if stored_prev != prev or stored_hash != expected:\n            return aid               # first row that no longer matches\n        prev = stored_hash\n    return None<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A chain only proves something if the tail hash lives somewhere the database admin can&#8217;t reach. Write the latest <code>row_hash<\/code> to a separate store once a day: an append-only bucket, a ticket, a message to your security channel. Then &#8220;the chain verifies and ends at the hash we published on the 14th&#8221; is a statement someone can check.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two access rules round this out, and both come straight from the <a href=\"https:\/\/cheatsheetseries.owasp.org\/cheatsheets\/Logging_Cheat_Sheet.html\">OWASP Logging Cheat Sheet<\/a>. The writer should be a database account that can insert into this table and do nothing else. And reading the raw table should itself be restricted, because <code>activity_raw<\/code> contains phone numbers. Give most people a view with the digits masked:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE VIEW account_activity_masked AS\nSELECT account, activity_id, occurred_at, ip_address, category,\n       mask_digits(activity_raw) AS activity\nFROM account_activity;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>mask_digits<\/code> is whatever your database offers for regex replacement; the JavaScript version in the next section shows the pattern.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Retention is your call, and it should be a written policy rather than &#8220;forever by accident&#8221;. Pick a period that covers your incident investigations and any legal hold you&#8217;re subject to, then delete on schedule. When you do delete, delete from the old end only, and record the hash of the last row you removed so the chain still has a verifiable start.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"classifying\" class=\"wp-block-heading\">Classifying Free-Text Activity Without Breaking<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Since <code>activity<\/code> is a sentence, you need a classifier to turn it into something you can alert on. The design rule is simple: unknown wording must land in a bucket, never in an exception. The platform can rephrase &#8220;API Key created via user panel&#8221; tomorrow and your pipeline should keep running, just with one more <code>unclassified<\/code> row to look at.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ordered regex rules work fine for this. First match wins, so put the specific rules above the general ones:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Ordered rules: first match wins. Unknown wording falls through to 'unclassified'.\nconst RULES = &#91;\n  &#91;'auth.login_failed', \/\\b(failed|invalid|incorrect)\\b.*\\blog ?in\\b|\\blog ?in\\b.*\\b(failed|invalid)\\b\/i],\n  &#91;'auth.login',        \/\\blogged in\\b|\\blog ?in successful\/i],\n  &#91;'auth.logout',       \/\\blogged out\\b|\\blog ?out\\b\/i],\n  &#91;'credential.apikey', \/\\bapi ?key\\b\/i],\n  &#91;'credential.password', \/\\bpassword\\b\/i],\n  &#91;'config.webhook',    \/\\bwebhook\\b\/i],\n  &#91;'config.sender',     \/\\bsender ?id\\b\/i],\n  &#91;'config.template',   \/\\btemplate\\b\/i],\n  &#91;'config.notifications', \/\\bnotification\\b\/i],\n  &#91;'billing.credit',    \/\\bcredit|\\bwallet\\b|\\bbalance\\b\/i],\n  &#91;'contacts',          \/\\bcontact|\\bgroup\\b\/i],\n  &#91;'messaging.send',    \/\\bsms sent\\b|\\bsent to\\b|\\bcampaign\\b|\\bscheduled\\b\/i],\n  &#91;'support',           \/\\bticket\\b\/i],\n];\nconst HIGH_RISK = new Set(&#91;'auth.login_failed', 'credential.apikey', 'credential.password',\n                           'config.webhook', 'config.notifications']);\n\nfunction classify(text) {\n  for (const &#91;category, re] of RULES) if (re.test(text)) return category;\n  return 'unclassified';\n}\n\n\/\/ Mask long digit runs (phone numbers) before the text leaves the restricted table.\nfunction maskDigits(text) {\n  return text.replace(\/\\+?\\d&#91;\\d\\s-]{6,}\\d\/g, m =&gt; m.slice(0, 3) + '***' + m.slice(-2));\n}\n\nmodule.exports = { classify, maskDigits, HIGH_RISK };<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run against the five sample sentences on the activity log reference page, it gives:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><code>activity<\/code> text<\/th><th>Category<\/th><th>Masked text<\/th><\/tr><\/thead><tbody><tr><td>User logged in successfully<\/td><td><code>auth.login<\/code><\/td><td>unchanged<\/td><\/tr><tr><td>SMS sent to +1234567890 &#8211; Delivered successfully<\/td><td><code>messaging.send<\/code><\/td><td>SMS sent to +12***90 &#8211; Delivered successfully<\/td><\/tr><tr><td>API Key created via user panel<\/td><td><code>credential.apikey<\/code><\/td><td>unchanged<\/td><\/tr><tr><td>Contact group &#8216;Customers&#8217; created with 150 contacts<\/td><td><code>contacts<\/code><\/td><td>unchanged<\/td><\/tr><tr><td>Credit purchased: 10000 Credits added to account<\/td><td><code>billing.credit<\/code><\/td><td>unchanged<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Notice &#8220;150 contacts&#8221; and &#8220;10000 Credits&#8221; survive the mask. The pattern only catches runs of eight or more digits, which is roughly where phone numbers start and quantities usually stop. Tune that to your own data once you&#8217;ve seen a few thousand rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>auth.login_failed<\/code> rule is speculative. Whether failed logins appear in this log at all, and in what words, is something you&#8217;ll only learn by watching. Keep the rule anyway: it costs nothing if it never matches, and it&#8217;s the most valuable alert in the set if it does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three habits keep the classifier honest over time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Count <code>unclassified<\/code> per day and alert on a jump.<\/strong> A sudden rise usually means the wording changed, and the rule you care about most may have stopped matching. That&#8217;s the cheapest early warning you&#8217;ll get.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Store the category, never overwrite the text.<\/strong> When you improve a rule, re-run it over <code>activity_raw<\/code> and update <code>category<\/code> in place. The chain hashes don&#8217;t cover <code>category<\/code>, so reclassifying never breaks verification.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Alert on the category, not the sentence.<\/strong> Your paging rule should say &#8220;any <code>credential.apikey<\/code> row outside a change window&#8221;, not &#8220;text contains &#8216;API Key created'&#8221;. The day the phrasing moves, you fix one regex instead of every alert.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"session-list\" class=\"wp-block-heading\">Reading the Connected Devices List<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/security\/connectedDevices<\/code> returns every active session on the account. No parameters beyond credentials and <code>output<\/code>. One row looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"sessionId\": \"abc123def456ghi789jkl012mno345pqr678stu901vwx234yz\",\n  \"sessionToken\": \"xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234\",\n  \"deviceName\": \"Chrome on Windows\",\n  \"deviceType\": \"Desktop\",\n  \"platform\": \"Desktop\",\n  \"os\": \"Windows\",\n  \"browser\": \"Chrome\",\n  \"location\": \"Mumbai, Maharashtra, India\",\n  \"ipAddress\": \"192.168.1.1\",\n  \"loginMethod\": \"UI\",\n  \"loginTime\": \"2025-01-15 10:30:00\",\n  \"lastActivity\": \"2025-01-15 11:45:00\",\n  \"lastActivityTimestamp\": 1705303500000,\n  \"timeAgo\": \"15 minutes ago\",\n  \"isCurrentSession\": false\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The rows sit in <code>response.sessions<\/code>, and <code>response.count<\/code> is the number of sessions. That&#8217;s a different meaning of <code>count<\/code> from the activity log, where it&#8217;s the total across all pages. Read the array, not the counter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what each field is good for when you&#8217;re building monitoring rather than a settings screen:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Field<\/th><th>Use it for<\/th><th>Don&#8217;t use it for<\/th><\/tr><\/thead><tbody><tr><td><code>sessionId<\/code><\/td><td>The key for diffs and for <code>logoutDevice<\/code><\/td><td>Display (it&#8217;s long and means nothing to people)<\/td><\/tr><tr><td><code>sessionToken<\/code><\/td><td>Nothing. Drop it at parse time<\/td><td>Anything at all<\/td><\/tr><tr><td><code>deviceName<\/code>, <code>os<\/code>, <code>browser<\/code>, <code>deviceType<\/code><\/td><td>A rough device fingerprint<\/td><td>Proving identity (all derived from a user agent)<\/td><\/tr><tr><td><code>location<\/code><\/td><td>A human-readable hint<\/td><td>Hard rules (it&#8217;s IP geolocation, coarse and spoofable)<\/td><\/tr><tr><td><code>ipAddress<\/code><\/td><td>Allowlist checks for API sessions<\/td><td>Assuming a private address means &#8220;internal&#8221;<\/td><\/tr><tr><td><code>loginMethod<\/code><\/td><td>Splitting <code>UI<\/code> from <code>API<\/code> sessions<\/td><td>Nothing else; it&#8217;s the most useful field here<\/td><\/tr><tr><td><code>loginTime<\/code>, <code>lastActivity<\/code><\/td><td>Display<\/td><td>Arithmetic (no time zone)<\/td><\/tr><tr><td><code>lastActivityTimestamp<\/code><\/td><td>Ordering, after a sanity check<\/td><td>Blind trust (see <a href=\"#timestamps\">timestamps<\/a>)<\/td><\/tr><tr><td><code>timeAgo<\/code><\/td><td>Nothing in code<\/td><td>Anything stored; it&#8217;s stale the moment you save it<\/td><\/tr><tr><td><code>isCurrentSession<\/code><\/td><td>Knowing which row is the caller<\/td><td>Assuming your server&#8217;s call has a row at all<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><code>deviceType<\/code> and <code>platform<\/code> carry the same three values (Desktop, Mobile, Tablet). Pick one for your fingerprint and ignore the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The list only shows live sessions. When one expires, gets logged out, or times out, it&#8217;s gone, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-your-connected-devices-a-complete-guide-to-device-information-and-session-details\/\">connected devices guide<\/a> confirms that inactive sessions don&#8217;t appear. So the list can tell you who&#8217;s in right now and nothing about who was in an hour ago. Your poller&#8217;s snapshots are the only session history you&#8217;ll have, which is a good reason to keep them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"session-tokens\" class=\"wp-block-heading\">Never Store a Session Token<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Look at that row again. Every session in the list comes back with a <code>sessionToken<\/code>, including sessions belonging to other devices. That token is accepted by <code>logoutDevice<\/code> as an alternative to <code>sessionId<\/code>, which tells you it identifies the session. Treat it as a live credential.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the parser should drop it before anything else touches the payload:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>KEEP = (\"sessionId\", \"deviceName\", \"deviceType\", \"platform\", \"os\", \"browser\",\n        \"location\", \"ipAddress\", \"loginMethod\", \"loginTime\", \"lastActivityTimestamp\",\n        \"isCurrentSession\")          # sessionToken is deliberately absent\n\ndef parse_sessions(resp):\n    r = resp.get(\"response\") or {}\n    if r.get(\"status\") != \"success\":\n        return None                  # unknown state: do not diff, do not alert on \"all gone\"\n    rows = r.get(\"sessions\") or &#91;]\n    return {s&#91;\"sessionId\"]: {k: s.get(k) for k in KEEP} for s in rows}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An allowlist of fields beats a denylist here. If a future version adds <code>refreshToken<\/code> or <code>deviceSecret<\/code>, an allowlist drops it without anyone noticing, and a denylist leaks it until someone does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same discipline applies one layer out. Don&#8217;t log the raw HTTP response from <code>connectedDevices<\/code> at debug level. Don&#8217;t send it to an error tracker on a parse failure. Don&#8217;t paste it into a ticket. The <a href=\"https:\/\/cheatsheetseries.owasp.org\/cheatsheets\/Logging_Cheat_Sheet.html\">OWASP Logging Cheat Sheet<\/a> lists session identifiers and access tokens among the things that shouldn&#8217;t be recorded directly, and a session token sitting in a log aggregator is exactly the kind of thing that turns a minor breach into a major one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you do need to log out by token for some reason, fetch the list, use the token in memory for that one call, and let it go. You never need to keep it. <code>sessionId<\/code> does the same job and is far less dangerous to store.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Also note the <code>parse_sessions<\/code> return of <code>None<\/code> on failure. A failed read isn&#8217;t an empty list. If your differ treated a failed call as &#8220;zero sessions&#8221;, it would announce that every session ended, and the next successful poll would announce they all started again. Skip the diff when the read fails.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"timestamps\" class=\"wp-block-heading\">Timestamps You Can and Can&#8217;t Trust<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These three APIs use four time formats between them:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Where<\/th><th>Field<\/th><th>Format<\/th><th>Zone<\/th><\/tr><\/thead><tbody><tr><td>Activity log rows<\/td><td><code>timestamp<\/code><\/td><td>ISO 8601<\/td><td>Explicit offset, e.g. <code>+05:30<\/code><\/td><\/tr><tr><td>Activity log rows<\/td><td><code>formattedTime<\/code><\/td><td>&#8220;04 Dec 2025, 06:30 PM&#8221;<\/td><td>None<\/td><\/tr><tr><td>Session rows<\/td><td><code>loginTime<\/code>, <code>lastActivity<\/code><\/td><td><code>Y-m-d H:i:s<\/code><\/td><td>None<\/td><\/tr><tr><td>Session rows<\/td><td><code>lastActivityTimestamp<\/code><\/td><td>Epoch milliseconds (integer)<\/td><td>UTC by definition<\/td><\/tr><tr><td>Logout and update replies<\/td><td><code>timestamp<\/code><\/td><td>ISO 8601<\/td><td>Explicit offset (<code>+00:00<\/code> on logout, <code>+05:30<\/code> on notification update)<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The activity log&#8217;s <code>timestamp<\/code> is the one to build on. It carries its own offset, so it converts cleanly to UTC, and that&#8217;s what the sync code stores in <code>occurred_at<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Session times are trickier. <code>loginTime<\/code> and <code>lastActivity<\/code> have no zone, so you can&#8217;t compare them with anything else without guessing. <code>lastActivityTimestamp<\/code> is an epoch, which is unambiguous in principle. In the published sample, though, it doesn&#8217;t agree with its own <code>lastActivity<\/code> string in any zone. Run the numbers:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timezone, timedelta\nIST = timezone(timedelta(hours=5, minutes=30))\nprint(datetime.fromtimestamp(1705303500000 \/ 1000, timezone.utc))   # 2024-01-15 07:25:00+00:00\nprint(datetime.fromtimestamp(1705303500000 \/ 1000, IST))            # 2024-01-15 12:55:00+05:30\n# the same row's lastActivity string says \"2025-01-15 11:45:00\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A year and some hours apart. On a reference page that&#8217;s just a hand-written sample, and the live API may well be consistent. But it&#8217;s a good reminder of the rule you&#8217;d follow anyway: measure, don&#8217;t assume.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things keep you safe whatever the real behaviour turns out to be.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Record <code>first_seen_at<\/code> from your own clock.<\/strong> The first time your poller sees a <code>sessionId<\/code>, stamp it with your server&#8217;s UTC time. That value is always comparable with everything else you store, and it&#8217;s accurate to within one polling interval. For &#8220;when did this device appear?&#8221;, it&#8217;s the honest answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Sanity-check the epoch before using it.<\/strong> If <code>lastActivityTimestamp<\/code> is in the future, or more than a couple of days older than your <code>first_seen_at<\/code> for a session that&#8217;s clearly still active, flag the row as clock-suspect and fall back to your own timestamps. The differ in the next section does exactly that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you need to know which zone <code>loginTime<\/code> uses, find out by measurement. Log in through the portal, poll the list straight away, and compare that session&#8217;s <code>loginTime<\/code> with your own clock. The offset you see is the offset to use, and it&#8217;s worth re-checking after any platform change.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"new-devices\" class=\"wp-block-heading\">Spotting New Devices and Unexpected IPs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With a clean parse, detection is a diff between the last snapshot and this one. New <code>sessionId<\/code> values are logins. Missing ones are logouts or expiries. The question is which logins deserve a page at 2 a.m.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Split them by <code>loginMethod<\/code> first, because <code>UI<\/code> and <code>API<\/code> sessions mean very different things.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>API<\/code> sessions should come from machines you own.<\/strong> Your servers have a short list of egress IPs. An <code>API<\/code> session from anywhere else means someone holds working credentials and is using them outside your infrastructure. That&#8217;s high severity, every time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>UI<\/code> sessions come from people.<\/strong> People travel, change laptops and use their phones. Alerting on every new IP will train your team to ignore the alert within a week. Alert instead on a device fingerprint you haven&#8217;t seen on this account before: the combination of <code>loginMethod<\/code>, <code>deviceType<\/code>, <code>os<\/code> and <code>browser<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import time\n\ndef fingerprint(s):\n    return (s.get(\"loginMethod\"), s.get(\"deviceType\"), s.get(\"os\"), s.get(\"browser\"))\n\ndef diff_sessions(previous, current, known_fingerprints, api_egress_ips, now_ms=None):\n    \"\"\"previous\/current: {sessionId: row}. Returns a list of (severity, message).\"\"\"\n    now_ms = now_ms or int(time.time() * 1000)\n    alerts = &#91;]\n    for sid, s in current.items():\n        if sid in previous:\n            continue\n        if s.get(\"loginMethod\") == \"API\" and s.get(\"ipAddress\") not in api_egress_ips:\n            alerts.append((\"high\", f\"API session from unexpected IP {s.get('ipAddress')}\"))\n        elif fingerprint(s) not in known_fingerprints:\n            alerts.append((\"medium\", f\"new device {s.get('deviceName')} from {s.get('location')}\"))\n        ts = s.get(\"lastActivityTimestamp\")\n        if isinstance(ts, int) and abs(now_ms - ts) &gt; 86_400_000 * 2:\n            alerts.append((\"info\", f\"session {sid&#91;:8]} clock looks off; using first-seen time\"))\n    for sid in previous.keys() - current.keys():\n        alerts.append((\"info\", f\"session {sid&#91;:8]} ended\"))\n    return alerts<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Feed <code>known_fingerprints<\/code> from your own history: every fingerprint that&#8217;s appeared on this account in the last 90 days and wasn&#8217;t followed by an incident. A medium alert on a new fingerprint should go to the account owner as a &#8220;was this you?&#8221; message, not to the on-call phone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then cross-reference the activity log. A new session should line up with a login row near the same moment, from the same <code>ipAddress<\/code>. A session with no matching login row is odd enough to look at. A burst of login rows with no new sessions might be failed attempts, depending on how the platform words them. Correlating the two sources catches things neither catches alone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two cautions on the inputs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>location<\/code> is IP geolocation. It&#8217;s useful for a human reading the alert (&#8220;Mumbai, Maharashtra, India&#8221; is easier to judge than an address), but VPNs, mobile carriers and corporate proxies move it around constantly. Don&#8217;t build hard rules on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ipAddress<\/code> is what the platform saw. The reference samples show private-range addresses such as <code>192.168.1.100<\/code>, which is normal for placeholder data. If you ever see a private address on a real session, don&#8217;t read it as &#8220;from inside our network&#8221;. It more likely means a proxy in the path, and you should find out which one before you allowlist anything. If you already restrict API access by IP, as covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/ip-based-api-security-sms-gateway-unauthorized-access-protection\/\">IP-based API security<\/a>, your egress list and that allowlist should be the same list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Polling cadence follows from what you&#8217;re trying to catch. The device list is a snapshot, so a session that starts and ends between two polls is invisible to it. Only the activity log will show it. Poll both on the same schedule, every five minutes or so, and let the log cover the gaps.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"logging-out\" class=\"wp-block-heading\">Logging Out One Device or All the Others<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two calls change session state, and both are POST only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/security\/logoutDevice<\/code> takes either <code>sessionId<\/code> or <code>sessionToken<\/code>, not both. Use <code>sessionId<\/code>. A successful reply looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"security\",\n    \"action\": \"logoutDevice\",\n    \"status\": \"success\",\n    \"msg\": \"Device logged out successfully\",\n    \"code\": 200,\n    \"count\": 1,\n    \"logout\": {\n      \"sessionId\": \"abc123def456ghi789jkl012mno345pqr678stu901vwx234yz\",\n      \"deviceName\": \"Chrome on Windows\",\n      \"loggedOut\": true,\n      \"timestamp\": \"2025-01-15T12:00:00+00:00\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/security\/logoutAllOtherDevices<\/code> takes nothing but credentials. It ends every session except the caller&#8217;s, and replies with <code>logoutAll.currentSessionId<\/code>, <code>logoutAll.devicesLoggedOut<\/code> and a timestamp. Here <code>count<\/code> is 1 while <code>devicesLoggedOut<\/code> can be 3, so once again: read the field you mean, not the counter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The error codes are where you decide what &#8220;done&#8221; means. Some errors mean the goal is already met:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Code<\/th><th>Meaning<\/th><th>Treat as<\/th><\/tr><\/thead><tbody><tr><td>448<\/td><td>Session ID or token is required<\/td><td>Bug in your call. Fix and don&#8217;t retry<\/td><\/tr><tr><td>449<\/td><td>Invalid session ID<\/td><td>Bug or a stale ID. Re-read the list<\/td><\/tr><tr><td>450<\/td><td>Session not found<\/td><td>Done. It&#8217;s already gone<\/td><\/tr><tr><td>451<\/td><td>Session doesn&#8217;t belong to user<\/td><td>Alert. You&#8217;re holding an ID from another account<\/td><\/tr><tr><td>452, 453<\/td><td>Failed or error while logging out<\/td><td>Retry with backoff, then escalate<\/td><\/tr><tr><td>454, 456<\/td><td>Failed or error logging out other devices<\/td><td>Retry with backoff, then escalate<\/td><\/tr><tr><td>455<\/td><td>No active sessions found<\/td><td>Done. There was nothing else to log out<\/td><\/tr><tr><td>500<\/td><td>General error<\/td><td>Retry with backoff<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Whatever the reply, confirm by re-reading. Call <code>connectedDevices<\/code> again and check the session is missing. A <code>\"loggedOut\": true<\/code> tells you the platform accepted the request; the list not containing that <code>sessionId<\/code> tells you it worked. That read-back costs one call and turns an assumption into a fact.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the sharp edge. &#8220;All other devices&#8221; means all except the session making the call. When a person clicks the button in the portal, that&#8217;s their browser, and it&#8217;s exactly what they want. When your server calls it with the <code>apikey<\/code> header, which session is &#8220;current&#8221;? It might be an <code>API<\/code> session the call itself creates or reuses. It might be none. Either way, every human signed in to the portal gets logged out, including whoever is trying to deal with the incident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So split the two cases. Automation logs out specific <code>sessionId<\/code> values it has decided are bad. <code>logoutAllOtherDevices<\/code> is for a person in a browser, or for a break-glass script that someone runs on purpose, knowing it will sign everyone out. Never put it in a scheduled job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The portal&#8217;s own advice for a suspected compromise is to log out all other devices and then change the password, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/managing-and-securing-your-connected-devices-step-by-step-guide\/\">connected devices step-by-step guide<\/a> walks through the screens. The next section explains why, for anyone using the API, that&#8217;s not quite enough.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-logout-misses\" class=\"wp-block-heading\">What Logging Out Doesn&#8217;t Revoke<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A session is one way into the account. It&#8217;s not the only one, and ending sessions does nothing to the others.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Way in<\/th><th>Ended by logging out sessions?<\/th><th>What actually closes it<\/th><\/tr><\/thead><tbody><tr><td>A live portal session<\/td><td>Yes<\/td><td><code>logoutDevice<\/code> or <code>logoutAllOtherDevices<\/code><\/td><\/tr><tr><td>The password<\/td><td>No<\/td><td>Change the password<\/td><\/tr><tr><td>The API key (<code>apikey<\/code> header)<\/td><td>No<\/td><td>Rotate the key<\/td><\/tr><tr><td>An OAuth access token you issued to an app<\/td><td>Unclear<\/td><td>Let it expire and revoke the app&#8217;s refresh token<\/td><\/tr><tr><td>A webhook URL the intruder added<\/td><td>No<\/td><td>Read back webhook config and restore it<\/td><\/tr><tr><td>An email address added to alerts<\/td><td>No<\/td><td>Read back notification settings and restore them<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The first three rows are the obvious ones. Someone who has your password logs straight back in after you end their session. Someone who has your API key never had a session to end; every request authenticates on its own. If you&#8217;ve followed the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-key-rotation-sms-api-without-dropping-messages\/\">API key rotation guide<\/a>, you already have a rotator that can issue a new key with your senders paused and resumed, so rotating during an incident is a button, not a project. For apps connected through OAuth, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/\">OAuth guide<\/a> covers the token lifetimes involved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The last two rows are about persistence. Someone who&#8217;s been inside your account for an hour doesn&#8217;t need to keep a session open if they&#8217;ve pointed your delivery webhook at their own server, or added their address to the login alerts so they&#8217;ll know when you&#8217;re investigating. Neither change shows up in the device list. Both should show up in the activity log, under configuration or account management, which is why the classifier marks <code>config.webhook<\/code> and <code>config.notifications<\/code> as high risk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So an incident is closed when every row in that table is closed, and you&#8217;ve confirmed each one by reading it back. The <a href=\"#incident-runbook\">runbook<\/a> puts them in order.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"notification-config\" class=\"wp-block-heading\">Notification Settings as Checked Config<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/notifications\/read<\/code> returns one settings document for the account. The groups you&#8217;ll see depend on what the account has: everyone gets <code>balanceAlerts<\/code>, <code>creditTransactionAlerts<\/code> and <code>loginAlerts<\/code>; SMS accounts get <code>smsAlerts<\/code>; <code>whatsappAlerts<\/code> and <code>voiceCallAlerts<\/code> appear only when those products are enabled; reseller accounts get <code>resellerAlerts<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For security, one group matters more than the rest:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"loginAlerts\": {\n  \"enabled\": true,\n  \"emails\": \"security@example.com\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If that&#8217;s on and pointed at a mailbox your security team reads, every portal login becomes an email. It&#8217;s the cheapest detection you&#8217;ll ever set up. It&#8217;s also the obvious target for anyone who gets in. Switch <code>enabled<\/code> to false, or swap the address for one of theirs, and your login alerts go quiet while the account looks perfectly normal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So don&#8217;t treat these settings as a form someone filled in once. Treat them as config with a desired state, and check it:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Keep the desired settings in version control, as update parameters: <code>enableLoginAlert=1<\/code>, <code>loginAlertEmails=security@example.com<\/code>, <code>enableBalanceAlert=1<\/code>, and so on.<\/li>\n\n\n\n<li>Once a day, and immediately after any <code>config.notifications<\/code> row shows up in the activity log, call <code>notifications\/read<\/code>.<\/li>\n\n\n\n<li>Diff the reply against the desired state.<\/li>\n\n\n\n<li>Any difference raises an alert. Whether you also auto-correct is a policy choice; at minimum, alert first, so a person sees that someone changed it.<\/li>\n\n\n\n<li>After any update, read the settings back and diff again.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The balance alerts are worth keeping in that desired state too. <code>balanceAlerts.smsBalance<\/code> and <code>smsAmount<\/code> set the thresholds, and a balance that hits zero stops your sends as surely as a breach does. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">invoice reconciliation guide<\/a> covers the billing side if you want to tie those thresholds to real spend.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Email lists compare as sets. <code>\"ops@example.com,Finance@example.com\"<\/code> and <code>\"finance@example.com, ops@example.com\"<\/code> are the same list: split on commas, trim, lowercase, sort. Comparing raw strings would raise a false alarm every time someone reorders the addresses.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"read-vs-update\" class=\"wp-block-heading\">Read and Update Speak Different Shapes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>notifications\/read<\/code> returns nested JSON with booleans. <code>notifications\/update<\/code> takes flat form parameters with <code>1<\/code> and <code>0<\/code>. The names don&#8217;t line up mechanically, so you need an explicit map:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Read path<\/th><th>Update parameter<\/th><th>Read type<\/th><th>Update type<\/th><\/tr><\/thead><tbody><tr><td><code>balanceAlerts.enabled<\/code><\/td><td><code>enableBalanceAlert<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>balanceAlerts.smsBalance<\/code><\/td><td><code>smsBalance<\/code><\/td><td>integer<\/td><td>integer<\/td><\/tr><tr><td><code>balanceAlerts.smsAmount<\/code><\/td><td><code>smsAmount<\/code><\/td><td>string, e.g. &#8220;50.0000&#8221;<\/td><td>decimal, e.g. 50.00<\/td><\/tr><tr><td><code>balanceAlerts.emails<\/code><\/td><td><code>smsBalanceEmails<\/code><\/td><td>comma-separated string<\/td><td>comma-separated string<\/td><\/tr><tr><td><code>balanceAlerts.dailyBalanceAlert.enabled<\/code><\/td><td><code>dailyBalanceAlert<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>balanceAlerts.dailyBalanceAlert.emails<\/code><\/td><td><code>dailyBalanceAlertEmails<\/code><\/td><td>string<\/td><td>string<\/td><\/tr><tr><td><code>smsAlerts.dailyUsageSummary.enabled<\/code><\/td><td><code>enableDailyUsageSummary<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>smsAlerts.weeklyUsageSummary.enabled<\/code><\/td><td><code>enableWeeklyUsageSummary<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>smsAlerts.monthlyUsageSummary.enabled<\/code><\/td><td><code>enableMonthlyUsageSummary<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>smsAlerts.monthlyDaywiseUsageSummary.enabled<\/code><\/td><td><code>enableMonthlyDaywiseUsageSummary<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>smsAlerts.latencyAlert.enabled<\/code><\/td><td><code>latencyAlert<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>creditTransactionAlerts.weeklySummary.enabled<\/code><\/td><td><code>enableWeeklyCreditTransaction<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>creditTransactionAlerts.monthlySummary.enabled<\/code><\/td><td><code>enableMonthlyCreditTransaction<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>loginAlerts.enabled<\/code><\/td><td><code>enableLoginAlert<\/code><\/td><td>boolean<\/td><td><code>1<\/code> or <code>0<\/code><\/td><\/tr><tr><td><code>loginAlerts.emails<\/code><\/td><td><code>loginAlertEmails<\/code><\/td><td>string<\/td><td>string<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Each <code>.emails<\/code> path under a summary maps to the matching <code>...Emails<\/code> parameter (<code>smsAlerts.weeklyUsageSummary.emails<\/code> to <code>weeklyUsageSummaryEmails<\/code>, <code>creditTransactionAlerts.monthlySummary.emails<\/code> to <code>monthlyCreditTransactionEmails<\/code>, and so on).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Watch the two odd ones. <code>latencyAlert<\/code> and <code>dailyBalanceAlert<\/code> are the flag names on update, with no <code>enable<\/code> prefix, while every other flag has one. And <code>smsAmount<\/code> comes back as a four-decimal string but goes in as a plain decimal, so compare it as a number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp, voice and reseller settings have update parameters (<code>enableWaDailyUsageSummary<\/code>, <code>enableVcDailyUsageSummary<\/code>, <code>enableUsersBalance<\/code> and their email twins), but their read shapes aren&#8217;t in the reference sample. Don&#8217;t guess the nesting. Map only the paths you&#8217;ve seen in a real reply, and report anything else as unmapped. Here&#8217;s the drift check, with the map trimmed to a few entries for space:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>FLAG_MAP = {\n    (\"balanceAlerts\", \"enabled\"): \"enableBalanceAlert\",\n    (\"balanceAlerts\", \"dailyBalanceAlert\", \"enabled\"): \"dailyBalanceAlert\",\n    (\"smsAlerts\", \"latencyAlert\", \"enabled\"): \"latencyAlert\",\n    (\"loginAlerts\", \"enabled\"): \"enableLoginAlert\",\n    # ...one entry per row of the table above\n}\nEMAIL_MAP = {\n    (\"balanceAlerts\", \"emails\"): \"smsBalanceEmails\",\n    (\"smsAlerts\", \"latencyAlert\", \"emails\"): \"latencyAlertEmails\",\n    (\"loginAlerts\", \"emails\"): \"loginAlertEmails\",\n    # ...\n}\nNUMBER_MAP = {\n    (\"balanceAlerts\", \"smsBalance\"): \"smsBalance\",\n    (\"balanceAlerts\", \"smsAmount\"): \"smsAmount\",\n}\n\ndef walk(node, path=()):\n    if isinstance(node, dict):\n        for k, v in node.items():\n            yield from walk(v, path + (k,))\n    else:\n        yield path, node\n\ndef emails(value):\n    return frozenset(e.strip().lower() for e in str(value or \"\").split(\",\") if e.strip())\n\ndef drift(current, desired):\n    \"\"\"current: response.notifications from read. desired: {update_param: value}.\n    Returns (changes_to_send, unmapped_paths).\"\"\"\n    changes, unmapped = {}, &#91;]\n    for path, value in walk(current):\n        if path in FLAG_MAP:\n            p = FLAG_MAP&#91;path]\n            if p in desired and int(bool(value)) != int(desired&#91;p]):\n                changes&#91;p] = int(desired&#91;p])\n        elif path in EMAIL_MAP:\n            p = EMAIL_MAP&#91;path]\n            if p in desired and emails(value) != emails(desired&#91;p]):\n                changes&#91;p] = \",\".join(sorted(emails(desired&#91;p])))\n        elif path in NUMBER_MAP:\n            p = NUMBER_MAP&#91;path]\n            if p in desired and abs(float(value) - float(desired&#91;p])) &gt; 1e-9:\n                changes&#91;p] = desired&#91;p]\n        else:\n            unmapped.append(\".\".join(path))\n    return changes, unmapped<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Feed it a read where someone has turned login alerts off and pointed them at an outside address, and it returns <code>{\"enableLoginAlert\": 1, \"loginAlertEmails\": \"security@example.com\"}<\/code> as the changes to send, plus any WhatsApp paths it doesn&#8217;t recognise in <code>unmapped<\/code>. That&#8217;s the shape you want: a small, specific update, and a list of things a person should look at.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three rules for the update call itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Send only what changed.<\/strong> The reference page says to include only the parameters you want to change, and the drift check already gives you exactly that set. A full rewrite risks blanking a field you didn&#8217;t mean to touch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Don&#8217;t send an empty email list to mean &#8220;off&#8221;.<\/strong> To disable an alert, send its flag as <code>0<\/code> and leave the addresses alone. Whether an empty <code>...Emails=<\/code> clears the list or is ignored isn&#8217;t something to find out in production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Read back after every update.<\/strong> The platform filters parameters by your account&#8217;s products, so a WhatsApp parameter sent from an SMS-only account may be dropped without an error. And error 478 means &#8220;no changes made or nothing to update&#8221;, which can happen when your desired value already matches. In both cases the read-back settles it: if the diff is empty afterwards, you&#8217;re done.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Error 477 is an email validation failure. Validate addresses yourself before sending so the whole update doesn&#8217;t fail on one typo.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"incident-runbook\" class=\"wp-block-heading\">An Incident Runbook That Uses All Three<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the order that works when you think someone else is in the account. The aim is to keep evidence, close every way in, and confirm each step by reading something back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Freeze the evidence before you touch anything.<\/strong> Run the activity sync now, export the result, and save the current <code>connectedDevices<\/code> snapshot (tokens already stripped by your parser). Record the tail <code>row_hash<\/code>. Logging people out changes the session list, and you want the &#8220;before&#8221; picture.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Log out the sessions you&#8217;ve identified as bad.<\/strong> By <code>sessionId<\/code>, from your tooling. If you can&#8217;t tell good from bad, have a person in the portal use &#8220;log out all other devices&#8221; from a browser they trust.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Change the password.<\/strong> Do it from that same trusted browser. Until this step, anyone with the password can sign straight back in. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-change-or-reset-my-password\/\">password help article<\/a> covers the portal flow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Rotate the API key.<\/strong> Pause your senders, rotate, reload the new key, resume. Authenticate the rotation with <code>userid<\/code> and <code>password<\/code> (the new password), not with the key you&#8217;re replacing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Read back everything an intruder could have changed to stay in.<\/strong> Notification settings first, because they decide whether you&#8217;ll hear about the next login. Then webhooks, sender IDs and templates. Diff each against your known-good state and restore anything that moved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Read the device list again.<\/strong> Every session you ended should be gone. If one is still there, or a new one has appeared since step two, the password or key change didn&#8217;t take, or someone still holds a working credential. Go back to step three.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Work out what was done.<\/strong> Filter the frozen activity export to the incident window. Look for sends you don&#8217;t recognise, credit purchases, contact exports and configuration changes. Then compare platform delivery reports with your own records: anything in the reports that isn&#8217;t in your <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a> was sent by someone else.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Keep watching.<\/strong> Tighten the session poller to every minute for a few days and treat any new <code>API<\/code> session or new UI fingerprint as high severity until you&#8217;re confident the account is clean.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The order matters more than any single step. Logging out before changing the password just invites the intruder to sign in again. Rotating the key before reading back the webhook leaves your delivery reports flowing to their server under a fresh key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Question you need answered<\/th><th>Surface<\/th><th>Call<\/th><th>Watch out for<\/th><\/tr><\/thead><tbody><tr><td>Who&#8217;s signed in right now?<\/td><td>Connected devices<\/td><td><code>security\/connectedDevices<\/code><\/td><td>Drop <code>sessionToken<\/code>; failed read is not &#8220;zero sessions&#8221;<\/td><\/tr><tr><td>Who was signed in last week?<\/td><td>Your snapshots plus the activity log<\/td><td>Your own store<\/td><td>The live list has no history<\/td><\/tr><tr><td>Did anyone create or change an API key?<\/td><td>Activity log<\/td><td><code>activity\/read<\/code>, <code>credential.apikey<\/code> rows<\/td><td>Wording can change; watch <code>unclassified<\/code><\/td><\/tr><tr><td>Did anyone change webhooks or alerts?<\/td><td>Activity log, then the config itself<\/td><td><code>activity\/read<\/code>, then read-back<\/td><td>Alert on the change and on the drift<\/td><\/tr><tr><td>Is a login from an expected machine?<\/td><td>Connected devices<\/td><td>Check <code>loginMethod<\/code> and <code>ipAddress<\/code><\/td><td>Private IPs may mean a proxy<\/td><\/tr><tr><td>End one suspicious session<\/td><td>Connected devices<\/td><td><code>security\/logoutDevice<\/code> with <code>sessionId<\/code><\/td><td>450 means already gone; confirm with a re-read<\/td><\/tr><tr><td>End everything except me<\/td><td>Connected devices<\/td><td><code>security\/logoutAllOtherDevices<\/code><\/td><td>From a person&#8217;s browser only, never a job<\/td><\/tr><tr><td>Make sure login alerts still work<\/td><td>Notification settings<\/td><td><code>notifications\/read<\/code>, diff, <code>update<\/code><\/td><td>Map read paths to update names explicitly<\/td><\/tr><tr><td>Keep a record we can defend<\/td><td>Your hash-chained copy<\/td><td>Daily tail-hash export<\/td><td>Chain follows insertion order<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">Implementation Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Every call uses the <code>apikey<\/code> header with <code>userid<\/code>, except key rotation, which uses <code>userid<\/code> and <code>password<\/code>.<\/li>\n\n\n\n<li>Activity sync walks from offset 0, advances by <code>len(logs)<\/code>, stops at the first stored <code>activityId<\/code>, and de-duplicates.<\/li>\n\n\n\n<li>Primary key on <code>(account, activity_id)<\/code>; one sync per login for sub-users and child accounts.<\/li>\n\n\n\n<li><code>activity_raw<\/code> and <code>timestamp_raw<\/code> stored exactly as received; <code>occurred_at<\/code> normalised to UTC.<\/li>\n\n\n\n<li>Hash chain over insertion order, verified daily, tail hash exported somewhere separate.<\/li>\n\n\n\n<li>Writer account can only insert; raw table access restricted; masked view for everyone else.<\/li>\n\n\n\n<li>Classifier falls through to <code>unclassified<\/code>; daily count of <code>unclassified<\/code> with an alert on a jump.<\/li>\n\n\n\n<li>Session parser keeps an allowlist of fields; <code>sessionToken<\/code> never stored or logged.<\/li>\n\n\n\n<li>A failed <code>connectedDevices<\/code> read skips the diff instead of reporting every session as ended.<\/li>\n\n\n\n<li><code>first_seen_at<\/code> recorded from your own clock; epoch values sanity-checked.<\/li>\n\n\n\n<li>High alert on any <code>API<\/code> session outside your egress IP list; owner prompt on new UI fingerprints.<\/li>\n\n\n\n<li><code>logoutAllOtherDevices<\/code> reachable only from a person&#8217;s browser or a deliberate break-glass script.<\/li>\n\n\n\n<li>Every logout confirmed by re-reading the device list; 450 and 455 treated as done.<\/li>\n\n\n\n<li>Desired notification settings in version control; daily drift check plus a check after any <code>config.notifications<\/code> row.<\/li>\n\n\n\n<li>Notification updates send only changed parameters and are always read back.<\/li>\n\n\n\n<li>Incident runbook written down, in order, with password and key rotation in it.<\/li>\n\n\n\n<li>Written retention period for your activity copy, deleting from the old end only.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some of how these endpoints behave isn&#8217;t pinned down by anything you can read. Each item below gives you the choice that stays safe whichever way the real behaviour goes, and none of them needs you to wait for an answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Rely on the newest-first order, not on <code>activityId<\/code> arithmetic.<\/strong> You can&#8217;t be sure IDs are sequential, gap-free or even increasing. The sync stops at the first stored row in a reverse-chronological list, which works whether IDs climb by one, jump around, or come from a shared counter across accounts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Assume the platform&#8217;s activity log has a retention limit, and copy everything from day one.<\/strong> How long rows are kept isn&#8217;t something you can plan around. If you start syncing on the day you need the history, the history may already be gone. A five-minute poller from the first day turns &#8220;we can&#8217;t know&#8221; into a query.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Key every row on <code>(account, activity_id)<\/code>.<\/strong> Whether <code>activityId<\/code> is unique per account or globally makes no difference to a composite key. A single-column key only works if it&#8217;s global, and you&#8217;d find out it wasn&#8217;t by losing a row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treat the <code>activity<\/code> text as unversioned prose.<\/strong> Wording can change without notice. Classify with rules that fall through to <code>unclassified<\/code>, count that bucket daily, and alert when it jumps. You&#8217;ll notice a rephrasing within a day instead of discovering months later that an alert went quiet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Don&#8217;t depend on failed logins appearing in the log.<\/strong> They may be recorded, recorded under different wording, or not recorded at all. Keep a rule for them, but get your primary login detection from the device list diff and the <code>loginAlerts<\/code> emails, which don&#8217;t depend on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Drop <code>sessionToken<\/code> at parse time, every time.<\/strong> Whether that token can authenticate anything beyond <code>logoutDevice<\/code> is unclear. You don&#8217;t need to know: a field you never store can&#8217;t leak, and <code>sessionId<\/code> does everything you need.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Record your own <code>first_seen_at<\/code> for every session and treat platform session times as hints.<\/strong> <code>loginTime<\/code> has no zone and the epoch can disagree with it. Your own UTC clock reading at first sight is always comparable with the rest of your data and is accurate to one polling interval.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Never call <code>logoutAllOtherDevices<\/code> from automation.<\/strong> Which session counts as &#8220;current&#8221; for a call authenticated with the <code>apikey<\/code> header is unclear. If it&#8217;s an API session, every human gets logged out. If it&#8217;s none, possibly every session goes. Either way, logging out specific <code>sessionId<\/code> values from automation does what you meant and nothing more.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Confirm every logout by re-reading the device list.<\/strong> A success reply may arrive while the session lingers briefly, and a 450 may arrive for a session that&#8217;s already gone. The re-read is the only answer that doesn&#8217;t depend on which happened.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Rotate the password and API key in every incident, even after logging sessions out.<\/strong> Whether ending a session also invalidates anything else (OAuth tokens, mobile app sessions, a session that the API key creates) can&#8217;t be confirmed from outside. Rotating both credentials closes the door whatever the answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Map only the notification paths you&#8217;ve seen, and fail closed on the rest.<\/strong> WhatsApp, voice and reseller read shapes may nest differently from the SMS ones. Unknown paths go to an <code>unmapped<\/code> list for a person to review; they&#8217;re never auto-corrected from a guess.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Disable an alert with its flag, never with an empty email list, and read back after every update.<\/strong> Whether <code>loginAlertEmails=<\/code> clears the list or is ignored, and whether parameters for products you don&#8217;t have are dropped silently, are both settled by the read-back. Sending the flag as <code>0<\/code> gets the result you want either way.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I get activity log events pushed to me instead of polling?<\/strong><br>Not directly. <code>SMSApi\/activity\/read<\/code> is a pull endpoint. Poll it every few minutes with the incremental sync above; on a quiet account each poll is a single call that stops at the first row you already have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many activity rows can I fetch per call?<\/strong><br>Up to 200. <code>limit<\/code> accepts 1 to 200 and defaults to 50. For a backfill use 200, and advance <code>offset<\/code> by the number of rows you actually received.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why do I see the same activity row on two pages?<\/strong><br>Because new events arrived while you were paging. The list is newest first, so every new event pushes older rows one position further back, and the next page starts with rows you&#8217;ve already read. De-duplicate on <code>activityId<\/code> and it stops mattering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I filter the activity log by date or type?<\/strong><br>No, the call takes only <code>limit<\/code>, <code>offset<\/code> and <code>output<\/code>. Copy the log into your own store and filter there. That&#8217;s also the only way to keep history beyond whatever the platform retains.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the activity log show what my sub-users did?<\/strong><br>It shows the authenticated user&#8217;s own activities. Sync each login separately with its own credentials and keep a separate high-water mark for each.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is it safe to store the <code>sessionToken<\/code> from the connected devices list?<\/strong><br>No. Treat it as a live credential and drop it at parse time. Use <code>sessionId<\/code> for diffs and for <code>logoutDevice<\/code>; it does the same job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does logging out all other devices stop someone who has my API key?<\/strong><br>No. API requests authenticate on every call and don&#8217;t depend on a portal session. Rotate the key, and change the password too, because the password can issue a new session or a new key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why did <code>logoutAllOtherDevices<\/code> sign out my whole team?<\/strong><br>Because &#8220;other&#8221; means every session except the caller&#8217;s. If the call came from a server, the caller isn&#8217;t anyone&#8217;s browser. Use it only from a trusted browser, and use <code>logoutDevice<\/code> with specific <code>sessionId<\/code> values from automation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does error 455 mean when logging out other devices?<\/strong><br>There were no other active sessions to end. Treat it as success, then re-read the device list to confirm.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I make sure login alerts can&#8217;t be quietly switched off?<\/strong><br>Keep the desired <code>enableLoginAlert<\/code> and <code>loginAlertEmails<\/code> values in version control, read <code>SMSApi\/notifications\/read<\/code> daily and after any notification change in the activity log, and alert on any difference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my notification update return 478?<\/strong><br>478 means no changes were made or there was nothing to update. Usually the value you sent already matched. Read the settings back; if they match your desired state, you&#8217;re done.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Are the session location and IP reliable enough to block on?<\/strong><br>Not on their own. <code>location<\/code> is IP geolocation and <code>ipAddress<\/code> is what the platform saw, which a proxy or VPN can change. Hard-block only on <code>API<\/code> sessions from outside your known egress IPs, and treat everything else as a prompt to check.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Catch Account Takeovers Before They Cost You<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Running messaging at a scale where an account takeover would hurt? Open an account on the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">SMSGatewayCenter demo<\/a>, point the activity sync and session poller at it, and have your audit trail running before your first production send. If you&#8217;d like a hand wiring it into your SIEM, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch with the team<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-preference-centre-design-purposes-channels\/\">Building an SMS and WhatsApp Preference Centre: Purposes, Channels, Frequency and the Send Gate<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-key-rotation-sms-api-without-dropping-messages\/\">API Key Rotation for an SMS API: Rotating a Single-Slot Key Without Dropping Messages<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-message-encryption-aes-wire-format-key-handling\/\">SMS Message Encryption: What It Hides, What It Doesn\u2019t, and How to Get the Wire Format Right<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">SMS Click Tracking and Attribution: Joining Smart Link Clicks to Messages and Recipients<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram: Building an Opt-In Record You Can Prove<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>Your messaging account already records logins, key changes, webhook edits and credit purchases, and it lists every live session. Here&#8217;s how to copy that activity log without missing or doubling rows, watch sessions for new devices, keep login alerts from being quietly switched off, and run an incident response that actually closes the door.<\/p>\n","protected":false},"author":118,"featured_media":3103,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-3107","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-sms-gateway-news"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3107","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=3107"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3107\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3103"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3107"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3107"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3107"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}