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

Policy checks and hooks

Block sends with beforeSend, such as to suppressed recipients, and log or meter every attempt with redacted hooks.

beforeSend blocks messages, for example to people who opted out. hooks give you logs and metrics for every send without leaking phone numbers or message text.

Block sends with beforeSend

beforeSend runs once per logical send, after local validation and the idempotency check, before any provider request. Return { kind: "allow" } or { kind: "reject", reason }.

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" };
  },
});

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.

The callback receives { id, message, encoding, segments }: the logical send ID, your SmsSendInput as passed, and the segment estimate.

  • If beforeSend throws, the error propagates out of send() unchanged and nothing is sent.
  • With an idempotency store, a policy 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. SMS SDK keeps no suppression list for you. STOP, HELP, and opt-outs shows how to fill one from webhooks.

Observe sends with hooks

import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

export const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100001" })],
  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 });
    },
  },
});

This 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
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, such as +1********23), encoding, segments, and idempotencyKey when set. Hooks never receive the message body, credentials, or full phone numbers.

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

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

Logging errors

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

Next steps