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
429withRetry-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"
}'{
"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 is404, 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
fromrejects it here, withstatus: "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:
| Recipient | What 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.
| Value | What happens |
|---|---|
429 | 429 rate_limited with Retry-After. Nothing is done. |
503 | 503 unavailable with Retry-After. Nothing is done. |
delay=20s | The request is done at once, and the answer comes after 20 seconds (1 to 60). |
drop | The 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 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.