Transactional Email
Send templated and ad-hoc transactional email from your project — register reusable templates, send to a user or a raw address, manage suppressions, and inspect delivery history.
Amba sends transactional email on your behalf — welcome mails, receipts, password resets, anything triggered by an app event. You register reusable HTML templates, then send by template name (to an end user or a raw address) or send a one-off ad-hoc message. Every send is recorded in a delivery log, and bounces / complaints are suppressed automatically so you don't keep mailing a dead address.
All routes mount under /v1/admin/projects/:projectId/email/* and require
a developer Bearer token (your PAT). The amba_email_* MCP tools wrap the
same surface for agentic use.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /admin/projects/:projectId/email/templates | Register or update a template (upsert). |
| GET | /admin/projects/:projectId/email/templates | List templates. |
| GET | /admin/projects/:projectId/email/templates/:name | Describe one template. |
| PATCH | /admin/projects/:projectId/email/templates/:name | Update a template's fields. |
| DELETE | /admin/projects/:projectId/email/templates/:name | Delete a template. |
| POST | /admin/projects/:projectId/email/send | Send (by template or ad-hoc). |
| GET | /admin/projects/:projectId/email/deliveries | List deliveries (filter by template/status). |
| GET | /admin/projects/:projectId/email/deliveries/:id | Describe one delivery. |
| POST | /admin/projects/:projectId/email/suppressions | Add a manual suppression. |
| GET | /admin/projects/:projectId/email/suppressions | List suppressions. |
| DELETE | /admin/projects/:projectId/email/suppressions/:email | Remove a suppression (unblock). |
| POST | /admin/projects/:projectId/email/domains | Add a sending domain. |
| GET | /admin/projects/:projectId/email/domains | List domains with DNS and verification status. |
| GET | /admin/projects/:projectId/email/domains/:domain | Refresh one domain's status. |
| POST | /admin/projects/:projectId/email/domains/:domain/verify | Publish (Amba-managed) and verify. |
| DELETE | /admin/projects/:projectId/email/domains/:domain | Remove a sending domain. |
Sending domains
Amba sends from a shared mailbox until you add a domain of your own. Add a domain, let it verify, then make an address on it the project's default sender. Full request and response shapes are in the API reference.
Where the domain's DNS lives decides who creates the DNS records. Every
domain response says which path the domain is on (dns_managed) and what
happens next (next_step).
| Bought through Amba | Hosted elsewhere | |
|---|---|---|
dns_managed | true | false |
| Who creates the records | Amba | You, at your DNS host |
| DMARC | Amba adds v=DMARC1; p=none; when the domain has no policy | recommended_dmarc lists the record when the domain has none |
| Verification starts | As soon as Amba has written the records | When you call verify |
| Automatic re-checks | For 72 hours after Amba writes the records (and after any verify call) | For 72 hours after each verify call |
| Your next step | None. Wait for status: "verified" or the email.domain.verified event | Create the records, then call verify |
Domains bought through Amba
A domain you bought with amba domains buy (or the amba_domains_purchase
MCP tool) lives in DNS that Amba runs, and so does any subdomain of it such
as mail.example.com. Adding it as a sending domain is the whole job:
Amba then:
- Writes every record the domain needs: a DKIM TXT record, the MX and SPF
TXT records on the
send.return-path subdomain, any return-path CNAME such asrsend.example.com, and_dmarcTXTv=DMARC1; p=none;when the domain has no DMARC policy. For a subdomain, a policy on the parent domain already covers it. - Starts verification once every one of those records is in DNS
(
dns_status: "published"). - Checks again automatically until the domain verifies: every 5 minutes for the first hour, every 15 minutes until hour six, then hourly, for up to 72 hours. Records Amba writes usually verify within minutes.
Each entry in records carries a publish_state: published (the record is
in DNS), pending (Amba writes it on the next check), conflict (see below),
or not_needed (DMARC: the domain already has a policy, which Amba keeps).
dns_status is published only while every record is published or
not_needed. When the domain verifies, status becomes verified and Amba
sends the email.domain.verified control event to any webhook subscribed to
it (see amba_control_webhooks_create).
Amba changes only what sending needs:
- An identical record already in DNS is left as it is.
- The domain's root MX and SPF records are never touched, so email forwarding on the domain keeps working.
- An existing DMARC policy is kept as it is, including a policy delegated
with a CNAME at
_dmarc. If a DMARC policy is added to the domain later, Amba removes itsp=nonerecord on the next verify. - A different record at a name Amba needs (another SPF policy on
send., another DKIM key at the same selector, a CNAME at a TXT or MX name, or any record at the name of a return-path CNAME other than that same CNAME) is a conflict. Amba leaves that record in place and answers409 DNS_RECORD_CONFLICTwith a message naming the record; the domain stays added (details.domain). If a site or function domain on this project uses that name, remove that domain; otherwise contact Amba support to have the record removed. Then call verify. The automatic checks also publish once the conflict is gone. - Removing the sending domain deletes exactly the records Amba created.
A domain that another project bought through Amba can only be added by that
project (409 DOMAIN_CLAIMED). A sending domain you added before its purchase
completed moves to this path as soon as the purchase completes: Amba
publishes its records on the next automatic check.
Domains hosted elsewhere
The add response lists every record to create under records, each with its
own status:
| Record | Type | Name | Value |
|---|---|---|---|
| DKIM | TXT | <selector>._domainkey.<domain> | The public key in records[].value |
| SPF | MX | send.<domain> | Priority and host in records[] |
| SPF | TXT | send.<domain> | The v=spf1 … value in records[] |
| SPF | CNAME | rsend.<domain> | The target host in records[], when the list includes it |
| DMARC | TXT | _dmarc.<domain> | v=DMARC1; p=none; (recommended_dmarc, only when the domain has no policy) |
Create every entry in records, CNAME included, with names and values exactly
as returned. DNS changes can take up to 72 hours to propagate. Call verify
once the records exist; Amba then checks again automatically for 72 hours
(next_check_at shows the next check and checks_until the end of the
window). If the window ends before the domain verifies, it stays pending
and next_step asks you to call verify again.
Verification can finish in steps. While some records are verified and the
rest are still being checked, provider_status is partially_verified,
status stays pending (email keeps going from the shared mailbox), the
automatic checks keep running, and next_step names each record still
unverified, for example
3 of 4 records are verified. Still unverified: SPF CNAME rsend.example.com.
Each record's own status shows the same detail. A verified domain that
drops back to pending (for example when it needs a newly required record)
starts a new 72-hour window of automatic checks; for a domain bought through
Amba, the first of them publishes the new record.
Default sender
Once the domain's status is verified, make an address on it the project's
default sender:
default_from_email must be on a verified sending domain; otherwise the
update answers 400 INVALID_SENDER. The default sender applies to every
project send and to passwordless sign-in email (one-time codes and magic
links). If the domain later loses verification or is removed, Amba falls back
to its shared mailbox, and the domain response reports
default_sender_fallback: true until it verifies again.
Your app code stays the same. The client SDKs need no change because the server picks the sender, so sign-in codes and magic links carry the branded sender as soon as it is set.
Templates
A template is a named subject + HTML body, with an optional plain-text
body. Template names must match ^[a-z][a-z0-9_]*$. The body uses
{{variable}} placeholders that are filled in from the data you pass at
send time. When no text_body is set, a plain-text version is derived
from the HTML automatically.
POST .../email/templates is an upsert — registering a template whose
name already exists overwrites the subject and body.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | ^[a-z][a-z0-9_]*$. |
subject | string | yes | Non-empty. Supports {{variables}}. |
html_body | string | yes | Supports {{variables}}. |
text_body | string | no | Plain-text fallback. Auto-derived if omitted. |
Sending
POST .../email/send accepts one of three body shapes:
All shapes also accept from: {email, name?}, reply_to (one address or a
list), headers, tags, category, and idempotency_key. An explicit
from.email must be on this project's verified domain; otherwise the
request returns 400 INVALID_SENDER before delivery. Supported custom
headers are List-Unsubscribe, List-Unsubscribe-Post, and
X-Entity-Ref-ID. List-Unsubscribe-Post must equal
List-Unsubscribe=One-Click. Structural mail headers cannot be set.
Idempotency keys are scoped to the project and protect provider sends for
24 hours; retry with the same payload.
Delivery records include from_email and sending_domain, including when
the shared mailbox was used.
Templated, to an end user (looks up the user's email from your project):
Templated, to a raw address:
Ad-hoc (no template):
The response carries the delivery id and status:
status is one of queued (accepted for delivery), suppressed (the
recipient is on the suppression list — nothing was sent), or failed.
Deliveries
Every send writes a delivery row you can list and inspect. Filter by
?template=, ?status=, and ?limit= (max 200).
A delivery's status progresses through queued → sent, or lands on
suppressed / failed. last_error carries a human-readable reason when
a send fails.
Suppressions
Bounces and complaints add the address to the suppression list
automatically; subsequent sends to a suppressed address return
status: "suppressed" without contacting the recipient. You can also add
a manual suppression, or remove one to unblock an address you've
confirmed is valid again.
reason is one of bounce, complaint, or manual (default manual).
Filter the list with ?reason=.
From the app
POST /v1/client/email/send sends a template to the signed-in user from your app, with
the client API key and the user's session token. The body is
{ "template": "receipt", "data": { … } }; the recipient is always the signed-in user.
- The user's email must be verified (an email OTP or magic link sign-in, an email OTP
link, or an Apple / Google email the provider verified). Otherwise the call answers
403 EMAIL_NOT_VERIFIEDand sends nothing. - Every
datavalue is HTML-escaped where the template's HTML body uses it, so a value shows as text. Put markup in the template itself. The subject and text body use the values as written. - Each user can send 10 emails an hour and 50 a day, and each project accepts 1,000
sends from the app an hour. Past a budget the call answers
429 RATE_LIMIT_EXCEEDEDwithRetry-After.
Sends from your server, an agent, or a function (below) keep the full send API: any recipient, unescaped template values, and no app-send budget.
From an agent or function
-
MCP — the
amba_email_*tools (amba_email_send, the template and suppression operations, delivery reads, andamba_email_domains_*) wrap the endpoints above so an agent can send mail without touching HTTP. Pass yourpatand theproject_id. The sending-domain tools end their result with the domain'snext_step, so an agent knows whether to ask a human for DNS changes (dns_managed: false) or simply wait (dns_managed: true). -
Functions — call the send endpoint from a function using the injected
env.AMBA_INTERNAL_TOKEN:
Errors
| Code | Status | Meaning |
|---|---|---|
INVALID_TEMPLATE_NAME | 400 | Name doesn't match ^[a-z][a-z0-9_]*$. |
TEMPLATE_NOT_FOUND | 404 | Send referenced a template that isn't registered. |
USER_NOT_FOUND | 404 | to.user_id has no email on file. |
EMAIL_VALIDATION | 400 | The send payload was malformed. |
EMAIL_NOT_VERIFIED | 403 | Send from the app: the signed-in user's email is not verified. |
RATE_LIMIT_EXCEEDED | 429 | Send from the app: the user's or the project's send budget is spent. Retry after Retry-After seconds. |
EMAIL_PROVIDER_REJECTED | 502 / 503 | The mail couldn't be handed off; safe to retry on 503. |
INVALID_SENDER | 400 | from.email or default_from_email is not on a verified sending domain of this project. |
INVALID_DOMAIN | 400 | The sending domain is not a valid DNS name. |
DOMAIN_NOT_FOUND | 404 | The project has no such sending domain. |
DOMAIN_CLAIMED | 409 | The domain belongs to another project (added there first, or bought through Amba by it). |
DNS_RECORD_CONFLICT | 409 | Domain bought through Amba: a record Amba did not create holds a name Amba needs. The message names it; details.domain is the added domain. |
DNS_CLEANUP_FAILED | 502 | Removing a domain bought through Amba could not delete the records Amba created. Nothing was removed; retry. |
FREE_TIER_LIMIT_REACHED | 402 | Free project: sends and custom templates are a paid feature (details.limit is email_send or email_templates). Sign-in email keeps working up to 25 a day. See free-tier limits. |