Monitoring Webhooks
There are a few ways to monitor webhooks, in addition to the record of webhooks on your webhook server.
The first is within Nuki Web. In the “API” menu, head to “Advanced API”, where you will see a table of the last 300 webhook requests. Here you can see the status code that we received from your webhook server, as well as the payload of the webhook.

Additionally, we offer a dedicated endpoint for fetching these webhook logs. Once again, only the last 300 are stored. You can fetch those logs with a GET request to a dedicated webhook logs endpoint.
The following optional parameters can be used with this endpoint:
| Parameter | Description |
|---|---|
| id | The webhook ID you wish to filter for. This is a hexidecimal representation of an Object ID |
| limit | The number of webhook log entries that should be returned (Default: 50) |
Here is an example response from this endpoint:
[ { "id": "68c555..........6bcace0c", "succeeded": true, "responseStatus": 200, "duration": 466, "accountId": 987654321, "response": { "headers": { "X-Nuki-Signature-SHA256": "d89d6833806449..............................b9d5e7d0682c32c20fcd", "X-Nuki-Signature": "842c71c8f....................5a563b66136", "Content-Type": "application/json; charset=UTF-8" }, "body": { "feature": "DEVICE_MASTERDATA", "deleted": false, "smartlockId": 123456789, "accountId": 987654321, "type": 4, "authId": 1212121212, "name": "Hello", "favorite": false, "firmwareVersion": 199175, "hardwareVersion": 1291, "serverState": 0, "adminPinState": 0, "creationDate": "2025-01-16T12:20:23.541Z", "updateDate": "2025-09-13T11:27:37.772Z" }, "timestamp": "2025-09-13T11:27:39.298Z", "path": "https://andrew-nuki.free.beeceptor.com" }, "apiKeyId": 1010101010, "updated": "2025-09-13T11:27:39.764Z", "created": "2025-09-13T11:27:39.000Z" }, // ...]Verifying Webhooks
Section titled “Verifying Webhooks”Every webhook we send is signed with a secret that only you and Nuki know. By checking this signature, you can be sure that a webhook really came from Nuki, and that its content was not changed on the way. Anyone who knows your webhook URL can send requests to it, so we recommend verifying every webhook before you act on it.
The idea is simple: we run the webhook body and your secret through a fixed calculation, and send the result along with the webhook. You run the same calculation on the body you received, using your copy of the secret. If both results are the same, the webhook is genuine. Without the secret, nobody can produce a matching result, and changing even a single character of the body produces a completely different one.
How Webhooks Are Signed
Section titled “How Webhooks Are Signed”Each webhook contains two signature headers. Both are calculated from the same body with the same secret, and only differ in the hash algorithm:
| Header | Algorithm | Description |
|---|---|---|
| X-Nuki-Signature-SHA256 | HMAC-SHA256 (RFC 2104) | 64 hexadecimal characters. We recommend using this header |
| X-Nuki-Signature | HMAC-SHA1 (RFC 2104) | 40 hexadecimal characters. Provided for compatibility with existing integrations |
You only need to check one of them. The signature is calculated as follows:
| Data | The raw request body, exactly as we sent it (JSON, UTF-8 encoded) |
| Key | Your secret, used as plain text (UTF-8). Even if your secret looks like hexadecimal or Base64, do not decode it |
| Result | The HMAC in lowercase hexadecimal, without any prefix (such as sha256=) |
Which secret is used depends on where the webhook is sent:
| Webhook | Secret |
|---|---|
| Central webhooks | The Client Secret of your Advanced API credentials, which you will find in Nuki Web |
| Decentral webhooks | The secret from the response when you registered the decentral webhook |
How to Verify a Webhook
Section titled “How to Verify a Webhook”-
Read the raw request body before you parse it as JSON.
-
Calculate the HMAC-SHA256 of the raw body, using your secret as the key.
-
Convert the result to lowercase hexadecimal.
-
Compare your result with the value of the
X-Nuki-Signature-SHA256header, using a constant-time comparison. A normal string comparison (==) can reveal through tiny timing differences how many characters matched. Every language offers a safe comparison function, and the examples below use it. -
If the values match, the webhook is genuine and you can process it. If the header is missing or the values differ, reject the request (for example with a
401response) and do not process it.
Code Examples
Section titled “Code Examples”Each example reads the secret from the environment variable NUKI_WEBHOOK_SECRET. To check X-Nuki-Signature instead, replace SHA-256 with SHA-1.
This example uses Express. express.raw() keeps the body exactly as it was received.
import crypto from 'node:crypto';import express from 'express';
const NUKI_SECRET = process.env.NUKI_WEBHOOK_SECRET;
function isValidNukiSignature(rawBody, signature) { if (typeof signature !== 'string') { return false; } const expected = Buffer.from(crypto.createHmac('sha256', NUKI_SECRET).update(rawBody).digest('hex')); const received = Buffer.from(signature.trim().toLowerCase()); return expected.length === received.length && crypto.timingSafeEqual(expected, received);}
const app = express();
app.post('/nuki-webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!isValidNukiSignature(req.body, req.get('X-Nuki-Signature-SHA256'))) { return res.sendStatus(401); } const webhook = JSON.parse(req.body.toString('utf8')); // ... process the webhook res.sendStatus(204);});
app.listen(3000);This example uses Flask. request.get_data() returns the body exactly as it was received.
import hashlibimport hmacimport os
from flask import Flask, abort, request
NUKI_SECRET = os.environ["NUKI_WEBHOOK_SECRET"]
app = Flask(__name__)
def is_valid_nuki_signature(raw_body: bytes, signature: str | None) -> bool: if signature is None: return False expected = hmac.new(NUKI_SECRET.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode("utf-8"), signature.strip().lower().encode("utf-8"))
@app.post("/nuki-webhook")def nuki_webhook(): raw_body = request.get_data() if not is_valid_nuki_signature(raw_body, request.headers.get("X-Nuki-Signature-SHA256")): abort(401) webhook = request.get_json() # ... process the webhook return "", 204php://input contains the body exactly as it was received.
<?php$secret = getenv('NUKI_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_NUKI_SIGNATURE_SHA256'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, strtolower(trim($signature)))) { http_response_code(401); exit;}
$webhook = json_decode($rawBody, true);// ... process the webhookhttp_response_code(204);This example only uses the Java standard library (Java 17 or later). Pass it the body as bytes, exactly as it was received, for example from HttpServletRequest.getInputStream().
import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.util.HexFormat;import java.util.Locale;
public final class NukiWebhookVerifier {
private final SecretKeySpec key;
public NukiWebhookVerifier(String secret) { this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); }
/** * @param rawBody the request body, exactly as it was received * @param signature the value of the X-Nuki-Signature-SHA256 header */ public boolean isValid(byte[] rawBody, String signature) throws GeneralSecurityException { if (signature == null) { return false; } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(key); byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8); byte[] received = signature.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8); return MessageDigest.isEqual(expected, received); }}Testing Your Implementation
Section titled “Testing Your Implementation”Use these values to check that your code calculates signatures correctly:
| Secret | mySecret123 |
| Body | {"hello":"world"} |
| X-Nuki-Signature-SHA256 | df46b477700fde714a53eca6cd37bc5480d0e0ee95037e4970da95e8bdfc4cfa |
| X-Nuki-Signature | 9ed1a5b4465ffc9c238a03096b2f49fdbcd3527a |
You can reproduce these values on the command line with OpenSSL:
printf '%s' '{"hello":"world"}' | openssl dgst -sha256 -hmac 'mySecret123'This also shows why the raw body matters. With a single extra space, the body {"hello": "world"} contains the same JSON data, but its signature is completely different: 090986868e32d84ec5cd78c9a4e2f20550363ccb07d0c819f6ed2737ef27eca2.
When you activate webhooks for your Advanced API, we send a test webhook with the body {} to your webhook URL. It is signed in the same way as every other webhook, so a correct implementation accepts it.