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, the response includes 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. You must verify this signature to ensure the webhook is from Sure Send CRM and hasn't been tampered with.

The header value is sha256= followed by the lowercase hex HMAC-SHA256 digest of the raw request body:

X-Webhook-Signature: sha256=8f2c1b9e0a...4d7a

The same value is also sent in a Suresendcrm-Signature header. Check either one.

To verify a delivery:

  1. Read the raw request body exactly as received. Don't parse and re-serialize the JSON first: the signature covers the exact bytes we sent.
  2. Compute HMAC-SHA256 of those bytes, using your secret_key string as the key. Use the string as returned; don't base64-decode it.
  3. Hex-encode the digest and put sha256= in front of it.
  4. Compare the result with the header using a constant-time comparison.

Verification Examples

Ruby:

require 'openssl'
require 'rack/utils'

def verify_webhook(request, secret_key)
  received_signature = request.headers['X-Webhook-Signature'].to_s
  body = request.raw_post # raw bytes, before any JSON parsing

  computed_signature = "sha256=#{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 (Express):

const crypto = require('crypto');

// Mount with express.raw({ type: 'application/json' }) so req.body is the raw Buffer.
// Re-serializing a parsed body with JSON.stringify will not reproduce the signed bytes.
function verifyWebhook(req, secretKey) {
  const receivedSignature = req.headers['x-webhook-signature'] || '';

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

  const received = Buffer.from(receivedSignature);
  const computed = Buffer.from(computedSignature);

  // timingSafeEqual throws if the lengths differ, so check that first
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

Python:

import hmac
import hashlib

def verify_webhook(request, secret_key):
    received_signature = request.headers.get('X-Webhook-Signature', '')
    body = request.body  # raw bytes, before any JSON parsing

    computed_signature = 'sha256=' + 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(); // raw body, before any JSON parsing

    $computedSignature = 'sha256=' . 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 (201 Created):

{
  "id": "webhook-uuid",
  "name": "Contact Sync Webhook",
  "url": "https://your-app.com/webhooks/crm-events",
  "system_identifier": "partner_api",
  "status": "active",
  "events": ["peopleCreated", "peopleUpdated", "peopleStageUpdated"],
  "retry_count": 0,
  "last_triggered_at": null,
  "created_at": "2025-10-16T12:00:00.000Z",
  "updated_at": "2025-10-16T12:00:00.000Z",
  "secret_key": "your-secret-key-store-this-securely",
  "team_id": "team-uuid",
  "scope": "team",
  "user_id": null
}

Webhook objects use snake_case field names, unlike most other Partner API resources.

⚠️ Important: The secret_key is only returned during webhook creation. Store it securely - you cannot retrieve it later. Other responses include secret_key_set: true instead.

Requirements

  • URL must use HTTPS
  • Up to 50 webhooks per event per system identifier
  • Your endpoint must accept the connection within 5 seconds and respond within 10 seconds. Return a 2xx status to acknowledge the delivery; anything else counts as a failure (see Retries and Failures)

Available Events

Get the complete list of available events:

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

The endpoint returns 81 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
  • companiesTagsAdded - Tags added to company
  • companiesTagsRemoved - Tags removed from company
  • companiesStageUpdated - Company pipeline stage changed
  • companiesAssigned - Company assigned to user/team
  • companiesUnassigned - Company unassigned

Relationships:

  • peopleRelationshipsCreated - Contact relationship established
  • peopleRelationshipsUpdated - Relationship details changed
  • peopleRelationshipsDeleted - Relationship removed

Communication:

  • emailsCreated - Email sent or received
  • emailsUpdated - Email status changed
  • emailsDeleted - Email removed
  • callsCreated - Call logged
  • callsUpdated - Call details updated (phone, note, direction, duration, contact, status, outcome or call time)
  • callsDeleted - Call removed
  • textMessagesCreated - Text message sent or received
  • textMessagesUpdated - Text message changed (including delivery status updates)
  • textMessagesDeleted - Text message removed
  • notesCreated - Note added
  • notesUpdated - Note modified
  • notesDeleted - Note removed

Tasks & Appointments:

  • tasksCreated - Task created
  • tasksUpdated - Task modified, including when it is completed
  • tasksDeleted - Task removed
  • contractTasksCreated - Tasks created from contract milestone templates (batched)
  • appointmentsCreated - Meeting/appointment scheduled
  • appointmentsUpdated - Appointment modified
  • appointmentsDeleted - Appointment cancelled

Contracts:

  • contractsCreated - New contract created
  • contractsUpdated - Contract details/stage changed
  • contractsDeleted - Contract removed
  • contractsStageUpdated - Contract stage changed
  • contractPipelineStatusesCreated - Contract pipeline stage assigned
  • contractPipelineStatusesUpdated - Contract pipeline stage changed
  • contractPipelineStatusesDeleted - Contract pipeline stage removed

Loans (Lender Tools):

  • loansCreated - New loan created
  • loansUpdated - Loan details updated
  • loansStageUpdated - Loan stage changed
  • loansFunded - Loan funded
  • loansArchived - Loan archived (lost, withdrawn, denied, expired)
  • loansDeleted - Loan permanently deleted (created in error)
  • loansLockExpiring - Rate lock expiring (10 and 5 day thresholds)
  • loansAppraisalUpdated - Appraisal status changed
  • loansUnderwritingUpdated - Underwriting status changed
  • loansClearToClose - Loan cleared to close
  • loansPurchased - Funded loan purchased by a servicer
  • partnersCreditPullAttributed - Credit pull attributed to a referral partner

Referrals:

  • referralsCreated - New referral sent from this team
  • referralsReceived - Referral received by this team
  • referralsAccepted - Referral accepted by receiving team
  • referralsDeclined - Referral declined by receiving team
  • referralsClosed - Referral closed
  • referralUpdatesPosted - Progress update posted on referral

Email Marketing:

  • emailMarketingOpened - Marketing email opened
  • emailMarketingClicked - Link clicked in marketing email
  • emailMarketingUnsubscribed - Recipient unsubscribed

Stages, Custom Fields & Attachments:

  • stagesCreated - Stage created
  • stagesUpdated - Stage modified
  • stagesDeleted - Stage removed
  • customFieldsCreated - New custom field defined
  • customFieldsUpdated - Custom field configuration changed
  • customFieldsDeleted - Custom field removed
  • attachmentsCreated - File attached (data names the record it was attached to)
  • attachmentsUpdated - Attachment modified
  • attachmentsDeleted - Attachment removed

Tracking Events & Property Activity:

  • eventsCreated - Tracking event created
  • eventsUpdated - Tracking event updated
  • eventsDeleted - Tracking event deleted
  • propertyViewed - Lead viewed a property listing
  • propertySaved - Lead saved/favorited a property
  • propertyInquiry - Lead submitted a property inquiry

Property activity events fire in addition to eventsCreated for the same underlying event.

There is no separate "task completed" event. Completing a task sends tasksUpdated.

Calls, Texts and Emails

  • Updated and deleted events. Each communication type has created, updated and deleted events. callsUpdated only fires when a field in the call payload changes, so recording, transcript and AI-analysis updates don't send it. emailsUpdated fires when the email's status changes.
  • Deleted payloads are minimal. callsDeleted, textMessagesDeleted and emailsDeleted carry only { "person_id": "..." } in data. The deleted record's id is in resourceIds.
  • Text message bodies. Text webhooks put the full message text in data.body. The REST API returns the same text as message.
  • Text message media. Text webhooks include a single data.mediaUrl, which is the first attachment if there is one. It is often empty. For inbound MMS it can be a Twilio media URL that needs Twilio credentials to download. The REST text message endpoints don't return any media fields.
  • Trav AI calls appear in GET /api/partner/calls but don't send calls* webhooks.

Webhook Payload Format

All webhooks are delivered with this standard format:

{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventCreated": "2025-10-16T12:00:00.000Z",
  "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. It stays the same when a delivery is retried or replayed, so use it for idempotency
  • eventCreated - When the event occurred (UTC, ISO 8601 with milliseconds)
  • 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

Object keys in the body are sent in sorted order. Verify the signature against the raw body; don't rely on key order when you parse it.

Bulk Action Webhooks

When performing bulk actions (like updating source or stage for multiple contacts at once), Sure Send CRM sends a single webhook with all affected resource IDs in the resourceIds array. This is more efficient than sending individual webhooks for each resource.

Key differences from single-resource webhooks:

  • resourceIds contains multiple IDs (all affected resources)
  • data describes the action performed rather than individual resource details
  • uri points to the first resource (use resourceIds for the full list)

Bulk Source Update Example:

{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001",
    "789a0123-e89b-12d3-a456-426614174002"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_source_update",
    "sourceId": "source-uuid",
    "sourceName": "Website Form",
    "updatedCount": 3
  }
}

Bulk Stage Update Example:

{
  "eventId": "660e8400-e29b-41d4-a716-446655440001",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleStageUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_stage_update",
    "stageId": "stage-uuid",
    "stageName": "Qualified Lead",
    "updatedCount": 2
  }
}

