Receive results with webhooks

Add an endpoint under Settings, API keys, send a test event, verify signatures, handle retries and rotate your signing secret.

Written By Philip Poppe

Last updated About 1 hour ago

Enrichment takes anything from a few seconds to a few minutes. Instead of asking us over and over whether a job is done, add a webhook: we send the finished job to your server the moment it is ready. This is the integration we recommend. Polling stays available as a fallback.

Webhooks only fire for enrichments started through the API, so you need API access and a key first; see Create an API key and Make your first API call.

Where to find it

In the SurroundR web app, go to Settings, then API keys. Webhooks sits under your keys. Like the keys, it is for account admins and owners only.

Step 1: add an endpoint

  1. Click Add endpoint.

  2. Enter the Endpoint URL on your server, for example https://example.com/webhooks/surroundr.

  3. Optionally add a Description, like "CRM sync", so you can tell endpoints apart.

  4. Tick the events you want. At least one is required:

    • Completed: every requested channel was found.

    • Partial: some requested channels were found.

    • No data: the job ran and nothing was found.

    • Failed: the job could not run.

    Because each outcome is its own event, you can for example send failures to a separate endpoint.

  5. Click Add endpoint.

webhook setup

Copy the signing secret. Right after you add the endpoint, Your webhook signing secret appears. It starts with whsec_ and you only see it once. Store it with your server's configuration: your code needs it to check that requests really come from us. If you lose it, rotate it (see below).

URL rules

  • It has to start with https://, with no username or password in it.

  • It has to be reachable from the public internet. Private, local and internal network addresses are refused.

  • We do not follow redirects. Give us the final URL.

  • An account can have up to 10 endpoints.

Step 2: send a test event

On the endpoint card, click Send test event. We send a test event to your URL right away, signed exactly like a real one, and tell you whether it arrived: Test event delivered (HTTP 200), or Test event failed with the reason. Use it to check your signature code before real results come in. A test never counts against your endpoint.

Reading the endpoint card

active webhook for enrichment
  • Status: Active, Disabled (you paused it) or Auto-disabled (we paused it because deliveries kept failing, see below).

  • Events: what it is subscribed to, or All events.

  • Last success and Last failure: when we last delivered, or failed to.

  • Failures in a row: goes back to 0 on the next successful delivery.

The buttons on the card:

  • Edit: change the URL, description or events.

  • Send test event: see Step 2.

  • Rotate secret: get a new signing secret, see below.

  • Disable / Enable: pause and resume deliveries, for example during maintenance. Events that finish while an endpoint is off are not sent later.

  • Deliveries: the last 50 attempts, newest first, with the time, event, attempt number and result (the HTTP status your server returned, or why there was no answer, like a timeout).

  • Delete: stop sending to this URL and remove its delivery log. This cannot be undone.

What we send

A POST with a JSON body:

{ 
    "event_id": "evt_7c9e6679742540de944be07fc1f90ae7", 
    "type": "enrichment.completed", 
    "created_at": "2026-09-29T09:14:10.123Z", 
    "api_version": "2026-09-23", 
    "data": { ...the job... } 
}

type is enrichment.completed, enrichment.partial, enrichment.no_data or enrichment.failed (and webhook_endpoint.test for a test). data is exactly the job you would get from GET /v1/enrichments/{id}, so the same code reads both. Match it to your own record with data.external_id.

Every request also carries a SurroundR-Signature header and the user agent SurroundR-Webhooks/1.0.

Step 3: verify the signature

Anyone can send a request to your URL. The signature proves it came from us and was not changed. Always check it before you trust the body.

SurroundR-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is when we signed the request, in Unix seconds.

  • v1 is an HMAC-SHA256, in hex, of the text {t}.{raw body}, using your signing secret as the key. Use the secret exactly as we gave it to you, whsec_ included.

To verify:

  1. Use the raw body, before you parse it. The signature covers the exact bytes we sent. If your framework parses the JSON first and you turn it back into text, the check will fail.

  2. Split the header on commas and take t and every v1. Ignore anything else, so future versions do not break you.

  3. Reject the request if t is more than 5 minutes away from your clock. This stops someone replaying an old request. Keep your server clock in sync.

  4. Compute the HMAC yourself and compare it to each v1 with a constant-time comparison. Accept if one matches.

  5. Only then parse the body.

Answer 400 or 401 when the check fails.

Node.js (Express)

const crypto = require('crypto'); const express = require('express'); 
const SECRETS = [process.env.SURROUNDR_WEBHOOK_SECRET]; // add the new one here while rotating 
const TOLERANCE_SECONDS = 300; 

