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.
Before you start
- You need a registered endpoint. Nothing is delivered until one exists,
and registration is not on the public
/signal-api/v1surface — it is a dashboard action, because it mints a secret. Go to Exports & API → Webhooks → Add endpoint, or ask the assistant ("register a webhook at https://…"). - You need its signing secret (
whsec_…), returned at registration and shown exactly once. Store it asSIGNAL_WEBHOOK_SECRET; both examples below read it from there. Lose it and you rotate rather than recover it. - You need the pixel live, or nothing will ever be identified to send. The quickstart covers install and verification.
If you are reading this page first, registration is the one step you cannot do from code.
{
"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": "dana@acme.com", "name": null, "title": null },
"resolution": { "source": "liveintent", "confidence": 95 },
"identified_at": "2026-09-11T22:14:07+00:00"
}
}
What is actually populated. The example above is not aspirational — it is the shape you will receive, including the nulls. Write your mapper against this, not against the field list.
| Field | Always present? | In practice |
|---|---|---|
data.person | the object, yes | never absent — but see the fields below |
data.person.email | yes | the identity. A visitor without one is never created, so you can key on it. |
data.person.name | no — usually null | populated for about 2% of visitors today |
data.person.title | no — usually null | same |
data.company.domain | yes | personal is a real value: consumer mailboxes roll up under one sentinel. Filter it out of anything B2B. |
data.company.name | no | fall back to the domain |
data.company.firmographics | the key, yes | always {} today. Firmographic enrichment is not wired; treat a non-empty object as a future addition, never as a precondition. |
data.resolution.source | yes | "liveintent" is the only value currently emitted |
data.resolution.confidence | yes | an integer 0–100, not a string. 95 for every row today, so do not build a threshold you cannot test. |
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…
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 ~20.5h: 2m, 4m, 8m, 16m, 32m, 64m, 128m, 256m, then 6h before each remaining attempt (the backoff doubles up to a 6h cap, it does not stop there) |
| 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
event = await request.json()
# Endpoints receive EVERY event type — there is no per-endpoint filter.
# Branch before you touch `data`, or a test ping (or an event type added
# after you wrote this) gets processed as a visitor.
if event.get("type") != "signal.visitor_identified":
return {"ok": True} # acknowledge and ignore
enqueue(event) # 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");
// Length-check BEFORE the compare. timingSafeEqual throws RangeError on
// buffers of different sizes, and the signature is attacker-supplied — so
// `v1=deadbeef` on a guessable URL would crash the handler with a 500
// instead of rejecting it. Python's compare_digest is length-safe; this
// is the one place the two examples genuinely differ.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
res.json({ ok: true }); // acknowledge first
// Every event type arrives here. Branch before reading `data`.
if (event.type !== "signal.visitor_identified") return;
enqueue(event, 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. From Exports & API → Webhooks, or by asking the
assistant. It fires a signal.test event at your endpoint — same signature
scheme, harmless payload — so you can confirm your verification code works
before real data depends on it. Like registration, this is a dashboard action
and not part of the public /signal-api/v1 surface.
It is also the first thing your handler will receive, which is exactly why it
must branch on type: a handler that assumes every delivery is a visitor will
read data.person.email off the test ping and fail on its first contact with
the system.
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.
Markdown source: /developers/workflows/receive-identified-visitors-by-webhook.md