# BuzzerAPI AI agent onboarding

BuzzerAPI lets an AI agent manage authorized entry through an apartment or condo call box that dials a phone number. The owner signs in and manages keys at buzzerapi.com; AI agents use the REST API. The resident or property manager must set the call box to dial the provisioned virtual number and test the door-release tone at the entrance.

**Machine-readable contract:** [https://www.buzzerapi.com/openapi.json](https://www.buzzerapi.com/openapi.json) (OpenAPI 3.1). `GET https://api.buzzerapi.com/v1/openapi.json` redirects there (follow the 302), and `GET /v1/health` returns it as `openapi_url`. Load it before calling endpoints you have not used.


## Hosted MCP connector (release review)

See [the MCP connector guide](https://www.buzzerapi.com/mcp.html) and [MCP agent policy](https://www.buzzerapi.com/mcp.md) for OAuth account linking, building-specific permissions, tool safety and client setup. The hosted endpoint is awaiting release validation. This is separate from REST API key onboarding. Its optional `billing:write` and `buildings:write` scopes cover checkout, the billing portal and building changes, with payment always completed by you on Stripe's page; API keys, phone verification and sign-in stay website-only.


## Hosted MCP connector

The hosted OAuth connector is in development. See [MCP guide](https://www.buzzerapi.com/mcp.md) for its tools, permissions and connection process. Its intended production endpoint is `https://api.buzzerapi.com/mcp`; release and client authentication checks are still pending. The REST setup below is a separate surface.

## Start for free

1. Open [BuzzerAPI pricing](https://www.buzzerapi.com/#pricing), select **Try for free**, and verify the account owner's email.
2. Copy the AI agent API key shown once into protected credentials as `BUZZER_API_KEY`. Keys never expire by default. A lifetime can only be set when a key is created (1–90 days); to shorten one later, create a new key and revoke the old one. Never paste a key into a public log or repository.
   For monitoring only, leave **Read** checked and uncheck **Write** when creating the key. It can inspect the account, buildings, access rules, and activity, but cannot change access or setup, request checkout, or manage buildings. **Manage buildings** stays off by default. The owner can see and revoke every key, including website sign-ins, on the [account page](https://www.buzzerapi.com/app.html). Keys can only be created or managed there; an AI agent key gets `403 WEB_SESSION_REQUIRED` from the key management endpoints, but it can revoke itself with `DELETE /v1/auth/key`.
3. Inspect `GET /v1/account` and public `GET /v1/plans`. With Write enabled, you can also use `GET /v1/billing/plans`. No payment is needed to explore account status, prices, or this documentation.
4. You can create access rules now, before any payment (see [Grant access](#grant-access)). They start opening the door once a virtual number is connected and tested. `GET /v1/account` lists every rule type you can create in `capabilities.access_types`.
5. When the owner is ready to connect a building, request `api_weekly` or `api_yearly` hosted checkout and return the URL. Payment happens on Stripe's hosted page. A checkout URL is not proof of activation; check account status afterward.
6. When the owner is ready, draft the [property manager email](#ask-the-property-manager-to-connect-the-call-box) with the provisioned virtual number, then test a real call at the entrance.

## Ask the property manager to connect the call box

The call box must dial the building's virtual number instead of the resident's phone. Only the property manager or building manager can change that. When the owner is ready to ask, draft this email for them. Replace `VIRTUAL_NUMBER` with `virtual_number` from `GET /v1/buildings/{buildingId}/setup`, written as a normal phone number such as (206) 555-0123. Show the draft to the owner to send from their own email; do not send it for them unless they ask.

```text
Subject: RE: Changing my buzzer number

Hi,

Would you please change the phone number for my buzzer to VIRTUAL_NUMBER?

Thank you!
```

After the property manager confirms the change, set the release keypress if needed and have the owner test a real call at the entrance. Rules created earlier start working once the number is connected.

## Grant access

Create a passcode, timer, or recurring schedule with `POST /v1/unlock`. No subscription or virtual number is needed to create, edit, or revoke rules. `Idempotency-Key` (16–100 letters, digits, hyphens, or underscores) is required for creation. After a timeout, resend the same key and body: you get `200` with the original rule and `Idempotency-Replayed: true`, never a duplicate, and a revoked rule stays revoked. Keys never expire. The same key with a different body returns `409 IDEMPOTENCY_CONFLICT`. A passcode without `access_code` generates four digits; custom codes are 1–4 digits except `1` alone. Email sign-in codes are separate and always six digits.

Every create sends `type` and `enabled`. The type is never guessed from the other fields, so a passcode sent without `access_code` can't become a timer that lets anyone in. A timer can start now with `{"type":"timer","label":"Delivery window","enabled":true,"expires_in_minutes":15}` or later with `{"type":"timer","label":"Delivery window","enabled":true,"starts_at":"2026-10-01T13:00:00-07:00","ends_at":"2026-10-01T15:00:00-07:00"}` for two hours. Set the window with exactly one of `expires_in_minutes` (1 to 1,440, 1 day) or `ends_at` (1 minute to 1 day after the start); both, or neither, returns `400`. `starts_at` and `ends_at` need a timezone and only go with `enabled: true`; `starts_at` is now or later, within 1 year, never in the past. A passcode can expire after `expires_in_minutes` (1 to 525,600, from now) or at `ends_at`, up to 1 year ahead, not both, when creating or in a `PATCH`; with neither it never expires, and `PATCH` with `"ends_at":null` removes an expiry. Responses show a timer's window end or a passcode's expiry as `ends_at` (`null` when a passcode never expires). Recurring access uses local 24-hour `start_hour_and_minutes` and `end_hour_and_minutes` plus an IANA `timezone`.

```sh
curl -X POST "https://api.buzzerapi.com/v1/unlock" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID" \
  -H "Content-Type: application/json" -H "Idempotency-Key: delivery-timer-001" \
  -d '{"type":"timer","label":"Delivery","enabled":true,"expires_in_minutes":15,"starts_at":"2026-10-01T13:00:00-07:00","remaining_uses":1}'
curl -X POST "https://api.buzzerapi.com/v1/unlock" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID" \
  -H "Content-Type: application/json" -H "Idempotency-Key: guest-passcode-001" \
  -d '{"type":"passcode","label":"Guest","enabled":true,"expires_in_minutes":60,"remaining_uses":1}'
curl -X POST "https://api.buzzerapi.com/v1/unlock" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID" \
  -H "Content-Type: application/json" -H "Idempotency-Key: cleaner-routine-001" \
  -d '{"type":"routine","label":"Mon/Wed/Fri cleaner","enabled":true,"days":["monday","wednesday","friday"],"start_hour_and_minutes":"09:00","end_hour_and_minutes":"17:00","timezone":"America/Los_Angeles"}'
curl -X POST "https://api.buzzerapi.com/v1/unlock" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID" \
  -H "Content-Type: application/json" -H "Idempotency-Key: walker-routine-code-001" \
  -d '{"type":"routine","label":"Weekend cleaner","enabled":true,"days":["saturday","sunday"],"start_hour_and_minutes":"10:00","end_hour_and_minutes":"14:00","timezone":"America/Los_Angeles","access_code":"2468","enter_code_with_voice":true}'
curl "https://api.buzzerapi.com/v1/unlock/$UNLOCK_ID" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID"
curl -X DELETE "https://api.buzzerapi.com/v1/unlock/$UNLOCK_ID" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: $BUILDING_ID"
```

Timers and schedules without a code let any caller in during their window, immediately and without a code prompt, so use them only when the owner authorizes that broader access. Add `access_code` (1–4 digits except `1` alone, never generated) to a schedule to require it during the window instead; it has no use limit or expiry, and `PATCH` with `"access_code":null` removes it. Removing a schedule's code also turns voice off, so a code added later starts with voice off until you send `enter_code_with_voice: true` again. `enter_code_with_voice: true` lets callers speak the code aloud instead of typing it, on passcodes and on schedules with a code (it requires `access_code` when creating a schedule). Voice applies to the whole call: while any active code on the building allows voice, callers can speak any active code aloud. Timers and passcodes share one counter, `remaining_uses` (1–100). Each buzz that opens the door takes one, and at 0 the rule's `status` becomes `exhausted`. Omit it for unlimited buzzes (on a passcode, `null` also means unlimited). `PATCH` with a number sets a new count, and `remaining_uses: null` removes the use limit on timers and passcodes.

**Scoping a delivery:** use a timer with `"remaining_uses":1` and a short `expires_in_minutes`, or a passcode with `"remaining_uses":1` and an expiry (tighter, because the visitor must also know the code). Restricting a rule to the courier's phone number is not possible: every buzz arrives from the building's call box line, so the API cannot tell who is calling. A spent passcode becomes exhausted; to reuse it, send a new `remaining_uses` and `enabled: true` together. Responses show `enabled` (the on/off switch) and `status` (`active`, `scheduled` for a timer whose `starts_at` is ahead, `inactive`, `expired`, `exhausted`, or `revoked`). To turn a timer on, PATCH `enabled: true` with `expires_in_minutes` or `ends_at` and an optional `starts_at`; `enabled: false` turns it off and clears its window. Filter lists with `?enabled=` or `?status=`. A recorded buzz does not prove physical entry.

## Check activity

`GET /v1/logs` returns persisted access activity; poll it to follow new events. Use `since`, follow every pagination cursor, overlap timestamp checkpoints, and deduplicate by log ID. Filter by `unlock_id` and `succeeded` for a specific rule, and by `type=unlock` to exclude calls forwarded to the resident (`type=call`). Search entry names with `q` (case-insensitive, for example `q=delivery` to find a rule labeled "Package delivery"). A failed passcode attempt has no `unlock_id`. To be notified instead of polling, register a webhook.

## Webhooks

`POST /v1/webhooks` with `{"url":"https://...","events":[...]}` (scope `access:write`) registers an HTTPS endpoint for the whole account; do not send `X-Buzzer-Building-Id`, because each event carries `building_id` and `building_label` (same as `GET /v1/buildings`). The response includes `secret` (`whsec_...`); store it right away because `GET /v1/webhooks` never returns it. A retry with the same `Idempotency-Key` and body returns `200` with the same endpoint and secret. An account can have up to 5 endpoints. `GET /v1/webhooks` lists them (scope `access:read`). `POST /v1/webhooks/{id}/test` sends a signed `webhook.test` event, and `DELETE /v1/webhooks/{id}` removes the endpoint. The owner can also add, test, and delete endpoints on the account page (https://www.buzzerapi.com/app.html), which shows the secret once.

Events: `access.granted` (a rule opened the door; includes `unlock_id`, `unlock_type`, `label`, `remaining_uses`), `access.call_forwarded` (no rule matched; forwarded to the resident), `access.denied` (wrong passcode), `unlock.no_uses_remaining` (last use spent), and `webhook.test`. Reply 2xx within 5 seconds. Failures are retried after 1 minute, 5 minutes, 30 minutes, and 2 hours, and a retry keeps the same event `id`, so deduplicate on it. Delivery order is not guaranteed; order events by `data.occurred_at` (on `access.granted`, `access.call_forwarded`, and `access.denied`).

Verify `BuzzerAPI-Signature: t=...,v1=...` before parsing: `v1` is hex HMAC-SHA256 of `"<t>.<raw body>"`, keyed with the whole secret including `whsec_`. Compare in constant time and reject `t` older than 5 minutes.

```js
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  if (!p.t || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex'));
  const given = Buffer.from(p.v1 || '');
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
```

The Lowkey mobile app can also provide push notifications.

## Multiple buildings

One BuzzerAPI subscription supports up to 20 buildings, with **one subscription unit at the same price per building**. Each building has a separate virtual number and activity history. Call `GET /v1/buildings`, use an explicit ID in `/v1/buildings/{buildingId}/setup` and `/v1/buildings/{buildingId}/settings`, and send `X-Buzzer-Building-Id` for access and logs; linked accounts must select even the primary building. Billing and keys stay with the parent account.

A parent key with `account:read` and explicit `buildings:write` can preview and execute additions or removals. Buildings can be in the US (state and ZIP) or Canada (province and postal code with `country: "CA"`; a Canadian postal code implies CA when country is omitted). Preview shows the Stripe invoice estimate and effects. Execute only the reviewed quote. Removal releases the number, permanently deletes that building's rules and activity, revokes its keys, and reduces the billed quantity with proration. The primary building cannot be removed through this flow.

```sh
curl -X POST "https://api.buzzerapi.com/v1/buildings/preview" -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"add","label":"East entrance","address":{"city":"Seattle","state":"WA","zip":"98101"},"unlock_tone":"9","phone_number":"+12065550123"}'
curl -X POST "https://api.buzzerapi.com/v1/buildings" -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: building-$QUOTE_ID" \
  -d '{"quote_id":"'"$QUOTE_ID"'"}'
curl "https://api.buzzerapi.com/v1/buildings/operations/$QUOTE_ID" -H "Authorization: Bearer $BUZZER_API_KEY"
curl -X POST "https://api.buzzerapi.com/v1/buildings/preview" -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "Content-Type: application/json" -d '{"action":"remove","building_id":"'"$BUILDING_ID"'"}'
curl -X DELETE "https://api.buzzerapi.com/v1/buildings/$BUILDING_ID" -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: building-$QUOTE_ID" \
  -d '{"quote_id":"'"$QUOTE_ID"'"}'
```

Quotes expire after 15 minutes. After a timeout, inspect the operation and reuse the original quote and Idempotency-Key. `reconciliation_required` needs support before another building change. `BUILDING_ADD_FAILED` (status `failed`) means the addition rolled back with nothing purchased; request a new preview to try again.

See the [full API reference](https://www.buzzerapi.com/api-reference), [setup guide](https://www.buzzerapi.com/docs), and [OpenAPI 3.1 contract](https://www.buzzerapi.com/openapi.json).

## Per-building tone and greeting

Read GET /v1/buildings/{buildingId}/settings with account:read, then PATCH that same path with setup:write and the exact quoted ETag in If-Match. Body: {"unlock_tone":"9","greeting":{"custom_greeting":"Welcome"}}. The path must name an authorized building from the inventory; child keys cannot target siblings. PATCH requires an owned provisioned number: GET returns editable (false for invited members), and a member's PATCH returns 403 NUMBER_OWNER_REQUIRED, so do not retry it. Linked-building writes require an active BuzzerAPI subscription. Unknown/null fields and empty patches are rejected; omitted settings are preserved. Tone is 1–3 digits/#/*. Greeting strings are shorter than 120 UTF-16 code units, stored without trimming. Empty custom_greeting restores default instructions; empty access_granted_phrase/access_denied_phrase silences that phrase. prompt_for_passcode is boolean, preserving the existing call flow. GET includes these defaults even before a greeting is saved.

The response includes the building ID and new version. Missing/malformed If-Match returns 428 SETTINGS_VERSION_REQUIRED; stale/cross-building versions return 409 SETTINGS_VERSION_CONFLICT. Read back and review after conflicts or uncertain saves. Do not automatically retry writes. Only the named building changes. Changing a tone requires the user to test at the entrance; the API neither programs the call box nor verifies physical release.
