BUZZER API / DOCUMENTATION

Connect an AI agent.
Let someone in.

Sign in, subscribe, connect a building, and manage access with the REST API.

Apartment access, through software

Buzzer API is the product and its REST interface. You sign in and manage keys on the website; AI agents and applications use the REST API. AI agents can also connect through the hosted MCP connector (see the MCP guide). It uses OAuth, not API keys, and mirrors this API: access rules, building settings, checkout and the billing portal (billing:write), quote-then-confirm building additions and removals (buildings:write), and webhooks. Payment is always completed by you on Stripe's page. API keys, forwarding phone verification and sign-in stay website-only.

Use it when a delivery driver, guest, cleaner, dog walker, or contractor needs access through an apartment or condo buzzer, telephone intercom, call box, or entry system that dials a phone number. It does not control a hardware-only intercom. A building manager must connect the provisioned virtual number and someone must test the release tone at the entrance.

Create an account and AI agent key for free, and set up access rules before you pay. Rules start opening the door after you subscribe, receive a virtual number, connect the building call box, and test the entrance. Checkout and the public API endpoint are being prepared.

For AI agents: read the onboarding guide, the REST reference, or the machine-readable OpenAPI 3.1 contract. The API host also redirects GET https://api.buzzerapi.com/v1/openapi.json to that contract, and GET /v1/health returns its location as openapi_url (see spec discovery).

