---
title: "Test mode"
description: "Send with an mf_test_ key to exercise the real send path without delivering mail, force per-recipient bounces and complaints, and read back which recipient was affected."
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

An `mf_test_` key runs the same code path as a live send: the request is validated, persisted, queued, and handed to Amazon SES, and real SES events come back as webhooks and timeline events. The one difference is the envelope. Every recipient is rewritten to an address on the Amazon SES mailbox simulator, so no mail reaches a real inbox.

```bash
curl -X POST https://api.mailfully.com/v1/emails \
  -H "Authorization: Bearer mf_test_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "orders@mail.acme.com",
    "to": "ada@example.com",
    "subject": "Your order shipped",
    "text": "Order 4212 shipped."
  }'
```

Test sends are exempt from the [verified-domain](/guides/verify-a-domain) gate and are never metered against your monthly quota, so you can build an integration before DNS is in place. The daily send cap still applies, and test messages are only visible to `mf_test_` keys. See [Authentication](/concepts/authentication#test-and-live-environments) for how the two environments separate your data.

## Force an outcome

The **local part** of each recipient decides what SES simulates:

| Send to | Simulated outcome |
|---|---|
| `bounce@…` | Permanent hard bounce |
| `complaint@…` | Spam complaint |
| `suppressionlist@…` | Permanent bounce, as if the address sat on the SES account-level suppression list |
| `ooto@…` | Delivered, followed by an out-of-office auto-reply |
| `success@…`, or anything else | Delivered |

Only the local part selects the outcome, so keep whatever fixture domain you already use: `bounce@example.com`, `bounce@acme.test`, and `bounce@yourcompany.com` all bounce. Matching is case-insensitive.

The whole local part has to be the keyword. `bounce@example.com` bounces, but `bounce-1@example.com` and `bounce+retry@example.com` are ordinary addresses and deliver.

## How recipients are rewritten

Each recipient becomes a **labeled** simulator address, `<outcome>+<label>@simulator.amazonses.com`, where the label identifies the address you asked for:

```
ada@example.com          →  success+ada=example.com@simulator.amazonses.com
bounce@example.com       →  bounce+bounce=example.com@simulator.amazonses.com
Ada <ada@example.com>    →  success+ada=example.com@simulator.amazonses.com
ada+news@example.com     →  success+ada_news=example.com@simulator.amazonses.com
```

The label is the lowercased address with `@` rewritten to `=` and every character outside `[a-z0-9._=-]` rewritten to `_`. Labels over 48 characters are shortened to their first 38 characters plus a dot and 8 hex digits of the address's SHA-256, which keeps the local part inside the 64-octet limit RFC 5321 sets.

Labels exist because SES combines the simulated bounces from one sending request into a single notification. Without a per-recipient label, a bounce on a three-recipient message names `bounce@simulator.amazonses.com` and tells you nothing about which recipient it belongs to. AWS supports labels on simulator addresses for this VERP-style matching, and Mailfully uses them so a test bounce points at exactly one recipient.

## Read back the affected recipient

Bounce and complaint events carry a `recipients` array, and the labels are resolved back to the addresses you originally requested. A message sent to `ada@example.com` and `bounce@example.com` reports the bounce like this:

```bash
curl https://api.mailfully.com/v1/emails/01J1X6P9T3KQ7ZW2V5R8YBAGMD/events \
  -H "Authorization: Bearer mf_test_xxxxxxxxxxxx"
```

```json
{
  "data": [
    { "type": "send", "event_at": "2026-07-08T14:02:12.000Z", "detail": null },
    {
      "type": "bounce",
      "event_at": "2026-07-08T14:02:15.000Z",
      "detail": {
        "bounce_type": "Permanent",
        "bounce_subtype": "General",
        "diagnostic_code": "smtp; 550 5.1.1 user unknown",
        "recipients": ["bounce@example.com"]
      }
    }
  ]
}
```

The [`email.bounced` webhook](/guides/webhooks#the-event-envelope) resolves the same way, in `data.bounce.recipients`.

Resolution is not conditional on the environment. A live address never matches the labeled-simulator pattern and passes through exactly as SES reported it, so a test payload has the same shape as a live one and your handler needs no test-only branch.

## What test mode leaves alone

- **The message keeps your recipients.** Only the SES envelope is rewritten. [`GET /v1/emails/:id`](/api-reference/emails/get) returns the `to`, `cc`, and `bcc` you sent, and list filters match those addresses.
- **Templates, domains, webhooks, and suppressions are shared** with live mode. One webhook endpoint receives events from both environments; tag your test sends if your handler needs to tell them apart.
- **Everything before the send still runs**: idempotency, scheduling, cancellation, suppression filtering, and template rendering behave as they do in live mode. Rate limits apply too, from a separate bucket at the same rate, so test traffic does not eat your live allowance.

## Suppressions from test bounces

A test bounce or complaint suppresses the **simulator** address, for example `bounce+ada=example.com@simulator.amazonses.com`, never `ada@example.com`. Two things follow:

- A bounce drill can never block a live send to that person, and repeating the drill works because the address you send to is not the one on the list.
- Those entries show up in your [suppression list](/guides/suppressions), one per distinct test recipient. Unlike live entries they are not mirrored to the SES account-level suppression list, since AWS never suppresses simulator addresses.

<Note>
Suppression entries are org-global, so simulator addresses appear when you list suppressions with a live key too. The list has no domain filter; skip anything ending in `@simulator.amazonses.com` in an operational view, or [delete](/api-reference/suppressions/delete) the entries once a drill is done.
</Note>
