Webhooks
Webhooks let you receive HTTP POST notifications when events occur in your Heffl workspace. Instead of polling the API, your server gets notified in real time when leads are created, deals change stage, invoices are paid, and more.
Setup
- Go to Settings > Developer in your Heffl workspace
- Click Add Webhook Endpoint
- Enter your endpoint URL (must be HTTPS in production)
- Select the events you want to subscribe to
- Save — you’ll receive a signing secret starting with
whsec_
Store your webhook signing secret securely. You’ll need it to verify webhook signatures.
Every webhook delivery sends a JSON POST request with this structure:
{
"event": "lead.created",
"timestamp": "2025-01-15T10:30:00.000Z",
"payload": {
"id": "ld_abc123",
"name": "Jane Smith",
"email": "[email protected]",
"mobile": "+1234567890",
"title": "Product Demo Request",
"value": 5000,
"source": "Website",
"stage": "New",
"stageId": 1,
"archived": false,
"createdAt": "2025-01-15T10:30:00.000Z",
"customFields": {},
"ownerId": 42,
"ownerName": "John Doe"
}
}
Every webhook request includes these headers:
| Header | Description |
|---|
Content-Type | application/json |
User-Agent | Heffl-Webhooks/1.0 |
webhook-id | Unique message ID (e.g., msg_2KWPBgLlAfxdpx2AI54pPJ85f4W) |
webhook-timestamp | Unix timestamp in seconds |
webhook-signature | HMAC-SHA256 signature (see below) |
Verifying signatures
Every webhook is signed using HMAC-SHA256 following the Standard Webhooks specification. Always verify signatures to ensure the request came from Heffl.
How it works
- The signed content is:
{webhook-id}.{webhook-timestamp}.{request-body}
- The signature is computed using HMAC-SHA256 with your signing secret
- The signature header format is:
v1,{base64-encoded-signature}
Node.js verification example
const crypto = require('crypto');
function verifyWebhook(req, signingSecret) {
const msgId = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
const signature = req.headers['webhook-signature'];
const body = JSON.stringify(req.body);
// Check timestamp tolerance (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
// Compute expected signature
const secret = Buffer.from(signingSecret.replace('whsec_', ''), 'base64');
const signedContent = `${msgId}.${timestamp}.${body}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('base64');
// Compare signatures
const receivedSigs = signature.split(' ');
for (const sig of receivedSigs) {
const [version, hash] = sig.split(',');
if (version !== 'v1') continue;
const expected = Buffer.from(expectedSig);
const received = Buffer.from(hash);
if (expected.length === received.length &&
crypto.timingSafeEqual(expected, received)) {
return true; // Valid
}
}
throw new Error('Invalid signature');
}
Python verification example
import hmac
import hashlib
import base64
import time
import json
def verify_webhook(headers, body, signing_secret):
msg_id = headers['webhook-id']
timestamp = headers['webhook-timestamp']
signature = headers['webhook-signature']
# Check timestamp tolerance (5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# Compute expected signature
secret = base64.b64decode(signing_secret.replace('whsec_', ''))
signed_content = f"{msg_id}.{timestamp}.{body}".encode()
expected_sig = base64.b64encode(
hmac.new(secret, signed_content, hashlib.sha256).digest()
).decode()
# Compare signatures
for sig in signature.split(' '):
version, hash_value = sig.split(',', 1)
if version != 'v1':
continue
if hmac.compare_digest(expected_sig, hash_value):
return True
raise ValueError('Invalid signature')
Retry policy
If your endpoint fails to respond with a 2xx status within 20 seconds, Heffl retries delivery with exponential backoff:
| Attempt | Delay |
|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 14 hours |
| 9 | 20 hours |
| 10 | 24 hours |
After 10 failed attempts, the delivery is marked as permanently failed.
Retry behavior by status code
| Status | Behavior |
|---|
2xx | Success — no retry |
410 Gone | Endpoint disabled — no further deliveries |
429, 502, 504 | Retried |
Other 4xx | Not retried (likely permanent client error) |
5xx | Retried |
Best practices
- Respond quickly. Return a
200 status immediately and process the event asynchronously. The delivery timeout is 20 seconds.
- Use idempotency. The
webhook-id header is unique per delivery. Store it to detect and skip duplicate deliveries.
- Verify signatures. Always validate the
webhook-signature header to ensure the request is authentic.
- Check timestamps. Reject requests with timestamps older than 5 minutes to prevent replay attacks.
- Use HTTPS. Always use HTTPS endpoints in production. HTTP endpoints will generate warnings.
- Handle gracefully. If you receive an event type you don’t recognize, return
200 and ignore it — new events may be added.
Secret rotation
Heffl supports rotating webhook secrets without downtime. During rotation, signatures are generated with both the old and new secrets (space-separated in the webhook-signature header). Your verification code should check if any of the signatures is valid.