Webhook API Contract
Overview
Contacts4Us sends outbound webhooks to notify external systems when contact-related events occur. This document defines the API contract that webhook consumers must implement to receive these notifications.
Webhook Configuration
Multiple Endpoints
Users and organizations can configure multiple webhook URLs. Each endpoint:
- Has its own name for identification in the UI
- Has its own HMAC secret for signature verification
- Can be enabled or disabled independently
- Tracks delivery status separately
URL Requirements
- Must use HTTPS (HTTP is rejected)
- Must be reachable from Contacts4Us servers
- Should respond within 30 seconds
Event Types
| Event | Description | When Fired |
|-------|-------------|------------|
| contact.created | A new contact was added | After card scan confirmation or manual entry |
| contact.updated | An existing contact was modified | After contact edit is saved |
| contact.deleted | A contact was removed | After contact deletion |
| contact.batch_created | Multiple contacts were added at once | After batch scan or import |
| profile.updated | A profile owner updated their info | When owner edits claimed profile (sent to all who have this profile) |
Payload Structure
Envelope
All webhook payloads share this top-level structure:
| Field | Type | Description |
|-------|------|-------------|
| event_type | string | One of the event types listed above |
| event_id | string (UUID) | Unique identifier for this event (for idempotency) |
| timestamp | string (ISO 8601) | When the event occurred, in UTC |
| api_version | string | API version (currently 2026-03-01) |
| source | string | Always contacts4us |
| account | object | Information about the account that owns this data |
| data | object | Event-specific payload |
Account Object
| Field | Type | Description |
|-------|------|-------------|
| user_id | string (UUID) | The user who owns these contacts |
| user_email | string | User's email address |
| organization_id | string (UUID) or null | Organization ID if this is an org contact |
| organization_name | string or null | Organization name if applicable |
Contact Object
Used within the data field for contact events:
| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| id | string (UUID) | no | Contacts4Us internal ID |
| first_name | string | yes | |
| last_name | string | yes | |
| email | string | yes | |
| phones | array of objects | no | All phone numbers (see Phone Object below) |
| title | string | yes | Job title |
| company_name | string | yes | |
| website | string | yes | |
| address | object | yes | Structured address (see below) |
| field | string | yes | Professional field category |
| notes | string | yes | User-added notes |
| tags | array of strings | no | Tag names applied to this contact |
| created_at | string (ISO 8601) | no | When contact was first added |
| updated_at | string (ISO 8601) | no | When contact was last modified |
| scanned_at | string (ISO 8601) | yes | When the business card was scanned (null if manually entered) |
Phone Object
| Field | Type | Description |
|-------|------|-------------|
| number | string | The phone number |
| type | string | One of: cell, work, home, fax, other |
| is_primary | boolean | Whether this is the primary phone |
Address Object
| Field | Type | Description |
|-------|------|-------------|
| line_1 | string or null | Street address |
| line_2 | string or null | Apt, suite, etc. |
| city | string or null | |
| state | string or null | State or province |
| postal_code | string or null | ZIP or postal code |
| country | string or null | Country name |
Example Payloads
contact.created
{
"event_type": "contact.created",
"event_id": "evt_01234567-89ab-cdef-0123-456789abcdef",
"timestamp": "2026-03-15T14:30:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": "org_11111111-2222-3333-4444-555555555555",
"organization_name": "Acme Corp"
},
"data": {
"contact": {
"id": "con_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"first_name": "Jane",
"last_name": "Smith",
"phones": [
{ "number": "+1-555-123-4567", "type": "cell", "is_primary": true },
{ "number": "+1-555-987-6543", "type": "work", "is_primary": false }
],
"title": "VP of Engineering",
"company_name": "TechCo Industries",
"website": "https://techco.com",
"address": {
"line_1": "123 Innovation Drive",
"line_2": "Suite 400",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "United States"
},
"field": "Technology",
"notes": null,
"tags": ["prospect", "conference-lead"],
"created_at": "2026-03-15T14:30:00Z",
"updated_at": "2026-03-15T14:30:00Z",
"scanned_at": "2026-03-15T14:29:45Z"
}
}
}
contact.updated
{
"event_type": "contact.updated",
"event_id": "evt_99999999-8888-7777-6666-555555555555",
"timestamp": "2026-03-16T09:15:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": null,
"organization_name": null
},
"data": {
"contact": {
"id": "con_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"first_name": "Jane",
"last_name": "Smith",
"phones": [
{ "number": "+1-555-123-4567", "type": "cell", "is_primary": true }
],
"title": "CTO",
"company_name": "TechCo Industries",
"website": "https://techco.com",
"address": {
"line_1": "123 Innovation Drive",
"line_2": "Suite 400",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "United States"
},
"field": "Technology",
"notes": "Promoted in Mar 2026",
"tags": ["prospect", "conference-lead", "decision-maker"],
"created_at": "2026-03-15T14:30:00Z",
"updated_at": "2026-03-16T09:15:00Z",
"scanned_at": "2026-03-15T14:29:45Z"
},
"changes": {
"title": {
"old": "VP of Engineering",
"new": "CTO"
},
"notes": {
"old": null,
"new": "Promoted in Mar 2026"
},
"tags": {
"added": ["decision-maker"],
"removed": []
}
}
}
}
contact.deleted
{
"event_type": "contact.deleted",
"event_id": "evt_delete12-3456-7890-abcd-ef1234567890",
"timestamp": "2026-03-17T16:00:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": null,
"organization_name": null
},
"data": {
"contact_id": "con_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"deleted_at": "2026-03-17T16:00:00Z"
}
}
contact.batch_created
{
"event_type": "contact.batch_created",
"event_id": "evt_batch123-4567-8901-2345-678901234567",
"timestamp": "2026-03-15T15:00:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": "org_11111111-2222-3333-4444-555555555555",
"organization_name": "Acme Corp"
},
"data": {
"contacts": [
{
"id": "con_11111111-1111-1111-1111-111111111111",
"first_name": "John",
"last_name": "Doe",
"phones": [],
"title": "Sales Manager",
"company_name": "Example Inc",
"website": null,
"address": null,
"field": "Retail",
"notes": null,
"tags": [],
"created_at": "2026-03-15T15:00:00Z",
"updated_at": "2026-03-15T15:00:00Z",
"scanned_at": "2026-03-15T14:58:00Z"
},
{
"id": "con_22222222-2222-2222-2222-222222222222",
"first_name": "Sarah",
"last_name": "Johnson",
"phones": [
{ "number": "+1-555-987-6543", "type": "work", "is_primary": true }
],
"title": "Director",
"company_name": "Company LLC",
"website": "https://company.com",
"address": null,
"field": "Consulting",
"notes": null,
"tags": [],
"created_at": "2026-03-15T15:00:00Z",
"updated_at": "2026-03-15T15:00:00Z",
"scanned_at": "2026-03-15T14:59:00Z"
}
],
"batch_size": 2
}
}
profile.updated
Sent when a profile owner updates their own information. This goes to all users who have this profile as a contact (if they have sync_updates enabled).
{
"event_type": "profile.updated",
"event_id": "evt_profile1-2345-6789-0abc-def012345678",
"timestamp": "2026-03-20T10:00:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": null,
"organization_name": null
},
"data": {
"contact": {
"id": "con_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"first_name": "Jane",
"last_name": "Smith",
"phones": [
{ "number": "+1-555-999-8888", "type": "cell", "is_primary": true }
],
"title": "CTO",
"company_name": "TechCo Industries",
"website": "https://techco.com",
"address": {
"line_1": "456 New Office Blvd",
"line_2": null,
"city": "Austin",
"state": "TX",
"postal_code": "78701",
"country": "United States"
},
"field": "Technology",
"notes": "Promoted in Mar 2026",
"tags": ["prospect", "conference-lead", "decision-maker"],
"created_at": "2026-03-15T14:30:00Z",
"updated_at": "2026-03-20T10:00:00Z",
"scanned_at": "2026-03-15T14:29:45Z"
},
"changes": {
"phones": {
"old": [
{ "number": "+1-555-123-4567", "type": "cell", "is_primary": true }
],
"new": [
{ "number": "+1-555-999-8888", "type": "cell", "is_primary": true }
]
},
"address": {
"old": {
"line_1": "123 Innovation Drive",
"line_2": "Suite 400",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "United States"
},
"new": {
"line_1": "456 New Office Blvd",
"line_2": null,
"city": "Austin",
"state": "TX",
"postal_code": "78701",
"country": "United States"
}
}
},
"updated_by": "profile_owner"
}
}
Authentication
HMAC Signature
All webhook requests include an HMAC-SHA256 signature for verification. Each webhook URL has its own secret, configured when the webhook is created.
Signature Headers
| Header | Description |
|--------|-------------|
| X-Contacts4us-Signature | HMAC-SHA256 signature of the request body |
| X-Contacts4us-Timestamp | Unix timestamp when signature was generated |
Signature Calculation
The signature is calculated as:
HMAC-SHA256(webhook_secret, timestamp + "." + request_body)
Where:
webhook_secretis the secret configured for this webhook URLtimestampis the Unix timestamp (same asX-Contacts4us-Timestampheader)request_bodyis the raw JSON payload (before any parsing)
Verification Steps
- Extract the timestamp from
X-Contacts4us-Timestampheader - Verify the timestamp is within 5 minutes of current time (prevents replay attacks)
- Concatenate:
timestamp + "." + raw_request_body - Compute HMAC-SHA256 using your webhook secret
- Compare your computed signature with
X-Contacts4us-Signatureheader - Use constant-time comparison to prevent timing attacks
Verification Example (Elixir)
def verify_signature(payload, timestamp, signature, secret) do
# Check timestamp freshness (within 5 minutes)
current_time = System.system_time(:second)
timestamp_int = String.to_integer(timestamp)
if abs(current_time - timestamp_int) > 300 do
{:error, :timestamp_expired}
else
# Compute expected signature
signed_payload = timestamp <> "." <> payload
expected = :crypto.mac(:hmac, :sha256, secret, signed_payload)
|> Base.encode16(case: :lower)
# Constant-time comparison
if Plug.Crypto.secure_compare(expected, signature) do
:ok
else
{:error, :invalid_signature}
end
end
end
Verification Example (Node.js)
const crypto = require('crypto');
function verifySignature(payload, timestamp, signature, secret) {
// Check timestamp freshness (within 5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
const timestampInt = parseInt(timestamp, 10);
if (Math.abs(currentTime - timestampInt) > 300) {
return { valid: false, error: 'timestamp_expired' };
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
const valid = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
return { valid };
}
Request Details
HTTP Method
All webhooks are sent as POST requests.
Headers
| Header | Value |
|--------|-------|
| Content-Type | application/json |
| User-Agent | Contacts4us-Webhook/1.0 |
| X-Contacts4us-Signature | HMAC signature |
| X-Contacts4us-Timestamp | Unix timestamp |
| X-Contacts4us-Event | Event type (e.g., contact.created) |
| X-Contacts4us-Delivery-Id | Unique ID for this delivery attempt |
Timeout
Webhook requests timeout after 30 seconds. If your endpoint needs more time to process, return a 2xx response immediately and process asynchronously.
Expected Responses
Success
Return any 2xx status code to indicate successful receipt:
| Status | Meaning |
|--------|---------|
| 200 OK | Success |
| 201 Created | Success (resource created) |
| 202 Accepted | Success (will process asynchronously) |
| 204 No Content | Success (no response body) |
Response body is optional and ignored.
Failure
Any non-2xx response is treated as a failure and triggers retry logic:
| Status | Behavior |
|--------|----------|
| 3xx | Treated as failure (redirects not followed) |
| 4xx | Treated as failure, retried (may indicate temporary auth issue) |
| 5xx | Treated as failure, retried |
| Timeout | Treated as failure, retried |
| Connection error | Treated as failure, retried |
Retry Behavior
Retry Schedule
Failed deliveries are retried with exponential backoff:
| Attempt | Delay After Failure | |---------|---------------------| | 1 | Immediate | | 2 | 30 seconds | | 3 | 5 minutes | | 4 | 30 minutes |
After 4 failed attempts, the delivery is marked as permanently failed.
Retry Tracking
Each webhook URL tracks failures independently. If you have two webhook URLs configured:
- URL A succeeds immediately
- URL B fails and retries
URL A's delivery is complete, while URL B continues retrying.
Manual Retry
Users can manually trigger a retry for failed deliveries from the Contacts4Us UI. This:
- Resets the attempt counter
- Sends the original payload immediately
- Follows the same retry schedule if it fails again
Idempotency
Event IDs
Each event has a unique event_id. Use this to prevent duplicate processing:
- When you receive a webhook, check if you've already processed this
event_id - If yes, return 2xx but skip processing
- If no, process the webhook and store the
event_id
Why Duplicates Occur
Duplicates can occur when:
- Network issues cause us to not receive your 2xx response
- Manual retry is triggered
- System failover during delivery
Always implement idempotency checks.
Testing Webhooks
Test Endpoint
Users can send a test webhook from the Contacts4Us UI. Test webhooks:
- Use event type
test.ping - Contain minimal sample data
- Verify connectivity and signature validation
Test Payload
{
"event_type": "test.ping",
"event_id": "evt_test0000-0000-0000-0000-000000000000",
"timestamp": "2026-03-15T12:00:00Z",
"api_version": "2026-03-01",
"source": "contacts4us",
"account": {
"user_id": "usr_fedcba98-7654-3210-fedc-ba9876543210",
"organization_id": null,
"organization_name": null
},
"data": {
"message": "This is a test webhook. If you received this, your endpoint is configured correctly."
}
}
Local Development
For local testing, use a tunneling service like ngrok to expose your local endpoint:
- Start your local webhook handler
- Run ngrok:
ngrok http 3000 - Configure the ngrok URL as your webhook endpoint in Contacts4Us
- Send a test webhook
Disabling Webhooks
Per-Contact Control
Each contact relationship has a webhook_sync_enabled flag. When disabled:
- Changes to that contact do not trigger webhooks
- Useful for contacts you manage elsewhere
Per-Webhook Control
Each webhook URL can be disabled without deletion:
- Disabled webhooks receive no events
- Re-enabling resumes delivery (does not replay missed events)
Automatic Disabling
A webhook URL is automatically disabled after:
- 10 consecutive failures within 24 hours
- Users are notified via email when this occurs
- Manual re-enabling is required
Rate Limits
Outbound Limits
Contacts4Us limits outbound webhooks to prevent overwhelming your endpoints:
| Limit | Value | |-------|-------| | Per webhook URL | 100 requests/minute | | Per account (all webhooks) | 500 requests/minute |
If limits are exceeded, deliveries are queued and sent when capacity is available.
Your Endpoint
Your endpoint should be able to handle bursts during:
- Batch imports
- Multi-card scans
- Profile propagation updates (one contact owner updating affects many recipients)
Versioning
API Version Header
All payloads include api_version indicating the contract version.
Breaking Changes
We will:
- Announce breaking changes 90 days in advance
- Support old versions for 6 months after new version release
- Allow version selection per webhook URL (future feature)
Non-Breaking Changes
We may add new fields to payloads at any time. Your integration should:
- Ignore unknown fields
- Not fail if new fields are present
Security Recommendations
- Always verify signatures - Never process webhooks without signature verification
- Use HTTPS - All webhook URLs must use HTTPS
- Validate timestamps - Reject webhooks with old timestamps
- Implement idempotency - Handle duplicate deliveries gracefully
- Respond quickly - Return 2xx within 30 seconds, process async if needed
- Rotate secrets - Periodically rotate webhook secrets
- Monitor failures - Set up alerts for failed webhook deliveries
Support
For webhook integration issues:
- Check the delivery logs in your Contacts4Us dashboard
- Review the response code and body from your endpoint
- Contact support with the
X-Contacts4us-Delivery-Idfor specific delivery issues