# The Mailchimp Transactional (Mandrill) alternative for AI agents

> Source: <https://mailkite.dev/blog/mandrill-for-ai-agents/>
> Published: 2026-07-04 00:00:00+00:00

# The Mailchimp Transactional (Mandrill) alternative for AI agents

Mandrill can receive email, but it's gated behind a paid Mailchimp marketing plan and hands you inbound as a batched mandrill_events array with no auth verdict and no agent loop. MailKite (which we build) gives an agent a real scoped inbox delivered as one parsed JSON event with a receive→reply loop. For developers wiring an autonomous agent to an inbox.

So the first surprise isn’t technical: to point an agent at a Mandrill inbox you first need a paid Mailchimp *marketing* account (Standard or Premium), and then Transactional as an add-on on top. Mandrill hasn’t been standalone since 2016. Everything below is the honest version of what that buys an agent builder, where Mandrill genuinely holds up, and the ~20 lines that are the entire MailKite side.

Everything below is a repo you can run while you read. Clone [ demo-mandrill-ai-agent](https://github.com/mailkite/demo-mandrill-ai-agent) and

`npm start`

, or [open it in StackBlitz](https://stackblitz.com/github/mailkite/demo-mandrill-ai-agent?file=server.mjs)(real Node in a browser tab, no account) where it runs on load. You don’t need a domain to see the loop run: a webhook normally needs a public URL, so

`npm start`

boots the server and self-fires a correctly *signed*

`email.received`

event at its own localhost — the whole receive→verify→think→reply loop runs in one command (the reply is a dry-run, and `managed-route.mjs`

dry-runs the hosted path too). It also ships the Mandrill path next to it in `mandrill-contrast/`

, so every contrast here is something you can diff and test, not take on faith. A domain comes in only when you want *real*inbound email to arrive.

Here’s the whole MailKite side: email in, verify the signature, hand the body to your model, reply through the same client. This is the heart of `server.mjs`

in that repo — the repo wraps it in a dry-run harness and a stub agent so it runs with no key and no LLM, but the loop is exactly this, and it runs as pasted on Node 18+ (`npm install mailkite express`

):

``` python
import express from "express";
import { MailKite } from "mailkite";

const app = express();
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

app.use("/hooks/agent", express.raw({ type: "application/json" }));

app.post("/hooks/agent", async (req, res) => {
  // signature check, replay window, constant-time compare — one call
  if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);

  const event = JSON.parse(req.body);
  if (event.type !== "email.received") return;

  // Body is untrusted INPUT, never instructions. Use auth to decide trust.
  const answer = await runAgent({
    task: event.text,
    from: event.from.address,
    trusted: event.auth.spf === "pass" && event.auth.dmarc === "pass",
  });

  await mk.send({
    from: event.to[0].address,   // reply from the address it was sent to
    to: event.from.address,
    subject: `Re: ${event.subject}`,
    inReplyTo: event.id,         // threads the reply
    html: answer.html,
  });
});

app.listen(3000);
```

That’s the receive→think→reply loop, whole. `mk.send()`

returns `{ id, status }`

, and `npm start`

boots the server and self-fires the exact payload MailKite’s delivery worker sends, so in one command you watch the agent read the task, take its trust verdict straight off `event.auth`

, and dry-run the reply — no account or LLM required. The same handler shape exists for Python, Ruby, Go, PHP, and Java: see the [receiving docs](/docs/receiving) and [sending docs](/docs/sending). The rest of this post is the honest version of the diagram: where Mandrill genuinely fits an agent, what its inbound path asks you to build, and what the parsed payload actually contains.

## Where Mandrill wins for agents, honestly

Mandrill is not a toy, and if you’re already living in Mailchimp it can be the pragmatic call:

I won’t pretend Mandrill can’t receive email. It can, and has for years. The friction for an *agent* is different: what it costs to get in the door, and how much unpacking sits between “an email arrived” and “my model can act on it.”

## What Mandrill asks of an agent builder

Two things, before any model runs.

First, the account. Transactional is an add-on to a paid Mailchimp Standard ($20/mo) or Premium plan; Free and Essentials plans can’t enable it. Transactional itself is block-based: a block is 25,000 emails starting at ~$20, cheaper per block at volume. So an agent that just needs to read a few verification emails still starts around $40/month of marketing-suite spend before it sends its first reply.

Second, the inbound shape. Mandrill doesn’t hand you one clean message. It POSTs a form-encoded body whose `mandrill_events`

field is a JSON *string* holding an array of events, and you verify the `X-Mandrill-Signature`

by rebuilding the signed string yourself: the exact webhook URL, then every POST key and value concatenated in sorted key order, HMAC-SHA1’d with your webhook key, base64-encoded. Here’s that handler, honestly, in Mandrill’s own idiom — it’s [ mandrill-contrast/handler.mjs](https://github.com/mailkite/demo-mandrill-ai-agent/blob/main/mandrill-contrast/handler.mjs) in the same repo, sitting right next to the MailKite

`server.mjs`

above so you can put them side by side:

```
// Mandrill inbound: form-encoded POST, mandrill_events is a JSON string you unpack
import express from "express";
import crypto from "node:crypto";

const app = express();
app.use(express.urlencoded({ extended: false }));

const WEBHOOK_KEY = process.env.MANDRILL_WEBHOOK_KEY;
const WEBHOOK_URL = "https://myapp.ai/hooks/mandrill"; // must match what you registered, exactly

function verify(body, signature) {
  let signed = WEBHOOK_URL;
  for (const key of Object.keys(body).sort()) signed += key + body[key]; // sorted key+value, no delimiter
  const expected = crypto.createHmac("sha1", WEBHOOK_KEY).update(signed).digest("base64");
  return expected === signature; // Mandrill sends base64 of the BINARY hmac, not hex
}

app.post("/hooks/mandrill", (req, res) => {
  if (!verify(req.body, req.headers["x-mandrill-signature"])) return res.sendStatus(401);

  const events = JSON.parse(req.body.mandrill_events); // a JSON array inside a form field
  for (const ev of events) {
    if (ev.event !== "inbound") continue;
    const m = ev.msg;
    // assemble your own trust verdict from separate fields
    const trusted = m.spf?.result === "pass" && m.dkim?.signed && m.dkim?.valid;
    runAgent({ task: m.text, from: m.from_email, trusted });
    // …then a SEPARATE Messages/send API call to reply. No inReplyTo helper; you set headers.
  }
  res.sendStatus(200);
});

app.listen(3000);
```

None of this is hard. But it’s a batch you iterate, a signature you rebuild from sorted POST pairs, and a trust verdict you compose by hand, all before the interesting part. Then replying is a second, unrelated API call where threading is on you. The repo pins both costs so they aren’t just claims — `npm test`

shows inbound arriving as a batch array you iterate, and `trustFromResults()`

returning no DMARC verdict at all, the field the MailKite path reads straight off `event.auth`

.

## The comparison, for an agent

| Mandrill (Mailchimp Transactional) | MailKite | |
|---|---|---|
| To start | Paid Mailchimp Standard+ plan, then Transactional add-on | DNS-verify a domain (SPF+DKIM to send, MX to receive) |
| Pricing floor | ~$20/mo plan + ~$20 per 25k-email block | Free tier: 3,000 messages/mo (in + out), no per-domain fee |
| Inbound shape | `mandrill_events[]` array in a form-encoded POST | One parsed `email.received` JSON event |
| Auth verdict | Separate `spf` /`dkim` /`spam_report` you combine | One `auth` block: `{ spf, dkim, dmarc, spam }` |
| Signature check | Rebuild HMAC-SHA1 over sorted POST pairs yourself | `verifyWebhook(sig, raw, secret)` — one call |
| Reply / threading | Separate send call; you set threading headers | `mk.send({ inReplyTo: event.id })` threads it |
| Agent loop | None; you host the whole model loop | BYO loop, or a route with `action: 'agent'` |

The through-line: Mandrill *receives*, but every convenience an agent wants (a single message, a ready trust verdict, a threaded reply, a place to run the loop) is left for you to assemble, on top of a paid marketing plan you may not otherwise want.

## What actually hits your agent’s webhook

On MailKite the same inbound email arrives already decoded, one event, no array to unpack and no signature to rebuild by hand:

```
{
  "id": "msg_2Hk9…",
  "type": "email.received",
  "from": { "address": "ada@example.com" },
  "to": [{ "address": "agent@myapp.ai" }],
  "subject": "Re: invoice #1042",
  "text": "Looks good — approved!",
  "html": "<p>Looks good — approved!</p>",
  "threadId": "<a1b2c3@mail.example.com>",
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
  "attachments": [
    { "id": "msg_2Hk9…:0", "filename": "po.pdf", "contentType": "application/pdf",
      "size": 18213, "url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…" }
  ]
}
```

Don’t take the shape on faith — fire one and watch it come back, `auth`

block and all. Spoof the SPF/DKIM to `fail`

and see the verdict flip; that’s the field the Mandrill path makes you assemble from separate result strings, with no DMARC to read at all:

That `auth`

block is load-bearing for an agent. The email body is untrusted input, never instructions: `From:`

is trivially forged, so a naive loop that *obeys* what an email says is a prompt-injection target. Check `auth`

before you weight a sender, and bound what a fooled agent can even do. The full argument is in [agent inbox security by design](/blog/agent-inbox-security-by-design/); read it before you point any loop at real mail.

## Don’t want to host the loop?

MailKite, which we build, can run the agent for you. Give a route an `action`

of `agent`

and an `agentPrompt`

, and every matching inbound message runs the model loop on a durable queue, capped and recorded as a transcript you can drill into:

```
await mk.createRoute({
  match: "support@myapp.ai",
  action: "agent",
  agentPrompt: "Answer billing questions from the docs. Escalate anything else to humans@myapp.ai.",
});
```

Same parsed inbound edge either way, and both are in the repo: bring-your-own is [ server.mjs](https://github.com/mailkite/demo-mandrill-ai-agent/blob/main/server.mjs) (the code at the top), and the hosted path is

[, which dry-runs the route creation with no key. Bring-your-own wins when the agent’s brain is your code; the built-in route wins when the job is “read this mail, answer or escalate” and you’d rather not run infrastructure. Either way,](https://github.com/mailkite/demo-mandrill-ai-agent/blob/main/managed-route.mjs)

`managed-route.mjs`

`mandrill-contrast/handler.mjs`

is the same job the Mandrill way, and `npm test`

runs both sides so you can diff the shapes.## FAQ

**Can Mandrill (Mailchimp Transactional) receive inbound email?**
Yes. You add an inbound domain, point your MX records at Mailchimp Transactional’s servers, and create mailbox routes that POST matching mail to your webhook as a `mandrill_events`

array. It receives fine; the work is unpacking the batched array, verifying the `X-Mandrill-Signature`

, and composing a trust verdict from separate `spf`

/`dkim`

/`spam_report`

fields yourself.

**Do I need a paid Mailchimp account to use Mandrill?**
Yes. Since 2016 Mandrill isn’t standalone: it’s an add-on that requires a paid Mailchimp Standard or Premium marketing plan, then transactional blocks (25,000 emails per block) on top. MailKite has no marketing-plan prerequisite and a free tier of 3,000 messages a month, inbound and outbound.

**How is the Mandrill inbound webhook signed?**
With an `X-Mandrill-Signature`

header: base64 of an HMAC-SHA1, keyed by your webhook’s auth key, over your exact webhook URL followed by every POST key and value concatenated in sorted key order. It must be the binary HMAC base64-encoded, not hex. MailKite verifies its signature in one call: `MailKite.verifyWebhook(sig, rawBody, secret)`

.

**Can an agent read verification codes and reply on its own with MailKite?**
Yes. Inbound arrives as parsed JSON, your handler passes `event.text`

to your model, and the agent replies with `mk.send({ inReplyTo: event.id })`

from the address it was written to. No IMAP polling, no shared personal Gmail, no OAuth token refresh.

**Is MailKite cheaper than Mandrill for an agent?**
For a low-volume agent, almost always: MailKite starts free (3,000 messages/mo, in + out) with no per-domain fee, while Mandrill needs a paid Mailchimp plan (~~$20/mo) plus at least one transactional block (~~$20). At very high transactional send volume Mandrill’s block pricing gets competitive per message; the trade is the marketing plan and the inbound assembly you own.

If Mandrill has you standing up a marketing plan and unpacking a `mandrill_events`

array just so an agent can read one email, there’s a shorter path. Clone the [demo repo](https://github.com/mailkite/demo-mandrill-ai-agent) (or [run it in your browser](https://stackblitz.com/github/mailkite/demo-mandrill-ai-agent?file=server.mjs)), then [point a domain at MailKite](/docs/quickstart) and your agent’s next inbound email arrives as one parsed event with an auth verdict attached.

*Related: the pillar on giving your agent its own inbox and agent inbox security by design.*
