# Guide to Pipeline CRM Webhooks: Features and Best Practices

Pipeline CRM webhooks tell your systems when deals, people and companies change. If your sales team works in Pipeline CRM, webhooks are how a stage change, a new contact or a deleted company reaches your billing system, data warehouse or fulfilment process without polling the API.

This guide covers how Pipeline CRM webhooks are set up, how to turn on and verify request signing, the events and payload, the retry and rotation rules, and the practices that keep a Pipeline CRM receiver reliable.

## What are Pipeline CRM webhooks?

In Pipeline CRM, a webhook is an automation with a **Call an API** action. Pipeline describes it as "a `POST` of a JSON payload to a URL you own whenever a record is created, updated or deleted". You can build one in the app, where an automation pairs a trigger and conditions with the action, or subscribe a URL to one model and action through the API. Both deliver the same payload.

Signing is opt-in per endpoint. Once enabled, deliveries follow Standard Webhooks: `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, with a base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}` keyed with the decoded `whsec_` secret. Endpoints without a secret keep receiving unsigned deliveries.

## Pipeline CRM webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Admin > Automations** with a **Call an API** action, or `POST /api/v3/admin/webhooks` |
| Events | `event_model` (`deal`, `person`, `company`) × `event_action` (`create`, `update`, `destroy`) |
| Save check | The app's **Test** request must get a `2xx` before the automation can be saved |
| Signing | Opt-in per endpoint, under **Request signing** |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64>` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | `whsec_...`, shown once. The key is the base64-decoded part after `whsec_` |
| Rotation | The old secret keeps working for 7 days, and both sign. At most two active secrets |
| Deduplication key | `webhook-id`, the same on every attempt |
| Response window | A `2xx` within "a few seconds" |
| Retries | Increasing delays, up to 16 attempts over roughly a day |
| Not retried | `3xx` (redirects aren't followed) and `410 Gone` (switches the endpoint off) |
| Ordering | Not guaranteed |
| Source IPs | Not published |

## Common events

There are nine combinations of model and action:

| `event_model` | `event_action` | Sent when |
| --- | --- | --- |
| `deal` | `create`, `update`, `destroy` | A deal is created, changed or deleted |
| `person` | `create`, `update`, `destroy` | A person is created, changed or deleted |
| `company` | `create`, `update`, `destroy` | A company is created, changed or deleted |

No single field names the event. Combine `event_model` and `event_action`, for example as `deal.update`, and route on that. The `trigger` field is the automation's own human-readable trigger name, such as "Deal stage changed", so don't route on it. Here is Pipeline's example:

```json
{
  "trigger": "Deal stage changed",
  "timestamp": "2026-09-03 14:22:07",
  "event_model": "deal",
  "event_action": "update",
  "event_model_id": 4521,
  "event_data": { "id": 4521, "name": "Acme Corporation - Q4 renewal", "deal_stage_id": 4, "value": 12000.0, "user_id": 13 },
  "event_changes": { "deal_stage_id": [3, 4] },
  "caused_by_user": { "id": 13, "email": "sam.okafor@northwind.example", "first_name": "Sam", "last_name": "Okafor", "time_zone": "America/New_York" }
}

```

`event_data` is the record after the change, in the same shape the API returns, and it can be switched off per endpoint. `event_changes` appears on updates only and maps each changed field to `[old, new]`. The body's `timestamp` has no time zone, so use `webhook-timestamp` when you need an exact send time.

## Setting up Pipeline CRM webhooks

Start your receiver first. The app won't save an automation until its **Test** request reaches your URL and gets a `2xx`, and a redirect doesn't count.

1. In Pipeline CRM, go to **Admin > Automations** and create an automation with a trigger and any conditions.
2. Add a **Call an API** action with your URL and an email address for failure notifications.
3. Under **Request signing**, click **Enable signing** and store the secret, for example as `PIPELINE_CRM_WEBHOOK_SECRET`. It is shown once and can't be recovered, only rotated.
4. Click **Test**, then save the automation.

To subscribe from code, send one model and action per subscription:

```bash
curl -X POST "https://api.pipelinecrm.com/api/v3/admin/webhooks" \
  -H "Authorization: Bearer $PIPELINE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook":{"event_model":"deal","event_action":"update","url":"https://example.com/webhooks/pipeline-crm","failure_email":"ops@example.com"}}'

