Webhooks Guide

Webhooks Guide

Webhooks allow you to receive real-time notifications when events occur in Sure Send CRM. Instead of polling the API for changes, webhooks push data to your application as soon as something happens.

Overview

When an event occurs (like a new contact being created or a stage being updated), Sure Send CRM will send an HTTP POST request to the URL you specify, containing details about what happened.

Webhook Security

Security is critical for webhooks. Every webhook delivery includes a cryptographic signature that you must verify to ensure the request is legitimate.

Signature Verification

When you create a webhook, you receive a secret key that is used to sign all webhook deliveries. The secret key is only shown once during webhook creation - store it securely.

Every webhook delivery includes an X-Webhook-Signature header containing an HMAC-SHA256 signature of the request body. You must verify this signature to ensure the webhook is from Sure Send CRM and hasn't been tampered with.

Verification Examples

Ruby:

require 'openssl'
require 'rack/utils'

def verify_webhook(request, secret_key)
  received_signature = request.headers['X-Webhook-Signature']
  body = request.body.read

  computed_signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, body)

  # Use constant-time comparison to prevent timing attacks
  Rack::Utils.secure_compare(computed_signature, received_signature)
end

Node.js:

const crypto = require('crypto');

function verifyWebhook(req, secretKey) {
  const receivedSignature = req.headers['x-webhook-signature'];
  const body = JSON.stringify(req.body);

  const computedSignature = crypto
    .createHmac('sha256', secretKey)
    .update(body)
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(computedSignature)
  );
}

Python:

import hmac
import hashlib

def verify_webhook(request, secret_key):
    received_signature = request.headers.get('X-Webhook-Signature')
    body = request.body

    computed_signature = hmac.new(
        secret_key.encode('utf-8'),
        body,
        hashlib.sha256
    ).hexdigest()

    # Use compare_digest to prevent timing attacks
    return hmac.compare_digest(computed_signature, received_signature)

PHP:

function verifyWebhook($request, $secretKey) {
    $receivedSignature = $request->header('X-Webhook-Signature');
    $body = $request->getContent();

    $computedSignature = hash_hmac('sha256', $body, $secretKey);

    // Use hash_equals to prevent timing attacks
    return hash_equals($computedSignature, $receivedSignature);
}

Creating a Webhook

Endpoint: POST /api/partner/webhooks

curl -X POST https://api.suresend.ai/api/partner/webhooks \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Contact Sync Webhook",
    "url": "https://your-app.com/webhooks/crm-events",
    "events": ["peopleCreated", "peopleUpdated", "peopleStageUpdated"]
  }'

Response:

{
  "id": "webhook-uuid",
  "name": "Contact Sync Webhook",
  "url": "https://your-app.com/webhooks/crm-events",
  "status": "active",
  "events": ["peopleCreated", "peopleUpdated", "peopleStageUpdated"],
  "secretKey": "your-secret-key-store-this-securely",
  "systemIdentifier": "your-system",
  "retryCount": 0,
  "createdAt": "2025-10-16T12:00:00Z",
  "updatedAt": "2025-10-16T12:00:00Z"
}

⚠️ Important: The secretKey is only returned during webhook creation. Store it securely - you cannot retrieve it later.

Requirements

  • Maximum 2 webhooks per event per system identifier
  • URL must use HTTPS
  • URL must return 200-299 status within 5 seconds

Available Events

Get the complete list of available events:

curl https://api.suresend.ai/api/partner/webhooks/events \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Common Events

People (Contacts):

  • peopleCreated - New contact created
  • peopleUpdated - Contact details updated
  • peopleDeleted - Contact deleted
  • peopleTagsAdded - Tags added to contact
  • peopleTagsRemoved - Tags removed from contact
  • peopleStageUpdated - Contact pipeline stage changed
  • peopleAssigned - Contact assigned to user/team
  • peopleUnassigned - Contact unassigned

Companies:

  • companiesCreated - New company created
  • companiesUpdated - Company details updated
  • companiesDeleted - Company deleted

Communication:

  • emailsCreated - Email sent or received
  • callsCreated - Call logged
  • textMessagesCreated - SMS sent or received
  • notesCreated - Note added

