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          │
└─────────────────┘                          └──────────────────┘
  1. Event occurs — A contact, company, or link is created, updated, or deleted
  2. Propstreet sends — HTTP POST to your registered URL with signed payload
  3. You verify — Check the HMAC-SHA256 signature
  4. You respond — Return 2xx within 10 seconds
  5. 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:

  1. Extract headers: webhook-id, webhook-timestamp, webhook-signature
  2. Construct the signed payload: {webhook-id}.{webhook-timestamp}.{raw_body}
  3. Decode your secret (strip whsec_ prefix, base64-decode the rest)
  4. Compute HMAC-SHA256 and base64-encode the result
  5. 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

  1. Fix the issue with your endpoint
  2. Update the webhook status to active
  3. 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 secret securely — it's only returned during creation. The secret uses the whsec_ 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

  1. Always verify signatures — Never process webhooks without signature verification
  2. Use HTTPS — Webhook URLs must use HTTPS (enforced by API)
  3. Respond quickly — Return 2xx within 10 seconds; do heavy processing async
  4. Handle duplicates — Use webhook-id header for idempotency
  5. Protect your endpoint — Consider IP allowlisting or additional authentication
  6. Rotate secrets periodically — Use the regenerate endpoint to rotate secrets
  7. 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.
  8. 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: true header
  • Payload includes "_test": true field
  • Uses first subscribed event if event_type is 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

  1. Create a test webhook pointing to your endpoint
  2. Use the test endpoint to verify connectivity
  3. Create a contact via the API
  4. Check delivery history to confirm delivery
  5. 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" with lastError explaining the issue
  • status: "exhausted" indicating all retries failed
  • lastStatusCode showing 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 since timestamp
  • 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:

  1. Use webhooks for immediate updates
  2. Run delta sync periodically (hourly/daily) to catch any missed events
  3. Use delivery history to identify and replay missed webhooks