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
beforeSendthrows, the error propagates out ofsend()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.