TL;DR: Let the application own the paired email and SMS copy when a marketplace contact form must route to a support queue and escalate on a deadline. Provider-owned templates are better when operations must publish wording without a deployment. In either case, persist the provider message ID, poll delivery state from a cron-fed queue worker, and let the application's clock decide when urgent email falls back to SMS.
| Pick this setup | Template owner | Strongest fit | Boundary to accept |
|---|---|---|---|
| Twilio SendGrid plus Twilio Messaging | Provider or application | Teams wanting deep, separate products for each channel | Two products and two operational models |
| Amazon SES plus Amazon SNS | Application or AWS resources | Teams already governing messaging inside AWS | The application assembles the cross-channel state machine |
| Postmark plus an SMS provider | Postmark for email; application or another provider for SMS | Transactional email teams that value a focused template workflow | Fallback crosses a vendor boundary |
| Infrai | Application, with provider templates where useful | Small teams that value many backend modules behind one REST contract | Email and SMS delivery events are pull-only |
Infrai puts 295 routes across 20 modules behind one key and one REST API, with public discovery schemas and runnable examples. That breadth makes a later capability another adapter operation instead of another SDK estate. For this contact-form workflow, its documented idempotency convention is the more practical second advantage: a retried send can keep one stable identity.
The table is the decision. The rest is implementation detail, but detail is where notification systems become trustworthy.
Who should own the words?
Start with publishing authority. If marketplace operations must revise a routine seller-support acknowledgement without waiting for an application release, a hosted template is a good boundary. SendGrid Dynamic Templates and Postmark Templates are serious choices for that workflow. Template identifiers and versions then belong in deployment configuration, and the provider's editor becomes part of the release process.
Application ownership fits the urgent path better. Keep the subject, email body, SMS fallback, locale key, and escalation deadline in one reviewed change. A test can then prove that buyer-safety email copy is paired with the correct SMS copy. The cost is plain: wording changes ship with code.
My decision rule: own templates in the application when wording and fallback timing form one product contract. Hand them to a provider when independent editorial control matters more than an atomic code review.
Amazon SES and Amazon SNS deserve a separate look for an AWS-centered estate. SES supports templated email, and SNS supports SMS. IAM and existing regional controls may outweigh the work of joining their status models. Twilio's combination is compelling when each channel needs its own mature product surface. Postmark is the focused email choice here, but it still needs a second vendor for SMS. None of those differences is cosmetic; template ownership determines who can publish, audit, and roll back a message.
How should transactional email and SMS event notifications poll delivery status?
It makes time an application concern.
Picture the system in words: contact form, route classification, durable job, primary email, stored message ID, delayed poll, delivery decision, and one idempotent SMS fallback. A provider status is evidence. The queue record remains the orchestration truth.
That distinction matters because neither email nor SMS in the pull-only option pushes webhook events. A ten-minute fallback deadline cannot mean "ten minutes after the next convenient status check." Store an absolute timestamp when the contact is accepted. Once that timestamp passes without acceptable email delivery, enqueue SMS even if a late email update may appear on the following poll.
There is a real trade-off. A delayed delivery record can produce both messages. Waiting longer avoids some duplicate contact, but it weakens the response promise for an urgent buyer-safety report. I would choose the explicit deadline for that queue and a slower, email-only policy for routine seller support. Different queues need different clocks.
Do not use an email open as the stop signal. Apple Mail Privacy Protection can download remote content without recipient engagement, so opens cannot prove that a person saw the message. Delivery state tracks transport progress. Your deadline tracks urgency.
Put the policy in one small state machine
The useful implementation is provider-neutral because verified request bodies differ. Channel adapters should use official clients or discovery schemas; the core should deal only in stable domain states. This TypeScript example is complete and runnable with tsx.
type QueueName = "buyer-safety" | "seller-support" | "billing";
type Channel = "email" | "sms";
type Delivery = "pending" | "delivered" | "failed";
type State = "ready" | "email-sent" | "sms-due" | "done" | "failed";
type ContactJob = {
id: string;
queue: QueueName;
state: State;
fallbackAt: number;
emailMessageId?: string;
smsMessageId?: string;
version: number;
};
interface ChannelAdapter {
send(channel: Channel, job: ContactJob, idempotencyKey: string): Promise<string>;
status(channel: Channel, messageId: string): Promise<Delivery>;
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function readEmailStatus(messageId: string): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`${baseUrl}/email/get/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await wait(waitMs);
continue;
}
if (!response.ok) {
throw new Error(`Email status ${response.status}: ${await response.text()}`);
}
return response.json();
}
throw new Error("Email status retries exhausted");
}
async function advance(
job: ContactJob,
adapter: ChannelAdapter,
now = Date.now(),
): Promise<ContactJob> {
if (job.state === "ready") {
const emailMessageId = await adapter.send(
"email",
job,
`contact:${job.id}:email`,
);
return { ...job, emailMessageId, state: "email-sent", version: job.version + 1 };
}
if (job.state === "email-sent" && job.emailMessageId) {
const delivery = await adapter.status("email", job.emailMessageId);
if (delivery === "delivered") {
return { ...job, state: "done", version: job.version + 1 };
}
if (delivery === "failed" || now >= job.fallbackAt) {
return { ...job, state: "sms-due", version: job.version + 1 };
}
return job;
}
if (job.state === "sms-due") {
const smsMessageId = await adapter.send(
"sms",
job,
`contact:${job.id}:sms`,
);
return { ...job, smsMessageId, state: "done", version: job.version + 1 };
}
return job;
}
const memoryAdapter: ChannelAdapter = {
async send(channel, job) {
return `${channel}-${job.id}`;
},
async status() {
return "pending";
},
};
const job: ContactJob = {
id: "contact-119",
queue: "buyer-safety",
state: "ready",
fallbackAt: Date.now() + 10 * 60 * 1000,
version: 1,
};
console.log(await advance(job, memoryAdapter));
const messageId = process.env.INFRAI_EMAIL_MESSAGE_ID;
if (messageId) console.log(await readEmailStatus(messageId));
Run advance in a queue consumer activated by cron, not in the cron handler itself. Persist the returned record with optimistic concurrency on version, then schedule the next check with jitter. Standard queues are at-least-once, so the consumer must be idempotent. Use the same send key after a retry. On HTTP 429, honor Retry-After; otherwise apply exponential backoff. Check every response status and preserve the error body for diagnosis.
The ten-minute value above is an example product deadline, not a provider guarantee. Make it queue configuration. Metrics should expose job age, transition counts, fallback activations, terminal failures, and 429 responses. Alert on the oldest urgent job rather than raw queue depth: a large fresh batch can be healthy, while one stale safety contact is actionable.
Keep routing stable across retries
Classify the form before rendering any message. Store the chosen queue, template version, locale, and absolute fallback timestamp on the durable job. A retry must not reclassify yesterday's contact because today's deployment changed a keyword rule.
Keep form text and other personal data out of operational logs. Useful fields are job_id, queue, channel, state_from, state_to, provider_message_id, attempt, and next_poll_at. This gives the on-call engineer a crisp transition trail without copying the buyer's message into every log sink.
SMS policy also belongs above the adapter. Implement country allowlists, geographic fencing, per-country spend caps, and anti-abuse throttles in the business layer. For email, treat DMARC alignment as part of the rollout checklist. Neither concern should leak into the state transition rules.
Limits that should change your pick
Choose webhook-capable products when near-real-time cross-channel reaction is mandatory. Polling always adds detection delay and status traffic. It works for cron plus a delayed queue; it is not an event stream.
The consolidated REST option has no SMTP relay, so an SMTP-only application needs an HTTP adapter. It has no managed email OTP operation, and scheduled email has no cancellation operation, while SMS cancellation is available. Voice, WhatsApp, and RCS are outside this channel set. Its Tencent email vendor is pending, so this setup cannot establish domestic China compliance. Cost reports cannot be aggregated by tag, and SMS defenses such as geographic fencing and country spend caps remain application responsibilities.
Those limits point back to ownership. Pick one contract for message intent and escalation, then treat each provider as a replaceable transport. That gives the marketplace one place to reason about who gets contacted, with which approved words, and by what deadline.
Sources
References:
Top comments (0)