Webhooks

A webhook is a way for one system to send real-time data to another system as soon as an event happens. Instead of constantly checking (polling) for updates, a webhook lets your application receive updates automatically.

Think of it like a notification system: when something important happens, the system calls your provided URL (usually an API endpoint) with the event data.


How to Use a Webhook

  1. Provide a URL: You give the system a public URL (your webhook endpoint) that can receive POST requests.
  2. Wait for Events: When a specific event occurs (like a person enrichment completing), the system sends a request to your URL with the relevant data.
  3. Handle the Request: Your server processes the data and responds with a 2xx status, such as 200 OK, to confirm receipt.

Example Use Case

You want to be notified when a person's enrichment is completed. You provide a webhookUrl in the request. Once the enrichment is done, we send a POST request to your URL with the result data.

This approach is efficient, reduces load on your system, and gives you near-instant updates.

Supported event types

Example notification event:

    enum EventType {
        PersonEnrichmentCompleted       = "person.enrichment.completed",
        PersonBatchEnrichmentCompleted  = "person.batch-enrichment.completed",
        CompanyEnrichmentCompleted      = "company.enrichment.completed",
    }
    export interface Event {
        eventType: EventType;
        data: any;
    }

"person.enrichment.completed"

Will be triggered when a contact within a bulk enrichment has been completed.

Source: POST /v2/people/enrich

If the notificationOptions.webhookUrl field is set, our server will send a POST request to that URL each time a person enrichment is completed.

To acknowledge successful receipt of the event, your server should respond with a 2xx status, such as 200 OK. If the delivery fails with a network error or any other status, Surfe retries it a limited number of times, with increasing delays between attempts.

The request body sent by our server will include the details of the completed person enrichment, as shown below:

Request Body

application/jsonRequired
eventTypeRequiredstring
Value in: "person.enrichment.completed"
dataRequiredobject

Example webhook request from our server to your server

{
  "eventType": "person.enrichment.completed",
  "data": {
    "enrichmentID": "01973f39-f391-7b75-8812-7aad73f798f6",
    "person": {
      "companyDomain": "surfe.com",
      "companyName": "Surfe",
      "country": "France",
      "departments": [
        "Engineering",
        "R&D"
      ],
      "emails": [
        {
          "email": "david.chevalier@surfe.com",
          "validationStatus": "VALID"
        }
      ],
      "externalID": "external-id",
      "firstName": "David",
      "jobTitle": "Co-Founder & CEO",
      "lastName": "Chevalier",
      "linkedInUrl": "https://www.linkedin.com/in/david-maurice-chevalier",
      "location": "Paris, Île-de-France",
      "mobilePhones": [
        {
          "confidenceScore": 0.8,
          "mobilePhone": "+33 6 12 34 56 78"
        }
      ],
      "seniorities": [
        "Manager",
        "Head"
      ],
      "status": "COMPLETED"
    }
  }
}

Note that the data.person field is of type EnrichedPersonResponse, the same type used in the people field of the GET /v2/people/enrich/:id endpoint.

The country field is a full English country name by default. When ISO country codes are enabled for your account, it is a lowercase ISO 3166-1 alpha-2 code instead, for example fr. Values that cannot be converted are returned unchanged, so do not assume the field is always two letters.

Example enrichment request with webhookUrl provided

 
curl -X POST "https://api.surfe.com/v2/people/enrich" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "include": {
      "email": true,
      "linkedInUrl": false,
      "mobile": false
    },
    "notificationOptions": {
      "webhookUrl": "https://webhook.site/d17b3a76-2336-4c18-8de9-7b44096f62b3"
    },
    "people": [
      {
        "companyDomain": "surfe.com",
        "companyName": "Surfe",
        "externalID": "external-id",
        "firstName": "David",
        "lastName": "Chevalier",
        "linkedinUrl": "https://www.linkedin.com/in/david-maurice-chevalier"
      }
    ]
  }'

"person.batch-enrichment.completed"

Will be triggered when all contacts within a bulk enrichment have finished processing.

Source: POST /v2/people/enrich

If the notificationOptions.webhookUrl field is set, our server will send a POST request to that URL once the entire batch has completed. This is in addition to the per-contact person.enrichment.completed events.

To acknowledge successful receipt of the event, your server should respond with a 2xx status, such as 200 OK. If the delivery fails with a network error, or with a 408, 429 or 5xx response, Surfe retries it a limited number of times, with increasing delays between attempts. Other 4xx responses are not retried.

