Skip to content

Sync feeds

Pagination by page number is fine for a person browsing, but it can skip or repeat rows when data changes underneath a loop. For keeping another system current, CardOffice offers keyset cursors: “everything that changed since X”.

The pattern is the same everywhere:

  1. Start with a timestamp, such as 1970-01-01T00:00:00Z.
  2. Request the feed. Process the rows it returns, oldest first.
  3. Only after you have applied the batch, store meta.nextSince and meta.nextSinceId as your new position.
  4. Repeat while meta.hasMore is true.

If you crash between steps 2 and 3, you simply repeat work. You never skip it. Treat the timestamp as an opaque string and send back exactly what you were given. Re-formatting it can lose sub-millisecond precision and make boundary rows replay forever.

GET/cards?since={timestamp}scope: cards:read
Parameter Description
since ISO 8601 timestamp, required to select this form
sinceId Tie-breaker returned by the previous call. Optional on the first call
limit 1 to 500, default 200

Rows are ordered by last change, so a revoked card reappears. Each row has id, cardHolderId, externalId, externalUuid, serialNumber, status, format, issueNumber, issuedAt, expiresAt, revokedAt, revocationReason and updatedAt. Cards that were themselves mirrored from the external system are excluded, so you never receive your own data back.

{
"data": [ { "id": "…", "serialNumber": "AC-000123", "issueNumber": 2, "status": "active" } ],
"meta": {
"nextSince": "2027-02-03 01:00:00.123456+00",
"nextSinceId": "c2a4f0f6-1f1b-4b0a-9d7c-0d1b6e5e4a21",
"hasMore": false
}
}
GET/photos/changed?since={timestamp}scope: photos:read

Every approved, current photo whose approval happened after your cursor. limit is 1 to 200, default 50. Each row carries photoId, cardHolderId, externalId, externalUuid, changedAt and a short-lived downloadUrl. Only holders that carry an externalUuid are included.

PUT/card-holders/by-external-uuid/{uuid}scope: card_holders:write

An idempotent create-or-update keyed on your system’s row UUID. Use this when your own identifiers are not reliably unique: the UUID is the sync key, so two people sharing an externalId cannot silently merge into one holder. The second arrives, collides on the unique externalId, and comes back as 409 for a person to resolve. The body is a card holder with an optional issuedAt timestamp, which records when your system issued the person’s current card.

POST/reconcile/holdersscope: cards:read

A read-only drift check. Send up to 500 UUIDs:

{ "externalUuids": ["6f1b0c3e-…", "a02d9f71-…"] }

For each UUID the answer says whether CardOffice holds that person and, if so, their externalId, holderStatus and activeCard (issueNumber, status, issuedAt). It changes nothing: reconciliation that repairs is just a second writer with a delay.

Where a door system owns credential numbers, CardOffice never invents them. Two endpoints carry that exchange:

  • GET/card-holders/pending-door-numbers (scope card_holders:read) lists holders created in CardOffice that the door system does not know yet.
  • POST/card-holders/{id}/door-numbers (scope card_holders:write) records the numbers the door system allocated, or a permanent failure. A repeat for the same UUID returns 200. Pointing a holder at a different UUID is refused with 409, because numbers are never silently re-pointed.

The reliable way to run a two-way sync is to give every field exactly one owner. The system of record for a field sends it; the other end only receives it. Two systems writing one column is not a sync, it is a race.

© 2026 Secure ID Ltd. All rights reserved.Documentation licence and terms