Cannot Delete Faces from a Terminal: API Returns notSupport
The problem was not that the terminal struggled to recognize faces. If anything, it recognized them too well. The problem was that someone who had resigned two months earlier could still walk through the door, because the face deletion command documented as available returned just one word when sent to the device: notSupport.
I picked up some follow-on work on an employee registry system I had left unfinished. The task was to keep the central registry and the face recognition terminal at the door in sync without anyone having to walk over and use the terminal's controls. I thought it would take half a day: read the API documentation and write a sync loop. But once I started, I got stuck on one operation: "delete." I tried every face deletion command listed in the documentation, and they all returned the same answer: notSupport, even though the device's own capabilities endpoint declared support for it. What finally worked was not deletion, so I decided to write up the whole process, because finding it took two full days.
The IP addresses in this article are examples, but the commands, JSON structures, and response codes are the actual ones I encountered.
Scope: Our own devices, with credentials assigned to us
I need to establish this first. Everything in this article concerns maintaining access control devices under my responsibility, on a LAN I administer, using an administrator account assigned to me with the job. This is asset and personnel registry work, not an attempt to access someone else's device.
Every piece of code in this article uses an administrator account assigned with the job and sends requests only to devices on the private network I administer. Access control devices affect people's access rights. Running scripts like these against devices you do not own is illegal and is not what this article covers.
One rule I always follow is never to embed passwords in script files; I read them only from environment variables. These files eventually get copied to another machine, handed to the next technician, or accidentally committed to git before anyone can remove them.
# บัญชีผู้ดูแลของเครื่องที่เราได้รับมอบมา วางไว้ใน environment ไม่ใช่ในไฟล์
export ACS_HOST=10.10.10.41
export ACS_USER=admin
export ACS_PASS='<PASSWORD>'
python face-sync.py --dry-run
import os, requests
from requests.auth import HTTPDigestAuth
HOST = os.environ["ACS_HOST"]
AUTH = HTTPDigestAuth(os.environ["ACS_USER"], os.environ["ACS_PASS"])
BASE = "http://%s/ISAPI" % HOST # ISAPI ใช้ Digest ไม่ใช่ Basic
Why sync both ways instead of sending updates one way
My first design was the simplest possible approach: make the central registry the source of truth and send commands to the terminal in one direction. Add new employees, delete those who leave, done. It turned out that this did not work, because people also made changes directly on the terminal. A technician installing a terminal at another door would register people on the device itself without going through the central system. With one-way sync, those people would be deleted every night, with nobody knowing where their records had gone.
The central system always overwrites the device
Easy to write and predictable, but anything created directly on the terminal silently disappears on the next run. I would never know what had been on the device because I never read it back.
Read the actual records before deciding
Fetch the device's registry and compare it with the central system on every run, then split it into three groups: users to add, users whose access must be disabled, and users on the device but absent from the central system. Leave the last group alone and report it for a person to decide.
That third group matters more than you might think, because it identifies gaps in the central system, not errors on the device. I had the script write that group to a separate file every night, and in the first month it turned up more than ten people whose records I needed to collect and add to the central system.
Read the device's registry first with UserInfo/Search
The first leg of the cycle is to retrieve every user on the device. The endpoint is POST /ISAPI/AccessControl/UserInfo/Search?format=json. It uses paging, so I have to request one page at a time and send a fixed searchID for that run. Changing searchID midway makes the device start counting again, leaving the loop running forever.
{
"UserInfoSearchCond": {
"searchID": "sync-20261030",
"searchResultPosition": 0,
"maxResults": 30
}
}
def fetch_users(page_size=30):
out, pos = [], 0
while True:
body = {"UserInfoSearchCond": {"searchID": "sync-20261030",
"searchResultPosition": pos,
"maxResults": page_size}}
r = requests.post(BASE + "/AccessControl/UserInfo/Search?format=json",
auth=AUTH, json=body, timeout=10)
r.raise_for_status()
blk = r.json().get("UserInfoSearch", {})
rows = blk.get("UserInfo", []) or []
out.extend(rows)
# responseStatusStrg = MORE แปลว่ายังมีหน้าถัดไป OK แปลว่าหมดแล้ว
if blk.get("responseStatusStrg") != "MORE" or not rows:
return out
pos += len(rows)
I use only a few values from each row: employeeNo as the primary key, name, and the Valid object, which indicates whether access is enabled and the period during which it is valid.
{
"employeeNo": "A1007",
"name": "ตัวอย่าง ทดสอบ",
"userType": "normal",
"Valid": { "enable": true, "beginTime": "2026-01-01T00:00:00",
"endTime": "2030-12-31T23:59:59", "timeType": "local" },
"numOfFace": 1
}
numOfFace is the number of faces linked to that person. I use it to identify who has already enrolled a face, because it is zero for someone who has only a card and no face.
The dead end: Every face deletion command returns notSupport
Once I had the user list, what seemed like the simplest next step was to delete the faces of people who had left. The documentation for this device family says the face library lives under FDLib and supports deletion through FDSearch, so I first tried sending the request directly with curl.
curl -s --digest -u "$ACS_USER:$ACS_PASS" -X PUT \
"http://$ACS_HOST/ISAPI/Intelligent/FDLib/FDSearch/Delete?format=json&FDID=1&faceLibType=blackFD" \
-H 'Content-Type: application/json' \
-d '{"FPID":[{"value":"A1007"}]}'
This was the response, and it was exactly the same regardless of the faceLibType value, whether I changed the method to DELETE, sent the request with or without FDID, or used employeeNo or a PID read from the library itself as FPID.
{
"statusCode": 4,
"statusString": "Invalid Operation",
"subStatusCode": "notSupport",
"errorCode": 1073741830,
"errorMsg": "notSupport"
}
What cost me time was that the device's capabilities endpoint explicitly advertised this feature. Even when I queried it, all the operation names were there.
curl -s --digest -u "$ACS_USER:$ACS_PASS" \
"http://$ACS_HOST/ISAPI/Intelligent/FDLib/capabilities?format=json" | head -30
The capabilities object for this device family is a family specification, not a model specification. Door access terminals use a different firmware branch from video analytics cameras. Their face libraries are tied more tightly to user records, leaving no standalone deletion command available. I spent nearly a day trying requests because I trusted capabilities too much.
I later discovered that the conclusion in this section that "individual face images cannot be deleted" was wrong. The delete button in the device's own web interface can do it, and the command it uses is undocumented. I wrote up how I found that command separately in the article on capturing HAR from the device's web interface. The access disabling method below still works and is faster when the task is simply to prevent entry through the door.
What actually works: Disable access instead of deleting
The workable solution was to stop treating deletion as a requirement and modify the user record instead. The endpoint is PUT /ISAPI/AccessControl/UserInfo/Modify?format=json, with Valid.enable set to false. At the door, the result is that the person's face scan no longer opens it, which was what the task required anyway.
{
"UserInfo": {
"employeeNo": "A1007",
"Valid": { "enable": false }
}
}
What took another half day was discovering that the body really does need to be this short. On my first attempt, I sent the whole Valid object as returned by Search, including beginTime, endTime, and timeType, because I thought sending the original data back would be safest. It was rejected.
{
"statusCode": 6,
"statusString": "Invalid Content",
"subStatusCode": "badJsonContent",
"errorCode": 1073741825,
"errorMsg": "badJsonContent"
}
The field that broke it was beginTime, which was already stored on the device. When I sent it back with the modification request, the device interpreted it as setting a new validity period and applied stricter format validation than when reading it out. So I removed every field I did not intend to change, leaving only employeeNo and the value to update, and it worked immediately.
def disable_user(employee_no):
"""ปิดสิทธิ์แทนการลบ body ต้องสั้นที่สุด มีแค่กุญแจกับฟิลด์ที่จะเปลี่ยน"""
body = {"UserInfo": {"employeeNo": str(employee_no),
"Valid": {"enable": False}}}
r = requests.put(BASE + "/AccessControl/UserInfo/Modify?format=json",
auth=AUTH, json=body, timeout=10)
j = r.json()
if j.get("statusCode") != 1:
raise RuntimeError("%s / %s" % (j.get("subStatusCode"), j.get("errorMsg")))
return True
Being able to read an object does not mean you can send the whole thing back. When updating values, send only the key and the fields you intend to change. Time fields are the most common triggers for badJsonContent that I encounter. This rule also applies to other endpoints in the same family, not just UserInfo.
An unintended benefit is that disabling access preserves a trail, unlike deletion. If I ever need to look back at which doors someone could access and until what date, the records are still intact. Personally, I think disabling is better than deleting anyway in a system that affects people's access rights.
The complete sync cycle and its required rules
Once I had both the read and write operations working, all that remained was to put them together into a complete run. I scheduled it for one in the morning every night. A run takes less than a minute for a registry of a few hundred people.
def sync(hr_active, dry_run=True):
on_device = {u["employeeNo"]: u for u in fetch_users()}
to_disable, unknown, plan = [], [], []
for emp, u in on_device.items():
enabled = u.get("Valid", {}).get("enable", False)
if emp in hr_active:
continue # ยังทำงานอยู่ ไม่แตะ
if emp.startswith("A") and enabled:
to_disable.append(emp) # กองที่ระบบกลางบอกว่าออกแล้ว
elif emp not in hr_active:
unknown.append(emp) # ลงทะเบียนที่หน้าเครื่อง ไม่แตะ
for emp in to_disable:
plan.append(("disable", emp))
if not dry_run:
disable_user(emp)
return plan, unknown
I enforce three rules in this script. First, the script defaults to --dry-run; it only writes changes when explicitly run with --apply. Second, if a run would disable access for more than ten people at once, it stops and only reports, because that indicates a problem with the central registry, not ten people resigning at once. Third, it must not touch records whose identifiers do not begin with the prefix issued by the central system.
# รันจริงต้องพิมพ์ apply เท่านั้น และมีเพดานกันทะเบียนกลางเพี้ยน
python face-sync.py --apply --max-disable 10 | tee /var/log/face-sync/$(date +%F).log
Device logs fill up and cannot be deleted without wiping the device
The next issue I encountered after the system was running was the device's event log. I could read it through POST /ISAPI/AccessControl/AcsEvent?format=json and search past events by time range normally. But when storage was nearly full and I wanted to remove old entries, there was no command to delete a specific range. The only option was a factory reset, which also wiped the entire user registry.
curl -s --digest -u "$ACS_USER:$ACS_PASS" -X POST \
"http://$ACS_HOST/ISAPI/AccessControl/AcsEvent?format=json" \
-H 'Content-Type: application/json' \
-d '{"AcsEventCond":{"searchID":"logdump","searchResultPosition":0,
"maxResults":24,"major":5,"minor":0,
"startTime":"2026-10-01T00:00:00+07:00",
"endTime":"2026-10-30T23:59:59+07:00"}}'
A factory reset is the only command that actually clears the device's event log, but it also deletes all users and faces. If you trigger it without a copy of the registry outside the device, you will have to call everyone back to enroll their faces again, one person at a time. The right approach is to export the events to files on a schedule and let the device overwrite its old entries automatically.
The model I worked with could store about one hundred and fifty thousand events before overwriting old entries. At a door used more than a thousand times a day, that means roughly four months of history, which is shorter than many organizations' internal retention policies require. So I had the same script export the logs as daily JSON files stored outside the device from day one, instead of waiting until they were needed.
The rules I follow for this device family
- Confirm the scope before starting every time: the device is under my administration, and the account is one assigned to me. Keep credentials in the environment, not in a file.
- Do not trust capabilities completely. Test the commands I actually need against that device model with curl before writing a script.
- When updating values, always send the smallest possible body: just the key and the fields I intend to change. Time fields trigger badJsonContent.
- Design for two-way sync. Read the actual records on the device and compare them before deciding. Report records that exist on the device but not in the central system; do not delete them.
- Disable access instead of deleting. It preserves the trail and produces the same result at the door.
- Export event logs for storage outside the device from day one, because the device overwrites old entries and cannot clear only the logs.
- Every script that writes to a device must default to dry-run and have a limit on the number of records changed per run.
Personally, I think the real lesson from this job is not about the brand or which endpoints work. It is that discrepancies between documentation and actual behavior are normal for embedded devices. Anyone writing code to communicate with devices must always test real commands against real hardware first, then design the system to keep working even when the command they expected to use does not work. In this case, if I had insisted that deletion was the only acceptable solution, the job would probably still be unfinished today.
What remains on my plan is to add a third leg: have the device push events back to my system in an event-driven setup instead of waiting for the one-in-the-morning run, and find a way to export enrolled face images for backup outside the device. That way, if I ever really need a factory reset, I will not have to call everyone back to have their pictures taken again. If I have left anything out or made any typos, I will come back and correct them later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first