function verifySurroundrSignature(rawBody, header, secrets, now = Date.now()) {        
    if (!header) return false; 
    let timestamp = null; 
    const signatures = []; 
    for (const part of header.split(',')) { 
        const i = part.indexOf('='); 
        if (i <= 0) continue; 
        const key = part.slice(0, i).trim(); 
        const value = part.slice(i + 1).trim(); 
        if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value); 
        else if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value))        
            signatures.push(Buffer.from(value, 'hex')); 
    } if (timestamp === null || signatures.length === 0) 
    return false; 
    if (Math.abs(Math.floor(now / 1000) - timestamp) > TOLERANCE_SECONDS) return false; 
    return secrets.some((secret) => { 
        const expected = crypto
            .createHmac('sha256', secret) 
            .update(`${timestamp}.`) 
            .update(rawBody) // the Buffer exactly as received 
            .digest(); 
    return signatures.some((sig) => crypto.timingSafeEqual(sig, expected)); }); 
} 

const app = express(); 

// express.raw keeps the body as a Buffer: verify first, parse after. 
app.post('/webhooks/surroundr', express.raw({ type: 'application/json' }), (req, res) => { if (!verifySurroundrSignature(req.body, req.get('SurroundR-Signature'), SECRETS)) { return res.sendStatus(400); } 
const event = JSON.parse(req.body.toString('utf8')); 
// deduplicate on event.event_id, then handle event.data 
res.sendStatus(204); 
});

Python (Flask)

import hashlib 
import hmac import os 
import re import time 
from flask import Flask, abort, request 
SECRETS = [os.environ["SURROUNDR_WEBHOOK_SECRET"]] # add the new one here while rotating 
TOLERANCE_SECONDS = 300 
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") 
def verify_surroundr_signature(raw_body, header, secrets, now=None): 
    if not header: 
        return False 
    timestamp = None 
    signatures = [] 
    for part in header.split(","): 
        key, sep, value = part.partition("=") 
        key, value = key.strip(), value.strip() 
        if not sep: 
            continue 
        if key == "t" and value.isascii() and value.isdigit():    
            timestamp = int(value) 
        elif key == "v1" and _HEX64.match(value):     
            signatures.append(value.lower()) 
        if timestamp is None or not signatures: 
            return False 
        if abs(int(now if now is not None else time.time()) - timestamp) > TOLERANCE_SECONDS: 
            return False 
    signed_payload = f"{timestamp}.".encode() + raw_body 
    for secret in secrets: 
        expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() 
        if any(hmac.compare_digest(expected, sig) for sig in signatures): 
            return True 
    return False 


app = Flask(__name__) 


@app.post("/webhooks/surroundr") 
def surroundr_webhook(): 
    raw_body = request.get_data() # raw bytes: verify first, parse after 
    if not verify_surroundr_signature(raw_body, request.headers.get("SurroundR-Signature"), SECRETS): 
        abort(400) 
    event = request.get_json() 
    # deduplicate on event["event_id"], then handle event["data"] 
    return "", 204

Step 4: answer fast, and expect repeats

  • Reply with any 2xx within 10 seconds. Anything else counts as a failure: another status, a redirect, a timeout, a connection or certificate error. Save the event and do the heavy work afterwards.

  • You may get the same event twice. For example when your server processed it but its answer did not reach us in time. event_id stays the same on every retry, so store the ones you have handled and skip repeats.

  • Order is not guaranteed. Events for different jobs can arrive in any order. Rely on created_at and the job's own fields, not on arrival order.

  • An endpoint receives events for jobs that finish after it was added.

  • Rarely, a job's outcome changes after it finished, for example a slow source answers after the job was reported failed. The new outcome arrives as a new event with its own event_id.

When your server is down

Retries

A failed delivery is retried roughly 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours later (each wait varies a little), six attempts in all. After that we stop trying that event. Every attempt shows up under Deliveries.

Auto-disabled

If every delivery to an endpoint has failed for 3 days straight, we turn it off and email your account's owners and admins. The card then shows Auto-disabled with the date. To bring it back: fix your server, click Send test event to confirm it works, then click Enable. Nothing re-enables an endpoint for you, and events from while it was off are not resent automatically.

Your results are never lost

A failing or disabled webhook does not touch the job. You can always fetch it with GET /v1/enrichments/{id} for 30 days. To have a missed event sent again instead, replay it through the API (see Replay a missed event below).

Rotate the signing secret

Rotate when a secret may have leaked, or as routine hygiene. You can do it without missing a single delivery:

  1. On the endpoint card, click Rotate secret.

  2. Choose how long the current secret stays valid, in hours (24 by default, up to 168), and confirm. Your new signing secret appears once. Copy it.

  3. Until that time runs out, every request is signed with both secrets, so your server verifies whichever one it holds. Deploy the new secret.

  4. Remove the old secret from your configuration. When the window ends the old one stops being used. Nothing else to do.

