Send through your own Amazon SES
Connect the Amazon SES account in your own AWS account, and mail from the domains verified there goes out through it. Amazon bills you for those emails at its own price. mails.ai still runs its rules on every send, keeps each message and its events, and puts the bounces and complaints Amazon reports on your suppression list.
Before you start
- A paid mails.ai plan. On Free, setup and connect answer
402 feature_not_enabled, and agents send through mails.ai. - An API key with the
managescope, not tied to one agent: the connection is the whole workspace’s, since every agent on its domains sends through it. A connected app can read the connection but not change it. - An AWS account with Amazon SES in the standard AWS partition (not AWS China or GovCloud), with the domains you send from verified as identities in one SES region.
Connect your account
Get the role to create
POST /v1/ses-connection/setupanswers with the IAM role your AWS account needs, filled in for your workspace. Calling it again answers the same.curl -X POST https://api.mails.ai/v1/ses-connection/setup \ -H "Authorization: Bearer $MAILS_API_KEY" \ -o setup.json{ "object": "ses_connection_setup", "principal_arn": "arn:aws:iam::…", "external_id": "mails-…", "trust_policy": { "Version": "2012-10-17", "Statement": [ { "Sid": "MailsAiAssumeRole", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::…" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "mails-…" } } } ] }, "permissions_policy": { "Version": "2012-10-17", "Statement": [ … ] }, "events_endpoint": "https://api.mails.ai/api/internal/byo-ses-events", "steps": [ … ], "connection": { "object": "ses_connection", "status": "pending", … } }The
trust_policylets mails.ai’s AWS principal assume the role, and only with your workspace’sexternal_id. The ID is made the first time you call setup and never changes. It is not a secret: it keeps another mails.ai workspace that learns your role’s ARN from connecting it. Thepermissions_policyis the least that sending and the bounce reports need; each statement is set out below.Create the role in AWS
In the IAM console, create a role with a custom trust policy and paste
trust_policyinto it, then addpermissions_policyto the role as an inline policy. Or, with the AWS CLI and the answer you saved:jq .trust_policy setup.json > trust-policy.json jq .permissions_policy setup.json > permissions-policy.json aws iam create-role --role-name mails-ai-ses \ --assume-role-policy-document file://trust-policy.json \ --query Role.Arn --output text aws iam put-role-policy --role-name mails-ai-ses \ --policy-name mails-ai-ses \ --policy-document file://permissions-policy.jsonThe first command prints the role’s ARN, which the next step takes.
Connect it
Send the role’s ARN and the SES region you send from:
curl https://api.mails.ai/v1/ses-connection \ -H "Authorization: Bearer $MAILS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "role_arn": "arn:aws:iam::123456789012:role/mails-ai-ses", "region": "us-east-1" }'Before it answers, mails.ai:
- tries to assume the role without your external ID, and refuses a role that lets it in;
- assumes it with the ID, and reads the account: sandbox or production, sending on or paused, the quota;
- makes a configuration set,
mails-ai-and your workspace ID, and an SNS topic,mails-ai-ses-events-and the same ID, which only your account’s SES may publish to, and only for that configuration set; - subscribes its HTTPS endpoint to the topic, and points the configuration set’s bounce, complaint and delivery events at it.
The answer is the connection:
{ "object": "ses_connection", "id": "sesc_01K8…", "status": "connected", "external_id": "mails-…", "role_arn": "arn:aws:iam::123456789012:role/mails-ai-ses", "aws_account_id": "123456789012", "region": "us-east-1", "configuration_set": "mails-ai-wsk_01k8…", "events": { "sns_topic_arn": "arn:aws:sns:us-east-1:123456789012:mails-ai-ses-events-wsk_01k8…", "confirmed": false, "confirmed_at": null, "types": ["BOUNCE", "COMPLAINT", "DELIVERY"] }, "account": { "production_access": true, "sandbox": false, "sending_enabled": true, "enforcement_status": "HEALTHY", "max_24_hour_send": 50000, "max_send_rate": 14, "sent_last_24_hours": 0, "checked_at": "2026-10-21T09:30:00.000Z" }, "last_error": null, "connected_at": "2026-10-21T09:30:00.000Z", "disconnected_at": null, "created_at": "2026-10-21T09:28:00.000Z" }A step the role may not take answers
400 ses_permission_denied, quoting the statement that grants it. Fix it and connect again: what was made before stays, and connecting again uses it.Wait for the reports to be confirmed
Amazon SNS sends mails.ai a request to confirm the subscription, and mails.ai confirms it. Until then, nothing is sent through your account: a send from one of its domains answers
409 ses_events_not_confirmed, since its bounces would not reach your suppression list. Checkevents.confirmed:curl https://api.mails.ai/v1/ses-connection \ -H "Authorization: Bearer $MAILS_API_KEY"Send
Send from an address on a domain verified in that region, or on a subdomain of one. It goes out through your account:
curl https://api.mails.ai/v1/messages \ -H "Authorization: Bearer $MAILS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "hello@acme.com", "to": "customer@example.com", "subject": "Your receipt", "body_text": "Thanks for your order." }'An app on Resend’s SDK can do the same with no code change: see Resend compatibility.
Check the account
POST /v1/ses-connection/test assumes the role again, reads the account and answers the connection with what it found. It changes nothing in your account.
curl -X POST https://api.mails.ai/v1/ses-connection/test \
-H "Authorization: Bearer $MAILS_API_KEY"account.sandbox: true: the account is in Amazon’s sandbox, where SES sends only to addresses verified in the account. Production access is something you ask Amazon for, in the SES console.account.sending_enabled: false: sending is paused in the account.account.max_24_hour_sendandaccount.max_send_rate: Amazon’s quota for the account, which binds every send through it.
How a send through your account works
- Which mail goes through it. Mail from a domain that is a verified identity in the connected region, or from a subdomain of one, goes through your account, even when you verified the same domain at mails.ai too. An email address verified in SES does not count: the domain must be. mails.ai remembers SES’s answer for a domain for up to ten minutes, or one minute when the domain is not verified. Mail from your mails.ai addresses, and from domains verified only at mails.ai, goes out as before.
- The rules still run, as on any send: prohibited content, phishing links and plainly cold or bulk mail are refused, and suppression and the agent’s own limits and reputation apply. The AI review that also reads mail sent through mails.ai is skipped for a send that goes out at once through your account, which goes out on your account’s reputation. A scheduled send is still reviewed.
- Your plan’s email caps do not apply. Sends through your account are not counted against your plan’s monthly emails or its hourly and daily caps. They are counted on their own: up to 200,000 a month on Pro and up to 2,500,000 a month on Scale. Past that, a send answers
429 monthly_limit_exceeded. Amazon’s quota applies as well. - Replies come back to the agent. Unless the send names a
reply_to, its Reply-To is the agent’s receiving address at mails.ai (billing.acme_com@in.mails.aiforbilling@acme.com), so a reply arrives as areply.receivedevent. - Test keys never send. A send with an
mk_test_key is stored and raises its events, and reaches neither your account nor anyone’s inbox. - Amazon’s refusals are passed on. When SES refuses a message, the send answers
422 ses_message_rejectedwith Amazon’s reason. When your account is over its sending rate or its daily quota, it answers503 upstream_unavailablewithRetry-After: 60.
Bounces and complaints
Every send through your account carries mails.ai’s configuration set, so Amazon reports its bounces, complaints and deliveries to the SNS topic, and SNS delivers them to mails.ai. A permanent bounce or a complaint puts the address on your workspace’s suppression list. You get message.bounced, message.complained, message.delivered and suppression.added events, as for any send, by polling or a webhook.
Disconnect
DELETE /v1/ses-connection stops sending through your account at once. Then mails.ai deletes the configuration set and the SNS topic it made there, if the role still lets it, and says what happened to each in removed.
curl -X DELETE https://api.mails.ai/v1/ses-connection \
-H "Authorization: Bearer $MAILS_API_KEY"After it, mail from a domain verified only in your SES is refused with 422 domain_not_verified. The connection keeps your external ID, so the same role can be connected again.
You can also end access from AWS: delete the role, or take mails.ai out of its trust policy. mails.ai keeps the role’s temporary credentials until five minutes before they expire, so such a change holds from the next time it asks for new ones, within the hour. Then a send from one of the account’s domains answers 400 ses_role_not_assumable.
What the role lets mails.ai do
ses:GetAccountOn the account: whether it is in the sandbox, whether sending is on, and its quota.ses:GetEmailIdentityOn your identities: whether a From domain is verified in the account.ses:SendEmail,ses:SendRawEmailOn your identities and mails.ai’s configuration sets: the send itself, always with mails.ai’s configuration set, so every send reports its bounces and complaints.ses:CreateConfigurationSet,ses:CreateConfigurationSetEventDestination,ses:UpdateConfigurationSetEventDestination,ses:DeleteConfigurationSetOn configuration sets namedmails-ai-*: the configuration set and its one event destination, made when you connect and deleted when you disconnect.sns:CreateTopic,sns:SetTopicAttributes,sns:DeleteTopicOn topics namedmails-ai-ses-events-*: the topic the reports go to.sns:SubscribeOn those topics, to mails.ai’s HTTPS endpoint only: subscribing mails.ai to the topic. Conditions on the protocol and the endpoint keep the role from pointing your reports anywhere else.
Nothing else: mails.ai never creates, changes or deletes an identity, never reads or writes your account’s suppression list, and touches no configuration set or topic but its own.
Routes
POST /v1/ses-connection/setupThe role to create in your AWS account, filled in for your workspace. Needs themanagescope.POST /v1/ses-connectionConnects the role, from itsrole_arnand your SESregion. Needs themanagescope.GET /v1/ses-connectionThe connection. Needs thereadscope.POST /v1/ses-connection/testLooks at the account again: sandbox or production, sending on or paused, the quota. Needs themanagescope.DELETE /v1/ses-connectionStops sending through the account and removes what connecting made there. Needs themanagescope.
Errors
Each code with its HTTP status. Every one comes in the API’s usual error body.
402 feature_not_enabledThe workspace is not on a paid plan, on setup or connect.400 invalid_param_valuerole_arnis not an IAM role ARN in the standard AWS partition, orregionis not an AWS region.400 ses_role_not_assumablemails.ai could not assume the role. The message quotes the trust policy it needs.400 ses_role_external_id_not_requiredThe role lets mails.ai in without your external ID. Add thests:ExternalIdcondition, as quoted.400 ses_permission_deniedThe role may not take a step. The message quotes the statement that grants it.400 ses_setup_failedYour account refused a step for another reason, which the message gives. What was made before it stays.409 ses_events_not_confirmedSNS has not confirmed the subscription yet, so nothing is sent through the account. Connect again to subscribe again.422 ses_message_rejectedAmazon SES refused the message, in its own words: the sandbox, a paused account, an identity that lost its verification.422 domain_not_verifiedThe From domain is verified neither in your SES nor at mails.ai, for example after you disconnect.429 monthly_limit_exceededYour plan’s monthly number of emails through your own account is used up.503 upstream_unavailableAWS did not answer, or your account is over its sending rate or daily quota.Retry-Aftersays when to try again.404 resource_not_foundNo account is connected.403 insufficient_scopeThe key lacks the scope the route needs, or is tied to one agent.403 connected_app_not_allowedA connected app tried to change the connection: use an API key.
Common questions
Was this page helpful?