---
title: "Block and log sends"
description: "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

```ts
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](/sending/length-and-cost#block-long-messages). The SDK keeps no suppression list for you. [STOP, HELP, and opt-outs](/receiving/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

- [STOP, HELP, and opt-outs](/receiving/opt-outs)
- [createSmsClient reference](/reference/create-sms-client)
