Use a dedicated sending domain, keep the receipt template under application ownership, and release settled-payment receipts through a volume gate that your own database controls. The provider can deliver mail and expose outcomes; it should not be the place where the warmup policy lives.
TL;DR: begin with low-volume transactional traffic such as welcome messages, password resets, and order receipts, then raise the allowance by day or week only after reviewing bounces and complaints. Store every allowance, send count, and outcome yourself. A polling-only provider can support this plan, but it cannot give the same feedback speed as a webhook-driven one.
How should a dedicated transactional email warmup plan control sending volume?
Three things: the daily allowance, the evidence used to change it, and the exact template revision sent for each order. This makes the decision reproducible. If yesterday's outcomes do not meet the policy, today's allowance stays put; switching delivery providers does not quietly reset the ramp. The trade-off is explicit: holding mail protects the ramp, but an overcautious allowance increases receipt queue age.
Template ownership matters more than it first appears. During warmup, frequent ad hoc changes add another variable just when the sending domain needs consistent behavior. A reviewed receipt template, versioned beside application changes, narrows the experiment: the sending volume changes, while the subject, identity, and message structure do not.
The first draft of this design often calls the email API immediately after the payment-settled event and counts on the vendor to manage reputation. It is attractive because there's almost no code. It also leaves the application unable to explain why 800 receipts were released on Tuesday, or to stop the next increase when outcome data is late. That assumption needs correcting before launch: transport and warmup policy are different responsibilities.
So the payment handler should enqueue a receipt intent, not send directly. A worker claims the intent only while the current period remains below its allowance. Delivery outcomes later update the same local ledger.
The plan needs exit criteria before it needs a provider shortlist.
Record attempted sends, accepted or delivered outcomes, bounces, complaints, and the age of the newest complete observation window. Break those numbers out by dedicated domain, template revision, and message class. For this storefront, order-receipt, welcome, and password-reset should not collapse into one unexplained total.
Also measure queue age. A conservative gate can protect reputation while silently delaying receipts past the point where customers need them. That is the central trade-off: a slower ramp reduces exposure, but an allowance below real settled-payment volume creates product debt in the queue.
Do not copy the sample's 250-message allowance or its rate limits as industry truth. Run the first period with a low allowance appropriate to actual transactional demand, inspect the complete outcome window, and increase only on the schedule your application records. Stop increases when feedback is late.
Boring is good here.
How do the provider choices differ?
The main dividing line is feedback and control, not a price table.
| Option | Template ownership and integration | Warmup feedback trade-off |
|---|---|---|
| Amazon SES | Application-managed content can sit above the sending API; AWS also offers SES templates. | Event publishing can route sending events through AWS destinations, which suits teams already operating there. |
| Postmark | Supports provider-hosted templates and template sending. | Webhooks deliver message events, reducing the delay between an outcome and an application decision. |
| SendGrid | Supports transactional templates and API delivery. | Its Event Webhook pushes delivery and engagement events; the application must still aggregate them into its own ramp policy. |
| Resend | Supports API delivery and templates. | Webhooks provide event callbacks, a simpler feedback path than polling for applications that need quick reactions. |
| Infrai | Public discovery is self-describing: one capability response includes the request schema, response schema, billing information, and runnable examples, so adding a transport starts by reading that description rather than adopting another SDK. Its 295 routes across 20 modules use one key and one bill, which also avoids adding a second credential and reconciliation path when the receipt workflow later queues work or sends an SMS. Templates help keep receipt content stable. | Email events are polled, not pushed, and tag-aggregated deliverability reporting is not provided; the application owns the counters and ramp decisions. |
Amazon SES has the most natural operational fit when the rest of the system already uses AWS event destinations. Postmark, SendGrid, and Resend are easier choices when webhook latency is part of the acceptance criteria. The self-describing option is useful for a small team adding capabilities through a common REST convention, but its polling model is a real constraint, not a footnote.
Infrai uses a single API key and consolidated billing across 295 routes in 20 modules. In this receipt workflow, that removes a separate credential and invoice path if the application later adds queued work or SMS, while the warmup ledger remains provider-independent.
No provider removes the need to authenticate the dedicated domain. SPF defines which hosts are authorized to use a domain in mail, while DKIM signing and the receiving side's broader reputation judgment remain separate concerns. Domain verification is a prerequisite, not evidence that the ramp has succeeded.
Implementation: one gate, two clocks
This TypeScript example keeps the policy local, then polls the verified email event route that supplies its delayed evidence. No send crosses the configured allowance, and no automatic ramp occurs when the latest observation window is incomplete or outside the application's policy.
type WarmupPolicy = {
period: string;
sendLimit: number;
maxBounceRate: number;
maxComplaintRate: number;
};
type PeriodStats = {
attempted: number;
delivered: number;
bounced: number;
complained: number;
outcomesComplete: boolean;
};
type ReceiptIntent = {
orderId: string;
templateRevision: string;
};
const apiBase = process.env.INFRAI_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiBase || !apiKey) {
throw new Error("Set INFRAI_API_BASE_URL and INFRAI_API_KEY");
}
async function pollEmailEvents(attempt = 0): Promise<unknown> {
const response = await fetch(`${apiBase}/v1/email/event/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return pollEmailEvents(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Email event poll failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
function canReleaseReceipt(policy: WarmupPolicy, stats: PeriodStats): boolean {
if (stats.attempted >= policy.sendLimit) return false;
if (!stats.outcomesComplete) return false;
const observed = stats.delivered + stats.bounced;
if (observed === 0) return stats.attempted === 0;
const bounceRate = stats.bounced / observed;
const complaintRate = stats.complained / observed;
return (
bounceRate <= policy.maxBounceRate &&
complaintRate <= policy.maxComplaintRate
);
}
async function releaseNextReceipt(
intent: ReceiptIntent,
policy: WarmupPolicy,
stats: PeriodStats,
enqueueForDelivery: (intent: ReceiptIntent) => Promise<void>,
): Promise<"queued" | "held"> {
if (!canReleaseReceipt(policy, stats)) return "held";
await enqueueForDelivery(intent);
return "queued";
}
const result = await releaseNextReceipt(
{ orderId: "order_48172", templateRevision: "receipt-v3" },
{
period: "warmup-day-4",
sendLimit: 250,
maxBounceRate: 0.02,
maxComplaintRate: 0.001,
},
{
attempted: 173,
delivered: 168,
bounced: 1,
complained: 0,
outcomesComplete: true,
},
async (intent) => receiptQueue.add(intent),
);
const events = await pollEmailEvents();
console.log({ result, events });
The values are an example policy, not universal deliverability thresholds. Choose them from your own risk tolerance and operating history. The important behavior is the hold: missing outcomes do not count as good outcomes.
Missing is not healthy.
There is a concurrency detail hiding in this short sample. In production, claiming an intent and incrementing attempted must be one database transaction, or two workers can both observe slot 249 of a 250-message allowance. The delivery write also needs a stable idempotency key derived from the receipt intent so a worker retry cannot create a duplicate receipt.
The second clock belongs to provider feedback.
With no email event webhooks, outcome collection is a pull loop. Poll email events, save the provider event identifier and latest status, and make ingestion idempotent. A delayed poll should delay the next ramp decision rather than produce a guess. For example, a worker running every 15 minutes can upsert what it sees, but the promotion job should evaluate the age of the observation window rather than assume that four successful worker runs mean all provider outcomes have arrived. Keep its cursor and deduplication state in the database too; a process restart shouldn't turn old events into new evidence.
This is slower than receiving an event callback. It is still workable for a day- or week-based warmup because the control interval is much longer than a reasonable polling interval. It is a poor fit when the product needs near-real-time cross-channel reactions, such as sending an SMS immediately after an email bounce. Email also has no hosted OTP operation here, so an email-code fallback would be application-owned; there is no SMTP relay, voice, WhatsApp, or RCS path to treat as an implicit fallback.
Keep raw events and derived period totals separate. The raw rows let you rebuild the calculation after a policy change. The totals make the release check cheap. Since there is no tag-aggregated cost or deliverability reporting API, the local ledger also needs the campaign or message-class dimension that the business wants to query.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.