Verify a sending domain

Goal: register a domain with a mail provider your account may use, publish exactly the DNS records the API returns, run verification until it passes, and follow the domain’s warmup ramp.
Prerequisites: an API key from the customer portal (section API keys, see Quickstart) with the domains:read and domains:write scopes; access to your domain’s DNS zone editor; the concepts in Domains & warmup.

A domain must reach verified before it can send anything: it is the domain_id of a transactional send, an OTP request, or a campaign, or the domain part of a from address. Which mailbox the mail comes from (billing@…, news@…) is decided separately by sender profiles, described in Domains & warmup. A newly verified domain with warmup enabled then ramps up its daily volume step by step — this guide covers both.

Steps

1. Check which providers your account can use

curl https://emails.routeon.io/api/v1/me 
  -H "Authorization: Bearer <API_KEY>"

Expected response (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": {
    "tenant_id": "<TENANT_ID>",
    "auth_type": "api_key",
    "scopes": ["domains:read", "domains:write", "email:send", "messages:read"],
    "allowed_providers": ["ses", "kumomta"]
  },
  "meta": {}
}

allowed_providers decides what you send in the next step:

allowed_providers What to do
One entry, e.g. ["ses"] provider is optional; that provider is used.
Several entries provider is required — ses (Amazon SES) or kumomta (KumoMTA).
Empty [] No domain can be connected yet; POST /domains answers 422 no_provider_allowed. Contact support.

2. Register the domain

curl -X POST https://emails.routeon.io/api/v1/domains 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{
    "domain": "mail.example.com",
    "provider": "ses",
    "warmup_enabled": true
  }'

warmup_enabled defaults to true; leave it on for a domain that has no sending history. Expected response (201 Created) for a domain on ses:

{
  "status": "success",
  "http_code": 201,
  "payload": {
    "id": "<DOMAIN_ID>",
    "domain": "mail.example.com",
    "status": "pending_dns",
    "provider": "ses",
    "dkim_selector": null,
    "warmup_enabled": true,
    "dns_records": [
      { "record": "SPF", "type": "TXT", "host": "mail.example.com", "value": "v=spf1 include:amazonses.com ~all", "mandatory": true },
      { "record": "DKIM", "type": "CNAME", "host": "<TOKEN_1>._domainkey.mail.example.com", "value": "<TOKEN_1>.dkim.amazonses.com", "mandatory": true },
      { "record": "DKIM", "type": "CNAME", "host": "<TOKEN_2>._domainkey.mail.example.com", "value": "<TOKEN_2>.dkim.amazonses.com", "mandatory": true },
      { "record": "DKIM", "type": "CNAME", "host": "<TOKEN_3>._domainkey.mail.example.com", "value": "<TOKEN_3>.dkim.amazonses.com", "mandatory": true },
      { "record": "DMARC", "type": "TXT", "host": "_dmarc.mail.example.com", "value": "v=DMARC1; p=none", "mandatory": true },
      { "record": "MAIL_FROM", "type": "MX", "host": "bounce.mail.example.com", "value": "feedback-smtp.<REGION>.amazonses.com", "priority": 10, "mandatory": true },
      { "record": "SPF", "type": "TXT", "host": "bounce.mail.example.com", "value": "v=spf1 include:amazonses.com ~all", "mandatory": true },
      { "record": "VERIFY", "type": "TXT", "host": "_platform-verify.mail.example.com", "value": "<VERIFICATION_TOKEN>", "mandatory": true }
    ],
    "verified_at": null,
    "reputation_verdict": "pass",
    "reputation_reasons": [],
    "reputation_checked_at": "2026-09-28T09:00:00Z",
    "created_at": "2026-09-28T09:00:00Z"
  },
  "meta": {}
}

The record set depends on the provider. The same domain on kumomta returns four records instead of eight, and dkim_selector carries the generated selector (on ses it is null):

