Email marketing

How a store earns the right to send marketing email: the wall, the DNS record set, warm-up, reputation isolation and the automatic pause.

Written 2026-09-14. How a store earns the right to send marketing email, why every condition exists, and how a store that turns into a spammer is stopped before it can hurt another store or formahand.com.

Related: billing (plans and the wall shape), domains (the storefront records).

0. No platform sender, and the shared store address

Rule, 2026-09-18, unchanged: **Formahand never sends a store's email from formahand.com.** That domain carries Formahand's own account mail to merchants; a merchant's bounces and complaints must never touch its reputation, and Formahand's own sending address is not and will not be a From address for a store's buyers.

Amendment, 2026-09-22. The first version of that rule also meant a brand-new store on <slug>.store-on-formahand.app sent nothing at all until it bought and verified a domain — somebody paid and got no receipt. So there is a second domain, store-on-formahand.app: Formahand's, and used for **transactional mail only**. It is what shops.shopify.com is for Shopify. A store with no verified domain of its own sends as itself over it:

From:     "the example store" <no-reply@store-on-formahand.app>
Reply-To: owner@touche.example        (the store's support address — and nothing else)
Message-ID: <…@store-on-formahand.app>

Reply-To is the store's public support address or nothing. The account holder's address is their sign-in address: personal more often than not, and given to Formahand to log in with, never to be published to every buyer the store ever has. A store that has set no support address sends with no Reply-To at all, and the dashboard and describe_readiness ask for one (a recommendation, Settings → Store profile) instead of quietly substituting the merchant's own.

Since 2026-09-23 that same domain also carries the free storefront addresses, <slug>.store-on-formahand.app (domains "Two domains"). The two uses do not collide, and the reason they cannot is worth stating: the mail names — mail, _dmarc, dkim*, bounce, formahand-mail — are **reserved slugs**, so no merchant can ever be handed one; each carries its own DNS record, so the storefront wildcard never sees it; and a request for one of those names on that domain redirects to formahand.com instead of serving a store. What it does mean is that the sender's reputation and the storefronts' reputation are now the same registrable domain, which is one more reason this address is transactional only and campaigns still require the merchant's own verified domain.

Which address a message leaves from is decided in two steps. Your own verified sender first: a verified sending identity whose domain is neither of Formahand's own (formahand.com, store-on-formahand.app) nor a subdomain of either, and which your store still has live. That is kind: "own", and nothing else changes for it. Otherwise the shared store address, kind: "shared". Only when neither exists does the message go unsent, reported as { id: null, status: "skipped", sent: false, reason: "no_verified_sender" }.

Every message your store sends takes the same path, so they all behave identically: order, shipping and refund mail, payment instructions and payment requests, shopper sign-in codes, the newsletter double-opt-in confirmation, automation and staff mail, and the schedule pause notice.

Only Formahand's own templates travel on it. The order confirmation, the shipping update, the refund notice, payment instructions, the sign-in code and the newsletter double opt-in are transactional by construction: the platform wrote them, the recipient asked for them, and they carry no offer.

Two kinds of mail are refused on the shared address, and both refusals are made twice.

  • Campaigns. A campaign never uses the shared address; sending is refused without a verified own-domain sender with 409 and code: "own_domain_required"; and any message on the shared domain that carries List-Unsubscribe is refused again lower down, so no other path can put marketing on it either. The marketing wall (§1, condition 3) is untouched.
  • **A flow's own send_email.** Merchant-authored free text to a recipient the merchant chose — a win-back or a "we miss you" is marketing in substance whatever header it carries. A store on the shared address skips it with reason: "own_domain_required" and the flow's email-sender readiness item keeps asking for the merchant's own domain. Order and shipping mail is unaffected: that is Formahand's template, not the merchant's.

The rule in one line: **the shared address carries mail Formahand wrote, to somebody who asked for it. Everything else waits for a domain of the merchant's own.**

The kinds allowed on the shared address are a closed list, not a flag anyone can forget to set: order-confirmation, order-shipped, order-delivered, order-refunded, payment-instructions, payment-request and queued shopper mail (sign-in codes and newsletter confirmations). Anything else waits for your own domain.

One more thing follows from a shared reputation: **on the shared address the body of a transactional email is Formahand's, not the merchant's.** A store may edit its transactional templates as always, and the edited subject and From name are used — they are short, carry no links or images, and the message only ever fires on something the buyer did. The body is set aside and the shipped wording is sent, because a rewritten "order confirmation" is exactly how a sale announcement reaches everybody who ever bought, and one store doing that costs every other store on the address its delivery. The merchant's edit is not lost: it is used in full the moment they verify a domain of their own, and the order timeline says which wording went out (TEMPLATE_BODY_DEFAULTED). Sign-in and newsletter mail is the exception — your store renders it before queueing, and it goes to one address that asked for it seconds earlier, so it cannot be fanned out to a list at all.

A shared From address does not mean shared consequences. Every store on the shared address keeps its own bounce rate, complaint rate, meters, ceilings and automatic pause, and a complaint on the shared address pauses that one store. A message is attributed by store id, never by the From domain, which is shared, and never by the store's own sender-domain record either: a stale record must not send a store's mail somewhere else. The store id and the store's From name travel with a retry, so an hour-later retry still knows whose message it is and still shows the buyer the same sender.

On top of each store's own allowance the shared address has two ceilings of its own, because that reputation is shared: **2,000 messages a day across every store**, and 200 a day for any one store, so one store's automations cannot empty the day and leave every other store's buyers without a receipt. Your store's own daily cap is checked first, so what binds is the smaller of the two.

Running out of either is deferred, not failed. The refusal asks to be tried again once the ceiling resets at UTC midnight, and the retry queue holds the message without spending one of its attempts — otherwise "this goes out tomorrow" would quietly become a permanently failed order confirmation a day later, which is the opposite of what the merchant was told. A message held this way is still bounded: after a week it fails, saying plainly that it waited for capacity and did not go.

A held message is reported as queued, never as sent, and never as failed: sent: false with reason: "deferred" and emailQueued: true. Every merchant-facing surface then says the same sentence — *"Queued — this goes out when capacity frees on Formahand's shared store address, within a day…"* — on the order timeline, the Orders screen, the label panel, the create-order panel, Sending settings (sending.overCapacity) and get_sender. A message that is "queued" on one screen and "not sent" on another is a merchant deciding twice whether to chase a customer.

Two consequences worth naming. A failed order confirmation is raised so it is logged, but a queued one is not: the send is claimed before the message leaves, so nothing re-sends it and the queue owns it from then on. And an ordinary retry — a hiccup that clears within the hour — still counts as sent, because nothing about it is the merchant's to act on.

There is deliberately no priority slice inside those ceilings: everything allowed on the shared address is already order, payment or sign-in mail, so there is no lower-priority class to hold back. If one is ever allowed on, this is the decision to revisit.

Formahand's own mail to an account holder — the waitlist welcome, monthly statements, the store deletion notice, a finished data export, a delivery-budget alert, an agent-governance alert and billing notices — travels on a separate path and is never reachable from a store's own mail.

Consequences: transactional_email's two requirements (a live custom domain, a verified sender on it) are recommended, not required, and so is storefront_open's email item — a shop that can confirm an order may open, and one on the shared address can. Campaigns still require both. The Home checklist still asks every store to send from its own domain, and a new store's sender address is still empty, shown as Not set; what changed is that nothing waits on it.

1. The wall

POST /api/campaigns (session, or a token with marketing:send) sends only when all of these hold; GET /api/campaigns reports the same checklist as wall, steps, warnings, caps, plus campaigns, the environment's stored campaigns, newest first.

#ConditionWhyStep
1The plan includes marketing (Growth or Pro)Marketing is the paid product; free stores send order and shipping emails onlyplan → Plan & billing / start_upgrade
2At least one custom domain with status activeMarketing must come from an identity the merchant owns, never from *.store-on-formahand.appdomain → Online Store → Domains / add_domain
3The sender's domain is that custom domain or a subdomain of it (mail.example.com for example.com; the apex for a www storefront) and its status is verified (every record verified: both signing keys, the bounce host, SPF and DMARC). PUT /api/sender refuses any other address at 409, so a store can never claim a domain it has not connected. The platform address (@formahand.com) never qualifiesEach store sends from its own sending domain, so its DKIM and its reputation are its ownsender → Email → Sending settings (session only; the merchant pastes the records or hands them to an agent with describe_domain_setup { includeEmail: true })
4The current terms are accepted (the store's recorded acceptance matches the current TERMS_VERSION)The terms carry the anti-spam and consent rules the merchant is bound byconsent → the dashboard interstitial (#consent)
5No reputation pause recorded or live (§4)Protects recipients, the other stores and the platformreview → mailto support
6Inside this month's allowance and today's warm-up cap (§4)Volume grows with a track recordwait, or upgrade for the monthly cap

The refusal is a wall body (billing §4): 402 for reason: "plan" (conditions 1, and 6 when the next plan lifts the monthly cap), 409 for setup (2–4), paused (5) and limit (6). Steps carry done, so the dashboard shows ticks and an agent knows what is left. DMARC missing is a warning, not a step.

Transactional mail is walled by §0 rather than by this checklist: order and shipping confirmations go out from the store's verified sender when it has one, and from the shared store address as the store when it has none, bounded by the template flow limits (automations §6). Marketing adds the plan, the terms, the caps and the pause on top — and, unlike transactional mail, it never travels on the shared address.

A store that opened before the terms were versioned has nothing recorded, and a version bump makes an older acceptance stale. Nothing is backfilled: the dashboard shows a one-time Accept the terms interstitial when the session snapshot says consentCurrent is false; accepting records the current versions and the merchant's own marketing opt-in (unchecked by default) through POST /api/consent (session only). The marketing wall reads the same record. Nobody accepts on a merchant's behalf.

Shopper consent is double opt-in. POST /api/newsletter (the theme form and a headless front end's JSON) is anonymous and open to any origin, so all it can record is that an address asked: the contact is written as pending, one confirmation link goes out from the store's own verified sender, and only following that link sets subscribed. Campaigns reach subscribed contacts only, so nothing a stranger posts into the form can ever be mailed, and a sign-up can never pull back an address that unsubscribed. The link is a random token kept as a hash, good for 72 hours and spent on first use. A store with no verified sender queues nothing and the contact simply stays pending.

Sign-ups are rate limited: at most 3 an hour for one address, 10 for one visitor and 120 for one store. Over any of those the answer is 429 and nothing is written. The checkout marketing tick is unchanged: that is the buyer agreeing in person, on an order they placed.

3. The DNS record set

Every mail record a merchant sets points at Formahand's own domain, never at whoever delivers the mail. The merchant sets this DNS once; a change of delivery service is an edit Formahand makes in its own zone and never reaches them. The storefront needs one CNAME per hostname, and no mail record shares a name with it. For a store on example.com with the sender hello@example.com (<store> below is that store's own handle):

PurposeTypeNameValueProxy
StorefrontCNAMEexample.com (flattened at the DNS host)customers.formahand.comproxied
StorefrontCNAMEwww.example.comcustomers.formahand.comproxied
DKIMCNAMEformahand1._domainkey.example.com<store>.dkim.formahand.comDNS only
DKIM (rotation spare)CNAMEformahand2._domainkey.example.com<store>.dkim2.formahand.comDNS only
Bounces / return path (required)CNAMEformahand-mail.example.com<store>.bounce.formahand.comDNS only
SPFTXTexample.comthe domain's own SPF with include:spf.formahand.com added—
DMARC (required)TXT_dmarc.example.comthe domain's own policy if it has one, else v=DMARC1; p=none; rua=mailto:dmarc@example.com—

The five mail rows above are the whole set a merchant is asked for, and every one of them is required: the domain is verified only when both signing keys, the bounce host, the SPF include and the DMARC policy all resolve. The bounce host is where a rejected message comes back to, so without it the delivery outcomes a store's reputation is judged on never arrive; it is not optional and never was a nicety. A store set up earlier may still carry one extra CNAME on its domain, a name that is not in the table above: it is unused now, nothing checks it, and the merchant can delete it whenever they like. It is still labelled (authorization) so it renders as what it is until they do.

The DKIM key behind <store>.dkim.formahand.com is Formahand's own, generated per store, so the key the merchant publishes survives a change of delivery service too. The names and values a merchant is shown are the ones their own sender resolved to; placeholders marked expected: true are shown only while no sender has been saved yet. At a flattening DNS host, a merchant with the apex as storefront keeps the flattened CNAME at the apex; the mail names are separate, and the apex only carries the SPF TXT, which coexists with a flattened CNAME.

Which addresses may be saved. PUT /api/sender accepts an address only on one of this store's own live custom domains, or on a subdomain of one (the same rule the marketing wall applies to a verified sender; a www.<apex> hostname counts for its apex). The free *.store-on-formahand.app address, a domain that is connected but not live yet, and any domain belonging to someone else are refused with 409 and a message naming the step that is left. The sending identity is claimed by domain, so without this one store could overwrite another store's identity, or squat a domain it does not own so the real owner is refused later. A domain already set up for another store is refused with 409 too, with that message passed straight through (a failure in Formahand's own plumbing stays a 502).

describe_domain_setup { hostname, includeEmail: true } (and GET /api/domains?describe=<host>&email=1) returns email: { domain, senderEmail, status, records[], coexistence[], notes[] } next to the storefront records, with purpose per record (dkim, return-path, spf, dmarc, plus authorization for the extra record an older store still has) and a status per record (verified / pending / not_started; DMARC is recommended). An agent creates them with the merchant's own DNS login, exactly like the CNAME (domains); the sender itself is saved by the merchant under Email → Sending settings (session only), and Verify DNS there (or POST /api/sender) checks the delegation by reading those names.

4. Reputation isolation, warm-up and pause

Isolation. Every store's mail leaves from its own verified domain, i.e. its own sending domain with its own DKIM selector. Bounces and complaints land on the merchant's domain reputation, not on formahand.com, whose only mail is Formahand's own account mail (§0). The free address can never be a store sender at all: it is refused when the sender is saved, refused again when a message is about to leave, and refused a third time by the wall.

Caps:

  • Monthly: the plan's campaignEmailsPerMonth (Growth 5,000, Pro 50,000) per UTC calendar month. Over it → 402 with requiredPlan: pro on Growth, 409 limit on Pro.
  • Warm-up: day 1 of a store's sending life allows 500, doubling every day (cap(day) = min(500 × 2^day, monthlyCap)), so 500 / 1,000 / 2,000 / 4,000 / 5,000 on Growth. The clock starts on the store's first send and keeps counting across months. Over today's cap → 409 limit with usage: { used, limit, period: <day> }.
  • A campaign is checked with its real recipient count, so it is refused rather than truncated; the counters add the recipients that actually left (recipients minus suppressed) once the send is accepted.

Pause. Formahand samples the newest 1,000 campaign deliveries for a store — { sent, delivered, failed, suppressed, pending, bounced, complained, source, since } — and pauses sending when, over at least 50 sends, complained / sent > 0.1% or bounced / sent > 5%. The pause is recorded against the store, so it holds even if the sample cannot be refreshed later; the wall (reason: "paused", 409) lists the last sample and a Request a review action in the dashboard, and Formahand clears it after that review.

4a. Your store's own sending reputation

Since 2026-09-19 each store's mail is delivered under **its own sending identity**, created when the store first saves its sender, so a store's bounces, complaints, suppressions and quota are its own and one store cannot degrade another store's mail. A store whose sender was saved before this change keeps working until it saves its sender again, which moves it across.

  • Records for a moved store's domain are the ones in §3: the merchant sets CNAMEs on formahand1._domainkey, formahand2._domainkey and formahand-mail, plus include:spf.formahand.com in their own SPF record (a domain has exactly one; Sending settings says to edit it, not add one), and a DMARC policy, which is required (2026-09-20): a domain with no DMARC record is not verified. Any valid policy counts, so a merchant's own stricter one is left alone, and a domain with none starts at p=none. Every name in that set is Formahand's own, so nothing a merchant adds names a mail vendor, and a change of delivery service is an edit Formahand makes rather than a message to every store. Stores set up before 2026-09-21 were also asked for one extra CNAME; nothing needs it any more, and it can be deleted.
  • Pause is decided from real delivery events: over a store's newest 1,000 outcomes, more than 5% hard bounces or 0.1% complaints (at least 100 outcomes, or 3 complaints) suspends that store's sending only. The reputation sample reports those events (source: "provider_events"), so the §4 pause sees complaints too.
  • Meter and limits: once a day Formahand refreshes the store's monthly counts (transactional_emails_sent, marketing_emails_sent, email_bounces, email_complaints) and applies the limits of its email tier (billing §1a: the included amount, or ten times it with a card, and the tier's daily cap). Your remaining allowance is checked before a send, so a campaign can never overshoot it, and campaigns may use only part of each day, so order email is never crowded out. The statement bills the tier and everything the store sent from its own domain past the included amount; the campaign wall (§4) counts order email already sent against the same allowance.
  • No fallback anywhere: a store without its own verified sender is refused (409) before anything is sent.

4a-bis. Letting a domain go

A sending address is a claim on a domain, so it has to be given back as carefully as it is taken:

  • The store loses the domain. Removing a custom domain releases the sender that rode on it — but only when no other live domain of the store still carries the address, so a store with plutus.sh and mail.plutus.sh keeps sending after one of them goes. The identity is marked failed, the claim is released, and the records Formahand published for it are taken back out.
  • The address is never proved. An unverified claim expires after 7 days. This matters because of the www rule: a store whose only live domain is www.example.com may save @example.com, and without an expiry that claim would hold the apex against its real owner for ever. The merchant simply saves the address again.
  • Every message asks anyway. Before any message leaves, Formahand re-checks that the sender's domain is still one of the store's active domains, exactly as the marketing wall does for a campaign. A release that fails therefore cannot leave a store sending from a name it no longer holds; the worst case is a claim that has to be cleaned up by hand.

Limits follow the same "now, not later" rule: a store's ceilings are applied the moment its sender is first saved and the moment a card is added, as well as at the daily refresh.

4b. Agents

Campaigns are agent work under the same wall (agents). A token with marketing:send calls create_campaign to write a draft (subject, HTML or Markdown body, an audience of subscribed contacts optionally narrowed to one tag), preview_campaign to see the audience, the recipients of the next send and the wall as it stands, and send_campaign to send — which takes the same path POST /api/campaigns takes, so the plan, the domain, the verified sender, the terms, the caps, the warm-up ramp and the pause are evaluated once and identically for a session and a token; a refusal is the same 402/409 wall body with steps. Reading the audience (GET /api/campaigns, list_customers) needs customers:read. An agent can never subscribe a contact, accept the terms, send past the caps or reach more than 50 addresses per call, and there is no scheduler yet: scheduleAt is refused rather than quietly ignored. Setting sending up is agent work since 2026-09-22 (§5 "Agents set sending up"): set_sender and verify_sender need settings:write and meet the same domain-ownership rule the dashboard meets. Every send is recorded in the store's event log under the actor token:<id>, so the merchant can audit it in Settings → Agents & API.

4c. Segments as audiences

A campaign's audience can be a segment as well as a tag: create_campaign { audience: { segmentId } }, or POST /api/campaigns { audience: { segmentId | tag | email } }. A segment is a saved filter over the contact list (agents) — the ones you save on your store, plus the four every store has (Repeat customers, Bought once 60+ days ago, Subscribed never ordered, Top spenders) — so the audience is recomputed at preview and at send, never stored with the draft. Consent is applied after the narrowing, in the same filter every other audience goes through: a segment can only ever reach its subscribed members, and an audience of one address ("Email this customer" on the customer page) reaches nobody when that shopper has not subscribed. The wall, the caps, the warm-up ramp and the 50-recipient limit are unchanged: a segment narrows who, never what is allowed.

5. Dashboard

  • Customers: the list carries segment pills (the built-in four, then the saved ones), Save as segment for the filter on screen, and a customer page with the timeline, notes, addresses, tags, group and Email this customer — which opens a composer when the wall allows and shows the wall itself when it does not.
  • Campaigns (the Email module): the wall renders under the sending-health strip when any condition fails (steps with ticks, Open links, See plans); the composer shows it too and Send stays disabled. Once every condition holds the wall disappears and sending works as before (50 recipients per call).
  • Settings → Plan & billing: this month's usage, today's cap and warm-up day, the pause state.
  • Email → Sending settings: a Sender card with "From name · …", "From address · … / Not set" and "Email domain · Needs setup | Verified ›", which opens the Email domain part (#settings/email/domain, with "← Sending settings") — one status line ("Managed by <provider>" when the DNS provider is known), then three numbered steps (connect the domain → choose the address → add the records; the third counts the records from the data, never from a number in the copy), the record table with copy buttons and Verify. A Notifications card carries "Customer emails · Order, shipping, sign-in ›" (to Automations → Email templates, #automations/templates), "Staff emails · you + N ›" and the Daily summary switch.
  • Campaigns: when the wall is unmet the screen shows only the gate card, with nothing behind it — "Send email as your store", the wall steps with a single Fix this button on the next one, the footnote "Until then, no email leaves your store." and a Later link that hides it for the browser session (the server refuses the send either way). Once past it, a "Sending" card ("Sender · <address or Not set> · <badge>", "Audience · N") and the campaigns list; New campaign opens the composer at #email/new, and Send asks in a dialog ("Send this campaign?").

Agents set sending up. A token with settings:write does the whole thing itself, on its own store, through the same functions /api/sender calls — so there is one ownership rule, not two:

ToolScopeDoes
get_sendersettings:read{ sender: { fromName, email, domain, status, dnsRecords } | null, verified, pendingRecords } — the Sending settings view
set_sendersettings:write{ email, fromName }: saves the address (refused unless it is on one of this store's live domains, and never a formahand.com address), publishes what the records resolve to, and hands back the records to create
verify_sendersettings:writeRe-checks the published records; answers verified and what is still pendingRecords
send_test_emailsettings:write{ to? }: one real message from the verified sender, through the transactional path order confirmations take. to may only be the owner's or a team member's address (default: the owner); refused while the sender is not verified; at most 5 per store per UTC day; it counts against the store's monthly allowance like any other message

All four are live-store tools (fh_test_ tokens are refused: the sender belongs with the domains). In propose mode the three writes become proposals like every other write. The merchant keeps the dashboard's Verify button; neither door is privileged over the other. The Or let your agent add them buttons in Sending settings tell the agent to save the address, create the records, verify, then send the test message, and to fall back to asking the merchant to press Verify when its token has no settings:write.

Domain Connect and the sending records. The storefront CNAME can be added in one click (domains); the sending records cannot. The published template is providers/formahand.com/services/storefront, it carries that CNAME only and has no variables, and every store's DKIM key is its own — so a one-click apply would need a second, variable-carrying template published and adopted by the providers. Until that exists, Sending settings shows the record table and the merchant (or their agent, through describe_domain_setup { includeEmail: true }) adds the records. The UI already reads email.domainConnectUrl from GET /api/domains?describe=…&email=1 and shows Set up automatically the day that field appears.