Amba

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

MethodPathDescription
POST/admin/projects/:projectId/email/templatesRegister or update a template (upsert).
GET/admin/projects/:projectId/email/templatesList templates.
GET/admin/projects/:projectId/email/templates/:nameDescribe one template.
PATCH/admin/projects/:projectId/email/templates/:nameUpdate a template's fields.
DELETE/admin/projects/:projectId/email/templates/:nameDelete a template.
POST/admin/projects/:projectId/email/sendSend (by template or ad-hoc).
GET/admin/projects/:projectId/email/deliveriesList deliveries (filter by template/status).
GET/admin/projects/:projectId/email/deliveries/:idDescribe one delivery.
POST/admin/projects/:projectId/email/suppressionsAdd a manual suppression.
GET/admin/projects/:projectId/email/suppressionsList suppressions.
DELETE/admin/projects/:projectId/email/suppressions/:emailRemove a suppression (unblock).
POST/admin/projects/:projectId/email/domainsAdd a sending domain.
GET/admin/projects/:projectId/email/domainsList domains with DNS and verification status.
GET/admin/projects/:projectId/email/domains/:domainRefresh one domain's status.
POST/admin/projects/:projectId/email/domains/:domain/verifyPublish (Amba-managed) and verify.
DELETE/admin/projects/:projectId/email/domains/:domainRemove 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 AmbaHosted elsewhere
dns_managedtruefalse
Who creates the recordsAmbaYou, at your DNS host
DMARCAmba adds v=DMARC1; p=none; when the domain has no policyrecommended_dmarc lists the record when the domain has none
Verification startsAs soon as Amba has written the recordsWhen you call verify
Automatic re-checksFor 72 hours after Amba writes the records (and after any verify call)For 72 hours after each verify call
Your next stepNone. Wait for status: "verified" or the email.domain.verified eventCreate 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 email domains add "$PROJECT_ID" example.com

Amba then:

  1. 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 as rsend.example.com, and _dmarc TXT v=DMARC1; p=none; when the domain has no DMARC policy. For a subdomain, a policy on the parent domain already covers it.
  2. Starts verification once every one of those records is in DNS (dns_status: "published").
  3. 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 its p=none record 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 answers 409 DNS_RECORD_CONFLICT with 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

amba email domains add "$PROJECT_ID" example.com
# Create the listed records at your DNS host, then:
amba email domains verify "$PROJECT_ID" example.com

The add response lists every record to create under records, each with its own status:

RecordTypeNameValue
DKIMTXT<selector>._domainkey.<domain>The public key in records[].value
SPFMXsend.<domain>Priority and host in records[]
SPFTXTsend.<domain>The v=spf1 … value in records[]
SPFCNAMErsend.<domain>The target host in records[], when the list includes it
DMARCTXT_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.

{
  "data": {
    "domain": "example.com",
    "status": "pending",
    "dns_managed": false,
    "dns_status": null,
    "records": [
      {
        "record": "SPF",
        "type": "MX",
        "name": "send.example.com",
        "value": "…",
        "priority": 10,
        "status": "not_started",
        "publish_state": null
      }
    ],
    "recommended_dmarc": {
      "record": "DMARC",
      "type": "TXT",
      "name": "_dmarc.example.com",
      "value": "v=DMARC1; p=none;"
    },
    "next_check_at": null,
    "next_step": "Create the records listed in records and the recommended_dmarc record at your DNS host, then call verify. …"
  }
}

Default sender

Once the domain's status is verified, make an address on it the project's default sender:

amba projects update "$PROJECT_ID" \
  --default-from-email support@example.com \
  --default-from-name "Example" \
  --default-reply-to support@example.com

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.

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/email/templates' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "welcome",
    "subject": "Welcome to {{app_name}}, {{first_name}}!",
    "html_body": "<h1>Hi {{first_name}}</h1><p>Thanks for joining {{app_name}}.</p>"
  }'
{ "data": { "name": "welcome", "created_at": "2026-05-27T10:00:00.000Z" } }
FieldTypeRequiredNotes
namestringyes^[a-z][a-z0-9_]*$.
subjectstringyesNon-empty. Supports {{variables}}.
html_bodystringyesSupports {{variables}}.
text_bodystringnoPlain-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.