The request body sent by our server will include the batch enrichment ID and a callback URL to retrieve the full results:

Request Body

application/jsonRequired
eventTypeRequiredstring
Value in: "person.batch-enrichment.completed"
dataRequiredobject

Example webhook request from our server to your server

{
  "eventType": "person.batch-enrichment.completed",
  "data": {
    "message": "Your enrichment has finished ✨",
    "enrichmentID": "01973f39-f391-7b75-8812-7aad73f798f6",
    "enrichmentCallbackURL": "https://api.surfe.com/v2/people/enrich/01973f39-f391-7b75-8812-7aad73f798f6"
  }
}

Use the enrichmentCallbackURL (or enrichmentID with GET /v2/people/enrich/:id) to retrieve the full enrichment results for all contacts in the batch.

"company.enrichment.completed"

Will be triggered when a company within a bulk enrichment has been completed.

Source: POST /v2/companies/enrich

If the notificationOptions.webhookUrl field is set, our server will send a POST request to that URL each time a company enrichment is completed.

To acknowledge successful receipt of the event, your server should respond with a 2xx status, such as 200 OK. If the delivery fails with a network error, or with a 429 or 5xx response (except 501), Surfe retries it a limited number of times. Other responses are not retried.

The request body sent by our server will include the details of the completed company enrichment, as shown below:

Request Body

application/jsonRequired
eventTypeRequiredstring
Value in: "company.enrichment.completed"
dataRequiredobject

Example webhook request from our server to your server

{
 "eventType": "company.enrichment.completed",
 "data": {
  "enrichmentID": "01973b15-68d2-7937-b87c-78dd0069b2db",
  "company": {
    "name": "Surfe",
    "websites": [
      "surfe.com"
    ],
    "description": "Sales teams waste too much time on repetitive admin tasks. Surfe handles everything before the phone call - identifying target accounts, building lead lists, and enriching data. Our mission is to empower sales teams, automating the essential steps of the workflow while enhancing the human touch. Trusted by 3,000+ companies from local to global GTM needs like Google, Uber, AWS, Bolt, Pigment. Compatible with HubSpot, Salesforce, Pipedrive, and Copper CRM. As a GDPR-compliant and ISO27001-certified tool, Surfe helps your team operate from a gold standard of data, making sure everything is structured, categorized, updated, and protected. Surfe has been selected as Top 100 fastest-growing products on G2 and recognized as a Top 100 Rising European Startups by VivaTech in 2025.",
    "employeeCount": 65,
    "externalID": "external-id",
    "founded": "2020",
    "hqAddress": "52 Rue Chaussée D'antin Paris FR 75009",
    "hqCountry": "FR",
    "industry": "IT Services",
    "subIndustry": "Internet Services & Infrastructure",
    "keywords": [
      "crm updates",
      "reply rate",
      "linkedin sales",
      "linkedin messages",
      "synchronize linkedin",
      "secure crm"
    ],
    "linkedInURL": "https://linkedin.com/company/surfe",
    "revenue": "10-50M",
    "signals": {
      "leadershipChange": {
        "changeCount": 1,
        "changes": [
          {
            "type": "promotion",
            "date": "2026-07-01",
            "firstName": "Jane",
            "lastName": "Doe",
            "linkedinUrl": "https://www.linkedin.com/in/janedoe",
            "oldTitle": "Sales Manager",
            "oldCompanyDomain": "surfe.com",
            "newTitle": "VP Sales",
            "newCompanyDomain": "surfe.com"
          }
        ]
      },
      "funding": {
        "rounds": 1,
        "totalRaised": 30000000,
        "fundingRounds": [
          {
            "name": "Series B",
            "amount": 30000000,
            "amountCurrency": "USD",
            "announcedDate": "2026-06-12",
            "leadInvestors": ["Investor A", "Investor B"]
          }
        ]
      },
      "hiring": {
        "activePostings": 18,
        "newPostingsLast30Days": 6,
        "departments": [
          { "name": "Engineering", "activeCount": 12 },
          { "name": "Sales", "activeCount": 6 }
        ]
      }
    },
    "status": "COMPLETED"
  }
 }
}

Note that the data.company field is of type EnrichedCompanyResponse, the same type used in the companies field of the GET /v2/companies/enrich/:id endpoint.