Tasks & Activities:

  • tasksCreated - Task created
  • tasksCompleted - Task marked complete
  • appointmentsCreated - Meeting/appointment scheduled

Webhook Payload Format

All webhooks are delivered with this standard format:

{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleCreated",
  "resourceIds": ["123e4567-e89b-12d3-a456-426614174000"],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "firstName": "John",
    "lastName": "Doe",
    "email": "[email protected]",
    "stage": "Lead",
    "createdAt": "2025-10-16T12:00:00Z"
  }
}

Fields:

  • eventId - Unique identifier for this event (use for idempotency)
  • eventCreated - When the event occurred
  • event - Event type (matches your subscribed events)
  • resourceIds - Array of affected resource IDs
  • uri - Direct API link to fetch the full resource
  • data - Event-specific payload data

Delivery Headers

POST /webhooks/crm-events HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-Webhook-Signature: abc123...
User-Agent: SureSendCRM-Webhook/1.0

Handling Webhooks

Basic Webhook Handler

const express = require('express');
const crypto = require('crypto');

const app = express();

// Important: Use raw body for signature verification
app.use(express.raw({ type: 'application/json' }));

app.post('/webhooks/crm-events', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const secretKey = process.env.WEBHOOK_SECRET_KEY;

  // 1. Verify signature
  const computedSig = crypto
    .createHmac('sha256', secretKey)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computedSig))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // 2. Parse payload
  const payload = JSON.parse(req.body.toString());

  // 3. Check for duplicate (idempotency)
  if (isDuplicateEvent(payload.eventId)) {
    return res.status(200).json({ message: 'Already processed' });
  }

  // 4. Process the webhook
  switch (payload.event) {
    case 'peopleCreated':
      handlePersonCreated(payload.data);
      break;
    case 'peopleUpdated':
      handlePersonUpdated(payload.data);
      break;
    default:
      console.log('Unhandled event:', payload.event);
  }

  // 5. Respond quickly (< 5 seconds)
  res.status(200).json({ received: true });

  // 6. Do heavy processing async
  processWebhookAsync(payload);
});

app.listen(3000);

Retry Logic

Sure Send CRM automatically retries failed webhook deliveries:

  • Success: HTTP status 200-299 received within 5 seconds
  • Failure: Any other status, timeout, or network error
  • Retry Schedule: Exponential backoff (1min, 5min, 15min, 1hr, 6hr, 24hr)
  • Max Retries: After 10 consecutive failures, webhook is automatically disabled
  • Reset: A successful delivery resets the failure counter

Webhook Status:

  • active - Enabled and receiving deliveries
  • disabled - Manually disabled (no deliveries)
  • failed - Automatically disabled after 10 consecutive failures

Testing Webhooks

Test a Webhook Endpoint

curl -X POST https://api.suresend.ai/api/partner/webhooks/{webhook-id}/test \
  -H "Authorization: Bearer YOUR_API_TOKEN"

This sends a test payload:

{
  "eventId": "test-event-uuid",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "testEvent",
  "resourceIds": ["test-resource-id"],
  "uri": "https://api.suresend.ai/api/test",
  "data": {
    "message": "This is a test webhook from SureSendCRM"
  }
}

Local Testing with ngrok

For local development, use ngrok to expose your local server:

# Start your local server
node server.js

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok URL in your webhook
# https://abc123.ngrok.io/webhooks/crm-events

Managing Webhooks

List All Webhooks

curl https://api.suresend.ai/api/partner/webhooks \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Get Webhook Details

curl https://api.suresend.ai/api/partner/webhooks/{webhook-id} \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Update a Webhook

curl -X PUT https://api.suresend.ai/api/partner/webhooks/{webhook-id} \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "disabled"
  }'

Delete a Webhook

curl -X DELETE https://api.suresend.ai/api/partner/webhooks/{webhook-id} \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Best Practices

Security

  1. Always verify signatures - Never process webhooks without verification
  2. Use constant-time comparison - Prevents timing attacks
  3. Store secret keys securely - Use environment variables or secret management
  4. Use HTTPS only - Never expose webhook endpoints over HTTP
  5. Validate payload structure - Check data types and required fields