[
  { "record": "MAIL_FROM", "type": "CNAME", "host": "bounce.mail.example.com", "value": "<BOUNCE_TARGET>", "mandatory": true },
  { "record": "DKIM", "type": "TXT", "host": "<SELECTOR>._domainkey.mail.example.com", "value": "v=DKIM1; k=rsa; p=<PUBLIC_KEY>", "mandatory": true },
  { "record": "DMARC", "type": "TXT", "host": "_dmarc.mail.example.com", "value": "v=DMARC1; p=none", "mandatory": true },
  { "record": "VERIFY", "type": "TXT", "host": "_platform-verify.mail.example.com", "value": "<VERIFICATION_TOKEN>", "mandatory": true }
]
Provider Records to publish
ses 8: SPF TXT on the domain, three DKIM CNAMEs, DMARC TXT, MX and SPF TXT on bounce.<domain>, ownership TXT
kumomta 4: CNAME on bounce.<domain>, DKIM TXT, DMARC TXT, ownership TXT

The <…> values above are placeholders: the real DKIM tokens or key, the bounce target and the verification token (a UUID) are generated for your domain and come back in your own response. Registration also runs an automatic reputation check on the domain; a rejected domain is not created (see the failures table).

3. Publish the DNS records

Create every entry of dns_records at your DNS provider exactly as returned — type, host, value, and priority for the MX record. All of them are mandatory.

  • Every host is a fully-qualified name. If your zone editor appends the zone origin to what you type, enter only the leading labels (_dmarc.mail, not _dmarc.mail.example.com, in a zone for example.com).
  • Copy values verbatim. A long DKIM TXT value may be split into several strings by your DNS panel; that is fine.
  • VERIFY proves you control the domain. It has no mail-authentication role but is required on every provider.
  • On kumomta, bounce.<domain> must hold only the CNAME — remove any MX or TXT left there from an earlier setup. On ses, the same name holds the MX and the SPF TXT, and must not hold a CNAME.
  • If you already publish a DMARC record for the domain, keep yours instead of adding a second one.

You can re-read the record set at any time:

curl https://emails.routeon.io/api/v1/domains/<DOMAIN_ID> 
  -H "Authorization: Bearer <API_KEY>"

4. Run verification

curl -X POST https://emails.routeon.io/api/v1/domains/<DOMAIN_ID>/verify 
  -H "Authorization: Bearer <API_KEY>"

The call answers with a 2xx status; its payload while records are still missing:

{
  "domain_id": "<DOMAIN_ID>",
  "status": "verifying",
  "ok": false,
  "reasons": [
    { "record": "VERIFY", "code": "ownership_not_verified" },
    { "record": "DKIM", "code": "ses_not_verified_for_sending" },
    { "record": "MAIL_FROM", "code": "ses_mail_from_not_verified" }
  ]
}

Once every check passes, the same call answers "status": "verified", "ok": true and an empty reasons array.

Verification happens only when you call this endpoint — there is no background re-check, and a domain never becomes failed because time ran out. The first unsuccessful call moves the domain from pending_dns to verifying; after that it stays there until a call succeeds. Wait for DNS propagation and call it again until ok is true. On ses, Amazon SES detects the DKIM and MAIL FROM records on its own schedule, so ses_not_verified_for_sending and ses_mail_from_not_verified can remain for a while after your records are already visible — keep repeating the call.

Reason code Meaning
ownership_not_verified The _platform-verify TXT record is missing or has a different value.
ses_not_verified_for_sending Amazon SES has not yet confirmed the three DKIM CNAMEs.
ses_mail_from_not_verified Amazon SES has not yet confirmed the MX on bounce.<domain>.
ses_bounce_node_occupied A CNAME (for example from an earlier KumoMTA setup) sits on bounce.<domain>. Remove it.
kumo_bounce_not_configured The CNAME on bounce.<domain> is missing.
kumo_bounce_node_occupied bounce.<domain> holds something else — another CNAME, or leftover MX/TXT records. Leave only the returned CNAME.
kumo_dkim_not_configured No DKIM TXT record matches the key returned for this domain.
kumo_dmarc_not_configured No valid v=DMARC1 record at _dmarc.<domain>.
dns_lookup_failed A lookup timed out or failed; retry later.
provider_unavailable The provider could not be reached; retry later.

The list is open — new codes may appear, so display unknown ones as-is rather than failing.

5. Confirm the domain