Example enrichment request with webhookUrl provided

 
curl -X POST "https://api.surfe.com/v2/companies/enrich" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "companies": [
      {
        "domain": "surfe.com",
        "externalID": "external-id"
      }
    ],
    "notificationOptions": {
      "webhookUrl": "https://webhook.site/d17b3a76-2336-4c18-8de9-7b44096f62b3"
    }
  }'

Secure your endpoint

You should secure your webhook integration and verify that all webhook requests are coming from Surfe. You can verify webhook signatures using your webhook shared secret key. You can find your secret key in the API settings. Surfe generates it when you create your API key, or with your first company enrichment request that sets a webhookUrl. If your account has no secret, person webhooks are sent without a signature. Company enrichment requests that set a webhookUrl create the secret first, so company webhooks are always signed.

To help add to the security of webhook communication, usage of https endpoints is required.

❌ Not valid, insecure http URL:

"notificationOptions": {
  "webhookUrl": "http://insecure-surfe-webhook.com"
}

✅ Valid, secure https URL:

"notificationOptions": {
  "webhookUrl": "https://secure-surfe-webhook.com"
}

How to Verify Surfe Webhook Signatures

To ensure webhook requests are genuinely sent by Surfe and have not been tampered with, each webhook event sent once your shared secret exists includes an x-surfe-signature header. The signature contains 2 parts, a timestamp prefixed with t= and the signature scheme prefixed by v0=.

x-surfe-signature:
t=1758468698287,
v0=ac18feb8cdbcaf4bf98beb90586f95159646a21629326f8bb6f63305f82155ea
#(a real signature is a single line)

Surfe generates signatures using hash-based message authentication code HMAC with SHA-256.

Follow these steps to verify the signature:

Step 1: Extract timestamp and signature from the header

Split the header using the , character as the separator to get a list of elements. Then split each element using the = character as the separator to get a prefix and value pair.

The value for the prefix t corresponds to the timestamp, in milliseconds since the Unix epoch, and v0 corresponds to the signature. You can discard all other elements.

Step 2: Recreate the signed payload

The signed_payload string is created by concatenating:

  • The timestamp (as a string)
  • The character .
  • The actual JSON payload (that is, the raw request body, exactly as received)

Step 3: Compute the HMAC Hash

Compute an HMAC with the SHA256 hash function. Use the endpoint’s signing secret as the key, and use the signed_payload string as the message.

Step 4: Compare Signatures

Compare the computed signature with the v0 value from the x-surfe-signature header, using a constant-time comparison. Then compute the difference between the current time and the received timestamp, and check that it is within your tolerance.

Step 5: Accept or Reject the Request

If the signatures match and the timestamp is within your tolerance, process the webhook. Otherwise, reject it.

Code examples

Each example rejects requests whose timestamp is more than five minutes away from the current time. Adjust the tolerance constant to your needs.

Python

import hashlib
import hmac
import re
import time
 
TOLERANCE_MS = 5 * 60 * 1000
 
def verify_webhook_signature(payload: bytes, secret: str, signature_header: str) -> bool:
    """payload is the raw request body, as bytes."""
    timestamp = ''
    signature = ''
    for part in signature_header.split(','):
        if part.startswith('t='):
            timestamp = part[len('t='):]
        if part.startswith('v0='):
            signature = part[len('v0='):]
    if not re.fullmatch(r'[0-9]+', timestamp) or not re.fullmatch(r'[0-9a-f]{64}', signature):
        raise ValueError('invalid signature header format')
 
    # The timestamp is in milliseconds since the Unix epoch
    if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
        return False
 
    message = timestamp.encode() + b"." + payload
    expected_signature = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
 
    return hmac.compare_digest(expected_signature, signature)
 
shared_secret = "<YOUR_SHARED_SECRET>"
signature_header = "t=1758503642403,v0=4ff48a2e4767453781f153ad1e15453809d8d90795a840477894d8592bffd79f"
payload = b'{"eventType":"person.enrichment.completed","data":{"enrichmentID":"01996efb-b83b-7e8a-89cf-7a1ad6e92cc9","person":{"externalID":"external-id","firstName":"David","lastName":"Chevalier","companyName":"Surfe","companyDomain":"surfe.com","linkedInUrl":"https://linkedin.com/in/david-maurice-chevalier","emails":[],"mobilePhones":[],"status":"COMPLETED"}}}'
 
