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 "invalid recipient address" in message:
raise BadAddress(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 arrives as an HTTP 503 with retryable: true even though it’s permanent, so match on the message text before you hand it to a retry loop; otherwise a typo’d address quietly eats your whole backoff budget.
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 Pro-only.
{
"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
}
}
That 402 comes back from POST /v1/email/domain/verify, and the same status fires on a send with a custom from before any DNS check happens. If a branded sender on day one is non-negotiable and you’d rather not pay for a plan, Resend and Mailgun both verify a domain on their free tiers, and picking one of them for that reason is a perfectly 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.000115 per email, verified 2026-07-26 and flagged approximate because the vendor mix moves underneath. A new account carries $2 of free credit, which works out around 17,000 messages — more than most side projects send in a year. Prices here drift downward and promotions run, so read the current 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.
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.