API & Ademco Contact ID Format

This page documents the JSON/plaintext APIs under /api/v2, the Ademco/SIA DC-05 "Contact ID" event format used by the reporting endpoint, and how to send an event from a Roblox alarm panel.

Login, registration, password changes, API key management, and admin user-management endpoints aren't covered here since they're only meant to be used by the website itself - see the Account page.

Authentication

Most endpoints below accept either of the following, checked in this order:

  1. A login session - the cookie your browser already holds after logging in at /login.
  2. An API key - a per-user token you can create on the Account page, useful for scripts/dashboards that can't hold a browser session. Send it as either:
    • Authorization: Bearer <key> header (preferred), or
    • X-Api-Key: <key> header, or
    • ?api_key=<key> query parameter - only meant for the live-events websocket below, since plain WebSocket clients often can't set custom headers. Avoid it for regular requests since query strings tend to end up in access logs.

API keys carry the same rank/permissions as the user that created them and can optionally expire (set at creation time). Revoking a key takes effect immediately.

The reporting endpoint (panels/dialers posting alarm events) is a separate, unauthenticated-by-user model: the 4-digit ACCT account number embedded in the Contact ID message itself identifies which panel the event belongs to.

Reporting API

POST /api/v2/reporting
Content-Type: text/plain

<raw Contact ID message>

The body is a raw Contact ID digit string (see formats below) - no JSON, no auth headers. Optional Roblox-specific headers may be sent alongside it:

Header Sent by Meaning
Roblox-Place-Id Roblox automatically (servers) Place the reporting game server is running
Roblox-Job-Id Your dialer script, if non-empty Server instance id (game.JobId)

Responses:

Status Body Meaning
200 {"success": true} Event accepted and stored (delayed 1-5s to simulate real dialer comm timing)
400 {"error": "Invalid Contact ID message: ..."} Body isn't 13/15/16 digits
400 {"error": "Invalid checksum"} 16-digit form with a bad checksum digit
404 {"error": "Unknown account"} No panel is registered with that ACCT

If the panel has a Discord webhook configured, a notification is fired (fire-and-forget) right after the event is stored. Any panel owner/admin currently viewing that panel's page also receives it instantly over the live-events websocket (below).

Field reference

Field Digits Meaning
ACCT 4 Account number identifying the reporting panel/system
MT 2 Message Type — always 18 (Contact ID) or 98 (extended CID)
Q 1 Qualifier — 1 new event/open, 3 new restore/close, 6 previous event (status)
XYZ 3 Event code (see the full event code table)
GG 2 Group/partition number
CCC 3 Zone number (alarm/trouble events) or user number (open/close events)
S 1 Checksum digit

The reporting endpoint accepts three digit-string lengths: 13, 15, and 16 digits. Each is explained below.

16-digit format — full spec, with checksum

ACCT MT Q XYZ GG CCC S

This is the complete message exactly as defined by SIA DC-05, as it would be transmitted over a phone line via DTMF. It includes the message type and a trailing checksum digit.

Checksum rule: sum every digit in the message (treating a 0 as 10), including the checksum digit itself. The total must be a multiple of 15.

local function checksumDigit(payload) -- 15-digit ACCT+MT+Q+XYZ+GG+CCC string
	local sum = 0
	for digit in payload:gmatch("%d") do
		local d = tonumber(digit)
		sum = sum + (d == 0 and 10 or d)
	end
	local needed = 15 - (sum % 15) -- how much the checksum digit must contribute
	if needed < 1 or needed > 10 then
		return nil -- not every payload has a valid single-digit checksum
	end
	return needed == 10 and "0" or tostring(needed)
end

checksumDigit("123418340101005") -- "2"

Not every 15-digit payload has a solvable checksum digit this way (a single decimal digit can only contribute a value of 1-10, but some payloads need a contribution of 0 or 11-14) - this is why the checksum-free 15-digit form below exists.

Example — account 1234, restore (3) of event 401 (open/close), partition 01, user 005, checksum 2:

