<!-- Webhooks — https://docs.warmerly.com/webhooks -->

# Webhooks

Subscribe a URL you control to receive HTTP `POST` notifications for mailbox health
events, health score drops, auto-pauses, OAuth disconnects, DNS changes, and similar.
This page covers managing **outbound webhook subscriptions**; it's unrelated to
Warmerly's own inbound provider webhooks (Stripe, Unipile), which aren't part of
the public API.

**Webhook subscriptions are an Agency-plan feature.** Listing, creating, and re-enabling
a subscription return `403 agency_plan_required` on Starter and Growth workspaces.
Deleting a subscription you already hold is always allowed, even after a downgrade.

All endpoints below require `X-Api-Key` and are not project-scoped, a subscription
receives events for every account you own across every project.

## Event types

Pass one or more of these in `eventTypes` when creating a subscription:

| Event type | Description |
| --- | --- |
| `score_drop` | A mailbox's warmup health score dropped. |
| `score_floor` | A mailbox's health score hit a critical floor. |
| `oauth_disconnected` | A sign-in connected mailbox (Microsoft 365, Outlook, or Gmail connected before 2026-09-15) lost its connection. |
| `auth_fail` | A mailbox failed to authenticate. |
| `dns_changed` | A mailbox's DNS records (SPF/DKIM/DMARC) changed. |
| `smtp_fail` | An SMTP send failed for a mailbox. |
| `imap_fail` | An IMAP sync failed for a mailbox. |
| `flagged` | A mailbox was flagged. |
| `auto_paused` | A mailbox was automatically paused. |
| `auto_resumed` | A mailbox was automatically resumed after a pause. |
| `manual_pause` | A mailbox was manually paused from the dashboard. |
| `aged_in` | A mailbox finished warmup ramp-up and is now fully aged in. |
| `marked_dead` | A mailbox was marked dead. |
| `oauth_refresh_fail` | An OAuth token refresh failed for a mailbox. |
| `oauth_connected` | A mailbox was connected via OAuth. |
| `oauth_reconnected` | A previously disconnected OAuth mailbox was reconnected. |

## List subscriptions

```
GET /webhooks/subscriptions
```

Returns your active (non-revoked) subscriptions. The secret is masked to its last 4
characters, it's only shown in full at creation time.

```bash
curl https://app.warmerly.com/api/v1/webhooks/subscriptions \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "subscriptions": [
    {
      "id": "e5b3a7c2-8d4e-4f0a-9c2b-6a1d3e8f5c7b",
      "name": "Mailbox alerts → Slack relay",
      "targetUrl": "https://example.com/hooks/warmerly",
      "secret": "••••7c9e",
      "eventTypes": ["score_drop", "oauth_disconnected"],
      "active": true,
      "lastDispatchedAt": "2026-08-18T09:00:00.000Z",
      "lastDeliveryStatus": "success",
      "lastDeliveryError": null,
      "consecutiveFailures": 0,
      "createdAt": "2026-07-01T10:00:00.000Z"
    }
  ]
}
```

## Create a subscription

