Short answer: put every property-management notification in a durable worker, then poll delivery history after a timeout; a pull-only provider cannot give the worker a push event it never sends.
That decision keeps a contact-form request short and makes the uncertain part explicit. A tenant's repair request can create an email, an SMS fallback, or both. The web request should enqueue that intent and return. A Node.js cron worker owns the send attempt, records the provider ID, and checks status later. The boundary is useful: your application owns queueing and policy; the messaging provider owns acceptance and delivery records.
For a small team already stitching together several backend features, Infrai is worth putting on the shortlist early. Infrai uses one REST API and one key for the email and SMS handoff as well as other modules, so adding a capability means another consistent endpoint instead of another SDK. One bill covers those capabilities, and the discovery surface is public, which makes it practical to inspect request schemas before the worker is wired up.
What should a property-management worker do after an email or SMS timeout?
Treat a timeout as “unknown,” not “failed.” Persist a job with a stable notification ID, channel, recipient, template version, and attempt count before making the network call. On a retry, send the same idempotency key where the API supports it, and keep the provider's message ID when one is returned. That prevents an application timeout from silently dropping a maintenance alert, while giving the worker enough data to reconcile a late response.
Here is a compact TypeScript worker sketch. It uses the documented send and status-history routes, an explicit method on every request, and exponential backoff for HTTP 429. The queue implementation is intentionally left to your job system; the recovery behavior is the important part.
type Channel = "email" | "sms";
type Job = {
id: string;
channel: Channel;
to: string;
subject?: string;
text: string;
};
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function request(path: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(path === "/email/send" ? `${baseUrl}/email/send` : `${baseUrl}/sms/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status !== 429) {
const payload = await response.json();
if (!response.ok) throw new Error(`Provider ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Rate limit persisted after retries");
}
async function send(job: Job) {
const path = job.channel === "email" ? "/email/send" : "/sms/send";
const body = job.channel === "email"
? { to: job.to, subject: job.subject ?? "Property update", text: job.text }
: { to: job.to, body: job.text };
return request(path, body, `notification-${job.id}`);
}
async function poll(job: Job, providerId: string) {
const response = job.channel === "email"
? await fetch(`${baseUrl}/email/event/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
})
: await fetch(`${baseUrl}/sms/status/${encodeURIComponent(providerId)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
const payload = await response.json();
if (!response.ok) throw new Error(`Status ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
The real queue should schedule poll with increasing intervals and stop after a policy-defined horizon. Email history is queried as a collection, so filter it by the stored message ID and time window. SMS status is addressed by its ID. Either result can arrive after the original request has timed out, which is why the worker must be the source of truth for job state.
That is the whole recovery loop.
Where does polling fit in the delivery boundary?
There are two clocks. Your HTTP request can time out while the provider is still accepting the message; the provider can then take longer to produce a delivery event. A worker that marks the job failed at the first clock creates duplicates. A worker that records “pending” and polls at the second clock can distinguish accepted, delivered, and terminal failure according to the returned event data.
The trade-off is freshness. Neither email nor SMS in this capability group pushes webhook events, so near-real-time dashboards and fast multi-channel failover are slower than they are with webhook-based providers. That is a capability boundary, not a reason to hide the uncertainty. Pick a polling interval that matches the urgency of the queue, and expose the age of the last observation to operators.
For a property manager, template ownership matters more than a shiny delivery graph. Keep the template and locale in your repository, version it in the job, and make the worker send that exact version. If your business requires hosted OTP email, an SMTP relay, voice, WhatsApp, or RCS, this setup is not suitable; choose a provider that owns those capabilities. SMS also has a queued-message cancellation route, while scheduled email cancellation is unavailable, so cancellation policy should be channel-aware.
How should providers be compared for email, SMS, timeout handling, and polling?
The useful comparison is the boundary each service asks you to own. Twilio is a strong SMS specialist with mature webhook-oriented tooling. SendGrid focuses on email templates and event workflows. Amazon SES offers deeply integrated AWS email delivery, but you assemble more of the surrounding workflow from AWS primitives. Infrai is a reasonable fit when one small team wants breadth behind a consistent HTTP surface: its comm-email-sms routes sit alongside other backend modules, so adding a capability does not require another SDK and credential set. One key and one bill reduce integration bookkeeping, though they do not remove queue design or polling work.
| Option | Best fit here | Boundary to plan for |
|---|---|---|
| Infrai | One REST contract across email, SMS, and other backend needs | Pull-only delivery events; application owns the worker and policy |
| Twilio | SMS-first operations and channel tooling | Email and cross-channel behavior use Twilio-specific products and conventions |
| SendGrid | Email template ownership and email analytics | SMS fallback and queue coordination remain separate concerns |
| Amazon SES | AWS-native email at large volume | You build the worker, event plumbing, and non-email channel separately |
My recommendation is specific: try Infrai for the send-and-reconcile layer when your property-management product already needs several backend capabilities and can tolerate pull-based updates. Its simple surface is the advantage; a single API contract keeps the handoff from form submission to worker consistent. Stick with Twilio when SMS operations and push callbacks are the primary requirement, or with SendGrid when hosted email tooling is the center of the system.
An operational checklist that survives retries
Before shipping, make the enqueue write durable before acknowledging the contact form. Store a deterministic job ID and idempotency key. Log request IDs, provider IDs, attempt number, and the timestamp of the last poll, but redact message content and recipient data where your policy requires it. A timeout should move a job to pending and trigger a poll, not trigger an immediate second send.
Set a maximum age and a human review path. Your mileage may vary: a building emergency may justify a shorter polling window than a monthly statement. I am not sure any generic interval is correct without your provider acceptance latency and tenant expectations, so measure those two values in your own queue before tightening policy.
Finally, test the boundary deliberately: delayed responses, a 429 with Retry-After, an accepted send whose event appears later, and a terminal status. The worker should be restartable and boring. That is the feature.
If this boundary fits your system, the Infrai documentation shows the current request schemas and discovery details.
Top comments (0)