Webhooks
Subscribe a URL to door and key events, verify the signature, and handle retries.
Why webhooks
A subscription has SnapKey push each event to a URL you control, so your system is told as soon as
SnapKey records it instead of polling for it. You get the door events — access.granted,
access.denied, door.closed, door.left_open — and the key lifecycle: key.activated when a
person activates a setup link, key.revoked when a key is returned or deleted.
GET /events stays the endpoint for everything a push cannot do: backfilling history, replaying the
gap after a paused subscription, and reading the access log for an audit.
Create a subscription
Subscribe a URL to one or more event types. Pass "*" to receive every type.
curl -X POST "https://api.snapkey.dk/public/v1/webhooks" \
-H "Authorization: Bearer $SNAPKEY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/snapkey", "events": [ "access.granted", "access.denied" ], "description": "Tidevand Energi facility dashboard" }'
{
"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"
}
The response carries the signing secret once — store it now; it is never returned again.
GET /webhooks never returns it. The secret is always generated by SnapKey; a secret field in the
request is rejected as an unknown field.
The url must be https:// and resolve to a public host; loopback, private-range and
.local/.internal hosts are refused. The API key's own location may hold at most 5 active
subscriptions — the sixth answers 422 with detail code limit_reached. The cap is per location,
so departments under it have their own allowance.
You can also create a subscription in the SnapKey dashboard, under Developer → Webhooks.
Manage a subscription from your system
GET /webhooks/{id} returns one subscription and PATCH /webhooks/{id} changes it, so you never
have to open the dashboard to keep an integration running. You can change the url, the events
list, the description and the status. At least one of them has to be in the body — an empty one
is refused with 422 validation_failed.
curl -X PATCH "https://api.snapkey.dk/public/v1/webhooks/<id>" \
-H "Authorization: Bearer $SNAPKEY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'
{
"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"
}
A new url is validated exactly like the one you created the subscription with: https://, a
public host, at most 2048 characters. Changing the URL does not rotate the signing secret. A new
events list is validated like the one on create — "*" or known event names, at least one.
status takes paused or active, and nothing else. paused stops deliveries: nothing new is
queued and retries still in flight fail. active on a paused subscription resumes it and clears the
failure streak, exactly what Resume in the dashboard does. active on a subscription that is
already active changes nothing.
Events
| Event | Meaning |
|---|---|
access.granted |
A door was opened |
access.denied |
A door refused a key |
door.closed |
A door was closed |
door.left_open |
A door did not report back |
key.activated |
A key was activated |
key.revoked |
A key was revoked |
key.issued |
A key invitation was sent |
unlock.failed |
A remote unlock was never confirmed by the device |
lock.online |
A remote lock became reachable |
lock.offline |
A remote lock stopped being reachable |
person.created |
A person was created |
person.updated |
A person's details changed |
person.deleted |
A person was deleted |
ping |
A test delivery |
access.granted
Sent when a key opened a lock. data is the same object GET /events returns.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": {
"id": 88213,
"name": "Headquarters staff"
}
}
}
access.denied
Sent when a lock refused a key. data.reason says why: no_access, outside_time_window,
unknown_key, or null when the locking system gave no reason. A refusal is the one case that can
arrive with no person and no key — an unknown key was presented, as below.
{
"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
}
}
door.closed
Sent when a lock reported the door closed. The event inherits person and key from the access
that opened the door.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": {
"id": 88213,
"name": "Headquarters staff"
}
}
}
door.left_open
Sent when a lock did not report a close after being opened — the door is standing open, or the lock
stopped answering. The event inherits person and key from the access that opened the door.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": {
"id": 88213,
"name": "Headquarters staff"
}
}
}
key.activated
Sent whenever a key's state becomes handed_over — a person activating a setup link, a manager
handing a key over in the SnapKey dashboard, or a reconciliation against the locking system. It is
not a receipt for one of your POST /keys calls, so reconcile on data.key.id (which is also
data.id), never on your own invitation ids.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": {
"id": 88213,
"name": "Headquarters staff",
"state": "handed_over"
}
}
}
key.revoked
Sent when a key is returned or deleted — including by DELETE /keys/{id}.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": {
"id": 88213,
"name": "Headquarters staff",
"state": "returned"
}
}
}
key.issued
Sent when an invitation for a key has been sent — by POST /keys or from the dashboard. channel
says how it went out: sms, email or both. This is the invitation, not the key coming into use;
key.activated follows when the recipient activates it.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"security_groups": [
"HQ-STAFF"
],
"starts_at": "2026-09-02T09:00:00Z",
"expires_at": "2026-12-02T09:00:00Z"
}
}
unlock.failed
Sent when a remote unlock was ordered but the device never confirmed it. data is the access-event
object with a reason alongside it: timed_out, device_missing, publish_failed or
device_error. This one is webhook-only — a remote unlock that was never confirmed is not a door
event, so it never appears in GET /events.
{
"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ørensen",
"email": "mette.sorensen@example.com"
},
"key": null
}
}
lock.online
Sent when a remote lock becomes reachable again. Presence is reported per gateway and the event is
sent once for every lock on it, with since giving the moment the state changed.
{
"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"
}
}
lock.offline
Sent when a remote lock stops being reachable. A disconnect is reported within seconds of the unit
dropping; a unit that simply goes quiet is reported offline after 45 minutes without a heartbeat. As
with lock.online, one event is sent per lock on the gateway, and since gives the moment the
state changed.
{
"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"
}
}
person.created
Sent when a person is created, whatever created them — this API, the dashboard, SCIM provisioning or
the iLOQ sync. data is the same object GET /people/{id} returns, so it holds only the fields the
API already shows you.
{
"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ørensen",
"email": "mette.sorensen@example.com",
"phone": "+4520123456",
"company_name": "Tidevand Energi",
"title": "Facility Manager",
"language": "da",
"location": {
"id": 12,
"name": "Headquarters"
}
}
}
person.updated
Sent when one of a person's client-visible fields changes — name, e-mail, phone, company, title or
language. changed_fields lists which ones. A save that touches only internal columns sends
nothing.
{
"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ørensen",
"email": "mette.sorensen@example.com",
"phone": "+4520987654",
"company_name": "Tidevand Energi",
"title": "Facility Manager",
"language": "da",
"location": {
"id": 12,
"name": "Headquarters"
},
"changed_fields": [
"phone"
]
}
}
person.deleted
Sent when a person is deleted, soft or hard.
{
"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ørensen",
"email": "mette.sorensen@example.com",
"location": {
"id": 12,
"name": "Headquarters"
}
}
}
ping
Sent by POST /webhooks/{id}/ping. Never produced by a real event, and never subscribed to — use it
to confirm your endpoint is reachable and your signature check works.
{
"id": "6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55",
"type": "ping",
"created_at": "2026-09-02T09:13:03Z",
"data": {
"message": "pong"
}
}
What a delivery looks like
A subscription receives an HTTP POST for every matching event:
POST https://hooks.example.com/snapkey
Content-Type: application/json
User-Agent: SnapKey-Webhooks/1.0
X-SnapKey-Event: access.granted
X-SnapKey-Delivery: 6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55
X-SnapKey-Timestamp: 1788336930
X-SnapKey-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "{t}.{raw body}")>
{ "id": "6f0a1d5c-…", "type": "access.granted", "created_at": "2026-09-02T08:15:30Z", "data": { … } }
| Header | Meaning |
|---|---|
X-SnapKey-Event |
The event type, identical to type in the body. |
X-SnapKey-Delivery |
The delivery UUID, identical to id in the body. Stable across retries. |
X-SnapKey-Timestamp |
Unix seconds at which this attempt was sent. |
X-SnapKey-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "{t}.{raw body}")> |
User-Agent |
SnapKey-Webhooks/1.0 |
The envelope is the same for every type:
id— the delivery id, stable across retries.type— the event type.created_at— when SnapKey created the delivery, not when this attempt was sent.data— the event itself. For a door event it is the same objectGET /eventsreturns.
Verify the signature
- Split
X-SnapKey-Signatureon,intot=<unix seconds>andv1=<hex digest>. - Reject the delivery if
tis more than 300 seconds away from your own clock — that is what stops a captured delivery from being replayed later. - Compute
HMAC-SHA256over the string"{t}.{raw body}"— the raw bytes you received, not a re-serialised copy — keyed with your subscription secret. - Compare it with
v1in constant time (hash_equals,crypto.timingSafeEqual, …).
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySnapKeySignature(header, rawBody, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(now - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
Respond fast, expect retries
Respond with any 2xx within 10 seconds. Anything else — a non-2xx status, a timeout, a connection
failure — is a failed attempt. Redirects are not followed; any 3xx is a failed attempt. SnapKey
retries after 1 m, 5 m, 30 m, 2 h, 6 h, 24 h, then marks the delivery exhausted and stops.
Paused subscriptions
A subscription that has been failing continuously for 24 hours is set to paused, and the user who
created the API key (or the subscription) is e-mailed, when one is known. A paused subscription
receives nothing; resume it from the dashboard or with PATCH /webhooks/{id}, and recover the gap
with GET /events?since=.
curl -X GET "https://api.snapkey.dk/public/v1/events?since=2026-09-01T00%3A00%3A00Z&lock_id=4172&person_id=9c1f2a84-3b7e-4d21-9f60-5a2c8d7e1b40&type=access.granted%2Caccess.denied" \
-H "Authorization: Bearer $SNAPKEY_API_KEY"
Deliveries and redelivery
GET /webhooks/{id}/deliveries lists what SnapKey tried to send to a subscription, newest first and
cursor paged, so you can see exactly which attempts your endpoint accepted or rejected.
curl -X GET "https://api.snapkey.dk/public/v1/webhooks/<id>/deliveries?event_type=access.granted&since=2026-09-01T00%3A00%3A00Z" \
-H "Authorization: Bearer $SNAPKEY_API_KEY"
{
"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
}
Narrow the list with status (a delivery status), event_type (one event name) and since (an
ISO 8601 timestamp).
POST /webhooks/{id}/deliveries/{delivery_id}/redeliver sends one of them again. It creates a fresh
delivery carrying the same event type and payload, with the original delivery's id as its
event_id, so the duplicate guard never swallows it. The original row is left untouched. The new
delivery starts over and follows the full retry ladder, and any delivery can be redelivered whatever
its status — including ones that were delivered successfully, and pings.
Redelivery only works on an active subscription: a paused one answers 409 with the code
subscription_paused. Resume the subscription first. Redelivery is limited to 30 calls per minute
per API key.
curl -X POST "https://api.snapkey.dk/public/v1/webhooks/<id>/deliveries/<deliveryId>/redeliver" \
-H "Authorization: Bearer $SNAPKEY_API_KEY"
At-least-once delivery
Delivery is at-least-once and unordered: dedupe on data.id together with type, and do not
rely on deliveries arriving in the order the events happened.
Test locally
POST /webhooks/{id}/ping sends a test delivery — {"message": "pong"} under event type ping — to
the subscription URL synchronously and returns the resulting delivery record, so you can see the
exact HTTP status and error your endpoint produced. Use it to confirm your endpoint is reachable and
your signature check works.
A ping is a single attempt: it is never retried, it does not clear a subscription's failure streak and it does not un-pause it.
curl -X POST "https://api.snapkey.dk/public/v1/webhooks/<id>/ping" \
-H "Authorization: Bearer $SNAPKEY_API_KEY"
The delivery your endpoint receives:
{
"id": "6f0a1d5c-2b47-4a19-8f31-7c9de2b04a55",
"type": "ping",
"created_at": "2026-09-02T09:13:03Z",
"data": {
"message": "pong"
}
}
The url must be https:// and resolve to a public host, so a laptop needs a tunnel — ngrok,
Cloudflare Tunnel or similar — rather than localhost.
What does not produce a webhook
GET /events exposes door events only: access.granted, access.denied, door.closed and
door.left_open. Rows created outside the door-event pipeline — internal sync — and app telemetry
are not events, so they are neither pushed to a subscription nor returned by GET /events.
door.left_open is only ever a real door-left-open report from the locking system: the lock did not
report a close after being opened. It is never inferred by SnapKey.