Settings & domains

Everything the settings screens do is reachable over HTTP. An agent can set up an account from nothing, find out why a send is refused, and fix it — without a browser.

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.
CodeWhat it meansHow to clear it
sending_disabledSending is switched off application-wideNothing you can do from the API
organization_not_allowedThe organization is not on the sending allowlistAsk the operator to allow it
sending_pausedThe email provider suspended sending for this organizationNothing from the API — see below
domain_not_verifiedA domain is declared but its DKIM is not SUCCESSPublish the DNS records, then verify
domain_ownership_unprovenDKIM is SUCCESS, but this organization has not proved it owns the domainPublish the _agentmail-challenge TXT, then verify
sender_not_on_verified_domainA 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_identityNo sender address is set — reported only alongside a real blockerPATCH /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:
statusWhenWhat to do
suspendedThe email provider suspended sending for this organizationNothing from the API — see the callout
sending_disabledSending is switched off application-wideWait — nothing you can do from the API
not_allowedThe organization is not on the sending allowlistAsk the operator to allow it
verification_failedSending is blocked on domains, and one of them failed DKIM verificationRe-check the DNS records, then verify
dns_pendingSending is blocked on domains for any other reason, including none declaredDeclare a domain, publish DNS, verify
ownership_pendingDKIM succeeded, but the domain ownership is not proved yetPublish the challenge TXT, then verify
sender_mismatchA domain is verified, but the sender address is not on itPATCH /api/v1/settings/sending
incompleteNothing blocks, but the sending isolation is not fully in placeNothing — the operator retries it
activeNothing blocks, and the isolation is in placeSend
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.
Two domains, one verified and one failed? 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.
  1. 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"
  2. Read the records to publish. GET /api/v1/domains returns them. Seven in practice: one TXT for the ownership challenge, three DKIM CNAME, one MX and one TXT for the custom MAIL FROM, one TXT for 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 returns SUCCESS to whoever declares it, so verified also requires this proof. A TXT name 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;"}
          ]
        }
      ]
    }
  3. Publish them at the registrar. That part is outside the API.
  4. Re-check. POST /api/v1/domains/{domainId}/verify asks SES again and stores the answer. Poll it: DNS propagation takes from a few minutes to 72 h.
  5. Confirm. GET /api/v1/me now 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 the TXT 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/sendingadmin 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"
A field left out stays as it is. A field set to 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."
}
With no verified domain yet, any address is accepted: the sender is often set before DNS is published. 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/optinadmin 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"
FieldEffect
requiredWhen true, the organization forces double opt-in: doubleOptIn: false sent to the contact API no longer has any effect
subjectSubject of the confirmation email
contentIts 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.
Available variables: {{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-sendadmin 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"
The 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 the admin 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

EndpointRoleWhat it does
GET /api/v1/settingsmemberThe whole settings state in one read
PATCH /api/v1/settings/sendingadminSender address, display name, postal address
PATCH /api/v1/settings/optinadminDouble opt-in and the confirmation email
POST /api/v1/settings/optin/test-sendadminSends a test of the confirmation email, with a fake link
GET /api/v1/domainsmemberDeclared domains, statuses and DNS records to publish
POST /api/v1/domainsadminDeclares a domain and creates its SES identity
GET /api/v1/domains/{domainId}memberOne domain with its DNS records
POST /api/v1/domains/{domainId}/verifyadminRe-checks against SES and stores the status
DELETE /api/v1/domains/{domainId}adminRemoves the declaration — the SES identity is kept