Try for free, then connect

  1. On the BuzzerAPI pricing card, choose Try for free, enter your email, and verify the six-digit code sent to you.
  2. Copy the API key shown once and the Paste to AI agent setup prompt. The AI agent can inspect your account and plans without payment. The key never expires by default. A key's lifetime cannot be changed later; to limit it, create a new key with a 1–90 day lifetime and revoke this one. It can set up access rules now; they cannot open the door until you subscribe and connect a virtual number.
  3. When you are ready for a virtual number, have the API return the Stripe checkout URL for api_weekly or api_yearly. Complete payment yourself. Each building uses one subscription unit at the same price.
  4. Check account and billing status until the subscription is active and a virtual number is provisioned. A checkout URL or return page is not proof of activation.
  5. Verify your personal forwarding phone on the account page if you want calls to reach you. SMS verification is available only after activation and number provisioning; it is never sent during free exploration. The forwarding phone is managed from your website sign-in; AI agent keys cannot read or change it.
  6. Have the building manager set the call box to dial that virtual number, using the property manager email below. Set its exact release keypress (1–3 characters from digits, #, *), then test at the entrance.
# Once the public API is available, explore without subscribing.
curl https://api.buzzerapi.com/v1/health
curl https://api.buzzerapi.com/v1/account \
  -H "Authorization: Bearer $BUZZER_API_KEY"
curl https://api.buzzerapi.com/v1/billing/plans \
  -H "Authorization: Bearer $BUZZER_API_KEY"

# Only when the owner is ready for a virtual number, request hosted checkout.
curl -X POST https://api.buzzerapi.com/v1/billing/checkout \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H 'Idempotency-Key: subscription-start-001' \
  -H 'Content-Type: application/json' \
  -d '{"plan":"api_weekly"}'
# The owner completes payment at the returned URL. Then check account status.
curl https://api.buzzerapi.com/v1/account \
  -H "Authorization: Bearer $BUZZER_API_KEY"

The pricing-card flow gives you a scoped AI agent key; store it as BUZZER_API_KEY in the AI agent's protected credentials. The API can inspect account and billing status without payment. A hosted payment step and building-manager setup are required before live access. Use GET /v1/billing/checkout to recover a checkout after a timeout, POST /v1/billing/checkout/{id}/expire to discard an unpaid checkout (requesting another plan also replaces an unpaid one), and POST /v1/billing/portal for a hosted billing-management URL. Reuse the original idempotency key on a retry.

Property manager email

Only the property manager or building manager can point the call box at the virtual number. When the owner is ready to ask, an AI agent should 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. The owner sends it from their own email.

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 test a real call at the entrance before creating access rules.

Credentials and permissions

Set BUZZER_API_KEY in your AI agent's secret environment or use its protected connector settings. The optional setup prompt contains the key, so paste it only into an AI agent you trust. Never put a key in a URL or public repository. GET /v1/account checks a key against the server, and DELETE /v1/auth/key revokes the key that sends it.

ScopeAllowsWebsite checkbox
account:readAccount readiness, building inventory, setup statusRead
access:readRead access rulesRead
access:writeCreate, update, revoke accessWrite
logs:readRead activityRead
buildings:writePreview, execute, and read the receipts of paid building additions and permanent removalsManage buildings
setup:writeChange building release tone and greetingWrite
billing:writePlan catalog, checkout (create, recover, expire), and billing portalWrite
keys:writeList every key, create AI agent keys, and revoke any key. Website sign-in only; AI agent keys cannot hold itNot offered

Your website sign-in holds every scope. It lasts 12 hours and stays on the server in an encrypted HttpOnly cookie; the browser never sees it. On the account page, Read and Write are selected by default; Manage buildings is off. Uncheck Write for a read-only key that can inspect the account, buildings, access rules, and activity without changing them. Building management requires Read but does not require the separate Write permissions for access rules, setup, and billing. AI agent keys never expire by default; choose 1–90 days at creation when you want a time limit. Secrets are returned once. The account page can issue an AI agent key before payment. The AI agent can request a hosted checkout URL only with billing:write; payment remains with the account owner. Access rules can be created and changed before subscribing; they start opening the door once a virtual number is connected. Changing the release tone requires an owned virtual number, and on a linked building an active BuzzerAPI subscription. Building additions require an active BuzzerAPI subscription.

See and revoke every key

The account page always lists every active key on the account: This browser (your current sign-in), Website sign-in (other browsers where you are signed in), and each AI agent key, with its permissions, creation date, last use, and expiry. Revoke any one of them, or use Revoke all other keys to revoke everything except this browser. Revoking this browser signs you out. Key management works only from a website sign-in: GET /v1/auth/keys, POST /v1/auth/keys, DELETE /v1/auth/keys/{id}, and POST /v1/auth/keys/revoke-others return 403 WEB_SESSION_REQUIRED to an AI agent key. An AI agent key can still revoke itself with DELETE /v1/auth/key.

Multiple buildings and virtual numbers

An active BuzzerAPI subscription permits a parent-account AI agent to manage linked buildings, each with its own virtual number, access rules, and activity history. Each building adds one unit at the same subscription price. List buildings and select the intended entrance by ID, label, and number.

curl "https://api.buzzerapi.com/v1/buildings" \
  -H "Authorization: Bearer $BUZZER_API_KEY"
curl "https://api.buzzerapi.com/v1/buildings/$BUILDING_ID/setup" \
  -H "Authorization: Bearer $BUZZER_API_KEY"
curl "https://api.buzzerapi.com/v1/unlock" \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "X-Buzzer-Building-Id: BUILDING_ID"

Accounts with linked buildings must select explicitly, including for the primary building. Single-building accounts can omit selection. Setup diagnostics and settings always use an explicit building ID in the path. Send X-Buzzer-Building-Id on unlock and logs requests. Access responses and activity logs identify building_id.

A child-account key is confined to that child. Linked-building writes require an active BuzzerAPI subscription; reading and revoking existing access remain available after downgrade. Billing, key ownership, and building management stay account-level: do not send X-Buzzer-Building-Id on those requests.

Add a building

Use a parent-account key with both account:read and buildings:write. The account must already have one active BuzzerAPI subscription whose billed quantity matches its building inventory. The limit is 20 buildings including the primary. Buildings can be in the United States or Canada: supply a city plus a two-letter state and ZIP code, or a two-letter province and postal code (for example M5V 3L9). Send country as US or CA; when it is omitted, a Canadian postal code selects Canada. The new virtual number is local to that country. The resident forwarding number must be E.164; if omitted, the parent's configured number is used.

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":{"street":"100 Example Street","city":"Seattle","state":"WA","zip":"98101"},"unlock_tone":"9","phone_number":"+12065550123"}'
# Inspect input, billing, and expires_at. Execute only the authorized quote.
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"
# Canadian building (country is optional; the postal code implies CA)
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":"King West","address":{"city":"Toronto","state":"ON","zip":"M5V 3L9","country":"CA"},"unlock_tone":"9"}'

A preview purchases nothing. It shows current/new subscription quantity, amount_due_now, charge_timing, ends_trial, and the next renewal invoice estimate, in minor currency units (for example, cents for USD). Adding a building charges one full term of your current weekly or annual plan immediately, before provisioning; it keeps your plan and does not prorate. If you are trialing, adding a building ends the trial and immediately bills a full term for all buildings. Review the quoted immediate amount before authorizing execution. Quotes last 15 minutes and become invalid (QUOTE_STALE) if anything in the snapshot changes: a building is added or removed, any building's label, address, or forwarding phone is edited, or the subscription's status, quantity, price, discount, or tax rates change. Executing an add collects the immediate charge, increases subscription quantity without proration, and provisions a virtual number. Its stored result.charge receipt contains invoice_id, amount, and currency. Connect and physically test the new number before granting visitors access.

Remove a building

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"}'
# Verify the exact building and number and every listed effect.
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"}'
curl "https://api.buzzerapi.com/v1/buildings/operations/QUOTE_ID" \
  -H "Authorization: Bearer $BUZZER_API_KEY"

Removal releases the phone number, permanently deletes the linked building's rules and activity history, revokes its API keys, and reduces billed quantity for future renewals without proration. Removal has amount_due_now: 0, charge_timing: none, and does not issue a prorated refund. The primary building cannot be removed through this flow. This is different from revoking one visitor's access.

Retries and operation recovery

The quote ID is also the operation ID. Send a stable Idempotency-Key with each execute request, such as building-QUOTE_ID. After a timeout, inspect the operation and reuse the original quote and request ID. Never create a new preview to bypass an uncertain operation.

StatusNext step
quotedReview and execute before expiration
expiredObtain a fresh preview; no mutation started
runningPoll GET /v1/buildings/operations/{id}
succeededRead the stored result; a retry returns the same receipt
failedThe addition rolled back: no building was provisioned, and any collected building charge was refunded. Payment failures return 402 PAYMENT_FAILED; other rolled-back additions return 409 BUILDING_ADD_FAILED. Create a new preview when ready to try again.
reconciliation_requiredContact support with the operation ID. Do not start another request. A running operation that stops making progress is also reported this way.

Only one building change runs at a time per account (409 BUILDING_BUSY). A failed addition that fully rolled back, such as when no virtual numbers are available in the area, ends as failed, and another change can start. Any other failure after execution starts ends as reconciliation_required and blocks further building changes until support resolves it, instead of risking another number purchase or release. Previews can still be created while an operation awaits reconciliation, but they cannot be executed until it is resolved. HTTP 202 means the operation status was returned, not that the building change completed; inspect status and next_action.

Visitor access rules

A timer allows entry during a short window without a visitor code. A passcode is useful for a specific guest or delivery. A recurring routine allows a weekly schedule, with or without a visitor code. All three are included in BuzzerAPI. Confirm the owner's intended visitor and time window before creating access.

An active timer, or a routine without a code, lets anyone who buzzes the unit in during its window, immediately and without a code prompt. Limit a timer with remaining_uses, add an access_code to a routine, or use a passcode when only one visitor should get in.

You can create, edit, activate, deactivate, and revoke rules before subscribing or before a virtual number exists. Nothing opens the door until the building's virtual number receives a call, so rules set up early simply start working once the number is connected and tested.

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","enabled":true,"expires_in_minutes":15,"remaining_uses":1,"label":"Package delivery"}'
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","enabled":true,"expires_in_minutes":60,"remaining_uses":1,"label":"Guest"}'
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","enabled":true,"days":["monday","wednesday","friday"],"start_hour_and_minutes":"09:00","end_hour_and_minutes":"17:00","timezone":"America/Los_Angeles","label":"Cleaner"}'
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","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,"label":"Dog walker"}'
# Read the grant and its ETag, then update with If-Match, or revoke it.
curl -i "https://api.buzzerapi.com/v1/unlock/UNLOCK_ID" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: BUILDING_ID"
curl -X PATCH "https://api.buzzerapi.com/v1/unlock/UNLOCK_ID" \
  -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: BUILDING_ID" \
  -H "Content-Type: application/json" -H 'If-Match: "VERSION"' \
  -d '{"label":"Updated label"}'
