Build a webhook receiver

A signature-verified endpoint that rejects forged and replayed deliveries, 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 and timestamp, deduplicate by event ID, respond fast, and hand the work to a background job.

This page is a complete, deployable endpoint in Node, Python, and Go. Each one verifies deliveries exactly as Verify the signature describes: the signature covers the timestamp, the event ID, and the body, so a captured delivery cannot be replayed with a new timestamp or event ID.

Set up the subscription

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

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

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.DAIRO_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Set DAIRO_WEBHOOK_SECRET to the webhook's signing secret");
const TOLERANCE_SECONDS = 300;
const EVENT_ID = /^evt_[0-9a-f]{32}$/;
const TIMESTAMP = /^[0-9]{1,12}$/;
const seen = new Set(); // use your database or Redis in production

// Returns the verified event ID, or null when the delivery must be rejected.
function verifyDairoWebhook(rawBody, headers, secret, nowSeconds = Date.now() / 1000) {
  // An unset or empty secret would make the key a public constant: refuse it.
  if (typeof secret !== "string" || secret === "") return null;
  const timestamp = headers["x-dairo-timestamp"] ?? "";
  const eventId = headers["x-dairo-event-id"] ?? "";
  if (!TIMESTAMP.test(timestamp) || !EVENT_ID.test(eventId)) return null;
  if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return null;

  const key = crypto.createHash("sha256").update(secret).digest("hex");
  const expected = Buffer.from(
    "v2=" +
      crypto.createHmac("sha256", key).update(`${timestamp}.${eventId}.`).update(rawBody).digest("hex"),
  );
  for (const entry of (headers["x-dairo-signature"] ?? "").split(" ")) {
    const candidate = Buffer.from(entry);
    if (candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)) {
      return eventId;
    }
  }
  return null;
}

const app = express();

// The raw body is required: the signature covers the exact bytes Dairo sent.
app.post("/dairo/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const eventId = verifyDairoWebhook(req.body, req.headers, SECRET);
  if (!eventId) return res.status(401).send("invalid or stale delivery");

  if (seen.has(eventId)) return res.sendStatus(200); // already processed
  seen.add(eventId);

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

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

app.listen(3000);

The seen set above is in memory, so it forgets across restarts and doesn't deduplicate across instances. In production, record each processed event ID in a durable, shared store — a unique key in your database, or Redis — and keep it at least as long as you might receive a retry, redrive, or replay of that event.

Test it

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

dairo send \
  --inbox-id inbox_123 \
  --to you@example.com \
  --subject "Webhook test" \
  --text "Trigger an event."

The send fires message.delivered once the receiving server accepts it. Send to one of your own inbox addresses to also see message.received. A forged, altered, or stale delivery gets a 401; a duplicate event ID is a no-op 200.

Next steps

On this page