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/v1 surface — 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 as SIGNAL_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.

FieldAlways present?In practice
data.personthe object, yesnever absent — but see the fields below
data.person.emailyesthe identity. A visitor without one is never created, so you can key on it.
data.person.nameno — usually nullpopulated for about 2% of visitors today
data.person.titleno — usually nullsame
data.company.domainyespersonal is a real value: consumer mailboxes roll up under one sentinel. Filter it out of anything B2B.
data.company.namenofall back to the domain
data.company.firmographicsthe key, yesalways {} today. Firmographic enrichment is not wired; treat a non-empty object as a future addition, never as a precondition.
data.resolution.sourceyes"liveintent" is the only value currently emitted
data.resolution.confidenceyesan 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

Successany 2xx
Timeout10 seconds — respond first, work later
Redirectsnot followed. A 3xx is a failure. Register the final URL
Guaranteeat-least-once
Retries10 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 keyX-Signal-Delivery — stable across retries
Endpoint disabledafter 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:

  1. Sign the raw bytes, not a re-serialised object. json.loads then json.dumps will not round-trip to the same bytes and the signature will never match.
  2. 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