Bulk User Assignment Example:

{
  "eventId": "770e8400-e29b-41d4-a716-446655440002",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleAssigned",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001",
    "789a0123-e89b-12d3-a456-426614174002"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_user_assignment",
    "assigneeId": "user-uuid",
    "assigneeName": "John Smith",
    "assignmentType": "user",
    "updatedCount": 3
  }
}

Bulk Pond Assignment Example:

{
  "eventId": "880e8400-e29b-41d4-a716-446655440003",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_pond_assignment",
    "pondId": "pond-uuid",
    "pondName": "New Leads",
    "assignmentType": "pond",
    "updatedCount": 2
  }
}

Bulk Group Assignment Example:

{
  "eventId": "990e8400-e29b-41d4-a716-446655440004",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001",
    "789a0123-e89b-12d3-a456-426614174002"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_group_assignment",
    "groupId": "group-uuid",
    "groupName": "Sales Team",
    "assignmentType": "group",
    "updatedCount": 3
  }
}

Bulk Tags Added Example:

{
  "eventId": "aa0e8400-e29b-41d4-a716-446655440005",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleTagsAdded",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_tags_added",
    "tagNames": ["hot lead", "buyer"],
    "tagCount": 2,
    "updatedCount": 2
  }
}

Bulk Tags Removed Example:

