Can I send? — the one call
GET /api/v1/me answers it. Several separate guards can refuse a send, and until now they refused
without saying which one: this block names them.
curl -H "x-api-key: $MK_API_KEY" "https://agentsmail.io/api/v1/me"{
"success": true,
"data": {
"organization": {"id": "…", "name": "Atelier Nord", "slug": "atelier-nord"},
"organizationRole": "admin",
"keyId": "…",
"sending": {
"ready": false,
"blockers": ["domain_not_verified"],
"setup": {
"status": "dns_pending",
"provisioning": "provisioned"
}
}
}
}Blocker codes
They are stable and never translated — this is a machine interface. Build your remediation on them, not on a message.| Code | What it means | How to clear it |
|---|---|---|
sending_disabled | Sending is switched off application-wide | Nothing you can do from the API |
organization_not_allowed | The organization is not on the sending allowlist | Ask the operator to allow it |
sending_paused | The email provider suspended sending for this organization | Nothing from the API — see below |
domain_not_verified | A domain is declared but its DKIM is not SUCCESS | Publish the DNS records, then verify |
domain_ownership_unproven | DKIM is SUCCESS, but this organization has not proved it owns the domain | Publish the _agentmail-challenge TXT, then verify |
sender_not_on_verified_domain | A domain is verified, but the sender address is not on it (nor on one of its subdomains) | PATCH /api/v1/settings/sending with an address on the verified domain |
no_sender_identity | No sender address is set — reported only alongside a real blocker | PATCH /api/v1/settings/sending |
sending_paused is not something you can retry. It appears when the email provider
suspends the organization — reputation, usually a bounce or complaint rate. Every send to your
contacts is refused while it is there: campaigns, sequences, opt-in confirmations. The
campaign test send stays available, so you can still check how a campaign renders while you
sort the suspension out — it goes out under the application address, not yours. The blocker
clears on its own when the provider re-enables sending, and never from the API. Fix what caused
it — clean your lists, stop importing cold contacts — and talk to the operator.no_sender_identity alone is not a blocker. With no sending identity set, emails still go out
under the application address. It appears only when something else is already blocking, to tell
you the account is bare.sender_not_on_verified_domain is a real blocker, and it is yours to fix. A verified domain
only lets you send from that domain: with codemail.io verified, hello@codemail.io and
news@news.codemail.io go out, hello@example.org does not. Without this check the provider
accepts the batch and then fails every message with "Email address is not verified" — nothing
in the product would tell you why. It only ever appears once at least one domain is verified:
before that, set the sender address freely.Setup status
sending.setup is the same state the account owner reads on screen, as one code instead of a list.
It is purely informational: it never enters blockers and never changes ready. Gate your
sends on ready, and use setup.status to say where the account stands in one word.
status is derived, first match wins, in this order:
status | When | What to do |
|---|---|---|
suspended | The email provider suspended sending for this organization | Nothing from the API — see the callout |
sending_disabled | Sending is switched off application-wide | Wait — nothing you can do from the API |
not_allowed | The organization is not on the sending allowlist | Ask the operator to allow it |
verification_failed | Sending is blocked on domains, and one of them failed DKIM verification | Re-check the DNS records, then verify |
dns_pending | Sending is blocked on domains for any other reason, including none declared | Declare a domain, publish DNS, verify |
ownership_pending | DKIM succeeded, but the domain ownership is not proved yet | Publish the challenge TXT, then verify |
sender_mismatch | A domain is verified, but the sender address is not on it | PATCH /api/v1/settings/sending |
incomplete | Nothing blocks, but the sending isolation is not fully in place | Nothing — the operator retries it |
active | Nothing blocks, and the isolation is in place | Send |
provisioning is the raw isolation state — none, provisioned or incomplete. none is not a
fault: it simply means nothing has been attempted yet, and it does not stop a send.
An organization that is not allowed to send never reads
active. That is the whole point of
the ordering: a green light for a send the service would refuse is worse than no light at all. A
verified domain does not make an account allowed, and setup.status says so.blockers is empty, ready is true, and
setup.status is active — the account sends fine. verification_failed only ever shows up when
the failure is actually blocking.
Setting up a domain, end to end
This is the loop an agent runs. Nothing here needs a human.-
Declare the domain.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \ -d '{"domain":"atelier-nord.fr"}' \ "https://agentsmail.io/api/v1/domains" -
Read the records to publish.
GET /api/v1/domainsreturns them. Seven in practice: oneTXTfor the ownership challenge, three DKIMCNAME, oneMXand oneTXTfor the custom MAIL FROM, oneTXTfor DMARC. The challenge record comes first, and it is the one that decides usage. Its name is_agentmail-challenge.<domain>and its value is the organization's own token. DKIM alone is not enough: an identity already verified in the provider account returnsSUCCESSto whoever declares it, soverifiedalso requires this proof. ATXTname accepts several values — two organizations transferring a domain can publish side by side. The challenge record is served even when the provider is unreachable, because its value comes from our own database.{ "success": true, "data": [ { "id": "…", "domain": "atelier-nord.fr", "dkimStatus": "PENDING", "mailFromStatus": "PENDING", "verified": false, "ownershipProved": false, "lastCheckedAt": "2026-08-12T12:00:00.000Z", "sesUnavailable": false, "dnsRecords": [ {"type": "TXT", "name": "_agentmail-challenge.atelier-nord.fr", "value": "Xy7pQ2rL9mNv4KtB1sZaWg"}, {"type": "CNAME", "name": "abc._domainkey.atelier-nord.fr", "value": "abc.dkim.amazonses.com"}, {"type": "MX", "name": "mail.atelier-nord.fr", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10}, {"type": "TXT", "name": "mail.atelier-nord.fr", "value": "v=spf1 include:amazonses.com ~all"}, {"type": "TXT", "name": "_dmarc.atelier-nord.fr", "value": "v=DMARC1; p=none;"} ] } ] } - Publish them at the registrar. That part is outside the API.
-
Re-check.
POST /api/v1/domains/{domainId}/verifyasks SES again and stores the answer. Poll it: DNS propagation takes from a few minutes to 72 h. -
Confirm.
GET /api/v1/menow answers"ready": true.
DKIM and ownership gate sending, together.
mailFromStatus and the DMARC record improve
deliverability — a pending MAIL FROM never blocks a send. The verified field means usable:
dkimStatus === "SUCCESS" and the ownership challenge proved, on the same domain. Read
verified and you never have to reimplement the rule; read ownershipProved and dkimStatus
to know which of the two records is still missing.Ownership is proved once, and never revoked
The proof is a dated event, not a continuous state. Once a domain is proved, removing theTXT
record later does not revoke it — otherwise any DNS outage would cut off sending. Re-publishing
the challenge for another organization transfers the domain: a proved domain belongs to exactly one
organization at a time.
Checking is synchronous only: it happens when you call verify or open the domain screen. There is
no background job.
The records are never stored
The provider records are read from SES on every call, because they change if the identity is recreated. Do not cache them: a stale copy sends someone to publish records that no longer verify anything. The challenge record is the exception in origin only — its value is ours, which is why it survives a provider outage — but it is served the same way, never cached by you.When SES is unreachable
Reads do not fail.GET /api/v1/domains returns the last known state with "sesUnavailable": true
and its lastCheckedAt. verify does fail — checking is precisely what you asked for, and
pretending otherwise would be lying.
Reading everything at once
GET /api/v1/settings returns the whole state in one call.
{
"success": true,
"data": {
"sending": {
"senderEmail": "bonjour@atelier-nord.fr",
"senderName": "Atelier Nord",
"postalAddress": "12 rue des Lilas\n59000 Lille",
"effectiveSender": "Atelier Nord <bonjour@atelier-nord.fr>"
},
"optin": {
"required": true,
"subject": "Confirme ton inscription",
"content": "<div>…</div>",
"customized": false
},
"readiness": {
"ready": true,
"blockers": [],
"setup": {"status": "active", "provisioning": "provisioned"}
}
}
}readiness here is the very same object GET /api/v1/me returns under sending, setup
included — one question, one answer, two places to read it.
effectiveSender is what will actually go out, fallback included — that is what you want to check,
not the column.
Sending identity
PATCH /api/v1/settings/sending — admin role required.
curl -X PATCH -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"senderName":"Atelier Nord"}' \
"https://agentsmail.io/api/v1/settings/sending"null is cleared and falls back to the
application address. Without that distinction, setting only the display name would wipe the sender
address.
The sender address must be on a verified domain
Once the organization has at least one verified domain,senderEmail must be on it — or on one
of its subdomains. Anything else is refused with 400, naming both facts:
curl -X PATCH -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"senderEmail":"hello@example.org"}' \
"https://agentsmail.io/api/v1/settings/sending"{
"error": "Erreur de validation : Votre domaine codemail.io est vérifié — mais hello@example.org n’est pas dessus. Utilisez une adresse sur ce domaine ou sur l’un de ses sous-domaines."
}domain_not_verified still blocks the sends until a domain is verified, and
sender_not_on_verified_domain takes over from there.
Sign-up settings
PATCH /api/v1/settings/optin — admin role required.
curl -X PATCH -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"required":true,"subject":"Confirm your sign-up","content":"<p><a href=\"{{confirmationUrl}}\">Confirm</a></p>"}' \
"https://agentsmail.io/api/v1/settings/optin"| Field | Effect |
|---|---|
required | When true, the organization forces double opt-in: doubleOptIn: false sent to the contact API no longer has any effect |
subject | Subject of the confirmation email |
content | Its HTML |
The content must contain
{{confirmationUrl}}. Without it the recipient has no way to
confirm, and double opt-in breaks silently. A content without it is refused with 400, naming the
missing variable.{{confirmationUrl}} (required), {{firstName}}, {{lastName}}, {{email}},
{{postalAddress}}, {{currentYear}}. There is no unsubscribe variable — the recipient is not
subscribed yet.
Sending the default content back is not a customization. A content identical to the default is
stored as empty, so the organization keeps inheriting future improvements to it. customized tells
you which case you are in.
Testing the confirmation email
POST /api/v1/settings/optin/test-send — admin role required.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"email":"you@example.com","content":"<p><a href=\"{{confirmationUrl}}\">Confirm</a></p>"}' \
"https://agentsmail.io/api/v1/settings/optin/test-send"content and subject of the body win over what is stored: try before you publish. The
confirmation link in a test is fake — testing three times must not put three real, valid tokens
into circulation.
A test is a real SES send: it passes the same guards as any other. Answers {"status":"blocked"}
(sending switched off application-wide), {"status":"not_allowed"} (organization not allowed, or
domain not verified) or {"status":"send_refused"} (the provider refused the send) rather than
failing, so you can tell a refusal from an error — and tell the refusals apart.
Roles and scoping
Reads need only membership. Every write needs theadmin role in the organization.
The organization always comes from the key, never from the URL or the body. A domainId belonging
to another organization answers 404, never 403 — a different code would turn the identifier into
an existence oracle.
Endpoint reference
| Endpoint | Role | What it does |
|---|---|---|
GET /api/v1/settings | member | The whole settings state in one read |
PATCH /api/v1/settings/sending | admin | Sender address, display name, postal address |
PATCH /api/v1/settings/optin | admin | Double opt-in and the confirmation email |
POST /api/v1/settings/optin/test-send | admin | Sends a test of the confirmation email, with a fake link |
GET /api/v1/domains | member | Declared domains, statuses and DNS records to publish |
POST /api/v1/domains | admin | Declares a domain and creates its SES identity |
GET /api/v1/domains/{domainId} | member | One domain with its DNS records |
POST /api/v1/domains/{domainId}/verify | admin | Re-checks against SES and stores the status |
DELETE /api/v1/domains/{domainId} | admin | Removes the declaration — the SES identity is kept |