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.
Most endpoints below accept either of the following, checked in this order:
Authorization: Bearer <key> header (preferred), orX-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.
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 | 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.
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"
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"
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.
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.
All of these accept a login session or an API key (see Authentication,
above), except GET /api/v2/stats which is public.
GET /api/v2/stats
Returns {"panels": n, "events": n, "users": n} - no auth required.
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
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