{
  "eventId": "bb0e8400-e29b-41d4-a716-446655440006",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleTagsRemoved",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001",
    "789a0123-e89b-12d3-a456-426614174002"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_tags_removed",
    "tagNames": ["old tag", "archived"],
    "tagCount": 2,
    "updatedCount": 3
  }
}

Bulk Timeframe Update Example:

{
  "eventId": "cc0e8400-e29b-41d4-a716-446655440007",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_timeframe_update",
    "timeframe": "0_3_months",
    "updatedCount": 2
  }
}

Bulk Timeframe Update with Specific Date Example:

{
  "eventId": "dd0e8400-e29b-41d4-a716-446655440008",
  "eventCreated": "2025-10-16T12:00:00Z",
  "event": "peopleUpdated",
  "resourceIds": [
    "123e4567-e89b-12d3-a456-426614174000",
    "456e7890-e89b-12d3-a456-426614174001"
  ],
  "uri": "https://api.suresend.ai/api/partner/people/123e4567-e89b-12d3-a456-426614174000",
  "data": {
    "action": "bulk_timeframe_update",
    "timeframe": "specific_date",
    "timeframeDate": "2026-03-15",
    "updatedCount": 2
  }
}

Handling Bulk Webhooks:

To fetch details for all affected resources, iterate over resourceIds:

async function handleBulkWebhook(payload) {
  if (payload.resourceIds.length > 1) {
    // Bulk action - fetch each resource if needed
    for (const resourceId of payload.resourceIds) {
      const person = await fetchPerson(resourceId);
      await processUpdate(person, payload.data);
    }
  } else {
    // Single resource - data contains full details
    await processUpdate(payload.data);
  }
}

Delivery Headers

