{
  "info": {
    "name": "Nimbio Public API",
    "description": "Programmatic access to your Nimbio keys, latches, members, and opens.\n\n**Authentication** — every endpoint (except `/healthz`) takes a bearer token:\n`Authorization: Bearer nimbio_test_…` or `nimbio_live_…`. Click **Authorize**\nand paste a key to try calls here.\n\n**Test vs live** — a `nimbio_test_*` key runs the full pipeline (auth, rate\nlimit, scope, validation) but never fires a gate, sends a message, or mutates a\nmember: writes come back `simulated` / `would_*`. Use a test key to explore\nsafely; switch to a `nimbio_live_*` key only when you want real side effects.\n\n**Idempotency** — the write endpoints that have a physical side effect (the\nopens and `POST /v1/community/messages`) accept an `idempotency_key`, either as a\nbody field or as an `Idempotency-Key` request header. Scoped to your API key, it\nmakes the write at-most-once for 24 hours: a retry with the same token replays\nthe original status code and body (marked `Idempotent-Replayed: true`) rather\nthan opening the gate again. Retry-safe failures — `502 upstream_unavailable` —\nfree the token; a `504 did_not_open` deliberately does not, because the open was\ndispatched and may have physically fired.\n\n**Account vs community keys** — there are two key types. The `/v1/account/*`\nendpoints (your keys and opening your own latches) require an account-scoped key;\nthe `/v1/community/*` endpoints require a community-scoped key (created by a\ncommunity manager in the admin portal). `/v1/me` works with either — its `key.type`\nfield tells you which one you're holding.\n",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{api_key}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "base_url",
      "value": "https://api.nimbio.com"
    },
    {
      "key": "api_key",
      "value": ""
    },
    {
      "key": "key_id",
      "value": ""
    },
    {
      "key": "latch_id",
      "value": ""
    },
    {
      "key": "community_key_id",
      "value": ""
    },
    {
      "key": "account_community_id",
      "value": ""
    },
    {
      "key": "request_id",
      "value": ""
    },
    {
      "key": "x_request_id",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "Account",
      "item": [
        {
          "name": "List your keys",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/account/keys?include_hidden=False",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "account",
                "keys"
              ],
              "query": [
                {
                  "key": "include_hidden",
                  "value": "False",
                  "disabled": false
                }
              ]
            },
            "description": "Every Nimbio key on your account, with its latches nested. Hidden keys are\nexcluded unless `include_hidden=true`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Open one of your latches",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/account/keys/{{key_id}}/latches/{{latch_id}}/open",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "account",
                "keys",
                "{{key_id}}",
                "latches",
                "{{latch_id}}",
                "open"
              ]
            },
            "description": "Open a latch using one of your own keys.\n\nTest mode: writes `test_simulated`, returns 200 with `would_open` — box never\nfires (the latch permission is authorized on the backend only for live opens).\nLive mode: synchronous — blocks until the open settles, then returns 200 when\nthe box confirms, 403 when denied, 409 when the latch requires an NFC scan, or\n504 when the gate does not confirm within the timeout (`did_not_open`).\n\nIdempotency: send an `idempotency_key` (body field or `Idempotency-Key`\nheader) to make this at-most-once. Within 24h a repeat of the same token\nreplays the original status and body — marked with `Idempotent-Replayed:\ntrue` and an `idempotent_replay` body field, carrying the original\n`request_id` — and issues no second open. A duplicate arriving while the\nfirst is still in flight gets 409; the same token with a different request\ngets 422. A 502 frees the token so a real retry proceeds. **A 504\n`did_not_open` does not**: the open was dispatched and may have physically\nfired, so retrying it would risk opening the gate again with nobody there —\nuse a new `idempotency_key` if you deliberately want another attempt.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"note\": \"Front gate test\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Who am I",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "me"
              ]
            },
            "description": "Key metadata + usage. Quota-exempt (per-minute limit only), so\nintegrations can poll it for diagnostics without burning quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Reads",
      "item": [
        {
          "name": "Describe this community",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community"
              ]
            },
            "description": "**Call this first.** A read-only description of the community your key is\nbound to — the bootstrap call for any integration.\n\nIt answers the three things the rest of this surface assumes you already\nknow:\n\n**Latch ids.** `latches[].latch_id` is what `POST /v1/community/open` and\nthe hold-open endpoints take. Read them from here instead of scraping them\nout of a gate-status or key-status payload. The ids and names are the same\nones `GET /v1/community/gate-status` reports; live sensed state is not\nduplicated here, so call gate-status for that.\n\n**Feature flags.** `features` tells you what this community has turned on —\nbranch on it instead of calling an endpoint and interpreting a 403. For\nexample, `features.hold_opens` false means every `/v1/community/hold-opens`\ncall will 403, and `features.access_log_history` false means the access-log\nendpoints will. `member_open_notifications` and `event_keys` are the\n*resolved* values (the property-type default combined with any\nper-community override), i.e. what the product actually enforces.\n\n**Timezone.** Every timestamp you read or write on this API — access-log\nrows, hold-open windows, recurring schedules — is meant to be interpreted in\nthe community's local clock, and that clock lives on its hardware. A\ncommunity has no timezone column of its own: each latch inherits the\ntimezone of the box it hangs off. `timezone` is the single zone every\nconfigured latch agrees on, or `null` when they disagree or none is set;\n`timezones` lists every distinct zone in play, and each entry in `latches`\ncarries its own. A multi-site community will legitimately report `null` at\nthe top level — use the per-latch value there.\n\nContains no personal data: this describes the community, not its roster\n(see `GET /v1/community/members` for that).\n\nQuota-exempt: never counts against the monthly quota, so integrations can\ncall it on startup and on every config refresh."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get gate status",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/gate-status",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "gate-status"
              ]
            },
            "description": "Latest known sensed state per latch in the key's community, including\neach latch's configured status vocabulary (``possible_statuses``).\nQuota-exempt: never counts against the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);",
                  "try {",
                  "  var d = pm.response.json();",
                  "  if (d && d[\"latches\"] && d[\"latches\"][0]) pm.collectionVariables.set('latch_id', String(d[\"latches\"][0][\"latch_id\"]));",
                  "} catch (e) {}"
                ]
              }
            }
          ]
        },
        {
          "name": "Get key + latch statuses",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/key-statuses",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "key-statuses"
              ]
            },
            "description": "All key + latch statuses (disabled, offline, held_open, disabled_until).\nQuota-exempt: never counts against the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);",
                  "try {",
                  "  var d = pm.response.json();",
                  "  if (d && d[\"keys\"] && d[\"keys\"][0]) pm.collectionVariables.set('community_key_id', String(d[\"keys\"][0][\"id\"]));",
                  "} catch (e) {}"
                ]
              }
            }
          ]
        },
        {
          "name": "List keys with restrictions",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/keys",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "keys"
              ]
            },
            "description": "List every key in the key's community with its access restrictions.\n\nRestriction-focused companion to `/v1/community/key-statuses`: that endpoint\nreports live state (offline, held-open) for monitoring; this one reports the\nconfigured limits on each key — whether it is disabled, time-restricted\n(temporal windows), expiring (`expiry`), depth-limited for resharing\n(`sharing.max_share_depth`), or per-latch restricted (scan-only, NFC-scan\nrequired, a limited number of uses)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List members",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/members",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members"
              ]
            },
            "description": "Pending / accepted / removed members for the key's community."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);",
                  "try {",
                  "  var d = pm.response.json();",
                  "  if (d && d[\"accepted\"] && d[\"accepted\"][0]) pm.collectionVariables.set('account_community_id', String(d[\"accepted\"][0][\"account_community_id\"]));",
                  "} catch (e) {}"
                ]
              }
            }
          ]
        },
        {
          "name": "List members (paged + searchable)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/members/page?bucket=accepted&page=1&size=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "page"
              ],
              "query": [
                {
                  "key": "bucket",
                  "value": "accepted",
                  "disabled": false
                },
                {
                  "key": "page",
                  "value": "1",
                  "disabled": false
                },
                {
                  "key": "size",
                  "value": "100",
                  "disabled": false
                },
                {
                  "key": "search",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "One bucket of the key's community roster, paged and optionally searched.\n\n`bucket` is `accepted` (default), `unaccepted` (pending join requests), or\n`removed`. `page` is 1-indexed; `size` is 1–500 (default 100). `search` is a\ncase-insensitive substring match over first name, last name, and phone\nnumber — phone matching ignores punctuation, so `555-1234` matches\n`+1 555-123-4567`. Filtering happens before paging, so `total` counts the\nmatches and paging through a search never skips anyone.\n\nRows are shaped exactly as /v1/community/members returns them; homes appear\nas a single `is_home: true` row listing their members' ids. Use this instead\nof /v1/community/members when you poll a large community or only need one\nbucket — it ships far less data (and far fewer phone numbers) per call."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get one member",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}"
              ]
            },
            "description": "One member of the key's community, by `account_community_id` (as returned\nby /v1/community/members or carried on a `member.requested` webhook).\n\n`bucket` says which list they are in (`accepted` / `unaccepted` /\n`removed`). A member of any other community returns 404, exactly as an\nunknown id does — a key can only ever read its own community's roster."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Live event stream (SSE)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/events/stream",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "events",
                "stream"
              ],
              "query": [
                {
                  "key": "events",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "last_event_id",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "Server-Sent Events stream of this community's live events. Quota-exempt;\nthe connection itself charges one per-minute request. At most\n3 concurrent streams per key. Heartbeat comments every ~25s."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Writes",
      "item": [
        {
          "name": "Rename or disable a community key",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/keys/{{key_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "keys",
                "{{key_id}}"
              ]
            },
            "description": "Rename one of the community's own keys, or disable it community-wide.\n\nSend `name`, `disabled`, or both — each is applied only when present, so\nthis never clobbers the field you did not send. An empty body is rejected\n(422) rather than treated as a no-op write.\n\n**Disabling a community key affects every member holding a child of it.**\nKeys are not shared between accounts in Nimbio: sharing a key *mints a new\nchild key* on the recipient's account, pointing back at the parent. A key's\ndisabled state is inherited down that chain, so disabling the community's\nkey instantly denies every member key descended from it — potentially the\nentire community. `descendant_key_count` in the response is that blast\nradius; read `GET /v1/community/keys` first if you are unsure which key you\nare about to cut.\n\nIt is reversible by this same endpoint: send `disabled: false` to restore\naccess. Authorization for this API key does not depend on the key you\ndisable, so you cannot lock yourself out of re-enabling it.\n\nTo disable one member's access instead of the whole community's, use\n`POST /v1/community/members/{account_community_id}/keys-disabled`.\n\n`name` is cosmetic — it changes what members see and what the access logs\nlabel opens with, never who can open what. It is trimmed, must not be\nempty, and is capped at 200 characters.\n\nTest mode: validated and scope-checked, then returns `\"result\":\n\"simulated\"` — a test key never disables a real key or renames anything.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"disabled\": true,\n  \"name\": \"Contractor Key (revoked)\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Issue an open",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/open",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "open"
              ]
            },
            "description": "Issue an open on a latch in the key's community.\n\nTest mode: validate scope, write `test_simulated`, return 200 — box never fires.\nLive mode: synchronous — blocks until the open settles, then returns 200 when\nthe box confirms it opened, 403 when the open is denied, or 504 when the gate\ndoes not confirm within the timeout (`did_not_open`).\n\nIdempotency: send an `idempotency_key` (body field or `Idempotency-Key`\nheader) to make this at-most-once. Within 24h a repeat of the same token\nreplays the original status and body — marked with `Idempotent-Replayed:\ntrue` and an `idempotent_replay` body field, carrying the original\n`request_id` — and issues no second open. A duplicate arriving while the\nfirst is still in flight gets 409; the same token with a different request\ngets 422. A 502 frees the token so a real retry proceeds. **A 504\n`did_not_open` does not**: the open was dispatched and may have physically\nfired, so retrying it would risk opening the gate again with nobody there —\nuse a new `idempotency_key` if you deliberately want another attempt.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"note\": \"Front gate test\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add a member by phone",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members"
              ]
            },
            "description": "Add a member to the community by phone number and grant them keys.\n\nIf the phone number has no Nimbio account, a pending account is created.\n`key_ids` are community key ids (from /v1/community/key-statuses); each\ngrants the member access via a new key on their account.\n\nTest mode: validate phone + key scope, write `test_simulated`, return 200 —\nno account is created and no keys are granted.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key_ids\": [\n    \"{{community_key_id}}\"\n  ],\n  \"phone_number\": \"+15551234567\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);",
                  "try {",
                  "  var d = pm.response.json();",
                  "  if (d && d[\"accepted\"] && d[\"accepted\"][0]) pm.collectionVariables.set('account_community_id', String(d[\"accepted\"][0][\"account_community_id\"]));",
                  "} catch (e) {}"
                ]
              }
            }
          ]
        },
        {
          "name": "Bulk add members by phone",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/bulk-add",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "bulk-add"
              ]
            },
            "description": "Add many members by phone number in one call — the initial roster import.\n\n`items` is a list of `{phone_number, key_ids}` objects (at most 100). Key\nscope, phone shape, and in-batch duplicate phone numbers are all checked\nbefore anything is created; any of them rejects the whole batch (422).\n\nThis is the highest-side-effect bulk call on the API: each successful item\nmay create an account, mints keys, sends that member a push notification,\nand fires a `member.approved` webhook — none of which can be undone. An\nalready-accepted member fails with `already_member` and the rest of the\nbatch still runs, so re-submitting after a partial failure does not\ndouble-add anyone.\n\nQuota: one monthly-quota unit **per item**; one call against the per-minute\nlimit. Test mode: validate everything, create nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": [\n    {\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ],\n      \"phone_number\": \"+15551234567\"\n    },\n    {\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ],\n      \"phone_number\": \"+15557654321\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Bulk disable / enable members' keys",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/keys/bulk-disabled",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "keys",
                "bulk-disabled"
              ]
            },
            "description": "Disable or enable many members' keys in one call (reversible — no key is\nremoved).\n\n`items` is a list of `{account_community_id, key_ids}` objects (at most 100),\nvalidated in full before anything is applied. `disabled` is required and\napplies to the whole batch: true disables, false re-enables.\n\nSetting a key to the state it is already in is a no-op, so re-submitting is\nsafe. A key the member does not hold is a per-item `key_not_held` failure —\nunlike revoke there is no \"already in the desired state\" reading, and\nenabling a key someone does not have needs a grant, not a toggle.\n\nQuota: one monthly-quota unit **per item**; one call against the per-minute\nlimit. Test mode: validate everything, change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"disabled\": true,\n  \"items\": [\n    {\n      \"account_community_id\": 4021,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    },\n    {\n      \"account_community_id\": 4022,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Bulk grant keys to members",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/keys/bulk-grant",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "keys",
                "bulk-grant"
              ]
            },
            "description": "Grant community keys to many members in one call.\n\n`items` is a list of `{account_community_id, key_ids}` objects (at most 100).\nEvery item is checked against your community *before* anything is applied —\na single member or key belonging elsewhere rejects the whole batch with\nnothing changed (422). Then each item is applied in request order and\nreports its own result; the response is 207 with one entry per input item,\nin the same order.\n\nGrants are idempotent: a key the member already holds is reported under\n`granted.exists` and changes nothing, so re-submitting a batch (or just its\nfailed items) is safe.\n\nQuota: a bulk call costs one monthly-quota unit **per item**, and one call\nagainst the per-minute limit.\n\nTest mode: validate everything, write `test_simulated`, change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": [\n    {\n      \"account_community_id\": 4021,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    },\n    {\n      \"account_community_id\": 4022,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Bulk revoke keys from members",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/keys/bulk-revoke",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "keys",
                "bulk-revoke"
              ]
            },
            "description": "Revoke community keys from many members in one call.\n\n`items` is a list of `{account_community_id, key_ids}` objects (at most 100),\nvalidated in full before anything is applied.\n\nKeys only: members stay accepted members of the community. Removing a member\nentirely (`remove_member`) is deliberately **not** batched — use\n`POST /v1/community/members/{account_community_id}/revoke-keys` per member,\nso a mass removal is always an explicit, per-member act.\n\nRe-submitting is safe: a key the member no longer holds is reported under\n`already_revoked_community_key_ids` and the item still succeeds.\n\nQuota: one monthly-quota unit **per item**; one call against the per-minute\nlimit. Test mode: validate everything, change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": [\n    {\n      \"account_community_id\": 4021,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    },\n    {\n      \"account_community_id\": 4022,\n      \"key_ids\": [\n        \"{{community_key_id}}\"\n      ]\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve a pending member",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/approve",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "approve"
              ]
            },
            "description": "Approve a pending join request and grant the member their keys.\n\nCloses the loop opened by the `member.requested` webhook: the person sitting\nin the `unaccepted` bucket of GET /v1/community/members becomes an accepted\nmember, gets a key on their account for each id in `key_ids` (community key\nids, from /v1/community/key-statuses), receives the approval push, and the\ncommunity's `member.approved` webhook fires — exactly as if a community\nmanager had approved them in the portal.\n\n`move_out_date` is an optional ISO date (`YYYY-MM-DD`) stamped on the\nmembership. Approving an already-approved member is a 409.\n\nTest mode: validate the member, keys, and date, write `test_simulated`,\nreturn 200 — nobody is approved.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key_ids\": [\n    \"{{community_key_id}}\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Grant keys to a member",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/grant-keys",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "grant-keys"
              ]
            },
            "description": "Grant additional keys to an existing accepted member.\n\n`key_ids` are community key ids (from /v1/community/key-statuses). Each that\nthe member does not already have is granted; a previously disabled key is\nre-enabled; keys they already hold are left unchanged. Use POST\n/v1/community/members to add a brand-new member.\n\nTest mode: validate scope, write `test_simulated`, return 200 — no change.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key_ids\": [\n    \"{{community_key_id}}\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Disable / enable a member's keys",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/keys-disabled",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "keys-disabled"
              ]
            },
            "description": "Disable or enable a member's keys (reversible — keys are not removed).\n\n`key_ids` are community key ids (from /v1/community/key-statuses). `disabled`\nis required: true disables the member's keys, false re-enables them.\n\nTest mode: validate scope, write `test_simulated`, return 200 — no change.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"disabled\": true,\n  \"key_ids\": [\n    \"{{community_key_id}}\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Revoke keys from a member",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/revoke-keys",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "revoke-keys"
              ]
            },
            "description": "Revoke community keys from a member.\n\n`key_ids` are community key ids (from /v1/community/key-statuses). By\ndefault only those keys are removed and the member stays active; pass\n`remove_member: true` to remove the member from the community entirely.\n\nTest mode: validate scope, write `test_simulated`, return 200 — no change.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"key_ids\": [\n    \"{{community_key_id}}\"\n  ],\n  \"remove_member\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List sent messages",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/messages?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "messages"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "The messages already sent to this community, newest first.\n\nThe read half of `POST /v1/community/messages`: confirm a broadcast landed,\nrender a history, or check before re-sending after an ambiguous failure.\nPaginated by `limit` (1-200, default 50) and `offset`; `has_more` says\nwhether another page exists. `sent_at` is ISO-8601 in **UTC**.\n\nOnly real sends appear here. A test-mode key's send is validated and\ndiscarded without being recorded, so nothing simulated is ever in the log\nand there is no simulated/real flag to check — a test-mode key reads the\nlive history but can never add to it.\n\n`sender_name` / `sender_account_id` identify the community manager the\nmessage was sent as. An API key acts as its owning manager, so a message\nsent through this API is not distinguishable from one the same person sent\nfrom the Nimbio community portal."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Message all members",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/messages",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "messages"
              ]
            },
            "description": "Send a message to all community members.\n\nTest mode: validated via dry_run — no push is sent. Live mode: delivered.\n\nIdempotency: send an `idempotency_key` (body field or `Idempotency-Key`\nheader) to make this at-most-once — a retry within 24h replays the original\nresponse instead of pushing the message to every member a second time. A\nduplicate still in flight gets 409; the same token with a different message\ngets 422; a 502 frees the token so a real retry proceeds.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"message\": \"Pool maintenance Thursday 9am–noon.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Logs",
      "item": [
        {
          "name": "Community access log",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-logs?page=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-logs"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "0",
                  "disabled": false
                },
                {
                  "key": "from",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "to",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "The community's access log (gate opens) for a date window, paginated 1000\nper page (`page` is 0-indexed; `has_more` says whether another page exists).\nEach row includes the opener's `phone` when on file. Commercial communities\nname the actual opener; residential communities use a '<owner> Keychain'\ngrouping. Requires the community's Access Log History on.\n\n**Window.** `from`/`to` accept a bare date or a full ISO-8601 datetime. A\nbare date covers the whole day in the direction of its bound —\n`from=2026-08-01` is 00:00:00 UTC, `to=2026-08-01` is 23:59:59.999999 UTC,\nso a single day is `from=2026-08-01&to=2026-08-01`. A datetime without an\noffset is read as UTC. Omit both for the default last-90-days window.\n\n**Retention.** Only the last 90 days are kept. A `from` older than that is\nclamped to the floor (not an error); the response's `from`/`to` always echo\nthe *effective* window, so compare them with what you asked for to detect a\nclamp. A window that lies entirely before the floor is rejected with 400.\n\n**Incremental sync.** Poll with `from` set to the `datetime` of the newest\nrow you already stored (and no `to`) to pull only what is new, instead of\nre-paging the whole 90 days on every run."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Configuration change log",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/change-logs?type=hold_open&days=30&limit=500&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "change-logs"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "hold_open",
                  "disabled": false
                },
                {
                  "key": "days",
                  "value": "30",
                  "disabled": false
                },
                {
                  "key": "limit",
                  "value": "500",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "The community's **configuration** audit trail — who changed what, when.\n\nThis is the *change* log. For the *access* trail (who opened which gate),\nuse `/v1/community/access-logs`; for physical open/closed transitions, use\n`/v1/community/gate-status-log`. The two are easy to confuse: an access log\ntells you a gate opened, this tells you someone rewrote the schedule that\nlet it open.\n\n`type` selects one of four trails:\n\n| `type` | Covers |\n|---|---|\n| `hold_open` | Hold opens: manual on/off, one-time events, recurring schedules, disable-until dates |\n| `key_schedule` | Key access schedules (time-of-day / day-of-week restrictions) |\n| `guest_view` | GuestView / Directory settings, short codes, directory access codes, GuestView Entry windows |\n| `guest_link` | Guest links: creation, revocation, and the community-level link settings |\n\nEvery row carries the same envelope — `log_type`, `log_id`, `datetime`,\n`account_id`, `account_display_name`, `action_type`, `summary` — plus a\n`details` object holding the subject of the change: `latch_id` /\n`latch_name` for `hold_open`, `guest_view` and `guest_link`, and `key_id`\nfor `key_schedule`. `log_id` is unique only within its own `log_type`.\n\n**Retention:** every trail is pruned at 30 days, so `days` is capped at 30\nand nothing older is recoverable through this endpoint. Export on a\nschedule if you need a longer archive. The effective window comes back as\n`from`/`to`.\n\n**Ordering:** newest first, by `datetime`.\n\n**Timestamps:** ISO-8601 with an explicit UTC offset (`+00:00`), always\nUTC — not the community's local time zone.\n\n**Personal data:** `account_display_name` names the manager or admin who\nmade the change. `key_schedule` summaries are prefixed with the key's name,\nwhich for a member key is usually that member's name. Guest names and phone\nnumbers do not appear in any of these trails."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Gate status-change log",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/gate-status-log?page=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "gate-status-log"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "0",
                  "disabled": false
                },
                {
                  "key": "from",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "to",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "The community's gate status-change log (physical open/closed transitions)\nfor a date window, paginated 1000 per page (`page` 0-indexed; `has_more`\nflags another page). Distinct from /v1/community/gate-status (current state).\nRequires the community's Access Log History on.\n\n**Window.** Identical rules to `/v1/community/access-logs`: `from`/`to` take\na bare date (widened to 00:00:00 / 23:59:59.999999 UTC respectively) or a\nfull ISO-8601 datetime (naive is UTC); omitting both means the last 90 days;\na `from` older than the 90-day retention floor is clamped, and the response\nechoes the effective window. Poll with `from` set to your newest stored row\nto sync incrementally."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Key usage history report",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/key-usage?from=&to=&page=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "key-usage"
              ],
              "query": [
                {
                  "key": "from",
                  "value": "",
                  "disabled": false
                },
                {
                  "key": "to",
                  "value": "",
                  "disabled": false
                },
                {
                  "key": "page",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "Per-key usage history for the community over a date window — the same\nreport a Community Manager sees on the portal's Access Logs page.\n\n**Commercial vs residential.** Nimbio attributes an open differently\ndepending on the community's property type, and *the server decides which\nrule applies* — there is no parameter for it, because getting it wrong would\nmisreport who opened a gate. Read `report_type` to interpret `user`:\n\n* `\"commercial\"` — `user` is the **actual opener's** name.\n* `\"residential\"` — `user` is `\"<master key owner> Keychain\"`. Every open on\n  a household's keys is attributed to the account holder, so you cannot tell\n  which member of the household opened the gate. This groups; it does not\n  anonymize — the account holder is named.\n\nRows carry personal data beyond `user`: `phone` and `location`. Note that\n`phone` here does **not** mean what it means on `/v1/community/access-logs`\n— there it is the account holder's number, looked up from the key; here it\nis whatever the open itself recorded, which is the **visitor's** number on\nguest-link, GuestView-entry, and access-code opens, and `null` on ordinary\nmember opens. `location` is the opener's GPS coordinates as `\"(x,y)\"` when\nthe client sent them.\n\n**Dates and timezone.** `from` and `to` are inclusive `YYYY-MM-DD` calendar\ndays interpreted in the **community's local timezone** (the timezone of its\nlatches), echoed back in `timezone`. The `datetime` on each row is local time\nfor that same zone, with no offset attached.\n\n**Window cap.** The window may span at most `max_range_days` (14) days. A\nwider request is **clamped** to the most recent allowed span rather than\nrejected: `from`/`to` are the *effective* window, `requested_from` /\n`requested_to` are what you asked for, and `clamped` is `true` when they\ndiffer — so walk a long period in ≤14-day steps. This is a cap on the\n*width* of one request, not a lookback limit: the window may sit as far back\nas you like. Paginated 1000 rows per page (`page` 0-indexed); `has_more` is\n`true` when the page came back full.\n\n**When to use this instead of `/v1/community/access-logs`.** The access log\nis the raw event feed: a rolling 90 days, UTC timestamps, no date window.\nThis endpoint is the CM-facing *report*: any window you name (including\nolder than 90 days), local-time day boundaries, and the property-type\nattribution rule applied and declared. Use the access log for incremental\nsyncing of events; use this for billing, occupancy, and audit reporting that\nmust match what the Community Manager sees.\n\nRequires the community's Access Log History setting to be on."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Member access logs",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/access-logs?window=last_30",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "access-logs"
              ],
              "query": [
                {
                  "key": "window",
                  "value": "last_30",
                  "disabled": false
                }
              ]
            },
            "description": "A member's access logs (key opens) for a 30-day window.\n\n`window` selects the range: `last_30` (default, the last 30 days), `30_60`\n(30-60 days ago), or `60_90` (60-90 days ago). Scope is the member's key(s)\nplus every descendant key (people they re-shared with); each row names the\nactual opener. Requires the community's Access Log History setting to be on.\nReads behave identically for test and live keys."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Access Codes",
      "item": [
        {
          "name": "List access codes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes"
              ]
            },
            "description": "Every access code in the community — including codes residents made\nthemselves, matching what the CM portal shows.\n\n**The PIN is never returned here.** `code_masked` is a run of asterisks and\n`code_length` its length; the cleartext code is returned exactly once, by\n`POST /v1/community/access-codes`. If you lose a PIN, delete the code and\ncreate a new one — there is no way to read it back.\n\n`api_managed` is true for the codes this API key created. Those are the\nonly ones `PATCH` and `DELETE` will touch; anything else answers 404.\n\n`access_code_mode` says which system the community runs (see\n`GET /v1/community/access-codes/mode`). In `single_entry` mode each row\nalso carries the owner's 3-letter `preamble` and `entry_code_masked` — the\npreamble in clear, the code masked (`ESM******`) — which is what the\nvisitor types. In `per_member` mode both keys are present and null."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Create an access code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes"
              ]
            },
            "description": "Mint a keypad / GuestView PIN on the key's community.\n\n**The PIN comes back exactly once — in this response.** Deliver it to the\nvisitor now; no later call will reveal it.\n\nWhat the visitor types depends on the community's access-code mode\n(`GET /v1/community/access-codes/mode`). In `per_member` mode it is `code`\nas given, after picking the code owner in the GuestView directory. In\n`single_entry` mode it is `entry_code` — the key owner's 3-letter\n`preamble` followed by the code, e.g. `ESM481502` — typed into the\ncommunity's single entry field. Hand the visitor `entry_code` in that\nmode, not `code`; it too is returned only here.\n\nThree independent limits, which do different things:\n\n* `expires_in_hours` / `expires_in_days` — **mutually exclusive**; passing\n  both is a 422. Either sets one absolute cutoff, computed **in UTC from\n  the moment of this call**. After it passes the code is dead.\n* `temporal` — a **recurring weekly window** (e.g. weekdays 09:00-17:00).\n  It is evaluated in the **gate's own local timezone** at redemption, not\n  UTC and not the caller's timezone. It never expires on its own.\n\nA code may carry both, and then must satisfy both: inside the weekly window\n*and* before the cutoff. Omitting both makes a code that works until it is\ndisabled or deleted.\n\n`latch_ids` must all be on the community's access-code allowlist\n(`/v1/community/access-codes/eligible-latches`) and must all be openable by\na single community key — if no one key covers them, split them across two\ncodes (422 `no_key_for_latches`).\n\n**Test mode** (`nimbio_test_…`): every rule above is validated and then\n*nothing is stored*. A test key can never leave a working PIN on a real\ngate, so the response is `{\"result\": \"simulated\"}` with no code id.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code\": \"481502\",\n  \"expires_in_hours\": 4,\n  \"latch_ids\": [\n    \"l1…\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List gates access codes may open",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/eligible-latches",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "eligible-latches"
              ]
            },
            "description": "The gates a community manager has allowed access codes to open.\n\nEvery `latch_ids` entry you pass to create or update must appear here, or\nthe call is rejected with `invalid_latch`. This allowlist is set in the CM\nportal only — the API can read it, not change it. `feature_enabled` mirrors\nthe community's Directory Access Codes setting; when it is false, writes\nreturn 403 `access_codes_disabled`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List access-code redemption attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/logs?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "logs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "Every access-code entry attempt in the community, newest first.\n\n`limit` is clamped to 1-200 (default 50) and `offset` floored at 0.\n`result` names the outcome — `opened`, `code_not_found`, `code_disabled`,\n`code_expired`, `temporal_blocked`, `latch_not_assigned`, `open_failed`, …\n\n`code_entered_masked` is asterisks, never the typed digits: a successful\nattempt's entered code *is* a working PIN. Correlate a row to a code you\nminted through `directory_access_code_id`, which is set whenever the entry\nmatched an existing code. Rows carry the visitor's `client_ip` and the code\nowner's name — this is the community's own log, same as the CM portal's."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get the access-code mode",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/mode",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "mode"
              ]
            },
            "description": "Which of the two access-code systems the community runs, and what a\nswitch would cost.\n\n* `per_member` — the default. A visitor at the GuestView directory finds\n  the member they are visiting, taps the lock on that row and types that\n  member's code. Codes are unique *per member*, so two members may hold\n  the same digits.\n* `single_entry` — one \"Enter access code\" field for the whole community,\n  no member picking. Every member carries a 3-letter **preamble** (A-Z,\n  derived from their name, unique within the community) and the visitor\n  types preamble followed by code, e.g. `ESM481502`. Codes minted through\n  this API carry the key owner's preamble; the list and create responses\n  report it, and `POST` returns the full `entry_code` once.\n\n`flip_preview` is a read-only dry run of `PUT /v1/community/access-codes/mode`\nto the *other* mode: `codes_to_delete` — **every** code in the community\ngoes, residents' own included, because the strings change meaning;\n`members_affected` — how many members lose at least one code and will be\nnotified; `members_to_assign_preamble` — how many members would be given\na preamble (zero when switching back to `per_member`). Read this before\nyou flip, so you can show the cost to a human first.\n\nThe mode is configuration, not a feature: it can be read (and switched)\nwhile the community's Directory Access Codes feature is off, which is how\na manager picks the system before turning codes on."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Switch the access-code mode",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/mode",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "mode"
              ]
            },
            "description": "Switch the community between `per_member` and `single_entry` codes.\n\n**This is destructive.** Switching in either direction deletes **every**\naccess code in the community — the ones this key minted, the ones other\nintegrations minted, and the ones residents made for themselves in the\napp — because a code's meaning changes with the mode (`481502` versus\n`ESM481502`). Every member who lost a code is notified by push and a\ncommunity-wide message explains the new system. Switching to\n`single_entry` also assigns a preamble to every member who lacks one.\nRedemption log rows are kept.\n\nSo the call is a two-step handshake, the same one the NFC-tag writes use:\n\n1. Send `{\"mode\": \"single_entry\"}`. If the community is already in that\n   mode you get `200` with `changed: false` and nothing happens. Otherwise\n   you get **`409 requires_confirmation`** and `error.preview` — the same\n   counts as `GET /v1/community/access-codes/mode` reports — and, again,\n   nothing happens.\n2. Show the preview to a human, then re-send with `\"confirm\": true`. The\n   live response reports `deleted_codes` and `notified_members`.\n\n`confirm` is only honoured as the JSON boolean `true`; a string is not.\nA caller that always sends `confirm: true` has opted out of the safety\nnet — do that only behind your own confirmation step.\n\n`mode` is also reported, read-only, as `read_only.access_code_mode` in\n`GET /v1/community/settings`; sending it to `PATCH /v1/community/settings`\nis refused with 422, precisely so a generic settings write can never wipe\nevery code by accident.\n\nThe mode can be switched while the Directory Access Codes feature is off\n(there is nothing to delete then, and it lets you pick the system before\nenabling codes) — this endpoint does not answer `access_codes_disabled`.\n\n**Test mode** (`nimbio_test_…`): the 409 handshake runs exactly as above,\nand `confirm: true` then answers `{\"result\": \"simulated\", \"would_change\":\n{...}}` — the same preview — without deleting or notifying anything,\nunless the community is already in that mode, which answers `result: ok`,\n`changed: false` exactly as a live key would.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"confirm\": true,\n  \"mode\": \"single_entry\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete an access code",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/{{directory_access_code_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "{{directory_access_code_id}}"
              ]
            },
            "description": "Delete a code **this key created**. The PIN stops working immediately\nand its gate assignments go with it; the redemption log rows stay, so the\nhistory of who came through is preserved.\n\nA resident's code, or a code in another community, answers 404. Test mode\nvalidates and deletes nothing."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Update an access code",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/access-codes/{{directory_access_code_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "access-codes",
                "{{directory_access_code_id}}"
              ]
            },
            "description": "Change a code **this key created**: switch it off after the visit\n(`disabled: true`), re-point it at different gates, push out its cutoff, or\nreplace its weekly window.\n\nA code created by a resident in the app, or one belonging to another\ncommunity, answers 404 — this endpoint does not reach either. Use the CM\nportal for a resident's own PIN.\n\nThe PIN itself cannot be changed; delete the code and create a new one.\nOmitted fields are left as they are, and there is no way to *clear* an\nexisting cutoff or weekly window — to stop a code working, disable or\ndelete it. Sending an empty body is a 400.\n\nExpiry and `temporal` mean exactly what they mean on create: a UTC cutoff\nrecomputed from *now*, and a recurring window evaluated in the gate's local\ntimezone. Test mode validates and changes nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"disabled\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Guest Links",
      "item": [
        {
          "name": "List guest links",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-links?include_inactive=True",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-links"
              ],
              "query": [
                {
                  "key": "include_inactive",
                  "value": "True",
                  "disabled": false
                }
              ]
            },
            "description": "Every guest link on the key's community, newest first.\n\n`state` is computed live and is one of `active`, `upcoming` (an event link\nwhose window has not opened yet), `expired`, `spent` (a limited-use link\nthat ran out of opens), `revoked`, or `feature_disabled`.\n\nThe `token` and the ready-to-send `url` are listed here, not only at\ncreation — a link recovered from this list is exactly as usable as the one\nthe create call returned, so treat this response as secret material."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Create a guest link",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-links",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-links"
              ]
            },
            "description": "Mint a guest link on the key's community and return it with its URL.\n\n`link_type` is `event` or `limited_use`, and the two take different limits:\n\n- **event** — requires `title`, `window_start` and `window_end`. Live\n  between those two instants, unlimited opens inside them. The window may\n  not exceed the community's maximum (8 hours unless the community raised\n  it) and may not have already ended.\n- **limited_use** — requires `max_uses` (1 to the community's cap, 20 by\n  default). Optional `expires_at` is a wall-clock backstop; it defaults to,\n  and may not exceed, 30 days from now.\n\nAll datetimes are ISO-8601. An offset (including `Z`) is honored; a naive\nstring is read as **UTC** — communities carry no timezone of their own.\nEverything returned is UTC.\n\n`latch_ids` must all be opened by the backing key and none may appear in\n`/v1/community/guest-links/latch-exclusions` for this link type.\n\n`key_id` names the community key behind the link. It may be omitted only\nwhen the community has exactly one; with several you must choose, because\nthe choice decides which gates the link could ever reach.\n\nThe response carries `token` and `url` — the URL is what you hand the\nguest. It is not one-time: `GET /v1/community/guest-links` returns it\nagain, so a lost URL is recoverable and a leaked list is a leaked key.\n\n**Test mode:** every rule above is checked and then nothing is created —\n`{\"result\": \"simulated\"}` with no token, no URL, and no new row on the\nlist. A test key can never hand someone a working gate.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"expires_at\": \"2026-08-25T11:00:00Z\",\n  \"latch_ids\": [\n    \"{{latch_id}}\"\n  ],\n  \"link_type\": \"limited_use\",\n  \"max_uses\": 6,\n  \"title\": \"Booking 8841 — Unit 4\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Gates excluded from guest links",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-links/latch-exclusions",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-links",
                "latch-exclusions"
              ]
            },
            "description": "Gates the community has taken out of each link type.\n\nAbsence is permission: a gate that is not listed may back a link of that\ntype, so an empty answer means every gate the community key opens is\nofferable. Check this before creating — naming an excluded gate is a 422,\nand the exclusion list is per link type, so a gate barred from `event`\nlinks may still be fine for `limited_use` ones."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Guest-link attempt history",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-links/logs?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-links",
                "logs"
              ],
              "query": [
                {
                  "key": "guest_link_id",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "Every attempt against the community's guest links, newest first —\nsuccesses, refusals, and rate-limited hits alike.\n\n**This is guest PII.** Rows carry the redeemer's IP address and user agent,\nand the display name of a signed-in account when the community requires one.\nHandle and retain accordingly.\n\n`guest_link_id` narrows to one link; a link belonging to another community\nis a 404, the same answer an unknown id gets."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Revoke a guest link",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-links/{{guest_link_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-links",
                "{{guest_link_id}}"
              ]
            },
            "description": "Kill a guest link. **Terminal — there is no un-revoke**; mint a new link\ninstead. Revoking an already-revoked link is a no-op success.\n\nThe id must belong to this key's community. One that does not is a 404,\nidentical to an unknown id — an API key cannot probe another community's\nids.\n\nTest mode: reports what it would have revoked and leaves the link live."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · GuestView Entry",
      "item": [
        {
          "name": "Read GuestView Entry settings",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry"
              ]
            },
            "description": "The complete GuestView Entry configuration in one read.\n\n- `allowed` — the community master switch.\n- `eligible_latches` — the gates a visitor may open. Nothing outside this\n  set is reachable via GuestView Entry, whatever the schedule says.\n- `schedule` — the recurring windows. **Each window is evaluated in its\n  latch's own local timezone**, not UTC and not yours. A latch with NO\n  windows is available to guests at any hour; the first window you add is\n  what starts restricting it.\n- `directory_viewing_enabled` — read-only context: whether the GuestView\n  *directory listing* is on. That is a separate feature; guest entry does\n  not require it.\n\nQuota-exempt (the per-minute limit still applies)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Turn GuestView Entry on or off",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry"
              ]
            },
            "description": "Permit or forbid GuestView Entry for the community.\n\nAn explicit setter, deliberately not a toggle: this endpoint is retryable,\nand a retried toggle would flip a gate's guest access back open. Sending\nthe value it already has is a successful no-op.\n\nTurning it off leaves the eligible-latch set and the schedule intact, so\nturning it back on restores the same configuration. Returns the full\nupdated settings, so no follow-up GET is needed. Test-mode keys validate\nand report but change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"allowed\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Replace the gates GuestView Entry covers",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry/eligible-latches",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry",
                "eligible-latches"
              ]
            },
            "description": "Set which gates a GuestView visitor may open.\n\n**This REPLACES the whole set** — that is why it is a PUT. Send the\ncomplete list every time: any currently-eligible latch you leave out loses\nguest eligibility, and **its schedule windows are deleted along with it**\n(an ineligible latch never opens, so its windows would be dead rows).\nSending `[]` removes GuestView Entry from every gate. A caller that sends\na partial list silently removes the latches it omitted.\n\nEvery id must belong to this key's community; a foreign latch is a 404 and\nnothing is written. Requires GuestView Entry to be switched on (403\n`guest_view_entry_disabled` otherwise). Returns the full updated settings.\nTest-mode keys validate and report but change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"latch_ids\": [\n    \"{{latch_id}}\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Read GuestView Entry attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry/logs?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry",
                "logs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                },
                {
                  "key": "success",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "Every GuestView Entry attempt on this community, newest first.\n\n**These rows identify visitors**: name, phone number and client IP of the\nperson who stood at the gate, alongside the result (`opened`, or a failure\nreason such as `outside_schedule`, `latch_not_eligible`, `rate_limited`,\n`scan_required`). Handle and retain them accordingly.\n\n`limit` is clamped to 1–200 (default 50) and `offset` floors at 0;\n`has_more` is true when the page came back full. Readable whether or not\nGuestView Entry is currently switched on, so history survives the feature\nbeing turned off. Quota-exempt (the per-minute limit still applies)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add a GuestView Entry window",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry/schedule",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry",
                "schedule"
              ]
            },
            "description": "Add one recurring window during which GuestView Entry is permitted.\n\n**Timezone: the window is evaluated in each LATCH's own local timezone**,\nat the moment a visitor taps open — never UTC, never the caller's, never\nthe community's. `09:00`–`17:00` on a latch in Los Angeles means 9-to-5\nPacific, and the same window applied to a latch in New York means 9-to-5\nEastern. This is the same rule hold-open windows and quiet hours follow.\nA latch whose timezone cannot be resolved skips the window check and stays\navailable around the clock.\n\n`days_of_the_week` is a string of `MTWHFSU` letters — **H is tHursday and\nU is sUnday** — with no repeats and at least one day. `start_time` and\n`end_time` are `'HH:MM'` 24-hour strings between `00:00` and `23:59`, or\nnull for an unbounded end. An `end_time` earlier than `start_time` wraps\npast midnight, and the days name the day the window **starts**.\n\nThe window is applied to every latch in `latch_ids` and stored as one row\nper latch, so each gets its own `schedule_id`. Those latches must already\nbe eligible (`PUT eligible-latches` first) — 422 `latch_not_eligible`\notherwise; a latch outside the community is a 404.\n\nRemember a latch with **no** windows is available to guests at any hour;\nadding the first window is what starts restricting it. Returns the full\nupdated settings. Test-mode keys validate and report but change nothing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"days_of_the_week\": \"MTWHF\",\n  \"end_time\": \"17:00\",\n  \"latch_ids\": [\n    \"{{latch_id}}\"\n  ],\n  \"start_time\": \"09:00\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Remove a GuestView Entry window",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/guest-view-entry/schedule/{{schedule_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "guest-view-entry",
                "schedule",
                "{{schedule_id}}"
              ]
            },
            "description": "Delete one GuestView Entry window, by the `schedule_id` from the\nsettings read.\n\n**Removing a window widens access**, and removing a latch's *last* window\nmakes that gate available to guests at any hour — the schedule is a\nwhitelist of permitted times, not a blacklist. Treat this like any other\npermission-widening write.\n\n**Timezone reminder:** the windows you are choosing between are evaluated\nin each latch's own local timezone, so read the settings first and delete\nby id rather than reasoning about times in your own zone.\n\nAn id belonging to another community is a 404 and nothing is deleted.\nReturns the full updated settings. Test-mode keys validate and report but\nchange nothing."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Hold Opens",
      "item": [
        {
          "name": "Get hold opens",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/hold-opens",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "hold-opens"
              ]
            },
            "description": "Hold-open state per latch: `held_open` (any source), the `manual` flag,\none-time `events`, `recurring` schedules, and `disabled_until`.\nQuota-exempt: never counts against the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Set indefinite hold open",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open"
              ]
            },
            "description": "Turn the \"hold open until turned off\" state on or off for a latch.\n\nImplemented as the CM portal's indefinite hold-open window, so a hold\nstarted here appears on the portal's Hold Opens page (and can be ended\nthere with \"End Hold Open\") — and a portal-started indefinite hold can be\nended here with `state: false`. The switch semantic: `manual` is true\nwhile an indefinite hold is active (from either source); `held_open` is\nthe combined truth (indefinite OR an active timed/recurring window).\nTurning the toggle off does not cancel an active timed window.\nTurning it on requires the latch to have a configured timezone.\nTest mode: validated, never moves the gate.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"state\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Suspend or resume a latch's hold opens",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/disabled-until",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "disabled-until"
              ]
            },
            "description": "Temporarily suspend **every** scheduled hold open on a latch — the\nholiday / special-event override — or resume them.\n\nWhile the suspension is in force the scheduler skips all recurring and\none-time windows on this latch and releases any hold already active. It\nclears itself once the moment passes; send `until: null` to resume sooner.\nThe value is what GET /v1/community/hold-opens reports as `disabled_until`.\n\n**Timezone:** `until` is **the latch's local time** (the `timezone` from\nGET /v1/community/hold-opens), never UTC.\n\nPer latch, not per community: suspending one gate's schedules leaves the\nothers running. `until` is required — send an explicit `null` to resume,\nso an empty body can never resume by accident.\nTest mode: validated, nothing is suspended.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"until\": \"2026-12-25 23:59\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add a timed hold-open window",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/events",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "events"
              ]
            },
            "description": "Add a one-time hold-open window to a latch (e.g. \"hold open for the next\nhour\"). Returns the `event_id` needed to end the window early via DELETE.\nDatetimes are naive local time for the latch's timezone.\nTest mode: validated, nothing is scheduled.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"end\": \"2026-08-01 17:00\",\n  \"start\": \"2026-08-01 09:00\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Remove a timed hold-open window",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/events/{{event_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "events",
                "{{event_id}}"
              ]
            },
            "description": "Remove a one-time hold-open window early. Idempotent: re-removing an\nalready-gone window returns `removed: false` instead of an error.\nTest mode: validated, nothing is removed."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add a recurring hold-open schedule",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/recurring",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "recurring"
              ]
            },
            "description": "Hold a gate open on a repeating weekly schedule — business hours, trash\nday, a standing delivery window.\n\n**Timezone:** times are **the latch's local time**, the `timezone` GET\n/v1/community/hold-opens reports for that latch. Never UTC, and no offset\nis accepted: `\"08:00\"` means 08:00 at the gate.\n\n**Days:** letters from `MTWHFSU`, where **H is Thursday, S is Saturday and\nU is Sunday** — the same string the read emits.\n\nOmit `start_time` and `end_time` for an **all-day** schedule (held open for\nthe whole of each selected day). A window may not wrap past midnight;\nsplit `22:00–06:00` into `22:00–24:00` plus `00:00–06:00` on the following\nday.\n\nReturns the `temporal_date_id` used to change or delete the schedule — the\nsame id that appears under `recurring[]` in GET /v1/community/hold-opens.\nTest mode: validated, nothing is scheduled.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"days_of_the_week\": \"MTWHF\",\n  \"end_time\": \"09:00\",\n  \"recurring_week\": 1,\n  \"start_time\": \"08:00\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete a recurring hold-open schedule",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/recurring/{{temporal_date_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "recurring",
                "{{temporal_date_id}}"
              ]
            },
            "description": "Delete a recurring hold-open schedule.\n\nDeliberately **not** idempotent: a `temporal_date_id` that is not on this\nlatch returns 404 rather than a cheerful success, because silently\nsucceeding would let a caller believe it had cancelled a schedule that is\nstill holding a gate open.\nTest mode: validated, nothing is deleted."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Change a recurring hold-open schedule",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/hold-open/recurring/{{temporal_date_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "hold-open",
                "recurring",
                "{{temporal_date_id}}"
              ]
            },
            "description": "Change an existing recurring schedule. Partial update: any field left\nout keeps its current value.\n\n**Timezone:** times are **the latch's local time** (the `timezone` from GET\n/v1/community/hold-opens), never UTC. **Days** are `MTWHFSU` letters —\nH=Thursday, S=Saturday, U=Sunday.\n\nTime handling is explicit rather than inferred:\n\n* `start_time` + `end_time` **replace** the schedule's window,\n* `clear_times: true` removes the window, making the schedule all-day,\n* sending neither leaves the times alone.\n\nTest mode: validated, nothing changes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"days_of_the_week\": \"MTWHFS\",\n  \"end_time\": \"09:30\",\n  \"start_time\": \"07:30\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Homes",
      "item": [
        {
          "name": "List homes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/homes?include_hidden=True",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "homes"
              ],
              "query": [
                {
                  "key": "include_hidden",
                  "value": "True",
                  "disabled": false
                }
              ]
            },
            "description": "Every home (unit) in the key's community, with its resident count,\nsorted by address."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add a home",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/homes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "homes"
              ]
            },
            "description": "Add a home (unit) to the key's community.\n\nReturns the created home as a full resource — id, address, flags, and\nresident count — so a roster sync can record it without a second call.\nTest mode: the address is validated, nothing is created.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"home_address\": \"12 Elm St, Unit 3\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Remove a home (DETACHES its residents)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/homes/{{home_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "homes",
                "{{home_id}}"
              ]
            },
            "description": "Remove a home (unit) from the community.\n\n**This detaches every resident attached to the home.** They remain members of the community and keep their keys, but they are no longer associated with any unit, and nothing restores the association automatically — you would have to re-add the home and re-attach each resident by hand. The response reports `detached_member_count` so you can see how many were affected; call `GET /v1/community/homes/{home_id}` first if you want to know before you commit. Test mode reports the count it would detach and changes nothing."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get a home",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/homes/{{home_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "homes",
                "{{home_id}}"
              ]
            },
            "description": "One home in the key's community, plus its residents and their move-out\ndates. A home belonging to another community reads as 404, the same as one\nthat does not exist."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Update a home",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/homes/{{home_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "homes",
                "{{home_id}}"
              ]
            },
            "description": "Edit a home: any of `home_address`, `owner_occupied`, `hidden`. A field\nyou omit is left alone, so you can change one without reading the rest.\n\n`hidden` is a setter, not a toggle: `{\"hidden\": true}` sent twice leaves the\nhome hidden. Test mode: validated, nothing changes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"owner_occupied\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Set a member's move-out date",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/members/{{account_community_id}}/move-out-date",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "members",
                "{{account_community_id}}",
                "move-out-date"
              ]
            },
            "description": "Record (or clear) a member's move-out date, so their access lapses\nwithout anyone remembering to revoke it. Send `{\"move_out_date\": null}` to\nclear a date. The member must belong to the key's community. Test mode:\nvalidated, nothing changes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"move_out_date\": \"2026-12-31\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Access Schedules",
      "item": [
        {
          "name": "List access schedules",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/key-schedules",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "key-schedules"
              ]
            },
            "description": "Access schedules for the community's own keys.\n\nReturns the community's key(s) — always, so the community-wide restriction\ncan be seen and set even when unset — and nothing else. Revoked and hidden\ncommunity keys are omitted.\n\n**Member keys are not listed, and cannot be scheduled through this API.** A\nschedule on the community key *is* the community-wide rule: it cascades to\nevery member key beneath it. An individual member's own schedule is not a\ncommunity manager's to read or rewrite, so the single-key endpoints refuse\none with `not_a_community_key`. Use `descendant_key_count` to see how many\nmembers a change would reach.\n\nEach entry's `windows` holds only the windows **in force today** — a window\nwhose date range has passed is omitted and counted in\n`inactive_window_count` instead, so an expired schedule is never rendered as\n\"no restriction\". `GET /v1/community/keys/{key_id}/schedule` returns every\nwindow, expired ones included, because a write replaces the whole schedule.\n\n`restricted` is true when the key is genuinely time-limited.\n`permanently_blocked` is true for a key that has windows saved but the\nrestriction switched off — that key is denied at **every** hour of every\nday, which is a fault to repair rather than a working schedule. Saving a\nschedule through this API clears that state.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get one key's access schedule",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/keys/{{key_id}}/schedule",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "keys",
                "{{key_id}}",
                "schedule"
              ]
            },
            "description": "One community key's access schedule.\n\nThe key must be one of **this community's own keys**. A member key beneath\nit — even a member of this same community — is refused with\n`not_a_community_key` (403): a member's individual schedule is not a\ncommunity manager's to read. A key belonging to another community is refused\nas `not_in_scope`.\n\nReturns **every** window, including any whose date range has passed, because\n`PUT` replaces the whole schedule and a caller must see what it is about to\noverwrite. The list endpoint filters those out instead.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Replace a key's access schedule",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/keys/{{key_id}}/schedule",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "keys",
                "{{key_id}}",
                "schedule"
              ]
            },
            "description": "Replace a community key's access schedule.\n\nSend the **complete** schedule: the windows you send become the whole\nschedule, and `\"windows\": []` removes the restriction entirely so the key\nmay open at any time.\n\n**Community keys only.** A member key is refused with `not_a_community_key`\n(403), even one belonging to this community — an individual member's\nschedule is not a community manager's to rewrite. The community-wide rule is\nwhat this API sets, and it applies to every member key beneath the community\nkey, so check `descendant_key_count` (live members only) before writing one.\nA schedule applies to every gate the key opens; there is no per-gate\noverride.\n\nWindows are evaluated in each gate's own local time and cannot run past\nmidnight — for overnight access send two windows, the first ending at\n`24:00` and the second starting at `00:00` on the following day. Use\n`24:00` rather than `23:59`: the comparison is `now < end`, so `23:59`\nexcludes 23:59:00–23:59:59 and the two halves would leave a one-minute gap\nevery night. `24:00` is valid as an end only, and reads back as `24:00`.\n\nTest mode: validated and scope-checked, never changes who can open a gate.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"windows\": [\n    {\n      \"days_of_the_week\": \"MTWHF\",\n      \"end_time\": \"18:00\",\n      \"start_time\": \"06:00\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Map & Geofences",
      "item": [
        {
          "name": "Update a gate's geofence",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/latches/{{latch_id}}/geofence",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "latches",
                "{{latch_id}}",
                "geofence"
              ]
            },
            "description": "Change one gate's proximity geofence.\n\n**Partial update.** Only the fields you send change; everything you omit\nkeeps its stored value. That is why this is a `PATCH` — there is no way to\nclear a configured centre here, because doing so by accident would stop a\nreal gate's proximity behaviour with nothing to show for it.\n\n`latitude` and `longitude` move together: send both or neither. They are\nWGS84 decimal degrees, and out-of-range values are rejected rather than\nstored.\n\n`radius_meters` is in **metres** and must be at least `min_radius_meters`\n(100). A smaller radius is **rejected with 422, not silently raised** —\nAndroid's Geofence API and iOS region monitoring both degrade below ~100 m,\nso a smaller fence would look configured and never fire. The response always\nechoes the effective `radius_meters`, so you can confirm what is in force.\n\n`mode` is `prompt` (the app notifies the member when they arrive, and they\ntap to open) or `auto_open` (the app opens the gate on arrival). `enabled`\nturns the fence on or off; enabling requires a centre — the gate's own, or\nits device's location as a fallback.\n\nThe change is stamped with your key's owning account and the time, and shows\nup on the CM portal's Map screen. Test mode: validated and scope-checked,\nbut a real gate's geofence never moves.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": true,\n  \"latitude\": 36.1699,\n  \"longitude\": -115.1398,\n  \"mode\": \"prompt\",\n  \"radius_meters\": 150\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Read the community map and gate geofences",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/map",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "map"
              ]
            },
            "description": "Where this community's gates are, and the geofence each one advertises.\n\nReturns the community centre, one entry per Nimbio device (`box`) with its\nown location, and every gate on that device with its geofence config.\nCoordinates are **WGS84 decimal degrees** (`latitude`, `longitude`);\n`radius_meters` is **metres**.\n\nA gate with `center: null` has never had a fence configured — use its\n`box_location` as the suggested centre. `min_radius_meters` is the smallest\nradius the mobile apps can honour, and `geofence_modes` is the legal set for\n`mode`.\n\nGate coordinates are physical-security information: this response says\nexactly where a property's entrances are. It is scoped to your community\nkey, and treating it as sensitive downstream is on you.\n\nThis read consumes monthly quota (unlike gate status): a map is setup-time\nconfiguration, not something to poll."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · My Notifications",
      "item": [
        {
          "name": "Get my member-open notification settings",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/my-notification-settings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "my-notification-settings"
              ]
            },
            "description": "Member-open notification settings for **the community manager who owns\nthis API key** — not a community-wide setting.\n\nA community may have several managers, each with their own alert preference\nand their own quiet hours. This endpoint always reads the settings of the\naccount that owns the key you authenticated with, so two keys owned by two\nmanagers of the same community return two different objects here.\n\n`enabled` is whether that manager receives a push notification when one of\nthe community's members opens a gate. `feature_available` is whether the\ncommunity allows the feature at all — when it is false the writes below all\nreturn 403, and `enabled` has no effect.\n\n`quiet_hours` are the windows during which that manager's alerts are\nsuppressed. **Each window is evaluated in the local timezone of the gate\nthat was opened**, not the manager's timezone and not UTC — a community with\ngates in two timezones suppresses each gate's alerts on that gate's clock.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Turn my member-open alerts on or off",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/my-notification-settings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "my-notification-settings"
              ]
            },
            "description": "Turn member-open alerts on or off for **the community manager who owns\nthis API key** — not for the community, and not for its other managers.\n\nReturns the full updated settings object, including the quiet-hours list,\nso no follow-up GET is needed.\n\nRequires the community's member-open notifications feature to be enabled\n(`feature_available`); otherwise 403 `open_notifications_disabled`.\n\nTest mode: validated and scope-checked, never saved — matching the other\nconfiguration writes on this surface (`result` comes back `\"simulated\"`).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Add one of my quiet-hours windows",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/my-notification-settings/quiet-hours",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "my-notification-settings",
                "quiet-hours"
              ]
            },
            "description": "Add one quiet-hours window to **the settings of the community manager\nwho owns this API key** — it suppresses that manager's alerts only, not\nthose of the community's other managers.\n\nThis is additive: each call appends one window. To replace a schedule,\ndelete the windows you no longer want.\n\n**Timezone:** a window is evaluated in the local timezone of the gate that\nwas opened (each device carries its own), not the manager's timezone and\nnot UTC. A window with gates in two timezones therefore suppresses each\ngate's alerts on that gate's own clock. Getting this backwards is how a\nwindow silently fails to suppress real alerts, so it is worth stating in\nyour own integration's UI too.\n\nTimes are `'HH:MM'`, 24-hour. `start_time` later than `end_time` wraps past\nmidnight — `22:00` to `06:00` is one window, and its `days_of_the_week`\nnames the day the window *starts* on. Omit both times for an all-day window\non the named days. `'24:00'` is not accepted here (it is a key-schedule\nidiom, needed there only because schedules cannot wrap).\n\nReturns the full updated settings object. Requires the community's\nmember-open notifications feature to be enabled; otherwise 403. Test mode\nvalidates but never saves.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"days_of_the_week\": \"MTWHF\",\n  \"end_time\": \"06:00\",\n  \"start_time\": \"22:00\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete one of my quiet-hours windows",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/my-notification-settings/quiet-hours/{{quiet_hours_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "my-notification-settings",
                "quiet-hours",
                "{{quiet_hours_id}}"
              ]
            },
            "description": "Delete one quiet-hours window from **the settings of the community\nmanager who owns this API key**.\n\nThe window must belong to that manager. An id belonging to a different\nmanager — including another manager of the same community — returns 404\nand deletes nothing; the API deliberately does not distinguish \"not yours\"\nfrom \"does not exist\".\n\nReturns the full updated settings object. Test mode validates ownership but\nnever deletes."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · NFC Tags",
      "item": [
        {
          "name": "List NFC tags",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/nfc-tags?page=1&results_per_page=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "nfc-tags"
              ],
              "query": [
                {
                  "key": "search",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "page",
                  "value": "1",
                  "disabled": false
                },
                {
                  "key": "results_per_page",
                  "value": "50",
                  "disabled": false
                }
              ]
            },
            "description": "Every NFC tag issued to the key's community, newest first.\n\nAlways returns the `{items, page, results_per_page, total}` envelope — the\nshape does not change with the query parameters. `page` is 1-based and\nfloored at 1; `results_per_page` is clamped to 1..200.\n\nCryptographic tag material is never returned by this API. `tag_uid_hex` is\nthe fob's physical (publicly readable) UID, not a secret, and is how you\njoin a tag to its rows in the scan log."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Read the NFC scan log",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/nfc-tags/scan-log?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "nfc-tags",
                "scan-log"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                },
                {
                  "key": "result",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "tag_uid_hex",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "Every NFC tap recorded for the key's community, newest first.\n\n`limit` is clamped to 1..200 and `offset` floored at 0. Rows carry the\ntapping member's first and last name where the tap resolved to one, so a\nsecurity review can attribute a tap to a person; the device's IP address\nand Nimbio-internal record ids are not exposed."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get one NFC tag",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/nfc-tags/{{tag_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "nfc-tags",
                "{{tag_id}}"
              ]
            },
            "description": "One tag of the key's community. A tag belonging to another community is\nreported as not found — the API never confirms that it exists."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Assign, unassign, or disable an NFC tag",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/nfc-tags/{{tag_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "nfc-tags",
                "{{tag_id}}"
              ]
            },
            "description": "Edit one tag: bind it to a gate, detach it, or disable/re-enable it.\n\nSend `disabled` and/or `latch_id`; at least one is required.\n\n* `{\"disabled\": true}` — kill a lost fob. **This takes effect for physical\n  gate taps**: the next tap on any gate is refused.\n* `{\"disabled\": false}` — revive it.\n* `{\"latch_id\": \"<gate uuid>\"}` — bind the tag to one of the community's\n  gates. A tag opens the gate it is bound to, not a member's key.\n* `{\"latch_id\": null}` — detach it. The tag stays in your community and can\n  be re-assigned later.\n\n`disabled` is an explicit setter, never a toggle: revoking a stolen fob must\nnot depend on state you read a moment ago. Sending `disabled: true` together\nwith a non-null `latch_id` is rejected — a dead fob cannot be routed to a\ngate. When both fields are sent, `disabled` is applied first, so\n`{\"disabled\": false, \"latch_id\": \"…\"}` revives and then binds in one call.\n\nA 409 means this write would leave a Scan Only gate with no working tag.\nIt is a warning, not a veto — repeat with `\"confirm\": true` to proceed\n(revoking a stolen fob has to stay possible), or bind another tag first.\n\nTest-mode keys validate everything and return `simulated`; they never\nenable or disable a real physical credential.\n\nProgramming a blank fob is a separate, operator-side job done with the\n`nimbio nfc` tooling and a card reader — this endpoint manages tags that\nalready exist.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"disabled\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Devices",
      "item": [
        {
          "name": "List sense lines",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/sense-lines",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "sense-lines"
              ],
              "query": [
                {
                  "key": "box_id",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "Every sense line on the community's boxes.\n\nA **sense line** is a physical input on a Nimbio box that reports whether a\ngate actually moved — the feedback loop behind `GET /v1/community/gate-status`,\nthe gate-status-change log, and the `sense_line.changed` webhook event.\n\nEach sense line carries two configuration flags:\n\n* **`sense_line_online`** — whether this input is switched on. When it is\n  off, the box's raw transitions are still recorded (see\n  `GET /v1/community/sense-lines/records`) but the server acts on none of\n  them, and this input is not considered when deciding a gate's status.\n* **`latch_data_online`** — whether this input's readings are allowed to\n  drive the gate status reported to the apps and to this API.\n\n**`reporting`** is the derived answer you usually want: it is true only when\nboth flags are on, which is exactly the condition the backend requires\nbefore a transition updates gate status or fires `sense_line.changed`. A\ngate stuck on one status with `reporting: false` is a configuration problem,\nnot a stuck gate.\n\n`boxes` lists the boxes in scope with their latches, using the same\n`latch_id` / `latch_name` vocabulary as `GET /v1/community/gate-status`.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List observed sense-line transitions",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/sense-lines/records?limit=50&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "sense-lines",
                "records"
              ],
              "query": [
                {
                  "key": "box_id",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "sense_line_id",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                },
                {
                  "key": "offset",
                  "value": "0",
                  "disabled": false
                }
              ]
            },
            "description": "What the boxes actually reported, newest first.\n\nThese are the **raw** transitions read off the physical inputs, not the\nderived gate status. Two properties make this the endpoint to reach for when\na gate misreports:\n\n* Records are written **even when a sense line is switched off**, so a\n  healthy stream here alongside a stale `GET /v1/community/gate-status` is\n  proof the wiring is fine and the configuration is not.\n* `status` is the configured label for that `(sense_line_id, state)` pair,\n  or `null` when the state has no configured meaning — an unmapped state is\n  itself a finding, so these rows are returned rather than dropped.\n\nFor the human-readable, latch-oriented version of the same history, see\n`GET /v1/community/gate-status-log`.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get one sense line",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/sense-lines/{{sense_line_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "sense-lines",
                "{{sense_line_id}}"
              ],
              "query": [
                {
                  "key": "box_id",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "One sense line, with everything needed to explain a gate's status.\n\nBeyond the two flags (see `GET /v1/community/sense-lines` for what\n`sense_line_online` and `latch_data_online` mean), this returns:\n\n* **`status_map`** — the configured meaning of each raw state on this input:\n  which latch the state describes, the label the apps show for it\n  (`\"Open\"` / `\"Closed\"` / `\"Locked\"` …), and `transient_ms`, a non-zero\n  value meaning the label is shown briefly and then reverts. This mapping is\n  set when the hardware is installed and is **read-only** here.\n* **`last_record`** — the most recent transition the box actually reported.\n  A `last_record` that is weeks old on a gate that moves daily points at the\n  wiring or the box, not at the configuration.\n\n`box_id` is required: sense line ids are input numbers unique to a box, so\n\"sense line 1\" is ambiguous without it.\n\nDoes not consume the monthly quota."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Reconfigure a sense line",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/sense-lines/{{sense_line_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "sense-lines",
                "{{sense_line_id}}"
              ],
              "query": [
                {
                  "key": "box_id",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "description": "Switch a sense line's two flags on or off.\n\nSend only the flags you want to change — an omitted flag is left exactly as\nit was. Each value is an **explicit set**, never a toggle, so re-sending the\nsame request is a no-op.\n\n**This changes what a real gate reports.** Turning `sense_line_online` or\n`latch_data_online` off makes the gate's status freeze at its last known\nvalue for every app, this API's `GET /v1/community/gate-status`, and the\n`sense_line.changed` webhook; hold-open logic that reads gate state is\naffected too. Turning a miswired input off is a legitimate repair — do it\nknowing the gate will stop reporting, not by accident.\n\nThe state → label wiring (`status_map`) and the creation of sense lines are\ninstaller/admin operations and are not writable here.\n\nReturns the sense line as it now stands. Test mode (`nimbio_test_…`) is\nvalidated and scope-checked but never reconfigures real hardware — the\nresponse echoes `would_set` instead.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"latch_data_online\": true,\n  \"sense_line_online\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Settings",
      "item": [
        {
          "name": "Get community settings",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/settings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "settings"
              ]
            },
            "description": "Read the key's community configuration.\n\n`settings` holds everything you may change with `PATCH\n/v1/community/settings`. `read_only` holds the flags Nimbio provisions for\nyou — you can *detect* them here, which is the point: several endpoint\nfamilies are gated on them, and without this read the only way to discover\na feature is off is to call it and get a 403.\n\n  * `read_only.allow_hold_opens` false -> `/v1/community/hold-opens` and the\n    hold-open writes return 403.\n  * `read_only.is_open_log_history_enabled` false -> `/v1/community/access-log`\n    and `/v1/community/gate-status-log` return 403.\n  * `read_only.event_keys_enabled` is the *resolved* answer for event keys:\n    `settings.event_keys_override` ('inherit'/'allow'/'deny') combined with\n    the property type's default.\n  * `read_only.access_code_mode` (`per_member` / `single_entry`) is the\n    one read-only value an API key *can* change — but only through\n    `PUT /v1/community/access-codes/mode`, because switching it deletes\n    every access code in the community and needs an explicit confirm.\n\nTo change any other read-only flag, contact Nimbio — an API key cannot\nenable a feature the business provisions."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Update community settings",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/settings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "settings"
              ]
            },
            "description": "Change the key's community configuration — a partial update.\n\nSend `{\"settings\": {...}}` with only the keys you want to change;\neverything else is left alone. The whole patch is validated before anything\nis written, so a batch containing one bad value applies **nothing** — you\nnever have to reason about a half-applied profile. An unknown key is\nrejected with a 422 naming it, never silently ignored.\n\n**These settings change what the rest of the API does.** Turning\n`allow_directory_viewing` off makes the directory disappear for members and\nguests. Setting `event_keys_override` to `deny` stops event keys working\nfor the whole community regardless of the property type's default. The\nread-only counterparts behave the same way in reverse: with\n`allow_hold_opens` false, `/v1/community/hold-opens` returns 403 — but you\ncannot change that one here. Nor `access_code_mode`: sending it here is a\n422 pointing you at `PUT /v1/community/access-codes/mode`, which is\ndestructive (it deletes every access code) and so demands its own\nconfirmation step rather than riding along in a settings batch.\n\nTerminology labels are free text shown to members. A custom label and a\npicker option are mutually exclusive per side: setting one clears the\nother. Send an empty string or null to clear a custom label back to the\ndefault. Labels are capped at 255 characters (icons at 64) and may not\ncontain control characters.\n\nTest-mode keys (`nimbio_test_…`) validate the whole patch and return\n`result: simulated` with the community's *unchanged* settings — real\ncommunity configuration is never touched by a test key.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"settings\": {\n    \"allow_directory_viewing\": true,\n    \"event_keys_override\": \"allow\",\n    \"member_term_custom\": \"Tenant\",\n    \"member_term_custom_plural\": \"Tenants\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Short Codes",
      "item": [
        {
          "name": "List short codes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/short-codes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "short-codes"
              ]
            },
            "description": "Every GuestView short code assigned to your community.\n\nA short code is what a visitor types (or reaches via the QR on the placard)\nto open your community's guest directory and call a resident. Each entry\ncarries the `latch_id` it routes to plus the gate's `latch_name`, so you can\nrender or verify signage without a second lookup. `latch_id` is null for a\ncode that routes to the directory without preselecting a gate.\n\nCodes belonging to other communities are never listed here."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Create a short code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/short-codes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "short-codes"
              ]
            },
            "description": "Mint a GuestView short code on your community — the code your gate\nplacard or sign will display.\n\nOmit `code` and the server generates one (7 letters and digits), the same\nway the portal does. Supply `code` to claim a specific one: it must be 6–10\nletters and digits, and **short codes are unique across all of Nimbio, not\njust your community** — a code already in use anywhere comes back `409\nshort_code_taken` (including a code that differs only in letter case, since\na placard is read by a person). Generation retries on collision, so the\nserver-generated path effectively never 409s.\n\n`latch_id` is optional and must be a gate in your community; without it the\ncode routes to your directory without preselecting a gate.\n\n**There is no way to delete a short code** — not through this API, and not\nthrough the portal. A code you mint is permanent; the most that can be done\nto retire one is for a Nimbio admin to disable it. Mint deliberately.\n\nTest mode: validated in full (format, collision, latch ownership) and\nnothing is stored — a simulated create would otherwise consume a code out of\na global namespace that nothing can reclaim.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"latch_id\": \"{{latch_id}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Assign a short code to a gate",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/short-codes/{{code}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "short-codes",
                "{{code}}"
              ]
            },
            "description": "Repoint an existing short code at a different gate — move a printed\nplacard's code from one latch to another without reprinting the sign.\n\nAssignment is exclusive: the code routes to exactly one gate, so this\ndetaches it from whatever it pointed at before, and it takes effect for the\nnext visitor who types it.\n\nThe code must already belong to your community. An unknown code and a code\nowned by a different community both answer `404 short_code_not_found` — the\nnamespace is global, so the API deliberately gives no way to tell those two\napart, let alone to seize someone else's code. Claiming an unassigned code\nminted by a Nimbio admin (a pre-printed placard from a batch) is likewise\nnot available here; that stays a portal operation.\n\nTest mode: validated against your community, nothing changes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"latch_id\": \"{{latch_id}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Community · Webhooks",
      "item": [
        {
          "name": "List subscribable webhook event types",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhook-events",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhook-events"
              ]
            },
            "description": "The catalog of event types a webhook can subscribe to."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List webhooks",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks"
              ]
            },
            "description": "All webhooks registered on the key's community. Secrets are never listed."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Register a webhook",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks"
              ]
            },
            "description": "Register a webhook on the key's community.\n\nThe URL must be public https (private/internal addresses are rejected).\nThe HMAC signing secret is returned once here and never again — store it;\nrotate it via /rotate-secret if lost. Test mode: validated, nothing stored.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"description\": \"Home Assistant\",\n  \"events\": [\n    \"sense_line.changed\",\n    \"hold_open.changed\"\n  ],\n  \"url\": \"https://hooks.example.com/nimbio\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Delete a webhook",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Delete a webhook and its subscriptions. Test mode: validated, nothing\nis deleted."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Update a webhook",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Edit a webhook: any of `url`, `events`, `active`, `description`.\nPassing `active: true` revives a webhook that was auto-disabled after\nrepeated delivery failures. Test mode: validated, nothing changes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"active\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "List a webhook's delivery attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}/deliveries?limit=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}",
                "deliveries"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                }
              ]
            },
            "description": "Delivery attempts for one webhook, newest first — what a receiver\nreturned and when, so you can tell a silently-broken endpoint from one\nNimbio never fired at.\n\n`status` is `pending`, `delivered`, or `failed`; `attempts` counts tries so\nfar, and `last_status_code` / `last_error` describe the most recent one.\n`next_attempt_datetime` is set while a delivery is still being retried.\n\nThe event payload Nimbio sent is not echoed back here — replay it from the\nevent stream if you need it. `last_error` does include the first part of\n*your own* endpoint's response body on a non-2xx reply, which is what makes\na broken receiver diagnosable; if your error pages carry anything sensitive,\ntreat this field accordingly. Reads work for test and live keys alike."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Re-send every failed delivery for a webhook",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}/deliveries/retry-failed?limit=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}",
                "deliveries",
                "retry-failed"
              ],
              "query": [
                {
                  "key": "since",
                  "value": "",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "50",
                  "disabled": false
                }
              ]
            },
            "description": "Re-send every delivery for this webhook currently in the `failed` state,\noldest first — the \"my receiver was down for an hour, send me what I\nmissed\" path.\n\n**The original event id and payload are preserved.** Each re-send carries\nthe exact body Nimbio sent the first time and the same `event_id`\n(`X-Nimbio-Delivery` header), so a handler that de-duplicates on event id\nwill correctly ignore an event it already processed. What changes is the\n`delivery_id` — a replay is a new delivery row, so it appears in\n`GET .../deliveries` alongside the original, whose failure record is left\nintact. The signature is computed fresh at send time from the webhook's\n*current* secret, so a delivery replayed after a secret rotation is signed\nwith the new secret, and the `X-Nimbio-Timestamp` is the replay's, not the\noriginal's.\n\nDeliveries still `pending` are skipped (counted in `skipped_in_flight`) so a\nretry can never race Nimbio's own backoff and double-fire, and candidates\nare de-duplicated by event id — calling this twice in a row re-sends\nnothing the second time. `limit` caps re-sends at 100 per call.\n\nRefused with 409 if the webhook is inactive or auto-disabled: re-enable it\n(`PATCH /v1/community/webhooks/{webhook_id}` with `active: true`) first,\notherwise the re-sends would be discarded unsent. Test-mode keys return\n`result: \"simulated\"` and enqueue nothing — a replay POSTs a real event at\nyour real receiver."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Replay one delivery",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}/deliveries/{{delivery_id}}/replay",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}",
                "deliveries",
                "{{delivery_id}}",
                "replay"
              ]
            },
            "description": "Re-send one past delivery — the event you can see failed in\n`GET /v1/community/webhooks/{webhook_id}/deliveries`, once your receiver is\nfixed. Works for a `failed` delivery and for a `delivered` one you no longer\nhave.\n\n**The original event id and payload are preserved.** Nimbio stores the exact\nenvelope it POSTed, so the replay is that envelope byte-for-byte, carrying\nthe same `event_id` (and therefore the same `X-Nimbio-Delivery` header) as\nthe original attempt. A handler that de-duplicates on event id will\ncorrectly treat this as the event it already saw; a handler that does not\nwill apply it twice. The `delivery_id` is new — a replay is a new delivery\nrow, so it shows up in the delivery list next to the original, which keeps\nits failure record. The signature is computed at send time from the\nwebhook's *current* secret and a fresh `X-Nimbio-Timestamp`, so a replay\nafter a secret rotation is signed with the new secret.\n\nReturns 409 `delivery_in_flight` if a delivery of that event is still\n`pending` — Nimbio is already retrying it with backoff and a replay would\ndouble-fire. Returns 409 `webhook_disabled` if the webhook is inactive or\nauto-disabled; re-enable it first. Unknown ids and ids belonging to another\ncommunity are indistinguishable 404s. Test-mode keys return\n`result: \"simulated\"` and enqueue nothing."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Rotate a webhook's signing secret",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}/rotate-secret",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}",
                "rotate-secret"
              ]
            },
            "description": "Mint a new HMAC signing secret for a webhook. The old secret stops\nworking immediately; the new one is returned once here. Test mode:\nvalidated, the secret is unchanged (and not returned)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        },
        {
          "name": "Send a test ping",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/v1/community/webhooks/{{webhook_id}}/test",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "community",
                "webhooks",
                "{{webhook_id}}",
                "test"
              ]
            },
            "description": "Queue a synthetic `ping` delivery so the receiver can verify\nconnectivity and its HMAC verification end-to-end. Works for test and\nlive keys — a ping has no physical side effects."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('status is < 400', function () {",
                  "  pm.expect(pm.response.code).to.be.below(400);",
                  "});",
                  "var rid = pm.response.headers.get('X-Request-Id');",
                  "if (rid) pm.collectionVariables.set('x_request_id', rid);"
                ]
              }
            }
          ]
        }
      ]
    }
  ]
}
