Skip to content
Pitch2SaleDevelopers
Open the app
Early access: these pages are being written and reviewed. Facts in the header boxes come straight from the product.

Webhooks

A webhook subscription sends a signed POST to your HTTPS endpoint when an event you chose happens in your organization.

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
Terminal window
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.

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.

POST /pitch2sale HTTP/1.1
Content-Type: application/json
User-Agent: CRM-Webhook/1.0
X-CRM-Event: lead.created
X-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.

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.

  • Answer with any 2xx status 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 3xx is 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. timestamp is when this attempt was sent; retries and replays get a new one. To order changes, compare the record’s own updated_at in data.