> ## Documentation Index
> Fetch the complete documentation index at: https://qwady.wiki/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Push notifications for listing changes, delivered within minutes of detection

<Note>Webhooks require a **Business** tier subscription.</Note>

## Overview

Borough webhooks notify you when a listing changes, delivered within minutes of Borough detecting the change on its regular refresh cycle (not the instant it changes upstream). Webhooks use the [Standard Webhooks](https://www.standardwebhooks.com/) specification for payload signing and verification.

## Event types

| Event                     | Description                                                                                                     |
| ------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `listing.created`         | A new listing appeared in the search index                                                                      |
| `listing.price_decreased` | Listing price dropped by more than \$25                                                                         |
| `listing.price_increased` | Listing price increased by more than \$25                                                                       |
| `listing.status_changed`  | Listing status changed (e.g., ACTIVE to OFF\_MARKET; off-market transitions are delivered as `listing.expired`) |
| `listing.expired`         | Listing went off-market                                                                                         |

## Delivery scope

Subscriptions are matched on **event type only**. There is no per-subscription area, price, or listing filter: an active subscription receives every change of a subscribed type across the whole Borough dataset, so subscribing to `listing.created` means every new listing Borough indexes, citywide.

Two ways to narrow that down:

* **Filter in your handler.** Every payload carries `data.listingId` (see below); ignore the ones you do not care about.
* **Use a [watcher](/borough/guides/streaming#persistent-watchers).** `POST /v1/watchers` scopes monitoring to a single listing, a single building, or one saved search, and emits changes for that target only.

## Payload format

```json theme={null}
{
  "type": "listing.price_decreased",
  "data": {
    "listingId": "4961849",
    "oldValue": "5222",
    "newValue": "4900"
  },
  "timestamp": "2026-02-15T14:30:00Z"
}
```

## Signature verification

Every webhook request includes three headers:

| Header              | Description               |
| ------------------- | ------------------------- |
| `webhook-id`        | Unique delivery ID        |
| `webhook-timestamp` | Unix timestamp of signing |
| `webhook-signature` | `v1,<base64-hmac-sha256>` |

The signature is computed as:

```
HMAC-SHA256(secret, "${webhook-id}.${webhook-timestamp}.${body}")
```

### Verify with the SDK

<CodeGroup>
  ```typescript Express theme={null}
  import { webhookMiddleware } from '@borough/sdk/webhooks/express';

  app.post('/webhooks/borough',
  express.raw({ type: 'application/json' }),
  webhookMiddleware(process.env.BOROUGH_WEBHOOK_SECRET, async (event) => {
  console.log('Received:', event.type, event.data);
  })
  );

  ```

  ```typescript Next.js theme={null}
  // app/api/webhooks/borough/route.ts
  import { webhookHandler } from '@borough/sdk/webhooks/nextjs';

  export const POST = webhookHandler(
    process.env.BOROUGH_WEBHOOK_SECRET!,
    async (event) => {
      console.log('Received:', event.type, event.data);
    }
  );
  ```
</CodeGroup>

## Retry schedule

Failed deliveries (non-2xx responses, timeouts, and connection errors) are retried up to **7 times** with exponentially increasing delays (10s, 30s, 90s, 4.5m, 13.5m, 40.5m, then 1h), so a delivery is attempted over roughly two hours before it is given up on. After all retries are exhausted, the delivery is moved to a dead letter queue.

During a brief maintenance window, deliveries are parked and re-attempted rather than dropped. Parked attempts still count against the 7-retry budget, so an extended pause can send a delivery to the dead letter queue. Treat the retry budget as the ceiling in both cases.

## Managing subscriptions

Use the [Webhook CRUD endpoints](/borough/api-reference/webhooks/create-webhook) to create, list, update, and delete subscriptions.
