Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Block and log sends

Block sends with beforeSend, such as to people who opted out, and log or meter every attempt with hooks that never see phone numbers or message text.

beforeSend blocks a message before it reaches a provider, for example when the recipient opted out. hooks give you logs and metrics for every send without leaking phone numbers or message text.

Block sends with beforeSend

import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
import { db } from "./db";

export const sms = createSmsClient({
  adapters: [
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100001",
    }),
  ],
  beforeSend: async ({ message }) => {
    if (await db.suppressions.has(message.to)) {
      return { kind: "reject", reason: "Recipient opted out." };
    }
    return { kind: "allow" };
  },
  hooks: {
    onAttempt: ({ id, to, attempt }) => {
      console.log("sms.attempt", { id, to, provider: attempt.provider, outcome: attempt.outcome, ms: attempt.durationMs });
    },
    onAccepted: ({ id, provider, providerId, segments }) => {
      console.log("sms.accepted", { id, provider, providerId, segments });
    },
    onFailure: ({ id, errorCode, retrySafe, attempts }) => {
      console.error("sms.failed", { id, errorCode, retrySafe, attempts: attempts.length });
    },
  },
});

beforeSend runs once per send, after local validation and the idempotency check, before any provider request. It receives { id, message, encoding, segments }: the SDK’s send ID, your input as passed, and the segment estimate. Return { kind: "allow" } or { kind: "reject", reason }.

A rejection throws PolicyRejectedError. Nothing is sent and fallback never runs. Your reason appears only on error.reason. It stays out of the error message, toJSON(), hooks, and the idempotency store, because it may name a person.

  • If beforeSend throws, that error comes out of send() unchanged and nothing is sent.
  • With an idempotency store, a rejection is stored as rejected, so the same key can send once the policy allows it.
  • It runs inside every send(), so keep it fast.

Use it for a suppression list, quiet hours, per-recipient rate limits, or a segment budget. The SDK keeps no suppression list for you. STOP, HELP, and opt-outs shows how to fill one from webhooks.

Log sends with hooks

The client above logs one sms.attempt line per provider request, then one sms.accepted or sms.failed line per send.

Hook When Payload beyond the shared fields
onAttempt After every provider request attempt: a SendAttempt with provider, outcome, status, code, and duration
onAccepted Once, when a send is accepted provider, providerId, attempts
onFailure Once, when a send ends in an error errorCode, retrySafe, attempts

Every payload has id, to masked as +1********23, encoding, segments, and idempotencyKey when set. Hooks never receive the message body, credentials, or full phone numbers.

The SDK ignores a hook that throws or rejects, and send() does not wait for hooks, so a broken logger cannot change a send’s outcome.

onFailure does not run for errors thrown before the send starts: local validation errors, idempotency replays and conflicts, and errors thrown by beforeSend. A beforeSend rejection does run it.

Log errors safely

SmsError.toJSON() returns a redacted object with name, code, message, retrySafe, attempts, and provider. Phone numbers in provider messages are masked, those messages are cut to 200 characters, and cause is never included. If your logger serializes whole objects, log error.toJSON() instead of the raw error.

Next steps