Channel preferences and opt-out: one resolver, two suppression lists

Store notification preferences yourself, let the provider store the legal opt-out, and query both before every send — with the Node 22 resolver and the two APIs that disagree.

Keep the user’s channel choice in your own database and let the provider keep the legal opt-out. On Infrai that means one notification_preferences row you write, plus two suppression lists you read before every send: GET /v1/email/suppression/check/{email} for mail and POST /v1/sms/suppression/check for text. A resolver turns an event and a user into the channels that are both wanted and permitted.

The two lists don’t have the same shape, and that asymmetry is most of the implementation work.

Preference is not permission

A preference is a product setting — “send me the weekly digest, not the daily one” — and it belongs in a table you can migrate, backfill and expose in a settings page. A suppression entry is different: it records that a channel is closed, either because the person asked (unsubscribe, STOP) or because the network told you so (hard bounce, carrier block). Those two facts have different lifetimes and different owners, and merging them into one boolean column is how teams end up mailing an address that bounced six months ago.

So the rule is boring and worth stating plainly: your table can say no, and the suppression list can also say no, and only one of them can say yes.

Two lists, two shapes

Here’s where it gets fiddly. The email and SMS suppression surfaces answer the same question with different verbs, different keys and different vocabularies.

EmailSMS
Check one recipientGET /v1/email/suppression/check/{email}POST /v1/sms/suppression/check
How the recipient travelsin the pathin the JSON body as phone
Add an entryPOST /v1/email/suppression/addPOST /v1/sms/suppression/add
Scopesaccount, domainaccount only
Reason vocabularyhard_bounce, soft_bounce_5x, complained, unsubscribed, invalid, manual, user_requestuser_request, stop_reply, carrier_block, invalid, manual
Billingfree readfree read

Note the reason columns barely overlap. complained (a spam report) has no SMS equivalent, stop_reply and carrier_block have no email equivalent, and a shared enum in your own code would have to be the union of both. We keep them separate and map to a local channel_closed_reason at the edge.

The email check, with a real address:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/email/suppression/check/user@example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "email": "user@example.com",
    "reason": "manual",
    "added_at": "2026-07-04T17:02:22.803322Z",
    "scope": "account",
    "attempt_count_blocked": 0,
    "suppressed": true
  }
}

A clean address answers with only {"email": "...", "suppressed": false} — the reason fields are absent rather than null, so read suppressed and nothing else.

The SMS check is a POST, and it is not side-effecting despite the verb:

curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/check" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"phone": "+15551234567"}'
{
  "ok": true,
  "data": { "phone": "+15551234567", "suppressed": false },
  "metadata": { "cost_usd": 0.0, "latency_ms": 49 }
}

The table you own

Two columns per channel, and an event-type key so a user can mute billing alerts without muting security ones.

CREATE TABLE notification_preferences (
  user_id        uuid        NOT NULL,
  event_type     text        NOT NULL,      -- 'invoice.paid', 'login.new_device'
  email_enabled  boolean     NOT NULL DEFAULT true,
  sms_enabled    boolean     NOT NULL DEFAULT false,
  updated_at     timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, event_type)
);

-- Security notices are transactional and should not be preference-gated.
INSERT INTO notification_preferences (user_id, event_type, email_enabled, sms_enabled)
VALUES ('11111111-1111-1111-1111-111111111111', 'login.new_device', true, true)
ON CONFLICT (user_id, event_type) DO UPDATE SET sms_enabled = EXCLUDED.sms_enabled;

The resolver, in Node 22

One function decides; a second one sends. Keeping them apart means you can unit-test the decision without spending money on messages.

// notify.mjs — resolve channels, then dispatch what survived.
// Run: INFRAI_API_KEY=your_infrai_api_key node notify.mjs
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function call(method, path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: payload === undefined ? undefined : JSON.stringify(payload),
  });
  const json = await res.json();
  if (json.ok === false) {
    throw Object.assign(new Error(json.error.message), { code: json.error.code, retryable: json.error.retryable });
  }
  return json.data;
}

// Your table. Swap the Map for a SELECT against notification_preferences.
const prefs = new Map([
  ["u_1:invoice.paid", { email_enabled: true, sms_enabled: true }],
]);

