> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dairo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a webhook receiver

> A signature-verified endpoint that verifies each delivery, rejects replays, deduplicates by event ID, and processes Dairo events asynchronously.

A webhook receiver is the endpoint Dairo calls when something happens on your account. A production receiver does four things on every delivery: verify the signature, reject replays, deduplicate by event ID, and hand the work to a background job.

This page is a complete, deployable endpoint in Node, Python, and Go. The samples verify and replay-protect each delivery the way the Webhooks reference describes.

## Set up the subscription

Point a subscription at your endpoint and store the signing secret it returns.

```bash theme={null}
dairo webhook create \
  --url https://yourapp.com/dairo/webhook \
  --event message.received \
  --event message.delivered \
  --event message.bounced \
  --event message.complained
# Store the printed signing secret as DAIRO_WEBHOOK_SECRET.
```

## The endpoint

<CodeGroup>
  ```ts title="Node.js (Express)" theme={null}
  import express from "express";
  import crypto from "node:crypto";

  const app = express();
  const SECRET = process.env.DAIRO_WEBHOOK_SECRET!;
  const seen = new Set<string>(); // use Redis/DB in production

  // Raw body is required — verify on the exact bytes Dairo signed.
  app.use("/dairo/webhook", express.raw({ type: "application/json" }));

  app.post("/dairo/webhook", async (req, res) => {
    // 1. Verify the signature. The HMAC key is the SHA-256 hex digest of your
    //    signing secret (Dairo stores only that hash), NOT the raw secret itself.
    const signature = req.header("X-Dairo-Signature") ?? "";
    const signingKey = crypto.createHash("sha256").update(SECRET).digest("hex");
    const expected =
      "v1=" + crypto.createHmac("sha256", signingKey).update(req.body).digest("hex");
    const valid =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!valid) return res.status(401).send("invalid signature");

    // 2. Reject stale deliveries (replay protection).
    const timestamp = Number(req.header("X-Dairo-Timestamp"));
    if (!Number.isFinite(timestamp) ||
        Math.abs(Date.now() / 1000 - timestamp) > 300) {
      return res.status(401).send("stale timestamp");
    }

    // 3. Deduplicate by event ID.
    const eventId = req.header("X-Dairo-Event-Id")!;
    if (seen.has(eventId)) return res.sendStatus(200);
    seen.add(eventId);

    // 4. Respond fast; 5. process async.
    const event = JSON.parse(req.body.toString("utf8"));
    queueMicrotask(() => handleEvent(event).catch(console.error));
    res.sendStatus(200);
  });

  async function handleEvent(event: any) {
    switch (event.type) {
      case "message.received":
        // Fetch the body only now, when you actually need it.
        // const msg = await dairo.messages.get(event.data.messageId);
        break;
      case "message.bounced":
        // flag event.data.recipient as a bad address
        break;
      case "message.complained":
        // suppress event.data.recipient from future sends
        break;
    }
  }

  app.listen(3000);
  ```

  ```python title="Python (FastAPI)" theme={null}
  import hmac, hashlib, os, time, json
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()
  SECRET = os.environ["DAIRO_WEBHOOK_SECRET"].encode()
  seen: set[str] = set()  # use Redis/DB in production

  @app.post("/dairo/webhook")
  async def dairo_webhook(request: Request):
      raw = await request.body()  # raw bytes — required for verification

      # 1. Verify the signature. The HMAC key is the SHA-256 hex digest of your
      #    signing secret (Dairo stores only that hash), NOT the raw secret itself.
      signing_key = hashlib.sha256(SECRET).hexdigest().encode()
      expected = "v1=" + hmac.new(signing_key, raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(request.headers.get("X-Dairo-Signature", ""), expected):
          raise HTTPException(status_code=401, detail="invalid signature")

      # 2. Reject stale deliveries (replay protection).
      try:
          timestamp = int(request.headers["X-Dairo-Timestamp"])
      except (KeyError, ValueError):
          raise HTTPException(status_code=401, detail="missing timestamp")
      if abs(time.time() - timestamp) > 300:
          raise HTTPException(status_code=401, detail="stale timestamp")

      # 3. Deduplicate by event ID.
      event_id = request.headers["X-Dairo-Event-Id"]
      if event_id in seen:
          return {"ok": True}
      seen.add(event_id)

      # 4 + 5: enqueue for async processing, respond fast.
      event = json.loads(raw)
      # await queue.enqueue(event)
      return {"ok": True}
  ```

  ```go title="Go (net/http)" theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "math"
      "net/http"
      "os"
      "strconv"
      "sync"
      "time"
  )

  var (
      secret = []byte(os.Getenv("DAIRO_WEBHOOK_SECRET"))
      mu     sync.Mutex
      seen   = map[string]bool{} // use a real store in production
  )

  func webhook(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)

      // 1. Verify. The HMAC key is the SHA-256 hex digest of your signing secret
      //    (Dairo stores only that hash), NOT the raw secret itself.
      sum := sha256.Sum256(secret)
      signingKey := []byte(hex.EncodeToString(sum[:]))
      mac := hmac.New(sha256.New, signingKey)
      mac.Write(body)
      expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
      if !hmac.Equal([]byte(r.Header.Get("X-Dairo-Signature")), []byte(expected)) {
          http.Error(w, "invalid signature", http.StatusUnauthorized)
          return
      }

      // 2. Reject stale deliveries (replay protection).
      ts, err := strconv.ParseInt(r.Header.Get("X-Dairo-Timestamp"), 10, 64)
      if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
          http.Error(w, "stale timestamp", http.StatusUnauthorized)
          return
      }

      // 3. Deduplicate.
      id := r.Header.Get("X-Dairo-Event-Id")
      mu.Lock()
      dup := seen[id]
      seen[id] = true
      mu.Unlock()
      if dup {
          w.WriteHeader(http.StatusOK)
          return
      }

      // 4 + 5: decode, enqueue async, respond fast.
      var event map[string]any
      _ = json.Unmarshal(body, &event)
      w.WriteHeader(http.StatusOK)
  }

  func main() {
      http.HandleFunc("/dairo/webhook", webhook)
      http.ListenAndServe(":3000", nil)
  }
  ```
</CodeGroup>

<Note>
  The `seen` set above is in-memory, so it forgets across restarts and won't deduplicate across multiple instances. In production, track processed `X-Dairo-Event-Id` values in a durable, shared store such as Redis or your database.
</Note>

## Test it

Send a message to trigger events, then watch your endpoint receive them.

```bash theme={null}
dairo send \
  --inbox-id inbox_123 \
  --to you@example.com \
  --subject "Webhook test" \
  --text "Trigger an event."
```

A send fires `message.sent`, then `message.delivered` once the receiving server accepts it. Send to one of your own inbox addresses to also see `message.received`. A failed signature check returns `401`; a duplicate event ID is a no-op `200`.

## Next steps

* [Webhooks](/webhooks/webhooks) — every event type, header, and payload.
* [Build a support inbox agent](/examples/support-inbox-agent) — turn received events into automated replies.
