Webhooks
Real-time notifications when contacts, companies, and links change in your Propstreet network.
Webhooks push event data to your server immediately when changes occur, eliminating the need to poll the API for updates.
Our webhook implementation follows the Standard Webhooks specification, an industry standard backed by Svix, Kong, Zapier, and others. This ensures compatibility with standard webhook libraries and consistent behavior across platforms.
Quick Reference
| Feature | Details |
|---|---|
| Events | 9 types: contact/company/link × created/updated/deleted |
| Signature | HMAC-SHA256 via webhook-signature header (Standard Webhooks) |
| Retry Policy | 9 attempts over ~35 hours with exponential backoff |
| Auto-Disable | After 10 consecutive failures, or immediate on HTTP 410 response |
| URL Requirement | HTTPS only |
| API Endpoints | /api/v1/webhooks (CRUD + deliveries + test + replay) |
How Webhooks Work
┌─────────────────┐ ┌──────────────────┐
│ Propstreet │ │ Your Server │
│ │ │ │
│ Contact Updated├──────────────────────────► Webhook Handler │
│ │ POST + Signature │ │
│ │◄─────────────────────────┤ 200 OK │
└─────────────────┘ └──────────────────┘
- Event occurs — A contact, company, or link is created, updated, or deleted
- Propstreet sends — HTTP POST to your registered URL with signed payload
- You verify — Check the HMAC-SHA256 signature
- You respond — Return 2xx within 10 seconds
- Retry if needed — Failed deliveries retry with exponential backoff
Event Types
Subscribe to specific events when creating a webhook:
| Event | Trigger |
|---|---|
contact.created |
New contact added to network |
contact.updated |
Contact information changed |
contact.deleted |
Contact removed from network |
company.created |
New company added to network |
company.updated |
Company information changed |
company.deleted |
Company removed from network |
link.created |
New contact-company relationship |
link.updated |
Link details changed (role, etc.) |
link.deleted |
Contact-company relationship removed |
Payload Structure
Every webhook delivery includes:
{
"event": "contact.updated",
"resource_type": "contact",
"resource_id": 12345,
"timestamp": "2026-01-15T14:30:00.0000000Z",
"action_id": 987654321,
"changed_fields": ["email", "phone"],
"actor_user_id": "abc123",
"change_initiator": {
"type": "oauth_client",
"client_id": "partner-crm-client"
}
}
| Field | Description |
|---|---|
event |
Event type (e.g., contact.updated) |
resource_type |
Resource type: contact, company, or link |
resource_id |
ID of the affected resource |
timestamp |
When the event occurred (ISO 8601 UTC) |
action_id |
Unique identifier for this action (for tracing/debugging) |
changed_fields |
Fields that changed (for update events only, may be null) |
actor_user_id |
User who triggered the change (for loop prevention) |
change_initiator |
OAuth client that triggered the change (API calls only, see below) |
Changed Fields Reference
The changed_fields array lists which fields were modified in an update event.
Contact fields:
| Field | Description |
|---|---|
email |
Contact's email address |
first_name |
First name |
last_name |
Last name |
phone |
Phone number |
primary_language |
Primary language code |
primary_company_id |
ID of primary company association |
tags |
Array of tags |
strategy |
Investment strategy notes |
profile_picture |
Profile picture changed (triggers download URL) |
Company fields:
| Field | Description |
|---|---|
name |
Company name |
country |
Country code |
homepage_url |
Company homepage URL |
domain |
Company domain (e.g., "acme.com") - set directly or derived from URL |
tags |
Array of tags |
strategy |
Investment strategy notes |
profile_picture |
Logo/profile picture changed |
Link fields:
| Field | Description |
|---|---|
job_title |
Contact's job title at company |
Bidirectional Sync with change_initiator
The change_initiator field enables reliable loop prevention for bidirectional sync integrations. When a change is made via the API using OAuth credentials, this field identifies the OAuth client that made the change.
When present: The change was made via the API by an OAuth client.
{
"change_initiator": {
"type": "oauth_client",
"client_id": "your-oauth-client-id"
}
}
When absent (null): The change was made via the Propstreet UI, an import, or another non-API source.
This is more reliable than actor_user_id for bidirectional sync because:
- It identifies your specific integration, not just the bot user
- Multiple integrations using the same bot user can be distinguished
- Works even if you rotate bot users
app.post("/webhooks/propstreet", (req, res) => {
const { change_initiator, resource_id, event } = req.body;
// Skip events triggered by our integration (prevents infinite loops)
if (change_initiator?.client_id === process.env.MY_OAUTH_CLIENT_ID) {
return res.status(200).send("Skipped self-triggered event");
}
// Process the event - this came from another source
syncToExternalSystem(event, resource_id);
res.status(200).send("OK");
});
HTTP Headers
Every webhook request includes these headers per the Standard Webhooks specification:
| Header | Example | Description |
|---|---|---|
Content-Type |
application/json |
Always JSON |
webhook-id |
msg_2nYj9K4... |
Unique message ID for idempotency |
webhook-timestamp |
1704067200 |
Unix timestamp (seconds since epoch) |
webhook-signature |
v1,K7sBgP... |
HMAC-SHA256 signature (base64) |
X-Propstreet-Event |
contact.updated |
Event type (for routing convenience) |
Signature Verification
Always verify webhook signatures to ensure requests come from Propstreet.
We follow the Standard Webhooks signature scheme. The signature format is: v1,{base64_signature}
To verify:
- Extract headers:
webhook-id,webhook-timestamp,webhook-signature - Construct the signed payload:
{webhook-id}.{webhook-timestamp}.{raw_body} - Decode your secret (strip
whsec_prefix, base64-decode the rest) - Compute HMAC-SHA256 and base64-encode the result
- Compare signatures using constant-time comparison
Note: You can use standard webhook verification libraries like svix/webhooks instead of implementing verification manually.
Python
import hmac
import hashlib
import time
import base64
def verify_signature(
payload: str,
msg_id: str,
timestamp: str,
signature: str,
secret: str,
tolerance_seconds: int = 300
) -> bool:
"""Verify webhook signature per Standard Webhooks spec."""
try:
ts = int(timestamp)
except ValueError:
return False
# Check timestamp to prevent replay attacks
if abs(time.time() - ts) > tolerance_seconds:
return False
# Decode the secret (strip whsec_ prefix if present)
if secret.startswith("whsec_"):
secret = secret[6:]
key = base64.b64decode(secret)
# Construct signed payload: {msg_id}.{timestamp}.{payload}
signed_payload = f"{msg_id}.{timestamp}.{payload}"
# Compute signature
computed = base64.b64encode(
hmac.new(key, signed_payload.encode(), hashlib.sha256).digest()
).decode()
# Parse v1 signature from header (format: "v1,{base64}")
expected = signature.split(",")[1] if "," in signature else signature
return hmac.compare_digest(computed, expected)
# Usage in Flask
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your-base64-encoded-secret"
@app.route("/webhooks/propstreet", methods=["POST"])
def handle_webhook():
msg_id = request.headers.get("webhook-id", "")
timestamp = request.headers.get("webhook-timestamp", "")
signature = request.headers.get("webhook-signature", "")
payload = request.get_data(as_text=True)
if not verify_signature(payload, msg_id, timestamp, signature, WEBHOOK_SECRET):
return "Invalid signature", 401
event = request.json
# Process the event...
return "OK", 200
JavaScript (Node.js)
const crypto = require("crypto");
function verifySignature(
payload,
msgId,
timestamp,
signature,
secret,
toleranceSeconds = 300
) {
const ts = parseInt(timestamp, 10);
if (isNaN(ts)) return false;
// Check timestamp to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > toleranceSeconds) {
return false;
}
// Decode the secret (strip whsec_ prefix if present)
const secretKey = secret.startsWith("whsec_") ? secret.slice(6) : secret;
const key = Buffer.from(secretKey, "base64");
// Construct signed payload: {msg_id}.{timestamp}.{payload}
const signedPayload = `${msgId}.${timestamp}.${payload}`;
// Compute signature
const computed = crypto
.createHmac("sha256", key)
.update(signedPayload)
.digest("base64");
// Parse v1 signature from header (format: "v1,{base64}")
const expected = signature.includes(",")
? signature.split(",")[1]
: signature;
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}
// Usage in Express
const express = require("express");
const app = express();
app.post(
"/webhooks/propstreet",
express.raw({ type: "application/json" }),
(req, res) => {
const msgId = req.headers["webhook-id"];
const timestamp = req.headers["webhook-timestamp"];
const signature = req.headers["webhook-signature"];
const payload = req.body.toString();
if (
!verifySignature(
payload,
msgId,
timestamp,
signature,
process.env.WEBHOOK_SECRET
)
) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(payload);
// Process the event...
res.status(200).send("OK");
}
);
C#
using System.Security.Cryptography;
using System.Text;
public class WebhookSignatureValidator
{
public static bool VerifySignature(
string payload,
string msgId,
string timestampStr,
string signature,
string secret,
int toleranceSeconds = 300)
{
if (!long.TryParse(timestampStr, out var timestamp))
{
return false;
}
// Check timestamp to prevent replay attacks
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestamp) > toleranceSeconds)
{
return false;
}
// Decode the secret (strip whsec_ prefix if present)
var base64Secret = secret.StartsWith("whsec_", StringComparison.Ordinal)
? secret[6..]
: secret;
var keyBytes = Convert.FromBase64String(base64Secret);
// Construct signed payload: {msg_id}.{timestamp}.{payload}
var signedPayload = $"{msgId}.{timestamp}.{payload}";
var payloadBytes = Encoding.UTF8.GetBytes(signedPayload);
// Compute signature (base64-encoded HMAC-SHA256)
var computed = Convert.ToBase64String(
HMACSHA256.HashData(keyBytes, payloadBytes)
);
// Parse v1 signature from header (format: "v1,{base64}")
var expected = signature.Contains(',')
? signature.Split(',')[1]
: signature;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(expected)
);
}
}
Retry Policy
Failed deliveries are retried with exponential backoff:
| Attempt | Delay After Failure | Cumulative Time |
|---|---|---|
| 1 | (initial) | 0 |
| 2 | 3 seconds | 3s |
| 3 | 10 seconds | 13s |
| 4 | 30 seconds | 43s |
| 5 | 5 minutes | ~6m |
| 6 | 30 minutes | ~36m |
| 7 | 2 hours | ~2.5h |
| 8 | 8 hours | ~10.5h |
| 9 | 24 hours | ~34.5h |
Retriable conditions:
- HTTP 5xx errors (server errors)
- HTTP 429 (rate limited)
- Connection timeouts
- Network errors
Non-retriable conditions:
- HTTP 4xx errors (except 429) indicate configuration issues
- HTTP 410 Gone — Immediately disables the webhook (see Auto-Disable below)
After 9 failed attempts, the delivery is marked as exhausted.
Auto-Disable
Webhooks are automatically disabled in two scenarios:
1. HTTP 410 Gone (Immediate)
Per the Standard Webhooks specification, if your endpoint returns HTTP 410 Gone, the webhook is disabled immediately without retries. Use this to programmatically unsubscribe from webhooks when an endpoint is permanently removed.
2. Consecutive Failures (Gradual)
Webhooks are disabled after 10 consecutive failed deliveries (i.e., deliveries that exhaust all retry attempts without success). This prevents wasting resources on unreachable endpoints. Note: retries within a single delivery don't count individually toward this limit.
When Disabled
- Status changes to
disabled - No new deliveries are attempted
- Existing pending deliveries remain in queue
Re-enabling
- Fix the issue with your endpoint
- Update the webhook status to
active - Consecutive failure counter resets
curl -X PATCH "https://app.propstreet.com/api/v1/webhooks/123" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'
Managing Webhooks
Create a Webhook
curl -X POST "https://app.propstreet.com/api/v1/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/propstreet",
"events": ["contact.created", "contact.updated", "contact.deleted"],
"name": "CRM Sync"
}'
Response includes the secret (shown only once):
{
"id": "123",
"url": "https://your-server.com/webhooks/propstreet",
"events": ["contact.created", "contact.deleted", "contact.updated"],
"name": "CRM Sync",
"status": "active",
"secret": "whsec_MIGfMA0GCSqGSIb3DQEBAQUAA4GNAD...",
"createdUtc": "2026-01-15T10:00:00Z"
}
[!important] Store the
secretsecurely — it's only returned during creation. The secret uses thewhsec_prefix followed by base64-encoded key material, per the Standard Webhooks specification.
List Webhooks
curl "https://app.propstreet.com/api/v1/webhooks" \
-H "Authorization: Bearer $TOKEN"
Update a Webhook
curl -X PATCH "https://app.propstreet.com/api/v1/webhooks/123" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"events": ["contact.created", "contact.updated", "company.created"],
"status": "paused"
}'
Delete a Webhook
curl -X DELETE "https://app.propstreet.com/api/v1/webhooks/123" \
-H "Authorization: Bearer $TOKEN"
Regenerate Secret
If your secret is compromised:
curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/secret" \
-H "Authorization: Bearer $TOKEN"
[!warning] This invalidates the previous secret immediately. Update your handler before the next delivery.
Viewing Delivery History
Check recent deliveries for debugging:
curl "https://app.propstreet.com/api/v1/webhooks/123/deliveries?limit=10" \
-H "Authorization: Bearer $TOKEN"
Response:
{
"data": [
{
"id": "456",
"eventId": "msg_2nYj9K4xLm...",
"eventType": "contact.updated",
"status": "delivered",
"attemptCount": 1,
"lastAttemptUtc": "2026-01-15T14:30:01Z",
"lastStatusCode": 200,
"lastDurationMs": 150,
"createdUtc": "2026-01-15T14:30:00Z",
"deliveredUtc": "2026-01-15T14:30:01Z"
},
{
"id": "457",
"eventId": "msg_3oZk0L5yMn...",
"eventType": "contact.created",
"status": "failed",
"attemptCount": 3,
"lastAttemptUtc": "2026-01-15T14:35:36Z",
"lastStatusCode": 500,
"lastError": "HTTP 500",
"lastDurationMs": 2500,
"createdUtc": "2026-01-15T14:35:00Z",
"deliveredUtc": null
}
]
}
The eventId (sent as webhook-id header) uses the msg_ prefix followed by a URL-safe base64-encoded hash, ensuring deterministic, idempotent message IDs per the Standard Webhooks specification.
Delivery Statuses
| Status | Meaning |
|---|---|
pending |
Waiting for first delivery attempt |
delivered |
Successfully delivered (2xx response) |
failed |
Last attempt failed, retry scheduled |
exhausted |
All retry attempts exhausted |
Security Best Practices
- Always verify signatures — Never process webhooks without signature verification
- Use HTTPS — Webhook URLs must use HTTPS (enforced by API)
- Respond quickly — Return 2xx within 10 seconds; do heavy processing async
- Handle duplicates — Use
webhook-idheader for idempotency - Protect your endpoint — Consider IP allowlisting or additional authentication
- Rotate secrets periodically — Use the regenerate endpoint to rotate secrets
- Use raw request body unchanged — When verifying signatures, use the raw request body exactly as received. Do not parse and re-serialize JSON, as this may change whitespace, ordering, or Unicode escaping, causing signature verification to fail.
- Return 410 to unsubscribe — If your endpoint is permanently removed, return HTTP 410 Gone to automatically disable the webhook
Idempotency
Webhooks may be delivered more than once (e.g., if your server returns 200 but connection drops before we receive it). Use the webhook-id header to deduplicate:
const processedDeliveries = new Set();
app.post("/webhooks/propstreet", (req, res) => {
const messageId = req.headers["webhook-id"];
if (processedDeliveries.has(messageId)) {
return res.status(200).send("Already processed");
}
// Process the event...
processedDeliveries.add(messageId);
res.status(200).send("OK");
});
For production, store message IDs in a database with TTL.
Testing Webhooks
Test Endpoint
Send a test delivery to verify your webhook endpoint is working:
curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/test" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"event_type": "contact.created"}'
Response:
{
"success": true,
"status_code": 200,
"duration_ms": 145,
"error": null
}
Key behaviors:
- Synchronous delivery (waits for response)
- Not persisted in delivery history
- Includes
X-Propstreet-Test: trueheader - Payload includes
"_test": truefield - Uses first subscribed event if
event_typeis omitted - Requires webhook to be
active(not paused/disabled)
This is useful for:
- Verifying endpoint connectivity before going live
- Testing signature verification logic
- Debugging delivery issues
Local Development
Use a tunneling service to expose your local server:
# Using ngrok
ngrok http 3000
# Register the temporary URL
curl -X POST "https://app.propstreet.com/api/v1/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/propstreet",
"events": ["contact.created"]
}'
# Test the webhook
curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/test" \
-H "Authorization: Bearer $TOKEN"
Verify Your Setup
- Create a test webhook pointing to your endpoint
- Use the test endpoint to verify connectivity
- Create a contact via the API
- Check delivery history to confirm delivery
- Review your server logs for the received payload
Check Delivery History
If webhooks aren't arriving:
curl "https://app.propstreet.com/api/v1/webhooks/123/deliveries" \
-H "Authorization: Bearer $TOKEN"
Look for:
status: "failed"withlastErrorexplaining the issuestatus: "exhausted"indicating all retries failedlastStatusCodeshowing what your server returned
Recovering Failed Deliveries
Replay Endpoint
Re-queue failed or exhausted deliveries for retry:
curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/replay" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"since": "2026-01-01T00:00:00Z"}'
Response:
{
"replayed_count": 15,
"message": "15 deliveries re-queued for retry"
}
Key behaviors:
- Re-queues up to 100 failed or exhausted deliveries
- Only replays deliveries created after the
sincetimestamp - Resets attempt count to 0 and schedules immediate delivery
- Requires webhook to be
active(not paused/disabled)
This is useful for:
- Recovering after fixing an endpoint issue
- Retrying exhausted deliveries after the 35-hour window
- Bulk recovery after infrastructure outages
Webhooks vs Delta Sync
| Approach | Best For |
|---|---|
| Webhooks | Real-time UI updates, instant notifications |
| Delta Sync | Batch processing, guaranteed consistency |
| Both | Production systems needing speed + reliability |
Recommended strategy:
- Use webhooks for immediate updates
- Run delta sync periodically (hourly/daily) to catch any missed events
- Use delivery history to identify and replay missed webhooks
Related Documentation
- Sync Patterns Guide — Delta sync and pagination
- Errors & Rate Limiting — Error handling patterns
- Authentication Guide — OAuth and API credentials
- API Reference — Full API specification