The engine is built and running on our own fleets. Supply was the missing layer. This is the bird's-eye read of what Zapmail's API actually gives us against every feature we intend to ship, grounded in the retired-exclusion branch, the approved doctrine, and the CMR docs, with the questions that still need their engineers named one by one.
The health engine, checkpoints, hospital, coroner and graveyard already run against real fleets. Everything Mail Legion sells is that intelligence; Zapmail is provisioning, domains, DNS, subscriptions and credential export underneath it. The API is deep enough for all of that. Warmup placement comes from Bison, not Zapmail, because the warming pools live there.
What needs attention is not the API. It is the 12-month price step-up to $5/mailbox/month, which puts a $3.50 to $5 managed price underwater on year-old subscriptions unless the engine runs their Recreation API reset, and the absence of Azure, which is 400 of the 624 icemail mailboxes on Pod 1 today. Both are decisions, not blockers.
retired-exclusionRead this branch, not main. It is 57 commits ahead across 76 files (+10.8k lines) and carries all seven shipped doctrine components. Test suite at last sign-off: 1,110 passing (uv run pytest, 12 to 15 minutes, needs Docker).
Worst signal wins. One bad night demotes, three clean nights promote. Graduation is 90+ on the 7-day rolling warmup score; age alone never raises volume.
| # | Component | Status | Evidence |
|---|---|---|---|
| 1 | Six states + nightly tag engine | shipped | tests/state/test_inbox_states.py, 10 behaviours. Bison tags written with emoji prefix. |
| 2 | Warmup telemetry (rolling 3d/7d + trend) | shipped | 25 behaviours. Reads warmup/sender-emails in Bison; lifetime score never used for a gate. |
| 3 | School checkpoints (day 7 / 14 / 21) | shipped | 10 behaviours. Day 14 under 80 names the successor. |
| 4 | Ramp interlock | shipped | 37 behaviours. Fixed the live bug where score-17 inboxes ramped to 8/day by age. |
| 5 | Hospital | shipped | 6 behaviours. Recorded stays, 30-day cap, escalation at cap. |
| 6 | Peer reply sensor | not built | Spec rewritten 31 Aug; unblocked by the azure calibration (25 inboxes/domain × 4 sends/day flat). |
| 7 | Placement testing (EmailGuard Pro) | not built | Bison send slice approved 31 Aug with rails: one-off compose only, seed-list recipients, daily cap, dry-run default. |
| 8 | renews_at + the bills | merged 2 Sep | Spec 2026-09-01-renews-at.md. icemail endpoint was reachable all along (workspace-sticky key, 30 req/min, urllib fingerprint-blocked). 613 of 2,692 live inboxes covered, $489/mo, all renewing September. Billing shape is provider-neutral (renewal_source). |
| 9a | Coroner + death certificate | shipped | 23 behaviours. Pronounced only on evidence, never idleness. Retired is one-way. |
| 9b | Estate settlement (renewal-eve cancel) | not built | Spends money; waits on component 8 (now in) and a provider cancel verb. This is where the Zapmail cancel semantics land. |
| 10 | Vocabulary | half | Renaming stored event types is unsafe on an append-only ledger; new events use the locked verbs. |
| 11 | Graveyard | shipped | 8 behaviours. Purge Q1 answered by live experiment 31 Aug: deleting a Bison sender does not alter campaign aggregates. Q2 (unibox threads) still open, so purge stays manual for senders with reply history. |
| + | Inbox signals (v34), provisioned_at, retired exclusion, azure-fast day 21 | shipped | Composed health verdict persisted per inbox so the read-only dashboard never holds a vendor key. |
| + | Buy execution | preview-only | Registrar adapters (Porkbun, Dynadot, Spaceship) and the claimed / indeterminate ledger exist; live purchase stays gated by policy per Charles. |
| Adapter | Surface | Under Zapmail |
|---|---|---|
| bison.py | Read-only BisonReader (inboxes, warmup rows, campaign stats, unsubscribes) + BisonWriter with approved verbs only (daily limit, warmup limit, signature, pause campaign, remove leads, tags) | unchanged Warmup placement and send metrics stay here |
| icemail.py | forwarding get/set, create_order, prewarm list/buy, list_mailboxes (billing rows), domain lookup | replaced by zapmail.py Same surface, mapped below |
| cloudflare.py | Redirect worker + KV, zones, nameservers, DNS CRUD, Pages custom domains, scoped token minting | unchanged Landing Factory zone owner |
| landing_factory.py | Subprocess wrapper: status, build, verify, onboard, generate copy, deploy | unchanged |
| emailguard.py | Blocklist checks (10/day free cap, Pro needed for a sweep), placement tests pending the send slice | unchanged |
| porkbun / dynadot / spaceship | Availability, register, expiry, auto-renew off | kept for stealth buys Zapmail can also register, see below |
DESIGN.md §4.1 lists zapmail as a value for domains.inbox_provider, and §4.5 lists it under vendor_links.vendor. The billing columns landed in v43 are provider-neutral by design (R14 in the renews_at spec). Adding Zapmail is a new adapter and a new renewal_source value, not a migration.
| Platform | |
|---|---|
| Company | Outbox Labs. Product: Zapmail. White-label brand: Cold Mail Reseller (CMR). |
| Base URL | https://customers.coldmailreseller.com/api/v1/integration |
| Auth | One partner key, header cmr-x-api-key. Every resource call carries userId. Server-side only. |
| Tenancy | A User is an end customer: owns domains, mailboxes, subscriptions, orders, billing. Full isolation by userId. Delete requires 0 domains and 0 mailboxes. |
| Providers | workspaceType GOOGLE or MICROSOFT (M365). No Azure (confirmed on call). |
| Rate limit | 5 req/s per partner key, global. 429 with Retry-After. Webhooks don't count. (icemail was 30/min.) |
| Async model | Orders return an actionId; results arrive by webhook. Docs: never retry while waiting, duplicates charge twice. |
| Sandbox | Separate base URL and key, cmr-x-sandbox-scenario header simulates 10 outcomes, webhooks fire to sandbox-only destinations, $0 charges. |
| Integrated in | ~30 platforms (Smartlead, Clay, Woodpecker, Reply). Battle-tested at scale. |
| Commercial & ops | |
|---|---|
| Wallet | Prepaid. Charged per order and per renewal. Must stay topped up; notifications available. |
| Mailbox price | Tiered by volume, shared in the call chat. Fill in from Kaustubh's message. Steps to $5/mailbox/mo when a domain passes 12 months (see landmines). |
| Domains | .com ~$12, other TLDs $4 to $15, drops announced 15 days ahead (.co at $5 now). Auto-renew off by default. 45-day renewal window, 7-day grace. |
| Admin dashboard | Every order, renewal, domain, mailbox, export, OAuth callback and webhook visible. Our support can act inside a user's account (DNS, forwarding, exports). |
| Support | Slack Connect #leadgrow-zapmail. 12 to 24h turnaround, IST hours (covers ET 9 to 5, not PT afternoons). No paid support tier; we are front line. |
| Custom work | Offered. They quote timelines up front (days to 2 weeks). |
| Contacts | Kaustubh Wadhwa (partnerships); founder/CEO; engineers Mitrajit and Goku. |
| Webhooks | HMAC-SHA256 (cmr-x-signature, cmr-x-timestamp, 5-min replay window). Retries: immediate, +30s, +5min, then marked failed (manual retry in dashboard). eventId for idempotency. |
| Feature | Zapmail primitive | Status | How it works under Zapmail |
|---|---|---|---|
| Workspace frameworkone workspace per end client | POST /users · every call scoped by userId | covered | Mail Legion workspace ⇆ one Zapmail user. Their per-user model is the exact "no sticky current-workspace context" shape the doc demands, and the thing that burned us with icemail's workspace-bound keys. Our side: pod-per-customer with customer-owned Bison + Cloudflare creds (pods.bison_key_env, landing_pages.cf_account_key already exist). This is the v1 build. |
| Inbox rotationsix states, night rounds | Provisioning status via mailbox.* and subscription.* events; GET /mailboxes | ours | Sensors split three ways: Bison = warmup placement (rolling 7d) + sends/bounces/replies; we compute blocklist (correct resolvers), DNS auth, landing probe; Zapmail = provisioning and renewal state only. Nothing in the tag engine changes. |
| Auto-reserve / auto-replenishthe 50% → 10% machine | POST /subscriptions/toggle-autorenewal + natural expiry · periodEnd on every subscription event · expiryDate on mailbox.created | confirm | Successor named at day 14 from aged reserve (component 12, inventory draw, never a buy). The dying box is cut by turning auto-renew OFF and letting it expire at periodEnd. Do not call POST /subscriptions/cancel: it is immediate, permanent, no proration. icemail had a native scheduled delete; Zapmail does not, so renewal-eve execution is ours (component 9b). Confirm nothing sends after expiry and that expired mailboxes are gone from billing. |
| Stealth domainsdrip buying, screened first | POST /domains/available · order via POST /orders/json · auto-renew off default · PATCH /domains/toggle-auto-renew | covered | Our buy plan, caps and pre-purchase DBL/SURBL screen stay as built. Zapmail becomes a fourth registrar lane beside Porkbun/Dynadot/Spaceship, useful for their announced drops. Lapse-by-default already matches doctrine. |
| Landing Factoryreal page per domain, BYO Cloudflare | PUT /dns/nameservers · POST/PUT/DELETE /dns · POST /domains/add-forwarding (forwardTo: null removes) | confirm | A domain sits on exactly one nameserver set. Our pages need it on the customer's Cloudflare; Zapmail needs mail auth on it. Two workable shapes: (a) flip NS to Cloudflare and have Zapmail hand us SPF/DKIM/MX values to write via our existing create_dns_record; (b) keep NS on Zapmail and point only the A record at Pages. Q1 below decides it. Bonus: their forwarding can be turned off per domain, which icemail cannot. |
| Aged domains marketplaceaged with receipts | Domain order + DNS + our ledger | ours | Provenance is our append-only events + checks tables. Zapmail only registers. Unsold stock routes into our own lanes. |
| Pre-warmed inboxesgraduates only reach the shelf | GET /pre-warmup · POST /pre-warmup/order (returns warmupDaysCompleted) | covered | Day 1 shelf can be backed by their stock (Google/Microsoft filter, transfers into the user's account). Our own pipeline: provision via Zapmail, attach to a Bison warming pool, graduate at 90+ rolling. Zapmail's own warmup API is not used; confirm it isn't billed by default (Q9). |
| Credential export to Bison | POST /exports/platform-credential (EMAILBISON needs appBaseUrl, clientId, appName, workspace) · POST /exports/platform-exports · mailbox.exported | covered | Credentials validated live at store time, then referenced by credentialId. One credential row per customer Bison workspace. Async; listen for mailbox.exported. |
| Billing awareness (renews_at) | expiryDate at mailbox.created · periodEnd on all subscription events · price on renewal.success | covered | Better than icemail: the date arrives at provision time instead of by backfill, closing the "capture at provision" gap the spec had to withdraw. Maps straight onto inboxes.renews_at / renewal_cost / renewal_cycle with renewal_source = 'zapmail'. |
| Orders queue with ETAs | actionId + domain.order.* / mailbox.order.* / mailbox.created | confirm | Status is event-driven and complete; an ETA is not exposed. Kaustubh said 24h turnaround. Ask if a status/ETA read exists (Q4). |
| Placement tests | Said to be on the API; not in the docs nav | confirm | Doctrine chose EmailGuard Pro as the lab (component 7). If Zapmail exposes one, it's a second arm, not a replacement. |
| Deliverability tier / benchmarks | none | ours | A reporting view over checks and inbox_daily_stats. Zero supplier dependency. |
| MCP layer | none | ours | Over our platform API, through the same dry-run and approval gates as the CLI. Zapmail never in the picture. |
| Azure laneazure-aged, 25×4 flat cap | none | gap | Not provisionable. See landmines for the decision. |
The icemail adapter is the template. Same read/write split, same envelope validation, same secret strip at the boundary. Each row is one existing method and its Zapmail equivalent.
| icemail.py today | Zapmail equivalent | Notes |
|---|---|---|
| create_order(data)POST /order → order id | POST /orders/json?userId= → actionId body: domains[], mailboxesPerDomain, billingCycle, firstName, lastName | Provider field not shown in quick-start (Q3). Results by webhook: domain.order.success then one mailbox.created per box. Never retry on timeout: record INDETERMINATE like buy_execute does. |
| list_mailboxes()billing rows, drops password | GET /mailboxes?userId= + GET /subscriptions?userId= admin creds: GET /mailboxes/admin-mailbox-details | Keep R3/R4/R11 verbatim: validate envelope, strip secrets, taint-test every sink. Pace under 5 rps. |
| set_forwarding(id, url)PUT /domain/{id}/forwarding | POST /domains/add-forwarding {domains[], forwardTo} | forwardTo: null removes. icemail could not express "no redirect"; this can. Async, 200 = accepted. |
| get_forwarding(id) | Domain read (Q: field name) | Verify what the domain object returns for forwarding. |
| lookup_domains(names)POST /lookup/domains | GET /domains?userId= (list) · POST /domains/has-workspace | Identity is domainId in vendor_links (vendor zapmail), same rule as R1. |
| list_prewarm() / buy_prewarm(ids) | GET /pre-warmup?serviceProvider= · POST /pre-warmup/order?userId= | Microsoft needs microsoftOauthRoute; Google export needs googleOauthRoute, clientId, appName. |
| (none: icemail managed DNS itself) | GET/POST/PUT/DELETE /dns {host, value, recordType} · PUT /dns/nameservers | New capability. excludedDMARCtags only valid on the _dmarc TXT. |
| (none: scheduled DELETE /domain/mailboxes) | POST /subscriptions/toggle-autorenewal then expiry · POST /subscriptions/cancel (immediate only) | The one behavioural difference that matters. Renewal-eve is ours to schedule off renews_at. |
| (none) | PATCH /domains/toggle-auto-renew | Default already off. Matches lapse-by-default. |
| (none) | Exports: /exports/platform-credential, /exports/platform-exports, OAuth: /mailboxes/oauth, /domains/add-client-id | Bison is EMAILBISON with the metadata block. Store one credentialId per customer workspace. |
| (none: we poll) | Webhooks, 15 event types | We have no inbound surface today (dashboard is read-only, CLI is cron). See build order for the poll-first recommendation. |
mailboxDetails includes password, appPassword (Google) and secret. Whatever receives that webhook is a secret sink and inherits the renews_at spec's R11: strip at the boundary, never log a row, taint-test every JSON sink. Print sorted(keys), never a row.
Ordered by how much they gate. The first two decide Charles's provisioning-to-Landing-Factory test.
"We host a unique landing page per sending domain on the customer's Cloudflare, so we want the domain on Cloudflare nameservers. Can you still set the mail records in that case, or will you hand us the SPF, DKIM and MX values so we write them into the Cloudflare zone ourselves? What exactly does PUT /dns/nameservers do: change delegation at the registrar, or only what your DNS layer answers with?"
If they require their own NS, Landing Factory falls back to an A record on their zone (weaker: no Pages custom domain, TLS from them). If they hand us records, our cloudflare.create_dns_record path already exists.
"Can we attach a domain the customer already owns at another registrar and order mailboxes on it, with you configuring mail records only? Is there an import endpoint, or is registration-through-you the only path?"
Not documented. Agencies arrive with existing domains; icemail supported import_existing.
"The quick-start order body has no provider field. Where do we choose GOOGLE vs MICROSOFT per order, and can one order mix them across domains?"
"Is there a read endpoint for an actionId (status, ETA, partial results), or are webhooks the only signal? What is the typical and worst-case time from order to mailbox.created for Google and for Microsoft?"
Our Orders page needs a live status; the docs only show push.
"We never want to pay a second month on an inbox we've decided to retire. Today that's toggle-autorenewal off and let it expire. Two checks: after expiry, is the mailbox fully stopped and off our wallet the same day? And would you add a cancelAtPeriodEnd flag, or a scheduled cancel endpoint, so it's one call?"
Nice-to-have; component 9b can schedule off renews_at without it.
"Your docs say mailbox subscriptions move to $5/mailbox/month when a domain passes 12 months. Does that apply to partner wholesale tiers? And confirm the Recreation API: cancel and re-provision on the same domain resets the clock with mailboxes and OAuth preserved, no downtime, no re-warm?"
This one is for Kaustubh as much as the engineers. It changes the managed-inbox margin on any account older than a year.
"Kaustubh mentioned placement tests are available by API but they're not in the docs. Which endpoint, what does a result look like, and what does one test cost?"
"mailbox.created ships the password and app password in the payload. Can we opt out and fetch credentials on demand from admin-mailbox-details instead, so the webhook receiver never holds secrets?"
"We run warmup in EmailBison's pools. Can mailboxes be provisioned with your warmup disabled, and is your warmup a separate line item we can leave off entirely?"
"Is there a low-balance webhook or a balance read endpoint, and can we set auto top-up? What happens to a renewal if the wallet is short: past_due then expired, on what timeline?"
"For the EMAILBISON platform credential, what exactly are clientId and workspace? Our customers each run their own Bison workspace; confirm one credential per workspace and that exports honour Bison's per-workspace API keys."
"Any plans for Azure-tenant Outlook mailboxes? If not, would you object to us running Azure through another supplier alongside you?"
actionId with ETA (Q4).renews_at (component 9b)."When a domain completes one year and renews at the 12-month mark, the mailbox subscription price automatically increases to $5 per mailbox per month," applied to all mailboxes on that domain, with a single subscription.updated event as notice. At a $3.50 to $5 managed price that is zero or negative margin on year-old accounts. Options: (a) price the step in, (b) have the replenish engine run the Recreation API at month 11 (cancel + re-provision same domain, mailboxes and OAuth preserved, clock resets), (c) negotiate a partner-tier exemption. (b) is a natural fit for a system that already knows every renewal date; (c) is the ask. Decide before publishing prices.
Zapmail provisions Google and M365 only. Today Pod 1's icemail workspace is 208 Google / 16 Microsoft / 400 Azure, and the doctrine carries an azure-aged lane (90-day hold, 25 inboxes/domain × 4 sends/day). Two honest paths: keep Azure on icemail/Hypertide as a second supply adapter (the schema is provider-neutral and renewal_source already names the provider), or drop Azure from the v1 customer offer, which is Aydan's lean since the target is 1k+ inbox agencies. Either way it is not a Zapmail problem, it is a product-scope decision.
Every order is accepted immediately and fulfilled by webhook. The docs are explicit that retrying while waiting double-registers and double-charges. Our intent/execution event pairs and the INDETERMINATE outcome in buy_execute are the pattern; add an eventId idempotency store for webhooks and an actionId ledger for orders.
infra-orchestrator has no inbound HTTP surface: the dashboard reads facts, the CLI runs on a schedule. Zapmail's contract is webhooks. Recommendation: poll first (GET /mailboxes and GET /subscriptions in the nightly run, 5 rps is ample for thousands of boxes), and add a webhook receiver as a second source later, writing into an inbound table the CLI consumes. That keeps "the surface asks, the CLI acts" intact and needs no new credential on the web host.
Both mailbox.created and the mailbox listing expose passwords. Same rule as icemail: the adapter is the only thing that ever sees them, and a taint test proves they never reach checks, events, observations, alerts or exception text. One icemail password reached a transcript on 1 Sep and had to be rotated; do not repeat it.
IST coverage means ET business hours are covered and PT afternoons are not. Fine for a beta with East-coast agencies; worth an SLA line in the partner terms before a 250-client onboarding.
Doesn't change: components 1 to 7, 9a, 10, 11. The engine is supplier-agnostic by construction and the doctrine page still wins on wording.
Changes:
zapmail adapter mapping expiryDate / periodEnd / price into the normalised billing row ({external_id, email, renews_at, cost, currency, cycle, observed_at}) with renewal_source = 'zapmail'. No migration.provision.py) gains a live provider path. Today it mints local inboxes and an order sheet; the Zapmail path submits POST /orders/json under the same dry-run / --live gate and records the actionId as intent, then completes on mailbox.created (poll or webhook).userId, Bison base URL + key, and Cloudflare account. The env-name indirection (bison_key_env, registrar_accounts.api_key_env) is the existing pattern to extend. This is the multi-tenancy build the product doc names as the real v1 work.Charles's immediate test (one Google, one M365, provisioned through Zapmail and fronted by Landing Factory) is the right first live exercise. It is gated by Q1 and Q3. Everything else here can proceed in sandbox with simulate:order-success while those two answers come back.