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

```http
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…
```

```json
{
  "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:

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)

```python
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)

```js
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](./export-and-keep-in-sync.md).