POST /webhooks/crm-events HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: SureSendCRM-Webhooks/1.0
X-Webhook-Signature: sha256=8f2c1b9e0a...4d7a
Suresendcrm-Signature: sha256=8f2c1b9e0a...4d7a
X-Event-Id: 550e8400-e29b-41d4-a716-446655440000
X-System: partner_api
  • X-Webhook-Signature / Suresendcrm-Signature - The same signature in two headers (see Signature Verification)
  • X-Event-Id - The payload's eventId
  • X-System - The webhook's system identifier (partner_api unless you set one when creating it)

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 (header value is "sha256=<hex>")
  const computedSig = 'sha256=' + crypto
    .createHmac('sha256', secretKey)
    .update(req.body)
    .digest('hex');

  const received = Buffer.from(signature || '');
  const computed = Buffer.from(computedSig);
  if (received.length !== computed.length || !crypto.timingSafeEqual(received, computed)) {
    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 (well inside the 10-second timeout)
  res.status(200).json({ received: true });

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

app.listen(3000);

Retries and Failures

Deliveries are sent about 3 seconds after the change that triggered them.

How each response is handled

Your endpoint returnsWhat happens
2xxDelivery succeeds. The webhook's failure counter resets to 0.
406 or 410Delivery fails and the webhook is set to disabled straight away. No retries.
Any other 4xxDelivery fails and is not retried. The failure counter doesn't change.
5xx, or any other status (redirects are not followed)Delivery is retried. Each failed attempt adds 1 to the failure counter.
Timeout or connection error (5 s to connect, 10 s to respond)Delivery is retried.

Retry schedule

A delivery gets up to 6 attempts: the first one plus 5 retries. The retries are about 1 second, 16 seconds, 81 seconds, 256 seconds (about 4 minutes) and 625 seconds (about 10 minutes) after each failed attempt. That is roughly 16 minutes from the first attempt to the last. If the last attempt fails, the delivery is marked failed and isn't retried automatically again. You can still retry or replay it yourself (see below).

When a webhook is marked failed

Each webhook keeps a failure counter. A 5xx (or other retryable status) adds 1 for each attempt, and a delivery that uses up all 6 attempts adds 1 more. Any successful delivery resets the counter to 0. When a delivery uses up its attempts and the counter is at 10 or more, the webhook's status changes to failed and it stops receiving deliveries.

In practice that means about two deliveries in a row that keep returning 5xx, or about ten in a row that time out. 4xx responses other than 406 and 410 never count.

Webhook Status:

  • active - Enabled and receiving deliveries
  • disabled - No deliveries. Set by you, or automatically when your endpoint returns 406 or 410
  • failed - No deliveries. Set automatically after repeated failures, as described above

No notification is sent. We don't email you or show an in-app alert when a webhook becomes disabled or failed. Check the webhook's status (GET /api/partner/webhooks/{id}) or the Webhooks page in Sure Send, and alert on it in your own monitoring.

To turn a webhook back on, fix your endpoint and set status back to active with PUT /api/partner/webhooks/{id} (or click Activate on the webhook's page in Sure Send). Re-enabling doesn't reset the failure counter. A successful delivery does, so send a test delivery (POST /api/partner/webhooks/{id}/test) right after re-enabling.

Delivery pacing

Outbound deliveries are paced per webhook and per team. By default that is up to 300 requests a minute per webhook and 3,000 a minute per team. When a burst goes over the pace, deliveries wait in a queue rather than being dropped. A delivery that still can't be sent after an hour of waiting is marked failed.

Delivery Log

Every delivery is recorded with its status (pending, success or failed), the HTTP status your endpoint returned, the start of its response body (the API returns the first 1,000 characters), any error message, and the payload that was sent.

  • In Sure Send: Go to Settings > Integrations & API > Webhooks, open a webhook and select the Deliveries tab. Only team owners and admins can see this page. Failed deliveries have a Retry button.
  • Through the API: List a webhook's deliveries, newest first:
curl "https://api.suresend.ai/api/partner/webhooks/{webhook-id}/deliveries?status=failed&page=1&limit=50" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

status (pending, success or failed), page and limit (default 50, max 100) are all optional. To fetch a single delivery, including its payload:

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

Retention: Delivery records are deleted after 7 days. The delivery lists in the API and in Sure Send only show the last 3 days. A delivery that is 3 to 7 days old can still be fetched by its id.

Retrying and Replaying Deliveries

Retry re-sends a delivery that failed. You can retry from the Webhooks page in Sure Send or through the API:

curl -X POST https://api.suresend.ai/api/partner/webhook_deliveries/{delivery-id}/retry \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Replay re-sends a delivery that has already finished, including one that succeeded. Use it when your endpoint returned 2xx but you lost the event afterwards. Replay is only available through the API:

curl -X POST https://api.suresend.ai/api/partner/webhook_deliveries/{delivery-id}/replay \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Both return 202 Accepted and queue the delivery again with a fresh set of 6 attempts. Retried and replayed deliveries:

  • Keep the same eventId and the same payload, so your idempotency check will spot them as duplicates. If you're replaying an event you lost, make sure that check doesn't throw the replay away (for example, remove the eventId from your store first).
  • Are signed again with the webhook's current secret key, so you verify them exactly like a first delivery.
  • Only work while the webhook is active. Otherwise the request returns 422. Retry also returns 422 for a delivery that didn't fail, and replay for one that hasn't been attempted yet.

A delivery can't be retried or replayed once its record has been deleted after 7 days.

If you created the webhook with your own system_identifier, add ?system_identifier=... to the delivery, retry and replay requests. Without it they look for webhooks under the default partner_api identifier and return 403.

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 queues a test delivery (it shows up in the delivery log like any other) with this 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"

Deleting a webhook also deletes its delivery history. Deliveries still waiting to be sent are dropped.

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 2xx well inside the 10-second timeout and do the processing asynchronously
  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), as the plain string. Don't base64-decode it
  • ✅ Verify the raw request body (don't parse and re-serialize it before verifying)
  • ✅ Include the sha256= prefix: the header is sha256=<hex>, so compare it with "sha256=" + hexdigest, or strip the prefix from the header first
  • ✅ Use constant-time comparison functions
  • ✅ Read the X-Webhook-Signature header (or Suresendcrm-Signature, which has the same value)

Webhook Disabled or Failed

  • ✅ Check the delivery log for the HTTP status and error on recent failed deliveries
  • ✅ disabled with a 406 or 410 in the log means your endpoint asked us to stop. Don't return those codes unless you mean it
  • ✅ failed means repeated 5xx responses or timeouts. Check your endpoint connects within 5 seconds and responds within 10
  • ✅ Return a 2xx status code on success
  • ✅ After fixing, set status back to active, send a test delivery, then retry or replay any deliveries you missed (within the 7-day retention)

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 = Buffer.from(req.headers['x-webhook-signature'] || '');
  const body = req.body; // raw Buffer from express.raw()

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

  return signature.length === computed.length && crypto.timingSafeEqual(signature, 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?


Did this page help you?