> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Keyword auto-reply and opt-out: the matching model

> How keyword rules dispatch an inbound message: a per-tenant rule set evaluated against every non-consent message body, with four match types, creation-order precedence, and actions that reply, change consent, hand off to an agent, or run a flow.

# The keyword-rules matching model

A keyword rule is a per-tenant dispatch rule that reads the body of every inbound (MO) message and decides what one match should do: send an auto-reply, record an opt-in or opt-out, forward the turn to a deployed AI agent, or start an automation flow. The active rule set is consulted once per inbound message, and at most one rule fires.

This page defines the model itself — the match semantics, the evaluation order, the consent writes, and what a matched action dispatches. The two guides — [Auto-reply rules](/guides/keyword-auto-reply-rules) (form setup) and [Keyword rules worked examples](/guides/keyword-rules-recipes) (API walkthroughs) — are exercises over this model; this page is the semantics they assume.

## What a keyword rule is

Each rule is one row in your organization's rule table, created through the dashboard (Settings → Auto-Reply Rules) or `POST /api/v1/settings/keyword-rules`. The fields:

| Field                  | Role                                                                                                                                |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `keyword`              | The text (or, with `match_type: "regex"`, the pattern) to match against the message body.                                           |
| `match_type`           | How the keyword is compared: `exact`, `contains`, `starts_with`, or `regex`.                                                        |
| `channel`              | The one channel the rule watches: `sms`, `whatsapp`, `rcs`, `viber`, or `email`. A rule only evaluates messages on its own channel. |
| `action`               | What a match does: `reply`, `forward_agent`, `trigger_flow`, `opt-out`, or `opt-in`.                                                |
| `response_text`        | The body for a `reply` action; an optional confirmation for `opt-out` / `opt-in`.                                                   |
| `agent_id` / `flow_id` | The deployed agent or flow a `forward_agent` / `trigger_flow` match routes to.                                                      |
| `active`               | A rule with `active: false` is skipped at evaluation time.                                                                          |

Rules are scoped to your whole organization, not to a specific number: a rule fires on every reply-capable number you have on its channel. The select clause for evaluation is deliberately narrow — `active = true` and the message's channel — so only enabled rules on the right channel are ever compared.

Two small normalization rules shape what the stored `action` value means:

* The legacy aliases `forward` and `agent` both mean `forward_agent`; `flow` means `trigger_flow`. Both the read and the write path normalize, so rows saved with the aliases and rows saved with the canonical values behave identically.
* An `opt-out` rule writes suppression (see below); a `reply` rule sends a billed message. The executor compares against the canonical vocabulary above.

## Match semantics

The matcher folds case on both sides of the comparison, so `hours`, `HOURS`, and `Hours` are the same keyword on every match type. Leading and trailing whitespace on the message body is trimmed before an `exact` or `starts_with` comparison.

| Match type    | Comparison                                                                                                                          |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `exact`       | The trimmed body equals the keyword, case-folded. `HOURS` matches `hours` and `hours`, not `HOURS PLEASE`.                          |
| `contains`    | The keyword appears anywhere in the body, case-folded. No body-side trimming needed: `UNSUBSCRIBE` matches `please unsubscribe me`. |
| `starts_with` | The trimmed body begins with the keyword, case-folded. `HELP` matches `HELP me` and `  HELP`, not `PLEASE HELP`.                    |
| `regex`       | The keyword is a regular expression compiled case-insensitive and tested against the first 1,000 characters of the body.            |

Two properties of the matcher carry over from the implementation and matter for what you can rely on:

* **Case folding is Unicode-aware in both branches** — the same `toUpperCase` fold runs on the rule keyword and the message body for `exact`, `contains`, and `starts_with`. Accents stay distinct (`CAFE` ≠ `CAFÉ`); if your audience uses both, add one rule per variant or use a `regex` like `caf[eé]`.
* **A disabled or broken rule never blocks evaluation.** A stored `regex` pattern that fails to compile — or one the platform's ReDoS safety gate flags (nested quantifiers such as `(a+)+`, alternation inside a quantifier such as `(ab|a)*`) — is skipped and logged once, and the remaining rules still evaluate normally. The save path rejects those patterns outright, and the match path re-checks them, so a dangerous pattern can never stall message processing.

