Hikvision ISAPI Access Control Events: TypeScript Example
Receive Hikvision access-control events over ISAPI: HTTP push plus an AcsEvent sweep that fills what push misses, into one idempotent table. TypeScript.
A Hikvision terminal gives you access events two ways. It can push each pass to your server over HTTP, or it can hand over its event log when you call POST /ISAPI/AccessControl/AcsEvent?format=json.
It's tempting to pick one. Push on its own can miss passes during a network blip or a device reboot, though, and a sweep that only polls sees each pass up to one poll interval later than push would. So our team runs both.
Devices push each pass to one authenticated endpoint, and a reconcile job re-reads each enabled device's event log on a schedule. Both paths land in the same pass_events table, idempotently. A duplicate is dropped on insert, and a pass that push lost gets filled by a later sweep, with the nightly 26-hour pass as the backstop.
The TypeScript below comes from the Hikvision adapter and reconcile worker we ship. Near the end is the one field that makes a terminal accept your push target and then post nothing.
Two paths into one table
We start with the sweep, the half that catches up on passes push missed. The reconcile sweep drains every page the device returns and hands only access events to process, counting what it fetched and what was new. It keeps the cursor of the last event it saw, of any kind.
export async function reconcileDeviceEvents(
pages: AsyncIterable<DeviceEvent[]>,
process: (e: DeviceEvent) => Promise<string>,
): Promise<{ fetched: number; inserted: number; lastCursor: string | null }> {
let fetched = 0
let inserted = 0
let lastCursor: string | null = null
for await (const page of pages) {
for (const event of page) {
fetched++
lastCursor = event.cursor
if (event.kind !== "access") continue
// …
if ((await process(event)) === "inserted") inserted++
}
}
return { fetched, inserted, lastCursor }
}
The worker writes with .onConflictDoNothing() and names no conflict target, so it leans on whatever unique index the table has. Since the TimescaleDB migration, that index is (deviceId, eventCursor, eventTime). Before it, (deviceId, eventCursor).
That's why re-scanning a window you've already seen is safe: the second insert of the same triple is dropped silently.
Paging AcsEvent when the device rejects beginSerialNo
Normally you'd page an event log by asking the device for everything after a serial number you already hold. That doesn't work everywhere. On a DS-K1T343MX running firmware V4.39.80, putting beginSerialNo in the AcsEventCond body comes back as HTTP 400 badJsonContent.
Our pager starts from the beginning instead. It keeps one searchID for the whole scan and asks for 30 results at a time, then filters serialNo > fromSerialNo on the client.
async *searchAcsEvents(
fromSerialNo: number,
opts: { startTime?: string; endTime?: string } = {},
): AsyncGenerator<AcsEventInfo[]> {
const searchID = crypto.randomUUID()
let position = 0
for (;;) {
const AcsEventCond: Record<string, unknown> = {
searchID,
searchResultPosition: position,
maxResults: 30,
major: 0,
minor: 0,
}
if (opts.startTime !== undefined) AcsEventCond.startTime = opts.startTime
if (opts.endTime !== undefined) AcsEventCond.endTime = opts.endTime
const res = await this.req("/ISAPI/AccessControl/AcsEvent?format=json", {
method: "POST",
body: JSON.stringify({ AcsEventCond }),
})
if (!res.ok) throw new Error(`AcsEvent HTTP ${res.status}`)
const data = (await res.json()) as {
AcsEvent: { responseStatusStrg: string; numOfMatches?: number; InfoList?: AcsEventInfo[] }
}
const { responseStatusStrg, numOfMatches = 0, InfoList = [] } = data.AcsEvent
const filtered = InfoList.filter((e) => e.serialNo > fromSerialNo)
if (filtered.length > 0) yield filtered
// …
}
}
startTime and endTime narrow the scan on the device side, and the reconcile sweep uses them to bound how far back it reads.
When should the paging loop stop?
Loop on "MORE" and on nothing else. The device signals further pages through responseStatusStrg, and the token for "no more" is spelled differently across firmwares, so matching the positive value is safer than matching the empty one. A second guard sits underneath. A device that reports MORE with numOfMatches zero or missing would never advance the position, and the next request would ask for the exact same page forever.
// Loop strictly on MORE - the "empty" token is written differently across firmwares
if (responseStatusStrg !== "MORE") return
// Stall guard: a device that reports MORE with numOfMatches 0/missing
// never advances `position`, so the next request would ask for the
// exact same page forever. Treat it as end-of-results instead of
// looping infinitely.
if (numOfMatches === 0) return
// Advance by the unfiltered count - it reflects the server's actual page size.
position += numOfMatches
The device's user list (UserInfo/Search) and face library (FDSearch) page through the same three-line guard. FDSearch has one wrinkle, though: some firmwares wrap its fields in an FDSearchResult object instead of returning them at the top level. We haven't confirmed that shape on our hardware, so the code unwraps it tolerantly and never depends on it.
Send the window in device-local time
Hikvision terminals speak local wall-clock time. The AcsEventCond window wants ISO timestamps with an explicit offset, like 2026-07-22T00:00:00+05:00, and the helper below builds exactly that string from a Date and an IANA zone. It takes the offset at that instant, so a device configured for a daylight-saving zone stays in step across a transition.
export function toDeviceLocalIso(d: Date, zone: Zone): string {
const offsetMin = offsetMinutesAt(zone, d)
const local = new Date(d.getTime() + offsetMin * 60_000)
const sign = offsetMin >= 0 ? "+" : "-"
const abs = Math.abs(offsetMin)
const offset = `${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
return (
`${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}` +
`T${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}:${pad(local.getUTCSeconds())}${sign}${offset}`
)
}
User validity is a different format with no offset in it, so keep it apart from event windows. The fromDeviceLocal() reader parses device-local YYYY-MM-DDThh:mm:ss validity strings, written with timeType: "local", back into UTC. The default validity window ends 2037-12-31T23:59:59, because the DS-K1T343MX rejects years past 2037.
How wide each sweep looks back
Every enabled device gets a regular sweep every 15 minutes, plus one nightly full pass at 00:40 in the attendance time zone (ATTENDANCE_TZ). No sweep reads the whole log. Each one reads a bounded window, and which window it gets depends on the kind of run and on what's already stored for that device.
A regular sweep anchors on the newest eventTime for the device, minus a 30-minute safety margin; a device with no stored passes seeds from a week back. The nightly pass ignores the stored cursor and re-scans 26 hours from the beginning.
const SAFETY_MARGIN_MS = 30 * 60_000
// If the device has no pass_events yet, seed the window from a week back
// rather than scanning its entire history.
const NO_HISTORY_LOOKBACK_MS = 7 * 24 * 60 * 60_000
// Nightly full pass: re-scan the last 26h (a bit over one day) regardless of
// the stored cursor, from the beginning (fromCursor: null).
const FULL_DAY_LOOKBACK_MS = 26 * 60 * 60_000
The worker picks between the three like this:
const fromCursor = data.fullDay ? null : row.lastEventCursor
const sinceMs = data.fullDay
? now.getTime() - FULL_DAY_LOOKBACK_MS
: latest
? latest.eventTime.getTime() - SAFETY_MARGIN_MS
: now.getTime() - NO_HISTORY_LOOKBACK_MS
A trailing door alarm still advances the stored cursor, since the cursor is the last event of any kind the sweep saw.
Parsing what the terminal pushes
One parser handles three body shapes: multipart with the JSON in a part (usually named event_log, with any picture parts skipped), an XML EventNotificationAlert, or plain JSON. Then it sorts. A body with no AccessControllerEvent is treated as a heartbeat. An access pass needs a majorEventType of 5 and a non-empty employeeNoString, and a body that fails any check comes back as "other".
export function parseNotification(
contentType: string | null,
rawBody: Uint8Array | string,
): ParsedNotification {
const raw = typeof rawBody === "string" ? rawBody : new TextDecoder("utf-8", { fatal: false }).decode(rawBody)
const obj = extractJson(contentType, raw)
if (!obj) return { kind: "other", raw: null }
const acs = obj.AccessControllerEvent as Record<string, unknown> | undefined
if (!acs) return { kind: "heartbeat" }
if (obj.eventType !== "AccessControllerEvent") return { kind: "other", raw: obj }
const major = Number(acs.majorEventType ?? 0)
const employeeNo = typeof acs.employeeNoString === "string" && acs.employeeNoString !== ""
? acs.employeeNoString : null
if (major !== 5 || !employeeNo) return { kind: "other", raw: obj }
// …
return { kind: "access", event, raw: obj }
}
A push event and a swept event come out of two readers but normalize to the same contract shape, DeviceEvent.
Is push actually arriving?
With addressingFormatType set to ipaddress, the terminal composes its push target from <ipAddress>, <portNo> and a <url> it treats as only the path to append. Put a full address in that last field, a value like http://10.0.0.5:80/api/v1/turnstile/event, and nothing complains. The write answers statusCode 1, and reading the slot back shows exactly what you wrote. Then the terminal posts nothing at all.
There's no error anywhere to notice. If a reconcile sweep is running, passes keep reaching your table, only through the sweep.
On a DS-K1T343MX running V4.39.80, our team rewrote <url> to the bare path, and within seconds the device started connecting. So the adapter normalizes <url> to a bare path before every write.
export function toNotificationPath(url: string): string {
const trimmed = url.trim()
if (trimmed === "") return "/"
// A full URL: keep path + query, drop scheme/host/port.
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed)
const path = `${parsed.pathname}${parsed.search}`
return path === "" ? "/" : path
} catch {
// Unparseable but scheme-shaped — fall through to the path handling
// below rather than handing the device something worse.
}
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`
}
The query string stays, since the device appends it verbatim. Four test cases pin the behaviour down:
describe("toNotificationPath", () => {
it("strips scheme, host and port from a full URL", () => {
expect(toNotificationPath("http://10.0.0.5:80/api/v1/turnstile/event")).toBe("/api/v1/turnstile/event")
expect(toNotificationPath("https://access.example.com/api/v1/turnstile/event")).toBe("/api/v1/turnstile/event")
})
it("keeps the query string, which the device appends verbatim", () => {
expect(toNotificationPath("http://10.0.0.5:8080/ingest?src=gate")).toBe("/ingest?src=gate")
})
it("leaves a bare path alone and adds the leading slash", () => {
expect(toNotificationPath("/api/v1/turnstile/event")).toBe("/api/v1/turnstile/event")
expect(toNotificationPath("api/v1/turnstile/event")).toBe("/api/v1/turnstile/event")
})
it("never returns an empty path", () => {
expect(toNotificationPath("")).toBe("/")
expect(toNotificationPath(" ")).toBe("/")
expect(toNotificationPath("http://10.0.0.5")).toBe("/")
})
})
You might expect the device's own self-test to help here. On the DS-K1T343MX V4.39.80 the probe never actually runs: POST /ISAPI/Event/notification/httpHosts/{id}/test answers HTTP 400 to a bodyless request, and methodNotAllowed when the host definition is sent in the body.
Reporting that as a failed connection would accuse a healthy link. The adapter maps it to a separate verdict, "unsupported":
async testHttpHost(id: number): Promise<PushTestResult> {
const res = await this.req(`/ISAPI/Event/notification/httpHosts/${id}/test`, { method: "POST" })
if (res.ok) return "ok"
if (res.status === 400 || res.status === 405 || res.status === 501) return "unsupported"
return "failed"
}
Every ISAPI request goes through digest auth
Each ISAPI call authenticates with digest, MD5 and qop=auth, which is what these terminals speak. The awkward case is a stale nonce. A device can reject an authorized attempt built from a nonce it just issued, at a clock or window edge, so the client retries the whole exchange exactly once with a fresh challenge.
Device certificates are self-signed, so the fetch turns certificate verification off.
export async function digestFetch(url: string, opts: DigestOpts): Promise<Response> {
const method = opts.method ?? "GET"
const init: RequestInit = {
method,
body: opts.body,
headers: opts.headers,
// Self-signed device certificates
tls: { rejectUnauthorized: false },
} as RequestInit
const res = await performDigestExchange(url, opts, method, init)
if (res.status !== 401) return res
// Stale nonce: the server can reject an authorized attempt built from a
// nonce it just issued (clock/window edge, concurrent request, etc).
// Retry the WHOLE exchange exactly once — fresh challenge, fresh nonce,
// recomputed response — never more than one retry.
return performDigestExchange(url, opts, method, init)
}
Where this code comes from
The code above comes from the Hikvision ISAPI adapter and the reconcile worker in ShipKit Access, a one-time $249 licence with full source. Hikvision over ISAPI is the only vendor that ships today. A second vendor is an adapter you write, or ask us to.
The device client on its own (digest auth, push setup, AcsEvent paging, users and faces, push parsing) is MIT-licensed on GitHub as hikvision-isapi-ts, without the attendance model.
ShipKit Access doesn't recognise faces; that happens in the terminal. The platform stores an enrolment photo, pushes it to the devices you configure, and records the events they report. It isn't certified for anything either, so fire safety, labour law and data protection in your jurisdiction stay yours.
It also ships a simulated terminal. One command (bun run demo) seeds the schema and three weeks of history and starts a live generator walking simulated staff through simulated doors on the real clock. You can read the whole attendance model against data you can see before deciding whether it fits how your organisation counts a working day. The full breakdown of what's in it lives on the ShipKit Access page.