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}
GET    /api/v2/panels/:id             - get one panel you own, or one shared with you
PATCH  /api/v2/panels/:id             - update a panel: {name?}
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

Panel sharing

Panel owners can grant other users read-only access to a panel - a shared user can view the panel and its event history/live feed, but can't edit or delete it, or manage its shares.

GET    /api/v2/panels/:id/shares            - list users a panel is shared with
POST   /api/v2/panels/:id/shares            - share a panel: {username}
DELETE /api/v2/panels/:id/shares/:userId    - revoke a share

Owner only. Sharing a panel doesn't notify the recipient - they'll see it under "Shared With You" on their dashboard and on the cross-panel /events page.

Notification platforms

A panel can forward every reported event to any number of notification platforms (e.g. multiple Discord webhooks). Each platform has a type (discord, webhook), an optional label, and a type-specific config object.

GET    /api/v2/notification-platforms                    - list available platform types + their config fields
GET    /api/v2/panels/:id/notifications                  - list a panel's configured platforms
POST   /api/v2/panels/:id/notifications                  - add one: {platformType, name?, config}
PATCH  /api/v2/panels/:id/notifications/:notificationId  - update: {name?, config?, enabled?}
DELETE /api/v2/panels/:id/notifications/:notificationId  - remove one

Owner only. discord's config is {webhook_url, message_content?}; webhook's config is {url} and receives a raw JSON POST of the event (not a Discord-shaped embed).

Live events (WebSocket, single panel)

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

Cross-panel events

A merged view across every panel you can see - your own panels plus any panels shared with you. Each panel/event is tagged with a relation field: "owned" or "shared".

GET /api/v2/events?limit=100

Returns:

{
  "panels": [
    { "id": 12, "name": "Front Office", "acctNumber": 1234, "ownerUsername": "alice", "relation": "owned" }
  ],
  "events": [
    {
      "panelId": 12, "panelName": "Front Office", "acctNumber": 1234,
      "ownerUsername": "alice", "relation": "owned",
      "timestamp": "2026-09-07 12:00:00", "event": { "...": "same shape as the reporting event" },
      "placeId": "123456", "jobId": "abc-123"
    }
  ]
}

Live events (WebSocket, account-wide)

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

Same idea as the single-panel feed above, but subscribes you to every panel currently visible to your account in one connection (own + shared) - handy for a single dashboard that watches everything at once instead of opening one socket per panel. Each message includes systemId/panelName so the client can tell which panel it came from:

{ "systemId": 12, "panelName": "Front Office", "timestamp": "2026-09-07 12:00:00", "event": { "...": "..." }, "placeId": "123456", "jobId": "abc-123" }

Like the single-panel feed, pass an API key via ?api_key= for standalone scripts. Panels created or shared with you after connecting won't appear until you reconnect.