# Management API (/docs/api) ## Overview The unMTA API provides programmatic access to manage your mail infrastructure. Use the API to automate domain configuration, manage SMTP credentials, and integrate unMTA into your deployment workflows. This API is for the unMTA **control plane**—managing clusters, domains, and credentials. For sending email via HTTP or SMTP, see [Sending Messages](/docs/message-injection). ## Base URL All API requests should be made to: ``` https://app.unmta.com/api/v1 ``` ## Authentication The API uses Bearer token authentication. Include your API token in the `Authorization` header of every request: ``` Authorization: Bearer your-api-token ``` ### Generating an API Token 1. Log in to the unMTA dashboard 2. Navigate to **API Settings** from the user menu 3. Click **Generate API key** 4. Copy the token immediately—it's only displayed once Store your API token securely. If you lose it, you'll need to generate a new one, which will revoke the previous token. ### Token Lifecycle - Each user can have one active API token at a time - Generating a new token automatically revokes any existing token - Tokens can be manually revoked from the API Settings page - Tokens do not expire automatically ## Request Format - All requests must include the `Authorization` header - Request bodies should be JSON with `Content-Type: application/json` - All timestamps are in ISO 8601 format (UTC) ## Response Format All responses return JSON. Successful responses wrap data in a `data` key: ```json { "data": { "id": "a1b2c3d4", "hostname": "c-a1b2c3d4.unmta.net" } } ``` Collections return an array within the `data` key: ```json { "data": [ { "id": "a1b2c3d4", "hostname": "c-a1b2c3d4.unmta.net" }, { "id": "e5f6g7h8", "hostname": "c-e5f6g7h8.unmta.net" } ] } ``` ## Error Responses ### 401 Unauthenticated Returned when the request lacks a valid API token. ```json { "message": "Unauthenticated." } ``` ### 404 Not Found Returned when the requested resource doesn't exist or you don't have access to it. ```json { "message": "Not Found" } ``` ### 422 Validation Error Returned when request data fails validation. ```json { "message": "The name field is required.", "errors": { "name": ["The name field is required."] } } ``` --- ## Clusters Clusters are the top-level organizational unit in unMTA. Each cluster contains its own MTAs, domains, and credentials. ### List Clusters Returns all clusters accessible to the authenticated user. ```http GET /api/v1/clusters ``` **Response Fields** | Field | Type | Description | |-------|------|-------------| | `id` | string | The cluster ID | | `hostname` | string | The cluster hostname | | `region` | string | The deployment region | | `status` | string | The cluster status | | `max_message_rate` | string \| null | The message rate limit applied per MTA, or `null` if unlimited | | `created_at` | string | ISO 8601 creation timestamp | | `updated_at` | string | ISO 8601 last update timestamp | The `max_message_rate` field uses the format `quantity/period` with an optional burst modifier. For example: `"500/hr"`, `"1,000/day"`, `"50/hr,max_burst=10"`, or `"500/30min"`. A value of `null` means no rate limit is applied—this is the default for most clusters. Rate limits are typically only used during customer trials or IP warming. The rate limit is applied **per MTA**. Clusters with multiple MTAs will have an effective total rate of `rate × number of MTAs`. Messages are not rejected when the rate limit is exceeded. They are held in a scheduled queue and delivered when the rate window allows. **Response** ```json { "data": [ { "id": "a1b2c3d4", "hostname": "c-a1b2c3d4.unmta.net", "region": "us-dal-1", "status": "active", "max_message_rate": null, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters \ -H "Authorization: Bearer your-api-token" ``` --- ## MTAs MTAs (Mail Transfer Agents) are the servers that handle email delivery within a cluster. ### List MTAs Returns all MTAs belonging to a cluster. ```http GET /api/v1/clusters/{cluster}/mtas ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Response** ```json { "data": [ { "id": "x1y2z3w4", "hostname": "m-x1y2z3w4.unmta.net", "primary_ipv4": "203.0.113.10", "primary_ipv6": "2001:db8::1", "outbound_ips": [ { "ipv4": "203.0.113.20", "ipv6": "2001:db8::10", "ptr": "ip-203-0-113-20.unmta.net" }, { "ipv4": "203.0.113.21", "ipv6": "2001:db8::11", "ptr": "ip-203-0-113-21.unmta.net" } ], "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/mtas \ -H "Authorization: Bearer your-api-token" ``` --- ## Domains Domains must be added and verified before you can send email through unMTA. Each domain belongs to a single cluster and has its own DKIM keys. ### List Domains Returns all domains belonging to a cluster. ```http GET /api/v1/clusters/{cluster}/domains ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Response** ```json { "data": [ { "name": "example.com", "status": "verified", "dkim_public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T12:45:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/domains \ -H "Authorization: Bearer your-api-token" ``` ### Create Domain Creates a new domain and generates DKIM keys. The response includes DNS records you'll need to configure. ```http POST /api/v1/clusters/{cluster}/domains ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Request Body** | Field | Type | Required | Description | | ------ | ------ | -------- | --------------------------------- | | `name` | string | Yes | Domain name (e.g., `example.com`) | **Domain Requirements** - Must be a valid fully-qualified domain name - Cannot be an IP address - Cannot use reserved TLDs: `localhost`, `local`, `test`, `invalid`, `example`, `localdomain` - Must be unique within the cluster **Request** ```json { "name": "example.com" } ``` **Response** (201 Created) ```json { "data": { "name": "example.com", "status": "pending", "dkim_public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...", "dns_records": { "dkim": { "type": "TXT", "name": "unmta._domainkey", "value": "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." }, "dmarc": { "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=reject; adkim=s; aspf=r" } }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } } ``` **Example** ```bash curl -X POST https://app.unmta.com/api/v1/clusters/a1b2c3d4/domains \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{"name": "example.com"}' ``` ### Get Domain Returns a specific domain with its DNS record configuration. ```http GET /api/v1/clusters/{cluster}/domains/{domain} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ---------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The domain name (case-insensitive) | **Response** ```json { "data": { "name": "example.com", "status": "verified", "dkim_public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...", "dns_records": { "dkim": { "type": "TXT", "name": "unmta._domainkey", "value": "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." }, "dmarc": { "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=reject; adkim=s; aspf=r" } }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T12:45:00Z" } } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/domains/example.com \ -H "Authorization: Bearer your-api-token" ``` ### Delete Domain Deletes a domain from the cluster. ```http DELETE /api/v1/clusters/{cluster}/domains/{domain} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ---------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The domain name (case-insensitive) | **Response**: 204 No Content Deleting a domain is immediate and irreversible. Any email sent from this domain will be rejected after deletion. **Example** ```bash curl -X DELETE https://app.unmta.com/api/v1/clusters/a1b2c3d4/domains/example.com \ -H "Authorization: Bearer your-api-token" ``` --- ## Tracking Domains Tracking domains brand the open and click-tracking URLs in your outbound email under a subdomain you control. Each tracking domain belongs to a single cluster and is verified by a single CNAME record. See [Tracking](/docs/tracking) for the full conceptual overview. ### List Tracking Domains Returns all tracking domains belonging to a cluster. ```http GET /api/v1/clusters/{cluster}/tracking-domains ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Response** ```json { "data": [ { "name": "track.example.com", "status": "active", "is_default": true, "verification_hostname": "v-deadbeef.track.unmta.net", "verified_at": "2025-01-15T10:42:00Z", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:42:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains \ -H "Authorization: Bearer your-api-token" ``` ### Create Tracking Domain Creates a new tracking domain and issues a unique CNAME verification target. The response includes the CNAME record you'll need to configure. ```http POST /api/v1/clusters/{cluster}/tracking-domains ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Request Body** | Field | Type | Required | Default | Description | | ------------ | ------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | — | Tracking domain name (e.g., `track.example.com`) | | `is_default` | boolean | No | `false` | If `true`, this becomes the cluster's default tracking domain and the previous default (if any) is automatically unset | **Tracking Domain Requirements** - Must be a valid subdomain — at least three labels, not an apex domain - Cannot be an IP address - Cannot use reserved TLDs: `localhost`, `local`, `test`, `invalid`, `example`, `localdomain` - Must be unique within the cluster **Request** ```json { "name": "track.example.com", "is_default": true } ``` **Response** (201 Created) ```json { "data": { "name": "track.example.com", "status": "pending", "is_default": true, "verification_hostname": "v-deadbeef.track.unmta.net", "verified_at": null, "dns_records": { "cname": { "type": "CNAME", "name": "track.example.com", "value": "v-deadbeef.track.unmta.net" } }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } } ``` **Example** ```bash curl -X POST https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{"name": "track.example.com"}' ``` ### Get Tracking Domain Returns a specific tracking domain with its CNAME record configuration. ```http GET /api/v1/clusters/{cluster}/tracking-domains/{domain} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The tracking domain name (case-insensitive) | **Response** ```json { "data": { "name": "track.example.com", "status": "active", "is_default": true, "verification_hostname": "v-deadbeef.track.unmta.net", "verified_at": "2025-01-15T10:42:00Z", "dns_records": { "cname": { "type": "CNAME", "name": "track.example.com", "value": "v-deadbeef.track.unmta.net" } }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:42:00Z" } } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains/track.example.com \ -H "Authorization: Bearer your-api-token" ``` ### Update Tracking Domain Updates a tracking domain. Currently only the `is_default` flag is mutable. ```http PATCH /api/v1/clusters/{cluster}/tracking-domains/{domain} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The tracking domain name (case-insensitive) | **Request Body** | Field | Type | Required | Description | | ------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | `is_default` | boolean | Yes | If `true`, this becomes the cluster's default tracking domain and the previous default (if any) is automatically unset. Pass `false` to clear the default. | At most one tracking domain per cluster can be the default. It's also valid to have no default — messages without a tracking host override will fall back to the unMTA-owned hostname. **Request** ```json { "is_default": true } ``` **Response** ```json { "data": { "name": "track.example.com", "status": "active", "is_default": true, "verification_hostname": "v-deadbeef.track.unmta.net", "verified_at": "2025-01-15T10:42:00Z", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T14:20:00Z" } } ``` **Example** ```bash curl -X PATCH https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains/track.example.com \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{"is_default": true}' ``` ### Verify Tracking Domain Triggers an on-demand check of the tracking domain's CNAME record. On success, a pending domain is promoted to **active** and `verified_at` is set. The endpoint always returns `200`; check `verification.verified` to determine the outcome. ```http POST /api/v1/clusters/{cluster}/tracking-domains/{domain}/verify ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The tracking domain name (case-insensitive) | **Response** ```json { "data": { "name": "track.example.com", "status": "active", "is_default": false, "verification_hostname": "v-deadbeef.track.unmta.net", "verified_at": "2025-01-15T10:42:00Z", "dns_records": { "cname": { "type": "CNAME", "name": "track.example.com", "value": "v-deadbeef.track.unmta.net" } }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:42:00Z" }, "verification": { "verified": true, "error": null } } ``` When the CNAME doesn't resolve to the issued target, `verification.verified` is `false` and `verification.error` carries a human-readable reason: ```json { "data": { "...": "..." }, "verification": { "verified": false, "error": "No CNAME record found." } } ``` **Example** ```bash curl -X POST https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains/track.example.com/verify \ -H "Authorization: Bearer your-api-token" ``` ### Delete Tracking Domain Deletes a tracking domain from the cluster. ```http DELETE /api/v1/clusters/{cluster}/tracking-domains/{domain} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `cluster` | path | Yes | The cluster ID | | `domain` | path | Yes | The tracking domain name (case-insensitive) | **Response**: 204 No Content Deleting a tracking domain is immediate and irreversible. Tracking links in any already-delivered mail that pointed at this domain will stop working. **Example** ```bash curl -X DELETE https://app.unmta.com/api/v1/clusters/a1b2c3d4/tracking-domains/track.example.com \ -H "Authorization: Bearer your-api-token" ``` --- ## Credentials SMTP credentials are used to authenticate when sending email through unMTA. Each credential can be restricted to specific domains and IP addresses. ### List Credentials Returns all SMTP credentials belonging to a cluster. ```http GET /api/v1/clusters/{cluster}/credentials ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Response** ```json { "data": [ { "user": "smtp_user", "allow_all_domains": false, "domains": ["example.com", "test.com"], "allow_all_ips": true, "allowed_ips": [], "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/credentials \ -H "Authorization: Bearer your-api-token" ``` ### Create Credential Creates a new SMTP credential. The password is generated automatically and returned only once in the response. ```http POST /api/v1/clusters/{cluster}/credentials ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Request Body** | Field | Type | Required | Default | Description | | ------------------- | ------- | ----------- | ------- | ----------------------------------------------------------------------- | | `user` | string | Yes | — | SMTP username | | `allow_all_domains` | boolean | No | `false` | Allow sending from all domains | | `domains` | array | Conditional | — | Allowed domain names (required if `allow_all_domains` is false) | | `allow_all_ips` | boolean | No | `true` | Allow connections from all IPs | | `allowed_ips` | array | Conditional | — | Allowed IP addresses/CIDR ranges (required if `allow_all_ips` is false) | **Username Requirements** - Maximum 255 characters - Allowed characters: letters, numbers, and `+ = , . @ _ -` - Must be unique within the cluster **Request** ```json { "user": "webapp-production", "allow_all_domains": false, "domains": ["example.com", "notifications.example.com"], "allow_all_ips": false, "allowed_ips": ["203.0.113.0/24", "198.51.100.5"] } ``` **Response** (201 Created) ```json { "data": { "user": "webapp-production", "allow_all_domains": false, "domains": ["example.com", "notifications.example.com"], "allow_all_ips": false, "allowed_ips": ["203.0.113.0/24", "198.51.100.5"], "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" }, "password": "aB3dEfGhIjKlMnOpQrStUvWxYz123456" } ``` The `password` is only returned once when the credential is created. Store it securely—there's no way to retrieve it later. **Example** ```bash curl -X POST https://app.unmta.com/api/v1/clusters/a1b2c3d4/credentials \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{ "user": "webapp-production", "domains": ["example.com"] }' ``` ### Update Credential Updates the domain and/or IP restrictions for an SMTP credential. ```http PUT /api/v1/clusters/{cluster}/credentials/{user} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ----------------------- | | `cluster` | path | Yes | The cluster ID | | `user` | path | Yes | The credential username | **Request Body** | Field | Type | Description | | ------------------- | ------- | -------------------------------- | | `allow_all_domains` | boolean | Allow sending from all domains | | `domains` | array | Allowed domain names | | `allow_all_ips` | boolean | Allow connections from all IPs | | `allowed_ips` | array | Allowed IP addresses/CIDR ranges | If you provide `domains` without `allow_all_domains`, it automatically sets `allow_all_domains` to `false`. The same applies to `allowed_ips` and `allow_all_ips`. **Request** ```json { "domains": ["example.com", "new-domain.com"] } ``` **Response** ```json { "data": { "user": "webapp-production", "allow_all_domains": false, "domains": ["example.com", "new-domain.com"], "allow_all_ips": true, "allowed_ips": [], "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T14:20:00Z" } } ``` **Example** ```bash curl -X PUT https://app.unmta.com/api/v1/clusters/a1b2c3d4/credentials/webapp-production \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{"allow_all_domains": true}' ``` ### Delete Credential Deletes an SMTP credential from the cluster. ```http DELETE /api/v1/clusters/{cluster}/credentials/{user} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ----------------------- | | `cluster` | path | Yes | The cluster ID | | `user` | path | Yes | The credential username | **Response**: 204 No Content Deleting a credential is immediate. Any applications using this credential will no longer be able to send email. **Example** ```bash curl -X DELETE https://app.unmta.com/api/v1/clusters/a1b2c3d4/credentials/webapp-production \ -H "Authorization: Bearer your-api-token" ``` --- ## Bot Signatures Bot signatures suppress open and click-tracking events generated by bots, security scanners, AI crawlers, and inbox-preview services. See [Bot Signatures](/docs/bot-signatures) for the full conceptual overview. Signatures come in two flavors: **defaults** (shared across all clusters, managed by unMTA, but individually toggleable per cluster) and **custom** (per cluster, fully managed by you). Each signature has a match `type` of `user_agent` (case-insensitive substring match against the `User-Agent` header), `ip_exact` (single IPv4/IPv6 address), or `ip_cidr` (CIDR block). Changes to bot signatures propagate to the MTAs as part of the normal config-sync cycle. Expect up to 15 minutes before every MTA is matching on the updated set. ### List Default Signatures Returns the default signatures available to the cluster, with a per-cluster `enabled` flag showing whether each one is currently active. ```http GET /api/v1/clusters/{cluster}/bot-signatures/defaults ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Query Parameters** | Name | Type | Description | | ---------- | ------- | ------------------------------------------------------------------------------------ | | `source` | string | Filter by source (e.g. `apple`, `microsoft`, `security`, `google`, `yahoo`, `ai`, `crawler`) | | `q` | string | Search term matched against `key`, `pattern`, and `label` | | `enabled` | boolean | Filter to only enabled (`true`) or disabled (`false`) defaults for this cluster | | `per_page` | integer | Page size. Default `50`, maximum `200` | | `page` | integer | Page number. Default `1` | **Response Fields** | Field | Type | Description | | --------- | -------------- | -------------------------------------------------------------------------------------------- | | `key` | string | Unique identifier for the default signature (e.g. `apple:17.0.0.0/8`, `ua:googleimageproxy`) | | `type` | string | Match type: `user_agent`, `ip_exact`, or `ip_cidr` | | `pattern` | string | The pattern matched against the incoming request | | `source` | string | Category the signature belongs to | | `label` | string \| null | Human-readable description | | `enabled` | boolean | Whether the signature is currently active for this cluster | **Response** ```json { "data": [ { "key": "ua:googleimageproxy", "type": "user_agent", "pattern": "GoogleImageProxy", "source": "google", "label": "Google Image Proxy", "enabled": true }, { "key": "apple:17.0.0.0/8", "type": "ip_cidr", "pattern": "17.0.0.0/8", "source": "apple", "label": "Apple Private Relay", "enabled": true } ], "meta": { "current_page": 1, "per_page": 50, "total": 12000 } } ``` **Example** ```bash curl -X GET "https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/defaults?source=security&per_page=100" \ -H "Authorization: Bearer your-api-token" ``` ### Get Default Signature Returns a single default signature with its per-cluster `enabled` flag. ```http GET /api/v1/clusters/{cluster}/bot-signatures/defaults/{key} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | ------------------------------------------ | | `cluster` | path | Yes | The cluster ID | | `key` | path | Yes | The default signature key | **Response** ```json { "data": { "key": "ua:googleimageproxy", "type": "user_agent", "pattern": "GoogleImageProxy", "source": "google", "label": "Google Image Proxy", "enabled": true } } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/defaults/ua:googleimageproxy \ -H "Authorization: Bearer your-api-token" ``` ### Update Default Signature Enables or disables a default signature for the cluster. The signature itself is not modified — only its per-cluster active state. ```http PATCH /api/v1/clusters/{cluster}/bot-signatures/defaults/{key} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------------------- | | `cluster` | path | Yes | The cluster ID | | `key` | path | Yes | The default signature key | **Request Body** | Field | Type | Required | Description | | --------- | ------- | -------- | ---------------------------------------------------------------- | | `enabled` | boolean | Yes | `true` to enable the signature for the cluster, `false` to disable it | **Request** ```json { "enabled": false } ``` **Response** ```json { "data": { "key": "ua:googleimageproxy", "type": "user_agent", "pattern": "GoogleImageProxy", "source": "google", "label": "Google Image Proxy", "enabled": false } } ``` **Example** ```bash curl -X PATCH https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/defaults/ua:googleimageproxy \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{"enabled": false}' ``` ### List Custom Signatures Returns all custom bot signatures belonging to a cluster. ```http GET /api/v1/clusters/{cluster}/bot-signatures/custom ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Response Fields** | Field | Type | Description | | ------------ | -------------- | --------------------------------------------------------------- | | `id` | string | UUID identifier | | `type` | string | Match type: `user_agent`, `ip_exact`, or `ip_cidr` | | `pattern` | string | The pattern matched against the incoming request | | `source` | string | Always `custom` for user-defined signatures | | `kind` | string | One of `proxy` or `automation`. Defaults to `automation`. | | `label` | string \| null | Human-readable description | | `created_at` | string | ISO 8601 creation timestamp | | `updated_at` | string | ISO 8601 last update timestamp | **Response** ```json { "data": [ { "id": "123e4567-e89b-12d3-a456-426614174000", "type": "user_agent", "pattern": "InternalScanner/1.0", "source": "custom", "kind": "automation", "label": "Corporate link scanner", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } ] } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/custom \ -H "Authorization: Bearer your-api-token" ``` ### Create Custom Signature Creates a new custom bot signature for the cluster. ```http POST /api/v1/clusters/{cluster}/bot-signatures/custom ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------- | | `cluster` | path | Yes | The cluster ID | **Request Body** | Field | Type | Required | Description | | --------- | -------------- | -------- | --------------------------------------------------------------------------------------------- | | `type` | string | Yes | Match type. One of `user_agent`, `ip_exact`, `ip_cidr` | | `pattern` | string | Yes | Pattern to match. Max 1000 characters | | `label` | string \| null | No | Optional description. Max 255 characters | | `kind` | string | No | One of `proxy` or `automation`. Defaults to `automation`. | **Pattern Requirements** - `user_agent` — any non-empty string up to 1000 characters, matched case-insensitively as a substring of the request's `User-Agent` header - `ip_exact` — a valid IPv4 or IPv6 address - `ip_cidr` — a valid CIDR block. IPv4 prefixes must be `/0`–`/32`; IPv6 prefixes must be `/0`–`/128` - `kind` — `automation` for scanners, AI crawlers, and generic HTTP clients (the usual choice for custom rules); `proxy` only for mailbox-provider image or link proxies fetching on behalf of a real recipient **Request** ```json { "type": "user_agent", "pattern": "InternalScanner/1.0", "label": "Corporate link scanner" } ``` **Response** (201 Created) ```json { "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "type": "user_agent", "pattern": "InternalScanner/1.0", "source": "custom", "kind": "automation", "label": "Corporate link scanner", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } } ``` **Example** ```bash curl -X POST https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/custom \ -H "Authorization: Bearer your-api-token" \ -H "Content-Type: application/json" \ -d '{ "type": "ip_cidr", "pattern": "198.51.100.0/24", "label": "Office egress range" }' ``` ### Get Custom Signature Returns a single custom bot signature. ```http GET /api/v1/clusters/{cluster}/bot-signatures/custom/{id} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------------------------- | | `cluster` | path | Yes | The cluster ID | | `id` | path | Yes | The custom signature UUID | **Response** ```json { "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "type": "user_agent", "pattern": "InternalScanner/1.0", "source": "custom", "kind": "automation", "label": "Corporate link scanner", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } } ``` **Example** ```bash curl -X GET https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/custom/123e4567-e89b-12d3-a456-426614174000 \ -H "Authorization: Bearer your-api-token" ``` ### Delete Custom Signature Deletes a custom bot signature from the cluster. ```http DELETE /api/v1/clusters/{cluster}/bot-signatures/custom/{id} ``` **Parameters** | Name | Location | Required | Description | | --------- | -------- | -------- | -------------------------------- | | `cluster` | path | Yes | The cluster ID | | `id` | path | Yes | The custom signature UUID | **Response**: 204 No Content **Example** ```bash curl -X DELETE https://app.unmta.com/api/v1/clusters/a1b2c3d4/bot-signatures/custom/123e4567-e89b-12d3-a456-426614174000 \ -H "Authorization: Bearer your-api-token" ``` --- ## HTTP Status Codes | Code | Description | | ----- | -------------------------------- | | `200` | Success | | `201` | Created | | `204` | No Content (successful deletion) | | `401` | Unauthenticated | | `404` | Not Found | | `422` | Validation Error | --- # Bot Signatures (/docs/bot-signatures) ## Overview Bot signatures let you identify open and click-tracking events that weren't generated by a real recipient. Email security gateways, inbox-preview services, AI crawlers, and mailbox providers that proxy images (such as Apple Private Relay or Google Image Proxy) all fetch tracking pixels and follow links automatically. Without a way to tell them apart from real recipients, those requests show up as opens and clicks and inflate your engagement metrics. When a tracking request matches a bot signature, unMTA **labels** the event with a `bot` field identifying the kind of automation that produced it — but still records it. See [Event Labeling](#event-labeling) below for how this shows up on Open and Click webhook payloads. Bot signatures come in two layers, merged at match time: | Layer | Scope | Managed by | | ----------- | --------------------------- | ---------------------------------------------------------------------------------------- | | **Default** | Shared across all clusters | unMTA. The larger curated dataset (Apple Private Relay CIDRs, AI crawlers, security gateways, etc.) — updated over time. Can be disabled individually per cluster. | | **Custom** | Per cluster | You. Add your own rules to cover bots unMTA doesn't ship defaults for. | Custom rules are always additive. Disables remove matching entries from the built-in and default layers. ## Match Types Every signature — default or custom — has a **type** that determines how the request is matched: | Type | Matches | Example pattern | | ------------ | ------------------------------------------------------------------------------------------------------- | --------------------------- | | `user_agent` | Case-insensitive substring match against the request's `User-Agent` header. | `GoogleImageProxy` | | `ip_exact` | Exact match against the request's source IP address (IPv4 or IPv6). | `203.0.113.10` | | `ip_cidr` | CIDR-block match against the request's source IP address (IPv4 or IPv6). | `17.0.0.0/8`, `2001:db8::/32` | A request is labeled if **any** signature matches. User-Agent matching runs first, then exact-IP lookups, then CIDR ranges — the first hit wins and its `source` becomes the event's `bot` label. ## Default Signatures unMTA ships a curated set of default signatures covering common sources of bot traffic. The list is updated over time as new bots, proxies, and scanners appear. ### What's Covered The dataset evolves over time as new bots appear. Categories currently covered include: - **Mailbox-provider image proxies** — Apple Private Relay, Google Image Proxy, Yahoo Mail Proxy - **Link-preview services** — Microsoft BingPreview, Teams URL Preview - **Email security gateways** — Proofpoint, Mimecast, Barracuda, Symantec, FireEye, MessageLabs, and others - **AI and LLM crawlers** — ChatGPT, Anthropic ClaudeBot, OpenAI GPTBot, Amazonbot - **Generic HTTP clients** — `curl`, `wget`, `python-requests`, `Go-http-client`, `HeadlessChrome`, and similar automation tooling - **Apple Private Relay egress ranges** — the CIDRs published by Apple - **Other provider egress ranges** — added as vendors publish authoritative lists (e.g. Microsoft Defender/SafeLinks, additional security vendors) ### Disabling a Default Per Cluster Defaults apply to every cluster automatically. If one of them is filtering real engagement for your audience, you can disable it for a specific cluster without affecting any other cluster. 1. Navigate to the **Bot Signatures** page from the sidebar 2. Open the **Defaults** tab 3. Use the **source** filter or search box to find the signature 4. Toggle the switch at the end of the row to disable it Disabling is per-cluster — the same default can be on for one cluster and off for another. You can re-enable it at any time by toggling the switch back on. Defaults themselves aren't editable — only their enabled state per cluster. If you need to match a bot unMTA doesn't already cover, add a custom rule. ## Custom Signatures Custom signatures are yours to manage. Use them to: - Flag events from an internal security scanner or link-checker you control - Cover a bot that unMTA's defaults don't handle yet - Tag a specific range of IPs that is consistently generating non-human opens ### Adding a Custom Rule 1. Navigate to the **Bot Signatures** page 2. Click **Add custom rule** 3. Choose the match type (**User Agent**, **IP Address**, or **IP CIDR**) 4. Enter the pattern 5. Optionally, enter a label to help you remember what the rule is for 6. Click **Save** ### Pattern Requirements | Match type | Requirement | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `user_agent` | Any non-empty string up to 1000 characters. Matched case-insensitively as a substring of the `User-Agent` header. | | `ip_exact` | A valid IPv4 or IPv6 address. | | `ip_cidr` | A valid CIDR block. IPv4 prefixes must be in the range `/0`–`/32`; IPv6 prefixes must be in the range `/0`–`/128`. | For user-agent rules, prefer the most distinctive portion of the header — for example `BadBot/` rather than `Mozilla/5.0`, which would also match real browsers. ### Deleting a Custom Rule 1. Open the **Custom rules** tab 2. Click the menu button on the rule and select **Delete** 3. Confirm Deleting is immediate. The rule is removed from the cluster and MTAs stop matching on it once the next config sync completes (see below). ## Prefetch Detection In addition to signature matching, unMTA flags pixel or click requests that arrive **implausibly soon** after the message was sent. If the delta is under 5 seconds **and** no user-agent or IP signature matched, the event is labeled `bot: "prefetch"`. This catches link-scanners and preview fetchers that don't match any known signature — a real recipient opening an email within 5 seconds of delivery is almost always automation. More specific signature hits always win over prefetch, so a fast Apple Mail Privacy Protection load still labels as `apple`. ## Event Labeling When a signature (or prefetch detection) matches, unMTA adds a nested `bot` object to the Open or Click webhook payload: ```json { "type": "Open", "id": "...", "recipient": "...", "bot": { "kind": "proxy", "source": "apple" } } ``` Human traffic emits the event **without** the `bot` field, so treat the field's presence — not its truthiness — as the signal. ### `bot.kind` — the field you bucket on A stable, two-value enum that tells you whether the hit was a mailbox-provider proxy standing in for a real recipient, or automation with no human behind it: | `kind` | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `proxy` | A mailbox-provider image or link proxy fetched the URL on a real recipient's behalf. Most consumers count these as engagement. | | `automation` | Security scanners, AI crawlers, generic HTTP clients, prefetchers, and customer-defined bots. No evidence of human intent. | | *(absent)* | No signature matched and the hit wasn't a prefetch. Treat as human. | This is the field to filter on. It's stable against future change — if Google were to launch a prefetcher tomorrow, the corresponding signature would ship with `kind: "automation"` and every dashboard that buckets on `kind` would be automatically correct without any rule rewrites. ### `bot.source` — diagnostic detail The fine-grained identifier for the specific provider or category that matched. Use it for slicing dashboards ("how much of our `automation` traffic is AI crawlers vs Proofpoint?"), not for bucketing human-vs-bot. Current source values include: | `source` | Typical `kind` | Matched against | | ------------ | -------------- | ------------------------------------------------------------------- | | `apple` | `proxy` | Apple Mail Privacy Protection | | `google` | `proxy` | Google Image Proxy / Gmail image caching | | `yahoo` | `proxy` | Yahoo Mail image proxy / link preview | | `microsoft` | `proxy` or `automation` | BingPreview and Skype/Teams URL Preview (`proxy`); Defender/SafeLinks scanners (`automation`) | | `security` | `automation` | Email security gateways (Proofpoint, Mimecast, Barracuda, etc.) | | `ai` | `automation` | AI/LLM crawlers (ChatGPT, ClaudeBot, GPTBot, Amazonbot) | | `crawler` | `automation` | Generic HTTP clients (`curl`, `wget`, `python-requests`, etc.) | | `custom` | `automation` (default) | A custom rule you added matched | | `prefetch` | `automation` | No UA or IP signature matched, but the hit arrived under the prefetch threshold | The `source` ↔ `kind` relationship is set per-signature, not fixed globally — a future `microsoft` signature could ship with either kind depending on what it matches. ## API Reference For programmatic management of bot signatures, see the [Bot Signatures API documentation](/docs/api#bot-signatures). --- # Clusters (/docs/clusters) ## Overview A cluster is a group of one or more MTAs (mail transfer agents) that form your dedicated email sending infrastructure. Smaller deployments use a single MTA, while higher-volume deployments use [multiple MTAs](#multi-mta-clusters) for increased throughput and redundancy. Each cluster has its own SMTP and HTTP endpoints, domains, and credentials. ## Finding Connection Details To configure your application to send email, you need your cluster's connection details. 1. Navigate to the **Cluster** page from the sidebar 2. View the **Connection Details** card on the right The connection details include: | Field | Example | Description | | ------------- | --------------------------------------------------- | ----------------------------------------- | | **HTTP** | `https://c-your-cluster-id.unmta.net/api/inject/v1` | HTTPS endpoint for HTTP message injection | | **SMTP Host** | `c-your-cluster-id.unmta.net` | Hostname for SMTP connections | Your cluster's hostname will be unique. Replace `c-your-cluster-id.unmta.net` in the examples with the value from your dashboard. ## SMTP Ports unMTA accepts SMTP connections on three ports. All ports require STARTTLS encryption. | Port | Use Case | | -------- | --------------------------------------------------------------- | | **587** | Recommended for most applications. Standard submission port. | | **25** | Standard SMTP port. May be blocked by some cloud providers. | | **2587** | Alternative port if 587 is blocked by your network or firewall. | Port 587 is the recommended choice for application email. Some cloud providers (AWS, GCP, Azure) block outbound port 25 by default to prevent spam. ## Connecting Your Application Use the connection details with your [credentials](/docs/credentials) to send email. ### SMTP Example ```bash swaks --to recipient@example.com \ --from sender@yourdomain.com \ --server c-your-cluster-id.unmta.net:587 \ --tls \ --auth-user your-credential \ --auth-password your-password \ --header "Subject: Test" \ --body "Hello from unMTA" ``` ### HTTP Example ```bash curl -X POST https://c-your-cluster-id.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello", "text_body": "Hello from unMTA" } }' ``` For complete message options, see [Sending Messages](/docs/message-injection). ## Rate Limits Some clusters may have a message rate limit configured. This controls how many messages each MTA in the cluster will send within a given time window. For most clusters, the rate limit is set to **Unlimited**. Rate limits are typically only applied during **customer trials** or **IP warming** scenarios where controlled sending volumes are important for building sender reputation. When a rate limit is active, it is displayed on the **Cluster** page in the dashboard. The rate is shown per MTA (e.g., `500/hr per MTA`). Rate limits are managed by administrators and are read-only for customers. Messages are never rejected when a rate limit is exceeded—they are held in a scheduled queue and delivered when the rate window allows. ## Multi-MTA Clusters Clusters with multiple MTAs provide increased throughput and fault tolerance. Your application connects to the same hostname regardless of how many MTAs are in the cluster - the infrastructure handles distribution automatically. **DNS load balancing** - Traffic is distributed across MTAs using DNS. Each connection resolves to one of the available MTAs in your cluster. **Automatic health monitoring** - MTAs are continuously monitored. If an MTA becomes unavailable, it's automatically removed from the DNS rotation. When the MTA recovers, it's restored to the rotation. **No application changes** - Scaling from one MTA to multiple (or vice versa) requires no changes to your application configuration. The cluster hostname remains the same. ## API Reference To list your clusters programmatically, see the [Clusters API documentation](/docs/api#clusters). --- # Credentials (/docs/credentials) ## Overview SMTP credentials authenticate applications and services when sending email through your unMTA cluster. Each credential consists of a username and password that your application uses to connect to the SMTP server. Credentials provide two key security features: - **Domain restrictions** — Limit which sending domains a credential can use - **IP restrictions** — Limit which IP addresses can connect using the credential These restrictions help you follow the principle of least privilege. A web application server might only need to send from `notifications.example.com`, while a marketing platform might need access to all your domains. Each credential belongs to a single cluster and can be configured with any combination of domain and IP restrictions. ## Domain Restrictions By default, new credentials are restricted to specific domains. You must explicitly select which domains the credential can send from. | Setting | Behavior | | -------------------- | -------------------------------------------------- | | **All domains** | Credential can send from any domain in the cluster | | **Specific domains** | Credential can only send from the selected domains | When you add a new domain to your cluster, credentials set to "All domains" will automatically gain access to it. Credentials restricted to specific domains will not. ## IP Restrictions IP restrictions control which source IP addresses can authenticate using a credential. By default, credentials allow connections from any IP address. | Setting | Behavior | | ---------------- | ---------------------------------------------------- | | **All IPs** | Accept connections from any IP address (default) | | **Specific IPs** | Only accept connections from the listed IP addresses | ### Supported IP Formats When specifying allowed IPs, you can use: - **IPv4 addresses**: `192.0.2.1` - **IPv4 CIDR ranges**: `198.51.100.0/24` - **IPv6 addresses**: `2001:db8::1` - **IPv6 CIDR ranges**: `2001:db8::/32` CIDR notation lets you allow entire subnets with a single entry. For example, `203.0.113.0/24` allows any IP from `203.0.113.0` to `203.0.113.255`. ## Creating a Credential 1. Navigate to the **Credentials** page from the sidebar 2. Click the **Add credentials** button 3. Enter a **username** for the credential 4. Configure domain restrictions: - Check **All domains** to allow sending from any domain, or - Leave unchecked and select specific domains from the list 5. Configure IP restrictions: - Leave **All IPs** checked to allow connections from anywhere, or - Uncheck and enter specific IP addresses or CIDR ranges 6. Click **Save** ### Username Requirements - Maximum 255 characters - Allowed characters: letters, numbers, and `+ = , . @ _ -` - Must be unique within the cluster ### Password Generation When you create a credential, the system automatically generates a secure 32-character password. This password is displayed only once—immediately after creation. Copy the password immediately and store it securely. The password cannot be retrieved later. If you lose it, you'll need to delete the credential and create a new one. ## Using Credentials Credentials authenticate your application when sending email through unMTA. You can connect via SMTP or HTTPS—use whichever fits your application best. To find your cluster's endpoint hostname and available ports, see [Clusters](/docs/clusters). ### SMTP Connection | Setting | Value | | -------------- | ----------------------------------- | | **Host** | Your cluster's SMTP endpoint | | **Port** | 25, 587, or 2587 | | **Username** | The credential username you created | | **Password** | The generated password | | **Encryption** | STARTTLS required on all ports | ### HTTPS Connection | Setting | Value | | ------------------ | ----------------------------------------------------- | | **Endpoint** | Your cluster's HTTPS endpoint | | **Port** | 443 | | **Authentication** | HTTP Basic Auth with credential username and password | For complete details on message formats and sending options, see [Sending Messages](/docs/message-injection). ## Managing Credentials ### Viewing Credentials The Credentials page displays all credentials for your current cluster, showing: - Username - Permission summary (e.g., "All domains, all IPs" or "2 domains, 3 IPs") - Creation date ### Editing a Credential To modify a credential's restrictions: 1. Click the credential's username in the table, or click the menu and select **Edit** 2. Update the domain and/or IP restrictions 3. Click **Save** You cannot change a credential's username or regenerate its password. To change either, delete the credential and create a new one. ### Deleting a Credential To delete a credential: 1. Click the menu button next to the credential 2. Select **Delete credential** 3. Type the credential username to confirm 4. Click **Delete** Deleting a credential is immediate and irreversible. Any applications using this credential will no longer be able to send email. ## Security Best Practices ### Use Specific Domain Restrictions Instead of granting "All domains" access, restrict each credential to only the domains it needs. This limits the impact if a credential is compromised. ### Restrict by IP When Possible If your sending application has a static IP address or uses a known IP range, configure IP restrictions. This adds an extra layer of security—even if the credential is leaked, it can't be used from unauthorized locations. ### Use Separate Credentials for Each Application Create a dedicated credential for each application or service that sends email. This allows you to: - Track which application sent specific emails - Revoke access for a single application without affecting others - Apply appropriate restrictions for each use case ### Rotate Credentials Periodically Consider deleting and recreating credentials periodically, especially for sensitive applications. Since you can't regenerate a password, rotation requires creating a new credential and updating your application configuration. ## API Reference For programmatic credential management, see the [Credentials API documentation](/docs/api#credentials). --- # Domains (/docs/domains) ## Overview Before you can send email through your cluster, you must add and verify at least one domain. This process proves you own the domain and configures the DNS records necessary for email authentication. Proper domain authentication improves deliverability, protects your brand from spoofing, and ensures receiving mail servers trust your messages. Each domain belongs to a single cluster. You can add the same domain name to multiple clusters if needed—each will have its own unique DKIM keys. ## Email Authentication Concepts When you add a domain, you'll configure two types of DNS records. Here's what each one does: ### DKIM (DomainKeys Identified Mail) DKIM adds a cryptographic signature to every email you send. When a receiving mail server gets your message, it looks up your public key in DNS and verifies the signature matches. This proves: - The email actually came from your domain - The message wasn't modified in transit When you add a domain, a unique RSA 2048-bit key pair is automatically generated. The public key goes in your DNS; the private key is securely stored and used to sign outbound messages. ### DMARC (Domain-based Message Authentication, Reporting & Conformance) DMARC builds on DKIM by telling receiving servers what to do when authentication fails. It also enables reporting so you can monitor authentication results. The DMARC policy we recommend uses: - `p=reject` — Reject emails that fail authentication - `adkim=s` — Strict DKIM alignment (the signing domain must exactly match the From domain) - `aspf=r` — Relaxed SPF alignment ## Adding a Domain 1. Navigate to the **Domains** page from the sidebar 2. Click the **Add domain** button 3. Enter your domain name (e.g., `example.com`) 4. Click **Add domain** You'll be redirected to the domain detail page showing the DNS records you need to configure. ### Domain Requirements - Must be a valid fully-qualified domain name (FQDN) - Cannot be an IP address - Cannot use reserved TLDs: `localhost`, `local`, `test`, `invalid`, `example`, `localdomain` - Must be unique within your cluster (the same domain can exist in different clusters) ## DNS Records Setup After adding a domain, you'll see two DNS TXT records that need to be added to your DNS provider: | Record | Name | Value | | ------ | ------------------------------ | ------------------------------------------ | | DKIM | `unmta._domainkey.example.com` | `p=MIIBIjANBg...` (your unique public key) | | DMARC | `_dmarc.example.com` | `v=DMARC1; p=reject; adkim=s; aspf=r` | Click the copy button next to each record value to copy it to your clipboard. ### Provider-Specific Instructions {/* prettier-ignore */} 1. Log in to your Cloudflare dashboard 2. Select your domain 3. Go to **DNS** → **Records** 4. Click **Add record** 5. For each record: - **Type**: TXT - **Name**: Enter the name from the table above (e.g., `unmta._domainkey` for DKIM) - **Content**: Paste the value - **TTL**: Auto 6. Click **Save** For the DKIM record, enter only `unmta._domainkey` as the name—Cloudflare automatically appends your domain. {/* prettier-ignore */} 1. Open the Route 53 console 2. Select **Hosted zones** and click your domain 3. Click **Create record** 4. For each record: - **Record name**: Enter the subdomain portion (e.g., `unmta._domainkey` for DKIM, `_dmarc` for DMARC) - **Record type**: TXT - **Value**: Paste the value wrapped in quotes - **TTL**: 300 (or your preference) 5. Click **Create records** {/* prettier-ignore */} The general steps for adding TXT records are similar across DNS providers: 1. Log in to your DNS provider's management console 2. Navigate to your domain's DNS settings (often called "DNS Management", "DNS Records", or "Advanced DNS") 3. Add a new TXT record for each of the two records 4. For each record, you'll need to enter: - **Type**: TXT - **Name/Host**: The record name - **Value/Content**: The record value from the table above - **TTL**: Use the default or set to 3600 (1 hour) 5. Save your changes Some providers automatically append your domain to the record name. If you're adding the DKIM record and your provider does this, enter only `unmta._domainkey` rather than the full `unmta._domainkey.example.com`. DNS changes can take up to 48 hours to propagate, though most updates appear within a few minutes to a few hours. ## Verifying a Domain Once you've added the DNS records: 1. Go to your domain's detail page 2. Click the **Verify Now** button The system queries DNS for your DKIM record. It must be correctly configured for verification to succeed. If verification fails, you'll see which records are still pending. Double-check your DNS configuration and try again—remember that DNS propagation can take time. ### What Gets Verified | Record | Verification Check | | ------ | ------------------------------------------------------------------------ | | DKIM | TXT record at `unmta._domainkey.yourdomain.com` contains your public key | DMARC is displayed in the DNS records table but is not required for domain verification. However, we strongly recommend configuring it for optimal deliverability. ## Domain Status Domains have two possible statuses: | Status | Meaning | | ------------ | -------------------------------------------------------------------- | | **Pending** | DNS records not yet verified. Email cannot be sent from this domain. | | **Verified** | Domain is authenticated and ready to send email. | ### Ongoing Monitoring Verified domains are automatically monitored to ensure DNS records remain correctly configured. If your DNS records change or are removed: 1. **First failed check**: You'll receive a warning email. The domain stays verified to give you time to fix the issue. 2. **Second consecutive failed check**: The domain is downgraded to **Pending** status and you'll receive a notification. You'll need to fix the DNS records and verify again. This two-strike policy prevents brief DNS issues from immediately disrupting your email sending while still catching persistent problems. ## Managing Domains ### Viewing Domain Details Click any domain in the list to view its detail page, which shows: - Current verification status - All DNS records with copy buttons - Per-record verification status ### Deleting a Domain To delete a domain: 1. Go to the domain's detail page 2. Click the menu button and select **Delete domain** 3. Type the domain name to confirm 4. Click **Delete domain** Deleting a domain is immediate and irreversible. Any email sent from this domain will be rejected after deletion. ## API Reference For programmatic domain management, see the [Domains API documentation](/docs/api#domains). --- # Events (/docs/events) ## Overview The Events page provides two ways to export event data from your unMTA cluster: | Method | Use Case | | ---------------- | --------------------------------------------------------------------------------- | | **Webhooks** | Real-time HTTP notifications for integrations, analytics, and automated workflows | | **Log Shipping** | Periodic upload to S3 for compliance, archival, and batch processing | Both methods deliver the same event data in the same JSON format—choose the one that fits your use case, or use both together. Event logs on each MTA are automatically deleted after 24 hours. Enable webhooks and/or log shipping to preserve your event data beyond this retention window. ## Event Types unMTA generates events throughout the email delivery lifecycle, plus engagement events when open and click-tracking is enabled for a message: | Event | Description | | -------------------- | ------------------------------------------------------------- | | **Reception** | Message accepted by unMTA for delivery | | **Delivery** | Message successfully delivered to the recipient's mail server | | **Bounce** | Permanent delivery failure (5xx SMTP response) | | **TransientFailure** | Temporary delivery failure (4xx SMTP response, will retry) | | **Expiration** | Message exceeded maximum lifetime in queue | | **AdminBounce** | Message bounced via admin API | | **OOB** | Out-of-band bounce received after initial acceptance | | **Feedback** | ARF feedback report (spam complaint from recipient) | | **Open** | Tracking pixel loaded by the recipient (opt-in per message) | | **Click** | Tracked link clicked by the recipient (opt-in per message) | ## Event Payload Each event is a JSON object. The example below shows a Delivery event, which includes the most fields: ```json { "type": "Delivery", "id": "d7f8a9b0c1d2e3f4", "sender": "d7f8a9b0c1d2e3f4@c-abc123.unmta.net", "recipient": "user@example.com", "queue": "example.com", "site": "example.com", "peer_address": { "name": "mx.example.com", "addr": "192.0.2.1" }, "response": { "code": 250, "content": "OK" }, "timestamp": 1706000000, "created": 1706000000, "num_attempts": 1, "bounce_classification": "Uncategorized", "egress_pool": "default", "egress_source": "default-source", "delivery_protocol": "ESMTP", "meta": { "from": "sender@example.com", "to": "user@example.com", "subject": "Your order has shipped", "message_id": "", "x_mailer": "MyApp/1.0", "hostname": "m-12345678.unmta.net", "authn_id": "api_user" }, "headers": {}, "session_id": "984851cf-952c-4be6-82fd-375345ab3419", "nodeid": "a3541223-aa59-4ad2-80ea-8785d23e29e6", "tls_cipher": "TLS_AES_256_GCM_SHA384", "tls_protocol_version": "TLSv1.3" } ``` ### Key Fields | Field | Description | | ----------------------- | ----------------------------------------------------------- | | `type` | Event type (see Event Types table above) | | `id` | Unique message identifier | | `sender` | Envelope sender (bounce address) | | `recipient` | Envelope recipient | | `queue` | Destination queue | | `site` | Mail exchanger site | | `peer_address` | Remote server hostname and IP | | `response` | SMTP response code and message | | `timestamp` | Unix timestamp when the event occurred | | `created` | Unix timestamp when the message was received | | `num_attempts` | Number of delivery attempts | | `bounce_classification` | Bounce category (e.g., Uncategorized, AuthenticationFailed) | | `meta` | Message headers captured at reception | | `session_id` | SMTP session identifier | | `nodeid` | MTA node that processed the event | ### Event-Specific Fields Different event types include additional fields: **Reception events:** - `reception_protocol` — How the message was received (ESMTP, HTTP) - `size` — Message size in bytes **Delivery, Bounce, and TransientFailure events:** - `delivery_protocol` — Protocol used for delivery (ESMTP) - `egress_pool` — Outbound IP pool used - `egress_source` — Specific outbound source **Feedback events:** - `feedback_report` — ARF report details **Open and Click events** have a different, tracking-specific shape (no `meta`, no `peer_address`, etc.) — see [Open and Click Events](#open-and-click-events) below. ### Captured Headers The following headers are captured in the `meta` field: - `from`, `to`, `cc`, `reply_to` - `subject`, `message_id` - `in_reply_to`, `references` - `list_id`, `list_unsubscribe`, `list_unsubscribe_post` - All custom `X-*` headers ### System Meta Fields In addition to captured headers, unMTA adds the following system-generated fields to `meta`: | Field | Description | | ---------- | ---------------------------------------------------------------------------------- | | `hostname` | Hostname of the MTA node that processed the message (e.g., `m-12345678.unmta.net`) | | `authn_id` | Authenticated identity used to submit the message | ## Open and Click Events Open and Click events are emitted when a recipient loads a tracking pixel or clicks a tracked link, for messages that opted in to tracking with `X-UNMTA-Track-Opens: 1` or `X-UNMTA-Track-Clicks: 1`. They use a different, intentionally flatter shape than lifecycle events — they originate at the tracking endpoint, not at the MTA's delivery pipeline, so they don't carry `peer_address`, `meta`, `response`, etc. ### Open Event ```json { "type": "Open", "id": "d7f8a9b0c1d2e3f4", "recipient": "user@example.com", "timestamp": 1706000042, "event_time": "2026-01-23T12:00:42Z", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...", "remote_ip": "203.0.113.42" } ``` ### Click Event ```json { "type": "Click", "id": "d7f8a9b0c1d2e3f4", "recipient": "user@example.com", "url": "https://example.com/promo?utm_source=email", "timestamp": 1706000117, "event_time": "2026-01-23T12:01:57Z", "user_agent": "Mozilla/5.0 ...", "remote_ip": "203.0.113.42" } ``` ### Bot-Classified Example When the pixel or click came from a scanner, AI crawler, or mailbox proxy, the event gains a nested `bot` object: ```json { "type": "Open", "id": "d7f8a9b0c1d2e3f4", "recipient": "user@example.com", "timestamp": 1706000042, "event_time": "2026-01-23T12:00:42Z", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/605.1.15 ...", "remote_ip": "17.58.32.42", "bot": { "kind": "proxy", "source": "apple" } } ``` ### Open/Click Fields | Field | Description | | ------------- | -------------------------------------------------------------------------------------------- | | `type` | `"Open"` or `"Click"` | | `id` | Message ID of the original send (matches the `id` on the corresponding Reception/Delivery) | | `recipient` | Envelope recipient the pixel/link was minted for | | `url` | *(Click only)* The original URL the recipient clicked | | `timestamp` | Unix timestamp when the pixel/click was loaded | | `event_time` | Same moment as `timestamp`, as an ISO-8601 string | | `user_agent` | Value of the `User-Agent` header on the pixel/click request | | `remote_ip` | IP that loaded the pixel or clicked the link | | `bot` | *(optional)* Bot classification. A nested object with `kind` (`"proxy"` or `"automation"`) and `source` (fine-grained provider — `"apple"`, `"google"`, `"security"`, `"ai"`, `"crawler"`, `"prefetch"`, etc.). Present only when the event was identified as non-human. See [Bot Signatures](/docs/bot-signatures#event-labeling) for the complete enum and recommended filtering patterns. | The `bot` field is **absent** for human traffic — treat its presence, not its truthiness, as the signal. Bucket engagement metrics on `bot.kind` (or the absence of `bot`); use `bot.source` only for diagnostic slicing. The agent never suppresses Open/Click events; filtering is a consumer-side decision. ### Routing Open and Click events route the same way lifecycle events do: to the webhook destination named by `X-UNMTA-Event-Dest` on the original send, or the default destination if the header wasn't set. If no default is configured and no header was set, the event is not sent. ## Webhooks Webhooks deliver events to your HTTP endpoint in real-time. Use webhooks when you need immediate notification of email events—for example, to update a CRM when an email bounces or to trigger workflows when messages are delivered. ### How Webhooks Work 1. Events are collected as they occur 2. Events are batched (up to 500 per request) for efficiency 3. Your endpoint receives a JSON array of event objects 4. unMTA retries on failure with exponential backoff ### Multiple Webhook Destinations Each cluster supports multiple webhook destinations. This lets you route events to different endpoints based on the message — for example, sending transactional events to one system and marketing events to another. Each destination has: - A user-defined **name** for your reference - A unique **6-character ID** (e.g. `abc123`) used to route messages at send time - Its own **URL** and **signing secret** - An optional **default** flag — if set, this destination receives events for any message that doesn't specify a destination ### Routing Events to a Destination Add the `X-UNMTA-Event-Dest` header to your message at injection time with the destination's 6-character ID: ``` X-UNMTA-Event-Dest: abc123 ``` All lifecycle events for that message (Delivery, Bounce, TransientFailure, Expiration) — as well as any `Open` and `Click` events generated by tracking — are sent to the specified destination. OOB bounces and ARF feedback reports are also routed back to the correct destination via the encoded return-path. **If no header is present:** events go to the destination marked as default. **If no default is configured and no header is set:** no webhook is sent for that message. ### Configuring Webhook Destinations 1. Navigate to the **Events** page from the sidebar 2. In the **Webhooks** section, click **Add webhook** 3. Enter a name and your HTTPS endpoint URL 4. Optionally enable **Set as default** to receive events from messages with no explicit destination 5. Click **Save** 6. Open the destination to copy the **ID** (used in `X-UNMTA-Event-Dest`) and **secret** (used for signature verification) You can add as many destinations as needed. To temporarily stop sending to a destination without deleting it, use the **Disable webhook** option in the actions menu. Each destination's signing secret is auto-generated and cannot be changed. Copy it when you create the destination and store it securely. ### URL Requirements - Must be a valid URL with `http://` or `https://` protocol - Cannot be localhost, 127.0.0.1, or ::1 - Cannot use private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x) - Cannot use local domains (.local, .lan, .internal) ### Webhook Request Format ```http POST /your-endpoint HTTP/1.1 Host: your-server.com Content-Type: application/json User-Agent: unMTA-Webhooks/1.0 X-Webhook-Id: a1b2c3d4e5f6... X-Webhook-Timestamp: 1706000000 X-Webhook-Signature: v1=abc123def456... [ {"type": "Delivery", "id": "msg1", ...}, {"type": "Bounce", "id": "msg2", ...} ] ``` ### Webhook Authentication Every webhook request includes a cryptographic signature so you can verify it came from unMTA. Each destination is signed with its own secret. | Header | Description | | --------------------- | --------------------------------------------------------- | | `X-Webhook-Id` | Unique identifier for this batch (SHA256 hash of payload) | | `X-Webhook-Timestamp` | Unix timestamp when the request was sent | | `X-Webhook-Signature` | HMAC-SHA256 signature in format `v1=` | The signature is computed as: ``` signing_string = webhook_id + "." + timestamp + "." + payload signature = HMAC-SHA256(secret, signing_string) ``` Always verify webhook signatures before processing events. This prevents attackers from sending fake events to your endpoint. ### Idempotent Consumers Webhook consumers should handle duplicate events. Use the `X-Webhook-Id` header to deduplicate. Each destination is retried independently — a failure at one endpoint does not cause re-delivery to any other endpoint that already received the batch. Duplicates at a given endpoint come only from retries against that same endpoint (for example, when your server returns a 5xx and unMTA retries the same batch). ### Verifying Webhook Signatures {/* prettier-ignore */} ```php $payload = file_get_contents('php://input'); $webhookId = $_SERVER['HTTP_X_WEBHOOK_ID']; $timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP']; $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']; $signingString = $webhookId . '.' . $timestamp . '.' . $payload; $expectedSignature = 'v1=' . hash_hmac('sha256', $signingString, $secret); if (!hash_equals($expectedSignature, $signature)) { http_response_code(401); exit('Invalid signature'); } $events = json_decode($payload, true); foreach ($events as $event) { // Process each event } ```` {/* prettier-ignore */} ```javascript const crypto = require('crypto'); app.post('/webhook', (req, res) => { const payload = JSON.stringify(req.body); const webhookId = req.headers['x-webhook-id']; const timestamp = req.headers['x-webhook-timestamp']; const signature = req.headers['x-webhook-signature']; const signingString = `${webhookId}.${timestamp}.${payload}`; const expectedSignature = 'v1=' + crypto .createHmac('sha256', secret) .update(signingString) .digest('hex'); if (!crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signature) )) { return res.status(401).send('Invalid signature'); } for (const event of req.body) { // Process each event } res.status(200).send('OK'); }); ```` {/* prettier-ignore */} ```python import hmac import hashlib import json from flask import Flask, request app = Flask(**name**) @app.route('/webhook', methods=['POST']) def webhook(): payload = request.get_data(as_text=True) webhook_id = request.headers.get('X-Webhook-Id') timestamp = request.headers.get('X-Webhook-Timestamp') signature = request.headers.get('X-Webhook-Signature') signing_string = f"{webhook_id}.{timestamp}.{payload}" expected_signature = 'v1=' + hmac.new( secret.encode(), signing_string.encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected_signature, signature): return 'Invalid signature', 401 events = json.loads(payload) for event in events: # Process each event pass return 'OK', 200 ```` {/* prettier-ignore */} ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { payload, _ := io.ReadAll(r.Body) webhookId := r.Header.Get("X-Webhook-Id") timestamp := r.Header.Get("X-Webhook-Timestamp") signature := r.Header.Get("X-Webhook-Signature") signingString := webhookId + "." + timestamp + "." + string(payload) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signingString)) expectedSignature := "v1=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expectedSignature), []byte(signature)) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var events []map[string]interface{} json.Unmarshal(payload, &events) for _, event := range events { // Process each event } w.WriteHeader(http.StatusOK) } ```` ### Handling Failures When your endpoint returns a 4xx or 5xx status code, unMTA will retry the batch to that destination with exponential backoff. Retries are scoped to the destination that failed — other destinations in the same batch are not re-delivered. To stay safe against retries against your own endpoint, make your consumer idempotent: - Use the `X-Webhook-Id` header to deduplicate requests - Store processed webhook IDs and skip duplicates - Return 200 OK as soon as you've received the events ## Log Shipping Log shipping periodically uploads event logs to S3-compatible storage. Use log shipping when you need to archive events for compliance, run batch analytics, or integrate with data warehouses. ### Supported Storage Services **AWS S3** — Native Amazon S3 with region selection **S3-Compatible Services:** - MinIO - DigitalOcean Spaces - Backblaze B2 - Cloudflare R2 - Wasabi - Any S3-compatible API ### Upload Frequency | Frequency | Use Case | | ---------- | ----------------------------------------------- | | 5 minutes | Near real-time analysis, high-volume monitoring | | 10 minutes | | | 15 minutes | | | 30 minutes | Balance of timeliness and efficiency | | 1 hour | Standard operational logging | | 3 hours | | | 6 hours | | | 12 hours | | | 24 hours | Daily archival, compliance retention | Logs are uploaded as compressed JSON files (zstd compression). ### Configuring Log Shipping 1. Navigate to the **Events** page from the sidebar 2. In the S3 Log Shipping section, click **Edit Log Shipping** 3. Enable the **Enable S3 log shipping** toggle 4. Select your service type: - **AWS S3** — Enter your region - **S3 Compatible** — Enter your endpoint URL 5. Enter your bucket name 6. Optionally enter a path prefix (e.g., `logs/unmta/`) 7. Enter your access key and secret key 8. Select your upload frequency 9. Click **Test Connection** to verify access 10. Click **Save** ### S3 Bucket Requirements Your S3 credentials need the following permissions: | Permission | Required | Purpose | | ----------------- | -------- | ------------------------------------------ | | `s3:PutObject` | Yes | Upload log files | | `s3:DeleteObject` | No | Clean up test files during connection test | **Example IAM Policy (AWS):** ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject"], "Resource": "arn:aws:s3:::your-bucket-name/*" } ] } ``` ### Log File Format Log files are uploaded with the following characteristics: - **Format**: JSON, zstd compressed - **Structure**: One event per line (JSONL format) - **Organization**: Files organized by timestamp in your bucket - **Content**: Same event payload structure as webhooks ## Best Practices ### For Webhooks - **Always verify signatures** — Each destination has its own secret; reject requests with invalid signatures to prevent spoofing - **Respond quickly** — Return 200 OK immediately after receiving; process events asynchronously - **Handle retries** — Use `X-Webhook-Id` for deduplication; retries against your endpoint can produce duplicate deliveries - **Use HTTPS** — Encrypt webhook traffic to protect event data in transit - **Monitor failures** — Each destination is retried independently, but a chronically failing endpoint will fall behind and eventually exhaust its retry budget ### For Log Shipping - **Choose appropriate frequency** — Higher frequencies provide fresher data but create more small files - **Use path prefixes** — Organize logs by environment or purpose (e.g., `production/unmta/`) - **Set retention policies** — Configure S3 lifecycle rules to manage storage costs - **Secure credentials** — Use IAM roles with minimal required permissions --- # Getting Started (/docs/index) ## Overview unMTA provides email delivery infrastructure for your applications. You can send email using either SMTP or HTTP—choose whichever fits your application best. This guide walks you through the initial setup: adding a domain, creating credentials, and sending your first message. ## Prerequisites Before you begin, you'll need: - Access to the unMTA dashboard (from your [onboarding](/onboarding)) - A domain you control (for DNS configuration) - An application that needs to send email ## 1. Add Your Domain Domains must be verified before you can send email through unMTA. Verification confirms you control the domain and enables DKIM signing for your messages. 1. Navigate to **Domains** in the sidebar 2. Click **Add domain** 3. Enter your domain name (e.g., `example.com`) 4. Click **Save** The dashboard displays the DNS records you'll need to configure. See [Domains](/docs/domains) for complete details on domain verification and DNS configuration. ## 2. Configure DNS Records Add these DNS records to your domain. The exact values are shown in the dashboard after adding your domain. | Record Type | Name | Purpose | | ----------- | ------------------ | --------------------------- | | TXT | `unmta._domainkey` | DKIM public key for signing | | TXT | `_dmarc` | DMARC policy (recommended) | DNS changes can take up to 48 hours to propagate, though most providers update within minutes. See [Domains](/docs/domains) for provider-specific instructions. ## 3. Verify Your Domain Once DNS records are in place: 1. Return to the **Domains** page 2. Click the menu on your domain row 3. Select **Check DNS** If records are configured correctly, the domain status changes to **Verified** and you can start sending email. ## 4. Create Credentials Credentials authenticate your application when sending email. 1. Navigate to **Credentials** in the sidebar 2. Click **Add credentials** 3. Enter a username for the credential 4. Select which domains this credential can send from 5. Click **Save** Copy the password immediately—it's only shown once. If you lose it, you'll need to delete the credential and create a new one. You can restrict credentials to specific domains and IP addresses. See [Credentials](/docs/credentials) for details. ## 5. Send Your First Email With a verified domain and credentials, you're ready to send. Choose SMTP or HTTP based on your application. To find your cluster's SMTP hostname and HTTP endpoint, see [Clusters](/docs/clusters). ### SMTP Connect to your cluster's SMTP endpoint on port 587 with STARTTLS: ```bash swaks --to recipient@example.com \ --from sender@yourdomain.com \ --server your-cluster.unmta.net:587 \ --tls \ --auth-user your-credential \ --auth-password your-password \ --header "Subject: Test Message" \ --body "Hello from unMTA!" ``` ### HTTP POST to the injection endpoint with HTTP Basic Auth: ```bash curl -X POST https://your-cluster.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello from unMTA", "text_body": "This is a test email." } }' ``` See [Sending Messages](/docs/message-injection) for complete options including scheduling and campaign headers. ## What's Next - **Track delivery** — Set up [webhooks or log shipping](/docs/events) to monitor email events - **Manage queues** — Use the [Queues](/docs/queues) page to monitor and control delivery - **Automate setup** — Use the [API](/docs/api) to manage domains and credentials programmatically --- # Sending Messages (/docs/message-injection) ## Overview Message injection is how you submit email for delivery through your unMTA cluster. You can inject messages using either SMTP or HTTP—choose whichever fits your application best. Before sending email, you'll need: - **A verified domain** — See [Domains](/docs/domains) to add and verify your sending domain - **A credential** — See [Credentials](/docs/credentials) to create SMTP/HTTP credentials ## SMTP Injection Connect to your cluster's SMTP endpoint using any standard SMTP client or library. ### Connection Settings | Setting | Value | | -------------- | ------------------------------ | | **Host** | Your cluster's SMTP endpoint | | **Port** | 25, 587, or 2587 | | **Encryption** | STARTTLS required on all ports | | **Auth** | SMTP AUTH PLAIN | To find your cluster's SMTP hostname and HTTP endpoint, see [Clusters](/docs/clusters). ### Requirements - **STARTTLS required** — Authentication only accepted over encrypted connection - **FROM header required** — Used for DKIM signing - **Domain validation** — MAIL FROM and FROM header domains must be verified and allowed for your credential - **Max message size** — 10 MB ### Example Using [swaks](https://www.jetmore.org/john/code/swaks/) (Swiss Army Knife for SMTP): ```bash swaks --to recipient@example.com \ --from sender@yourdomain.com \ --server your-cluster.unmta.net:587 \ --tls \ --auth-user your-credential \ --auth-password your-password \ --header "Subject: Test Message" \ --body "Hello from unMTA!" ``` ## HTTP Injection Submit messages via HTTP POST for applications that prefer REST APIs over SMTP. ### Endpoint ``` POST https://your-cluster.unmta.net/api/inject/v1 ``` ### Authentication HTTP Basic Auth using your credential username and password. ### Request Format Send a JSON object with: | Field | Required | Description | | ----------------- | -------- | ------------------------------------------------------------------------------ | | `envelope_sender` | Yes | Bounce address for delivery failures | | `recipients` | Yes | Array of recipient email addresses | | `content` | Yes | Message content object | | `tracking` | No | Open/click tracking opt-in ([see below](#tracking-open-and-click-via-api)) | ### Content Object | Field | Required | Description | | ----------- | -------- | -------------------------------------- | | `from` | Yes | From header (used for DKIM signing) | | `subject` | No | Subject line | | `text_body` | No | Plain text body | | `html_body` | No | HTML body | | `reply_to` | No | Reply-To address | | `headers` | No | Additional headers as key-value object | Include at least one of `text_body` or `html_body`. For best deliverability, include both so recipients without HTML support see the plain text version. ### Example ```bash curl -X POST https://your-cluster.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello from unMTA", "text_body": "This is the plain text version.", "html_body": "

