LOOMAL
How to

How to receive
inbound email via webhook in Node.js.

Let your agent act on incoming mail the moment it arrives — no polling, no IMAP, just an HTTP endpoint Loomal calls when mail lands.

Polling mail.list_messages works but it's wasteful. For agents that respond to inbound email in near-real-time, configure a webhook: Loomal POSTs to your endpoint the moment a message arrives at the agent's address.

This recipe uses Express, but the shape applies to any HTTP framework. The key operations are: validate the signature, parse the payload, hand the extractedText to your agent.

1. Configure the webhook in the Loomal console

In the Loomal console, go to your identity's Webhooks tab. Add a webhook for the message.received event pointing at your publicly reachable endpoint (use ngrok or tunnel for local dev).

Copy the webhook signing secret; you'll need it to verify that incoming requests are actually from Loomal and not a forged POST.

2. Build the Express endpoint

The payload shape: messageId, threadId, from, subject, extractedText (quotes and signature stripped), and labels. Verify the signature first — ignore any request that doesn't have a valid signature.

webhook.ts
import express from "express";
import crypto from "node:crypto";

const app = express();

app.post(
  "/webhooks/loomal",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("X-Loomal-Signature") ?? "";
    const expected = crypto
      .createHmac("sha256", process.env.LOOMAL_WEBHOOK_SECRET!)
      .update(req.body)
      .digest("hex");
    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body.toString()) as {
      event: "message.received";
      messageId: string;
      threadId: string;
      from: string;
      subject: string;
      extractedText: string;
      labels: string[];
    };

    await handleIncoming(event);
    res.status(200).send("ok");
  },
);

app.listen(3000);

3. Hand the message to your agent

The webhook handler should be fast — acknowledge within a few seconds. For anything beyond a trivial reply, enqueue a job and return 200; a background worker calls your agent and sends the reply via mail.reply.

handler.ts
async function handleIncoming(event: {
  messageId: string;
  threadId: string;
  from: string;
  extractedText: string;
}) {
  const reply = await agent.generate(
    `Reply to this support email from ${event.from}:\n${event.extractedText}`,
  );
  await fetch(
    `https://api.loomal.ai/v0/messages/${event.messageId}/reply`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.LOOMAL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ text: reply.text }),
    },
  );
}

4. Retry semantics

Loomal retries non-2xx responses with exponential backoff up to several hours. Make your handler idempotent — keyed on messageId — so replays don't double-send replies. A small Redis set or database table keyed on messageId is enough.

FAQ

Can I process attachments in the webhook?

The payload includes attachment metadata. Fetch the content via GET /v0/messages/:messageId/attachments/:attachmentId using your API key. This keeps payloads small and lets you skip attachments you don't need.

What about polling instead of webhooks?

Polling mail.list_messages with labels=unread works fine for low-volume agents. Webhook is better for latency-sensitive workloads and for avoiding wasted API calls at scale.

How do I test this locally?

Use ngrok or a similar tunnel to expose your local port. Configure the webhook URL to the tunnel's HTTPS endpoint; all requests flow through normally.

Give your agent its own identity.

Free tier, 30-second setup.

Last updated: 2026-04-15