<!-- Source: https://useworkloom.com/dev/webhooks/ · Workloom API v1 -->

# Webhooks

Tell Workloom where to send events and it POSTs them to your server as they happen, signed so you can check they came from Workloom.

## Setting one up

Add an endpoint in Workloom under [Settings › API and webhooks › Webhooks](https://app.useworkloom.com/settings/webhooks). You can also manage endpoints over the API: see [Webhook endpoints](https://useworkloom.com/dev/webhook-endpoints/).

1. Choose Add endpoint and give an HTTPS address that accepts POST requests. Addresses on private networks are refused.
2. Choose the events, or Send everything. Send everything covers events added later, but never the Activity log, which you choose by name.
3. Copy the signing secret. It starts with `whsec_`. You can show it again or rotate it later.
4. Press Send a test and check your server answered with a 2xx.

Answer within 10 seconds with any 2xx status, then do the slow work from a queue.

> Each endpoint has its own secret. Managing webhooks needs the webhooks permission, which admins have, and a two-step verification code.

**Headers on a delivery**

```http
X-Workloom-Signature: t=1791002364,v1=3f1c0d…9a2b
X-Workloom-Event: deal.won
X-Workloom-Delivery: whd_3f1c0d9a2b7e44c1a8d6b5f0
Workloom-Version: 2026-10-07
Content-Type: application/json
User-Agent: Workloom-Webhooks/1
```

**A delivery**

```json
{
  "id": "whd_3f1c0d9a2b7e44c1a8d6b5f0",
  "event_id": "evt_66ff5d63e4b0a1b2c3d4aa01",
  "type": "deal.won",
  "api_version": "v1",
  "workloom_version": "2026-10-07",
  "livemode": true,
  "created_at": "2026-10-07T09:12:44.000Z",
  "occurred_at": "2026-10-07T09:12:40.000Z",
  "data": {
    "deal_id": "66f5c3d1e4b0a1b2c3d4c3d4",
    "deal": {
      "id": "66f5c3d1e4b0a1b2c3d4c3d4",
      "name": "Brightline, 10 seats",
      "pipeline_id": "default",
      "stage": "won",
      "status": "won",
      "amount": 29900,
      "currency": "USD",
      "probability": 100,
      "forecast_category": "best_case",
      "expected_close_date": "2026-11-14T00:00:00.000Z",
      "next_step": {
        "text": "Call with VP Finance",
        "due_at": "2026-10-09T10:00:00.000Z"
      },
      "company_name": "Brightline",
      "company_domain": "brightline.example",
      "company_id": "66f19b77e4b0a1b2c3d4a1b2",
      "primary_contact_id": "66f2a1c0e4b0a1b2c3d4e5f6",
      "contacts": [
        {
          "contact_id": "66f2a1c0e4b0a1b2c3d4e5f6",
          "role": "champion"
        }
      ],
      "closed_at": "2026-10-07T09:12:40.000Z",
      "close_reason_code": null,
      "owner_seat_id": "66e0f1a2e4b0a1b2c3d40a0b",
      "source": "api:Warehouse sync",
      "custom_fields": {
        "competitor": "Pipedrive"
      },
      "created_at": "2026-09-25T12:00:00.000Z",
      "updated_at": "2026-10-03T08:42:10.000Z"
    },
    "from_stage": "commit",
    "stage": "won"
  }
}
```

## Checking signatures

Every delivery has an `X-Workloom-Signature` header with a timestamp and an HMAC-SHA256 of the timestamp and the raw body, keyed with your endpoint’s secret.

**The header**

```http
X-Workloom-Signature: t=1791002364,v1=3f1c0d…9a2b
```

1. Split the header into `t` and `v1`.
2. Build the signed text: `t`, a full stop, then the raw request body exactly as received.
3. Compute HMAC-SHA256 of that text with your secret, as lowercase hex.
4. Compare it with `v1` using a constant-time comparison.
5. Reject the delivery if `t` is more than 5 minutes from now, to stop replays.

**Verify a delivery**

```javascript
// Check that a webhook delivery came from Workloom (Node 18+, no dependencies).
//
// Use the RAW request body, exactly as it arrived. A body parsed to JSON and
// serialised again is not the same bytes, and the signature will not match.
//
//   app.post('/hooks/workloom', express.raw({ type: 'application/json' }), (req, res) => {
//     if (!verifyWorkloom(req.body, req.get('X-Workloom-Signature'), process.env.WORKLOOM_WEBHOOK_SECRET)) {
//       return res.status(400).end();
//     }
//     res.status(200).end();               // answer first...
//     const event = JSON.parse(req.body);  // ...then do the work, once per event.id
//   });
const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

function verifyWorkloom(rawBody, header, secret, now = Date.now()) {
  const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(now / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

module.exports = { verifyWorkloom };
```

```python
"""Check that a webhook delivery came from Workloom (Python 3, standard library only).

Use the RAW request body, exactly as it arrived (Flask: request.get_data(),
Django: request.body). A body parsed to JSON and dumped again is not the same
bytes, and the signature will not match.
"""
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_workloom(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in (header or "").split(","))
        t = parts["t"]
        v1 = parts["v1"]
        age = abs((now if now is not None else time.time()) - int(t))
    except (KeyError, ValueError):
        return False
    if age > TOLERANCE_SECONDS:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

if __name__ == "__main__":
    import sys

    body, header, secret = sys.argv[1], sys.argv[2], sys.argv[3]
    print("valid" if verify_workloom(body.encode(), header, secret) else "invalid")
```

> Verify against the raw body. If your framework parses JSON first and you serialise it again, the bytes change and the signature will not match.

## Retries and auto-off

If your server does not answer with a 2xx within 10 seconds, Workloom tries again 30 s, 2 min, 10 min, 30 min, 1 h and 3 h later: 7 attempts over about 4 h 43 min. Deliveries can arrive out of order, so use `occurred_at` if order matters. A delivery can arrive twice, so ignore an `id` you have already handled.

- First attempt: straight away
- Retry 1: 30 s after the attempt before
- Retry 2: 2 min after the attempt before
- Retry 3: 10 min after the attempt before
- Retry 4: 30 min after the attempt before
- Retry 5: 1 h after the attempt before
- Retry 6: 3 h after the attempt before
- After 20 failed attempts in a row: the endpoint is turned off

After 20 failed attempts in a row, counting every retry, Workloom turns the endpoint off and shows why in Settings › API and webhooks. Turn it back on there once it is fixed. Events from while it was off are not sent again.

| Your server answers | What Workloom does |
| --- | --- |
| `2xx` | Marks it delivered. |
| `429`, `5xx`, or nothing in 10 s | Retries on the schedule above. |
| Any other `4xx` | Does not retry that delivery. It counts as a failure. |
| A redirect | Treats it as a failure and retries. Give the final address. |

## Test button and delivery log

Every endpoint has Send a test. It sends `webhook.test`, signed like any other event, and shows your server’s status code. The delivery log shows the latest 50 attempts: the event, the attempt, your status code, how long it took and the start of your answer. Attempts are kept 30 days.

- [Event list](https://useworkloom.com/dev/events/): Every event, what triggers it and the fields it carries.
- [Errors](https://useworkloom.com/dev/errors/): What the API returns when something is wrong.