try:
    if verify_webhook_signature(payload, shared_secret, signature_header):
        print("Signature is valid")
    else:
        print("Signature is invalid")
except ValueError as e:
    print(e)

Javascript

const crypto = require('crypto');
 
const TOLERANCE_MS = 5 * 60 * 1000;
 
function verifyWebhookSignature(payload, secret, signatureHeader) {
  let timestamp = '';
  let signature = '';
  for (const part of signatureHeader.split(',')) {
    if (part.startsWith('t=')) {
      timestamp = part.slice('t='.length);
    }
    if (part.startsWith('v0=')) {
      signature = part.slice('v0='.length);
    }
  }
  if (!/^\d+$/.test(timestamp) || !signature) {
    throw new Error('invalid signature header format');
  }
 
  // The timestamp is in milliseconds since the Unix epoch
  if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
    return false;
  }
 
  const message = `${timestamp}.${payload}`;
  const expectedSignature = crypto.createHmac('sha256', secret).update(message).digest('hex');
 
  const expected = Buffer.from(expectedSignature);
  const received = Buffer.from(signature);
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
 
const sharedSecret = '<YOUR_SHARED_SECRET>';
const signatureHeader = 't=1758503642403,v0=4ff48a2e4767453781f153ad1e15453809d8d90795a840477894d8592bffd79f';
const payload = '{"eventType":"person.enrichment.completed","data":{"enrichmentID":"01996efb-b83b-7e8a-89cf-7a1ad6e92cc9","person":{"externalID":"external-id","firstName":"David","lastName":"Chevalier","companyName":"Surfe","companyDomain":"surfe.com","linkedInUrl":"https://linkedin.com/in/david-maurice-chevalier","emails":[],"mobilePhones":[],"status":"COMPLETED"}}}';
 
try {
  if (verifyWebhookSignature(payload, sharedSecret, signatureHeader)) {
    console.log('Signature is valid');
  } else {
    console.log('Signature is invalid');
  }
} catch (err) {
  console.error(err.message);
}

Golang

package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"
)
 
const tolerance = 5 * time.Minute
 
func main() {
	sharedSecret := "<YOUR_SHARED_SECRET>"
	signatureHeader := "t=1758503642403,v0=4ff48a2e4767453781f153ad1e15453809d8d90795a840477894d8592bffd79f"
	payload := `{"eventType":"person.enrichment.completed","data":{"enrichmentID":"01996efb-b83b-7e8a-89cf-7a1ad6e92cc9","person":{"externalID":"external-id","firstName":"David","lastName":"Chevalier","companyName":"Surfe","companyDomain":"surfe.com","linkedInUrl":"https://linkedin.com/in/david-maurice-chevalier","emails":[],"mobilePhones":[],"status":"COMPLETED"}}}`
 
	valid, err := VerifyWebhookSignature(payload, sharedSecret, signatureHeader)
	if err != nil {
		fmt.Println(err)
		return
	}
	if valid {
		fmt.Println("Signature is valid")
	} else {
		fmt.Println("Signature is invalid")
	}
}
 
// VerifyWebhookSignature verifies the HMAC signature and the timestamp of a webhook payload.
func VerifyWebhookSignature(payload string, secret string, signatureHeader string) (bool, error) {
	var timestamp, signature string
	for _, part := range strings.Split(signatureHeader, ",") {
		if strings.HasPrefix(part, "t=") {
			timestamp = strings.TrimPrefix(part, "t=")
		}
		if strings.HasPrefix(part, "v0=") {
			signature = strings.TrimPrefix(part, "v0=")
		}
	}
	ms, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false, errors.New("invalid signature header format")
	}
 
	// The timestamp is in milliseconds since the Unix epoch
	age := time.Since(time.UnixMilli(ms))
	if age > tolerance || age < -tolerance {
		return false, nil
	}
 
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "." + payload))
	expectedSignature := hex.EncodeToString(mac.Sum(nil))
 
	return hmac.Equal([]byte(expectedSignature), []byte(signature)), nil
}

Preventing replay attacks

A replay attack occurs when someone captures a legitimate webhook payload and its signature, then tries to resend (replay) it to your endpoint. To help prevent this, Surfe adds a timestamp to the x-surfe-signature header. Since the timestamp is included in the data that is signed, any attempt to alter it will break the signature verification. If you receive a valid signature but the timestamp is outside your allowed time window, your application should reject the request as potentially malicious.

On this page