```

Subscriptions created through the API show up under **Admin > Automations** as **Call an API** actions. Request signing is only set in the app, so open each one there and enable signing.

If you receive Pipeline CRM webhooks through Hookdeck, create a Pipeline CRM source, enable signing on the **Call an API** action, and paste the whole `whsec_` secret into the source. Without signing, deliveries are unsigned and fail verification. A Hookdeck source URL answers requests with `200`, which satisfies the **Test** button.

## Securing Pipeline CRM webhooks

Build `{webhook-id}.{webhook-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of the secret after `whsec_`, base64-encode it, and compare it in constant time with every space-separated `v1,` entry in `webhook-signature`. Accept the request if any entry matches, or rotations will break.

You can verify with the `standardwebhooks` library, but it always rejects a `webhook-timestamp` more than five minutes old. Pipeline puts a fresh timestamp on each of its own attempts, but a retry or replay from Hookdeck resends the original request, so the library rejects late retries and replays of old events. The manual verifier below makes the window optional, like the one in the [Pipeline CRM webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/pipeline-crm-webhooks).

```javascript
const crypto = require('crypto');
const express = require('express');

// Pass a number of seconds to enforce a timestamp window, or null to skip it.
function verifyPipelineCrm(rawBody, headers, secret, tolerance_seconds = null) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signature_header = headers['webhook-signature'];
  if (!secret || !id || !timestamp || !signature_header) return false;

  if (tolerance_seconds !== null) {
    const ts = Number(timestamp);
    if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > tolerance_seconds) return false;
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64'),
    'utf8'
  );

  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/pipeline-crm', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PIPELINE_CRM_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyPipelineCrm(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const event_type = `${event.event_model}.${event.event_action}`;
  // Deduplicate on the webhook-id header, route on event_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import time

def verify_pipeline_crm(raw_body: bytes, headers, secret: str, tolerance_seconds=None) -> bool:
    webhook_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (secret and webhook_id and timestamp and signature_header):
        return False
    if tolerance_seconds is not None:
        try:
            if abs(time.time() - int(timestamp)) > tolerance_seconds:
                return False
        except ValueError:
            return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(
        key, f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body, hashlib.sha256
    ).digest()
    expected = base64.b64encode(digest)
    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

```

Pipeline calls five minutes "a sensible default" for the timestamp window and doesn't enforce one itself. If you add a window, make it long enough to cover your Hookdeck retry schedule, and rely on `webhook-id` to catch duplicates.

> **Make Pipeline CRM webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Pipeline CRM's Standard Webhooks signature, stores every deal, person and company event, and delivers it to your services with retries and replay you control.

## Pipeline CRM webhook limitations and pain points

### Unsigned until you enable signing

**The Problem:** Deliveries arrive with no `webhook-*` headers, so there is nothing to verify.

**Why It Happens:** Signing is opt-in for each **Call an API** action. Subscriptions created through the API are unsigned until someone enables signing in the app, and deliveries sent before signing was enabled stay unsigned.

**Workarounds:**

* Enable signing on every **Call an API** action before you rely on it.
* Reject requests without signature headers once signing is on.

**How Hookdeck Can Help:** Hookdeck's Pipeline CRM source type expects the Standard Webhooks headers. Unsigned deliveries are marked as failed verification and are not delivered to your destinations, which makes a missing secret obvious.

### Keying with the printable secret

**The Problem:** Every signature fails, consistently.

**Why It Happens:** The HMAC key is the base64-decoded part of the secret after `whsec_`, not the `whsec_...` string. Verifying a re-serialised body instead of the raw bytes fails the same way. Pipeline calls these "the two mistakes almost everyone makes".

**Workarounds:**

* Strip `whsec_` and base64-decode the rest before using it as the key.
* Verify against the raw request bytes.

**How Hookdeck Can Help:** Hookdeck's Standard Webhooks verification decodes the secret for you, so you paste it exactly as Pipeline shows it.

### Redirects and `410` end delivery

**The Problem:** Deliveries stop, or never start.

**Why It Happens:** Pipeline doesn't follow redirects, and doesn't retry a `3xx`. A URL that redirects from `http` to `https`, adds `www` or a trailing slash never receives events. A `410 Gone` response switches the endpoint off immediately, and an endpoint that keeps failing after its retries may also be switched off.

**Workarounds:**

* Register the final URL, exactly as your server serves it.
* Never return `410` from a webhook route unless you want the subscription removed.
* Watch the failure email address on each endpoint.

**How Hookdeck Can Help:** A Hookdeck source URL answers directly with `200`, so Pipeline always sees a success. Hookdeck then handles delivery to your service, with [automatic retries](/docs/retries) and [issues](/docs/issues) when something keeps failing.

### Out-of-order deliveries

**The Problem:** An older update overwrites a newer one.

**Why It Happens:** Pipeline says deliveries can arrive out of order, and retries spread over about a day make that more likely.

**Workarounds:**

* Treat each delivery as a notification and read the record back from the API when order matters, as Pipeline recommends.
* Compare record timestamps before overwriting stored data.

**How Hookdeck Can Help:** Hookdeck can [limit the delivery rate](/docs/destinations#set-a-max-delivery-rate) to your service, and keeps every event so you can replay a sequence after fixing a problem.

## Best practices

### Verify against the raw body

Use `express.raw()` in Express, `await request.text()` in a Next.js route handler, `await request.body()` in FastAPI, `request.raw_post` in Rails and `request.get_data()` in Flask, and parse only after the check passes.

### Deduplicate on `webhook-id`

Every attempt of one delivery carries the same `webhook-id`. Store the IDs you've processed and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Rotate with both secrets

Click **Rotate secret**, add the new secret alongside the old one, deploy, confirm deliveries verify, then revoke the old secret or let its 7 days run out. Accepting any matching `v1` entry keeps deliveries verifying throughout.

### Use `event_changes` for updates

On `update` events, `event_changes` tells you exactly what moved, such as `deal_stage_id` from `3` to `4`. Act on that rather than diffing `event_data` yourself.

### Acknowledge fast and process asynchronously

Pipeline expects a `2xx` within a few seconds. Verify, store the event, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Pipeline CRM sends a JSON `POST` for each deal, person and company change, and signs it with Standard Webhooks once you enable signing on the **Call an API** action. Key the HMAC with the decoded secret, accept any matching `v1` entry, keep the timestamp window optional so retries and replays still verify, deduplicate on `webhook-id`, and register a URL that never redirects.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Pipeline CRM signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Pipeline CRM webhooks reliably in minutes.