Search the docs

Find a page, a section, or an endpoint.

Test mode

A key whose mail never leaves Pidgeon, for CI.

A test key (pgn_test_…) does everything a live key does, and none of its mail leaves Pidgeon. Use one in CI, in a staging environment, or anywhere an agent is being built and should not be writing to real people.

What runs, and what does not

Everything runs: validation, the send checks (who may send as which address, suppression, the address's send policy), threading, the stored message, events and their webhooks, receipts, Idempotency-Key, and the Firewall on mail that arrives.

What does not:

  • Nothing is handed to the mail provider. Not for a send, a reply, a forward, a draft sent later, a scheduled send, or a calendar invitation. Whatever the recipient, whichever route or retry.
  • Nothing counts. No daily sending allowance, no usage, no billing. The five-minute and per-minute limits still apply, at your plan's numbers, but in a bucket of their own. So does the hourly budget for minting and rotating keys, at 300 an hour rather than 20 (limits). A test suite cannot use up what your production server needs.
  • No person is notified by email or push about test mail.

Every message written in test mode has "test": true — on the message, on the answer to a send, and on message.sent and message.received — and the dashboard marks it Test.

Making one

Pass test: true when you mint the key, or choose Test under Mode in Developers → API keys → New key. A test key's own children are always test keys.

curl https://pidgeon.ai/api/v1/api-keys \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "CI", "identity_ids": ["identity-id"], "access": "send", "test": true }'

A test key works on the addresses it was given, and on the test addresses it makes. It cannot create domains or pods, or issue IMAP and SMTP passwords: each of those counts against your plan, reaches the provider, or opens a way to send that test mode does not cover.

Test addresses

A test key with admin over the account, or over a pod, can create addresses with POST /v1/identities, as a live admin key does. What it creates is a test address, with "test": true:

  • Outside your plan. It counts against no address or inbox limit, and is never billed. An account holds up to 100 at once; past that the answer is 429 with Retry-After.
  • Deleted a day after it was made, with its mail. A test key may also delete one itself; it can't delete a live address.
  • Test mail only. It receives test sends from your account and POST /v1/test/inbound. Mail from outside is refused as though the address did not exist.
  • Every send from it is a test send, whoever makes it, a live key or a person included.

So each CI run can make its own address, and parallel runs never read each other's mail. A test key narrowed to some addresses gets 403, as a live one does.

Where test mail goes

A test send to one of your own account's addresses is delivered there, marked test, so two of your agents can talk to each other in CI. A test send to anybody else, including an address in another Pidgeon account, is accepted, stored as sent, and goes nowhere.

A reply to a test message is a test message too, whoever sends it. If a person answers a simulated customer from the dashboard, that reply also stays inside.

Playing the other side

Nobody outside will ever answer a test send, so POST /v1/test/inbound plays the other side. It delivers a message to one of the test key's addresses through the real inbound pipeline, exactly as an email from outside would arrive. Pass in_reply_to with a message id to have it arrive as a reply in that thread.

curl https://pidgeon.ai/api/v1/test/inbound \
-H "Authorization: Bearer $PIDGEON_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
  "to": "support@yourapp.pidgeon.email",
  "from": "customer@example.com",
  "subject": "Re: Your order",
  "text": "It never arrived.",
  "in_reply_to": "message-id"
}'
json
{
"data": {
  "status": "delivered",
  "message_id": "…",
  "thread_id": "…",
  "identity_id": "…",
  "spam": false
}
}
  • Test keys only. A live key gets 403: it would put a made-up sender into a real inbox.
  • An address the key reaches at send. Anything else is 404, the same as an address that does not exist.
  • The sender's rules, the spam filter and the Firewall all judge it. A sender rule that rejects from rejects it here, with status: "rejected".
  • Up to 1,000 an hour per account. It takes an Idempotency-Key, so a CI step that retries does not deliver twice.

Causing failures

The code that handles a refused recipient, a rate limit or an answer that never came is the code most likely to be wrong, and a working API never exercises it. In test mode you can cause each one on purpose. A live key gets the real behaviour: the magic addresses are ordinary addresses and the header is ignored.

Magic recipients

On a send with a test key, at any domain:

RecipientWhat happens
rejected@…Comes back in the send's rejected, with a reason. Everybody else on the send gets it, as when the provider refuses one address.
bounce@…Accepted, then a hard bounce a few seconds later: the message's delivery.state becomes bounced and message.bounced goes to your webhooks.

A +tag works (bounce+run-41@example.com), so parallel runs can tell their bounces apart. A test bounce is never counted against your account and never suppresses the address.

Pidgeon-Test-Fail

A header on any request that takes an Idempotency-Key (sends, replies, forwards, drafts, addresses), and on minting and rotating a key.

ValueWhat happens
429429 rate_limited with Retry-After. Nothing is done.
503503 unavailable with Retry-After. Nothing is done.
delay=20sThe request is done at once, and the answer comes after 20 seconds (1 to 60).
dropThe request is done, and the answer is 504 test_dropped instead of the result.

With delay and drop the request has happened, so retrying it with the same Idempotency-Key returns the first answer, with Idempotent-Replay: true, and does nothing twice. The retry is answered normally even if it carries the header again. Any other value is refused with 400 and field: "Pidgeon-Test-Fail", so a typo cannot pass a test.

cURL
curl https://pidgeon.ai/api/v1/messages \
-H "Authorization: Bearer $PIDGEON_TEST_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: draft-41" \
-H "Pidgeon-Test-Fail: drop" \
-d '{ "from": "support@yourapp.pidgeon.email", "to": "customer@example.com", "text": "On its way." }'
# 504 test_dropped. The message was sent; the same request again replays its answer.