async function resolveChannels(user, eventType) {
  const row = prefs.get(`${user.id}:${eventType}`) ?? { email_enabled: true, sms_enabled: false };
  const wanted = [];
  if (row.email_enabled && user.email) wanted.push("email");
  if (row.sms_enabled && user.phone) wanted.push("sms");

  const checks = await Promise.all(wanted.map(async (channel) => {
    if (channel === "email") {
      const d = await call("GET", `/v1/email/suppression/check/${encodeURIComponent(user.email)}`);
      return { channel, blocked: d.suppressed === true, reason: d.reason ?? null };
    }
    const d = await call("POST", "/v1/sms/suppression/check", { phone: user.phone });
    return { channel, blocked: d.suppressed === true, reason: d.reason ?? null };
  }));

  return {
    allowed: checks.filter((c) => !c.blocked).map((c) => c.channel),
    blocked: checks.filter((c) => c.blocked),
  };
}

async function dispatch(user, eventType, copy) {
  const { allowed, blocked } = await resolveChannels(user, eventType);
  for (const b of blocked) console.warn(`suppressed ${b.channel} for ${user.id}: ${b.reason ?? "unknown"}`);
  const sent = [];
  for (const channel of allowed) {
    try {
      if (channel === "email") {
        const d = await call("POST", "/v1/email/send", {
          to: user.email,
          subject: copy.subject,
          html: copy.html,
          tags: [eventType],
          auto_unsubscribe_link: true,
        });
        sent.push({ channel, id: d.message_id, suppressed: d.suppressed_recipients ?? [] });
      } else {
        const d = await call("POST", "/v1/sms/send", { to: user.phone, body: copy.text });
        sent.push({ channel, id: d.message_id, state: d.state });
      }
    } catch (err) {
      console.error(`${channel} failed: ${err.code} ${err.message} (retryable=${err.retryable})`);
    }
  }
  return sent;
}

const user = { id: "u_1", email: "user@example.com", phone: "+15551234567" };
const decision = await resolveChannels(user, "invoice.paid");
console.log("resolved:", decision);
if (process.env.SEND === "1") {
  console.log(await dispatch(user, "invoice.paid", {
    subject: "Your invoice is paid",
    html: "<p>Thanks — invoice INV-2026-0412 is settled.</p>",
    text: "Invoice INV-2026-0412 is settled.",
  }));
}

Two details in there earn their keep. tags: ["invoice.paid"] puts the event type on the message so your later usage query can answer “what did digest mail cost us last month” without a join. And auto_unsubscribe_link: true appends a signed unsubscribe link, which is what RFC 8058 one-click unsubscribe expects mailbox providers to find.

Recording the opt-out

When someone clicks unsubscribe or replies STOP, write it to the provider list first and your table second. If the second write fails you’ve still stopped the messages.

curl -sS -X POST "https://api.infrai.cc/v1/email/suppression/add" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"email": "leaver@example.com", "reason": "unsubscribed", "scope": "account"}'

curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/add" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"phone": "+15557654321", "reason": "stop_reply", "notes": "replied STOP 2026-07-26"}'

What the preference layer costs

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 300

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 2026-07-26: every suppression read and write on this API is free and rate-limited, so the preference layer itself adds nothing to the bill — only the messages that survive it do. Those are billed per message, $0.007475 for an SMS and $0.000115 published per email, and new accounts get $2 of free credit (around 267 texts). Read GET /v1/discovery for today’s figures rather than trusting this paragraph next quarter; rates on this surface have moved down over time and discount campaigns run. Reconcile against GET /v1/account/usage, which is what your invoice is built from.

Where this design runs out

Three limitations you should design around rather than discover.

Inbound STOP handling isn’t readable through the API on a standard key: GET /v1/sms/inbound/list answers HTTP 503 VENDOR_NOT_CONFIGURED until an SMS vendor key is hydrated for the account, so the stop_reply entries above have to come from whatever inbound path you actually run. SMS suppression is account-scoped only, so a multi-tenant product that wants tenant-level opt-out needs that column in its own table — email at least has scope: "domain". And there are no delivery webhooks and no X-RateLimit-* headers anywhere here, so both the delivery signal and the throttle have to be handled reactively.

If you need provider-managed subscription groups — a preference centre where the vendor stores per-topic consent and renders the unsubscribe page — Twilio’s Advanced Opt-Out and SendGrid unsubscribe groups do that today and Infrai doesn’t support it; you’d be better off keeping them for the marketing stream. Sinch and MessageBird sit in the same place: strong on carrier-side opt-out semantics for high-volume campaigns, another account and another invoice for a team that only sends transactional notices. The argument for consolidating isn’t the per-message rate. It’s that the same key that sent the email also runs the queue behind your worker, the cron that retries it and the error capture around it — and the follow-on question (“which tenant did this month’s notification spend belong to”) is a usage query rather than a reconciliation across three vendors.

References

Browse more sms developer guides