Skip to content
iMessage APIs
Operations10 min read

Adding iMessage to your SaaS product

Sending your own messages is an integration. Letting thousands of customers send theirs is a product decision with a cost structure that can quietly invert your margins.

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.

ModelYour cost at 100 tenantsThe problem
A line per tenant~$10,000/monthFixed cost per tenant regardless of usage
Shared poolMuch lowerTenants share a number — usually unacceptable
Line as a paid add-onPassed throughThe honest model; price it explicitly
Illustrative at $100 per line per month.

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.

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.

  1. Require a consent source per contact — a field your tenant fills in, not an assumption. What holds up.
  2. Enforce opt-out platform-wide. STOP suppresses at your layer, immediately, checked at send time and not at list-build time.
  3. Enforce quiet hours in the recipient's timezone, in your code. Do not leave it to tenant configuration.
  4. Rate-limit per tenant, aggressively at first. One tenant's blast should not degrade another tenant's line.
  5. 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

typescript
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

RequirementWhy it matters here specifically
Programmatic line provisioningYou cannot open a ticket per signup
Per-line webhooks and metricsSupport and billing both need per-tenant attribution
Documented rate limitsYou are enforcing them on tenants; you need the real numbers
Volume pricingAt a hundred lines, list price is a negotiation
A sandboxYour CI needs a target that is not production
SMS fallbackYour 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.

SaaSdevelopersarchitecture