This is the HTML version.

" } }' ``` ### Response On success, the API returns HTTP 200 with details about the injected message. | Status | Meaning | | ------ | -------------------------------------------- | | 200 | Message accepted for delivery | | 401 | Invalid credentials | | 429 | Rate limited (too many failed auth attempts) | ### Template Substitution HTTP injection supports template substitution, allowing you to personalize messages for each recipient without making separate API calls. #### Template Variables Variables are populated from three sources, in order of precedence: 1. **Per-recipient substitutions** — `recipients[].substitutions` (highest priority) 2. **Global substitutions** — `substitutions` at the request level 3. **Built-in variables** — `name` and `email` from each recipient Substitution values can be any JSON type—strings, numbers, objects, or arrays. #### Syntax Use double curly braces to insert variables: ``` Hello {{ name }}, your order #{{ order_id }} has shipped! ``` #### Template Dialects Control templating behavior with the `template_dialect` field: | Dialect | Description | | ------------ | ---------------------------------------- | | `Jinja` | [MiniJinja](https://docs.rs/minijinja/latest/minijinja/) engine—Jinja2-compatible syntax (default) | | `Handlebars` | Handlebars-compatible engine | | `Static` | No template expansion—send content as-is | MiniJinja supports most common Jinja2 features including filters, conditionals, and loops, plus additional functions like `now()`, `dateformat()`, and `pluralize()`. #### Scope Templates are applied to: - `text_body` - `html_body` - `headers` Attachments are **not** subject to template substitution. #### Example Send a personalized message to multiple recipients: ```bash curl -X POST https://your-cluster.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "substitutions": { "company": "Acme Corp" }, "recipients": [ { "email": "alice@example.com", "name": "Alice", "substitutions": { "order_id": "12345" } }, { "email": "bob@example.com", "name": "Bob", "substitutions": { "order_id": "67890" } } ], "content": { "from": "orders@yourdomain.com", "subject": "Your {{ company }} order has shipped", "text_body": "Hi {{ name }},\n\nYour order #{{ order_id }} is on its way!\n\nThanks,\n{{ company }}" } }' ``` This sends two personalized messages: - Alice receives: "Hi Alice, Your order #12345 is on its way! Thanks, Acme Corp" - Bob receives: "Hi Bob, Your order #67890 is on its way! Thanks, Acme Corp" To send content containing literal curly braces without substitution, set `template_dialect` to `Static`. ### Tracking (Open and Click) via API Open and click tracking can be turned on per message by including an optional `tracking` object on the request. | Field | Type | Description | | --------- | ------- | ---------------------------------------------------------------------------- | | `opens` | boolean | Enable (or explicitly disable) open tracking for this message | | `clicks` | boolean | Enable (or explicitly disable) click tracking for this message | | `domain` | string | Override the tracking domain for this message. Must be verified in your cluster | All three fields are optional. The effect is identical to setting the corresponding `X-UNMTA-Track-Opens`, `X-UNMTA-Track-Clicks`, and `X-UNMTA-Tracking-Domain` headers in the message — see the [Tracking guide](/docs/tracking) for the full feature reference. ```bash curl -X POST https://your-cluster.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello", "html_body": "

