Can a freshly deployed app send email with no domain and no DNS?
Yes — one API key, one POST, and the platform stamps a sender for you. What the shared address gets you, and the exact point where you outgrow it.
Yes, and it takes one environment variable. Leave from out of the request and Infrai stamps a sender on the account’s own verified domain, so the message goes out with working SPF, DKIM and DMARC before you’ve bought a domain or touched a nameserver. We confirmed the behaviour against the live API on 2026-07-26: a bare three-field POST came back 200 with from_used set to noreply+a1f9@send.infrai.cc.
That’s the whole answer to the literal question. The rest of this page is the part that matters more — what that address is good for, and the day you have to stop using it.
The smallest thing that works
Three fields, one header, no configuration file:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"tester@example.com","subject":"Your export is ready","html":"<p>The CSV you asked for is waiting in your account.</p>"}'
{
"ok": true,
"data": {
"message_id": "msg_oMOOxEWA80oaLHuJkOBRWlV0",
"mode": "default_vendor",
"from_used": "noreply+a1f9@send.infrai.cc",
"accepted_recipients": ["tester@example.com"],
"suppressed_recipients": [],
"vendor_message_id": "b9f4a0fd-b7a8-49b0-8cfc-7d63cd58fcb5"
}
}
mode: "default_vendor" means the platform chose the sender and the vendor for you. The +a1f9 tag is per account, which is how bounces and complaints find their way back to your suppression list rather than someone else’s.
Have the agent check the capability rather than assume it
If a coding assistant is generating the integration, point it at the discovery document instead of a blog post. One GET returns every route, its method and its billing class — machine-readable, and current by construction:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
if c["namespace"] == "email":
print(c["method"], c["path"], c["billing"].get("price_usd", "free"))'
Generated code that reads its own contract goes stale far more slowly than generated code that memorised one.
What you get, and what needs a domain
| Capability | Shared sender, zero DNS | Your own verified domain |
|---|---|---|
| Send transactional mail | yes | yes |
| SPF, DKIM, DMARC alignment | yes, on the platform domain | yes, on yours |
| Recipient sees your brand in From | no | yes |
| Delivery state and event history | yes | yes |
| Open and click tracking | no — needs the tracking CNAME | yes |
| Suppression list scoped to you | yes | yes |
| What it takes to enable | an API key | a Pro plan and DNS access |
The send function, in Python 3
Complete enough to paste into a generated app: it reads the key, handles the three outcomes that actually happen, and hands back the message id you’ll want later.
#!/usr/bin/env python3
"""mailer.py — transactional send with no sender configuration."""
import os
import sys
import requests
API = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
SESSION = requests.Session()
SESSION.headers.update({
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
})
class Suppressed(Exception):
"""The address is on this account's suppression list; nothing was delivered."""
class BadAddress(Exception):
"""Permanently unusable recipient — do not retry."""
def send(to: str, subject: str, html: str) -> str:
resp = SESSION.post(f"{API}/v1/email/send", json={"to": to, "subject": subject, "html": html}, timeout=30)
payload = resp.json()
if not payload.get("ok"):
error = payload.get("error", {})
message = error.get("message", "")
if 400 <= resp.status_code < 500 and error.get("retryable") is False:
raise BadAddress(f"{error.get('code', resp.status_code)}: {message}")
raise RuntimeError(f"{error.get('code', resp.status_code)}: {message}")
data = payload["data"]
if data.get("suppressed_recipients"):
raise Suppressed(", ".join(data["suppressed_recipients"]))
return data["message_id"]
if __name__ == "__main__":
try:
message_id = send(sys.argv[1], "Your export is ready", "<p>The CSV you asked for is waiting.</p>")
except (Suppressed, BadAddress) as exc:
sys.exit(f"not delivered: {exc}")
print(message_id)
Two behaviours are worth keeping whatever you build on. A suppressed address returns success with an empty delivery — treat it as a business outcome, not a 500. And a malformed recipient is a plain HTTP 400 carrying the code INVALID_RECIPIENT with retryable: false, so the retry rule is the textbook one: back off on 5xx, never on 4xx. A typo’d address fails on its first attempt rather than eating the whole backoff budget, and no string matching is involved.
Confirm the send with a free read:
curl -sS "https://api.infrai.cc/v1/email/get/msg_oMOOxEWA80oaLHuJkOBRWlV0" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"message_id": "msg_oMOOxEWA80oaLHuJkOBRWlV0",
"state": "sent",
"channel": "email",
"to": "tester@example.com",
"vendor": "resend",
"created_at": 1785027953.4247334
}
}
Where the free ride ends
Four boundaries, in the order most projects meet them.
The From line is the first. A password reset from noreply+a1f9@send.infrai.cc is fine for an internal tool and looks wrong on a product with paying customers. Replies are the second: the documented send body is to, from, subject and html, with no reply-to field, so put “replies to this address aren’t monitored” in the footer and give people a real support link instead. Third, engagement analytics: open and click tracking ride on a CNAME under your own domain, so the shared sender has no support for them at all. If you need open rates in week one, this isn’t the path.
The fourth is the upgrade itself, and the catch is that sending from your own domain is a Pro-plan capability. That boundary is declared rather than hidden — GET /v1/discovery reports minimum_tier: "pro" for the email.domain.verify capability, so an assistant generating your integration can read it before it writes the branch. On a standard account the route answers:
{
"ok": false,
"error": {
"code": "PRO_REQUIRED",
"http_status": 402,
"message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
"retryable": false
}
}
A 402 with retryable: false is the API asking you to change plan, not to try again — and the same status meets a send that carries a custom from, before any DNS is checked. Buy Resend or Mailgun instead if a branded sender on day one is genuinely non-negotiable and a paid plan isn’t in the budget yet: both verify a domain on their free tiers, and choosing one for that single reason is a defensible call.
What it costs while you’re proving the idea
Reads, suppression checks and domain lookups are free and rate-limited. Sends are metered at $0.00046 per email on POST /v1/email/send, verified 2026-07-27 and flagged approximate because the vendor mix moves underneath. How far the signup credit stretches is a division this page deliberately won’t do for you: the discovery record for email.send carries a new_account_trial_uses count, and that one is computed against whatever the rate is today. Prices here drift downward and promotions run, so read the number rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys; b = next(c["billing"] for c in json.load(sys.stdin)["capabilities"] if c["id"] == "email.send"); print(b["price_usd"], b["unit"], b["new_account_trial_uses"], "free on trial")'
Every response also echoes what that specific call cost in its metadata, so you never have to reconcile a rate against an invoice to find out.
Why this shape suits generated apps
An app assembled by an assistant tends to need six services in its first week: send an email, run something on a schedule, keep a file, capture an error, hold a queue, ask a model. Doing that with six vendors means six signups, six keys in six dashboards, and six invoices, and it’s where a lot of generated projects stall — not on the code, on the accounts. One key covering all of it is worth more here than any per-message rate: the cron entry that mails tomorrow’s digest, the object store holding the CSV it links to, and the error tracker that catches this script throwing all need no second account.
If email is the only external call your app will ever make, take a specialist. Otherwise, the fact that you didn’t have to buy a domain to send the first message is the same reason you won’t have to open another account for the second thing.