## Evaluation order and precedence

Evaluation is **first-match-wins in creation order, oldest first**. The active rules for the message's channel are loaded in creation order, each rule is checked in sequence, and the first rule whose match type succeeds terminates evaluation — one inbound message fires at most one rule.

Consequences to design around:

* **Specific beats general only when the specific rule is older.** An `exact: YES` created before a `contains: YES` wins the bare `YES`; reverse the creation order and the `contains` rule swallows every YES-form. To re-sequence, delete the broad rule and re-create it so it becomes the newest.
* **Ties break by creation order, too.** Two rules with the same keyword and match type both exist? The older one wins; the newer one never fires while the older stays active. The list endpoint returns **newest first** for management, so read a page from the bottom up to reconstruct the evaluation order.
* **The catch-all belongs last.** A broad `contains` rule or a `.*` regex created after your specific keywords only claims what nothing earlier matched.

Rules live in memory for a short while (a brief per-channel cache), and any create, update, or delete evicts it immediately — an edit takes effect on the very next inbound message.

## Opt-out and opt-in keywords: the consent pipeline

Before any of your rules run, the platform's built-in consent vocabulary (multilingual `STOP`/`START` and equivalents) is recognized and processed as an opt-out or opt-in directly — the rules layer is never consulted for those words, and a rule on `STOP` will never fire. Your own consent vocabulary is where keyword rules come in: a rule with `action: "opt-out"` or `action: "opt-in"` teaches the platform a *brand-specific* word the built-in layer doesn't know (`UNSUBSCRIBE`, `JOIN`, `JOIN THE CLUB`).

When an `opt-out` rule matches, the same canonical consent write the built-in `STOP` handling uses runs:

1. **The contact's channel preferences flip** — the per-channel flag is set to opted out, and the organization's **global opt-out** flag is set, so the write blocks sends to that contact on every channel, not only the one the message arrived on. A consent-history entry records the keyword and timestamp.
2. **An explicit consent record is appended** — the audit-grade evidence for the revoke, storable in the `consent_records` ledger that [Consent, opt-out, and suppression](/concepts/consent-and-suppression-model) describes.
3. **A suppression entry is created** — the send gate checks this before any dispatch, so every later send to that address is refused pre-dispatch with `RECIPIENT_OPTED_OUT` instead of being delivered and billed.

Then, if the rule has `response_text`, the confirmation goes out as the one outbound send an opt-out keyword is allowed to make. Opt-out and opt-in acknowledgements intentionally bypass the billed send path — confirmation of a consent change is free by platform design.

An `opt-in` rule matches the reverse: the contact's flags clear, a fresh consent record is written, and the matching suppression entries are **revoked** (marked revoked, never deleted) so the ledger keeps the full history. Pair every custom opt-out keyword with its opt-in counterpart so a contact always has a way back — nothing stops them from texting the words you tell them about, and only an explicit re-consent clears the block.

For how these stores interact with quiet hours, frequency caps, and duplicate suppression, see [Consent, opt-out, and suppression](/concepts/consent-and-suppression-model); the rules layer only decides *which* consent write happens.

## How a matched rule dispatches

The action decides the dispatch target:

* **`reply`** — the response text, rendered, is sent through the same billed outbound pipeline as any other message: wallet, quota, and usage gates all run. It appears in the Inbox thread and the Delivery log as an ordinary outbound message.
* **`forward_agent`** — the turn is handed to a deployed AI agent by `agent_id`, so an inbound keyword acts as a routing trigger into the agent-runtime (useful when the keyword signals a human-handoff intent, e.g. `SUPPORT`).
* **`trigger_flow`** — an automation flow by `flow_id` starts with the inbound message as its context; the run's context records the matched keyword, and the flow's history shows every keyword-triggered run.
* **`opt-out` / `opt-in`** — the consent pipeline above, plus an optional confirmation reply.

