API
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.
Before you can send
Two things, once per domain:
- Add and verify the domain. We give you three DNS records and check for them ourselves.
- Create an API key. Shown once. We store only a hash, so it cannot be shown again.
You can only send from a domain you have verified. This is why the service cannot be used to send as someone else, and it is not optional.
Use a subdomain like mail.yourcompany.com, and a separate one such as
news.yourcompany.com for newsletters — complaints about a newsletter
should never be able to hurt delivery of your password resets.
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 pa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "Prodavnica <hello@mail.prodavnica.rs>",
"to": ["kupac@example.com"],
"subject": "Narudžbina #4471 je potvrđena",
"html": "<p>Hvala na narudžbini.</p>",
"text": "Hvala na narudžbini."
}'
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: "Prodavnica <hello@mail.prodavnica.rs>",
to: ["kupac@example.com"],
subject: "Narudžbina #4471 je potvrđena",
html: "<p>Hvala na narudžbini.</p>",
text: "Hvala na narudžbini.",
}),
});
const { id } = await response.json();
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: "Prodavnica <hello@mail.prodavnica.rs>",
to: ["kupac@example.com"],
subject: "Narudžbina #4471 je potvrđena",
html: "<p>Hvala na narudžbini.</p>",
text: "Hvala na narudžbini."
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
id = JSON.parse(response.body)["id"]
import os, requests
response = requests.post(
"https://pigeonatlas.com/v1/emails",
headers={"Authorization": f"Bearer {os.environ['PIGEONATLAS_API_KEY']}"},
json={
"from": "Prodavnica <hello@mail.prodavnica.rs>",
"to": ["kupac@example.com"],
"subject": "Narudžbina #4471 je potvrđena",
"html": "<p>Hvala na narudžbini.</p>",
"text": "Hvala na narudžbini.",
},
)
message_id = response.json()["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 — 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": "Prodavnica <hello@mail.prodavnica.rs>",
"to": ["kupac@example.com"],
"subject": "Narudžbina #4471 je potvrđena",
"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. | After upgrading, or when the month resets. |
| 403 | Sending from a domain you have not verified. | No — verify it first. |
| 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 — 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