curl -X DELETE "https://api.buzzerapi.com/v1/unlock/UNLOCK_ID" -H "Authorization: Bearer $BUZZER_API_KEY" -H "X-Buzzer-Building-Id: BUILDING_ID"

Every create sends type and enabled. The type is never guessed from the other fields, so a passcode sent without its access_code can never become a timer that lets anyone in. enabled: true turns the rule on, false saves it turned off. Responses show enabled, the switch you set, and status, what the rule does right now: active, scheduled (an enabled timer whose starts_at is still ahead), inactive (turned off), expired, exhausted, or revoked.

A timer's window is set by exactly one of expires_in_minutes (1 to 1,440, 1 day) or ends_at, an ISO timestamp with timezone for when it closes, 1 minute to 1 day after it starts. Send one, not both. It starts now, or at starts_at: an ISO timestamp with timezone, now or later and within 1 year. A starts_at in the past is rejected (up to a minute late counts as now). starts_at and ends_at only go with enabled: true. To turn a timer on later, PATCH enabled: true with expires_in_minutes or ends_at, and an optional starts_at; PATCH enabled: false turns it off and clears its window. Responses show the window as starts_at, ends_at, and expires_in_minutes.

Omitting a passcode's access_code generates four digits. A passcode has unlimited uses and no expiry unless you set them, like passcodes made in the Lowkey mobile app. Explicit custom codes preserve the app's rule: 1–4 numeric digits except 1 alone, which calls the resident. Six-digit visitor codes are not supported. This is separate from the six-digit email sign-in code. Passcodes take remaining_uses from 1 to 100. For an expiry, send expires_in_minutes (1 to 525,600, counted from now) or ends_at (an ISO timestamp with timezone, within 1 year), not both, when creating or in a PATCH. Without one the passcode never expires and stops only when its uses run out or you turn it off. PATCH with "ends_at":null removes an expiry. Responses show the expiry as ends_at (null when it never expires). The visitor calls the unit and enters the code when prompted; the prompt waits for four digits, so a visitor with a shorter code presses # after it. Codes are not checked for uniqueness: if two active passcodes share a code, the first match is used. A passcode whose uses are spent becomes exhausted; to reuse it, set a new remaining_uses and set enabled to true in the same request.

