Short answer: choose an SMS API only after proving that your Node.js adapter can suppress invalid recipients before dispatch, render a versioned template, split a batch into auditable per-recipient results, and route US and EU traffic without leaking vendor details into the rest of your support system.
For a one-person SaaS, the best integration is the one that keeps outage communication boring. A provider feature grid can look impressive while hiding the expensive part: every caller learns a different batch shape, template syntax, and failure model. That work steals the same hours used to ship the support product.
Build a narrow boundary first. Then test candidates behind it.
Minute zero starts with a suppression decision
Customer-support alerts are unusually sensitive to stale contact data. An invalid number should not be retried each time a service incident opens, and an opted-out recipient should never reach the dispatch call. This makes the suppression list part of the send transaction, not an administrative feature to check later.
The US and EU requirement adds a second boundary. It does not justify two alerting systems. It does justify making region an explicit field in the request so routing, credentials, and operational records can be separated without teaching the incident workflow about a particular provider. The provider decision can then be based on the measured work needed to implement that adapter: authentication, regional routing, batch semantics, template ownership, delivery events, and suppression import/export.
Message length also belongs in the design review. Twilio's SMS character-limit documentation explains that GSM-7 messages have a 160-character limit for one segment, while UCS-2 messages have a 70-character limit; concatenated messages have lower per-segment limits. A single curly quote or non-GSM character can therefore change segmentation. Don't let an outage template become a billing and readability surprise after an editor changes punctuation.
This is the decision rule: reject any candidate that forces suppression, regional policy, or per-recipient outcomes to escape the adapter. Those are application invariants. Provider-specific template IDs and request fields are implementation details.
How should a startup API handle batch send, template support, and a suppression list?
The application-facing API should accept one incident, one template version, and a list of recipients. Internally, it should evaluate every recipient against suppression before it creates provider work. The result should preserve one row per requested recipient, including suppressed rows, so an operator can answer a simple question later: what happened to this person during incident inc_127?
A provider's native batch endpoint can still be useful, but it sits below this contract. Some adapters may submit one request containing many messages; another may use controlled individual requests. The caller should not care. This keeps a weekly shipping cadence intact because changing a provider means replacing one adapter rather than revisiting the support dashboard, incident worker, and audit view.
Use three outcome states at this boundary:
-
accepted: the adapter accepted the message for dispatch. -
suppressed: local policy prevented dispatch. -
rejected: the request could not be accepted, with a stable application error code.
Delivery is a later event, not a fourth spelling of accepted. Mixing submission and delivery creates a nasty ambiguity: a successful API response says the provider accepted work, not that a handset displayed the alert. Consider the two-recipient fixture below. The first submission is accepted, the worker loses its queue acknowledgement, and the job runs again. The stable ID lets the adapter recognize the second attempt without creating a second logical message. Later, two copies of the same delivery event arrive. The consumer updates the one record keyed by inc_127:cus_41, while inc_127:cus_42 remains suppressed and has no delivery state at all. An incident view can now show exactly one accepted recipient and one suppressed recipient. Without those separate states, the operator sees either a misleading batch success or a retry count that looks like two customer messages. Keep every eventual delivery event keyed by the application message ID and make duplicate events harmless.
Accepted is not delivered.
Templates deserve the same treatment. Store the template key and version with the incident record, render variables through one application-owned function, and pass the finished body through the adapter unless a firm requirement calls for provider-hosted templates. This gives the support team a reproducible message even if wording changes next week.
The catch is that application-owned rendering is not suitable when non-engineers must use a provider's approval workflow or manage localized content entirely outside deployments. In that case, keep provider-hosted templates, but map your stable key and version to regional provider IDs inside the adapter. There is more configuration to audit, yet the domain model stays clean.
Build the dispatch boundary in TypeScript
The following example is intentionally plain. It has no vendor SDK and no hidden global state. The in-memory repositories make it runnable; production implementations can replace them with durable stores while preserving the interfaces.
type Region = "US" | "EU";
type AlertRecipient = {
customerId: string;
phone: string;
region: Region;
};
type OutageAlert = {
incidentId: string;
template: "outage-opened-v1";
service: string;
statusUrl: string;
recipients: AlertRecipient[];
};
type SendResult = {
customerId: string;
messageId: string;
state: "accepted" | "suppressed" | "rejected";
code?: "SUPPRESSED_RECIPIENT" | "ADAPTER_REJECTED";
};
interface SuppressionStore {
has(phone: string): Promise<boolean>;
}
interface SmsAdapter {
submit(input: {
messageId: string;
phone: string;
region: Region;
body: string;
}): Promise<{ accepted: boolean }>;
}
const renderOutage = (alert: OutageAlert): string =>
`${alert.service} is unavailable. Updates: ${alert.statusUrl}`;
const stableMessageId = (incidentId: string, customerId: string): string =>
`${incidentId}:${customerId}`;
async function sendOutageBatch(
alert: OutageAlert,
suppressions: SuppressionStore,
adapter: SmsAdapter,
): Promise<SendResult[]> {
const body = renderOutage(alert);
const results: SendResult[] = [];
for (const recipient of alert.recipients) {
const messageId = stableMessageId(alert.incidentId, recipient.customerId);
if (await suppressions.has(recipient.phone)) {
results.push({
customerId: recipient.customerId,
messageId,
state: "suppressed",
code: "SUPPRESSED_RECIPIENT",
});
continue;
}
const submitted = await adapter.submit({
messageId,
phone: recipient.phone,
region: recipient.region,
body,
});
results.push({
customerId: recipient.customerId,
messageId,
state: submitted.accepted ? "accepted" : "rejected",
code: submitted.accepted ? undefined : "ADAPTER_REJECTED",
});
}
return results;
}
The stable ID is deliberate. A retry for inc_127 and customer cus_42 produces the same application key, which gives the durable adapter a place to enforce idempotency. The example loops sequentially because its goal is to expose the contract, not pretend that unlimited concurrency is safe. A production adapter should use a bounded queue sized from documented provider limits and its own latency budget.
Prove that dispatch never saw the suppressed recipient
Here is a small executable check. It proves the property that matters most: the suppressed EU recipient never reaches the adapter, while the eligible US recipient does. Put this beside each candidate adapter's contract tests rather than relying on a dashboard check after messages have already left the system.
const submitted: string[] = [];
const suppressions: SuppressionStore = {
async has(phone) {
return phone === "+4915550102";
},
};
const adapter: SmsAdapter = {
async submit(input) {
submitted.push(input.phone);
return { accepted: true };
},
};
const results = await sendOutageBatch(
{
incidentId: "inc_127",
template: "outage-opened-v1",
service: "Support inbox",
statusUrl: "https://status.example.test/incidents/127",
recipients: [
{ customerId: "cus_41", phone: "+12025550101", region: "US" },
{ customerId: "cus_42", phone: "+4915550102", region: "EU" },
],
},
suppressions,
adapter,
);
if (submitted.length !== 1 || submitted[0] !== "+12025550101") {
throw new Error("Suppression boundary failed");
}
if (results[1]?.code !== "SUPPRESSED_RECIPIENT") {
throw new Error("Suppressed result was not recorded");
}
Two recipients are enough for this contract test. They are not enough for a load test.
Split regional queues after the first release
Move suppression and message IDs into durable storage before adding concurrency. The transaction should reserve the message ID and read the current suppression state before enqueueing provider work. If two workers receive the same incident task, only one reservation should proceed. This is less glamorous than a new template editor, but duplicate outage alerts burn trust quickly.
Next, split orchestration from dispatch. The orchestration job expands the audience, records suppressed recipients, and places eligible messages onto region-specific queues. Dispatch workers own credentials, rate controls, and the provider adapter. Delivery-event consumers update message records later. This shape also makes regional changes local: a routing rule can move EU dispatch without changing the incident creator.
Keep the first operational dashboard small. Track requested, suppressed, accepted, rejected, and eventually delivered counts by incident and region; record queue age; and alert when accepted events stop arriving or the suppression lookup cannot complete. Do not log full phone numbers or rendered bodies merely because they are convenient during debugging. The audit trail needs identifiers, template versions, decisions, timestamps, and normalized codes.
I would also add a template fixture containing GSM-7 text and another containing a character that requires UCS-2 encoding, then inspect the segment estimate during review. I'm not sure a local estimator will match every provider's treatment of every character. The provider's documented encoding behavior and a preproduction message resolve that uncertainty, so this check should warn rather than silently rewrite customer-facing copy.
Only then add parallelism. Ship weekly, but make the queue boring first.
Delete the adapter on paper before choosing
Run the same acceptance suite against every candidate adapter. Measure engineering work instead of counting checkmarks on a marketing page. The useful comparison is concrete: can the adapter preserve application IDs, separate US and EU routing, expose per-recipient submission results, ingest delivery events, and synchronize suppressions without changing the caller?
| Test | Pass condition | Reason to reject |
|---|---|---|
| Suppression race | A newly suppressed recipient is not queued | Suppression exists only as a delayed manual export |
| Duplicate incident job | One application message ID is dispatched | No usable idempotency strategy can be built |
| Mixed-region batch | Each recipient reaches the configured regional route | Region is implicit or scattered through callers |
| Partial rejection | Every recipient gets a normalized result | The response cannot be reconciled to recipients |
| Template revision | The exact version can be reconstructed | Content changes without an auditable version |
| Delivery event replay | Replayed events leave one final record | Event handling cannot be made idempotent |
Native batch send can reduce request overhead, but it is not automatically the easiest integration. A batch API that returns one opaque status for 500 recipients creates reconciliation work. Individual submissions with bounded concurrency may be a better early trade if each response maps cleanly to a message ID. Your mileage may vary because published limits and delivery-event models differ; verify them in current documentation and in a sandbox before signing a long contract.
Likewise, a local suppression list is not suitable when another system is the authoritative consent ledger and policy requires decisions there at send time. Keep that authority, cache only what its rules permit, and fail closed when the suppression decision is unavailable. Outsource commodity transport. Do not outsource the meaning of consent or the audit record your support team must explain.
The final choice should be the adapter with the smallest verified maintenance surface for this workflow, even if another candidate has a longer feature list. That is a revenue-per-hour decision: fewer provider concepts in application code means more time for the product customers actually buy.
References
Further reading
The Twilio character-limit reference is the useful next stop before locking an outage template. The Amazon SES documentation covers email rather than SMS, but it is a helpful primary-source contrast when deciding which communication channel owns a support notification.
Top comments (0)