1234 18 3 401 01 005 2
"1234183401010052"  <- all 16 digits, including the trailing checksum "2"

15-digit format — full spec, no checksum

ACCT MT Q XYZ GG CCC

Identical to the 16-digit format but without the trailing checksum digit. Many receivers/APIs strip the checksum once the transmission has already been validated at the transport layer, since it adds no information for downstream consumers. This is the form Robloxia Central Station's own Vista panel simulator dialer sends, since a checksum digit can't always be made mathematically valid for every possible event and adds no integrity value over HTTPS anyway.

Example:

1234 18 3 401 01 005
"123418340101005"

13-digit format — simplified, no MT, no checksum

ACCT Q XYZ GG CCC

MT is always 18 for standard Contact ID, so some receivers/APIs drop it entirely (along with the checksum) once they've already confirmed the message is Contact ID data. The reporting endpoint treats this as shorthand and fills in a message type of 18 automatically.

Example — same event as above, without MT or checksum:

1234 3 401 01 005
"1234340101005"

The full list of event codes, descriptions, and display colors used by the dashboard is in ademco-contact-id-events.json.

Sending an event from Roblox (Luau)

A minimal dialer that reports a burglary alarm (event 130) on zone 5, partition 1, using the 13-digit form:

local HttpService = game:GetService("HttpService")

local REPORTING_URL = "https://rcs.pyramus.dev/api/v2/reporting"
local ACCOUNT_NUMBER = "1234" -- your panel's 4-digit account number

local function sendContactId(qualifier, eventCode, partition, zoneOrUser)
	local message = string.format(
		"%s%s%03d%02d%03d",
		ACCOUNT_NUMBER, qualifier, eventCode, partition, zoneOrUser
	)

	local headers = { ["Content-Type"] = "text/plain" }
	if game.JobId ~= "" then
		headers["Roblox-Job-Id"] = game.JobId
	end

	local ok, response = pcall(function()
		return HttpService:PostAsync(REPORTING_URL, message, Enum.HttpContentType.TextPlain, false, headers)
	end)

	if ok then
		print("[Dialer] Reported event " .. eventCode .. ": " .. response)
	else
		warn("[Dialer] Failed to report event: " .. tostring(response))
	end
end

-- New (qualifier "1") burglary alarm (event 130), partition 1, zone 5
sendContactId("1", 130, 1, 5)

HttpService must have HttpEnabled turned on (Game Settings -> Security, or game:GetService("HttpService").HttpEnabled = true from a plugin/Studio command bar) for this to work.

Other APIs

All of these accept a login session or an API key (see Authentication, above), except GET /api/v2/stats which is public.

Stats

GET /api/v2/stats

Returns {"panels": n, "events": n, "users": n} - no auth required.

Panels

GET    /api/v2/panels                 - list your panels
POST   /api/v2/panels                 - create a panel: {name, discord_webhook_url?}
GET    /api/v2/panels/:id             - get one panel you own
PATCH  /api/v2/panels/:id             - update a panel: {name?, discord_webhook_url?}
DELETE /api/v2/panels/:id             - delete a panel and its events
GET    /api/v2/panels/:id/events      - paginated event history: ?limit=50&offset=0

Example, listing your panels with an API key:

curl -H "Authorization: Bearer rcs_xxxxxxxxxxxxxxxxxxxx" \
     https://rcs.pyramus.dev/api/v2/panels

Live events (WebSocket)

GET /api/v2/panels/:id/events/live   (Upgrade: websocket)

Streams a JSON message for every new event reported to that panel, in the same shape as an entry from GET /api/v2/panels/:id/events: {id, timestamp, event, placeId, jobId}.

A browser dashboard already logged in reuses its session cookie automatically. A standalone script (no cookies) has to pass its API key on the query string instead, since most WebSocket clients can't set custom headers on the upgrade request:

wss://rcs.pyramus.dev/api/v2/panels/12/events/live?api_key=rcs_xxxxxxxxxxxxxxxxxxxx