Check out our site!

" }, "tracking": { "opens": true, "clicks": true, "domain": "track.yourdomain.com" } }' ``` **Precedence.** If both the `tracking` object and the equivalent `X-UNMTA-Track-*` header are set on the same message, the `tracking` object wins. Setting `opens: false` or `clicks: false` explicitly disables tracking even if the message carries an enabling header — useful for suppressing tracking on transactional or legally-sensitive messages while reusing a templated body. The `tracking` object is only supported when `content` is sent as a structured object. If `content` is a raw RFC822 string, the request returns `400` — in that case, place the `X-UNMTA-Track-*` headers in the message directly, since you already control its MIME. ## Special Headers These headers control message behavior during injection. Include them in your message headers for SMTP, or in the `headers` object for HTTP injection. ### X-Schedule Schedule messages for future delivery by including an `X-Schedule` header with a JSON value specifying when delivery should occur. **Deferred delivery** — Delay the first delivery attempt until a specific time: ``` X-Schedule: {"first_attempt":"2024-03-01T17:00:00-08:00"} ``` **Time-window delivery** — Restrict delivery to specific days and times: ``` X-Schedule: {"dow":"Mon,Tue,Wed,Thu,Fri","tz":"America/New_York","start":"09:00:00","end":"17:00:00"} ``` This example delivers only on weekdays between 9 AM and 5 PM Eastern time. Messages received outside this window are held until the next valid delivery time. | Parameter | Description | | ----------------- | ---------------------------------------------------------------------------- | | **first_attempt** | RFC 3339 timestamp for earliest delivery (e.g., `2024-03-01T17:00:00-08:00`) | | **dow** | Days of week for delivery (e.g., `Mon,Wed,Fri`) | | **tz** | IANA timezone name (e.g., `America/Phoenix`) | | **start** | Window start time in `HH:MM:SS` format | | **end** | Window end time in `HH:MM:SS` format | Time-window parameters (`dow`, `tz`, `start`, `end`) must all be specified together. You can combine them with `first_attempt` to delay until a specific time and then apply window constraints. ### X-Campaign Assign messages to a campaign for logical grouping and management: ``` X-Campaign: welcome-series ``` Campaigns let you organize related messages and manage them together: - **Promotional sends** — Group messages from a marketing campaign - **Transactional categories** — Separate order confirmations from shipping notifications - **A/B testing** — Track different message variants independently Messages with the same campaign are grouped in the [queue view](/docs/queues) and can be suspended or bounced as a unit. When suspending or bouncing a queue, you can target a specific campaign to affect only those messages while leaving other traffic to the same domain unaffected. If no campaign is specified, messages are grouped by destination domain only. The `X-Campaign` header is removed from the message before delivery. ## Rate Limiting unMTA does not rate limit message injection. However, it does include brute-force protection to prevent credential stuffing attacks by temporarily blocking IPs with repeated failed authentication attempts. ### How It Works - Failed authentication attempts are tracked per IP address - After the threshold is exceeded, the IP is temporarily blocked - IPv6 addresses are grouped by prefix to prevent address rotation attacks ### When Blocked If you exceed the failed authentication threshold: - **SMTP:** `421 4.7.0 Too many failed authentication attempts. Try again later.` - **HTTP:** `429 Too Many Requests` with `Retry-After` header If you're blocked due to failed attempts, wait before trying again. The block expires automatically after a short period. ## Message Validation Both SMTP and HTTP injection apply these validations: | Check | Description | | ------------------------ | ------------------------------------------------------- | | **Domain verified** | Sender domain must be verified in your cluster | | **Domain authorized** | Sender domain must be allowed for your credential | | **FROM header required** | Message must include a FROM header for DKIM signing | | **Domain alignment** | FROM header domain must match an allowed sending domain | Messages failing validation are rejected with an appropriate error message. ## Bounce Addresses unMTA automatically generates bounce addresses for delivery tracking. When a message bounces, the bounce notification is sent to this generated address, allowing unMTA to correlate bounces with the original message. The generated bounce address format is: ``` {message_id}@c-{cluster_id}.unmta.net ``` Your original envelope sender is preserved in message metadata and included in [event payloads](/docs/events). ## Best Practices ### Choose the Right Method - **SMTP** — Best for applications with existing SMTP infrastructure, email clients, or when streaming large volumes - **HTTP** — Best for REST API integrations, serverless environments, or when you need template substitution ### Validate Before Sending Ensure your sending domain is verified and your credential has access to it before attempting injection. Check the [Domains](/docs/domains) page for domain status. ### Handle Errors Gracefully - **401, 429 errors** — Check your credential username and password - **Domain errors** — Verify the sending domain is verified and allowed for your credential ### Use Campaigns for Organization Assign campaigns to logically group related messages. This makes it easier to monitor delivery and take action on specific message types without affecting other traffic. --- # Queues (/docs/queues) ## Overview The Queues page provides visibility into email delivery queues and allows administrative control over message flow. Messages waiting for delivery are organized into queues by destination domain and optionally by campaign. unMTA provides three views of your queues: | View | Purpose | | -------------------- | -------------------------------------------------------- | | **Scheduled Queues** | Active queues with messages waiting for delivery | | **Suspended Queues** | Paused queues where delivery is temporarily halted | | **Bounced Queues** | Queues where messages are being administratively bounced | You can view queues across an entire cluster or filter to a specific MTA using the selector in the top right. ## Scheduled Queues The Scheduled Queues tab shows all active delivery queues. Each queue represents messages destined for a specific domain, optionally grouped by campaign. | Column | Description | | ------------ | ------------------------------------ | | **Queue** | Destination domain (e.g., gmail.com) | | **Campaign** | Campaign identifier, if any | | **Messages** | Number of messages waiting in queue | Results are limited to the top 200 queues by message count. If your cluster has more queues, some may not be displayed. From any queue row, you can click the menu to suspend or bounce that specific queue. You can schedule messages for future delivery using the `X-Schedule` header, and assign messages to campaigns using the `X-Campaign` header. See [Sending Messages](/docs/message-injection#special-headers) for details. ## Suspended Queues Suspended queues have delivery temporarily paused. Messages remain in the queue but won't be processed until the suspension expires or is manually removed. | Column | Description | | ------------ | -------------------------------------------- | | **Domain** | Destination domain affected | | **Campaign** | Specific campaign, or all if not specified | | **Reason** | Explanation provided when suspending | | **Duration** | Time remaining until suspension auto-expires | To resume delivery, click the trash icon to delete the suspension. ## Bounced Queues Bounced queues are being administratively cleared. Matching messages are immediately bounced and removed, and new messages arriving for the same destination are also bounced until the rule expires. | Column | Description | | ------------ | --------------------------------------------- | | **Domain** | Destination domain affected | | **Campaign** | Specific campaign, or all if not specified | | **Reason** | Explanation provided when bouncing | | **Duration** | Time remaining until bounce rule auto-expires | | **Bounced** | Number of messages bounced so far | To stop bouncing new messages, click the trash icon to delete the bounce rule. ## Suspending a Queue Suspending a queue pauses delivery without discarding messages. Use this when you need to temporarily halt delivery—for example, during recipient server maintenance or while investigating delivery issues. ### Configuration | Field | Required | Description | | ------------ | -------- | --------------------------------------------------------- | | **Domain** | Yes | Destination domain to suspend (e.g., `gmail.com`) | | **Campaign** | No | Limit to specific campaign; leave empty for all campaigns | | **Duration** | Yes | How long to suspend (hours + minutes, minimum 1 minute) | | **Reason** | Yes | Explanation logged with the suspension | ### How to Suspend **From a scheduled queue:** 1. In the Scheduled Queues tab, click the menu icon on a queue row 2. Select **Suspend [domain]** 3. Adjust duration and enter a reason 4. Click **Suspend** **For any domain:** 1. Click **Options** in the top right 2. Select **Suspend a Queue** 3. Enter the domain (and optionally campaign) 4. Set duration and reason 5. Click **Suspend** ### Suspension Behavior - Messages remain queued but delivery attempts stop immediately - The suspension applies to the entire cluster or selected MTA - Suspension auto-expires after the specified duration - You can manually delete the suspension to resume delivery early ## Bouncing a Queue Bouncing a queue permanently discards matching messages. Each bounced message generates an `AdminBounce` event in your logs (unless logging is suppressed). This is a destructive operation. Bounced messages are immediately removed and cannot be recovered. ### Configuration | Field | Required | Description | | -------------------- | -------- | --------------------------------------------------------------- | | **Domain** | Yes | Destination domain to bounce | | **Campaign** | No | Limit to specific campaign; leave empty for all campaigns | | **Duration** | Yes | How long to bounce new messages (hours + minutes, min 1 minute) | | **Reason** | Yes | Logged in delivery logs and bounce records | | **Suppress logging** | No | Prevents `AdminBounce` log records for each bounced message | ### How to Bounce **From a scheduled queue:** 1. In the Scheduled Queues tab, click the menu icon on a queue row 2. Select **Bounce [domain]** 3. Adjust duration and enter a reason 4. Optionally enable **Suppress logging** 5. Click **Bounce** **For any domain:** 1. Click **Options** in the top right 2. Select **Bounce a Queue** 3. Enter the domain (and optionally campaign) 4. Set duration and reason 5. Click **Bounce** ### Bounce Behavior - Existing messages in matching queues are immediately bounced and removed - New messages arriving during the duration are also bounced - Each bounced message generates an `AdminBounce` event (unless suppressed) - The bounce rule auto-expires after the specified duration - You can manually delete the bounce rule to stop bouncing new messages AdminBounce events appear in your webhooks and log shipping if configured. See [Events](/docs/events) for details on the event payload. ## Removing Suspensions and Bounces ### Removing a Suspension 1. Navigate to the **Suspended Queues** tab 2. Find the suspension you want to remove 3. Click the trash icon on that row 4. Delivery resumes immediately for the affected queue ### Removing a Bounce Rule 1. Navigate to the **Bounced Queues** tab 2. Find the bounce rule you want to remove 3. Click the trash icon on that row 4. New messages will no longer be bounced (already bounced messages are gone) ## Best Practices ### When to Suspend - **Temporary delivery issues** — Pause while a recipient server is experiencing problems - **Investigation** — Hold messages while investigating high bounce rates - **Coordination** — Pause delivery while coordinating timing with a recipient domain - **Maintenance** — Halt delivery during your own maintenance windows ### When to Bounce - **Invalid domains** — Clear messages for domains confirmed as invalid or abandoned - **Recipient request** — Honor requests to remove all pending messages - **Business decision** — Stop all delivery to a domain based on policy - **Cleanup** — Clear test or unwanted messages from queues ### Duration Guidelines | Scenario | Suggested Duration | | ---------------------------- | ------------------ | | Quick investigation | 5–15 minutes | | Recipient server maintenance | 1–4 hours | | Extended investigation | 24 hours | | Permanent removal (bounce) | 24+ hours | ### Logging Considerations - **Keep logging enabled** for audit trails unless you have a specific reason not to - **Suppress logging** only for high-volume bounces where individual records aren't needed - **AdminBounce events** are valuable for tracking what was removed and why --- # Tracking (/docs/tracking) ## Overview Tracking domains let you brand the open and click-tracking URLs in your outbound email under a domain you control (e.g. `track.example.com`) instead of an unMTA-owned hostname. Recipients see your brand in the URLs that show up in their browser bar and link previews, which improves trust, deliverability, and analytics attribution. A tracking domain is a subdomain you point at unMTA via a single CNAME record. Once verified, unMTA terminates HTTPS for that hostname automatically (no certificate to manage on your side) and rewrites tracking links in delivered email to use it. Each tracking domain belongs to a single cluster. You can run multiple tracking domains in the same cluster and mark one as the **default**, which is used for any message that doesn't override the tracking host explicitly. ## Enabling Tracking on a Message Open and click tracking are opt-in per message. There are two equivalent ways to turn them on: - **In-message headers.** Works for both SMTP and HTTP injection. The sender controls the MIME and adds the opt-in headers directly. - **HTTP API fields.** HTTP injection only. The injecting system passes a `tracking` object on the request, without needing to mutate the message body — useful when the system that builds the message and the system that injects it are different. Either method strips the opt-in from the outbound message so recipients never see it. ### Using In-Message Headers | Header | Value | Effect | | ----------------------- | ----- | ------------------------------------------------------------------------------------------------- | | `X-UNMTA-Track-Opens` | `1` | Injects a 1×1 tracking pixel into the `text/html` part. Messages with no HTML part are a no-op. | | `X-UNMTA-Track-Clicks` | `1` | Rewrites `http(s)://` links in the `text/html` part to route through unMTA for click logging. | HTTP injection example: ```bash curl -X POST https://c-your-cluster-id.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello", "html_body": "

