Skip to content
Cowliss is in beta: features and pricing may change.

Sending and delivery

Nothing sends until you verify a domain you own. Add it, install the DNS records Cowliss gives you, and the status follows on its own:

Terminal window
cow domains create --domain mail.example.com
cow domains list # find the dom_ id of the domain you just added
cow domains get dom_… # the records to install, each with its own status
cow domains verify dom_… # re-check now instead of waiting

The records cover signing, the bounce return path, a policy record, and one _cowliss-challenge TXT record that proves the domain is yours. Each name verifies on its own, and a domain that lapses stops sends rather than sending unsigned mail.

Send from a subdomain such as mail.example.com or notify.example.com, not from example.com itself. Your root domain carries the reputation of your team’s own mailboxes, and automation mail sent from it spends that reputation: a spam complaint about a campaign can push your colleagues’ everyday email into spam folders. A subdomain keeps the two apart. Cowliss still lets you add a root domain, but warns when you do and suggests the subdomain: the CLI prints the warning, and the API answers with a root_domain entry in meta.warnings.

How many sending domains an organization can hold depends on its plan. A domain holds its place from the moment you add it, verified or not, until you delete it.

An automation names its own address in its definition’s from:

{
"trigger": { "event": "invoice_paid" },
"purpose": "transactional",
"from": "Acme Billing <[email protected]>",
"steps": []
}

from is required and sits on a verified domain: the schema refuses an automation without one, and the push refuses an unverified domain. A single sendEmail step can name its own. Replies go to the from address; a send from your own code can set a replyTo that is any address.

A send is evaluated when it runs, not when the automation started, so someone who unsubscribes on day two of a five-day automation is respected on day two. The first gate that stops it decides the outcome, and a stopped send does not throw: it resolves with a skipped_* status carrying the reason.

The address is the recipient’s email trait as it stands when the send runs, else their newest email identifier, so an identify that changes traits.email moves every later send, including those of automations already running. A profile with neither is not mailed: if no gate stops the send first, it fails, and the delivery log says the profile has no email address.

Gate What it asks
Automation enabled Is this automation turned on?
Sending paused Is your organization’s sending paused?
Suppressed Is this address on the suppression list?
Credit Does the organization have credit left?
Frequency cap Has this person already had 5 emails they could leave in the last rolling day?
Consent Is the template’s purpose on for this person, and so is its root?
Sending domain Is the from address on a domain you have verified?
Spam check Does the rendered message read as spam?

The spam check comes last because it reads the message exactly as it would leave, with every prop filled in and every link in place, so a link passed in through a prop is checked too. It applies to every email, receipts included. A message that scores as spam is skipped_spam, and the delivery log keeps its score and the names of the rules that matched, so you can see what to change. Broadcasts are checked per recipient, and an API send is refused with a 422.

Consent reads the purpose the template declares, never the automation’s, and it reads both halves of the path:

Template purpose Sent when Unanswered means
transactional Always: consent and the frequency cap are skipped Nothing to answer
transactional.<name> The name and transactional are both on On
marketing.<name> The name and marketing are both on Off

A run of skipped_consent on marketing mail usually means the grant never arrived: pass consent on the identify call your signup already makes, or set it from the dashboard. A version pushed with an older CLI fails its send with “Pushed with an older CLI. Push again to update.” rather than being sent under a guess.

An address is suppressed when mail to it hard-bounces or the recipient marks a message as spam. Every later send to it is skipped before it costs anything. Fake and disposable domains, such as example.com or a throwaway mailbox service, are always treated as suppressed.

A complaint also revokes consent. On marketing mail it turns off marketing and every name your app declares under it. On a transactional.<name> email it turns off that name alone, and on bare transactional mail it changes nothing, since that mail cannot be left. A bounce does not: the address does not work, which says nothing about what the person wanted.

Terminal window
cow suppressions list --q [email protected]
cow suppressions delete sup_… # admin only

Removing an entry is an admin action, for an address you know was fixed. It is not a way around a complaint.

Every email whose purpose someone can leave, anything but bare transactional, carries the headers that put a one-click unsubscribe button in the recipient’s mail client, and the endpoint behind them revokes consent for the purpose the mail was sent under. That is the same flag the consent gate reads, and the same one you set from identify. Unless the template renders its own links, the body also carries a footer saying who it was sent to and why, with links to update email preferences or leave this purpose, and your app’s name, website and postal address. Leaving everything, from the preferences page, turns off both roots, and receipts keep arriving. The page after the click can undo it. See Unsubscribe links and Data protection.

Every attempt writes one row, whether it went out or not:

Status Meaning
sent Handed off to the mail provider.
delivered Accepted by the recipient’s server.
bounced Rejected. A hard bounce also suppresses the address.
complained Marked as spam. Outranks a bounce, and revokes consent.
failed Cowliss could not attempt it.
skipped_* A gate stopped it: skipped_disabled, skipped_paused, skipped_suppressed, skipped_quota, skipped_frequency_cap, skipped_consent, skipped_domain, skipped_spam, skipped_ssrf.
would_* A dry run. Nothing was sent.

A late delivered never overwrites a bounced, so a row’s final status is the truthful one. Webhooks share the log, where a 2xx from your endpoint is terminal at delivered.

Terminal window
cow deliveries list --status bounced --since 2026-09-01T00:00:00Z
cow deliveries list --automation abandoned-checkout --channel email
cow deliveries get dlv_…