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:
{
"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.
- In Pipeline CRM, go to Admin > Automations and create an automation with a trigger and any conditions.
- Add a Call an API action with your URL and an email address for failure notifications.
- Under Request signing, click Enable signing and store the secret, for example as
PIPELINE_. It is shown once and can't be recovered, only rotated.CRM_ WEBHOOK_ SECRET - Click Test, then save the automation.
To subscribe from code, send one model and action per subscription:
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.
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:
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 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
410from 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 and 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 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.
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.
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 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 for free and handle Pipeline CRM webhooks reliably in minutes.