To choose an email API for a custom-domain welcome flow, start with the awkward ownership boundary: your media application generates the report attachment, but the email service may also want to own the message template. That boundary changes the choice.
Short answer: choose an email API that lets your application own and render the welcome template, accepts your custom-domain authentication setup, exposes a suppression check, and provides queryable delivery events; choose provider-owned templates only when non-developers must publish copy without an application deployment.
This is not a feature-count contest. For a US/EU SaaS welcome flow that attaches a generated report, the useful comparison is application-owned templates versus provider-owned templates. The first keeps a report version, message version, and send attempt in one release trail. The second gives an operations or content team more direct control. I would start with application ownership here because the attachment and its explanatory copy should change together. The catch is real: if a newsroom changes onboarding language several times a day, forcing every edit through the application repository is the wrong workflow.
The before-and-after model
Before the boundary is explicit, a welcome flow often looks like one action: generate a PDF, call send, and record success. That picture is too small. An accepted API request does not prove delivery, a suppressed recipient should not be submitted, and an attachment can be correct while the surrounding copy describes an older report format.
After the boundary is explicit, the flow becomes two connected pipelines. In words: account created -> template and report version selected -> recipient checked against suppression state -> MIME message assembled -> send accepted -> event cursor advanced -> terminal outcome recorded. The rendering pipeline owns content. The delivery pipeline owns transport state. A shared correlation ID joins them.
That separation is the useful before/after. It also makes alerting much less vague. “Welcome email failed” is a poor signal; “event polling has not advanced for 12 minutes” or “suppression decisions rose after an import” points at an owner and a next action. Don't collapse accepted, delivered, bounced, and suppressed into one boolean.
DKIM and a custom domain belong beside this model, not in a one-time setup checklist. DKIM signs selected message headers and the body, while DMARC describes domain-level handling and reporting around authenticated mail. The practical selection question is whether the service supports the domain alignment your organization intends to operate and gives you enough evidence to verify it. Keep DNS changes under review, record who owns rotation, and test the exact visible From domain used by the welcome flow.
Small detail. Big blast radius.
How should a US/EU SaaS choose an email API for DKIM, suppression, and event polling?
Use a proof-driven scorecard with one hard rule: a candidate passes only if the team can demonstrate the complete media-report journey in a test environment. Marketing pages are inputs, not proof. The demonstration should cover a custom domain, the actual attachment size range, suppression before submission, and event retrieval without webhooks.
| Decision test | Evidence to collect | Reject when |
|---|---|---|
| Template ownership | Rendered source, review path, rollback path | Copy and report schema can drift without detection |
| Custom-domain DKIM | DNS records, alignment result, rotation owner | The required sending-domain model cannot be verified |
| Suppression | Pre-send decision and reason | The app can repeatedly submit a known bad recipient |
| Event polling | Stable cursor, event identity, documented retention | Polling can silently skip or duplicate outcomes |
| Regional operation | Contractual and technical data-flow notes | US/EU requirements cannot be mapped to actual processing |
| Attachment handling | MIME inspection and realistic file tests | The generated report cannot be tested as it is sent |
Do not award points for a longer feature list. Mark each row pass, fail, or unresolved, then attach evidence. I'm not sure any single retention period or polling interval is correct for every media SaaS; the answer depends on the provider contract, report urgency, and the recovery objective. What matters is that retention exceeds the longest credible polling outage and that the team tests recovery from its last durable cursor.
For event polling, ask a sharper question than “is there an events endpoint?” You need deterministic pagination, a stable event ID, a timestamp or cursor with documented ordering behavior, enough retention for recovery, and a rate limit compatible with the required freshness. Polling is a pull-based queue consumer. Treat it like one: persist the cursor only after the page is committed, deduplicate by event ID, and replay a page safely.
No webhook is fine.
It does mean freshness and request volume become your responsibility. A 60-second polling loop creates a different operating profile from a five-minute loop, and neither should be chosen by habit. Set the interval from the product's delivery-status promise, then alert on cursor age rather than on whether the scheduler process happens to be alive.
A copyable TypeScript boundary
The adapter below is deliberately boring. It keeps provider concepts at the edge and makes the application own the template, report metadata, suppression decision, and event checkpoint. There are no guessed URL paths in it; each candidate service gets an adapter backed by its documented API.
type DeliveryState =
| "accepted"
| "delivered"
| "bounced"
| "complained";
type DeliveryEvent = {
id: string;
messageId: string;
occurredAt: string;
state: DeliveryState;
};
type RenderedReport = {
bytes: Uint8Array;
filename: string;
reportVersion: string;
};
interface EmailTransport {
isSuppressed(address: string): Promise<boolean>;
send(input: {
correlationId: string;
from: string;
to: string;
subject: string;
html: string;
attachment: RenderedReport;
}): Promise<{ messageId: string }>;
listEvents(input: {
cursor?: string;
}): Promise<{ events: DeliveryEvent[]; nextCursor?: string }>;
}
interface DeliveryStore {
hasEvent(eventId: string): Promise<boolean>;
saveEvent(event: DeliveryEvent): Promise<void>;
getCursor(): Promise<string | undefined>;
saveCursor(cursor: string): Promise<void>;
}
The send path checks suppression before doing transport work. It also records template and report versions in application data, even though only the report version is shown in the compact interface. That gives an operator a clean answer when a customer asks which report they received.
async function sendWelcomeReport(
transport: EmailTransport,
input: {
accountId: string;
recipient: string;
report: RenderedReport;
},
): Promise<{ status: "suppressed" } | { status: "accepted"; messageId: string }> {
if (await transport.isSuppressed(input.recipient)) {
return { status: "suppressed" };
}
const correlationId = `welcome:${input.accountId}:${input.report.reportVersion}`;
const result = await transport.send({
correlationId,
from: "reports@example.test",
to: input.recipient,
subject: "Your first audience report",
html: `<p>Your report is attached.</p>`,
attachment: input.report,
});
return { status: "accepted", messageId: result.messageId };
}
Acceptance is intentionally not named sent. Words shape dashboards. If the API accepted the request but the destination later produced an enhanced status such as 5.1.1, the final state belongs to the event consumer, not the synchronous request. RFC 3463 defines the enhanced mail status code structure; preserve the provider's raw reason alongside your normalized state instead of throwing useful diagnostic detail away.
Now the poller. The important ordering is event commit first, cursor commit second. A crash between those writes may repeat an event, so the event ID provides idempotency. Reversing the order can lose a page.
async function pollDeliveryEvents(
transport: EmailTransport,
store: DeliveryStore,
): Promise<number> {
const page = await transport.listEvents({ cursor: await store.getCursor() });
let saved = 0;
for (const event of page.events) {
if (await store.hasEvent(event.id)) continue;
await store.saveEvent(event);
saved += 1;
}
if (page.nextCursor) {
await store.saveCursor(page.nextCursor);
}
return saved;
}
Test this boundary with recorded, redacted fixtures for duplicate events, empty pages, events that arrive later than expected, and a restart from the previous cursor. Then run one integration test against each candidate's documented sandbox or test mode. The generic contract makes replacement possible, but it doesn't pretend providers normalize states, ordering, or suppression semantics in the same way.
What if the content team needs provider-owned templates?
Then let that requirement change the decision. Provider-owned templates are suitable when authorized editors need to publish copy independently, preview tooling is part of their daily work, and a content-only rollback must not wait for an application deployment. Stick with that model when editorial autonomy matters more than keeping the message and attachment schema in one commit.
The cost is a new compatibility boundary. Store the provider template identifier and an expected template revision with each send attempt. Validate required variables before submission. Keep a fixture representing the generated report, because a template preview that ignores the attachment can still approve misleading copy. Access control and audit history also become selection criteria: the question is no longer only whether a template editor exists, but who can publish, how a bad revision is rolled back, and how the application knows which revision was used.
For the media-report flow, application-owned templates remain the better default. The report filename, subject, explanatory text, and schema can be reviewed as one change. It is not suitable when content operations genuinely needs an independent release cadence. This isn't a minor exception; it is the condition that should flip the choice.
Can polling replace webhooks without weakening observability?
Yes, if the poller is operated as a first-class production consumer. Measure cursor age, oldest unprocessed event age, pages fetched, duplicate event count, normalized outcomes, and suppression decisions. Log the correlation ID, message ID, report version, template version, event ID, and polling cursor in structured fields. Never log report contents or full recipient addresses just to make a dashboard convenient.
Alert on user impact and stalled progress. A process-up check only proves that a timer fired. A useful alert says that no cursor has been committed within the recovery window while accepted messages remain unresolved. Another can track a sudden change in bounce or complaint outcomes, but its threshold needs a baseline and enough volume; your mileage may vary for a small publication with bursty weekly reports.
There is also a clean deployment test. Pause the poller after saving cursor A, create three test sends with distinct correlation IDs, and capture the accepted message IDs. Resume once, allow the consumer to store the first event page, and stop it before cursor B is committed. On the next start, the service may return that page again; the adapter should recognize every stored event ID, make no duplicate state transition, and only then commit cursor B. Finally, compare the three message IDs with the terminal outcomes in the delivery store and inspect cursor age on the dashboard. This exercise tests the part most diagrams skip: recovery. It also separates two failure signals that otherwise look identical. A growing cursor age means ingestion is behind, while a current cursor with unresolved messages means the delivery lifecycle itself has not produced a terminal event yet. The runbook should send those signals to different owners.
Webhooks remain the better choice when near-real-time reactions are mandatory and the team is prepared to authenticate incoming requests, handle retries, and expose an ingress path. Polling is a strong fit when inbound connectivity is prohibited or operational simplicity favors outbound requests, provided the service's event retention and query behavior meet the recovery objective. Neither transport removes the need for idempotency.
The final selection record can be one page: chosen ownership model, proof for each scorecard row, unresolved risks, polling recovery objective, and the condition that would trigger reevaluation. That is much more durable than a screenshot of a pricing grid. Re-run the attachment test and authentication checks before a domain change or major template revision, and review suppression behavior as part of incident drills.
Sources and References
- Resend documentation: https://resend.com/docs/introduction
- DKIM Signatures, RFC 6376: https://www.rfc-editor.org/rfc/rfc6376
- Domain-based Message Authentication, Reporting, and Conformance, RFC 7489: https://www.rfc-editor.org/rfc/rfc7489
- Enhanced Mail System Status Codes, RFC 3463: https://www.rfc-editor.org/rfc/rfc3463
- MIME Part One, RFC 2045: https://www.rfc-editor.org/rfc/rfc2045
- CTIA Messaging Interoperability and SMS/MMS Best Practices: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Top comments (0)