This is an info Alert.
SnapKey Logo
  • Lösungen
      • Lösungen
      • SnapKey Residential
      • SnapKey Utility
      • SnapKey Public
      • SnapKey Logistics
      • SnapKey Sensors
      • Gäste Check-in
      • Reaktivierbare Schlüssel
      • Vermietung
  • Branchen
      • Branchen
      • Versorgungsunternehmen
      • Wohngebäude
      • Bürogebäude & Coworking Spaces
      • Logistik
      • Ferienhäuser
      • Öffentliche Toiletten
      • Baustellen
      • Unbemannte Geschäfte
      • Temporäre Schließfächer
      • nav.case_virtual_keybox
  • Ressourcen
      • Ressourcen
      • Wissen
      • Videos
      • API-Dokumentation
      • Vertrauen & Sicherheit
      • Systemstatus
  • Partner
  • Unternehmen
      • Unternehmen
      • Über uns
      • Why SnapKey
      • Kontakt
auth.sign_inDemo buchen
Public API
    Getting startedAuthentication & limitsWebhooksErrorsAPI referenceChangelog

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 object GET /events returns.

Verify the signature

  1. Split X-SnapKey-Signature on , into t=<unix seconds> and v1=<hex digest>.
  2. Reject the delivery if t is more than 300 seconds away from your own clock — that is what stops a captured delivery from being replayed later.
  3. Compute HMAC-SHA256 over the string "{t}.{raw body}" — the raw bytes you received, not a re-serialised copy — keyed with your subscription secret.
  4. Compare it with v1 in 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.

On this page
    Why webhooksCreate a subscriptionManage a subscription from your systemEventsaccess.grantedaccess.denieddoor.closeddoor.left_openkey.activatedkey.revokedkey.issuedunlock.failedlock.onlinelock.offlineperson.createdperson.updatedperson.deletedpingWhat a delivery looks likeVerify the signatureRespond fast, expect retriesPaused subscriptionsDeliveries and redeliveryAt-least-once deliveryTest locallyWhat does not produce a webhook

SnapKey Logo

SnapKey ist Ihr digitaler Schlüssel für alle Arten von Schlössern. Öffnen Sie Türen und Schlösser ganz einfach direkt mit Ihrem Smartphone und genießen Sie schnellen, sicheren und flexiblen Zugang – ohne physische Schlüssel oder zusätzliche Apps. Perfekt für Privathaushalte, Unternehmen und gemeinsam genutzte Räume.

Lösungen
SnapKey ResidentialSnapKey UtilitySnapKey PublicSnapKey LogisticsGäste Check-in
Entwickler
API-DokumentationAPI-ReferenzWebhooksÄnderungsprotokollSystemstatus
Unternehmen
Über unsWhy SnapKeyPartner werdenWissenVideosKontakt
Rechtliches
GeschäftsbedingungenDatenschutzerklärungVertrauen & Sicherheit
Kontakt
SnapKey ApS+45 3242 9050info@snapkey.dk

© Alle Rechte vorbehalten.