{"openapi":"3.1.0","info":{"title":"SnapKey Public API","version":"1.3.0","x-logo":{"url":"https://api.snapkey.dk/images/snapkey-logo.svg","altText":"SnapKey"},"description":"## Overview\n\nThe SnapKey Public API lets your own systems drive access control in SnapKey without anyone\nopening the dashboard. It carries two flows:\n\n* **Your system \u2192 SnapKey.** Your HR or facility system creates and updates the **people** who\n  need access, and issues **keys** to them. Issuing a key sends the person a setup link (SMS or\n  e-mail); the key issued this way appears once they activate that link. `GET /keys` also lists\n  keys that never had a setup link \u2014 physical cards and iLOQ S5 fobs managed elsewhere \u2014 so read\n  `type` to tell them apart.\n* **SnapKey \u2192 your system.** Read the access **events** from the doors (`GET /events`), or\n  subscribe to **webhooks** and have SnapKey push each event to a URL you control.\n\nEverything you can read or write is limited to the location your API key belongs to and the\ndepartments underneath it.\n\n## Authentication\n\nEvery request carries a bearer token:\n\n```\nAuthorization: Bearer sk_live_9f3c\u2026\n```\n\nAPI keys are created in the SnapKey dashboard under **Developer \u2192 API keys**. The full token is\nshown **once**, at creation \u2014 store it in your secret manager; SnapKey keeps only a hash and the\n`sk_live_9f3c` prefix. A key can be revoked at any time from the same screen; a revoked or\nexpired key answers `401 unauthenticated`.\n\nEvery key carries an expiry date: **1 year** by default, up to **2 years** if the person creating\nit sets one further out. There is no non-expiring key \u2014 plan to rotate before `expires_at`, which\n`GET developer/api-keys` in the dashboard shows for every key.\n\nA key carries an explicit list of scopes. A request to an endpoint whose scope the key does not\nhold answers `403 insufficient_scope`.\n\n| Scope | Grants |\n|---|---|\n| `catalog:read` | `GET /locks`, `GET /locks/{id}`, `GET /security_groups` |\n| `people:read` | `GET /people`, `GET /people/{id}` |\n| `people:write` | `POST`, `PUT`, `DELETE` on `/people` \u2014 and everything `people:read` grants |\n| `keys:read` | `GET /keys`, `GET /keys/{id}`, `GET /invitations`, `GET /invitations/{id}` |\n| `keys:write` | `POST /keys`, `PATCH`, `DELETE` on `/keys/{id}`, `POST /invitations/{id}/resend`, `DELETE /invitations/{id}` \u2014 and everything `keys:read` grants |\n| `locks:control` | `POST /locks/{id}/unlock`, `GET /locks/{id}/commands/{command_id}` \u2014 only for the locks listed on the key |\n| `events:read` | `GET /events` |\n| `webhooks:manage` | all `/webhooks` endpoints |\n\nA read endpoint accepts either scope: `GET /people` is served for a key holding\n`people:read` **or** `people:write`.\n\n## Scope of a key\n\nAn API key belongs to exactly one location. Every list, lookup and write is limited to **that\nlocation and its descendants** \u2014 never the account root, never a sibling department. A row that\nexists elsewhere in the account is not \"forbidden\", it is simply `404 not_found`.\n\nReseller (platform-mode) accounts are not supported in v1: their keys answer\n`403 not_available` on every endpoint.\n\n## Rate limits\n\n600 requests per minute per API key. A request that arrives without a bearer token is counted\nagainst a shared per-IP bucket of the same size instead.\n\nEvery response from an **authenticated** endpoint carries:\n\n| Header | Meaning |\n|---|---|\n| `X-RateLimit-Limit` | requests allowed per minute (600) |\n| `X-RateLimit-Remaining` | requests left in the current window |\n\nExceeding a limit answers `429 rate_limited` with a `Retry-After` header holding the number of\nseconds to wait. Two endpoints are limited harder, because each one makes SnapKey act on the\noutside world:\n\n| Endpoint | Limit |\n|---|---|\n| `POST /keys` | 30 per minute, per API key \u2014 every call sends an SMS or e-mail |\n| `POST /invitations/{id}/resend` | 30 per minute, per API key \u2014 shares the POST /keys limit |\n| `POST /webhooks/{id}/ping` | 10 per minute, per API key |\n| `POST /locks/{id}/unlock` | 10 per minute, per API key, per lock |\n\nRequests that fail to authenticate are counted separately: **30 failed authentications per\nminute, per IP**. Once an IP has produced 30 failed authentications in a minute, every request\nfrom that IP is answered `429` until the window rolls \u2014 including requests with a valid key.\n`Retry-After` tells you how long. If you share an egress IP with other tenants or with\nmisconfigured clients, expect this and back off.\n\n## Pagination\n\nEvery list endpoint returns a cursor page:\n\n```json\n{ \"data\": [ \u2026 ], \"next_cursor\": \"eyJpZCI6NDE3MiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ\" }\n```\n\n* `limit` \u2014 items per page, `1`\u2013`200`, default `50`. Values outside the range are clamped.\n* `cursor` \u2014 opaque; pass back the `next_cursor` of the previous page verbatim.\n\n`next_cursor` is `null` on the last page. Rows are ordered by `id` ascending, so a page never\nreshuffles under you while you walk it.\n\n## Errors\n\nEvery non-2xx response uses one envelope:\n\n```json\n{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"The request could not be processed.\",\n    \"details\": [\n      { \"field\": \"phone\", \"code\": \"invalid\", \"message\": \"Phone must be in international format, e.g. +4520123456.\" }\n    ]\n  }\n}\n```\n\n`details` is present only for `validation_failed`.\n\n| Status | `code` | When |\n|---|---|---|\n| 400 | `invalid_request` | malformed query string or route parameters |\n| 401 | `unauthenticated` | missing, invalid, expired or revoked API key |\n| 403 | `insufficient_scope` | the key does not hold the scope this endpoint needs |\n| 403 | `not_available` | reseller (platform-mode) account \u2014 not supported in v1 |\n| 403 | `forbidden` | the person is managed by SnapKey and cannot be modified |\n| 403 | `lock_not_allowed` | the lock is not on this API key's allowlist |\n| 404 | `not_found` | no such row \u2014 including rows outside the key's scope |\n| 405 | `invalid_request` | that method is not supported on this path |\n| 409 | `iloq_rejected` | the locking system refused the create, change or delete |\n| 409 | `invitation_not_pending` | resend or cancel on an invitation that is activated, expired or cancelled |\n| 409 | `idempotency_conflict` | this `Idempotency-Key` was already used for a different body |\n| 409 | `idempotency_in_progress` | the first call with this `Idempotency-Key` is still running |\n| 409 | `subscription_paused` | redeliver on a paused subscription |\n| 409 | `lock_not_remote` | the lock is an iLOQ lock and cannot be opened over the network |\n| 409 | `lock_offline` | the lock's device has not been heard from recently |\n| 422 | `validation_failed` | the body failed validation; see `details` |\n| 422 | `entitlement_exceeded` | the account's plan limit for phone keys is reached \u2014 `POST /keys`, `POST /invitations/{id}/resend` |\n| 429 | `rate_limited` | rate limit exceeded; see `Retry-After` |\n| 500 | `server_error` | unexpected failure \u2014 safe to retry with backoff |\n| 502 | `publish_failed` | the command could not be handed to the device |\n| 503 | `not_available` | key revocation is not enabled on this server |\n\nDetail codes inside `details[].code`: `invalid`, `unknown_field`, `reserved_name`,\n`unknown_security_group`, `sms_disabled`, `limit_reached`, `not_editable`.\n\n## Idempotency\n\n`POST /people`, `POST /keys` and `POST /webhooks` accept an optional `Idempotency-Key` request\nheader \u2014 1\u201364 characters of `A\u2013Z a\u2013z 0\u20139 _ -`. Send one per logical operation and reuse it when\nyou retry after a timeout.\n\n* The first request runs normally; its status and body are stored for **24 hours**, per API key.\n* A retry with the same key **and the same body** returns the stored response verbatim, with\n  `Idempotent-Replayed: true`. That is the answer to \"did my retry create a duplicate?\": no.\n* The same key with a **different** body answers `409 idempotency_conflict`.\n* A retry that arrives while the first call is still running answers `409\n  idempotency_in_progress` with `Retry-After: 2`.\n* `5xx` responses and `409 iloq_rejected` are never stored \u2014 both are transient \u2014 so a retry\n  with the same key re-executes.\n* Responses larger than 64 KB, or that are not JSON, are not stored: a retry with the same key\n  executes the request again (never a replay).\n* A key that is still in progress after **60 seconds** is treated as abandoned (a killed worker,\n  a dropped connection) and the retry re-executes rather than waiting out the 24 hours.\n\nWithout the header nothing changes: `POST /people` still de-duplicates on phone and e-mail, and\n`POST /keys` still sends every time.\n\n## Caching and retention\n\nEvery `public/v1` response carries `Cache-Control: no-store, private` \u2014 none of it may be\ncached by a proxy or a browser.\n\n* Request logs (method, path, status, duration, IP \u2014 never bodies) are kept **90 days**.\n* Webhook delivery payloads are kept **30 days**.\n* Both are purged automatically. Deleting a person purges their delivery payloads immediately.\n\n## Timestamps\n\nAll timestamps are ISO 8601 in UTC with a `Z` suffix \u2014 `2026-09-02T08:15:30Z`. Plain dates use\n`YYYY-MM-DD`. Timestamps you send (`starts_at`, `expires_at`, `since`) are parsed as UTC unless\nthey carry their own offset.\n\n## Webhook delivery\n\nA subscription receives an HTTP `POST` for every matching event:\n\n```\nPOST https://hooks.example.com/snapkey\nContent-Type: application/json\nUser-Agent: SnapKey-Webhooks/1.0\nX-SnapKey-Event: access.granted\nX-SnapKey-Delivery: 6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55\nX-SnapKey-Timestamp: 1788336930\nX-SnapKey-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, \"{t}.{raw body}\")>\n\n{ \"id\": \"6f0a1d5c-\u2026\", \"type\": \"access.granted\", \"created_at\": \"2026-09-02T08:15:30Z\", \"data\": { \u2026 } }\n```\n\n**Verify the signature** like this:\n\n1. Split `X-SnapKey-Signature` on `,` into `t=<unix seconds>` and `v1=<hex digest>`.\n2. Reject the delivery if `t` is more than **300 seconds** away from your own clock \u2014 that is\n   what stops a captured delivery from being replayed later.\n3. Compute `HMAC-SHA256` over the string `\"{t}.{raw body}\"` \u2014 the raw bytes you received, not a\n   re-serialised copy \u2014 keyed with your subscription secret.\n4. Compare it with `v1` in **constant time** (`hash_equals`, `crypto.timingSafeEqual`, \u2026).\n\n```php\n[$t, $v1] = sscanf($header, 't=%d,v1=%s');\n$valid = abs(time() - $t) <= 300\n    && hash_equals(hash_hmac('sha256', $t.'.'.$rawBody, $secret), $v1);\n```\n\n**Respond with any 2xx within 10 seconds.** Anything else \u2014 a non-2xx status, a timeout, a\nconnection failure \u2014 is a failed attempt. Redirects are not followed; any 3xx is a failed\nattempt. SnapKey retries after **1 m, 5 m, 30 m, 2 h, 6 h, 24 h**, then marks the delivery\n`exhausted` and stops.\n\nA subscription that has been failing continuously for 24 hours is set to `paused`, and the user\nwho created the API key (or the subscription) is e-mailed, when one is known. A paused\nsubscription receives nothing; resume it from the dashboard and recover the gap with\n`GET /events`.\n\nA subscription can also be managed entirely through the API: `PATCH /webhooks/{id}` pauses it\n(`status: paused`, stopping deliveries immediately) or resumes it (`status: active`, which also\nclears `failing_since`), and the same call changes `url`, `events` or `description`.\n`GET /webhooks/{id}/deliveries` lists past attempts \u2014 filterable by `status`, `event_type` and\n`since` \u2014 and `POST /webhooks/{id}/deliveries/{delivery_id}/redeliver` re-sends one as a fresh\ndelivery with its own attempt ladder; the redelivery's `event_id` is the original delivery's id,\nso your duplicate guard recognises it as the same event. Redeliver answers `409\nsubscription_paused` while the subscription is paused \u2014 resume it first.\n\nDelivery is **at-least-once** and unordered: dedupe on `data.id` together with `type`.\n"},"servers":[{"url":"https://api.snapkey.dk/public/v1"}],"security":[{"ApiKey":[]}],"tags":[{"name":"Locks","description":"The doors in your account, and the security groups that open them."},{"name":"Security groups","description":"The access profiles a key can be issued against."},{"name":"People","description":"The employees, contractors and guests who hold keys."},{"name":"Keys","description":"Access grants, and the invitations that create them."},{"name":"Invitations","description":"Setup links issued to people, and what became of them."},{"name":"Events","description":"The access log \u2014 who opened which door, when, and what was refused."},{"name":"Webhooks","description":"Push subscriptions that deliver events to a URL you control."}],"x-tagGroups":[{"name":"Catalog","tags":["Locks","Security groups"]},{"name":"People","tags":["People"]},{"name":"Keys","tags":["Keys"]},{"name":"Events","tags":["Events"]},{"name":"Webhooks","tags":["Webhooks"]}],"paths":{"/locks":{"get":{"operationId":"listLocks","tags":["Locks"],"summary":"List locks","description":"Lists the locks in the API key's location and its departments.\n\nRequires scope `catalog:read`.\n","parameters":[{"$ref":"#/components/parameters/LocationId"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of locks.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockList"},"example":{"data":[{"id":4172,"name":"Main entrance","serial_number":"S5-004172","place":"Ground floor, east","provider":"iloq","online":null,"security_groups":["HQ-STAFF"],"location":{"id":12,"name":"Headquarters"}}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl https://api.snapkey.dk/public/v1/locks?limit=50 \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\"\n"}]}},"/locks/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"The lock id.","schema":{"type":"integer","format":"int64"},"example":4172}],"get":{"operationId":"getLock","tags":["Locks"],"summary":"Get a lock","description":"One lock, same shape as the list. For a remote lock `online` reflects the device's last\ncontact with SnapKey; poll it before an unlock, or subscribe to `lock.online` / `lock.offline`.\n\nRequires scope `catalog:read`.\n","responses":{"200":{"description":"The lock.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Lock"},"example":{"id":4172,"name":"Main entrance","serial_number":null,"place":"Ground floor, east","provider":"teltonika","online":true,"security_groups":["HQ-STAFF"],"location":{"id":12,"name":"Headquarters"}}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/locks/{id}/unlock":{"post":{"operationId":"unlockLock","tags":["Locks"],"summary":"Open a lock","description":"Opens a remote lock for its configured pulse. The API key must hold `locks:control` **and**\nlist the lock on its allowlist (set when the key is created in the dashboard) \u2014 a lock the\nkey may not open answers `403 lock_not_allowed` and is recorded as a refused access.\n\nThe call answers **202 Accepted** with a command: the device has been told, not yet heard\nfrom. Poll `GET /locks/{id}/commands/{command_id}`, or let the outcome reach you as\n`access.granted` or `unlock.failed` on a webhook. A second call while a command is still\nin flight returns that same command instead of pulsing the door again.\n\nRate limited to **10 requests per minute, per API key, per lock**. Requires scope\n`locks:control`.\n","parameters":[{"name":"id","in":"path","required":true,"description":"The lock id.","schema":{"type":"integer","format":"int64"},"example":4172}],"responses":{"202":{"description":"The command was sent to the device.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockCommand"},"example":{"id":"0d3f6c2a-7b1e-4f0a-9c8d-2e5b7a1f4c33","lock_id":4172,"status":"published","requested_at":"2026-09-19T09:40:11Z","published_at":"2026-09-19T09:40:11Z","confirmed_at":null,"timeout_seconds":10,"error":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"The key lacks the scope, or the lock is not on its allowlist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"lockNotAllowed":{"summary":"Lock not on the key's allowlist","value":{"error":{"code":"lock_not_allowed","message":"This API key may not open this lock."}}},"insufficientScope":{"summary":"Missing scope","value":{"error":{"code":"insufficient_scope","message":"This API key does not have the locks:control scope."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The lock cannot be opened right now. Nothing was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notRemote":{"summary":"An iLOQ lock","value":{"error":{"code":"lock_not_remote","message":"This lock cannot be opened remotely."}}},"offline":{"summary":"Device offline","value":{"error":{"code":"lock_offline","message":"The lock's device is offline."}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"},"502":{"description":"The command could not be handed to the device. It is recorded as `unlock.failed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"publish_failed","message":"The command could not be sent to the device."}}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.snapkey.dk/public/v1/locks/4172/unlock \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\"\n"}]}},"/locks/{id}/commands/{commandId}":{"get":{"operationId":"getLockCommand","tags":["Locks"],"summary":"Get an unlock command","description":"The state of a command returned by `POST /locks/{id}/unlock`. `status` moves from\n`published` to `confirmed` when the device answers, or to `timed_out` after\n`timeout_seconds` without an answer; `failed` means the device could not be reached at all.\n\nRequires scope `locks:control` and the lock on the key's allowlist.\n","parameters":[{"name":"id","in":"path","required":true,"description":"The lock id.","schema":{"type":"integer","format":"int64"},"example":4172},{"name":"commandId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"example":"0d3f6c2a-7b1e-4f0a-9c8d-2e5b7a1f4c33"}],"responses":{"200":{"description":"The command.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockCommand"},"example":{"id":"0d3f6c2a-7b1e-4f0a-9c8d-2e5b7a1f4c33","lock_id":4172,"status":"confirmed","requested_at":"2026-09-19T09:40:11Z","published_at":"2026-09-19T09:40:11Z","confirmed_at":"2026-09-19T09:40:12Z","timeout_seconds":10,"error":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/security_groups":{"get":{"operationId":"listSecurityGroups","tags":["Security groups"],"summary":"List security groups","description":"Lists the security groups (access profiles) in the API key's location and its departments.\nTheir `code` values are what you pass to `POST /keys`.\n\nRequires scope `catalog:read`.\n","parameters":[{"name":"is_default","in":"query","required":false,"description":"Return only the default (`true`) or only the non-default (`false`) groups.","schema":{"type":"boolean"},"example":true},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of security groups.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityGroupList"},"example":{"data":[{"id":301,"code":"HQ-STAFF","title":"Headquarters staff","description":"All shared doors at the Headquarters address.","is_default":true}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/people":{"get":{"operationId":"listPeople","tags":["People"],"summary":"List people","description":"Lists the people in the API key's location and its departments. `email` matches\ncase-insensitively; `phone` is normalised before matching, so `+45 20 12 34 56` and\n`+4520123456` find the same person.\n\nRequires scope `people:read` or `people:write`.\n","parameters":[{"name":"email","in":"query","required":false,"description":"Exact, case-insensitive e-mail match.","schema":{"type":"string"},"example":"mette.sorensen@example.com"},{"name":"phone","in":"query","required":false,"description":"Phone number in any format; normalised before matching.","schema":{"type":"string"},"example":"+4520123456"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of people.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonList"},"example":{"data":[{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520123456","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location":{"id":12,"name":"Headquarters"}}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"operationId":"createPerson","tags":["People"],"summary":"Create a person","description":"Creates a person and, when the account syncs with iLOQ, creates the matching person there.\n\n**Idempotent on phone and e-mail.** If a person in the API key's scope already has the same\nnormalised phone number or the same e-mail address, that person is returned with **200 OK**\nand nothing is changed. A genuinely new person answers **201 Created**. Send the request\nagain after a timeout without fear of duplicates.\n\nUnknown fields are rejected with `422 validation_failed` so typos surface at once. A `name`\nreserved for SnapKey's own service accounts (`SnapKey`, `Admin`, `ServiceUser`, matched\nwhole or as the first word) is rejected the same way with detail code `reserved_name`.\n\nRequires scope `people:write`.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonCreate"},"example":{"name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520123456","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location_id":12}}}},"responses":{"201":{"headers":{"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"description":"The person was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"},"example":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520123456","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location":{"id":12,"name":"Headquarters"}}}}},"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"},"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"description":"A person with this phone number or e-mail already existed; it is returned unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"The locking system refused to create this person (`iloq_rejected`), this Idempotency-Key\nwas already used for a different request (`idempotency_conflict`), or the first call with\nit is still running (`idempotency_in_progress`).\n","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter","description":"Present only when `code` is `idempotency_in_progress`; seconds to wait before retrying."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"iloqRejected":{"summary":"The locking system refused the create","value":{"error":{"code":"iloq_rejected","message":"The locking system refused to create this person."}}},"idempotencyConflict":{"summary":"Reused for a different body","value":{"error":{"code":"idempotency_conflict","message":"This Idempotency-Key was already used for a different request."}}},"idempotencyInProgress":{"summary":"First call still running","value":{"error":{"code":"idempotency_in_progress","message":"A request with this Idempotency-Key is still being processed."}}}}}}},"422":{"description":"The body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidPhone":{"summary":"Malformed field","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"phone","code":"invalid","message":"Phone must be in international format, e.g. +4520123456."}]}}},"reservedName":{"summary":"Reserved for SnapKey service accounts","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"name","code":"reserved_name","message":"This name is reserved for SnapKey service accounts."}]}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.snapkey.dk/public/v1/people \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"name\": \"Mette S\u00f8rensen\",\n        \"email\": \"mette.sorensen@example.com\",\n        \"phone\": \"+4520123456\",\n        \"company_name\": \"Tidevand Energi\",\n        \"title\": \"Facility Manager\",\n        \"language\": \"da\"\n      }'\n"}]}},"/people/{id}":{"parameters":[{"$ref":"#/components/parameters/PersonId"}],"get":{"operationId":"getPerson","tags":["People"],"summary":"Get a person","description":"Returns one person, including the `keys` they currently hold inside the API key's scope.\n\nRequires scope `people:read` or `people:write`.\n","responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"The person, with their keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonWithKeys"},"example":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520123456","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location":{"id":12,"name":"Headquarters"},"keys":[{"id":88213,"name":"Headquarters staff","type":"digital","state":"handed_over","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":null}]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"put":{"operationId":"updatePerson","tags":["People"],"summary":"Update a person","description":"Updates a person. Every field is optional; only the fields you send are changed. Unknown\nfields are rejected with `422 validation_failed`, as is a `name` that is one of SnapKey's\nreserved service names (`SnapKey`, `Admin`, `ServiceUser`, as the whole name or as the\nfirst word) \u2014 detail code `reserved_name`.\n\nThe change is written to the locking system first \u2014 if iLOQ refuses it, nothing is saved\nlocally and the call answers `409 iloq_rejected`.\n\nSnapKey's own service people cannot be modified through the public API and answer\n`403 forbidden`.\n\nRequires scope `people:write`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonUpdate"},"example":{"title":"Head of Facilities","phone":"+4520998877"}}}},"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"The updated person.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"The key lacks the `people:write` scope (`insufficient_scope`), the account is a reseller\naccount (`not_available`), or this person is managed by SnapKey (`forbidden`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"forbidden","message":"This person is managed by SnapKey and cannot be modified."}}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The locking system refused this change. Nothing was saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"iloq_rejected","message":"The locking system refused this change."}}}}},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"operationId":"deletePerson","tags":["People"],"summary":"Delete a person","description":"Deletes a person. When the account syncs with iLOQ the person is deleted there first; if the\nlocking system refuses, nothing is deleted and the call answers `409 iloq_rejected`.\n\nSnapKey's own service people cannot be deleted through the public API and answer\n`403 forbidden`.\n\nRequires scope `people:write`.\n","responses":{"204":{"description":"The person was deleted. No body."},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"The key lacks the `people:write` scope (`insufficient_scope`), the account is a reseller\naccount (`not_available`), or this person is managed by SnapKey (`forbidden`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The locking system refused to delete this person. Nothing was deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"iloq_rejected","message":"The locking system refused to delete this person."}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/keys":{"get":{"operationId":"listKeys","tags":["Keys"],"summary":"List keys","description":"Lists **every** access grant in the API key's location and its departments \u2014 not only the\nones issued through this API. A key issued with `POST /keys` appears here once the person\nactivates their setup link; until then the invitation is all there is. Physical cards and\niLOQ S5 fobs, which are managed outside this API and never had a setup link, are listed too.\nRead `type` to tell them apart.\n\nRequires scope `keys:read` or `keys:write`.\n","parameters":[{"name":"person_id","in":"query","required":false,"description":"Only the keys held by this person.","schema":{"type":"string","format":"uuid"},"example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},{"name":"state","in":"query","required":false,"description":"Only keys in this state \u2014 `planning`, `sent`, `handed_over`, `returned` or `other`.\nAn unrecognised value is not rejected; it simply matches nothing and returns an empty\npage.\n","schema":{"type":"string"},"example":"handed_over"},{"name":"security_group","in":"query","required":false,"description":"Only keys that carry this security group code.","schema":{"type":"string"},"example":"HQ-STAFF"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyList"},"example":{"data":[{"id":88213,"name":"Headquarters staff","type":"digital","state":"handed_over","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","invitation_id":5521,"security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":null}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"operationId":"createKey","tags":["Keys"],"summary":"Issue a key (invitation)","description":"Issues a key to a person. SnapKey creates an **invitation** and sends the person a setup\nlink over SMS, e-mail or both; the call answers **202 Accepted** with the invitation, not a\nkey. Follow it with `GET /invitations/{id}`, resend it with `POST /invitations/{id}/resend`,\nor recall it with `DELETE /invitations/{id}`. The key itself appears in `GET /keys` with\nstate `handed_over` once the person activates the link, and fires the `key.activated`\nwebhook at that moment.\n\n`security_groups` must be codes that exist at the API key's location \u2014 an unknown code\nanswers `422` with detail code `unknown_security_group`, naming the offending index.\nAsking for SMS delivery on an account without SMS answers `422` with detail code\n`sms_disabled`. Answers `422 entitlement_exceeded` when the account's phone-key allowance is\nused up.\n\nRate limited to **30 requests per minute, per API key**, counted before validation and scope\nchecks \u2014 a rejected request still consumes one. `X-RateLimit-Limit` on this route shows the\nlimiter closest to exhaustion on that route, so they can show `30` rather than the account-wide 600.\n\nRequires scope `keys:write`.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyCreate"},"example":{"person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","security_groups":["HQ-STAFF"],"name":"Headquarters staff","starts_at":"2026-09-01T00:00:00Z","expires_at":"2027-09-01T00:00:00Z","channel":"sms"}}}},"responses":{"202":{"headers":{"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"description":"The invitation was created and the setup link dispatched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationCreated"},"example":{"invitation_id":5521,"person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","security_groups":["HQ-STAFF"],"state":"sent","sent":{"email":false,"sms":true}}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such person in the API key's scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No such resource in your account."}}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"422":{"description":"The body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"unknownSecurityGroup":{"summary":"Unknown security group code","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"security_groups[0]","code":"unknown_security_group","message":"No security group with code \"HQ-STAF\" exists in this account."}]}}},"smsDisabled":{"summary":"SMS delivery is not enabled","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"channel","code":"sms_disabled","message":"SMS delivery is not enabled for this account."}]}}},"entitlementExceeded":{"summary":"Phone-key limit reached","value":{"error":{"code":"entitlement_exceeded","message":"This account's plan includes 50 phone keys and that limit is reached. Contact your partner to increase it."}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.snapkey.dk/public/v1/keys \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"person_id\": \"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40\",\n        \"security_groups\": [\"HQ-STAFF\"],\n        \"name\": \"Headquarters staff\",\n        \"channel\": \"sms\"\n      }'\n"}]}},"/keys/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"The key id.","schema":{"type":"integer","format":"int64"},"example":88213}],"get":{"operationId":"getKey","tags":["Keys"],"summary":"Get a key","description":"One key, same shape as the list. Revoked keys are gone from here as well as from the list.\n\nRequires scope `keys:read` or `keys:write`.\n","responses":{"200":{"description":"The key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Key"},"example":{"id":88213,"name":"Headquarters staff","type":"digital","state":"handed_over","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","invitation_id":5521,"security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"patch":{"operationId":"updateKey","tags":["Keys"],"summary":"Update a key","description":"Changes a key's name, security groups or validity window. Send only the fields you want to\nchange; at least one is required.\n\nAny change other than a pure rename puts the key back into state `sent` until the locking\nsystem confirms it, at which point it returns to `handed_over` and fires `key.activated`\nagain. An iLOQ S5 fob is managed in iLOQ Manager: only `name` can be changed, every other\nfield answers `422` with detail code `not_editable`.\n\nWhen the account syncs with iLOQ and the key is already linked, the change is written to iLOQ\n**before** it is saved; a refusal answers `409 iloq_rejected` and nothing changes. Editing a\nkey through the API replaces any time limits set directly in iLOQ Manager. Sending the same\nsecurity groups in a different order is not a change.\n\nRequires scope `keys:write`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyUpdate"},"example":{"security_groups":["HQ-STAFF","HQ-PARKING"],"expires_at":"2027-09-01T00:00:00Z"}}}},"responses":{"200":{"description":"The updated key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Key"},"example":{"id":88213,"name":"Headquarters staff","type":"digital","state":"sent","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","invitation_id":5521,"security_groups":["HQ-STAFF","HQ-PARKING"],"starts_at":"2026-09-01T00:00:00Z","expires_at":"2027-09-01T00:00:00Z"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The locking system refused the change. Nothing was saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"iloq_rejected","message":"The locking system refused this change."}}}}},"422":{"description":"The body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notEditable":{"summary":"A field an S5 fob does not allow","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"security_groups","code":"not_editable","message":"This field cannot be changed on an S5 fob."}]}}},"unknownSecurityGroup":{"summary":"Unknown security group code","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"security_groups[1]","code":"unknown_security_group","message":"No security group with code \"HQ-PARKNG\" exists in this account."}]}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X PATCH https://api.snapkey.dk/public/v1/keys/88213 \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"security_groups\": [\"HQ-STAFF\", \"HQ-PARKING\"], \"expires_at\": \"2027-09-01T00:00:00Z\" }'\n"}]},"delete":{"operationId":"deleteKey","tags":["Keys"],"summary":"Revoke a key","description":"Revokes a key. The key expires immediately, is removed from SnapKey and is queued for\nremoval in the locking system; the `key.revoked` webhook fires at once. Revocation in the\nlocking system finishes asynchronously.\n\nRequires scope `keys:write`.\n","responses":{"204":{"description":"The key was revoked. No body."},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"},"503":{"description":"Key revocation is not enabled on this server. Nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_available","message":"Key revocation is not enabled on this server."}}}}}}}},"/invitations":{"get":{"operationId":"listInvitations","tags":["Invitations"],"summary":"List invitations","description":"Every setup link issued to a person in the API key's location and its departments \u2014 from\nthis API or from the dashboard. Cancelled invitations are included; filter on `state` to\nnarrow. Door QR codes and other links not tied to a person are never listed.\n\nRequires scope `keys:read` or `keys:write`.\n","parameters":[{"name":"person_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},{"name":"state","in":"query","required":false,"description":"One of `pending`, `activated`, `expired`, `cancelled`. Any other value answers `422`.","schema":{"$ref":"#/components/schemas/InvitationState"},"example":"pending"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of invitations.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList"},"example":{"data":[{"id":5521,"state":"pending","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","key_id":null,"name":"Headquarters staff","security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":"2027-09-01T00:00:00Z","sent":{"email_at":null,"sms_at":"2026-09-19T08:12:03Z"},"created_at":"2026-09-19T08:12:02Z"}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/invitations/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"The invitation id \u2014 the `invitation_id` that `POST /keys` answered with.","schema":{"type":"integer","format":"int64"},"example":5521}],"get":{"operationId":"getInvitation","tags":["Invitations"],"summary":"Get an invitation","description":"One invitation in any state. `state` is derived: `cancelled` once recalled, `activated` once\nthe person has turned it into a key (`key_id` names that key, even if it was revoked later),\n`expired` when its `expires_at` has passed or its single use was consumed without a key,\notherwise `pending`.\n\nRequires scope `keys:read` or `keys:write`.\n","responses":{"200":{"description":"The invitation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation"},"example":{"id":5521,"state":"activated","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","key_id":88213,"name":"Headquarters staff","security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":"2027-09-01T00:00:00Z","sent":{"email_at":null,"sms_at":"2026-09-19T08:12:03Z"},"created_at":"2026-09-19T08:12:02Z"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"operationId":"cancelInvitation","tags":["Invitations"],"summary":"Cancel an invitation","description":"Recalls a pending setup link: it can no longer be activated and its `state` becomes\n`cancelled`. Only a `pending` invitation can be cancelled; anything else answers `409`. To\nrevoke a key that already exists, use `DELETE /keys/{id}`. A scan that is already in\nprogress when the link is cancelled can still complete; the invitation then reads as\n`cancelled` but a key exists \u2014 revoke it with `DELETE /keys/{id}`.\n\nRequires scope `keys:write`.\n","responses":{"204":{"description":"The invitation was cancelled. No body."},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/InvitationNotPending"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/invitations/{id}/resend":{"post":{"operationId":"resendInvitation","tags":["Invitations"],"summary":"Resend an invitation","description":"Sends the setup link again over SMS, e-mail or both (`channel`, default `sms`). Only a\n`pending` invitation can be resent; anything else answers `409`. A resend counts against\nthe account's phone-key allowance like a new issue and fires `key.issued` again.\n\nRate limited to **30 requests per minute, per API key**, like `POST /keys`.\n\nRequires scope `keys:write`.\n","parameters":[{"name":"id","in":"path","required":true,"description":"The invitation id.","schema":{"type":"integer","format":"int64"},"example":5521}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationResend"},"example":{"channel":"both"}}}},"responses":{"202":{"description":"The link was sent again. The invitation, re-read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation"},"example":{"id":5521,"state":"pending","person_id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","key_id":null,"name":"Headquarters staff","security_groups":["HQ-STAFF"],"starts_at":"2026-09-01T00:00:00Z","expires_at":"2027-09-01T00:00:00Z","sent":{"email_at":"2026-09-19T09:40:11Z","sms_at":"2026-09-19T08:12:03Z"},"created_at":"2026-09-19T08:12:02Z"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/InvitationNotPending"},"422":{"description":"The body failed validation, or the account is at its phone-key limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"smsDisabled":{"summary":"SMS delivery is not enabled","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"channel","code":"sms_disabled","message":"SMS delivery is not enabled for this account."}]}}},"noPhone":{"summary":"The person has no phone number","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"channel","code":"invalid","message":"The person has no phone number."}]}}},"entitlementExceeded":{"summary":"Phone-key limit reached","value":{"error":{"code":"entitlement_exceeded","message":"This account's plan includes 50 phone keys and that limit is reached. Contact your partner to increase it."}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.snapkey.dk/public/v1/invitations/5521/resend \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"channel\": \"both\" }'\n"}]}},"/events":{"get":{"operationId":"listEvents","tags":["Events"],"summary":"List access events","description":"The access log for the locks in the API key's location and its departments, oldest first\n(`id` ascending \u2014 the order SnapKey received them in). This is the endpoint to poll, and the\nendpoint to replay from after a webhook gap.\n\nOnly door events are exposed: `access.granted`, `access.denied`, `door.closed` and\n`door.left_open`. Internal sync and app telemetry rows are never returned.\n\nDoor events only. `unlock.failed`, key, person and lock presence events are webhook-only.\n\nRequires scope `events:read`.\n","parameters":[{"name":"since","in":"query","required":false,"description":"Only events at or after this instant. ISO 8601; parsed as UTC when no offset is given.","schema":{"type":"string","format":"date-time"},"example":"2026-09-01T00:00:00Z"},{"name":"lock_id","in":"query","required":false,"description":"Only events from this lock.","schema":{"type":"integer","format":"int64"},"example":4172},{"name":"person_id","in":"query","required":false,"description":"Only events caused by this person.","schema":{"type":"string","format":"uuid"},"example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},{"name":"type","in":"query","required":false,"description":"Comma-separated list of event types. An unrecognised list matches nothing.\n","schema":{"type":"string"},"example":"access.granted,access.denied"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of events.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventList"},"example":{"data":[{"id":9910427,"type":"access.granted","occurred_at":"2026-09-02T08:15:30Z","result":"success","reason":null,"source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff"}},{"id":9910433,"type":"access.denied","occurred_at":"2026-09-02T19:42:11Z","result":"denied","reason":"outside_time_window","source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff"}}],"next_cursor":"eyJpZCI6OTkxMDQzMywiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -G https://api.snapkey.dk/public/v1/events \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  --data-urlencode \"since=2026-09-01T00:00:00Z\" \\\n  --data-urlencode \"type=access.granted,access.denied\" \\\n  --data-urlencode \"limit=200\"\n"}]}},"/webhooks":{"get":{"operationId":"listWebhooks","tags":["Webhooks"],"summary":"List webhook subscriptions","description":"Lists the webhook subscriptions of the API key's own location. The `secret` is never\nreturned here \u2014 it is shown once, at creation.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of subscriptions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookList"},"example":{"data":[{"id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","url":"https://hooks.example.com/snapkey","events":["access.granted","access.denied"],"description":"Tidevand Energi facility dashboard","status":"active","failing_since":null,"last_delivery_at":"2026-09-02T08:15:31Z","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":null}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"operationId":"createWebhook","tags":["Webhooks"],"summary":"Create a webhook subscription","description":"Subscribes a URL to one or more event types. Pass `\"*\"` to receive every type.\n\nThe response carries the signing `secret` **once** \u2014 store it now; it is never returned\nagain. The secret is always generated by SnapKey; a `secret` field in the request is\nrejected as an unknown field.\n\nThe `url` must be `https://` and resolve to a public host; loopback, private-range and\n`.local`/`.internal` hosts are refused. The API key's own location may hold at most 5 active\nsubscriptions \u2014 the sixth answers `422` with detail code `limit_reached`. The cap is per\nlocation, so departments under it have their own allowance.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreate"},"example":{"url":"https://hooks.example.com/snapkey","events":["access.granted","access.denied"],"description":"Tidevand Energi facility dashboard"}}}},"responses":{"201":{"headers":{"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"description":"The subscription was created. `secret` appears in this response only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"},"example":{"id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","url":"https://hooks.example.com/snapkey","events":["access.granted","access.denied"],"description":"Tidevand Energi facility dashboard","status":"active","failing_since":null,"last_delivery_at":null,"created_at":"2026-09-02T09:12:00Z","secret":"whsec_3f8c1d90a47b4e2fa6c5029d81be7a34"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"422":{"description":"The body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"limitReached":{"summary":"Too many subscriptions","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"url","code":"limit_reached","message":"This location already has the maximum of 5 webhooks."}]}}},"nonPublicUrl":{"summary":"URL does not point at a public host","value":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"url","code":"invalid","message":"Webhook URL must point to a public host."}]}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.snapkey.dk/public/v1/webhooks \\\n  -H \"Authorization: Bearer sk_live_9f3c\u2026\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"url\": \"https://hooks.example.com/snapkey\",\n        \"events\": [\"access.granted\", \"access.denied\"],\n        \"description\": \"Tidevand Energi facility dashboard\"\n      }'\n"}]}},"/webhooks/{id}":{"get":{"operationId":"getWebhook","tags":["Webhooks"],"summary":"Get a webhook subscription","description":"One subscription, same shape as the list. The `secret` is never returned here.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"}],"responses":{"200":{"description":"The subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"},"example":{"id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","url":"https://hooks.example.com/snapkey","events":["access.granted","access.denied"],"description":"Tidevand Energi facility dashboard","status":"active","failing_since":null,"last_delivery_at":"2026-09-02T08:15:31Z","created_at":"2026-08-20T09:00:00Z"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"patch":{"operationId":"updateWebhook","tags":["Webhooks"],"summary":"Update a webhook subscription","description":"Changes any of `url`, `events`, `description`, `status`. At least one field is required.\n\nSetting `status` to `paused` stops deliveries immediately (in-flight retries fail with\n\"Subscription is paused.\"). Setting it to `active` on a paused subscription resumes it and\nclears `failing_since` \u2014 the same effect as the dashboard's Resume button; on an already\nactive subscription it is a no-op. `url` and `events` are validated exactly as on create;\nchanging `url` does not rotate the secret.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookUpdate"},"example":{"status":"active"}}}},"responses":{"200":{"description":"The updated subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"},"example":{"id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","url":"https://hooks.example.com/snapkey","events":["access.granted","access.denied"],"description":"Tidevand Energi facility dashboard","status":"active","failing_since":null,"last_delivery_at":"2026-09-02T08:15:31Z","created_at":"2026-08-20T09:00:00Z"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"The body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"body","code":"invalid","message":"At least one field is required."}]}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"operationId":"deleteWebhook","tags":["Webhooks"],"summary":"Delete a webhook subscription","description":"Deletes a subscription. Deliveries stop immediately.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"}],"responses":{"204":{"description":"The subscription was deleted. No body."},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/webhooks/{id}/ping":{"post":{"operationId":"pingWebhook","tags":["Webhooks"],"summary":"Ping a webhook subscription","description":"Sends a test delivery \u2014 `{\"message\": \"pong\"}` under event type `ping` \u2014 to the subscription\nURL **synchronously** and returns the resulting delivery record, so you can see the exact\nHTTP status and error your endpoint produced.\n\nA ping is a single attempt: it is never retried, it does not clear a subscription's failure\nstreak and it does not un-pause it. Pings are limited to **10 per minute**.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"The delivery record for the ping.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Delivery"},"example":{"id":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55","subscription_id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","event_type":"ping","event_id":null,"status":"delivered","attempt":1,"response_status":200,"last_error":null,"next_attempt_at":null,"delivered_at":"2026-09-02T09:13:04Z","created_at":"2026-09-02T09:13:03Z","payload":{"message":"pong"}}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"More than 10 pings in a minute. Retry after the number of seconds in `Retry-After`.","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/ServerError"}}}},"/webhooks/{id}/deliveries":{"get":{"operationId":"listWebhookDeliveries","tags":["Webhooks"],"summary":"List a subscription's deliveries","description":"A cursor page of delivery attempts against this subscription, newest first. Retention is\n**30 days**.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"},{"name":"status","in":"query","required":false,"description":"Only deliveries in this status.","schema":{"$ref":"#/components/schemas/DeliveryStatus"}},{"name":"event_type","in":"query","required":false,"description":"Only deliveries of this event type. Any name from `WebhookEventName`, plus `ping`, which is a delivery type rather than a subscribable event.","schema":{"type":"string"},"example":"access.granted"},{"name":"since","in":"query","required":false,"description":"Only deliveries created at or after this instant. ISO 8601; parsed as UTC when no offset is given.","schema":{"type":"string","format":"date-time"},"example":"2026-09-01T00:00:00Z"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"}],"responses":{"200":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"A page of deliveries.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryList"},"example":{"data":[{"id":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55","subscription_id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","event_type":"access.granted","event_id":"9910427","status":"delivered","attempt":1,"response_status":200,"last_error":null,"next_attempt_at":null,"delivered_at":"2026-09-02T08:15:31Z","created_at":"2026-09-02T08:15:30Z","payload":{"id":9910427,"type":"access.granted","occurred_at":"2026-09-02T08:15:30Z"}}],"next_cursor":null}}}},"400":{"description":"An invalid `status` or `since` value.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_request","message":"Invalid \"status\" value."}}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/webhooks/{id}/deliveries/{deliveryId}/redeliver":{"post":{"operationId":"redeliverWebhookDelivery","tags":["Webhooks"],"summary":"Redeliver a delivery","description":"Sends the same `event_type` and payload again as a brand-new delivery, `202` at the default\nattempt (0), so it follows the full retry ladder from the start. The original delivery row is untouched; the new one's `event_id`\nis the original delivery's id, so your duplicate guard treats it as the same event. Works\nfor any original status, including `delivered` and pings.\n\nOnly on an `active` subscription \u2014 otherwise `409 subscription_paused`. Limited to **30 per\nminute, per API key**.\n\nRequires scope `webhooks:manage`.\n","parameters":[{"$ref":"#/components/parameters/WebhookId"},{"name":"deliveryId","in":"path","required":true,"description":"The delivery to redeliver.","schema":{"type":"string","format":"uuid"}}],"responses":{"202":{"headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"description":"The new delivery, queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Delivery"},"example":{"id":"9b3e5f71-1a4c-4e3a-8b0d-6f2c7a91de44","subscription_id":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1","event_type":"access.granted","event_id":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55","status":"pending","attempt":1,"response_status":null,"last_error":null,"next_attempt_at":"2026-09-02T09:20:00Z","delivered_at":null,"created_at":"2026-09-02T09:20:00Z","payload":{"id":9910427,"type":"access.granted","occurred_at":"2026-09-02T08:15:30Z"}}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The subscription is paused.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"subscription_paused","message":"Resume the subscription before redelivering."}}}}},"429":{"description":"More than 30 redeliveries in a minute. Retry after the number of seconds in `Retry-After`.","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/ServerError"}}}}},"webhooks":{"access.granted":{"post":{"operationId":"onAccessGranted","summary":"A door was opened","description":"Sent when a key opened a lock. `data` is the same object `GET /events` returns.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockEventDelivery"},"example":{"id":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55","type":"access.granted","created_at":"2026-09-02T08:15:31Z","data":{"id":9910427,"type":"access.granted","occurred_at":"2026-09-02T08:15:30Z","result":"success","reason":null,"source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"access.denied":{"post":{"operationId":"onAccessDenied","summary":"A door refused a key","description":"Sent when a lock refused a key. `data.reason` says why: `no_access`, `outside_time_window`,\n`unknown_key`, or `null` when the locking system gave no reason. A refusal is the one case\nthat can arrive with no `person` and no `key` \u2014 an unknown key was presented, as below.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockEventDelivery"},"example":{"id":"2b7d40e9-9c11-4f6a-b3d5-1e08a7c62f93","type":"access.denied","created_at":"2026-09-02T19:42:12Z","data":{"id":9910435,"type":"access.denied","occurred_at":"2026-09-02T19:42:11Z","result":"denied","reason":"unknown_key","source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":null,"key":null}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"door.closed":{"post":{"operationId":"onDoorClosed","summary":"A door was closed","description":"Sent when a lock reported the door closed. The event inherits `person` and `key` from the\naccess that opened the door.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockEventDelivery"},"example":{"id":"8a5c3e10-64b2-4d8f-a09c-2f71b4e5d602","type":"door.closed","created_at":"2026-09-02T08:16:02Z","data":{"id":9910429,"type":"door.closed","occurred_at":"2026-09-02T08:16:01Z","result":"success","reason":null,"source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"door.left_open":{"post":{"operationId":"onDoorLeftOpen","summary":"A door did not report back","description":"Sent when a lock did not report a close after being opened \u2014 the door is standing open, or\nthe lock stopped answering. The event inherits `person` and `key` from the access that\nopened the door.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockEventDelivery"},"example":{"id":"1c4f7b28-05de-4a63-91b7-c8e02a6d3f45","type":"door.left_open","created_at":"2026-09-02T08:21:10Z","data":{"id":9910431,"type":"door.left_open","occurred_at":"2026-09-02T08:21:09Z","result":"success","reason":null,"source":"iloq","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"key.activated":{"post":{"operationId":"onKeyActivated","summary":"A key was activated","description":"Sent whenever a key's state becomes `handed_over` \u2014 a person activating a setup link, a\nmanager handing a key over in the SnapKey dashboard, or a reconciliation against the locking\nsystem. It is **not** a receipt for one of your `POST /keys` calls, so reconcile on\n`data.key.id` (which is also `data.id`), never on your own invitation ids.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyEventDelivery"},"example":{"id":"5e9b6f31-7a24-4c08-8de1-30b95c2f7a86","type":"key.activated","created_at":"2026-09-01T07:03:22Z","data":{"id":88213,"type":"key.activated","occurred_at":"2026-09-01T07:03:22Z","person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff","state":"handed_over"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"key.revoked":{"post":{"operationId":"onKeyRevoked","summary":"A key was revoked","description":"Sent when a key is returned or deleted \u2014 including by `DELETE /keys/{id}`.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyEventDelivery"},"example":{"id":"7d31c8a0-4e6b-4915-b2fa-91c07de4a538","type":"key.revoked","created_at":"2026-09-02T10:30:00Z","data":{"id":88213,"type":"key.revoked","occurred_at":"2026-09-02T10:30:00Z","person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":{"id":88213,"name":"Headquarters staff","state":"returned"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"ping":{"post":{"operationId":"onPing","summary":"A test delivery","description":"Sent by `POST /webhooks/{id}/ping`. Never produced by a real event, and never subscribed to\n\u2014 use it to confirm your endpoint is reachable and your signature check works.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PingDelivery"},"example":{"id":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55","type":"ping","created_at":"2026-09-02T09:13:03Z","data":{"message":"pong"}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. A ping is never retried."}}}},"unlock.failed":{"post":{"operationId":"onUnlockFailed","summary":"A remote unlock was never confirmed","description":"Sent when a remote-lock command was issued but the device never confirmed it happened \u2014\n`reason` says why: `timed_out` (no acknowledgement in time), `device_missing` (no device\nregistered on the gateway), `publish_failed` (could not reach the gateway) or\n`device_error` (the device answered with an error). `data` is the same object `GET /events`\nreturns for a door event, but `unlock.failed` itself is **not** listed by `GET /events` \u2014\nit is webhook-only.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockFailedDelivery"},"example":{"id":"3f8a6c12-9e04-4b7d-a1c6-5d20e8f4b937","type":"unlock.failed","created_at":"2026-09-05T14:02:11Z","data":{"id":9911042,"type":"unlock.failed","occurred_at":"2026-09-05T14:02:10Z","result":"denied","reason":"timed_out","source":"app","api_key":null,"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"key":null}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"key.issued":{"post":{"operationId":"onKeyIssued","summary":"A setup link was sent","description":"Sent when a setup link is sent to a person, by e-mail and/or SMS \u2014 from `POST /keys` or from\nthe SnapKey dashboard. This is a receipt for the invitation, not for activation: the key\nitself does not exist until the person opens the link, at which point `key.activated`\nfollows.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyIssuedDelivery"},"example":{"id":"1a9d4e70-6c2b-4f18-9a3e-8b05c7d21f64","type":"key.issued","created_at":"2026-09-02T09:00:00Z","data":{"invitation_id":55211,"channel":"sms","person":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com"},"security_groups":["HQ-STAFF"],"starts_at":"2026-09-02T09:00:00Z","expires_at":"2026-12-02T09:00:00Z"}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"person.created":{"post":{"operationId":"onPersonCreated","summary":"A person was created","description":"Sent when a person is created with a client-visible field set \u2014 through the API, the\ndashboard, SCIM, or an iLOQ sync. `data` is the same object `GET /people/{id}` returns\n(without `keys`).\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonCreatedDelivery"},"example":{"id":"7c2e9a41-3d68-4b0f-9c17-2a85e6d40b73","type":"person.created","created_at":"2026-09-02T09:05:00Z","data":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520123456","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location":{"id":12,"name":"Headquarters"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"person.updated":{"post":{"operationId":"onPersonUpdated","summary":"A person's client-visible fields changed","description":"Sent when any of `name`, `email`, `phone`, `company_name`, `title` or `language` changes \u2014\non any write path. A save that touches only other columns (sync bookkeeping, for example)\nemits nothing. `data` is the `GET /people/{id}` object (without `keys`) plus\n`changed_fields`, the names of the fields that changed in this write.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonUpdatedDelivery"},"example":{"id":"5b1f8d63-2a47-4e9c-8b06-3f71c9a52e08","type":"person.updated","created_at":"2026-09-02T09:10:00Z","data":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","phone":"+4520987654","company_name":"Tidevand Energi","title":"Facility Manager","language":"da","location":{"id":12,"name":"Headquarters"},"changed_fields":["phone"]}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"person.deleted":{"post":{"operationId":"onPersonDeleted","summary":"A person was deleted","description":"Sent on a soft or hard delete, on any write path. `data` carries only what still exists to\nreport \u2014 no `phone`, `company_name`, `title` or `language`.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDeletedDelivery"},"example":{"id":"4e0a7c25-8b31-4f6d-9c02-7a15e3f8b904","type":"person.deleted","created_at":"2026-09-02T09:15:00Z","data":{"id":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40","name":"Mette S\u00f8rensen","email":"mette.sorensen@example.com","location":{"id":12,"name":"Headquarters"}}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"lock.online":{"post":{"operationId":"onLockOnline","summary":"A remote lock's gateway came online","description":"Sent when a remote lock's Teltonika gateway flips from offline (or never-reported) to\nonline \u2014 either reported within seconds by the gateway's own presence webhook, or caught by\na once-a-minute sweep. `since` is when the flip is believed to have happened. One delivery\nper lock on the gateway; a gateway that never leaves the online state emits nothing further.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockPresenceDelivery"},"example":{"id":"2d6b9f04-5a13-4c8e-8f70-1b94d6e5c832","type":"lock.online","created_at":"2026-09-05T10:00:05Z","data":{"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"online":true,"since":"2026-09-05T10:00:02Z"}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}},"lock.offline":{"post":{"operationId":"onLockOffline","summary":"A remote lock's gateway went offline","description":"Sent when a remote lock's Teltonika gateway flips from online to offline \u2014 a disconnect\nreported by the gateway's own presence webhook, or **45 minutes** without a heartbeat caught\nby the once-a-minute sweep. `since` is when the flip is believed to have happened. A unit\nthat reconnects within the 45-minute staleness window after a silent gap never emits this.\n","security":[],"parameters":[{"$ref":"#/components/parameters/WebhookEventHeader"},{"$ref":"#/components/parameters/WebhookDeliveryHeader"},{"$ref":"#/components/parameters/WebhookTimestampHeader"},{"$ref":"#/components/parameters/WebhookSignatureHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LockPresenceDelivery"},"example":{"id":"8e1c4a70-3f26-4d9b-9a05-6c82e7f10b53","type":"lock.offline","created_at":"2026-09-05T11:15:03Z","data":{"lock":{"id":4172,"name":"Main entrance","place":"Ground floor, east"},"location":{"id":12,"name":"Headquarters"},"online":false,"since":"2026-09-05T10:30:00Z"}}}}},"responses":{"2XX":{"description":"Any 2xx within 10 seconds marks the delivery `delivered`. Anything else is retried."}}}}},"components":{"securitySchemes":{"ApiKey":{"type":"http","scheme":"bearer","bearerFormat":"sk_live_\u2026","description":"`Authorization: Bearer sk_live_\u2026` \u2014 created in the SnapKey dashboard under\n**Developer \u2192 API keys** and shown once, at creation.\n"}},"headers":{"RateLimitLimit":{"description":"Requests allowed per minute against this API key (or, unauthenticated, this IP).","schema":{"type":"integer"},"example":600},"RateLimitRemaining":{"description":"Requests left in the current minute window.","schema":{"type":"integer"},"example":599},"RetryAfter":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"},"example":37},"IdempotentReplayed":{"description":"Present and `true` when this body is a stored replay rather than a fresh execution.","schema":{"type":"string"},"example":"true"}},"parameters":{"Limit":{"name":"limit","in":"query","required":false,"description":"Items per page. Values below 1 or above 200 are clamped into that range rather than\nrejected; a non-integer is rejected with `422 validation_failed` \u2014 except on\n`GET /webhooks`, which does not validate `limit` and simply clamps whatever it can cast.\n","schema":{"type":"integer","default":50},"example":50},"Cursor":{"name":"cursor","in":"query","required":false,"description":"Opaque page cursor \u2014 the `next_cursor` of the previous page, passed back verbatim.","schema":{"type":"string"},"example":"eyJpZCI6NDE3MiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Retry-safety token for this operation, 1\u201364 characters of `A-Za-z0-9_-`, unique per logical\nrequest. A repeat with the same key and body replays the stored response\n(`Idempotent-Replayed: true`); a repeat with a different body answers `409\nidempotency_conflict`. Kept 24 hours, scoped to the API key. Responses larger than 64 KB, or\nthat are not JSON, are not stored: a retry with the same key executes the request again\n(never a replay).\n","schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"},"example":"order-4711"},"LocationId":{"name":"location_id","in":"query","required":false,"description":"Restrict to a single location inside the API key's scope.","schema":{"type":"integer"},"example":12},"PersonId":{"name":"id","in":"path","required":true,"description":"The person's UUID.","schema":{"type":"string","format":"uuid"},"example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"WebhookId":{"name":"id","in":"path","required":true,"description":"The subscription's UUID.","schema":{"type":"string","format":"uuid"},"example":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1"},"WebhookEventHeader":{"name":"X-SnapKey-Event","in":"header","required":true,"description":"The event type, identical to `type` in the body.","schema":{"type":"string"},"example":"access.granted"},"WebhookDeliveryHeader":{"name":"X-SnapKey-Delivery","in":"header","required":true,"description":"The delivery UUID, identical to `id` in the body. Stable across retries.","schema":{"type":"string","format":"uuid"},"example":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55"},"WebhookTimestampHeader":{"name":"X-SnapKey-Timestamp","in":"header","required":true,"description":"Unix seconds at which this attempt was sent.","schema":{"type":"string"},"example":"1788336930"},"WebhookSignatureHeader":{"name":"X-SnapKey-Signature","in":"header","required":true,"description":"`t=<unix seconds>,v1=<hex HMAC-SHA256(secret, \"{t}.{raw body}\")>` \u2014 reject a `t` more than 300 s from your clock, then compare `v1` in constant time.","schema":{"type":"string","pattern":"^t=[0-9]+,v1=[0-9a-f]{64}$"},"example":"t=1788336930,v1=6c1e0a53f9d4b7e28a3105cf7b4d92e0a8c31f6b5d70e94a2c8b1f3d05e7a964"}},"responses":{"Unauthenticated":{"description":"Missing, invalid, expired or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthenticated","message":"Missing, invalid, expired or revoked API key."}}}}},"Forbidden":{"description":"The key does not hold the scope this endpoint needs (`insufficient_scope`), or the account is\na reseller (platform-mode) account, which v1 does not support (`not_available`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"insufficientScope":{"summary":"Missing scope","value":{"error":{"code":"insufficient_scope","message":"This API key does not have the catalog:read scope."}}},"resellerAccount":{"summary":"Reseller account","value":{"error":{"code":"not_available","message":"The public API is not yet available for reseller accounts."}}}}}}},"NotFound":{"description":"No such row \u2014 including rows that exist outside the API key's scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No such resource in your account."}}}}},"ValidationFailed":{"description":"The request failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"validation_failed","message":"The request could not be processed.","details":[{"field":"limit","code":"invalid","message":"The limit field must be an integer."}]}}}}},"RateLimited":{"description":"Rate limit exceeded.","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Too many requests. Retry after the number of seconds in Retry-After."}}}}},"ServerError":{"description":"Unexpected failure. Safe to retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"server_error","message":"Unexpected failure. Safe to retry with backoff."}}}}},"IdempotencyConflict":{"description":"This `Idempotency-Key` was already used for a different request body (`idempotency_conflict`),\nor the first call with it is still running (`idempotency_in_progress`).\n","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter","description":"Present only when `code` is `idempotency_in_progress`; seconds to wait before retrying."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"conflict":{"summary":"Reused for a different body","value":{"error":{"code":"idempotency_conflict","message":"This Idempotency-Key was already used for a different request."}}},"inProgress":{"summary":"First call still running","value":{"error":{"code":"idempotency_in_progress","message":"A request with this Idempotency-Key is still being processed."}}}}}}},"InvitationNotPending":{"description":"The invitation is not pending (already activated, expired or cancelled). Nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invitation_not_pending","message":"Only a pending invitation can be resent."}}}}}},"schemas":{"Scope":{"type":"string","description":"A permission an API key can carry.","enum":["catalog:read","people:write","people:read","keys:write","keys:read","events:read","webhooks:manage","locks:control"]},"ErrorCode":{"type":"string","enum":["invalid_request","unauthenticated","insufficient_scope","not_available","forbidden","not_found","iloq_rejected","idempotency_conflict","idempotency_in_progress","subscription_paused","validation_failed","invitation_not_pending","entitlement_exceeded","rate_limited","server_error","lock_not_allowed","lock_not_remote","lock_offline","publish_failed"]},"ErrorDetail":{"type":"object","description":"One field-level problem.","required":["field","code","message"],"properties":{"field":{"type":"string","description":"The offending field, e.g. `phone` or `security_groups[0]`.","example":"phone"},"code":{"type":"string","enum":["invalid","unknown_field","reserved_name","unknown_security_group","sms_disabled","limit_reached","not_editable"],"example":"invalid"},"message":{"type":"string","example":"Phone must be in international format, e.g. +4520123456."}}},"Error":{"type":"object","description":"The envelope of every non-2xx response.","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"$ref":"#/components/schemas/ErrorCode"},"message":{"type":"string","example":"The request could not be processed."},"details":{"type":"array","description":"Present only for `validation_failed`.","items":{"$ref":"#/components/schemas/ErrorDetail"}}}}}},"LocationRef":{"type":"object","description":"The location a row belongs to.","required":["id","name"],"properties":{"id":{"type":"integer","example":12},"name":{"type":"string","example":"Headquarters"}}},"PersonRef":{"type":"object","required":["id","name","email"],"properties":{"id":{"type":"string","format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"name":{"type":"string","example":"Mette S\u00f8rensen"},"email":{"type":["string","null"],"example":"mette.sorensen@example.com"}}},"Lock":{"type":"object","required":["id","name","serial_number","place","provider","online","security_groups","location"],"properties":{"id":{"type":"integer","format":"int64","example":4172},"name":{"type":["string","null"],"example":"Main entrance"},"serial_number":{"type":["string","null"],"description":"The lock's serial number in the locking system.","example":"S5-004172"},"place":{"type":["string","null"],"description":"Where the lock sits, as written in SnapKey.","example":"Ground floor, east"},"provider":{"type":"string","description":"`iloq` \u2014 a lock in the iLOQ locking system. `teltonika` \u2014 a relay-driven lock SnapKey opens over the network.","enum":["iloq","teltonika"],"example":"teltonika"},"online":{"type":["boolean","null"],"description":"Whether the lock's device is reachable right now. `null` for an iLOQ lock, whose reachability SnapKey cannot see.","example":true},"security_groups":{"type":"array","description":"Codes of the security groups that open this lock.","items":{"type":"string"},"example":["HQ-STAFF"]},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]}}},"LockCommand":{"type":"object","description":"One remote unlock, from request to the device's answer.","required":["id","lock_id","status","requested_at","published_at","confirmed_at","timeout_seconds","error"],"properties":{"id":{"type":"string","format":"uuid","example":"0d3f6c2a-7b1e-4f0a-9c8d-2e5b7a1f4c33"},"lock_id":{"type":"integer","format":"int64","example":4172},"status":{"type":"string","description":"`requested` \u2192 `published` (sent to the device) \u2192 `confirmed` (device answered) \u2014 or `failed` (could not be sent) / `timed_out` (no answer within `timeout_seconds`).","enum":["requested","published","confirmed","failed","timed_out"],"example":"published"},"requested_at":{"type":"string","format":"date-time","example":"2026-09-19T09:40:11Z"},"published_at":{"type":["string","null"],"format":"date-time","example":"2026-09-19T09:40:11Z"},"confirmed_at":{"type":["string","null"],"format":"date-time","example":null},"timeout_seconds":{"type":"integer","example":10},"error":{"type":["string","null"],"description":"Why the command did not complete; `null` while pending or when confirmed.","enum":[null,"timed_out","device_missing","publish_failed","device_error"],"example":null}}},"SecurityGroup":{"type":"object","required":["id","code","title","description","is_default"],"properties":{"id":{"type":"integer","format":"int64","example":301},"code":{"type":["string","null"],"description":"The value you pass in `security_groups` when issuing a key.","example":"HQ-STAFF"},"title":{"type":["string","null"],"example":"Headquarters staff"},"description":{"type":["string","null"],"example":"All shared doors at the Headquarters address."},"is_default":{"type":"boolean","description":"Whether new keys at this location get this group by default.","example":true}}},"KeyType":{"type":"string","description":"`digital` \u2014 a key in the SnapKey app. `card` \u2014 a physical card or fob issued by SnapKey.\n`s5` \u2014 a physical iLOQ S5 key managed in iLOQ Manager.\n","enum":["digital","card","s5"]},"KeyState":{"type":"string","description":"`planning` \u2014 prepared, not yet sent. `sent` \u2014 the setup link is out, not yet activated.\n`handed_over` \u2014 active and usable. `returned` \u2014 revoked. `other` \u2014 anything else.\n","enum":["planning","sent","handed_over","returned","other"]},"Key":{"type":"object","description":"An access grant. A key issued through this API exists only once the person has activated\ntheir setup link; a card or S5 fob managed elsewhere exists as soon as it is registered.\n","required":["id","name","type","state","person_id","invitation_id","security_groups","starts_at","expires_at"],"properties":{"id":{"type":"integer","format":"int64","example":88213},"name":{"type":["string","null"],"example":"Headquarters staff"},"type":{"$ref":"#/components/schemas/KeyType"},"state":{"$ref":"#/components/schemas/KeyState"},"person_id":{"type":["string","null"],"format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"invitation_id":{"type":["integer","null"],"format":"int64","description":"The invitation (setup link) this key was activated from. `null` for cards, S5 fobs and keys that never had a setup link.","example":5521},"security_groups":{"type":"array","items":{"type":"string"},"example":["HQ-STAFF"]},"starts_at":{"type":["string","null"],"format":"date-time","description":"When the key becomes valid. `null` means immediately.","example":"2026-09-01T00:00:00Z"},"expires_at":{"type":["string","null"],"format":"date-time","description":"When the key stops working. `null` means never.","example":null}}},"Person":{"type":"object","required":["id","name","email","phone","company_name","title","language","location"],"properties":{"id":{"type":"string","format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"name":{"type":["string","null"],"example":"Mette S\u00f8rensen"},"email":{"type":["string","null"],"format":"email","example":"mette.sorensen@example.com"},"phone":{"type":["string","null"],"example":"+4520123456"},"company_name":{"type":["string","null"],"example":"Tidevand Energi"},"title":{"type":["string","null"],"example":"Facility Manager"},"language":{"type":["string","null"],"enum":["da","en",null],"example":"da"},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]}}},"PersonWithKeys":{"allOf":[{"$ref":"#/components/schemas/Person"},{"type":"object","required":["keys"],"properties":{"keys":{"type":"array","description":"The keys this person holds inside the API key's scope. Only on `GET /people/{id}`.","items":{"$ref":"#/components/schemas/Key"}}}}]},"PersonCreate":{"type":"object","description":"Unknown properties are rejected with `422 validation_failed`.","required":["name","email","phone"],"additionalProperties":false,"properties":{"name":{"type":"string","maxLength":101,"description":"First and last name, separated by whitespace.","example":"Mette S\u00f8rensen"},"email":{"type":"string","format":"email","maxLength":255,"example":"mette.sorensen@example.com"},"phone":{"type":"string","pattern":"^\\+?[0-9 ]{6,20}$","description":"International format, e.g. `+4520123456`.","example":"+4520123456"},"company_name":{"type":["string","null"],"maxLength":100,"example":"Tidevand Energi"},"title":{"type":["string","null"],"maxLength":100,"example":"Facility Manager"},"language":{"type":["string","null"],"enum":["da","en",null],"description":"Language of the setup link and reminders. Defaults to the location's language.","example":"da"},"location_id":{"type":["integer","null"],"description":"Which location to place the person in. Must be inside the API key's scope. Defaults to\nthe API key's own location.\n","example":12}}},"PersonUpdate":{"type":"object","description":"Every field is optional; only what you send is changed. Unknown properties are rejected.","additionalProperties":false,"properties":{"name":{"type":"string","maxLength":101,"example":"Mette S\u00f8rensen"},"email":{"type":"string","format":"email","maxLength":255,"example":"mette.sorensen@example.com"},"phone":{"type":"string","pattern":"^\\+?[0-9 ]{6,20}$","example":"+4520123456"},"company_name":{"type":["string","null"],"maxLength":100,"example":"Tidevand Energi"},"title":{"type":["string","null"],"maxLength":100,"example":"Head of Facilities"},"language":{"type":["string","null"],"enum":["da","en",null],"example":"da"},"location_id":{"type":"integer","description":"Must be inside the API key's scope.","example":12}}},"KeyCreate":{"type":"object","description":"Unknown properties are rejected with `422 validation_failed`.","required":["person_id","security_groups"],"additionalProperties":false,"properties":{"person_id":{"type":"string","format":"uuid","description":"The person to issue the key to. Must be inside the API key's scope.","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"security_groups":{"type":"array","minItems":1,"description":"Security group codes from `GET /security_groups`. All must exist at the API key's location.","items":{"type":"string","maxLength":64},"example":["HQ-STAFF"]},"name":{"type":["string","null"],"maxLength":50,"description":"Label the person sees. Defaults to the title of the first security group.","example":"Headquarters staff"},"starts_at":{"type":["string","null"],"format":"date-time","description":"When the key becomes valid. Omit for immediately.","example":"2026-09-01T00:00:00Z"},"expires_at":{"type":["string","null"],"format":"date-time","description":"When the key stops working. Must be after `starts_at`. Omit for never.","example":"2027-09-01T00:00:00Z"},"channel":{"type":["string","null"],"enum":["sms","email","both",null],"default":"sms","description":"How the setup link is delivered. `sms` and `both` require SMS to be enabled for the account.","example":"sms"}}},"KeyUpdate":{"type":"object","description":"Fields of `PATCH /keys/{id}`. All optional; send at least one.","properties":{"name":{"type":["string","null"],"maxLength":50,"example":"Headquarters staff"},"security_groups":{"type":"array","minItems":1,"items":{"type":"string","maxLength":64},"description":"Replaces the key's security groups. Codes must exist at the API key's location.","example":["HQ-STAFF"]},"starts_at":{"type":["string","null"],"format":"date-time","description":"`null` means valid immediately.","example":"2026-09-01T00:00:00Z"},"expires_at":{"type":["string","null"],"format":"date-time","description":"Must be after `starts_at`. `null` means never.","example":"2027-09-01T00:00:00Z"}}},"InvitationCreated":{"type":"object","description":"The result of `POST /keys`. The key itself does not exist yet \u2014 it appears in `GET /keys`\nonce the person activates the setup link.\n","required":["invitation_id","person_id","security_groups","state","sent"],"properties":{"invitation_id":{"type":"integer","format":"int64","description":"Identifies the setup link, not the key.","example":5521},"person_id":{"type":"string","format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"security_groups":{"type":"array","items":{"type":"string"},"example":["HQ-STAFF"]},"state":{"type":"string","const":"sent","example":"sent"},"sent":{"type":"object","description":"Which channels actually accepted the setup link.","required":["email","sms"],"properties":{"email":{"type":"boolean","example":false},"sms":{"type":"boolean","example":true}}}}},"InvitationState":{"type":"string","description":"`pending` \u2014 the setup link is out and can still be activated. `activated` \u2014 the person\nturned it into a key. `expired` \u2014 past its expiry, or consumed without a key. `cancelled` \u2014\nrecalled with `DELETE /invitations/{id}`.\n","enum":["pending","activated","expired","cancelled"]},"Invitation":{"type":"object","description":"A setup link issued to a person. The key it produces is a separate resource.","required":["id","state","person_id","key_id","name","security_groups","starts_at","expires_at","sent","created_at"],"properties":{"id":{"type":"integer","format":"int64","example":5521},"state":{"$ref":"#/components/schemas/InvitationState"},"person_id":{"type":"string","format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"key_id":{"type":["integer","null"],"format":"int64","description":"The key activated from this invitation; `null` until then.","example":null},"name":{"type":["string","null"],"example":"Headquarters staff"},"security_groups":{"type":"array","items":{"type":"string"},"example":["HQ-STAFF"]},"starts_at":{"type":["string","null"],"format":"date-time","example":"2026-09-01T00:00:00Z"},"expires_at":{"type":["string","null"],"format":"date-time","example":"2027-09-01T00:00:00Z"},"sent":{"type":"object","description":"When the link last went out on each channel. The SMS stamp is written when the message is actually sent, a few seconds after the request.","required":["email_at","sms_at"],"properties":{"email_at":{"type":["string","null"],"format":"date-time","example":null},"sms_at":{"type":["string","null"],"format":"date-time","example":"2026-09-19T08:12:03Z"}}},"created_at":{"type":"string","format":"date-time","example":"2026-09-19T08:12:02Z"}}},"EventTypeName":{"type":"string","description":"The door events the API exposes.","enum":["access.granted","access.denied","door.closed","door.left_open"]},"WebhookEventName":{"type":"string","description":"The event types a subscription can listen for. `*` means every type.","enum":["access.granted","access.denied","door.closed","door.left_open","key.activated","key.revoked","unlock.failed","key.issued","person.created","person.updated","person.deleted","lock.online","lock.offline","*"]},"EventResult":{"type":"string","enum":["success","denied"]},"EventReason":{"description":"Why access was refused. `null` whenever `result` is `success`, and also when the locking\nsystem gave no reason. `lock_not_allowed` \u2014 an API key tried a lock outside its allowlist.\n","oneOf":[{"type":"null"},{"type":"string","enum":["no_access","outside_time_window","unknown_key","lock_not_allowed"]}]},"Event":{"type":"object","description":"One row of the access log.","required":["id","type","occurred_at","result","reason","lock","location","person","key","source","api_key"],"properties":{"id":{"type":"integer","format":"int64","description":"Ascending in receive order \u2014 the value cursors page over.","example":9910427},"type":{"$ref":"#/components/schemas/EventTypeName"},"occurred_at":{"type":["string","null"],"format":"date-time","example":"2026-09-02T08:15:30Z"},"result":{"$ref":"#/components/schemas/EventResult"},"reason":{"$ref":"#/components/schemas/EventReason"},"source":{"type":"string","description":"What opened, or tried to open, the door: a person in the SnapKey app (`app`), an integration through this API (`api`), or the locking system itself (`iloq` \u2014 a key or fob at the cylinder).","enum":["app","api","iloq"],"example":"iloq"},"api_key":{"description":"The API key behind an `api` event; `null` otherwise. Its `name` is the one given in the dashboard.","oneOf":[{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","format":"int64","example":17},"name":{"type":"string","example":"Facility bot"}}},{"type":"null"}]},"lock":{"oneOf":[{"type":"object","required":["id","name","place"],"properties":{"id":{"type":"integer","format":"int64","example":4172},"name":{"type":["string","null"],"example":"Main entrance"},"place":{"type":["string","null"],"example":"Ground floor, east"}}},{"type":"null"}]},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]},"person":{"oneOf":[{"$ref":"#/components/schemas/PersonRef"},{"type":"null"}]},"key":{"oneOf":[{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","format":"int64","example":88213},"name":{"type":["string","null"],"example":"Headquarters staff"}}},{"type":"null"}]}}},"KeyEvent":{"type":"object","description":"The `data` of a `key.activated` or `key.revoked` delivery.","required":["id","type","occurred_at","person","key"],"properties":{"id":{"type":"integer","format":"int64","description":"The key id. Dedupe on this together with `type`.","example":88213},"type":{"type":"string","enum":["key.activated","key.revoked"],"example":"key.activated"},"occurred_at":{"type":"string","format":"date-time","example":"2026-09-01T07:03:22Z"},"person":{"oneOf":[{"$ref":"#/components/schemas/PersonRef"},{"type":"null"}]},"key":{"type":"object","required":["id","name","state"],"properties":{"id":{"type":"integer","format":"int64","example":88213},"name":{"type":["string","null"],"example":"Headquarters staff"},"state":{"$ref":"#/components/schemas/KeyState"}}}}},"WebhookStatus":{"type":"string","description":"`active` \u2014 delivering. `paused` \u2014 failing for 24 hours straight; SnapKey stopped sending and\ne-mailed the user who created it, when one is known. Resume it from the dashboard, then\nreplay the gap with `GET /events`.\n","enum":["active","paused"]},"Webhook":{"type":"object","required":["id","url","events","description","status","failing_since","last_delivery_at","created_at"],"properties":{"id":{"type":"string","format":"uuid","example":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1"},"url":{"type":"string","format":"uri","example":"https://hooks.example.com/snapkey"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventName"},"example":["access.granted","access.denied"]},"description":{"type":["string","null"],"example":"Tidevand Energi facility dashboard"},"status":{"$ref":"#/components/schemas/WebhookStatus"},"failing_since":{"type":["string","null"],"format":"date-time","description":"When the current run of failures started. `null` while healthy.","example":null},"last_delivery_at":{"type":["string","null"],"format":"date-time","description":"When SnapKey last delivered successfully to this URL.","example":"2026-09-02T08:15:31Z"},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-08-20T09:00:00Z"},"secret":{"type":"string","description":"**Write-once.** Returned only in the `201` response of `POST /webhooks`; never in\n`GET /webhooks`. Store it now \u2014 it cannot be retrieved later, only replaced by deleting\nthe subscription and creating a new one.\n","example":"whsec_3f8c1d90a47b4e2fa6c5029d81be7a34"}}},"WebhookCreate":{"type":"object","description":"Unknown properties are rejected with `422 validation_failed`.","required":["url","events"],"additionalProperties":false,"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"Must be `https://` and resolve to a public host.","example":"https://hooks.example.com/snapkey"},"events":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventName"},"example":["access.granted","access.denied"]},"description":{"type":["string","null"],"maxLength":255,"example":"Tidevand Energi facility dashboard"}}},"WebhookUpdate":{"type":"object","description":"Unknown properties are rejected with `422 validation_failed`. At least one property is\nrequired.\n","additionalProperties":false,"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"Must be `https://` and resolve to a public host. Does not rotate the secret.","example":"https://hooks.example.com/snapkey"},"events":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventName"},"example":["access.granted","access.denied"]},"description":{"type":["string","null"],"maxLength":255,"example":"Tidevand Energi facility dashboard"},"status":{"$ref":"#/components/schemas/WebhookStatus"}}},"DeliveryStatus":{"type":"string","description":"`pending` \u2014 queued. `retrying` \u2014 an attempt failed, another is scheduled at\n`next_attempt_at`. `delivered` \u2014 a 2xx came back. `exhausted` \u2014 all six retries failed.\n`failed` \u2014 abandoned because the subscription was paused or the job itself died.\n","enum":["pending","retrying","delivered","exhausted","failed"]},"Delivery":{"type":"object","description":"One attempt chain against one subscription.","required":["id","subscription_id","event_type","event_id","status","attempt","response_status","last_error","next_attempt_at","delivered_at","created_at","payload"],"properties":{"id":{"type":"string","format":"uuid","description":"Also the `X-SnapKey-Delivery` header and the `id` of the delivered body.","example":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55"},"subscription_id":{"type":"string","format":"uuid","example":"4d2a91c6-8f35-4b0e-9a17-63d8c0f5e2b1"},"event_type":{"type":"string","description":"An event name, or `ping` for a test delivery.","example":"ping"},"event_id":{"type":["string","null"],"description":"The source row's id, used to suppress duplicates. `null` for a ping.","example":null},"status":{"$ref":"#/components/schemas/DeliveryStatus"},"attempt":{"type":"integer","description":"Attempts made so far.","example":1},"response_status":{"type":["integer","null"],"description":"HTTP status your endpoint answered with. `null` on a connection-level failure.","example":200},"last_error":{"type":["string","null"],"description":"`HTTP <status>` for a non-2xx, `Connection failed` for a DNS/TLS/timeout failure.","example":null},"next_attempt_at":{"type":["string","null"],"format":"date-time","description":"When the next retry is due. `null` once the chain is finished.","example":null},"delivered_at":{"type":["string","null"],"format":"date-time","example":"2026-09-02T09:13:04Z"},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-09-02T09:13:03Z"},"payload":{"description":"The `data` object that was (or will be) delivered.","type":["object","null"],"example":{"message":"pong"}}}},"DeliveryEnvelope":{"type":"object","description":"The body of every webhook POST.","required":["id","type","created_at","data"],"properties":{"id":{"type":"string","format":"uuid","description":"The delivery id. Stable across retries.","example":"6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55"},"type":{"type":"string","description":"The event type.","example":"access.granted"},"created_at":{"type":"string","format":"date-time","description":"When SnapKey created the delivery, not when this attempt was sent.","example":"2026-09-02T08:15:31Z"}}},"LockEventDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/Event"}}}]},"KeyEventDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/KeyEvent"}}}]},"PingDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["message"],"properties":{"message":{"type":"string","const":"pong","example":"pong"}}}}}]},"UnlockFailedReason":{"type":"string","description":"Why a remote unlock was never confirmed. Read from the device's own error.","enum":["timed_out","device_missing","publish_failed","device_error"]},"UnlockFailedEvent":{"type":"object","description":"The `data` of an `unlock.failed` delivery \u2014 the same shape `GET /events` uses for a door\nevent, but `type` is `unlock.failed` (never listed by `GET /events` itself) and `reason`\nexplains the failure rather than a denial.\n","required":["id","type","occurred_at","result","reason","lock","location","person","key","source","api_key"],"properties":{"id":{"type":"integer","format":"int64","description":"Ascending in receive order \u2014 dedupe on this together with `type`.","example":9911042},"type":{"type":"string","const":"unlock.failed","example":"unlock.failed"},"occurred_at":{"type":["string","null"],"format":"date-time","example":"2026-09-05T14:02:10Z"},"result":{"$ref":"#/components/schemas/EventResult"},"reason":{"$ref":"#/components/schemas/UnlockFailedReason"},"source":{"type":"string","description":"What opened, or tried to open, the door: a person in the SnapKey app (`app`), an integration through this API (`api`), or the locking system itself (`iloq` \u2014 a key or fob at the cylinder).","enum":["app","api","iloq"],"example":"app"},"api_key":{"description":"The API key behind an `api` event; `null` otherwise. Its `name` is the one given in the dashboard.","oneOf":[{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","format":"int64","example":17},"name":{"type":"string","example":"Facility bot"}}},{"type":"null"}]},"lock":{"oneOf":[{"type":"object","required":["id","name","place"],"properties":{"id":{"type":"integer","format":"int64","example":4172},"name":{"type":["string","null"],"example":"Main entrance"},"place":{"type":["string","null"],"example":"Ground floor, east"}}},{"type":"null"}]},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]},"person":{"oneOf":[{"$ref":"#/components/schemas/PersonRef"},{"type":"null"}]},"key":{"oneOf":[{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","format":"int64","example":88213},"name":{"type":["string","null"],"example":"Headquarters staff"}}},{"type":"null"}]}}},"KeyIssuedEvent":{"type":"object","description":"The `data` of a `key.issued` delivery.","required":["invitation_id","channel","person","security_groups","starts_at","expires_at"],"properties":{"invitation_id":{"type":"integer","format":"int64","description":"The setup link's id. Dedupe on this together with `type`.","example":55211},"channel":{"type":"string","enum":["sms","email","both"],"example":"sms"},"person":{"$ref":"#/components/schemas/PersonRef"},"security_groups":{"type":"array","description":"Codes of the security groups the key grants.","items":{"type":"string"},"example":["HQ-STAFF"]},"starts_at":{"type":["string","null"],"format":"date-time","example":"2026-09-02T09:00:00Z"},"expires_at":{"type":["string","null"],"format":"date-time","example":"2026-12-02T09:00:00Z"}}},"PersonDeletedEvent":{"type":"object","description":"The `data` of a `person.deleted` delivery.","required":["id","name","email","location"],"properties":{"id":{"type":"string","format":"uuid","example":"9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40"},"name":{"type":["string","null"],"example":"Mette S\u00f8rensen"},"email":{"type":["string","null"],"format":"email","example":"mette.sorensen@example.com"},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]}}},"LockPresenceEvent":{"type":"object","description":"The `data` of a `lock.online` or `lock.offline` delivery.","required":["lock","location","online","since"],"properties":{"lock":{"type":"object","required":["id","name","place"],"properties":{"id":{"type":"integer","format":"int64","example":4172},"name":{"type":["string","null"],"example":"Main entrance"},"place":{"type":["string","null"],"example":"Ground floor, east"}}},"location":{"oneOf":[{"$ref":"#/components/schemas/LocationRef"},{"type":"null"}]},"online":{"type":"boolean","description":"`true` for `lock.online`, `false` for `lock.offline`.","example":true},"since":{"type":"string","format":"date-time","description":"When the flip is believed to have happened.","example":"2026-09-05T10:00:02Z"}}},"UnlockFailedDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/UnlockFailedEvent"}}}]},"KeyIssuedDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/KeyIssuedEvent"}}}]},"PersonCreatedDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/Person"}}}]},"PersonUpdatedDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"allOf":[{"$ref":"#/components/schemas/Person"},{"type":"object","required":["changed_fields"],"properties":{"changed_fields":{"type":"array","description":"Client-visible fields that changed in this write.","items":{"type":"string"},"example":["phone"]}}}]}}}]},"PersonDeletedDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/PersonDeletedEvent"}}}]},"LockPresenceDelivery":{"allOf":[{"$ref":"#/components/schemas/DeliveryEnvelope"},{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/LockPresenceEvent"}}}]},"CursorList":{"type":"object","description":"The shape of every list response.","required":["data","next_cursor"],"properties":{"data":{"type":"array","items":[]},"next_cursor":{"type":["string","null"],"description":"Pass back as `cursor` for the next page. `null` on the last page."}}},"LockList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Lock"}}}}]},"SecurityGroupList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SecurityGroup"}}}}]},"PersonList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}]},"KeyList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Key"}}}}]},"InvitationList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Invitation"}}}}]},"InvitationResend":{"type":"object","properties":{"channel":{"type":"string","enum":["sms","email","both"],"default":"sms","example":"both"}}},"EventList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}}}}]},"WebhookList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}]},"DeliveryList":{"allOf":[{"$ref":"#/components/schemas/CursorList"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Delivery"}}}}]}}}}