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:
- 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.
- Compute HMAC-SHA256 of those bytes, using your
secret_keystring as the key. Use the string as returned; don't base64-decode it. - Hex-encode the digest and put
sha256=in front of it. - 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)
endNode.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
2xxstatus 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 createdpeopleUpdated- Contact details updatedpeopleDeleted- Contact deletedpeopleTagsAdded- Tags added to contactpeopleTagsRemoved- Tags removed from contactpeopleStageUpdated- Contact pipeline stage changedpeopleAssigned- Contact assigned to user/teampeopleUnassigned- Contact unassigned
Companies:
companiesCreated- New company createdcompaniesUpdated- Company details updatedcompaniesDeleted- Company deletedcompaniesTagsAdded- Tags added to companycompaniesTagsRemoved- Tags removed from companycompaniesStageUpdated- Company pipeline stage changedcompaniesAssigned- Company assigned to user/teamcompaniesUnassigned- Company unassigned
Relationships:
peopleRelationshipsCreated- Contact relationship establishedpeopleRelationshipsUpdated- Relationship details changedpeopleRelationshipsDeleted- Relationship removed
Communication:
emailsCreated- Email sent or receivedemailsUpdated- Email status changedemailsDeleted- Email removedcallsCreated- Call loggedcallsUpdated- Call details updated (phone, note, direction, duration, contact, status, outcome or call time)callsDeleted- Call removedtextMessagesCreated- Text message sent or receivedtextMessagesUpdated- Text message changed (including delivery status updates)textMessagesDeleted- Text message removednotesCreated- Note addednotesUpdated- Note modifiednotesDeleted- Note removed
Tasks & Appointments:
tasksCreated- Task createdtasksUpdated- Task modified, including when it is completedtasksDeleted- Task removedcontractTasksCreated- Tasks created from contract milestone templates (batched)appointmentsCreated- Meeting/appointment scheduledappointmentsUpdated- Appointment modifiedappointmentsDeleted- Appointment cancelled
Contracts:
contractsCreated- New contract createdcontractsUpdated- Contract details/stage changedcontractsDeleted- Contract removedcontractsStageUpdated- Contract stage changedcontractPipelineStatusesCreated- Contract pipeline stage assignedcontractPipelineStatusesUpdated- Contract pipeline stage changedcontractPipelineStatusesDeleted- Contract pipeline stage removed
Loans (Lender Tools):
loansCreated- New loan createdloansUpdated- Loan details updatedloansStageUpdated- Loan stage changedloansFunded- Loan fundedloansArchived- 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 changedloansUnderwritingUpdated- Underwriting status changedloansClearToClose- Loan cleared to closeloansPurchased- Funded loan purchased by a servicerpartnersCreditPullAttributed- Credit pull attributed to a referral partner
Referrals:
referralsCreated- New referral sent from this teamreferralsReceived- Referral received by this teamreferralsAccepted- Referral accepted by receiving teamreferralsDeclined- Referral declined by receiving teamreferralsClosed- Referral closedreferralUpdatesPosted- Progress update posted on referral
Email Marketing:
emailMarketingOpened- Marketing email openedemailMarketingClicked- Link clicked in marketing emailemailMarketingUnsubscribed- Recipient unsubscribed
Stages, Custom Fields & Attachments:
stagesCreated- Stage createdstagesUpdated- Stage modifiedstagesDeleted- Stage removedcustomFieldsCreated- New custom field definedcustomFieldsUpdated- Custom field configuration changedcustomFieldsDeleted- Custom field removedattachmentsCreated- File attached (datanames the record it was attached to)attachmentsUpdated- Attachment modifiedattachmentsDeleted- Attachment removed
Tracking Events & Property Activity:
eventsCreated- Tracking event createdeventsUpdated- Tracking event updatedeventsDeleted- Tracking event deletedpropertyViewed- Lead viewed a property listingpropertySaved- Lead saved/favorited a propertypropertyInquiry- 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.
callsUpdatedonly fires when a field in the call payload changes, so recording, transcript and AI-analysis updates don't send it.emailsUpdatedfires when the email's status changes. - Deleted payloads are minimal.
callsDeleted,textMessagesDeletedandemailsDeletedcarry only{ "person_id": "..." }indata. The deleted record's id is inresourceIds. - Text message bodies. Text webhooks put the full message text in
data.body. The REST API returns the same text asmessage. - 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/callsbut don't sendcalls*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 idempotencyeventCreated- When the event occurred (UTC, ISO 8601 with milliseconds)event- Event type (matches your subscribed events)resourceIds- Array of affected resource IDsuri- Direct API link to fetch the full resourcedata- 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:
resourceIdscontains multiple IDs (all affected resources)datadescribes the action performed rather than individual resource detailsuripoints to the first resource (useresourceIdsfor 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'seventIdX-System- The webhook's system identifier (partner_apiunless 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 returns | What happens |
|---|---|
2xx | Delivery succeeds. The webhook's failure counter resets to 0. |
406 or 410 | Delivery fails and the webhook is set to disabled straight away. No retries. |
Any other 4xx | Delivery 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
failedEach 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 deliveriesdisabled- No deliveries. Set by you, or automatically when your endpoint returns406or410failed- 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
disabledorfailed. Check the webhook'sstatus(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
eventIdand 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 theeventIdfrom 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 returns422. Retry also returns422for 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-eventsManaging 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
- Always verify signatures - Never process webhooks without verification
- Use constant-time comparison - Prevents timing attacks
- Store secret keys securely - Use environment variables or secret management
- Use HTTPS only - Never expose webhook endpoints over HTTP
- Validate payload structure - Check data types and required fields
Reliability
- Respond quickly - Return
2xxwell inside the 10-second timeout and do the processing asynchronously - Implement idempotency - Use
eventIdto detect duplicates - Handle retries gracefully - Don't fail on duplicate events
- Log webhook deliveries - Keep audit trail for debugging
- Monitor webhook health - Track failure rates and alert on issues
Performance
- Queue heavy processing - Don't block webhook response
- Batch database operations - Reduce transaction overhead
- Rate limit your endpoint - Protect from abuse
- 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 issha256=<hex>, so compare it with"sha256=" + hexdigest, or strip the prefix from the header first - ✅ Use constant-time comparison functions
- ✅ Read the
X-Webhook-Signatureheader (orSuresendcrm-Signature, which has the same value)
Webhook Disabled or Failed
- ✅ Check the delivery log for the HTTP status and error on recent failed deliveries
- ✅
disabledwith a406or410in the log means your endpoint asked us to stop. Don't return those codes unless you mean it - ✅
failedmeans repeated5xxresponses or timeouts. Check your endpoint connects within 5 seconds and responds within 10 - ✅ Return a
2xxstatus code on success - ✅ After fixing, set
statusback toactive, 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
- Explore Events API to understand event data
- Learn about People API for contact management
- Check Rate Limiting for API limits
- Review Security Best Practices for API keys
Need Help?
- API Reference: See full webhook endpoints in sidebar
- Support: [email protected]
- Examples: Check our GitHub examples (coming soon)
Updated 6 days ago