For access that repeats indefinitely, use a recurring schedule with days, start_hour_and_minutes and end_hour_and_minutes (24-hour HH:MM, end after start on the same day), and an IANA timezone. days accepts any combination of days, each listed once, such as ["monday","wednesday","friday"] for Monday, Wednesday and Friday. Enabled does not mean a routine is currently inside its scheduled window. A routine can also take an access_code (same 1–4 digit rules as a passcode, never generated): during its window the caller is prompted for the code instead of being let in on every buzz, and outside it the code does nothing. A routine code has no use limit or expiry. Set enter_code_with_voice to true (it requires access_code) to let callers speak the code aloud instead of typing it; the same option works on passcodes. Voice applies to the whole call: while any active code on the building allows voice, callers can speak any active code aloud. On a routine PATCH, access_code adds or replaces the code and "access_code":null removes it. Removing a routine's code also turns voice off, so a code added later starts with voice off until you send enter_code_with_voice: true again. Responses include access_code and enter_code_with_voice only for routines that have a code. A timer lets in unlimited buzzes during its window unless you set remaining_uses (1 to 100). Each buzz that opens the door takes one use, for timers and passcodes alike; the response shows the current remaining_uses (null when unlimited), and at 0 the rule's status becomes exhausted. A PATCH with remaining_uses sets a new count; null removes the use limit on timers and passcodes. List with the enabled, status, type, limit, and cursor query parameters.

