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
Click Add endpoint.
Enter the Endpoint URL on your server, for example
https://example.com/webhooks/surroundr.Optionally add a Description, like "CRM sync", so you can tell endpoints apart.
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.
Click Add endpoint.

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

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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis when we signed the request, in Unix seconds.v1is 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:
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.
Split the header on commas and take
tand everyv1. Ignore anything else, so future versions do not break you.Reject the request if
tis more than 5 minutes away from your clock. This stops someone replaying an old request. Keep your server clock in sync.Compute the HMAC yourself and compare it to each
v1with a constant-time comparison. Accept if one matches.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 "", 204Step 4: answer fast, and expect repeats
Reply with any
2xxwithin 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_idstays 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_atand 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 ownevent_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:
On the endpoint card, click Rotate secret.
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.
Until that time runs out, every request is signed with both secrets, so your server verifies whichever one it holds. Deploy the new secret.
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 returns422with"field": "url".description(optional): up to 500 characters.enabled_events(optional): any ofenrichment.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}: changeurl,description(sendnullto clear it) orenabled_events. Send only the fields you want to change.DELETE /v1/webhook_endpoints/{id}: remove the endpoint and its delivery log. Returns204.POST /v1/webhook_endpoints/{id}/test: send a test event, see above.POST /v1/webhook_endpoints/{id}/disableand/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 forRetry-After.