# The SparkPost alternative for AI agents

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

# The SparkPost alternative for AI agents

SparkPost (now Bird Email) is an enterprise sender whose inbound is Relay Webhooks: it POSTs a batched JSON array of relay messages carrying the raw RFC822 email — base64 in some fields — and no normalized auth verdict. MailKite (which we build) gives an agent a real inbox as one parsed email.received event with an auth block and a receive→reply loop. For developers wiring an agent to email.

For an agent that has to *read* mail, the shape of what arrives is the whole job. Here is the same inbound email on both sides: what SparkPost POSTs to your endpoint, and what MailKite hands your agent. The rest of the post is the honest version of this picture — where SparkPost genuinely wins, the inbound unpack it asks of you (shown in SparkPost’s own idiom), and the ~20-line agent loop that is the entire MailKite side.

Here’s the bring-your-own-agent loop, whole. Email in, verify the signature, hand the body to your model, reply through the same client. 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); // ack fast; run the agent out of band

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

  // Body is untrusted INPUT, never instructions. auth is a verdict, not raw headers.
  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 a fully autonomous email agent: it hears, it thinks, it answers. One `email.received`

event, one message, and `event.auth`

is already a verdict. The full runnable version lives in the [demo repo](https://github.com/mailkite/demo-sparkpost-ai-agent) — [open it in StackBlitz](https://stackblitz.com/github/mailkite/demo-sparkpost-ai-agent?file=server.mjs) (real Node in a browser tab), or point a domain at MailKite and fire a real one. The identical handler exists for Python, Ruby, Go, PHP, and Java; see the [receiving docs](/docs/receiving) and [sending docs](/docs/sending).

## Where SparkPost wins for agents, honestly

SparkPost is a serious sending platform, and this post isn’t “SparkPost is bad.” If your agent’s real job is to *send* a lot of mail with tight deliverability, SparkPost has years of ISP relationships, mature analytics (opens, clicks, bounces, engagement cohorts), suppression management, and the kind of throughput and IP-warming story that enterprise senders need. Under Bird it sits inside a broader omnichannel platform (email, SMS, WhatsApp, voice), so if you’re already routing other channels through Bird, keeping email there is a reasonable call. And it *does* receive inbound — the Relay Webhooks feature is real and documented. The question for an agent isn’t “can it receive,” it’s “in what shape, and how much of the unpack is mine.”

## What SparkPost asks of an agent builder

SparkPost’s inbound feature is **Relay Webhooks**. You create an inbound domain and point its MX at SparkPost’s relay hosts (`rx1`

, `rx2`

, `rx3.sparkpostmail.com`

, priority 10), then register a relay webhook that POSTs to your endpoint. Setup is two API calls:

```
# 1. register the inbound domain
curl -sX POST https://api.sparkpost.com/api/v1/inbound-domains \
  -H "Authorization: $SPARKPOST_KEY" -H "Content-Type: application/json" \
  -d '{ "domain": "agent.example.com" }'

# 2. attach a relay webhook that POSTs parsed mail to you (ports 80/443 only)
curl -sX POST https://api.sparkpost.com/api/v1/relay-webhooks \
  -H "Authorization: $SPARKPOST_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "agent-inbound", "target": "https://myapp.ai/hooks/sparkpost",
        "match": { "domain": "agent.example.com" } }'
```

What lands on that endpoint is not one message. SparkPost POSTs a **batched JSON array** of `msys.relay_message`

objects, and each one carries the raw RFC822 email that you unpack yourself. Here’s the honest handler, in SparkPost’s own idiom ([ sparkpost-contrast/handler.mjs](https://github.com/mailkite/demo-sparkpost-ai-agent/blob/main/sparkpost-contrast/handler.mjs) in the demo repo):

```
// SparkPost Relay Webhook: a BATCHED array → de-batch → conditional base64 → parse MIME → dig auth
import { simpleParser } from "mailparser";

export default async function handler(req, res) {
  res.sendStatus(200); // ack the whole batch fast

  for (const { msys } of req.body) {            // it's an array — loop it yourself
    const m = msys?.relay_message;
    if (!m) continue;

    // raw message is in content.email_rfc822 — base64 ONLY when the flag says so
    const raw = m.content.email_rfc822_is_base64
      ? Buffer.from(m.content.email_rfc822, "base64")
      : m.content.email_rfc822;

    const mail = await simpleParser(raw);        // headers, body, attachments: your job
    const from = m.friendly_from;                // envelope + composed-from live in separate fields
    const to = m.rcpt_to;

    // there is NO normalized auth verdict. Parse it out of the raw headers yourself:
    const authResults = mail.headers.get("authentication-results") ?? "";
    const spfPass = /spf=pass/i.test(authResults);
    const dkimPass = /dkim=pass/i.test(authResults);
    // …then decide whether to trust this before the agent acts on it
  }
}
```

None of this is exotic. But look at what stands between “a mail arrived” and “the agent can act on it”: you de-batch the array, branch on `email_rfc822_is_base64`

and decode, run a MIME parser, reassemble sender identity from `friendly_from`

/ `msg_from`

/ `rcpt_to`

, and — the part that matters most for an agent — **derive SPF/DKIM/DMARC yourself by regex-ing Authentication-Results out of the raw headers**, because the relay payload gives you no normalized verdict. For an inbox where an LLM will

*follow*what the mail says, that trust signal is the whole ballgame, and SparkPost leaves you to compute it.

## The comparison, no adjective inflation

| SparkPost (Bird) | MailKite | |
|---|---|---|
| Inbound shape | Batched JSON array of `relay_message` | One `email.received` event per message |
| Raw message | RFC822 in `email_rfc822` , base64 when flagged | Decoded `text` / `html` fields |
| Auth to the agent | None normalized; parse `Authentication-Results` yourself | `auth{spf,dkim,dmarc,spam}` verdict |
| Reply + threading | Build `From` / `In-Reply-To` yourself | `from: event.to[0]` , `inReplyTo: event.id` |
| Agent runtime | None (sending-first ESP) | BYO loop, or route `action: 'agent'` on a queue |
| Getting started | Enterprise, sales-led onboarding | DNS-verify, then send; 3,000 msgs/mo free |
| Positioning | Enterprise omnichannel (email/SMS/WhatsApp) | Developer inbound → webhook |

The through-line: SparkPost wins enterprise sending and omnichannel breadth. MailKite wins the *inbound-for-an-agent* path — a parsed message, a trust verdict, and a reply that threads itself, instead of an array you unpack and an auth result you regex out of headers.

## What actually hits your agent’s webhook

Same inbound email, delivered parsed. No de-batching, no conditional base64, no MIME parser, and the `auth`

block is a verdict your agent can branch on directly:

```
{
  "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=…" }
  ]
}
```

That `auth`

block is load-bearing. Inbound email is a prompt-injection surface — `From:`

is plain text, so a sender can forge who they are and then simply tell your agent what to do. Checking `auth`

before you weight a sender’s instructions is necessary (not sufficient; the real defense is architectural). SparkPost hands you the raw headers and lets you derive that yourself; MailKite hands you the verdict. See [webhook security](/docs/webhook-security) and the agent-security post linked at the end.

## Or let MailKite run the agent

Everything above is the bring-your-own loop: your endpoint, your model, your reply. If you’d rather not host it, MailKite (which we build) can run the agent for you. A route whose `action`

is `agent`

carries a free-text `agentPrompt`

, and MailKite runs the model loop on a durable Cloudflare Queue — capped tool rounds, a 5-minute reaper as a backstop, and a full transcript you can drill into per route. The system prompt bakes in the same safety rules (body is untrusted, at most one reply, never answer no-reply senders), and the agent replies with an internal `send_email`

tool that threads via `inReplyTo`

. Same parsed inbound edge, same `auth`

verdict; the difference is just where the agent’s turns execute. Details in the [inbox-agents docs](/docs/inbox-agents). To start either way, DNS-verify a domain (SPF + DKIM to send, MX to receive) — no sandbox, no approval wait — per the [quickstart](/docs/quickstart).

## FAQ

**Can SparkPost receive inbound email?**
Yes. SparkPost (now Bird Email) supports inbound through **Relay Webhooks**: you register an inbound domain, point its MX at `rx1`

/`rx2`

/`rx3.sparkpostmail.com`

, and SparkPost POSTs a batched JSON array of `msys.relay_message`

objects to your endpoint. Each carries the raw RFC822 message in `content.email_rfc822`

. It receives fine; the work is that you de-batch, decode, and parse it yourself. MailKite delivers one already-parsed `email.received`

event instead.

**Does SparkPost’s inbound webhook include SPF/DKIM/DMARC results?**
Not as a normalized verdict. The relay-message payload has no SPF/DKIM/DMARC result fields — the only auth signals live inside the raw message headers (`Authentication-Results`

, `Received-SPF`

), which you parse yourself. For an agent that must decide whether to trust a sender before acting, that’s meaningful work. MailKite’s payload includes an `auth`

block with `spf`

, `dkim`

, `dmarc`

, and a spam verdict.

**Is SparkPost the same as Bird now?**
Effectively yes. MessageBird acquired SparkPost in 2021 and rebranded the product to **Bird Email** in March 2023 (MessageBird itself became Bird). The naming is split-brain today: marketing and support docs have moved to `bird.com`

, while the developer API reference still lives at `developers.sparkpost.com`

and the inbound relay-webhook feature still uses the `api.sparkpost.com`

surface. If you’re evaluating it, expect to cross both brands.

**Is the raw inbound message always base64-encoded?**
No — that’s a common trap. SparkPost base64-encodes `content.email_rfc822`

only when the message has content that isn’t safe to embed inline in JSON, and it flags that with `email_rfc822_is_base64`

. In many payloads the flag is `false`

and the raw message is inline. Your consumer has to branch on the boolean rather than assume base64, or you’ll mangle plain messages.

**Does SparkPost have an agent inbox or agent runtime?**
No. SparkPost is a sending-first enterprise ESP; inbound is the relay-webhook parse-and-POST feature, and there’s no built-in agent loop, queue, or transcript. (Bird the broader platform markets AI customer-service agents, but that’s a separate product and doesn’t touch the email relay path.) MailKite offers both a bring-your-own loop and a managed `action: 'agent'`

route.

If SparkPost has your agent de-batching an array, decoding conditional base64, and regex-ing SPF out of raw headers just to read one email, there’s a simpler shape. Clone the [demo repo](https://github.com/mailkite/demo-sparkpost-ai-agent) (or [run it in your browser](https://stackblitz.com/github/mailkite/demo-sparkpost-ai-agent?file=server.mjs)), then [point a domain at MailKite](/docs/quickstart) and your agent’s next inbound email arrives as parsed JSON with an auth verdict.

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