Webhooks

Get told when mail arrives instead of polling. HavitoMail sends a signed message.received event to your URL — the natural trigger for an AI agent.

Register a webhook

Request
curl -X POST https://havitomail.com/api/v1/webhooks \
  -H "Authorization: Bearer $HAVITO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://agent.acme.com/havito-webhook", "inbox_id": "8f2c1e0a-…"}'

Leave out inbox_id to receive events for every inbox on the account. The response includes a secret, shown once — store it; you need it to verify signatures. List webhooks with GET /webhooks and remove one with DELETE /webhooks/{id}.

Event payload

Each new message produces one POST with a JSON body:

message.received
{
  "event": "message.received",
  "inbox_id": "8f2c1e0a-5b7d-4c11-9a3e-2d6f0b9c7e41",
  "inbox": "support-agent@acme.com",
  "message": {
    "uid": 42,
    "message_id": "<CAF3x9k2@mail.example.com>",
    "from": { "name": "Sam Lee", "address": "sam@example.com" },
    "to": [{ "name": "", "address": "support-agent@acme.com" }],
    "subject": "Where is my refund?",
    "date": "2026-10-10T09:14:03.000Z",
    "text": "Hi, I returned the jacket last week…",
    "attachments": []
  }
}

The payload carries the plain-text body. Fetch the full message (HTML, cc, attachments) with GET /inboxes/{inbox_id}/messages/{uid} when you need it.

Headers

  • X-Havito-Timestamp — Unix time (seconds) when the event was signed.
  • X-Havito-Signature — sha256= followed by the hex HMAC-SHA256 of {timestamp}.{raw request body}, keyed with your webhook secret.

Verify in Node.js

Express
// Express — verify X-Havito-Signature before trusting the payload
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.HAVITO_WEBHOOK_SECRET;

app.post("/havito-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("X-Havito-Timestamp");
  const sig = req.header("X-Havito-Signature") || "";
  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.${req.body.toString("utf8")}`)
    .digest("hex");

  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // 5-minute window
  if (!ok || !fresh) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // hand event.message to your agent (queue it — respond fast)
  res.status(200).end();
});

Verify in Python

Flask
# Flask — verify X-Havito-Signature before trusting the payload
import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["HAVITO_WEBHOOK_SECRET"].encode()

@app.post("/havito-webhook")
def havito_webhook():
    ts = request.headers.get("X-Havito-Timestamp", "")
    sig = request.headers.get("X-Havito-Signature", "")
    raw = request.get_data()  # raw bytes, before JSON parsing
    expected = "sha256=" + hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected) or abs(time.time() - int(ts or 0)) > 300:
        abort(401)
    event = request.get_json()
    # hand event["message"] to your agent (queue it — respond fast)
    return "", 200
Always compute the HMAC over the raw body bytes, before any JSON parsing — re-serialising the JSON changes whitespace and breaks the signature. Reject events whose timestamp is more than a few minutes old to stop replays.

Delivery

  • Events are delivered within about 30 seconds of a message arriving (new mail is detected by polling the mailbox).
  • Respond with any 2xx status within 10 seconds — queue the work and let your agent process it asynchronously.
  • Each event is attempted once; failed deliveries are not retried, so keep the polling fallback below. A webhook is switched off after 50 failures in a row (re-create it once your endpoint is healthy).
  • Only new mail in INBOX triggers events, starting from when the webhook is created — existing mail is never replayed. Bodies over 20,000 characters are cut (text_truncated: true).
  • Design your handler to be idempotent: use inbox_id + message.uid as the key, and ignore an event you have already processed.
  • For a belt-and-braces setup, also poll GET /inboxes/{id}/messages?unread=true occasionally and process anything the webhook path missed.

Next: wire the event into your agent with the reply endpoint, or let Claude handle it via the MCP server.