Receive identified visitors by webhook
The integration most people should build first. A webhook tells you the moment someone is identified, instead of you asking us every few minutes whether anything happened.
Everything below is the contract the service actually implements. Where a number appears, it is read from the code that enforces it.
What you get
When a visitor on your site is identified, we POST once per registered endpoint:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Geysera-Signal-Webhooks/1
X-Signal-Event: signal.visitor_identified
X-Signal-Delivery: 9766ec60-… ← dedup on this
X-Signal-Timestamp: 1789171234
X-Signal-Signature: t=1789171234,v1=3f9c…
{
"id": "9766ec60-…",
"type": "signal.visitor_identified",
"created": 1789171234,
"data": {
"visitor_id": "9766ec60-…",
"company": {
"domain": "acme.com",
"domain_canonical": "acme.com",
"name": "Acme Corp",
"intent_score": 72,
"classification": "lead",
"firmographics": { }
},
"person": { "email": "…", "name": "…", "title": "…" },
"resolution": { "source": "…", "confidence": "…" },
"identified_at": "2026-09-11T22:14:07+00:00"
}
}
The delivery contract
| Success | any 2xx |
| Timeout | 10 seconds — respond first, work later |
| Redirects | not followed. A 3xx is a failure. Register the final URL |
| Guarantee | at-least-once |
| Retries | 10 attempts over ~21h: 2m, 4m, 8m, 16m, 32m, 64m, 128m, 256m, then 6h |
| Dedup key | X-Signal-Delivery — stable across retries |
| Endpoint disabled | after 15 consecutive failures |
Two consequences worth internalising:
Respond fast, then do the work. The timeout is 10 seconds and it is measured on our side. If your handler enriches a CRM record before replying, a slow CRM turns into a retry storm. Acknowledge, enqueue, process.
You will occasionally see the same event twice. At-least-once is a promise
that you will not silently lose an event; it is not a promise of exactly-once,
which nothing delivering over a network can honestly offer. X-Signal-Delivery
does not change between attempts, so a single unique index on your side makes
duplicates harmless.
Verifying the signature
Never process an unverified webhook. The URL is guessable; the signature is not.
The value is t=<timestamp>,v1=<hex>, where the hex is
HMAC-SHA256(secret, "<timestamp>.<raw body>").
Two rules that cause most implementation bugs:
- Sign the raw bytes, not a re-serialised object.
json.loadsthenjson.dumpswill not round-trip to the same bytes and the signature will never match. - Compare in constant time.
==on a hex digest leaks timing.
Python (FastAPI)
import hashlib, hmac, os, time
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["SIGNAL_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 5 * 60
@app.post("/geysera/webhook")
async def receive(request: Request):
raw = await request.body() # RAW bytes, before parsing
header = request.headers.get("X-Signal-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, provided = parts.get("t", ""), parts.get("v1", "")
if not timestamp or not provided:
raise HTTPException(400, "malformed signature header")
# Reject stale timestamps, or a captured delivery can be replayed forever.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(400, "timestamp outside tolerance")
expected = hmac.new(
SECRET.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, provided):
raise HTTPException(401, "bad signature")
event_id = request.headers.get("X-Signal-Delivery")
if already_processed(event_id): # your unique index
return {"ok": True} # 2xx: do not make us retry
enqueue(await request.json()) # return fast, work later
return {"ok": True}
Node (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// express.raw, NOT express.json — you need the exact bytes we signed.
app.post("/geysera/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const parts = Object.fromEntries(
(req.get("X-Signal-Signature") || "").split(",").map(p => p.split("=", 2))
);
const { t, v1 } = parts;
if (!t || !v1) return res.status(400).end();
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end();
const expected = crypto
.createHmac("sha256", process.env.SIGNAL_WEBHOOK_SECRET)
.update(`${t}.`).update(req.body)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.status(401).end();
res.json({ ok: true }); // acknowledge first
enqueue(JSON.parse(req.body), req.get("X-Signal-Delivery"));
});
Choosing what your handler does
The temptation is to write straight into your CRM. Consider what happens when it is down: you return non-2xx, we retry for ~21 hours, and then stop. Your handler's availability becomes our delivery window.
Better shape:
receive → verify → dedupe → enqueue → 2xx
└─ your worker retries against the CRM
on your own schedule, forever if needed
That decouples our retry budget from your downstream's uptime, which is the whole reason the two systems should not be joined at the handler.
Testing before you go live
Send a test ping. The API can fire a signal.test event at your endpoint on
demand — same signature scheme, harmless payload. Use it to confirm your
verification code works before real data depends on it.
Test locally with a tunnel (ngrok or similar). We do not follow redirects, so register the tunnel's final HTTPS URL, not a shortener.
Deliberately fail once. Return a 500 on purpose and confirm you receive the
event again roughly two minutes later, with the same X-Signal-Delivery. If
you do not, your dedup is keyed on something unstable — the most common
integration bug in this list, and one you want to find with a test rather than
with a customer's data.
When not to use webhooks
If you need a full current picture — a nightly export, a report, a backfill — poll the REST API instead. Webhooks tell you what changed; they are a poor way to learn what is. See Export identified accounts.