Webhooks

Register an HTTPS endpoint and lockapi will POST every matching event to it as it happens, instead of you having to poll. Deliveries are signed, retried with backoff on failure, and fully independent per endpoint — you can register as many as you like.

Register an endpoint

POST /v1/webhook_endpoints
{
  "url": "https://yourapp.com/hooks/lockapi",
  "subscribedEvents": ["access_code.unlocked", "access_code.verification_failed"],
  "description": "production"
}

→ {
  "id": "we_123",
  "url": "https://yourapp.com/hooks/lockapi",
  "subscribedEvents": [...],
  "enabled": true,
  "secret": "whsec_...",
  "createdAt": "..."
}

secret is returned exactly once, at creation time — store it now. Every later GET/PATCH on that endpoint omits it entirely; there is no way to retrieve it again, only to delete the endpoint and create a new one. Use subscribedEvents: ["*"] to receive every event type.

GET, PATCH (toggle enabled, change url/subscribedEvents), and DELETE /v1/webhook_endpoints/:id round out the CRUD surface.

Verifying the signature

Every delivery carries a Lockapi-Signature: t=<unix timestamp>,v1=<hmac hex> header, Stripe-style. The signed payload is the literal string ${t}.${rawBody}, HMAC-SHA256'd with your endpoint's secret — verify against the raw request body, not a re-serialized copy of the parsed JSON.

import crypto from 'crypto';

function isValid(secret, rawBody, header) {
  const [tPart, v1Part] = header.split(',');
  const t = tPart.split('=')[1];
  const v1 = v1Part.split('=')[1];
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Delivery body

{
  "id": "evt_123",
  "type": "access_code.unlocked",
  "occurredAt": "2026-07-07T13:42:44.629Z",
  "data": { ... }
}

Event types today

Currently emitted from the remote unlock flow:

  • access_code.unlocked — a REMOTE code was submitted, matched, and the lock confirmed unlocked.
  • access_code.unlock_uncertain— the vendor accepted the unlock command but lockapi couldn't confirm the physical state change.
  • access_code.verification_failed — a wrong code was submitted against a REMOTE code (payload includes the running failedAttempts count and whether this attempt triggered auto-revoke).
  • access_code.unlock_infra_error— the vendor/bridge was unreachable; not counted against the guest's attempt budget.
  • access_code.locked — a remote lock command was submitted and the lock confirmed locked.
  • access_code.lock_uncertain— the vendor accepted the lock command but lockapi couldn't confirm the physical state change.
  • access_code.lock_infra_error — the vendor/bridge was unreachable during a remote lock attempt.

Inbound vendor events (device battery, lock/unlock reported directly by hardware, etc.) will expand this list as each vendor's own webhook payload format gets normalized — the ingestion pipeline and this outbound delivery layer are already vendor-agnostic, only the per-vendor payload parsing is still pending.

Retries

A non-2xx response or a request that times out (10s) is retried up to 6 times with exponential backoff (1m, 5m, 30m, 2h, 12h). After the final attempt the delivery is marked EXHAUSTED and not retried again. Disabling or deleting an endpoint stops any deliveries still queued for it.