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