```
POST /webhooks/subscriptions
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string (1-120 chars) | Yes | A label to identify the subscription. |
| `targetUrl` | string (URL) | Yes | Must be a public `https://` URL, localhost, private/link-local IP ranges, and non-https URLs are rejected. |
| `eventTypes` | string[] | Yes | One or more values from [Event types](#event-types). |

Each user can hold at most **10 active subscriptions**; creating an 11th returns `400
too_many_subscriptions`.

```bash
curl -X POST https://app.warmerly.com/api/v1/webhooks/subscriptions \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mailbox alerts",
    "targetUrl": "https://example.com/hooks/warmerly",
    "eventTypes": ["score_drop", "auto_paused", "oauth_disconnected"]
  }'
```

`201 Created`:

```json
{
  "subscription": {
    "id": "e5b3a7c2-8d4e-4f0a-9c2b-6a1d3e8f5c7b",
    "name": "Mailbox alerts",
    "targetUrl": "https://example.com/hooks/warmerly",
    "eventTypes": ["score_drop", "auto_paused", "oauth_disconnected"]
  },
  "secret": "8f2a7c9e4b1d6f3a9c2e5b8d1a4f7c9e6b3d8a1f4c7e9b2d5a8f1c4e7b9d3a6f"
}
```

**`secret` is only ever returned in full here.** Store it — it's used to verify the
`X-Warmerly-Signature` header on every delivery (see [Verifying deliveries](#verifying-deliveries)
below). It cannot be retrieved again; delete the subscription and create a new one if
you lose it.

Errors: `400 bad_request` (`invalid_body` for a malformed request, or `unknown_event_types`
with `details: { unknown: [...] }` if `eventTypes` contains a value not in the table
above), `403 agency_plan_required` if your plan doesn't include webhooks.

## Update a subscription

```
PATCH /webhooks/subscriptions/{id}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `active` | boolean | Yes | Enable or disable delivery. Re-enabling also resets `consecutiveFailures` to 0. |

Currently the only supported update is toggling `active`, there's no way to change
`targetUrl` or `eventTypes` in place; delete and recreate the subscription instead. This
endpoint is also how you manually re-enable a subscription the delivery sweep
auto-disabled (see [Delivery & retries](#delivery--retries)).

```bash
curl -X PATCH https://app.warmerly.com/api/v1/webhooks/subscriptions/e5b3a7c2-8d4e-4f0a-9c2b-6a1d3e8f5c7b \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "active": true }'
```

```json
{
  "subscription": {
    "id": "e5b3a7c2-8d4e-4f0a-9c2b-6a1d3e8f5c7b",
    "name": "Mailbox alerts",
    "targetUrl": "https://example.com/hooks/warmerly",
    "eventTypes": ["score_drop", "auto_paused", "oauth_disconnected"],
    "active": true
  }
}
```

`404 not_found` if the subscription doesn't exist or isn't yours.

## Delete a subscription

```
DELETE /webhooks/subscriptions/{id}
```

Revokes (soft-deletes) the subscription; deliveries stop immediately. Not gated on plan
tier, a downgraded workspace can still turn off deliveries it can no longer manage.

```bash
curl -X DELETE https://app.warmerly.com/api/v1/webhooks/subscriptions/e5b3a7c2-8d4e-4f0a-9c2b-6a1d3e8f5c7b \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "revoked": true }
```

`404 not_found` if the subscription doesn't exist, isn't yours, or was already revoked.

## Delivery & retries

Deliveries are dispatched by a background sweep (not real-time) that polls for new
events roughly every few minutes. Each delivery is a `POST` to your `targetUrl`:

```json
{
  "id": "9d1e0b7a-5e7b-4f10-b2aa-1c8f0d3e6a4b",
  "type": "score_drop",
  "accountId": "3a6f9e2e-4b7a-4e9a-9b1d-6e3a2f8c1a10",
  "occurredAt": "2026-08-18T09:00:00.000Z",
  "data": { "severity": "warning", "title": "Health score dropped", "scoreBefore": 88, "scoreAfter": 61 }
}
```

`data`'s shape varies by `type`, it mirrors the underlying alert/account event and
isn't separately typed per event today (documenting the exact `data` shape for every
event type is a known gap; see [Known gaps](#known-gaps-in-this-page)).

- A delivery is retried up to **3 times** with exponential backoff if your endpoint
  doesn't respond `2xx` within **5 seconds**, or errors/times out.
- A subscription is **automatically disabled** (`active: false`) after **10 consecutive
  failed sweeps**. Re-enable it with `PATCH .../{id}`.
- Up to 50 events per subscription are delivered per sweep; if you have a burst larger
  than that, the remainder is delivered on the next sweep.
- Redirects are not followed automatically (`redirect: "manual"`), respond `2xx`
  directly from `targetUrl`.

### Verifying deliveries

Every delivery carries an `X-Warmerly-Signature` header: the hex-encoded HMAC-SHA256 of
the raw request body, signed with your subscription's `secret`.

```
X-Warmerly-Signature: 3f9a7c2e...  (hex-encoded HMAC-SHA256 of the raw body)
```

Verify it by recomputing the HMAC over the exact bytes of the received body and comparing:

```js
const crypto = require("crypto");
const expected = crypto.createHmac("sha256", subscriptionSecret).update(rawBody).digest("hex");
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSignature));
```

## Known gaps in this page

- The exact `data` payload shape is not separately documented per event type, it
  passes through the underlying alert/account event record as-is, and cataloguing all
  16 shapes precisely was out of scope for this pass.
- There's no endpoint to list recent delivery attempts/history beyond the single
  `lastDispatchedAt`/`lastDeliveryStatus`/`lastDeliveryError` snapshot on the
  subscription itself.
