# Webhooks

Instead of polling the API, subscribe an HTTPS URL to events. When something changes in banta - in the app, through the API or by an automation - banta sends your URL a signed `POST` with the details.

## Events

| Event | Sent when |
|---|---|
| `lead.created` | A lead was created |
| `lead.status_changed` | A lead's status changed (including between two custom statuses) |
| `lead.assigned` | A lead was assigned to another user, or unassigned |
| `lead.converted` | A lead was converted to a mortgage file |
| `lead.deleted` | A lead was permanently deleted |
| `mortgage_file.created` | A mortgage file was created |
| `mortgage_file.status_changed` | A mortgage file's status changed |
| `mortgage_file.assigned` | A mortgage file was assigned to another user, or unassigned |
| `mortgage_file.deleted` | A mortgage file was deleted (moved to the recycle bin or deleted permanently) |
| `task.created` | A task was created |
| `task.completed` | A task's status changed to `completed` |
| `payment.created` | A payment was recorded on a mortgage file |
| `charge.paid` | A charge's status changed to `paid` |
| `milestone.completed` | A milestone of a mortgage file was completed |

`GET /webhooks/events` returns this catalog. A test event, `ping`, is sent only by `POST /webhooks/{id}/test`.

## Subscribing

Webhook endpoints are managed with an API key that has an **explicit** webhooks permission - `webhooks:read` and `webhooks:write` (`webhooks:delete` to unsubscribe), `webhooks:*` or `admin`. The general `read`/`write` permissions do not include it, because a subscription sends personal data to an external URL.

```bash
curl -X POST "https://app.banta.co.il/api-service/webhooks" \
  -H "x-api-key: $BANTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/banta/webhooks",
    "events": ["lead.created", "lead.status_changed"],
    "description": "CRM sync"
  }'
```

The response includes `secret` (`whsec_...`). **It is shown only once** - store it in your server's secrets. You can replace it later with `POST /webhooks/{id}/rotate-secret`; the old secret stops working immediately.

- The URL must be `https` on the default port, without a username or password, and must not point to `localhost`, an internal host name or a private IP address.
- An organization can have up to 20 endpoints.
- Send a test with `POST /webhooks/{id}/test`, and follow it in `GET /webhooks/{id}/deliveries`.

## The request

```http
POST /banta/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: banta-webhooks/1
X-Banta-Event: lead.status_changed
X-Banta-Event-Id: 0f1e2d3c-4b5a-4968-8776-655443322110
X-Banta-Delivery: 7c6b5a49-3827-4615-a493-827160594837
X-Banta-Timestamp: 1791975012
X-Banta-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
```

```json
{
  "id": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "type": "lead.status_changed",
  "created_at": "2026-10-14T09:30:12.418503+00:00",
  "data": {
    "object": "lead",
    "id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
    "changes": { "status": { "from": "new", "to": "contacted" } },
    "lead": {
      "id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
      "lead_number": "1042",
      "first_name": "Dana",
      "last_name": "Levi",
      "phone": "0501234567",
      "email": "dana@example.com",
      "status": "contacted",
      "status_custom": null,
      "source": "website",
      "assigned_to": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a99",
      "converted_to_mortgage_file_id": null,
      "created_at": "2026-10-13T16:05:44.120931+00:00"
    }
  }
}
```

- `data.object` is the object type, and the key of its snapshot in `data` (`lead`, `mortgage_file`, `task`, `payment`, `charge` or `milestone`).
- The snapshot is the object at the moment of the change, limited to a fixed set of fields - never ID numbers, incomes or documents. Fetch the full record with `GET` and `data.id`.
- `changes` lists the snapshot fields that changed, with `from` and `to`. It is empty for created and deleted events.
- One change can produce several events - for example a status change and a new assignee in the same update.

## Verifying the signature

`X-Banta-Signature` is `sha256=` followed by the lowercase hex HMAC-SHA256 of `<X-Banta-Timestamp>.<raw body>`, keyed with the **whole** secret (including the `whsec_` prefix).

- Verify against the raw request body, exactly as received - not re-serialized JSON.
- Reject timestamps more than 5 minutes from your clock.
- Compare in constant time.

**Node.js (Express)**

```js
import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.BANTA_WEBHOOK_SECRET // whsec_... (the whole value)
const app = express()

app.post('/banta/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Banta-Timestamp') ?? ''
  const signature = req.get('X-Banta-Signature') ?? ''
  const rawBody = req.body.toString('utf8')

  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('stale timestamp')
  }
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('invalid signature')
  }

  const event = JSON.parse(rawBody)
  // event.id === X-Banta-Event-Id - skip it if you already processed it
  res.sendStatus(200) // reply fast, process asynchronously
})
```

**Python (Flask)**

```python
import hashlib, hmac, os, time
from flask import Flask, abort, request

SECRET = os.environ["BANTA_WEBHOOK_SECRET"].encode()  # whsec_... (the whole value)
app = Flask(__name__)

@app.post("/banta/webhooks")
def banta_webhook():
    timestamp = request.headers.get("X-Banta-Timestamp", "")
    signature = request.headers.get("X-Banta-Signature", "")
    raw_body = request.get_data()  # raw bytes, exactly as sent

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(400)
    expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    # deduplicate on event["id"]
    return "", 200
```

**PHP**

```php
<?php
$secret    = getenv('BANTA_WEBHOOK_SECRET'); // whsec_... (the whole value)
$timestamp = $_SERVER['HTTP_X_BANTA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_BANTA_SIGNATURE'] ?? '';
$rawBody   = file_get_contents('php://input');

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
// deduplicate on $event['id']
http_response_code(200);
```

## Responding and retries

Reply with any `2xx` within **10 seconds**, and do the work asynchronously.

| Your response | What banta does |
|---|---|
| `2xx` | Delivered |
| `408`, `429`, `5xx`, a network error or a timeout | Retries after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours - 8 attempts in about 45 hours. After that the delivery is `parked` |
| Any other `4xx`, or a redirect (`3xx`) | Final - not retried. Redirects are never followed |
| `410 Gone` | The endpoint is disabled immediately (this is how Zapier unsubscribes) |

After **30 failed attempts in a row**, the endpoint is disabled, deliveries waiting for it are skipped, and the organization's admins get a notification in banta. Fix the receiver, then reactivate it with `PATCH /webhooks/{id}` and `{"is_active": true}` - only events that happen after that are sent.

- **Delivery is at least once.** A retry or a redelivery has the same `id` (and `X-Banta-Event-Id`) - use it to ignore duplicates.
- **Order is not guaranteed.** Use `created_at` and the snapshot, and fetch the record when the order matters.
- If the API key that created an endpoint is deleted or revoked, the endpoint is disabled too.

## The delivery log

`GET /webhooks/{id}/deliveries` lists the deliveries of an endpoint, newest first - status, attempts, the last HTTP status and error, and the payload. `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` sends a delivery again with the same event `id`. The log is kept for 14 days (delivered) or 30 days (everything else).

## Zapier and Make

The endpoints follow the REST Hooks pattern: subscribe with `POST /webhooks` and keep the returned `id`; unsubscribe with `DELETE /webhooks/{id}`. In Make or n8n, create a custom webhook, copy its URL, and subscribe it the same way.

---

Part of the banta API documentation. Full index: https://docs.banta.co.il/llms.txt
