# Webhooks API

Subscribe to real-time events from your Rentalot account. When events occur (new inquiry, message sent, showing booked, etc.), Rentalot sends a `POST` request to your URL with an HMAC-signed JSON payload.

## Event Types

| Event | Description |
|-------|-------------|
| `inquiry.created` | A new contact reached out for the first time |
| `message.received` | An inbound message was received from a contact |
| `message.sent` | An outbound message was sent to a contact |
| `showing.booked` | A showing was scheduled |
| `showing.cancelled` | A showing was cancelled |
| `showing.deleted` | A showing was hard-deleted |
| `event.created` | A calendar event was created |
| `event.cancelled` | A calendar event was cancelled |
| `event.deleted` | A calendar event was deleted |
| `contact.updated` | A contact's details were updated |
| `property.updated` | A property listing was updated (incl. images) |
| `workflow.created` | A new workflow template was created |
| `workflow.updated` | A workflow template was updated |
| `workflow.deleted` | A workflow template was deleted |
| `workflow.completed` | A workflow run finished |
| `bulk_import.completed` | A bulk-property-import job finished |

## List Webhooks

```
GET /api/v1/webhooks
```

**Response (`200 OK`):**

```json
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "url": "https://example.com/webhooks/rentalot",
      "events": ["inquiry.created", "showing.booked"],
      "active": true,
      "description": "CRM sync",
      "lastDeliveredAt": "2026-02-11T14:30:00.000Z",
      "createdAt": "2026-02-01T10:00:00.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 2, "totalPages": 1 }
}
```

## Create Webhook

```
POST /api/v1/webhooks
```

**Request body:**

```json
{
  "url": "https://example.com/webhooks/rentalot",
  "events": ["inquiry.created", "message.received", "showing.booked"],
  "description": "CRM sync webhook"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | HTTPS URL to receive events. Private/loopback IPs are rejected (SSRF protection). |
| `events` | string[] | Yes | At least one event type from the list above. |
| `description` | string | No | Human-readable label (max 500 chars). |

Supports `Idempotency-Key`.

**Response (`201 Created`):**

```json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://example.com/webhooks/rentalot",
    "events": ["inquiry.created", "message.received", "showing.booked"],
    "active": true,
    "description": "CRM sync webhook",
    "secret": "a1b2c3d4e5f6...",
    "createdAt": "2026-02-01T10:00:00.000Z"
  }
}
```

The `secret` is **only returned on create and rotate**. Save it — you'll need it to verify webhook signatures.

## Get Webhook

```
GET /api/v1/webhooks/:id
```

**Response (`200 OK`):** `{ "data": <webhook> }` — no `secret` field.

## Update Webhook

```
PATCH /api/v1/webhooks/:id
```

```json
{
  "events": ["inquiry.created", "showing.booked"],
  "active": false
}
```

| Field | Type | Description |
|-------|------|-------------|
| `url` | string | New HTTPS endpoint |
| `events` | string[] | Replace subscribed events |
| `active` | boolean | Enable or disable delivery |
| `description` | string | Update label |

## Delete Webhook

```
DELETE /api/v1/webhooks/:id
```

Returns `204 No Content`.

## Test Webhook

```
POST /api/v1/webhooks/:id/test
```

Sends a synthetic `test.ping` event to the webhook URL with a payload of `{"message":"This is a test webhook delivery from Rentalot"}`. Use this to verify your endpoint is reachable and correctly handling payloads.

**Response (`200 OK`):**

```json
{
  "data": {
    "delivered": true,
    "event": "test.ping"
  }
}
```

`delivered: false` means the URL responded with a non-2xx status, timed out, or failed the SSRF check.

## Rotate Webhook Secret

```
POST /api/v1/webhooks/:id/rotate-secret
```

Generates a new HMAC signing secret. Resets `consecutiveFailures` to 0. The new secret is returned **once** in the response — save it immediately. The old secret stops working as soon as the rotation completes.

**Response (`200 OK`):**

```json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://example.com/webhooks/rentalot",
    "events": ["inquiry.created"],
    "active": true,
    "description": "CRM sync",
    "secret": "new-secret-here..."
  }
}
```

## Payload Format

Every webhook delivery is a `POST` request with a JSON body:

```json
{
  "event": "showing.booked",
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440000"
  },
  "timestamp": "2026-02-11T14:30:00.000Z",
  "webhookId": "9f1e1f2a-...",
  "version": "2026-02-27"
}
```

| Field | Description |
|-------|-------------|
| `event` | Event type — one of the values in the table above. |
| `data` | Event-specific payload. For most events this is `{ id }` referencing the affected resource. |
| `timestamp` | ISO 8601 server timestamp. |
| `webhookId` | Stable per-delivery UUID. Identical across retries — use it to dedupe on your side. |
| `version` | Webhook API version (date-based; bumped on breaking payload changes). |

## Verifying Signatures

Every delivery includes these headers:

| Header | Description |
|--------|-------------|
| `X-Webhook-Signature` | `sha256={HMAC-SHA256 hex digest of the raw request body}` |
| `X-Webhook-Id` | Same as `webhookId` in the body — stable across retries. |
| `X-Webhook-Timestamp` | ISO 8601 delivery timestamp (matches `timestamp` in the body). |

Compute the HMAC-SHA256 of the raw request body using your webhook `secret` and compare with the signature header:

```javascript
import crypto from "node:crypto";

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return signature === `sha256=${expected}`;
}
```

Always use a constant-time comparison (e.g. `crypto.timingSafeEqual`) in production.

## Retry Policy

Failed deliveries (non-2xx response, timeout > 10 s, or SSRF rejection) are retried with exponential backoff. After 10 consecutive failures the subscription is auto-deactivated — re-enable it via `PATCH` with `active: true` after fixing your endpoint, or call `rotate-secret` (which also resets the failure counter).

## Idempotency

`POST /api/v1/webhooks` and `POST /api/v1/webhooks/:id/rotate-secret` accept the `Idempotency-Key` header.