Check out our promo!

", "headers": { "X-UNMTA-Track-Opens": "1", "X-UNMTA-Track-Clicks": "1" } } }' ``` Only `` tags with `http://` or `https://` hrefs are rewritten. `mailto:`, `tel:`, `sms:`, `javascript:`, `data:`, anchor-only (`#section`), and relative URLs are left untouched. ### Using HTTP API Fields When injecting via the HTTP API, you can turn tracking on with a `tracking` object on the request body instead of embedding headers: ```bash curl -X POST https://c-your-cluster-id.unmta.net/api/inject/v1 \ -u "username:password" \ -H "Content-Type: application/json" \ -d '{ "envelope_sender": "bounces@yourdomain.com", "recipients": ["user@example.com"], "content": { "from": "sender@yourdomain.com", "subject": "Hello", "html_body": "

Check out our promo!

" }, "tracking": { "opens": true, "clicks": true } }' ``` `tracking.opens`, `tracking.clicks`, and `tracking.domain` have identical effect to their `X-UNMTA-Track-*` counterparts. If both are set on the same message, the API field wins — including `opens: false` or `clicks: false`, which explicitly suppress tracking even when the message carries an enabling header. The API field approach only works with structured `content`. If `content` is a raw RFC822 string, the request returns `400` — put the opt-in headers in the message directly instead, since you already control its MIME. ### Disabling Click Tracking on Specific Links When click tracking is enabled you can still opt individual links out by adding the `data-unmta-no-track` attribute. This is useful for unsubscribe links, legal footers, or any URL you don't want routed through a tracker. The attribute is stripped from the outbound HTML: ```html Unsubscribe Terms ``` Both the presence-only and `="true"` forms work. ### Choosing the Tracking Domain Per Message By default, tracked URLs use your cluster's [default tracking domain](#default-tracking-domain) if one is configured, or an unMTA-owned hostname (`c-.unmta.net`) otherwise. To override per message — for example, to brand links by customer or campaign — either add the `X-UNMTA-Tracking-Domain` header: ``` X-UNMTA-Tracking-Domain: track.example.com ``` …or, when injecting via HTTP, pass `tracking.domain` on the request body: ```json { "tracking": { "domain": "track.example.com" } } ``` Both are matched case-insensitively against your cluster's verified tracking domains and are stripped from the outbound message. Selection happens in this order: 1. `X-UNMTA-Tracking-Domain` value, if it names a verified domain in this cluster 2. The cluster's default tracking domain, if one is set 3. The unMTA-owned fallback (`c-.unmta.net`) If the header names a domain that isn't verified in your cluster, it's silently ignored and selection falls through to step 2. The send still succeeds — a misconfigured header never fails delivery. ## How It Works When you add a tracking domain, unMTA issues a unique **verification target** — a hostname like `v-deadbeef.track.unmta.net`. You add a single CNAME record at your DNS provider pointing your tracking subdomain at that target. The same record proves ownership and routes live tracking traffic. Behind the scenes, when a recipient opens a tracking URL on `track.example.com`, the request lands on unMTA's edge, which: 1. Looks up the hostname against the verified tracking domains registered to your cluster 2. Terminates TLS using a certificate it provisions and renews automatically 3. Records the open or click event and serves the appropriate response There's nothing for you to install, run, or rotate. ## Adding a Tracking Domain 1. Navigate to the **Tracking Domains** page from the sidebar 2. Click the **Add tracking domain** button 3. Enter your tracking subdomain (e.g., `track.example.com`) 4. Optionally toggle **Set as default** to make this the cluster's default tracking domain once verified 5. Click **Add tracking domain** You'll be redirected to the tracking domain detail page showing the CNAME record you need to configure. ### Tracking Domain Requirements - Must be a valid subdomain — at least three labels (e.g. `track.example.com`), not an apex domain - Cannot be an IP address - Cannot use reserved TLDs: `localhost`, `local`, `test`, `invalid`, `example`, `localdomain` - Must be unique within your cluster (the same name can exist in different clusters) Tracking subdomains can be anything you control — common choices are `track.example.com`, `links.example.com`, or `email.example.com`. Pick something short and stable since it will appear in every link your recipients see. ## DNS Record Setup After adding a tracking domain, you'll see a single CNAME record on the detail page: | Record | Name | Value | | ------ | ------------------- | --------------------------------------- | | CNAME | `track.example.com` | `v-deadbeef.track.unmta.net` (yours) | Click the copy button next to the target value to copy it to your clipboard. ### Provider-Specific Instructions {/* prettier-ignore */} 1. Log in to your Cloudflare dashboard 2. Select your domain 3. Go to **DNS** → **Records** 4. Click **Add record** 5. Configure the record: - **Type**: CNAME - **Name**: The subdomain portion (e.g., `track`) - **Target**: Paste the verification target from your tracking domain detail page - **Proxy status**: **DNS only** (grey cloud — do not proxy through Cloudflare) - **TTL**: Auto 6. Click **Save** The CNAME record must be **DNS only** (grey cloud). If Cloudflare's orange-cloud proxy is enabled, verification will fail and live tracking traffic will be intercepted. {/* prettier-ignore */} 1. Open the Route 53 console 2. Select **Hosted zones** and click your domain 3. Click **Create record** 4. Configure the record: - **Record name**: The subdomain portion (e.g., `track`) - **Record type**: CNAME - **Value**: Paste the verification target - **TTL**: 300 (or your preference) 5. Click **Create records** {/* prettier-ignore */} The general steps for adding a CNAME record are similar across DNS providers: 1. Log in to your DNS provider's management console 2. Navigate to your domain's DNS settings 3. Add a new CNAME record: - **Type**: CNAME - **Name/Host**: The subdomain portion (e.g., `track`) - **Value/Target**: The verification target from your tracking domain detail page - **TTL**: Use the default or set to 3600 (1 hour) 4. Save your changes Most providers automatically append your domain to the record name. Enter only the subdomain portion (e.g., `track`) rather than the full `track.example.com`. DNS changes can take up to 48 hours to propagate, though most updates appear within a few minutes to a few hours. ## Verifying a Tracking Domain Once you've added the CNAME record: 1. Go to your tracking domain's detail page 2. Click the **Verify Now** button unMTA walks the CNAME chain from your domain and confirms it ends at the issued verification target. Once it does, the status flips to **Active** and the domain is ready for use. If verification fails, double-check the CNAME record is in place and pointing at the correct target — DNS propagation can take time, and any proxy layer (such as Cloudflare's orange-cloud proxy) will prevent the chain from resolving. ### Ongoing Monitoring Active tracking domains are automatically re-checked once every 24 hours to make sure the CNAME still resolves correctly. If the record changes or disappears: 1. **First failed check**: You'll receive a warning email. The domain stays active to give you time to fix the issue. 2. **Second consecutive failed check**: The domain is downgraded to **Pending** status and you'll receive a notification. Tracking links in already-delivered mail will stop working until the CNAME is restored and the domain is verified again. This two-strike policy prevents brief DNS issues from immediately disrupting tracking while still catching persistent problems. ## Tracking Domain Status Tracking domains have two possible statuses: | Status | Meaning | | ----------- | -------------------------------------------------------------------------------------------------- | | **Pending** | CNAME not yet verified. Tracking links cannot be served from this domain. | | **Active** | CNAME verified and the domain is serving tracking traffic. | ## Default Tracking Domain Each cluster can have at most one **default** tracking domain. The default is used to rewrite tracking URLs in any outbound message that doesn't specify a tracking host explicitly. To set the default: 1. Go to the tracking domain's detail page (the domain must be **Active**) 2. Click **Set as default** Setting a domain as default automatically clears the flag from any other tracking domain in the same cluster. To remove the default entirely (so messages fall back to the unMTA-owned hostname), open the current default and click **Unset as default** — it's fine to have no default configured. Only verified (active) tracking domains can be set as default. Pending domains can be added to your cluster but won't accept the default flag until they're verified. ## Managing Tracking Domains ### Viewing Tracking Domain Details Click any tracking domain in the list to view its detail page, which shows: - Current verification status and default flag - The CNAME record with copy button - A **Verify Now** button while pending - A **Set/Unset as default** button while active ### Deleting a Tracking Domain To delete a tracking domain: 1. Go to the tracking domain's detail page 2. Click the menu button and select **Delete tracking domain** 3. Confirm the deletion Deleting a tracking domain is immediate and irreversible. Tracking links in any already-delivered mail that pointed at this domain will stop working — recipients will see broken links if they click them. ## API Reference For programmatic tracking domain management, see the [Tracking API documentation](/docs/api#tracking-domains).