The code that handles failure never runs
An agent that sends email has a happy path and a dozen others: a recipient the provider refuses, a bounce that arrives a minute later, a rate limit, an answer that never comes back. Those are the branches most likely to be wrong, and a working email API never exercises them. Testing against real mail is worse: it sends to real people and spends a real plan.
Pidgeon has a test mode for exactly this. It is the same API with a different key, not a separate sandbox to keep in step.
A test key runs the whole pipeline
Mint a key with "test": true, or pick Test in the dashboard. Its token starts pgn_test_, and everything works as it does in production: validation, threading, events and signed webhooks, delivery receipts, Idempotency-Key and the Pidgeon Firewall. What changes is where the mail goes:
- Nothing leaves Pidgeon. Mail to your own account's addresses is delivered there, marked
test. Anything else is recorded as sent and goes nowhere. - Nothing counts against your plan. Rate limits still apply, in a bucket of their own.
- It cannot touch production. A test key mints only test keys, and cannot change domains, live addresses or app passwords.
The SDKs, the CLI and the MCP server take a test key like any other, so your agent runs unchanged.
Test addresses that clean up after themselves
A test key can create addresses of its own. They are marked test, count against no plan (up to 100 at once), take only test mail, and are deleted a day after they are created, so a CI run can make fresh addresses every time without leaving any behind.
Simulated inbound mail
To test what your agent does when mail arrives, send it some:
curl https://pidgeon.ai/api/v1/test/inbound \
-H "Authorization: Bearer $PIDGEON_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "'"$TEST_ADDRESS"'",
"from": "customer@example.com",
"subject": "Where is my order?",
"text": "It was due on Monday."
}'
It goes through the real inbound path: sender rules, the spam filter, the Firewall, threading, message.received and your webhooks. Add in_reply_to and it joins an existing thread.
Failures on demand
With a test key you can cause each failure on purpose:
| Do this | And get |
|---|---|
Send to rejected@ any domain | The recipient comes back in the send's rejected, with a reason; the others still get it |
Send to bounce@ any domain | Accepted, then a hard bounce and message.bounced a few seconds later |
Header Pidgeon-Test-Fail: 429 or 503 | That error with Retry-After, and nothing done |
Header Pidgeon-Test-Fail: delay=20s | The work done at once, the answer 20 seconds later |
Header Pidgeon-Test-Fail: drop | The work done, and 504 test_dropped instead of the answer |
delay and drop do the work before they misbehave, so a retry with the same Idempotency-Key gets the original answer back. That is the case your retry logic has to get right, and now you can watch it do so. A +tag on the magic addresses (bounce+run-41@example.com) keeps parallel runs apart.
Start
Test mode is on every plan, including Free. The test mode page has the short version and the guide every trigger and limit; sign up, mint a test key, and point your test suite at it.