Handling Opt-Outs and Consent

This guide shows you how to keep consent state consistent between Sent and your own systems: mirror keyword opt-outs into your database, opt contacts out or back in through the API, detect consent-blocked sends, and apply a send window stricter than the platform's quiet hours.

Prerequisites:

Sent already enforces the consent gate at send time, so you do not need a pre-send opt-out check of your own. Compliance & Regulations describes everything the platform enforces automatically.

Mirror keyword opt-outs into your database

When a contact texts an opt-out keyword, Sent records the opt-out on the contact and fires a message.received webhook carrying the raw text. The webhook fires for every inbound message, keywords included. Match the keyword in your handler and update your own subscriber store:

const OPT_OUT_KEYWORDS = new Set(['STOP', 'CANCEL', 'UNSUBSCRIBE', 'QUIT', 'END']);
const OPT_IN_KEYWORDS = new Set(['START', 'UNSTOP', 'SUBSCRIBE']);

app.post('/webhooks/sent', async (req, res) => {
  // Acknowledge first; process after responding.
  res.sendStatus(200);

  const { field, event, payload } = req.body;
  if (field !== 'message' || event !== 'message.received') return;

  // payload.inbound_number is the contact's phone number.
  const keyword = (payload.text ?? '').trim().toUpperCase();

  if (OPT_OUT_KEYWORDS.has(keyword)) {
    // Update your own subscriber store.
    await db.subscribers.update(payload.inbound_number, {
      messagingConsent: false,
      optedOutAt: new Date(),
    });
  } else if (OPT_IN_KEYWORDS.has(keyword)) {
    await db.subscribers.update(payload.inbound_number, {
      messagingConsent: true,
      optedOutAt: null,
    });
  }
});

If you configured custom keywords in the dashboard (Compliance → Opt-Out Keywords), add them to the OPT_OUT_KEYWORDS and OPT_IN_KEYWORDS sets. Sent matches keywords exactly and case-insensitively: the entire trimmed message body must equal the keyword. Mirror that rule rather than substring-matching. The contact record's opt_out field is what Sent checks on every send; when in doubt, read it back with GET /v3/contacts?phone=....

Opt a contact out or in through the API

If you collect opt-outs on other surfaces (a web preference center, support tickets, email unsubscribes), write them to Sent so the send-time gate enforces them everywhere.

Find the contact

Look up the contact by phone number (URL-encode the + as %2B):

curl "https://api.sent.dm/v3/contacts?phone=%2B15551234567" \
  -H "x-api-key: YOUR_API_KEY"

The contact ID is in data.contacts[0].id.

Set the opt-out flag

curl -X PATCH "https://api.sent.dm/v3/contacts/8ba7b830-9dad-11d1-80b4-00c04fd430c8" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"opt_out": true}'

To restore consent, send {"opt_out": false}. Opt a contact back in only when you hold their documented consent; the API does not check how the opt-out was created. Opt-out is per contact, not per channel: setting the flag suppresses SMS, WhatsApp, and RCS alike.

A send to an opted-out contact is still accepted with 202; the message is then finalized as FILTERED with reason code ERR_CONSENT_BLOCKED, and a message.filtered webhook fires. Treat it as an expected policy outcome: do not retry, and reconcile your local consent state if it disagrees. Extend the same webhook handler:

// Inside the same webhook handler:
if (field === 'message' && event === 'message.filtered') {
  // payload.outbound_number is the recipient's phone number. FILTERED also
  // covers routing DENY rules, so confirm the contact's opt-out state
  // before mirroring it.
  const resp = await fetch(
    `https://api.sent.dm/v3/contacts?phone=${encodeURIComponent(payload.outbound_number)}`,
    { headers: { 'x-api-key': process.env.SENT_API_KEY } },
  );
  const { data } = await resp.json();
  const contact = data?.contacts?.[0];

  if (contact?.opt_out) {
    await db.subscribers.update(contact.format_e164, {
      messagingConsent: false,
      optedOutAt: new Date(),
    });
  }
}

For per-message detail, GET /v3/messages/{id} shows the FILTERED status; refer to the Error Catalog for ERR_CONSENT_BLOCKED remediation.

Apply a stricter send window

Sent's quiet-hours gate already holds sends that land inside a protected window for the destination country: the message is parked as SCHEDULED and released automatically, with a message.scheduled webhook on the hold. If your legal review requires a stricter window (for example, the US TCPA limits telephone solicitations to 8 AM–9 PM recipient local time, and several states go further), gate the send in your own scheduler:

// TCPA window: 8 AM-9 PM recipient local time (47 CFR § 64.1200(c)(1)).
// This check deliberately keeps a one-hour buffer inside the federal window
// (sending only 9 AM-8 PM) to absorb clock skew and stricter state rules.
function isInsideSendWindow(timeZone: string, now = new Date()): boolean {
  const hour = Number(
    new Intl.DateTimeFormat('en-US', {
      timeZone,
      hour: 'numeric',
      hourCycle: 'h23',
    }).format(now),
  );
  return hour >= 9 && hour < 20;
}

if (isInsideSendWindow(subscriber.timeZone)) {
  await sendMessage(subscriber);
} else {
  await queueForNextWindow(subscriber);
}

If the platform's quiet-hours rules are sufficient for you, skip this section and subscribe to message.scheduled webhooks to observe holds.

Verify your integration

  • Set opt_out: true on a test contact and send it a message: the API returns 202, and GET /v3/messages/{id} shows FILTERED shortly after.
  • Text STOP to your number from a test phone: a message.received webhook arrives with the text, and the contact's opt_out flag reads true on the next GET /v3/contacts/{id}.
  • Text START from the same phone: the flag returns to false.

On this page