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 |
door.opened |
A door contact reported the door open |
door.forced |
A door opened with nothing that explains it |
door.exit_button |
Somebody pressed the exit button |
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 |
lock.hold_open.started |
A lock started holding the door open |
lock.hold_open.ended |
A lock stopped holding the door open |
lock.hold_open.failed |
A hold could not be written or verified |
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 was not confirmed closed: it may be
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"
}
}
}
door.opened
Sent when a remote lock's door-contact input closed to open — this requires a SnapGate door
contact wired to input 1. It carries no top-level person or key, and data.actor is always
null: the contact reports the door, not who opened it. Pair it with access.granted on
occurred_at when you need the actor.
{
"id": "3d81f0a6-5c72-4e38-b1a9-40e7c5b2d9f1",
"type": "door.opened",
"created_at": "2026-09-22T08:15:31Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T08:15:30Z",
"source": "device",
"actor": null,
"details": {}
}
}
door.forced
Sent when the door contact reported an opening with no confirmed unlock or exit-button press in the
last 10 seconds, and the door isn't currently held open — the door was forced or propped. This
requires a SnapGate door contact on input 1 with an exit button wired to input 2; without an exit
button wired, forced-open detection is off for the lock, since an unexplained opening cannot be
told apart from someone leaving. data.actor is always null. Always accompanied by a
door.opened for the same edge.
{
"id": "9b4e27c0-13da-4f65-8c02-7a1f6e39b8d4",
"type": "door.forced",
"created_at": "2026-09-22T02:04:12Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T02:04:11Z",
"source": "device",
"actor": null,
"details": {}
}
}
door.exit_button
Sent when a remote lock's second input reported a press — this requires a SnapGate exit button
wired to input 2. The press is anonymous: data carries no top-level person or key, and
data.actor is always null. Inside an un-armed first-person-in window under the all_keys
toggle policy, the press also arms the block, which then emits lock.hold_open.started.
{
"id": "c0f5a731-8e42-4d06-9b3c-25ae7f1d6092",
"type": "door.exit_button",
"created_at": "2026-09-22T07:58:03Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T07:58:02Z",
"source": "exit_button",
"actor": null,
"details": {}
}
}
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"
}
}
lock.hold_open.started
Sent when a remote lock's hold relay was switched on — by a schedule block, by a first-person-in
arming (triggered by a door.exit_button press), or by a manual Stay open override. This
requires a SnapGate lock with a hold relay configured. source says which: schedule,
exit_button, manager, app or api.
data.actor names who caused it: null for a schedule block or an exit-button-triggered arming,
and otherwise an object with type (user, manager or api_key), person (always null —
door and hold events never resolve a person identity), key (the stand-alone remote key behind an
app- or API-triggered arming, with its id and name) and api_key (the API key behind an
api-sourced hold, with its id and name) — null fields for whichever of key and api_key
did not apply. A manager's own identity is never exposed on type: manager: person, key and
api_key are all null.
{
"id": "5a2d90b4-7c18-4e3f-a6d1-0b93e8c47f26",
"type": "lock.hold_open.started",
"created_at": "2026-09-22T08:00:01Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T08:00:00Z",
"source": "app",
"actor": {
"type": "user",
"person": null,
"key": {
"id": 88213,
"name": "Nattevagt"
},
"api_key": null
},
"details": {}
}
}
lock.hold_open.ended
Sent when the hold relay was switched off again — the schedule block ended, the override expired
or was cleared, or a manager closed the door from the dashboard. Requires the same SnapGate hold
relay as lock.hold_open.started. data.actor follows the same shape as on lock.hold_open.started
— set when a manager, the app or the API ended the hold, null for a schedule block or an expiry.
{
"id": "e71b3c05-92af-4d68-b410-6c58d2a7f931",
"type": "lock.hold_open.ended",
"created_at": "2026-09-22T16:00:02Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T16:00:01Z",
"source": "schedule",
"actor": null,
"details": {}
}
}
lock.hold_open.failed
Sent when the gateway never confirmed a hold write after the configured retries
(data.details.reason is timeout, with attempts), or when the unit went offline while the door
is held (offline) — the hold can then neither be verified nor released remotely. data.actor is
always null: this is the retry/offline sweep, never a person's action.
{
"id": "47c8e105-2b6d-4930-8f7a-e15cb3092d64",
"type": "lock.hold_open.failed",
"created_at": "2026-09-22T08:00:41Z",
"data": {
"lock": {
"id": 4172,
"name": "Main entrance",
"place": "Ground floor, east"
},
"location": {
"id": 12,
"name": "Headquarters"
},
"occurred_at": "2026-09-22T08:00:40Z",
"source": "schedule",
"actor": null,
"details": {
"reason": "timeout",
"attempts": 3
}
}
}
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 means the door was not confirmed closed: the lock did not report a close after being
opened, or SnapKey got no answer about the close from the locking system. Treat it as "not confirmed
closed", not as proof that the door is standing open.