Webhooks
Webhooks let you receive real-time event notifications from CRM Solid in your own systems — no polling required. When something happens (a new contact, an inbound message, a completed sequence), the server immediately POSTs a signed JSON envelope to every matching HTTPS endpoint you have registered.
Every delivery carries an X-Webhook-Signature header computed as HMAC-SHA256(secret, raw_body) — always verify it before trusting the payload. Endpoints are managed in Settings → Developers; you may register up to 20 endpoints per account.
Registering an endpoint
Section titled “Registering an endpoint”-
Open Settings → Developers in the panel.
-
Register a new webhook endpoint by providing an HTTPS URL where deliveries should be POSTed. Webhooks are only delivered to HTTPS endpoints.
-
Save the endpoint and copy its signing secret. This secret is used to compute and verify the
X-Webhook-Signatureof every delivery — store it securely. -
(Optional) Click Send test event to receive a synthetic
webhook.testdelivery and confirm your receiver works end to end.
Headers on every delivery
Section titled “Headers on every delivery”Every webhook POST includes the following headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256(secret, raw_body) |
X-Webhook-Event | e.g. contact.created |
X-Webhook-Event-Id | UUID of this event (idempotency key) |
Content-Type | application/json; charset=utf-8 |
Event types
Section titled “Event types”| Event | When it fires |
|---|---|
contact.created | Fires when a new CRM contact is created (any source — manual, import, scrape, inbound DM). |
message.sent | Fires when an outbound message (Telegram or X DM) is successfully delivered. |
message.received | Fires when an incoming Telegram or X DM is recorded against a contact. |
sequence.completed | Fires when a campaign sequence finishes processing all of its targets. |
subscription.changed | Fires when the user’s plan changes (upgrade, downgrade, cancel, renew). |
webhook.test | Synthetic event sent when you click “Send test event” — safe to ignore in production flows. |
Envelope shape
Section titled “Envelope shape”Every delivery is a JSON object with four top-level keys. The data field carries the event-specific payload.
| Field | Description |
|---|---|
id | Unique delivery ID (same across retries) |
type | Event type string, matches X-Webhook-Event |
createdAt | ISO-8601 UTC timestamp of the event |
data | Event-specific payload object |
A contact.created delivery looks like this:
{ "id": "evt_01HXYZ1234ABCDEF", "type": "contact.created", "createdAt": "2026-05-07T14:23:01.000Z", "data": { "contact": { "id": 8821, "name": "Anya Ivanova", "username": "anya_ivanova", "platform": "telegram", "createdAt": "2026-05-07T14:23:00.817Z" } }}Example payloads by event type
Section titled “Example payloads by event type”{ "id": "evt_01HXYZ2345BCDEFG", "type": "message.sent", "createdAt": "2026-05-07T14:24:10.000Z", "data": { "message": { "id": 41190, "contactId": 8821, "text": "Hello Anya, thanks for joining!", "platform": "telegram", "sentAt": "2026-05-07T14:24:09.442Z" } }}{ "id": "evt_01HXYZ3456CDEFGH", "type": "message.received", "createdAt": "2026-05-07T14:25:33.000Z", "data": { "message": { "id": 41200, "contactId": 8821, "text": "Sure, sounds great!", "platform": "telegram", "receivedAt": "2026-05-07T14:25:32.188Z" } }}{ "id": "evt_01HXYZ4567DEFGHI", "type": "sequence.completed", "createdAt": "2026-05-07T16:00:00.000Z", "data": { "sequence": { "id": 212, "name": "Onboarding drip", "targetCount": 340, "successCount": 338, "completedAt": "2026-05-07T15:59:58.000Z" } }}{ "id": "evt_01HXYZ5678EFGHIJ", "type": "subscription.changed", "createdAt": "2026-05-07T18:11:05.000Z", "data": { "subscription": { "previousPlan": "starter", "currentPlan": "growth", "changeType": "upgrade", "effectiveAt": "2026-05-07T18:11:04.000Z" } }}Verifying signatures
Section titled “Verifying signatures”Compute HMAC-SHA256(secret, raw_request_body), hex-lowercase the result, prepend sha256=, and compare to the X-Webhook-Signature header in constant time.
import crypto from 'node:crypto';
// rawBody: Buffer - do NOT parse/re-stringify// secret: your endpoint's signing secret// sigHeader: value of X-Webhook-Signature headerfunction verifyWebhook(rawBody, secret, sigHeader) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex');
// Constant-time comparison to prevent timing attacks const eBuf = Buffer.from(expected); const rBuf = Buffer.from(sigHeader); if (eBuf.length !== rBuf.length) return false; return crypto.timingSafeEqual(eBuf, rBuf);}import hmacimport hashlib
def verify_webhook(raw_body: bytes, secret: str, sig_header: str) -> bool: """ raw_body: the raw request body bytes (do not decode/re-encode) secret: your endpoint's signing secret sig_header: value of X-Webhook-Signature header """ expected = 'sha256=' + hmac.new( secret.encode('utf-8'), raw_body, hashlib.sha256, ).hexdigest()
# hmac.compare_digest runs in constant time return hmac.compare_digest(expected, sig_header)<?phpfunction verifyWebhook( string $rawBody, string $secret, string $sigHeader): bool { // rawBody: file_get_contents('php://input') // sigHeader: $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
// hash_equals runs in constant time return hash_equals($expected, $sigHeader);}package webhook
import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
// VerifyWebhook returns true when the signature is valid.// rawBody must be the exact bytes from the request body.func VerifyWebhook(rawBody []byte, secret, sigHeader string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// hmac.Equal runs in constant time return hmac.Equal([]byte(expected), []byte(sigHeader))}
// Usage: VerifyWebhook(body, os.Getenv("WEBHOOK_SECRET"),// r.Header.Get("X-Webhook-Signature"))Retry policy
Section titled “Retry policy”Deliveries are retried up to 8 attempts total on transient failure (5xx or network error). The backoff schedule is fixed.
The same delivery id and X-Webhook-Event-Id are reused across all attempts, so always de-duplicate on the event ID.
Failure handling
Section titled “Failure handling”How CRM Solid reacts to your receiver’s response status:
| Status | Result |
|---|---|
| 2xx Success | Delivery marked as delivered. No retry. The response body is ignored — only the status code matters. |
| 4xx Client error | Delivery is marked failed immediately. No retry scheduled — a 4xx typically indicates a permanent misconfiguration (wrong URL, bad auth on your receiver, etc.). |
| 5xx / timeout Server / network error | Retried according to the backoff schedule above. After all 8 attempts are exhausted the delivery status becomes “exhausted” and can be manually retried from the Developers panel. |
Failed or exhausted deliveries can be manually retried from the delivery log in Settings → Developers. Disabled endpoints can be re-enabled at any time — deliveries fired while the endpoint was disabled will not be replayed automatically.
Best practices
Section titled “Best practices”- Verify every signature against the raw request body in constant time before trusting a payload.
- Respond fast with a
2xx. Acknowledge the delivery immediately and process the payload asynchronously — slow responses count as timeouts and trigger retries. - De-duplicate on
X-Webhook-Event-Id. Retries reuse the same event ID, so make your handler idempotent. - Use the raw body for signature verification — never the re-serialised JSON.
- Monitor for auto-disable. Keep your receiver healthy to avoid 20 consecutive failures disabling the endpoint.
- Use HTTPS endpoints only. Deliveries are only sent to HTTPS URLs.
Related
Section titled “Related”- Authentication — how to obtain and use API credentials.
- API reference — the REST endpoints behind these events.