Skip to main content

API - Webhooks: Verifying Webhook Signatures

Full technical verification steps, the HMAC explanation, the code examples in Python, JavaScript and Golang. Replay attack prevention.

Updated over a week ago

Find Your Webhook Shared Secret


You can find your webhook shared secret key in your API settings. The key becomes available once you've received your first webhook notification.


Verifying That Requests Are Really From Surfe

Every webhook request Surfe sends includes an x-surfe-signature header. This lets you confirm the request is genuinely from Surfe and hasn't been tampered with — important for keeping your integration secure.

The header looks like this:

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

It contains two parts:
t= — the timestamp of when the request was sent

v0= — the signature itself

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, 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 request body)

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 value in the x-surfe-signature header. For an equality match, compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.


Step 5: Accept or Reject the Request:
If the signatures match, process the webhook.

Coding Examples:


Python

import hmac
import hashlib

def verify_webhook_signature(payload, secret, signature_header):
parts = signature_header.split(',')
timestamp = ''
signature = ''
for part in parts:
if part.startswith('t='):
timestamp = part.replace('t=', '')
if part.startswith('v0='):
signature = part.replace('v0=', '')
if not timestamp or not signature:
raise ValueError('invalid signature header format')

# Recreate the signed message
message = f"{timestamp}.{payload}"

# Compute HMAC
mac = hmac.new(secret.encode(), message.encode(), hashlib.sha256)
expected_signature = mac.hexdigest()

print("Expected signature:", expected_signature)
print("Received signature:", signature)

return hmac.compare_digest(expected_signature, signature)

shared_secret = "<YOUR_SHARED_SECRET>"
signature_header = "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"}}}'

try:
valid = verify_webhook_signature(payload, shared_secret, signature_header)
if valid:
print("Signature is valid")
else:
print("Signature is invalid")
except Exception as e:
print(e)


JavaScript:

const crypto = require('crypto');

function verifyWebhookSignature(payload, secret, signatureHeader) {
const parts = signatureHeader.split(',');
let timestamp = '';
let signature = '';
for (const part of parts) {
if (part.startsWith('t=')) {
timestamp = part.replace('t=', '');
}
if (part.startsWith('v0=')) {
signature = part.replace('v0=', '');
}
}
if (!timestamp || !signature) {
throw new Error('invalid signature header format');
}

// Recreate the signed message
const message = `${timestamp}.${payload}`;

// Compute HMAC
const hmacObj = crypto.createHmac('sha256', secret);
hmacObj.update(message);
const expectedSignature = hmacObj.digest('hex');

console.log('Expected signature:', expectedSignature);
console.log('Received signature:', signature);

return expectedSignature === signature;
}

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 {
const valid = verifyWebhookSignature(payload, sharedSecret, signatureHeader);
if (valid) {
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"
"strings"
)

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 {
// handle error
}
if valid {
// signature is valid
fmt.Println("Signature is valid")
} else {
// signature is invalid
fmt.Println("Signature is invalid")
}
}

// VerifyWebhookSignature verifies the HMAC signature for a webhook payload.
func VerifyWebhookSignature(payload any, secret string, signatureHeader string) (bool, error) {
parts := strings.Split(signatureHeader, ",")
var timestamp, signature string
for _, part := range parts {
if strings.HasPrefix(part, "t=") {
timestamp = strings.TrimPrefix(part, "t=")
}
if strings.HasPrefix(part, "v0=") {
signature = strings.TrimPrefix(part, "v0=")
}
}
if timestamp == "" || signature == "" {
return false, errors.New("invalid signature header format")
}

// Recreate the signed message
message := timestamp + "." + string(payload.(string))

// Compute HMAC
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
expectedMAC := mac.Sum(nil)
expectedSignature := hex.EncodeToString(expectedMAC)

fmt.Println("Expected signature:", expectedSignature)
fmt.Println("Received signature:", signature)
return hmac.Equal([]byte(expectedSignature), []byte(signature)), nil
}



Protecting Against 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.

Need Help?

If you have any questions or need further assistance, feel free to reach out to our support team via Intercom.

Additional Resources

Did this answer your question?