Some agent work starts when mail arrives rather than when an agent asks for it: a customer replies, a supplier sends an invoice, a monitoring service raises an alert. Holding a long-poll open per inbox to catch those stops making sense once you have more than a handful of inboxes. A webhook turns each arrival into an HTTP POST to your service, which puts it on a queue so a worker can run the agent.

This guide builds that pipeline against how Lumbox webhooks behave in production, including three things that are easy to miss: the payload is a summary rather than the full email, deliveries are not retried, and the receiver has to deduplicate.

Register the endpoint

curl -X POST https://api.lumbox.co/v1/webhooks \
  -H "X-API-Key: $LUMBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://agents.example.com/hooks/lumbox", "events": ["email.received"]}'

The response contains id, url, events, active, created_at and a secret starting with whsec_. The secret is shown once, so store it with your other credentials before doing anything else. The URL must use HTTPS and cannot point at localhost or a private IP address, so during local development you need a tunnel that gives your machine a public HTTPS address.

Webhooks belong to the organisation, not to an inbox. One registration receives email.received for every inbox in the org, so your service routes on data.inbox_id. Registration needs an org-level key, since project-scoped keys are blocked from the webhook routes. To check wiring before real mail flows, POST /v1/webhooks/:id/test sends a signed test event with a sample payload and returns the status code your endpoint answered with.

What arrives

POST /hooks/lumbox HTTP/1.1
Content-Type: application/json
X-Lumbox-Event: email.received
X-Lumbox-Signature: 3f1c9a...(64 hex characters)

{
  "event": "email.received",
  "timestamp": "2026-09-29T10:14:03.512Z",
  "data": {
    "email_id": "eml_4kQ9xZr2LmT7vB1nW8yH",
    "inbox_id": "inb_7Hn2Kp9QwX4rT1mZ6cVb",
    "from": "billing@supplier.example",
    "subject": "Invoice INV-2291 for September",
    "category": "transactional",
    "otp_codes": [],
    "summary": "Invoice INV-2291 for September from billing",
    "spam_score": 0.4,
    "is_spam": false,
    "spam_flags": {"spf": "pass"}
  }
}

A few details shape the receiver. from is a bare address here, while the full email object uses {name, address}. category and otp_codes come from the rule-based parser that runs on arrival. Mail flagged as spam is delivered too, with is_spam: true, so filtering is your call. The body text, links and attachments are not in the payload; fetch them with GET /v1/emails/:id when a workflow needs them, and note that this call marks the email read.

The receiver: verify, deduplicate, enqueue, answer

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

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

function validSignature(raw, header) {
  if (typeof header !== "string" || !/^[0-9a-f]{64}$/.test(header)) return false;
  const expected = createHmac("sha256", SECRET).update(raw).digest();
  return timingSafeEqual(expected, Buffer.from(header, "hex"));
}

app.post("/hooks/lumbox", express.raw({ type: "application/json" }), async (req, res) => {
  if (!validSignature(req.body, req.headers["x-lumbox-signature"])) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event !== "email.received") return res.sendStatus(204);

  if (await markSeen(event.data.email_id)) await queue.enqueue(event.data);
  res.sendStatus(200);
});

express.raw hands the handler a Buffer, so the HMAC runs over the exact bytes Lumbox signed. Parsing to JSON first and signing JSON.stringify of the result can produce different bytes and a signature that never matches. The format check runs before timingSafeEqual because that function throws on inputs of different lengths.

Lumbox waits 10 seconds for your response, so the handler does nothing slow. It records the email id, enqueues, and answers. markSeen returns true only the first time it sees an id, and a unique constraint makes that safe under concurrency:

CREATE TABLE lumbox_seen (email_id text PRIMARY KEY, seen_at timestamptz DEFAULT now());

-- markSeen: true when this insert created the row
INSERT INTO lumbox_seen (email_id) VALUES ($1) ON CONFLICT DO NOTHING;

The same table catches replayed requests. Someone who captures a signed delivery can resend it byte for byte and the signature still verifies, but the second copy finds its email_id already recorded. The signed body also carries timestamp, so rejecting deliveries older than a few minutes is a cheap extra check.

The worker: fetch, route, run the agent

const API = "https://api.lumbox.co/v1";
const headers = { "X-API-Key": process.env.LUMBOX_API_KEY };

const workflows = {
  transactional: runInvoiceAgent,
  support: runSupportAgent,
  security: alertOnCall,
};

async function handle(note) {
  if (note.is_spam) return;
  const workflow = workflows[note.category];
  if (!workflow) return;

  const email = await (await fetch(`${API}/emails/${note.email_id}`, { headers })).json();
  const outcome = await workflow({ inboxId: note.inbox_id, subject: email.subject, text: email.stripped_text });

  await fetch(`${API}/emails/${note.email_id}/labels`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ add: [`agent:${outcome}`] }),
  });
}

The agent gets stripped_text, which has quoted replies and signatures removed and arrives wrapped in a fence of = characters marked as untrusted email content. Keep that framing when you build the prompt, because the text was written by whoever sent the email. The label call records what happened on the email itself, and GET /v1/inboxes/:id/emails?label=agent:paid finds those messages later. Routing on category is only as good as the keyword rules behind it; the triage post covers where those rules misfire.

What a webhook does not guarantee

Each delivery is attempted once. If your endpoint is down, mid-deploy, or slower than 10 seconds, that notification is lost and Lumbox does not send it again. The email itself is unaffected: it was parsed and stored before the webhook fired, so only the notification is missing. GET /v1/webhooks/:id/deliveries lists each attempt with its status code, latency and error, which is where to look first when a workflow seems to have skipped a message.

Two emails that arrive close together can also be delivered in either order. Treat delivery as at-least-zero and in no particular order, and close the gap with a sweep that runs every minute or two:

async function sweep(since) {
  const url = `${API}/emails?since=${encodeURIComponent(since)}&limit=100&include_spam=true`;
  const { data } = await (await fetch(url, { headers })).json();
  for (const email of data.reverse()) {
    if (["outbound", "welcome"].includes(email.parsed.category)) continue;
    if (await markSeen(email.id)) {
      await queue.enqueue({
        email_id: email.id,
        inbox_id: email.inbox_id,
        category: email.parsed.category,
        is_spam: email.is_spam,
      });
    }
  }
  if (data.length === 100) console.warn(`sweep window since ${since} is full, shorten the interval`);
}

GET /v1/emails searches every inbox in the org, newest first, capped at 100 results, so one request covers the whole account. The sweep skips outbound (copies of mail you sent) and welcome (the message Lumbox seeds into an organisation's first inbox), because neither triggers a webhook. Anything the webhook already delivered is dropped by markSeen, so the worker never runs twice for one email.

To test the whole path, create an inbox, send it a message from your own mail account, and watch three places: your receiver's log, the queue, and GET /v1/webhooks/:id/deliveries. Then stop the receiver, send a second message, start it again, and confirm that within one sweep interval the worker runs for the email whose webhook never arrived.

For an agent that is waiting on one specific message as part of a task, a long-poll is still simpler; webhooks vs polling compares the two. If you want the reasoning behind the signature scheme, the post on webhook signing covers HMAC and RFC 9421 side by side. The request and response fields for every webhook route are in the webhooks docs.