Creation requires a stable Idempotency-Key of 16–100 letters, digits, hyphens, or underscores, scoped to the selected building. Reuse it with the same body after a timeout: you get the original rule back with Idempotency-Replayed: true. Keys never expire, a different body with the same key returns 409 IDEMPOTENCY_CONFLICT, and a replay does not reopen revoked access (see idempotency). Updates use the current version (If-Match in REST). With parallel AI agents, only one update can succeed for a given version. On 409 VERSION_CONFLICT, reread the grant and review the other changes before deciding whether to retry. Copy the ETag from GET unchanged; strong and proxy-generated weak ETags are accepted. Repeated deletion is safe. Revocation affects that rule, not unrelated rules that may also allow entry.

Scoping a delivery safely

For a single delivery, use a timer with "remaining_uses":1 and a short expires_in_minutes, or a passcode with "remaining_uses":1 and an expiry such as "expires_in_minutes":60. A passcode without remaining_uses has unlimited uses, so set it for a delivery. The first buzz uses it up, so nobody else gets in on the same rule. A passcode is the tighter choice because the visitor must also know the code.

Restricting a rule to the courier's phone number (caller-ID pinning) is not possible: every buzz arrives from the building's call box line, not from the visitor's phone, so the API cannot tell who is at the door. Single-use timers and single-use passcodes are the supported way to scope a delivery.

Check when access is used

Use GET /v1/logs to poll recorded activity. Select the building with X-Buzzer-Building-Id. Requests can filter by since and until, by type (unlock for access-rule attempts, call for calls forwarded to the resident), by unlock_id for one rule, by succeeded, and by q, a case-insensitive search of entry names such as a rule's label. Retain an overlapping timestamp checkpoint, follow every pagination cursor, and deduplicate by log ID.

A successful unlock entry means an access rule matched and BuzzerAPI played the release tone; it does not prove the door opened or someone entered. A failed passcode attempt has no unlock_id, and its name comes from the first active rule, not necessarily the one the visitor intended. To be notified instead of polling, register a webhook. The Lowkey mobile app can also send push notifications.

curl -G "https://api.buzzerapi.com/v1/logs" \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "X-Buzzer-Building-Id: BUILDING_ID" \
  --data-urlencode "since=2026-09-19T00:00:00Z"
curl -G "https://api.buzzerapi.com/v1/logs" \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "X-Buzzer-Building-Id: BUILDING_ID" \
  --data-urlencode "unlock_id=UNLOCK_ID" --data-urlencode "succeeded=true"
curl -G "https://api.buzzerapi.com/v1/logs" \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "X-Buzzer-Building-Id: BUILDING_ID" \
  --data-urlencode "q=delivery"

Webhooks

A webhook tells your server when access is used, so you do not have to poll GET /v1/logs. Register an HTTPS endpoint once for the account; it receives events for every building, and each event carries building_id and building_label, matching GET /v1/buildings. Do not send X-Buzzer-Building-Id to the webhook endpoints. An account can have up to 5 endpoints. You can also manage endpoints without code on the account page: add one, copy its secret (shown once), send a test event, or delete it.

curl -X POST "https://api.buzzerapi.com/v1/webhooks" \
  -H "Authorization: Bearer $BUZZER_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: webhook-endpoint-001" \
  -d '{"url":"https://example.com/buzzer/webhook","events":["access.granted","unlock.no_uses_remaining"]}'
# 201 {"id":"...","url":"https://example.com/buzzer/webhook","events":[...],"created_at":"...","secret":"whsec_..."}

curl -X POST "https://api.buzzerapi.com/v1/webhooks/WEBHOOK_ID/test" -H "Authorization: Bearer $BUZZER_API_KEY"

