Webhooks
A webhook is a request NUKI sends to your application when something happens, for example when a door is opened. The package has a receiver for those requests and a command that registers your URL with NUKI. Both are off until you switch them on.
1. Switch the receiver on
NUKI_WEBHOOK_ENABLED=true
NUKI_WEBHOOK_SECRET=a-long-random-string
Write true, not 1: only the boolean true registers the route. Run php artisan config:clear when your config is cached.
The package then registers one route from routes/webhooks.php:
| Method | Path | Name | Middleware |
|---|---|---|---|
| POST | webhook.route (default /nuki/webhook) | nuki.webhook | webhook.middleware (default ['api']) |
The api middleware group has no session and no CSRF check, which is what a request from another server needs.
Without a secret the receiver rejects every request with 401, so the route is never open by accident.
2. What the receiver does with a request
WebhookController does, in this order:
- Signature check. It computes
hash_hmac('sha256', <raw body>, <webhook.secret>)and compares it with the header named inwebhook.signature_header(defaultX-Nuki-Signature), usinghash_equals. No secret, no header or a mismatch gives401 {"error":"invalid signature"}. - Event id.
idfrom the body, otherwiseeventId, otherwise a SHA-1 of the whole body. - Duplicates.
Cache::add('nuki:webhook:<id>', …)remembers the id forwebhook.dedup_ttlseconds (default 600). A repeat gets200 {"status":"duplicate"}and no event. - Dispatch. The event NukiWebhookReceived is dispatched and the answer is
200 {"status":"ok"}.
The secret and the header name have to match what NUKI really sends for your kind of webhook. Check the NUKI Web API documentation for both, and set NUKI_WEBHOOK_SECRET and NUKI_WEBHOOK_SIGNATURE_HEADER to match.
To accept unsigned requests, for example behind a gateway that already authenticates the caller, switch the check off on purpose with NUKI_WEBHOOK_VERIFY_SIGNATURE=false. Only the boolean false does that.
3. Listen for the event
The package does nothing with the content of a webhook. Your application listens for the event, for example in the boot() method of app/Providers/AppServiceProvider.php:
use App\Jobs\ProcessNukiLog;
use Darvis\Nuki\Events\NukiWebhookReceived;
use Illuminate\Support\Facades\Event;
Event::listen(function (NukiWebhookReceived $event): void {
if ($event->type !== 'DEVICE_LOGS') {
return;
}
ProcessNukiLog::dispatch($event->payload, $event->accountKey);
});
ProcessNukiLog stands for a queued job of your own.
| Property | Content |
|---|---|
$event->type | event from the body, otherwise type, otherwise 'unknown'. |
$event->payload | The whole body as an array. |
$event->accountKey | The ?account= query parameter of the callback URL, or null. |
4. Register the callback URL with NUKI
Your URL has to be reachable from the internet; in development that takes a tunnel. Then:
php artisan nuki:webhook-register
Without arguments the command uses APP_URL plus webhook.route, the account key default and the events DEVICE_STATUS, DEVICE_CONFIG, DEVICE_LOGS and ACCOUNT_USER. To choose yourself:
php artisan nuki:webhook-register "https://example.com/nuki/webhook?account=tenant-42" \
--account=tenant-42 \
--events=DEVICE_LOGS --events=ACCOUNT_USER
Quote the URL, because of the ?. --account is the account whose token is used for the call to NUKI; the ?account= in the URL is what comes back in $event->accountKey. The event names are passed to NUKI as they are; the NUKI Web API documentation has the list.
Source: NukiWebhookRegisterCommand.
List and remove subscriptions:
use Darvis\Nuki\Facades\Nuki;
foreach (Nuki::as('tenant-42')->webhooks()->all() as $subscription) {
if ($subscription->id !== '') {
Nuki::as('tenant-42')->webhooks()->unsubscribe($subscription->id);
}
}
WebhookSubscription fills id from the key id of the NUKI answer. When NUKI names it differently the value is only in $subscription->raw, hence the check for an empty id. The bundled /nuki/webhooks page does the same from the browser.
Things that go wrong in production
- The event is handled twice. The duplicate check uses your default cache store. With the
arraystore nothing is remembered between requests, so every delivery is new. Use a store that is shared by all your workers, such asredisordatabase. - A failed listener loses the event. The id is stored before your listener runs. A listener that throws gives NUKI a
500, and a retry is then dropped as a duplicate. Keep the listener to dispatching a queued job. - Two different events count as one. A body without
idoreventIdis recognised by its content. Two identical bodies withindedup_ttlseconds are one event. accountKeyis a hint.?account=is not part of the signed body. Check it against thesmartlockIdin the payload before you act on it.- A captured request can be replayed. The signature covers the body only, there is no timestamp. After
dedup_ttlseconds the same request is accepted again, so make the handling idempotent: doing it twice must give the same result as doing it once.
Test your listener
See Testing → Test a webhook listener. In short: dispatch the event yourself, or send the raw JSON body with call(); post() with an array never matches the signature.