On this page

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-Deliverystable 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:

  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

    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.

This page is maintained next to the service it describes and rendered here. Read it as markdown if you are pointing an agent at it.