Store secret right away. Listing endpoints never returns it. If the create request times out, repeat it with the same Idempotency-Key and body: you get 200 with the same endpoint and secret, plus Idempotency-Replayed: true. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT. Omit events to receive every type. Creating needs access:write; listing needs access:read.

EventWhen it fires
access.grantedA timer, passcode, or routine granted access. Includes unlock_id, unlock_type, label, and remaining_uses.
access.call_forwardedA buzz matched no rule and was forwarded to the resident's phone.
access.deniedA visitor entered a wrong passcode.
unlock.no_uses_remainingA passcode or timer used its last use.
webhook.testSent by POST /v1/webhooks/{id}/test.

Respond with any 2xx within 5 seconds. Redirects are not followed. Any other result is retried after 1 minute, 5 minutes, 30 minutes, and 2 hours, then marked failed. A retry repeats the same event id, so deduplicate on it. Delivery order is not guaranteed, so order events by data.occurred_at, not by arrival. Delivery never delays the call at the door. Delete an endpoint with DELETE /v1/webhooks/{id} to stop deliveries.

Verify the signature

Every delivery has a BuzzerAPI-Signature: t=UNIX_SECONDS,v1=HEX header. v1 is the HMAC-SHA256 of t, a period, and the raw request body, keyed with the whole secret including the whsec_ prefix. Verify before parsing JSON, compare in constant time, and reject timestamps more than 5 minutes old.

// Node (Express): use the raw body, not parsed JSON
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/buzzer/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('BuzzerAPI-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
  const expected = crypto.createHmac('sha256', process.env.BUZZER_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  // deduplicate on event.id, then handle event.type
  res.sendStatus(204);
});
# Python
import hmac, hashlib, os, re, time

