Skip to main content

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

  1. Go to Settings > Developer in your Heffl workspace
  2. Click Add Webhook Endpoint
  3. Enter your endpoint URL (must be HTTPS in production)
  4. Select the events you want to subscribe to
  5. Save — you’ll receive a signing secret starting with whsec_
Store your webhook signing secret securely. You’ll need it to verify webhook signatures.

Payload format

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"
  }
}

Request headers

Every webhook request includes these headers:
HeaderDescription
Content-Typeapplication/json
User-AgentHeffl-Webhooks/1.0
webhook-idUnique message ID (e.g., msg_2KWPBgLlAfxdpx2AI54pPJ85f4W)
webhook-timestampUnix timestamp in seconds
webhook-signatureHMAC-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

  1. The signed content is: {webhook-id}.{webhook-timestamp}.{request-body}
  2. The signature is computed using HMAC-SHA256 with your signing secret
  3. 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:
AttemptDelay
1Immediate
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
814 hours
920 hours
1024 hours
After 10 failed attempts, the delivery is marked as permanently failed.

Retry behavior by status code

StatusBehavior
2xxSuccess — no retry
410 GoneEndpoint disabled — no further deliveries
429, 502, 504Retried
Other 4xxNot retried (likely permanent client error)
5xxRetried

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.