API mocks
Mandrill-compatible email API
A mock of the Mandrill (Mailchimp Transactional) API. Your app sends transactional email with the official SDK or plain HTTP, exactly as it would through Mandrill, and every accepted message lands in one of your team's Testlie email inboxes as a real email, with the HTML preview, link and accessibility checks, the spam report and everything else the email sandbox does. Nothing is delivered to real people.
Testlie is not affiliated with, endorsed or sponsored by Mailchimp or Intuit. "Mandrill" and "Mailchimp" are used only to describe API compatibility.
Connect
Create a Mandrill-compatible mock under API mocks → Push & messaging. Use its base URL in place of https://mandrillapp.com and its API key:
| Credential | Use |
|---|---|
| API key | The key field of every call's JSON body, like Mandrill |
| Webhook key | Signs webhook batches (X-Mandrill-Signature); webhooks/list returns it as auth_key |
Calls are POST {base URL}/api/1.0/<group>/<method>.json with a JSON body. The official SDKs use /api/1.3; any 1.x version and the .json suffix are accepted.
import mailchimpFactory from "@mailchimp/mailchimp_transactional";
const mailchimp = mailchimpFactory(process.env.MANDRILL_API_KEY);
mailchimp.basePath = "https://testlie.com/mock/<id>/api/1.3";
await mailchimp.messages.send({ message: { from_email: "app@example.com", subject: "Hi", text: "Hello", to: [{ email: "jane@example.com" }] } });In Python, set client.host = "https://testlie.com/mock/<id>/api/1.3" on mailchimp_transactional.Client.
A wrong or missing key answers HTTP 500 with Mandrill's error body:
{ "status": "error", "code": -1, "name": "Invalid_Key", "message": "Invalid API key" }Other errors use the same shape: ValidationError (code -2), Unknown_Template (5), Invalid_Template (6), Unknown_Message (11), Unknown_Webhook (3), Unknown_Method, PaymentRequired (the team's monthly email quota is used up) and Too_Many_Requests (HTTP 429).
Where messages go
Each accepted message is captured in the mock's capture inbox (Settings tab). By default that is the oldest inbox of the mock's project, or else the team's oldest inbox; sends fail with GeneralError while the team has no inbox. Only your own team's inboxes can be chosen.
Captured messages count against the plan's monthly email messages and the inbox's per-minute rate, like mail sent over SMTP. Inbox webhooks, forwarding rules and automatic spam checks apply as usual.
Like Mandrill, every recipient gets their own copy with only their address in To (preserve_recipients: false, the default). With preserve_recipients: true one message shows every to and cc recipient; bcc recipients are on the envelope only. Each message has Message-ID: <_id@mandrill.testlie.invalid> and an X-Mandrill-Message-Id header with Mandrill's _id, so tests can match the API response to the email.
Supported calls
| Call | Notes |
|---|---|
users/ping, users/ping2, users/info, users/senders |
"PONG!", { "PING": "PONG!" } |
messages/send |
html, text, subject, from_email, from_name, to[] (to/cc/bcc), headers, attachments, images (inline, by cid), tags, metadata, merge_vars, global_merge_vars, merge, merge_language, preserve_recipients, bcc_address, async. Up to 100 recipients |
messages/send-template |
template_name, template_content (fills mc:edit regions), plus the message fields above |
messages/info, messages/content |
By _id |
messages/search |
query, date_from, date_to, tags, senders, limit |
senders/list |
Per sender address: sent, bounces, rejects, opens, clicks |
templates/add, info, update, publish, delete, list, render |
A per-mock template store (up to 200 templates). send-template uses the published version |
webhooks/list, add, info, delete |
Webhooks added through the API receive the events they ask for; the mock's own webhook URL (Settings) gets all events |
The response of a send is Mandrill's array:
[{ "email": "jane@example.com", "status": "sent", "reject_reason": null, "_id": "4f0a…" }]status is sent, queued (with async: true), rejected (with reject_reason) or invalid (not an email address).
Merge tags: *|NAME|* (Mailchimp, the default) or {{name}} / {{{name}}} with merge_language: "handlebars". Per-recipient merge_vars override global_merge_vars. Unknown tags are left in place so a missing merge var is visible in the captured email. Handlebars helpers ({{#if}}, {{#each}}) are not evaluated.
Search: whitespace-separated terms that must all match. email:, subject:, sender: (or from_email:), tags:, state: and template: look at one field; a bare term looks at the email, subject and sender. * is a wildcard; * alone matches everything. This is a small subset of Mandrill's search syntax.
Not implemented: send_at scheduling (messages are captured at once), messages/send-raw, inbound routes, subaccounts, IP pools, exports and URL tracking reports.
Simulated outcomes
The recipient's address decides what happens: the local part itself or one of its +tags, e.g. bounce@example.com or jane+bounce@example.com.
| Address tag | Response | Captured | Events |
|---|---|---|---|
| (none) | sent |
yes | send |
reject |
rejected, hard-bounce |
no | reject |
spam |
rejected, spam |
no | reject |
unsub |
rejected, unsub |
no | reject |
bounce |
sent |
yes | send, then hard_bounce |
softbounce |
sent |
yes | send, then soft_bounce |
defer |
sent |
yes | deferral, then send |
open |
sent |
yes | send, then open |
click |
sent |
yes | send, then open and click (on the first link of the HTML) |
Later events come after the mock's asynchronous steps delay (Scenarios tab, 2 seconds by default), and messages/info reflects them from then on (state: "bounced", opens, clicks, smtp_events). The Outcome: decline scenario rejects every recipient with the chosen reason.
Webhooks
Set a webhook URL on the mock (or add one with webhooks/add). Events arrive in batches, as Mandrill sends them: a form-encoded POST whose mandrill_events field is a JSON array of events (send, deferral, hard_bounce, soft_bounce, open, click, reject, ...), each with event, _id, ts and msg.
Every batch carries X-Mandrill-Signature, computed like Mandrill documents it: start with the webhook URL exactly as registered, append each POST field's name and value with the names sorted, then base64-encode the HMAC-SHA1 of that string with the webhook key.
import { createHmac } from "node:crypto";
function verify(url, params, signature, key) {
let data = url;
for (const k of Object.keys(params).sort()) data += k + params[k];
return createHmac("sha1", key).update(data).digest("base64") === signature;
}The webhook scenarios (delay, drop, duplicate, bad signature) apply, deliveries are retried, and each one is shown on the mock's Webhooks tab with a replay button.