{
  "to": { "email": "listener@example.com" },
  "template": "newsletter",
  "from": { "email": "support@example.com", "name": "Example" },
  "reply_to": "support@example.com",
  "headers": {
    "List-Unsubscribe": "<https://example.com/unsubscribe?token=example>",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
  },
  "category": "marketing",
  "idempotency_key": "newsletter-2026-10-08-listener-123"
}

Templated, to an end user (looks up the user's email from your project):

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/email/send' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": { "user_id": "usr_123" },
    "template": "welcome",
    "data": { "first_name": "Sam", "app_name": "Acme" }
  }'

Templated, to a raw address:

{
  "to": { "email": "sam@example.com" },
  "template": "welcome",
  "data": { "first_name": "Sam", "app_name": "Acme" }
}

Ad-hoc (no template):

{
  "to": { "email": "sam@example.com" },
  "subject": "Your export is ready",
  "html": "<p>Download it <a href=\"https://...\">here</a>.</p>",
  "text": "Download it here: https://..."
}

The response carries the delivery id and status:

{
  "data": {
    "id": "del_abc",
    "status": "queued",
    "provider_message_id": "…"
  }
}

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).

curl 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/email/deliveries?template=welcome&status=sent' \
  -H 'Authorization: Bearer $AMBA_PAT'
{
  "data": [
    {
      "id": "del_abc",
      "template": "welcome",
      "to_email": "sam@example.com",
      "app_user_id": "usr_123",
      "provider_message_id": "…",
      "status": "sent",
      "last_error": null,
      "created_at": "2026-05-27T10:00:01.000Z",
      "delivered_at": "2026-05-27T10:00:03.000Z"
    }
  ]
}

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.

# Block an address manually
curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/email/suppressions' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "email": "bounced@example.com", "reason": "manual" }'
 
# Unblock it later
curl -X DELETE 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/email/suppressions/bounced@example.com' \
  -H 'Authorization: Bearer $AMBA_PAT'

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_VERIFIED and sends nothing.
  • Every data value 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_EXCEEDED with Retry-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, and amba_email_domains_*) wrap the endpoints above so an agent can send mail without touching HTTP. Pass your pat and the project_id. The sending-domain tools end their result with the domain's next_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:

    await fetch(`${env.AMBA_API_URL}/v1/admin/projects/${env.AMBA_PROJECT_ID}/email/send`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${env.AMBA_INTERNAL_TOKEN}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ to: { user_id: userId }, template: 'welcome', data: { first_name } }),
    });

Errors

CodeStatusMeaning
INVALID_TEMPLATE_NAME400Name doesn't match ^[a-z][a-z0-9_]*$.
TEMPLATE_NOT_FOUND404Send referenced a template that isn't registered.
USER_NOT_FOUND404to.user_id has no email on file.
EMAIL_VALIDATION400The send payload was malformed.
EMAIL_NOT_VERIFIED403Send from the app: the signed-in user's email is not verified.
RATE_LIMIT_EXCEEDED429Send from the app: the user's or the project's send budget is spent. Retry after Retry-After seconds.
EMAIL_PROVIDER_REJECTED502 / 503The mail couldn't be handed off; safe to retry on 503.
INVALID_SENDER400from.email or default_from_email is not on a verified sending domain of this project.
INVALID_DOMAIN400The sending domain is not a valid DNS name.
DOMAIN_NOT_FOUND404The project has no such sending domain.
DOMAIN_CLAIMED409The domain belongs to another project (added there first, or bought through Amba by it).
DNS_RECORD_CONFLICT409Domain 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_FAILED502Removing a domain bought through Amba could not delete the records Amba created. Nothing was removed; retry.
FREE_TIER_LIMIT_REACHED402Free 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.

On this page