A booking tool, a field service platform, a CRM — anything where your customers message their customers — eventually gets asked for iMessage. The API call is the easy part. Four things about doing it inside a product are genuinely different.
1. The cost structure fights your pricing
Per-line pricing is excellent for one business and hostile to multi-tenant software. Every tenant wanting their own number is a line you pay for monthly, whether they send four messages or four thousand.
| Model | Your cost at 100 tenants | The problem |
|---|---|---|
| A line per tenant | ~$10,000/month | Fixed cost per tenant regardless of usage |
| Shared pool | Much lower | Tenants share a number — usually unacceptable |
| Line as a paid add-on | Passed through | The honest model; price it explicitly |
The trap
Including iMessage in a flat per-seat plan. Your revenue is per seat and your cost is per line, so a tenant with two seats and three locations is immediately unprofitable — and you cannot fix it without repricing existing customers. Make the line a named add-on from day one, even if the margin is thin.
2. Consent is your customer's, and your liability
Your tenant collected the consent. You are operating the sending infrastructure. If a tenant uploads a purchased list, the complaints, the flagged lines and the reputational damage arrive at your platform.
- Require a consent source per contact — a field your tenant fills in, not an assumption. What holds up.
- Enforce opt-out platform-wide. STOP suppresses at your layer, immediately, checked at send time and not at list-build time.
- Enforce quiet hours in the recipient's timezone, in your code. Do not leave it to tenant configuration.
- Rate-limit per tenant, aggressively at first. One tenant's blast should not degrade another tenant's line.
- Put it in your terms, with the right to suspend a tenant whose complaint rate climbs — and the monitoring to see it.
3. Number lifecycle is a real subsystem
For a single business a phone number is a one-time setup step. In a product it is a resource with a lifecycle you now own.
- Provisioning on signup, and what the product does while it is pending.
- Release on churn — including the recycling delay, because a new tenant inheriting a number with someone else's history is an incident waiting to happen.
- Replacement when a line is flagged, ideally without the tenant noticing.
- Portability, which is usually the answer to "can we take our number with us" and is usually no. Say so in advance.
Model this properly in your schema. A phone_lines table with a status, a tenant, a provider reference and a history of state changes is worth building on day one, because retrofitting it after a hundred tenants is painful. What actually sits behind those numbers.
4. Support inherits a channel you do not control
"My messages aren't sending" becomes your ticket. It could be the API, the queue, the tenant's line being throttled, the recipient not being on iMessage, or the tenant having sent four hundred messages in ten minutes.
Build the diagnostic before you launch: per-tenant delivered rate, recent failures grouped by reason, line status, and the last hour's send volume — visible to your support team without a database query. Otherwise every ticket is an investigation. What to instrument.
A reference architecture
export async function sendForTenant(input: {
tenantId: string;
to: string;
body: string;
templateKey: string;
}) {
const line = await getActiveLine(input.tenantId);
if (!line) throw new NoLineProvisioned(input.tenantId);
// Suppression is checked here, at send time. Checking it when the
// campaign was built means someone who opted out an hour ago still gets it.
if (await isSuppressed(input.tenantId, input.to)) return { skipped: "opted_out" };
if (!withinQuietHours(input.to)) return { skipped: "quiet_hours" };
if (!(await tenantBudget.consume(input.tenantId))) {
return { skipped: "rate_limited" };
}
return enqueue({
lineId: line.id,
to: input.to,
body: input.body,
// Natural key, so a worker retry cannot double-send.
idempotencyKey: `${input.tenantId}:${input.to}:${input.templateKey}:${today()}`,
});
}The layer worth owning: your own send interface, with tenant policy enforced before the provider ever sees the request.
Keeping this interface yours rather than calling the provider directly from feature code means swapping providers is one module, not a migration. In a category where a vendor could change materially, that is worth the indirection.
Choosing a provider for a product
| Requirement | Why it matters here specifically |
|---|---|
| Programmatic line provisioning | You cannot open a ticket per signup |
| Per-line webhooks and metrics | Support and billing both need per-tenant attribution |
| Documented rate limits | You are enforcing them on tenants; you need the real numbers |
| Volume pricing | At a hundred lines, list price is a negotiation |
| A sandbox | Your CI needs a target that is not production |
| SMS fallback | Your tenants' contacts are not all on iPhone |
Ask about programmatic provisioning early — several providers still allocate lines by hand, which is fine for a business and fatal for a product. Provider comparison, pricing.