Webhooks
A webhook subscription sends a signed POST to your HTTPS endpoint when an event you chose happens in your organization.
Manage subscriptions
Section titled “Manage subscriptions”All routes live under /api/v1/webhooks and need the settings.webhooks permissions shown. Creating a subscription, listing deliveries and replaying a delivery also need a plan that includes webhooks (otherwise 402).
| Method and path | Does | Permission |
|---|---|---|
GET /api/v1/webhooks |
List subscriptions | settings.webhooks.view_global |
POST /api/v1/webhooks |
Create a subscription | settings.webhooks.create |
GET /api/v1/webhooks/{id} |
Get one subscription | settings.webhooks.view_global |
PUT /api/v1/webhooks/{id} |
Update url, events, secret or is_active |
settings.webhooks.edit |
DELETE /api/v1/webhooks/{id} |
Delete a subscription | settings.webhooks.delete |
POST /api/v1/webhooks/test/{id} |
Send a test delivery now | settings.webhooks.edit |
GET /api/v1/webhooks/{id}/deliveries |
Delivery log (status, page, limit) |
settings.webhooks.view_own |
POST /api/v1/webhooks/deliveries/{deliveryId}/replay |
Send a past delivery again | settings.webhooks.edit |
curl -X POST "https://api.pitch2sale.com/api/v1/webhooks" \ -H "Authorization: Bearer pat_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Data warehouse", "url": "https://hooks.example.com/pitch2sale", "events": ["lead.created", "opportunity.won"], "secret": "<random string, at least 16 characters>" }'name is required by validation but not currently stored. url must use https://, events needs at least one event, and is_active defaults to true.
Events
Section titled “Events”| Area | Events |
|---|---|
| Leads | lead.created, lead.updated, lead.deleted, lead.status_changed |
| Contacts | contact.created, contact.updated, contact.deleted |
| Opportunities | opportunity.created, opportunity.updated, opportunity.won, opportunity.lost |
| Activities and tasks | activity.created, task.created, task.completed, call.completed |
| Email and SMS | email.received, email.sent, sms.received, sms.sent |
| Forms | form.submitted |
| Proposals and contracts | proposal.sent, proposal.accepted, proposal.declined, contract.signed |
| Invoices | invoice.created, invoice.sent, invoice.paid, invoice.overdue |
| Workflows | workflow.triggered |
An unknown event name is rejected with 400. The test endpoint sends the event webhook.test.
The request you receive
Section titled “The request you receive”POST /pitch2sale HTTP/1.1Content-Type: application/jsonUser-Agent: CRM-Webhook/1.0X-CRM-Event: lead.createdX-CRM-Signature: <64 lowercase hex characters>
{"event":"lead.created","org_id":"…","timestamp":"2026-10-07T09:30:00.000Z","data":{ … }}| Field | Meaning |
|---|---|
event |
The event name (also in the X-CRM-Event header). |
org_id |
Your organization’s id. |
timestamp |
ISO 8601 time this attempt was sent; retries and replays get a new one. |
data |
The record or change that triggered the event. |
Verify the signature
Section titled “Verify the signature”X-CRM-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the subscription secret. Compute it over the exact bytes you received — before any JSON parsing — and compare in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto';export function verify(rawBody, header, secret) { const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); const a = Buffer.from(header ?? '', 'utf8'), b = Buffer.from(expected, 'utf8'); return a.length === b.length && timingSafeEqual(a, b);}In Express, read the body with express.raw({ type: 'application/json' }) on the webhook route so rawBody is the original bytes. Reject the request with 401 when verify returns false.
Responses, retries and timeouts
Section titled “Responses, retries and timeouts”- Answer with any
2xxstatus within 15 seconds. Do slow work after you respond. - Any other status, a timeout or a network error counts as a failure. Redirects are not followed: a
3xxis a failure. - A failed delivery is retried with exponential backoff, up to 3 attempts in total (retries after about 10 s and then 20 s).
- Every attempt is recorded in the delivery log; replay a delivery with
POST /api/v1/webhooks/deliveries/{deliveryId}/replay. - Deliveries can arrive more than once and out of order. Make your handler idempotent.
timestampis when this attempt was sent; retries and replays get a new one. To order changes, compare the record’s ownupdated_atindata.