def verify(raw_body: bytes, header: str) -> bool:
    if not isinstance(header, str):
        return False
    parts = {}
    for part in header.split(","):
        key, separator, value = part.strip().partition("=")
        if not separator or key not in ("t", "v1") or key in parts:
            return False
        parts[key] = value
    t = parts.get("t", "")
    signature = parts.get("v1", "")
    if not re.fullmatch(r"[0-9]{1,12}", t) or not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(os.environ["BUZZER_WEBHOOK_SECRET"].encode(),
                        f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

See the webhook reference for every payload field and URL rule.

REST API reference

Read every endpoint, request/response field, scope, limit, and error, or download the OpenAPI 3.1 contract. The table below is an overview.

Use Authorization: Bearer YOUR_API_KEY and Content-Type: application/json for JSON bodies. Public plans, health, and email-code authentication do not require a key. Building-scoped requests use X-Buzzer-Building-Id. Access creation, checkout, and building execution require Idempotency-Key; webhook creation accepts one. Treat IDs as opaque. Requests must use HTTPS; plain HTTP returns 426 HTTPS_REQUIRED.

curl "https://api.buzzerapi.com/v1/buildings" \
  -H "Authorization: Bearer $BUZZER_API_KEY"

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","enabled":true,"expires_in_minutes":60,"remaining_uses":1,"label":"Guest"}'
Method and pathPurpose / body
GET /v1/healthService health, plus openapi_url and docs_url
GET /v1/openapi.jsonRedirects (302) to the OpenAPI 3.1 contract; public, not rate limited
GET /v1/plansPublic BuzzerAPI weekly and yearly prices
POST /v1/auth/otpRequest email challenge: {email}
POST /v1/auth/verifyWebsite sign-in: {email,challenge,otp,purpose:"web"}; other purposes return WEBSITE_SIGN_IN_REQUIRED
DELETE /v1/auth/keyRevoke current key
GET, POST /v1/auth/keysWebsite sign-in: list every key / create an AI agent key: {name,scopes,expires_in_days}
DELETE /v1/auth/keys/:idWebsite sign-in: revoke any key
POST /v1/auth/keys/revoke-othersWebsite sign-in: revoke every key except the one sending the request
GET /v1/accountSubscription, number, capabilities, readiness, next actions
GET /v1/account/phoneWebsite sign-in: forwarding phone status
POST /v1/account/phone/verificationAfter subscription and provisioning, send SMS: {phone_number}
POST /v1/account/phone/confirmationConfirm SMS code: {code}
GET /v1/billing/plansAuthenticated plan catalog
GET, POST /v1/billing/checkoutRecover/create checkout; create: {plan}
POST /v1/billing/checkout/:id/expireExpire unpaid checkout
POST /v1/billing/portalHosted billing portal
GET /v1/buildings/{buildingId}/setupRead onboarding status and instructions
GET, PATCH /v1/buildings/{buildingId}/settingsRead / update tone and greeting with ETag
GET /v1/buildingsAllowed building inventory and virtual numbers
POST /v1/buildings/previewAdd: {action:"add",label,address:{street,city,state,zip,country:"US"|"CA"},unlock_tone,phone_number}; remove: {action:"remove",building_id}
POST /v1/buildingsExecute addition: {quote_id}
DELETE /v1/buildings/:idExecute removal with JSON body {quote_id}
GET /v1/buildings/operations/:idDurable operation status and receipt
GET, POST /v1/unlockList/create timer, passcode, routine
GET, PATCH, DELETE /v1/unlock/:idRead, update with If-Match, revoke
GET /v1/logsActivity; filter since,until,type,unlock_id,succeeded,q,limit,cursor
GET, POST /v1/webhooksList endpoints / register one: {url,events}; secret returned only on create
DELETE /v1/webhooks/:idDelete an endpoint and cancel its pending deliveries
POST /v1/webhooks/:id/testQueue a signed webhook.test event

The REST reference lists every field, filter, and option.

Errors and reliable automation

Errors return a JSON envelope. Inspect error.code, requestId, error.retryable, and error.next_action. Retain request IDs for support without logging secrets. Limits depend on your plan. Watch RateLimit-Remaining and RateLimit-Reset; after a 429, wait for Retry-After (see limits). A 2xx response means the request succeeded; for building changes, inspect the operation status for pending work. Recover a timed-out mutation with its original Idempotency-Key and identical body; a replay returns Idempotency-Replayed: true, and a changed body returns 409 IDEMPOTENCY_CONFLICT (see idempotency). See errors and recovery for every code.

QUOTE_EXPIRED or QUOTE_STALE requires a fresh preview before execution. BUILDING_BUSY requires checking the current operation, not spinning up parallel requests. BUILDING_BILLING_MISMATCH requires support reconciliation. BUILDING_ADD_FAILED means nothing changed; a new preview can be tried. A reconciliation_required receipt blocks further building changes; contact support with the operation ID.

Building tone and greeting

The account page lets you change the selected building’s unlock tone and greeting. The API targets a building explicitly: use an ID from GET /v1/buildings. Read its settings first, review them, then save with the returned ETag.

GET /v1/buildings/{buildingId}/settings
Authorization: Bearer <key with account:read>

PATCH /v1/buildings/{buildingId}/settings
Authorization: Bearer <key with setup:write>
Content-Type: application/json
If-Match: "<64-hex-token-from-GET>"

{"unlock_tone":"9","greeting":{"custom_greeting":"Welcome to East entrance"}}

Omitted fields stay unchanged. Tone is 1–3 digits, # or *. Greeting phrases are shorter than 120 UTF-16 code units (normally up to 119 characters; astral characters count twice). Empty custom_greeting restores standard instructions; empty access_granted_phrase or access_denied_phrase silences that phrase. prompt_for_passcode is boolean, preserving the existing call flow.

Only the number’s owner may change it. GET returns editable: false for invited members, and their PATCH returns 403 NUMBER_OWNER_REQUIRED. Linked-building writes require an active BuzzerAPI subscription. A child key cannot change a sibling. Tone and greeting save together and other buildings remain unchanged. After 409 SETTINGS_VERSION_CONFLICT or an uncertain save, read back and review before retrying; 428 SETTINGS_VERSION_REQUIRED means a current quoted ETag is needed. Test a changed tone at the entrance yourself. See the complete settings contract.