Emergency only: entering 0 hours stops the current secret right away. From then on deliveries fail your check until your server uses the new secret, so only do this when a secret has leaked and has to go now.

Manage webhooks through the API

Everything on this page can also be done from code with your API key, for example to set up endpoints as part of a deployment. Endpoints created through the API show up on the settings page, and the other way round.

Create an endpoint

curl https://api.surroundr.io/v1/webhook_endpoints -X POST -H "Authorization: Bearer $SURROUNDR_API_KEY" -H "Content-Type: application/json" -d '{ "url": "https://example.com/webhooks/surroundr", "description": "CRM sync", "enabled_events": ["enrichment.completed", "enrichment.partial"] }'
  • url (required): follows the same URL rules as above. A URL that breaks them returns 422 with "field": "url".

  • description (optional): up to 500 characters.

  • enabled_events (optional): any of enrichment.completed, enrichment.partial, enrichment.no_data, enrichment.failed. Leave it out to get all four.

The answer is 201 Created:

{ 
    "id": "we_9b2f4c1e8a7d4f3b9e6c5a4d3b2c1a0f", 
    "url": "https://example.com/webhooks/surroundr", 
    "description": "CRM sync", 
    "enabled_events": ["enrichment.completed", "enrichment.partial"], 
    "status": "enabled", 
    "disabled_reason": null, 
    "disabled_at": null, 
    "secrets": [{ 
        "created_at": "2026-09-29T10:02:00.000Z", 
        "expires_at": null     
    }], 
    "consecutive_failures": 0, 
    "last_failure_at": null, 
    "last_success_at": null, 
    "created_at": "2026-09-29T10:02:00.000Z", 
    "updated_at": "2026-09-29T10:02:00.000Z", 
    "secret": "whsec_..." 
}

Keep the id (starts with we_) for the calls below. The secret is in this response only, just like in the app: store it straight away.

Send a test event

curl https://api.surroundr.io/v1/webhook_endpoints/we_.../test -X POST -H "Authorization: Bearer $SURROUNDR_API_KEY"

Returns the logged attempt: succeeded, response_status (the HTTP status your server sent back), error (why there was no usable answer, such as timeout, tls_error, dns_failure or redirect_not_followed) and duration_ms.

All endpoint calls

Every call below uses the same Authorization: Bearer header.

  • GET /v1/webhook_endpoints: list all endpoints, newest first, as {"data": [...]}.

  • GET /v1/webhook_endpoints/{id}: one endpoint. The secret value is never included, only when each live secret was created and expires.

  • PATCH /v1/webhook_endpoints/{id}: change url, description (send null to clear it) or enabled_events. Send only the fields you want to change.

  • DELETE /v1/webhook_endpoints/{id}: remove the endpoint and its delivery log. Returns 204.

  • POST /v1/webhook_endpoints/{id}/test: send a test event, see above.

  • POST /v1/webhook_endpoints/{id}/disable and /enable: pause and resume deliveries. Enabling also resets the failure count.

  • GET /v1/webhook_endpoints/{id}/deliveries?limit=50: the delivery log, newest first (default 50, up to 100).

  • POST /v1/webhook_endpoints/{id}/rotate_secret: new signing secret, see below.

  • POST /v1/webhook_endpoints/{id}/events/{event_id}/retry: replay one event, see below.

Rotate the secret

curl https://api.surroundr.io/v1/webhook_endpoints/we_.../rotate_secret -X POST -H "Authorization: Bearer $SURROUNDR_API_KEY" -H "Content-Type: application/json" -d '{ "previous_secret_expires_in_hours": 24 }'

Returns the endpoint with the new secret, shown once. previous_secret_expires_in_hours works like the hours field in the app: 24 by default, up to 168, and 0 stops the old secret right away (emergency only).

Replay a missed event

curl https://api.surroundr.io/v1/webhook_endpoints/we_.../events/evt_.../retry -X POST -H "Authorization: Bearer $SURROUNDR_API_KEY"

Take the event_id from the delivery log. We send that one event again, now, with the same event_id and body, and return the attempt. Check succeeded: the call answers 200 whether or not your server accepted it.

  • You can replay any event whose job is less than 30 days old, including ones that already succeeded or ran out of retries. It goes only to the endpoint it was meant for; there is no bulk replay.

  • 409 conflict: the endpoint is disabled (enable it first), or the job's outcome changed and a newer event replaced this one.

  • 410 gone: the job is older than 30 days.

  • Replays have their own rate limit (per account), so they do not eat into your normal API budget. On 429, wait for Retry-After.