A `reply` body's text can interpolate the matched keyword, the sender's number, the channel, and the two endpoints with `{{...}}` tokens — see the [worked examples](/guides/keyword-rules-recipes#2-an-inbound-text-one-fired-rule-end-to-end) for the token list. An unrecognized token resolves to an empty string, never a literal `{{...}}` left in the outbound.

A message no rule matches falls through to the channel's other inbound handling — a deployed AI agent if the channel has one, otherwise the Inbox for a human. Keyword rules are first in that chain, so an accurate rule set decides earlier than the agent.

## Walkthrough: an inbound opt-out, evaluated pre-dispatch

Set the stage with one rule per match type (all on `sms`), created in this order:

```bash theme={null}
export ORBIT_API_KEY="dv_live_sk_…"
export ORBIT_API="https://api.orbit.devotel.io/api/v1"
```

Exact — match the whole trimmed body:

```bash theme={null}
curl -X POST "$ORBIT_API/settings/keyword-rules" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyword": "HOURS",
    "channel": "sms",
    "match_type": "exact",
    "action": "reply",
    "response_text": "We are open Mon–Fri, 9am–5pm ET."
  }'
```

Contains — the keyword anywhere in the body:

```bash theme={null}
curl -X POST "$ORBIT_API/settings/keyword-rules" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyword": "UNSUBSCRIBE",
    "channel": "sms",
    "match_type": "contains",
    "action": "opt-out",
    "response_text": "You are unsubscribed. Reply JOIN to start again."
  }'
```

Starts-with — the keyword at the front of the trimmed body:

```bash theme={null}
curl -X POST "$ORBIT_API/settings/keyword-rules" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyword": "SUPPORT",
    "channel": "sms",
    "match_type": "starts_with",
    "action": "forward_agent",
    "agent_id": "agent_…"
  }'
```

Now an inbound `please unsubscribe me` arrives on your SMS number. The evaluation runs pre-dispatch, before the message reaches a human's queue:

1. **Built-in consent layer first.** `please unsubscribe me` contains no built-in word — the built-in layer releases it to rules.
2. **Rules evaluate oldest-first.** The `HOURS` exact rule compares the trimmed body and fails. The `UNSUBSCRIBE` contains rule finds its keyword mid-body and matches; evaluation stops — the `SUPPORT` starts\_with rule is never checked.
3. **The matched action dispatches.** `opt-out` runs the consent pipeline: the contact's channel preferences flip to opted out globally, a consent record is appended, a suppression entry is created, and the confirmation — rendered from the rule's text — goes back over the same channel because the rule had `response_text`.

From then on, every send request naming that contact is refused with `RECIPIENT_OPTED_OUT` until a matching `opt-in` rule (or the Consent API) re-consents. Confirm in the [Opt-Outs view](/guides/opt-out-lists) — the record lands with method `keyword` and the matched word.

## Tenant-owned control

Keyword rules are your organization's own dispatch policy: which words trigger what, when the replies send, and what the replies say are all tenant-owned choices. The built-in STOP/START vocabulary aside, no rule exists until you create one. Recording consent changes through an opt-out keyword builds the audit trail, but as with any consent record it does not itself make a send lawful — you remain responsible for the initial consent capture and for message content.

## See also

* [Auto-reply rules](/guides/keyword-auto-reply-rules) — dashboard fields, scope, and setup
* [Keyword rules worked examples](/guides/keyword-rules-recipes) — match-type tables, precedence demos, and how to prove a rule fired
* [Consent, opt-out, and suppression](/concepts/consent-and-suppression-model) — the stores the opt-out/opt-in actions write
* [Custom Opt-Out Keyword Lists](/guides/opt-out-lists) — the built-in STOP/START vocabulary and brand-level aliases
* [Inbound message routing](/concepts/inbound-message-routing) — the separate priority-ordered routing engine for per-number targeting
* [Outbound send gating](/concepts/send-gating-and-quiet-hours) — the admission chain the suppression entry feeds
