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

# Webhooks

> HMAC-SHA-256 signed POSTs delivered by a background worker with exponential backoff.

Webhooks are part of [Salty](https://trysalty.com), the CRM built for AI agents — see [trysalty.com](https://trysalty.com) for the product overview and pricing.

When data changes in a Salty workspace, registered webhook endpoints receive a signed `POST` with the new state. A background worker fires pending deliveries within seconds and retries failures with an exponential backoff. After 8 consecutive failures the endpoint is marked inactive and a `webhook_endpoint.failed` meta-event fans out to any *other* still-active endpoints in the same workspace.

## Register an endpoint

```bash theme={null}
curl -X POST $SALTY_API/webhook-endpoints \
  -H "Authorization: Bearer $SALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/salty-webhooks",
    "subscribed_events": ["*"]
  }'
```

Response (the `signing_secret` is shown **once**; store it):

```json theme={null}
{
  "id": "e0f7ff39-9e17-4210-ad6c-57fa091eddd3",
  "url": "https://your-app.example.com/salty-webhooks",
  "subscribed_events": ["*"],
  "is_active": true,
  "created_at": "2026-05-24T12:00:00Z",
  "signing_secret": "whsec_rMvR4H8G4t80BxnImJ..."
}
```

Subsequent reads of the endpoint (`GET /webhook-endpoints` or `GET /webhook-endpoints/:id`) **omit** `signing_secret`.

## Signature

Every delivery carries:

```
Salty-Signature: t=1779587687,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

Verify by computing HMAC-SHA-256 over `<timestamp>.<body>` with your `whsec_…` secret, then compare with constant-time equality. **Reject deliveries older than 5 minutes** to prevent replay.

### Verifier (Node)

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(payload: string, header: string, secret: string, toleranceMs = 5 * 60_000): boolean {
  const parts = Object.fromEntries(header.split(',').map((s) => s.split('=').map((x) => x.trim())));
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() - t) > toleranceMs) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

### Payload shape

```json theme={null}
{
  "event": "person.created",
  "data": {
    "id": "b5a6d70e-483e-4826-ad66-99546ea4e102",
    "email": "jane@acme.com",
    "first_name": "Jane",
    "last_name": null,
    "primary_company_id": null,
    "custom_attributes": {},
    "created_at": "2026-05-24T12:00:00Z",
    "updated_at": "2026-05-24T12:00:00Z"
  },
  "delivery_id": "1125fdc6-aaaa-bbbb-cccc-dddddddddddd"
}
```

`delivery_id` is unique per delivery — use it for idempotent receiver-side processing (the same event may be re-delivered after a transient receiver failure).

## Retry schedule

`1s → 5s → 30s → 5min → 30min → 2h → 12h` between attempts. After the **8th** attempt fails, the delivery is marked `failed`, the endpoint flips `is_active = false`, and a `webhook_endpoint.failed` meta-event fans out to other still-active endpoints in the workspace:

```json theme={null}
{
  "event": "webhook_endpoint.failed",
  "data": {
    "endpoint_id": "bc31e811-…",
    "url": "https://broken-receiver.example.com/",
    "failed_delivery_id": "…",
    "last_response_status": 500
  },
  "delivery_id": "…"
}
```

To re-enable a deactivated endpoint, delete it and create a new one. (v1.1 will add a re-enable endpoint.)

## Events

| Object                      | Events                                                             |
| --------------------------- | ------------------------------------------------------------------ |
| `person`, `company`, `deal` | `.created`, `.updated`, `.deleted`                                 |
| `note`, `task`, `activity`  | `.created`, `.updated`, `.deleted`                                 |
| `custom_object_record`      | `.created`, `.updated`, `.deleted`                                 |
| `schema`                    | `.attribute_added`, `.attribute_modified`, `.attribute_deprecated` |
| meta                        | `webhook_endpoint.failed`                                          |

Subscribe to `"*"` for everything, or pass a list of exact event names (no glob patterns in v1).

## API surface

| Verb   | Path                                | Notes                                       |
| ------ | ----------------------------------- | ------------------------------------------- |
| POST   | `/webhook-endpoints`                | Returns `signing_secret` (once)             |
| GET    | `/webhook-endpoints`                | List, paginated                             |
| GET    | `/webhook-endpoints/:id`            | Single                                      |
| DELETE | `/webhook-endpoints/:id`            | Cascade deletes deliveries                  |
| GET    | `/webhook-endpoints/:id/deliveries` | Audit log, newest first                     |
| POST   | `/webhook-endpoints/:id/test`       | Synchronous fire; returns receiver response |

## Test endpoint

```bash theme={null}
curl -X POST $SALTY_API/webhook-endpoints/<id>/test \
  -H "Authorization: Bearer $SALTY_API_KEY"
```

Synchronously POSTs `{event: "webhook_endpoint.test", data: {ping: "pong"}}` to your URL and returns the receiver's HTTP status + body. Doesn't enqueue, doesn't retry — just a single fire to confirm wiring is right.

## Delivery audit

```bash theme={null}
curl $SALTY_API/webhook-endpoints/<id>/deliveries \
  -H "Authorization: Bearer $SALTY_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "id": "…",
      "webhook_endpoint_id": "…",
      "event_type": "person.created",
      "status": "delivered",
      "attempts": 1,
      "response_status_code": 200,
      "response_body": "",
      "last_attempt_at": "2026-05-24T12:00:02Z",
      "next_retry_at": null,
      "created_at": "2026-05-24T12:00:00Z"
    }
  ],
  "next_cursor": null
}
```

`status` is one of: `pending`, `in_flight`, `delivered`, `failed`. `next_retry_at` is the next scheduled attempt for `pending` rows; `null` once terminal.
