# Keyspace Members & Credentials — Integration Guide

**Version 1.12.0** · For third-party apps that are the **system of record for
their users** — a condo / residence app, an office or HR app — and want Keyspace
to enrol those people on the building's doors: profile, access-card numbers,
face photos.

This guide is the *narrative* — who owns what, which identity keys to send, and
the journey a member travels from your first `PUT` to offboarding. For
field-by-field detail see the
[full API reference §5](/llms-full.txt#5-provision-members-and-credentials-partner-managed);
for the machine-readable contract see the [OpenAPI spec](/docs/openapi.json).
Platform basics (credentials, token, envelope, webhooks) are on the
[getting-started page](/docs/onboarding.md).

---

## 1. Before you start

- **Credentials** — the same `app_id` / `app_secret` as every other module
  ([getting started §2](/docs/onboarding.md#2-get-your-credentials)). This
  surface takes the plain **`app_credentials`** token only; a delegated
  `act_as` token is `403`.
- **One project per app.** Member routes are scoped to the single `projectId`
  your app was provisioned for. A multi-project app cannot use them.
- **Doors are chosen by Keyspace, not by you.** At provisioning your Keyspace
  contact attaches a fixed set of **access groups** to your app. Every member
  you create is added to those groups automatically — that membership, never a
  credential on its own, is what makes devices accept the member's cards and
  face (*credential ≠ permission*). Need different doors? Ask your contact; there
  is no API for it.
- **Face photos are biometric data.** A face upload **requires the instant the
  person consented** (`faceConsentAt`, ISO-8601). Collect and store that consent
  in your own app; Keyspace persists the timestamp for audit and never returns
  image bytes.
- **Rate limit** — 30 requests / 60 s per app across the member routes
  (`429 KS036`). Batch imports: throttle, don't retry blindly.

---

## 2. The five-minute mental model

```mermaid
flowchart LR
    subgraph app["your app (system of record)"]
        user["user &quot;u-123&quot;"]
    end

    subgraph routes["/partner/members/u-123"]
        direction TB
        put["PUT<br>profile, employeeId, unit"]
        creds["PUT …/credentials<br>cards[], face + consent"]
        get["GET<br>status, source, credential summary"]
        del["DELETE<br>deactivated, cards revoked, link kept"]
    end

    subgraph ks["Keyspace (owns the devices)"]
        direction TB
        member["member"]
        profile["profile"]
        groups["fixed access groups"]
        doors["doors"]
        cards["cards (physical + auto)"]
        face["face"]
        member --- profile
        member --- groups
        member --- cards
        member --- face
        groups --> doors
    end

    user --> put --> member
    user --> creds --> member
    user --> get --> member
    user --> del --> member
```

- **`externalUserId` is yours.** Every route is keyed by *your* id for the
  person (opaque to Keyspace, ≤ 128 chars). The first `PUT` binds it to exactly
  one Keyspace member; every later call — including `act_as` with the same
  `subject` — resolves to that member. You can only reach members your app
  created or linked: any other id is `403 KS221` (it never creates by accident).
- **Existing people are reused, never duplicated.** Buildings already have
  members (admin console, bulk import, Office sign-in). Before creating,
  Keyspace resolves your body's identity keys — previous link → `email` →
  `phone` → `employeeId`, first hit wins — and only creates when nothing
  matches (§3).
- **Members, not guests.** People you `PUT` are project **members**
  (`userType: member`, `source: partner`). People who only ever appear as an
  `act_as` subject are **visitors** (`source: federated`) until an admin
  promotes them.
- **What the building admin sees.** Your members show in the Keyspace admin
  console with a **Partner** provenance chip. Admins can read them, adjust
  groups and edit fields, but cannot demote a partner member to a visitor.
  Fields you *send* on a later `PUT` overwrite theirs; fields you omit stay as
  they are.
- **Credentials are declarative.** `PUT …/credentials` describes the exact set
  the member should hold; Keyspace diffs it against the devices. Omit a field
  to leave that credential type untouched.

---

## 3. Choose your identity keys

Pick by what your app knows about a person. Keys are checked in the order
below; **`unit` is never an identity key** — it says where someone lives, not
who they are.

| Your app | Send | Why |
| --- | --- | --- |
| **Office / workplace** (HR system, employee app) | `employeeId` — plus `email` when the tenant signs in with Office / Microsoft | `employeeId` is your badge / HR id, unique per project; **valid alone** (the member is created without a login, username `{projectId}_{employeeId}`). Sending the work `email` too means the member who later signs in through the Office add-in is the *same* one you provisioned. |
| **Condo / residence** (resident app) | `email` and/or `phone` to identify the person, `unit: { addressNo, zoneId?, unitId? }` to bind the home | `addressNo` is the house number the building admin entered on the room (`404` if the project has no such room — partners never create rooms). `zoneId` disambiguates when the same number exists in several towers; `unitId` is an alternate key. First resident bound to a unit becomes its reservation **owner**, later ones **participants**; `unit: null` unbinds; a new unit moves them. |
| **Booking-only** (you just book rooms for people) | — | You don't need this surface. Pass people as attendees, or use `act_as` for per-user scope — see the [meeting guide](/docs/guides/meeting.md). |

`email` / `phone` must be unique across Keyspace; `employeeId` unique within
the project. `phone` is normalised (spaces/dashes stripped, Thai numbers default
to `+66`).

---

## 4. Step by step

### Step 1 — Authenticate

```bash
curl -X POST <baseUrl>/auth/accessToken \
  -H "Content-Type: application/json" \
  -d '{"app_id":"<app_id>","app_secret":"<app_secret>","grant_type":"app_credentials"}'
# → { access_token, expires_in, refresh_token, refresh_token_expires_in }
```

Send `Authorization: Bearer <access_token>` on every call; refresh ~5 min
before `expires_in`. Full token rules:
[reference §Authentication](/llms-full.txt#authentication).

### Step 2 — Create or update the member

`PUT /partner/members/{externalUserId}` — idempotent. First call creates, links
and grants the fixed groups; later calls update the profile (**omitted =
untouched**). `firstName` and `lastName` are required (empty `lastName` is
accepted).

Office app:

```json
{ "firstName": "Somchai", "lastName": "Jaidee", "employeeId": "HR-0042",
  "email": "somchai@example.com" }
```

Condo app:

```json
{ "firstName": "Somchai", "lastName": "Jaidee", "phone": "+66812345678",
  "unit": { "addressNo": "12/34", "zoneId": "66a1…" } }
```

Response `200` — keep `memberId`; read `created` and `matchedBy` to learn
whether you created a new person or picked up an existing one
(`link` | `email` | `phone` | `employeeId` | `provisioned`):

```json
{
  "code": "KS000", "success": true,
  "data": {
    "memberId": "66b1f0a2c9e77a0012345678",
    "externalUserId": "u-123",
    "projectId": "66a0…",
    "created": true,
    "matchedBy": "provisioned",
    "accessGroupIds": ["66c2…"],
    "employeeId": "HR-0042",
    "unit": { "roomId": "66d3…", "addressNo": "12/34", "groupId": "66d4…", "role": "owner" }
  }
}
```

If `accessGroupIds` is **empty**, your app has no active partner groups yet —
contact Keyspace before relying on door access.

**`409 KS006` — Keyspace never silently re-links.** `data.conflictKey` names
the key; when the person is already managed by you under another id,
`data.linkedExternalUserId` tells you which. Typical causes and fixes:

| Cause | What to do |
| --- | --- |
| The body's `email`/`phone`/`employeeId` points at a *different* member than the one already linked to this `externalUserId` | Fix the key on your side, or `DELETE` the wrong link first |
| Two of your users share an `employeeId` / `email` | Correct your data — one person, one id |
| `email` / `phone` / `employeeId` already taken by someone else in Keyspace | Ask the building admin to resolve the duplicate |
| `unit.addressNo` exists in several zones | Send `unit.zoneId` |

### Step 3 — Set the member's credentials

`PUT /partner/members/{externalUserId}/credentials` — a **declarative set**;
each field independent, omit to leave alone.

```json
{
  "cards": [
    { "cardNo": "2140450341", "label": "Resident card" },
    { "cardNo": "25AE947F", "cardCodeFormat": "hex" }
  ],
  "face": "data:image/jpeg;base64,/9j/4AAQSkZJRg…",
  "faceConsentAt": "2026-09-01T09:30:00.000Z"
}
```

- **`cards`** — the exact physical cards the member should hold (max 20).
  Missing ones are enrolled, absent ones revoked, common ones kept; `[]`
  revokes all. `cardNo` is read in `cardCodeFormat`, default
  **`decimal-reversed`** — the 10-digit number a Hikvision reader/terminal
  shows. Override per card only for another vendor's reader (see the OpenAPI
  enum). Matching is on the decoded card UID, so one card in two formats is one
  card; an unparseable number is `400`.
- **`face`** — base64 JPEG/PNG (bare or `data:image/…;base64,`), ≤ 10 MB
  decoded, type verified by magic bytes. A string sets/replaces and **requires
  `faceConsentAt`**; `null` erases (right to erasure).
- **Auto-generated card.** If, after applying the set, the member **has a face
  but holds no physical card**, Keyspace mints **one** virtual card with a
  random project-unique number and returns it with `generated: true`. Print or
  encode a physical card with that `cardNo` and hand it over — it opens the same
  doors. It is generated once per member; physical cards you add later don't
  retire it; offboarding revokes it and a re-onboarded member gets a fresh
  number.

Response `200`:

```json
{
  "code": "KS000", "success": true,
  "data": {
    "memberId": "66b1f0a2c9e77a0012345678",
    "cards": [
      { "cardId": "66c3…", "cardNo": "2140450341", "label": "Resident card", "type": "real", "generated": false },
      { "cardId": "66c4…", "cardNo": "0982716354", "label": "auto-generated (face) 1B2C3D4E", "type": "virtual", "generated": true }
    ],
    "face": { "present": true }
  }
}
```

`cardNo` in every response is the Hikvision decimal-reversed number, whatever
you submitted. The member must be active — `409` when offboarded (re-`PUT` the
member first).

### Step 4 — Read back

`GET /partner/members/{externalUserId}` returns `status`
(`active` | `deactivated`), `source`, `userType`, `accessGroupIds`,
`employeeId`, `unit`, `profile` and a **credential summary** — cards as above,
`face: { present }`. Face bytes are never returned. Never creates.

### Step 5 — Move, rename, change a key

Another `PUT` with the changed fields. Moving a resident to a new `unit`
removes them from the old one; `unit: null` unbinds. Changing `email` /
`phone` / `employeeId` re-runs the identity checks — a `409` here means the new
key already belongs to someone (see Step 2).

### Step 6 — Offboard (and re-onboard)

`DELETE /partner/members/{externalUserId}` → `204`. Every card (physical and
auto-generated) is revoked, device access withdrawn, the member removed from
your groups. **The link is kept**, so a later `PUT` re-activates the *same*
`memberId` — no duplicates. Idempotent.

### Step 7 — Book on behalf of the member (optional)

Same `externalUserId` as the `act_as` `subject`:

```json
{ "grant_type": "act_as", "app_id": "…", "app_secret": "…",
  "subject": "u-123", "subject_type": "external_user_id" }
```

The delegated token resolves to the member you provisioned (not a new visitor)
and books with that member's own quota and scope. Booking journey:
[meeting guide](/docs/guides/meeting.md); token rules:
[reference §act_as](/llms-full.txt#4-act-on-behalf-of-a-member-act_as--advanced).

---

## 5. Sequence — a resident's life in your app

```mermaid
sequenceDiagram
    participant Resident as resident
    participant App as your app
    participant API as Keyspace API

    Resident->>App: signs up
    App->>API: PUT /partner/members/u-123 (name, phone, unit)
    API-->>App: 200 created true, unit.role "owner"

    Resident->>App: selfie + biometric consent
    App->>API: PUT …/u-123/credentials (face, faceConsentAt)
    API-->>App: 200 auto-generated card, generated true + cardNo
    App->>Resident: show the cardNo, the building prints a card

    Note over App,API: building issues a real card later
    App->>API: PUT …/u-123/credentials (cards, cardNo 2140450341)
    API-->>App: 200 both cards listed

    Resident->>App: books the gym as themselves
    App->>API: POST /auth/accessToken (act_as, subject u-123)
    App->>API: POST /meetings (Bearer delegated)

    Resident->>App: moves out
    App->>API: DELETE /partner/members/u-123
    API-->>App: 204 cards revoked, link kept
```

---

## 6. Error handling

| Status / code | Meaning | Handle |
| --- | --- | --- |
| `400 KS001` | validation — missing `faceConsentAt`, bad card number, bad image, `lastName` missing | fix the request; don't retry as-is |
| `401 KS002` | token expired / invalid | refresh → retry once → re-authenticate |
| `503 KS234` | authentication temporarily unavailable — the token was not rejected | retry with backoff; don't re-authenticate |
| `401 KS223` | member could not be resolved | treat as "not linked"; `PUT` to create |
| `403 KS003` | not an app token (you sent a delegated token) | use the `app_credentials` token |
| `403 KS221` | not your member, or not a single-project app | check the id; talk to your contact about scope |
| `404 KS005` | `unit` names a room the project does not have | verify `addressNo` / `zoneId` with the building admin |
| `409 KS006` | offboarded member (credentials), duplicate email / phone / employeeId, identity-key conflict | read `data.conflictKey` / `data.linkedExternalUserId` — table in Step 2 |
| `429 KS036` | rate limited | back off; resume after 60 s |

Envelope and the full code list:
[reference §Error Handling](/llms-full.txt#error-handling).

---

## 7. Sandbox checklist

Run against `https://api.keyspace-dev.com` with your dev credentials; make the
script offboard everything it creates.

- [ ] Authenticate; refresh; recover from a forced `401`.
- [ ] `PUT` a new member → `created: true`; `PUT` again with a changed
      `profileName` → `created: false`, `matchedBy: "link"`.
- [ ] `PUT` a second `externalUserId` with the first member's `employeeId` →
      `409 KS006` with `linkedExternalUserId`; handled, not crashed.
- [ ] Condo: `PUT` with `unit` → `unit.role: "owner"`; second resident on the
      same unit → `"participant"`; `unit: null` → `unit: null`.
- [ ] Credentials: face + consent → one `generated: true` card; add a physical
      card → both listed; `cards: []` → physical revoked, auto card kept.
- [ ] Face without `faceConsentAt` → `400`.
- [ ] `GET` shows the summary; `DELETE` → `204`; `GET` → `status:
      "deactivated"`; `PUT` → same `memberId`, `status: "active"`.
- [ ] Optional: `act_as` with the same subject books a room as that member.

---

## 8. Go-live checklist

- [ ] Production `app_id` / `app_secret` issued and stored server-side only.
- [ ] Access groups confirmed with your Keyspace contact (`accessGroupIds`
      non-empty on the first production `PUT`).
- [ ] Consent capture live in your app before any face upload; erasure path
      (`face: null`) wired to your privacy process.
- [ ] Card numbers verified against a production reader (default
      `decimal-reversed`; override only if the reader is not Hikvision).
- [ ] Offboarding hooked to your leaver / move-out process.
- [ ] Throttling under the 30 req/min limit for bulk imports.

---

## 9. Getting help & reporting discrepancies

Found the API behaving differently from this guide? Send your Keyspace contact
the endpoint, the `externalUserId`, what you expected and what you saw — the
docs are served from the API and we fix drift at the source.
