Documentation
Send email over HTTP or SMTP. The request and response shapes match Resend's, so code written against them (including code an AI assistant wrote for you) works here after changing the base URL and the key.
If an assistant is writing that code, hand it one of the prompts instead of this page: each one carries the whole contract, so it has nothing left to guess about.
Quickstart: your first email in four steps
Transactional email works on every plan, Free included. Each step below is also a button in your dashboard's setup guide, which fills in your own domain and address.
-
1
Add your sending domain
In the dashboard, add a subdomain such as
mail.yourcompany.com. Newsletters need a separate one, such asnews.yourcompany.com: each domain sends one kind of mail and we enforce it, so complaints about a newsletter can never hurt your password resets. -
2
Publish three DNS records
We show a DKIM key, an SPF record and a bounce (MAIL FROM) record. Add them at your DNS provider; we check every few minutes and email you when the domain is verified. You can only send from a verified domain: that is what stops anyone sending as someone else.
-
3
Create an API key
Name it after where it lives (Production, Staging). It is shown once and we store only a hash, so copy it into your server's environment as
PIGEONATLAS_API_KEY. -
4
Send
Make one
POSTto/v1/emails, as below. The same request works with Resend's SDKs if you change the base URL.
Newsletters to a list are a separate product, included from the Growth plan. How newsletters work.
Authentication
Your key goes in the Authorization header on every request.
Authorization: Bearer pa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The same key is the password for SMTP. Keep it server-side: anyone holding it can send as your verified domains.
Send an email
POST https://pigeonatlas.com/v1/emails. The response comes back as soon as
the message is queued, so a slow recipient never slows your request.
curl -X POST https://pigeonatlas.com/v1/emails \
-H "Authorization: Bearer $PIGEONATLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@mail.acme.com>",
"to": ["customer@example.com"],
"subject": "Order #4471 confirmed",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order."
}'
const response = await fetch("https://pigeonatlas.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PIGEONATLAS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme <hello@mail.acme.com>",
to: ["customer@example.com"],
subject: "Order #4471 confirmed",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order.",
}),
});
const { id } = await response.json();
import os, requests
response = requests.post(
"https://pigeonatlas.com/v1/emails",
headers={"Authorization": f"Bearer {os.environ['PIGEONATLAS_API_KEY']}"},
json={
"from": "Acme <hello@mail.acme.com>",
"to": ["customer@example.com"],
"subject": "Order #4471 confirmed",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
},
)
message_id = response.json()["id"]
require "net/http"
require "json"
uri = URI("https://pigeonatlas.com/v1/emails")
request = Net::HTTP::Post.new(uri, {
"Authorization" => "Bearer #{ENV.fetch('PIGEONATLAS_API_KEY')}",
"Content-Type" => "application/json"
})
request.body = {
from: "Acme <hello@mail.acme.com>",
to: ["customer@example.com"],
subject: "Order #4471 confirmed",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order."
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
id = JSON.parse(response.body)["id"]
{ "id": "01a06673-4d4b-7173-a296-487a2e8917b1" }
Send text alongside html.
Filters weight HTML-only mail as more likely to be bulk, and some clients render nothing without it.
Not sending twice
Add an Idempotency-Key header. A repeated request with the same key
returns the original message id instead of sending again, for 24 hours.
Idempotency-Key: order-4471-confirmation
Use something derived from what you are sending about, not a random value because a retry has to produce the same key to be any use. This matters more than it sounds: retry logic is where duplicate receipts come from.
Scheduling
Pass scheduled_at as an ISO 8601 timestamp and we hold the message
until then. Your app does not need a cron job for it.
"scheduled_at": "2026-09-04T07:00:00Z"
Look up a message
curl https://pigeonatlas.com/v1/emails/01a06673-4d4b-7173-a296-487a2e8917b1 \ -H "Authorization: Bearer pa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
"id": "01a06673-4d4b-7173-a296-487a2e8917b1",
"from": "Acme <hello@mail.acme.com>",
"to": ["customer@example.com"],
"subject": "Order #4471 confirmed",
"last_event": "delivered",
"created_at": "2026-09-03T08:47:07Z"
}
Errors
Every error has the same shape, and the message says what to change.
{
"statusCode": 403,
"name": "not_found",
"message": "paypal.com is not one of your domains. Add it before sending from it."
}
| Status | Means | Retry? |
|---|---|---|
| 401 | Key missing, wrong, or revoked. | No. Fix the key. |
| 402 | Out of allowance on your plan, and no top-up credits. | After a top-up, an upgrade, or when the month resets. |
| 403 | Sending from a domain you have not verified, one past what your plan includes, or a newsletter domain. | No. Verify it, reorder your domains, change plan, or send from a transactional domain. |
| 422 | Something in the request is malformed. | No. The message says what. |
| 5xx | Our problem. | Yes, with backoff. |
402 rather than 403 when you run out so your code can tell "pay and this works" apart from "you are not allowed": the difference between a retry and an alarm at 3am.
Limits
- One recipient per request. Send several requests, or use a broadcast for a list.
- Suppressed addresses are accepted and not sent. A hard bounce or spam complaint suppresses an address permanently because sending to it again is what gets senders blocked.
- Messages over 25 MB are refused.
Who you may send to
Only people who asked to hear from you. Bought, rented or scraped lists are not permitted, and neither is reusing addresses collected for something else. We suppress an address automatically once it hard bounces or files a complaint, and that suppression is checked on every send, but the consent behind the first message is yours to hold, and we will ask you to show it.
Coming from somewhere else?
Moving from Resend · Sending from Lovable or Supabase · SMTP settings