Reliability

  1. Respond quickly - Return 200 within 5 seconds, process async
  2. Implement idempotency - Use eventId to detect duplicates
  3. Handle retries gracefully - Don't fail on duplicate events
  4. Log webhook deliveries - Keep audit trail for debugging
  5. Monitor webhook health - Track failure rates and alert on issues

Performance

  1. Queue heavy processing - Don't block webhook response
  2. Batch database operations - Reduce transaction overhead
  3. Rate limit your endpoint - Protect from abuse
  4. Scale horizontally - Load balance webhook handlers

Error Handling

app.post('/webhooks/crm-events', async (req, res) => {
  try {
    // Verify signature
    if (!verifySignature(req)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Parse payload
    const payload = JSON.parse(req.body.toString());

    // Check idempotency
    if (await isDuplicate(payload.eventId)) {
      return res.status(200).json({ message: 'Already processed' });
    }

    // Validate payload
    if (!isValidPayload(payload)) {
      console.error('Invalid payload:', payload);
      return res.status(400).json({ error: 'Invalid payload' });
    }

    // Respond immediately
    res.status(200).json({ received: true });

    // Process async
    await processWebhookAsync(payload);

  } catch (error) {
    console.error('Webhook error:', error);
    // Still return 200 if we've already responded
    if (!res.headersSent) {
      res.status(500).json({ error: 'Internal error' });
    }
  }
});

Troubleshooting

Webhook Not Receiving Events

  • ✅ Check webhook status is active
  • ✅ Verify events are subscribed in webhook configuration
  • ✅ Ensure URL is accessible from internet (not localhost)
  • ✅ Check firewall rules allow incoming connections
  • ✅ Verify SSL certificate is valid

Signature Verification Failing

  • ✅ Use the correct secret key (from webhook creation)
  • ✅ Verify raw request body (don't parse before verifying)
  • ✅ Use constant-time comparison functions
  • ✅ Check header name is exactly X-Webhook-Signature

Webhook Disabled After Failures

  • ✅ Check your endpoint responds within 5 seconds
  • ✅ Return 200-299 status code on success
  • ✅ Fix any errors causing 500 responses
  • ✅ Re-enable webhook after fixing issues

Testing Issues

  • ✅ Use ngrok for local testing
  • ✅ Check webhook test endpoint (/webhooks/{id}/test)
  • ✅ Verify secret key is stored correctly
  • ✅ Check application logs for errors

Example: Full Webhook Integration

// server.js
const express = require('express');
const crypto = require('crypto');
const { processContact, updateContact } = require('./services/contacts');
const { trackEvent } = require('./services/events');

const app = express();
app.use(express.raw({ type: 'application/json' }));

// Store processed event IDs (use Redis in production)
const processedEvents = new Set();

function verifyWebhook(req, secretKey) {
  const signature = req.headers['x-webhook-signature'];
  const body = req.body;

  const computed = crypto
    .createHmac('sha256', secretKey)
    .update(body)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computed)
  );
}

app.post('/webhooks/crm', async (req, res) => {
  const secretKey = process.env.WEBHOOK_SECRET_KEY;

  // Verify signature
  if (!verifyWebhook(req, secretKey)) {
    console.error('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Parse payload
  const payload = JSON.parse(req.body.toString());

  // Check idempotency
  if (processedEvents.has(payload.eventId)) {
    console.log('Duplicate event:', payload.eventId);
    return res.status(200).json({ message: 'Already processed' });
  }

  // Mark as processed
  processedEvents.add(payload.eventId);

  // Respond quickly
  res.status(200).json({ received: true });

  // Process async
  try {
    switch (payload.event) {
      case 'peopleCreated':
        await processContact(payload.data);
        await trackEvent('contact_created', payload.data);
        break;

      case 'peopleUpdated':
        await updateContact(payload.data);
        await trackEvent('contact_updated', payload.data);
        break;

      case 'peopleStageUpdated':
        await handleStageChange(payload.data);
        await trackEvent('stage_changed', payload.data);
        break;

      default:
        console.log('Unhandled event:', payload.event);
    }
  } catch (error) {
    console.error('Error processing webhook:', error);
    // Don't throw - we already responded with 200
  }
});

app.listen(process.env.PORT || 3000, () => {
  console.log('Webhook server running');
});

Next Steps

Need Help?