curl https://emails.routeon.io/api/v1/domains/<DOMAIN_ID> 
  -H "Authorization: Bearer <API_KEY>"

payload.status is "verified" and payload.verified_at is set.

6. Follow the warmup ramp

curl https://emails.routeon.io/api/v1/domains/<DOMAIN_ID>/warmup 
  -H "Authorization: Bearer <API_KEY>"

Expected response (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": {
    "domain_id": "<DOMAIN_ID>",
    "warmup_plan_id": "<WARMUP_PLAN_ID>",
    "current_day": 1,
    "today_cap": 50,
    "today_sent": 0,
    "state": "running",
    "today_cap_override": null
  },
  "meta": {}
}

payload is null when the domain has warmup_enabled: false, and also for a short moment right after verification, before the ramp has started. While a warmup-enabled domain has no ramp yet, its sends are held back exactly as if the cap were reached — wait until this call returns a state before sending.

  • current_day is a stage of the ramp, not a calendar day. A stage advances at most once per UTC day, and only after the domain has actually sent the stage’s volume with healthy bounce and complaint rates.
  • today_cap is today’s ceiling; today_sent counts messages handed to the provider today (UTC).
  • state: running — ramping normally; holding — bounce or complaint rates were too high, so the stage is frozen while sending up to the cap continues; paused — set only by you, see below; completed — the ramp is finished and no daily cap applies.

A message above today’s cap is not carried over to the next day. It stays deferred, is retried about once a minute, and after 10 deferrals becomes failed with error_message: "defer_cap_exceeded"; its credit is refunded. Plan your volume within today_cap.

Pause or resume the ramp, or set your own daily ceiling:

curl -X PATCH https://emails.routeon.io/api/v1/domains/<DOMAIN_ID>/warmup 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{ "today_cap_override": 20 }'

The response is the updated state. today_cap_override (an integer of at least 1) replaces today_cap as the enforced ceiling without changing the stage’s target; send null to clear it. { "state": "paused" } freezes the ramp: the stage does not advance and a new daily window is not opened, so from the next UTC midnight new sends on the domain are deferred (and fail as described above) until you send { "state": "running" }. Before the ramp has started, PATCH answers 404.

7. Change the provider or rotate the DKIM key (optional)

curl -X POST https://emails.routeon.io/api/v1/domains/<DOMAIN_ID>/provider 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{ "provider": "kumomta" }'

The response (2xx) is the domain object with its new dns_records. The domain returns to pending_dns, verified_at and warmup progress are cleared, and it cannot send until you publish the new records and verify again (steps 3–5). Sending the domain’s current provider re-provisions it with a fresh DKIM key — the way to rotate a key.

Verify

GET /domains/{id} reports status: "verified" with a non-null verified_at, and GET /domains/{id}/warmup returns a state (or null if you turned warmup off). GET /domains lists all your domains with their status.

Common failures

Symptom Cause Fix
422 provider_required on POST /domains Your account allows several providers and the request had no provider Send "provider": "ses" or "kumomta"
403 provider_not_allowed The requested provider is not in GET /me → allowed_providers Use one of the listed providers
422 no_provider_allowed allowed_providers is empty Contact support
409 domain_already_registered The domain is already registered in your account GET /domains and use the existing record
422 domain_reputation_rejected The automatic reputation check rejected the domain; meta.details.reasons lists {check, code} pairs Use a different domain
409 domain_verified_by_another_tenant on verify Another account already has this domain verified Use a different domain or subdomain, or contact support
ok: false with reasons Records missing, wrong, or not yet propagated Fix what each reason names, then call POST /domains/{id}/verify again
status stays pending_dns Verification was never called Call POST /domains/{id}/verify — nothing checks automatically
503 kumo_provider_misconfigured A platform-side configuration problem for kumomta Not fixable by retrying; contact support
Messages failed with defer_cap_exceeded More mail than today_cap allowed, or the ramp was paused or not started Keep daily volume within today_cap, resume a paused ramp, resend what failed
404 on PATCH /domains/{id}/warmup The ramp has not started (just verified, or warmup_enabled: false) Wait until GET /domains/{id}/warmup returns a state