---
status: shipped
---

# Slack templates

Status: shipped (#4741, #4746). Operators configure every Slack message CFX posts, to its own workspace
and to customers': **whether** it posts and **what** it says, through one template system for
both audiences. **Where** an internal message goes stays a separate routing table. Customers
keep their own `CustomerNotificationPreferencesService` choices on top.

## 1. Vocabulary

| Term         | Meaning                                                                                                                     |
| ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| **Audience** | `INTERNAL` (CFX's own workspace) or `CUSTOMER` (a customer's own channels)                                                  |
| **Key**      | A template key within an audience, e.g. `INTERNAL.ACH_BATCH`, `CUSTOMER.DEPOSIT`                                            |
| **Variant**  | One status a key posts for, e.g. `CREATED`; `*` when the key has no status axis                                             |
| **Template** | One `(audience, key, variant)`: a row in `b2b.slack_template` and its versions                                              |
| **Feed**     | A routing group for `INTERNAL` keys: `b2b.internal_slack_channel.feed` → a channel; `DEFAULT` fallback                      |
| **Registry** | Code-owned catalog (`@cfxlabsinc/slack-services/registry`): per variant its label, seeded status, variables, default blocks |
| **Blocks**   | An ordered list of Block Kit primitives whose text slots use the `{{…}}` grammar (§5)                                       |

## 2. Registry

`SLACK_TEMPLATES[audience][key] = { label, feed?, variants: { [variant]: definition } }`, in
display order. A definition carries `label`, `defaultStatus` (the status its row is seeded
with), `variables`, `axes`, `kinds`, `defaultTemplate` and `samples`. Only `INTERNAL` keys carry
`feed`; customer posts go to the customer's own channels.

- **Internal keys**, one per internal consumer: `ACH_BATCH`, `ACH_FILE`, `WIRE_BATCH`,
  `WIRE_FILE`, `BANK_TRANSACTION`, `BANK_INTERNAL_TRANSFER`, `WIRE_EMAIL`, `REDEMPTION`,
  `LEDGER_ACCOUNT_WITHDRAWAL`, `MANUAL_MINT`, `FEE_TRANSFER`, `AIPRISE_PROFILE`,
  `UTILA_TRANSACTION`, `EMAIL_SENT`, `AUTH_EMAIL_SENT`, and one customer-monitoring key per
  `slack-customer-events` consumer (`CUSTOMER_DEPOSIT` … `CUSTOMER_VIRTUAL_ACCOUNT`). Each key's
  feed shares its name, except `AUTH_EMAIL_SENT` (dashboard invites), which posts to
  `EMAIL_SENT`.
- **Customer keys**, one per customer-facing post in `slack-customer-events` (the
  `isHidden === false` branch): `DEPOSIT`, `WITHDRAWAL`, `LEDGER_TRANSFER`, `SWAP`,
  `ESCROW_TRANSFER`, `CARD_TRANSACTION`, `IDENTITY`, `ORGANIZATION`, `PAYMENT_INSTRUMENT`,
  `REWARD`. Each default reproduces the message the consumer posted before templates, which is
  its internal monitoring layout without the customer footer, so `templates/customer` derives
  each from its `CUSTOMER_*` key and throws at load if that footer ever changes shape. A
  customer key has the same variants as its monitoring key, all seeded `ACTIVE`: the consumers
  posted every status they handle to the customer. Fixtures captured from the old builders hold
  each default to today's post, except two old bugs the templates drop: an empty
  `{"type":"section"}` in a failed network ledger transfer with no accounts (Slack rejects the
  whole post) and a valueless `*Type:*` field on a claimed reward.
- Seeded: 26 internal keys with 74 variants, 10 customer keys with 35 variants.
- **Variants** follow the statuses each consumer posts today. Statuses a consumer used to skip as
  too noisy ("skip PENDING") are variants seeded `DISABLED`, so behaviour is unchanged at deploy
  and an operator can turn them on.
- Fixtures under `slack-services/test/fixtures/{legacy,customer}` hold each default to the old
  consumer's output, block for block.

## 3. Data model

Two tables in the `email_template` / `sms_template` shape.

**`b2b.slack_template`**: one row per registry variant, seeded by migration, never created at
run time. Adding a variant is a code change plus a migration adding its row;
`slackTemplate.integration.test.ts` holds table and registry equal.

| Column                                 | Contract                                                                 |
| -------------------------------------- | ------------------------------------------------------------------------ |
| `id` / `external_id`                   | Integer PK / `slacktpl_…`                                                |
| `audience`                             | `INTERNAL` \| `CUSTOMER`                                                 |
| `template_key` / `status_variant`      | A registry key / variant; `'*'` sentinel for no status axis              |
| `status`                               | `ACTIVE` \| `DISABLED`: the on/off switch                                |
| `live_version_id` / `draft_version_id` | NULL live posts the registry default; composite FKs to own versions only |
| `created_at` / `updated_at`            |                                                                          |

`UNIQUE (audience, template_key, status_variant)`; `live <> draft`; no soft delete, no hard
delete, no truncate.

**`b2b.slack_template_version`**, append-only: `blocks` jsonb (the `SlackTemplateContent`),
`version` (per template, trigger-assigned `max + 1`), `last_edited_by` (`adusr_…`),
`created_at`. An `AFTER INSERT` trigger moves the parent's `draft_version_id` to the new row.
Ids `slacktv_…`.

`b2b.internal_slack_channel` is unchanged and holds only routing: feed → channel.

## 4. Services and posting

- **`SlackTemplateService`** (read, cached): every row with its live and draft blocks is one
  `slack-template-search` entry, deleted on every write. `search` filters in memory;
  `getForPost(ref)` returns the row one post renders. A live version that no longer validates
  posts the registry default and logs `SLACK_TEMPLATE_INVALID template=… version=…`, once per
  version.
- **`SlackTemplateAdminService extends SlackTemplateService`**: `get`, `search`,
  `searchVersions`, `update({ id, data, actorId })` with exactly one of `{ blocks }` (validate,
  save a draft version), `{ status }` (on/off) or `{ liveVersion }` (the draft's number
  activates, an older one rolls back, `null` resets to the registry default), `delete` (discard
  the draft), and `renderTest`. Single actor; no activity hook (the dashboard records the audit
  row).
- **Internal posts**: `renderInternalSlackTemplate({ templateKey, statusVariant, variables })`
  reads the template and the channel of the key's registry feed from
  `InternalSlackChannelAdminService.getForFeed`. `DISABLED` → skip. On but no channel → throw,
  so a misconfigured route stays loud. A feed with no live row posts to `DEFAULT`; a `CUSTOMER_*`
  feed to `CUSTOMER_EVENTS`, never `DEFAULT`. Customer-monitoring posts use the customer bot's
  token, internal feeds the internal bot's.
- **Customer posts**: `slack-customer-events`' `SlackTemplatePoster.postCustomer({ channels,
templateKey, statusVariant, variables })` takes the customer's visible channels, already
  cleared by `CustomerNotificationPreferencesService`, reads the `CUSTOMER` template, and skips
  when it is `DISABLED`: one switch stops the post for every customer, on top of each customer's
  own preferences. A missing row throws (the migration seeds every variant, so a gap is a deploy
  fault). `postInternal` is the same poster's monitoring post.

## 5. Blocks

### 5.1 Primitives

A template's content is `{ blocks: SlackTemplateBlock[]; fallbackText: string }`, a curated
subset of Block Kit chosen because it always renders well:

| Primitive | Fields (text slots use the grammar)                           | Slack limits enforced           |
| --------- | ------------------------------------------------------------- | ------------------------------- |
| `header`  | `text`                                                        | ≤150 chars, plain text          |
| `section` | `text` (mrkdwn), optional `accessory` button                  | ≤3000 chars                     |
| `fields`  | `fields: { label, value }[]` rendered as a two-column section | ≤10 fields, each ≤2000 chars    |
| `divider` | —                                                             |                                 |
| `context` | `elements: string[]` (mrkdwn)                                 | ≤10 elements                    |
| `actions` | `buttons: { text, url, style?: "primary" \| "danger" }[]`     | ≤5 buttons, text ≤75, url ≤3000 |
| `image`   | `url`, `altText`                                              | allowlisted hosts               |

Every block also takes an optional `if` (a variable or `variable == "VALUE"`), so a block shows
only when it applies, e.g. a "Failure reason" section only for `FAILED`. ≤50 blocks per message.

### 5.2 Grammar and validation

The `{{var}}`, `{{#if var}}`, `{{#equals axis "VALUE"}}` grammar from email templates
(`@cfxlabsinc/text-templates`), plus `{{#each list}}…{{/each}}` for a variable the registry
declares of kind `list` (items `{ label, detail? }`). Inside the block only `{{label}}` and
`{{detail}}` resolve, `#each` does not nest, and the renderer shows at most 10 items then
`+ N more`. Email and SMS declare no lists, so their validators reject `#each`. The validator
checks: every variable is registered for the variant; every `#equals` value is a registered
axis value; a list is read only through `#each`; Slack limits above; button and image hosts
allowlisted (admin dashboard, block explorers, the MOVEUSD app); interpolated values are
escaped (`&`, `<`, `>`) so a payload cannot inject `<!channel>`, `<!here>` or links;
`<!channel>` / `<!here>` in authored text are rejected. One function serves save, preview and
live render.

### 5.3 Lifecycle

Save inserts a version and moves `draft_version_id`; activate moves `live_version_id` and
clears the draft when it is the one activated; rollback points live at an older version and
keeps any draft; reset clears live; discard clears the draft. Every version stays as history.

## 6. Admin surface

`/slack-templates` mirrors `/email-templates`' three levels:

1. **Key list** with audience tabs **Internal | Customer**: one row per key with its variant
   count, live/draft summary and three-valued enablement, **on / ◐ partially on / off**.
2. **Variant sheet** over the list: per-variant status switch, live and draft versions,
   `invalidReason`, row checkboxes with batch on/off, and a link to each editor.
3. **Editor** `/slack-templates/[key]/[id]`, a full page keyed by the `slacktpl_` id: a block
   builder in the spirit of Slack's Block Kit Builder. Left: a palette of the §5.1 primitives,
   the block list (add, reorder by drag or arrows, duplicate, delete) and a form per block;
   variable chips insert at the cursor and `#if` / `#equals` come from a picker. Right: a
   Slack-styled preview for a chosen sample case, plus the rendered Block Kit JSON. Actions: Save
   draft, Activate, Roll back, Reset to default, Discard draft, on/off, Send test. Versions tab
   with diff.

**Test send** publishes `slack.templateTest.requested` (`templateId`, `sampleCase`, `version?`;
no free text); `internal-slack-bot` renders the saved draft, else the live version, else the
default with registry samples and posts it, prefixed `[TEST]`, to the `TEMPLATE_TEST` feed's
channel. Customer templates test there too, never in a customer's channel.

`/internal-slack-channels` edits feed → channel only; each feed's panel lists its internal
templates read-only with links to `/slack-templates`.

Permissions: `slack-templates:r` / `:w` (`:w` includes on/off). Audit: every write records an
`admin.dashboard_user_activity` row on the `slacktpl_` id, typed `slackTemplate.draftSaved`,
`.activated`, `.rolledBack`, `.reset`, `.enabled`, `.disabled`, `.draftDiscarded` or
`.testPosted`.

## 7. SMS templates

The same model for the customer-branded SMS that `CustomerSmsNotificationSender` sends: the
escrow texts to a customer's counterparties, and the customer-team texts to the customer's own
dashboard members (§7.2).

- **Registry** (`notification-services/src/sms-templates`): one entry per `SmsNotification`
  template, in two groups. `ESCROW` holds the 8 escrow texts (`ESCROW_RECIPIENT_NOTIFIED` …
  `ESCROW_REQUEST_CANCELLED`), whose defaults reproduce the retired `buildBody` exactly.
  `CUSTOMER_TEAM` holds 25 `CUSTOMER_*` texts, one per email template and status (§7.2). Each
  entry carries its variables (derived from the union's fields; `?` = optional) and boolean axes
  (`isLargeAmount`, `isReversal`).
- **Tables**: `b2b.sms_template` (`template_key` unique, `status ACTIVE|DISABLED`,
  `live_version_id`, `draft_version_id`) and append-only `b2b.sms_template_version` (`body`,
  `version`, `last_edited_by`), same triggers as `slack_template` (§3). Absent row = default body, on.
- **Grammar**: `@cfxlabsinc/text-templates`, plus an `upper` filter (`{{upper senderName}}`)
  since copy uppercases names today. Validator: variables, link only from URL variables (no
  authored URLs), ≤480 chars (3 GSM segments) after worst-case interpolation, warning when the
  body leaves GSM-7 (emoji doubles segment cost), and the brand prefix must start the body
  (carrier rules for branded short-code traffic).
- **Send**: `CustomerSmsNotificationSender`, run only by `sms-notification-handler` (§7.1),
  reads the live template through a cached `SmsTemplateService.search` and renders it;
  `DISABLED` skips the send with an info log.
- **Admin**: `/sms-templates` mirrors `/email-templates`: the templates listed by group
  (Customer team, then Escrow) with an on/off switch, full-page editor with variable chips, a phone-style preview with segment and
  character counter per sample case, versions with diff and rollback, test send of the saved
  draft, else the live version, to a number the actor enters; unsaved edits must be saved first.
  Single actor. Permissions `sms-templates:r` / `:w`.

### 7.1 Sending from customer events

One Lambda, `sms-notification-handler`, sends every customer-branded SMS and is the only holder
of the Vibes and Twilio credentials. Like `email-notification-handler`, it subscribes to the
customer topic and decides what to text from the domain events the services already publish; no
service asks for an SMS.

```mermaid
flowchart LR
  A[EscrowTransferAdminService<br/>temporal-worker] -->|escrow.escrowTransfer.statusUpdated| T[(cfx-events)]
  B[EscrowTransferRequestService<br/>escrow-transfer-api] -->|escrow.escrowTransferRequest.statusUpdated| T
  T --> H[sms-notification-handler]
  C[/sms-templates test send<br/>admin-dashboard/] -->|lambda invoke| H
  H -->|live b2b.sms_template| V[Vibes US / Twilio intl]
  H -. transient failure .-> D[(DLQ)]
```

- **Triggers**: `SMS_TRIGGER_EVENTS` is the two escrow events (`ESCROW_SMS_EVENTS`) plus every
  email trigger (`CUSTOMER_TEAM_SMS_EVENTS`, derived from `EMAIL_EVENT_TEMPLATES`, §7.2). For an
  escrow event the handler rehydrates the row and builds the same payload the escrow services built before
  (`escrowSms.ts` in escrow-transfer-services, held byte-identical by a parity test):

  | Event and status     | Texted                         | Template                    |
  | -------------------- | ------------------------------ | --------------------------- |
  | transfer `CREATED`   | recipient, with the claim link | `ESCROW_RECIPIENT_NOTIFIED` |
  | transfer `CLAIMED`   | sender, by identity phone      | `ESCROW_CLAIMED`            |
  | transfer `CANCELLED` | recipient                      | `ESCROW_CANCELLED`          |
  | transfer `REJECTED`  | sender, by identity phone      | `ESCROW_REJECTED`           |
  | request `CREATED`    | recipient, with the pay link   | `ESCROW_REQUEST_CREATED`    |
  | request `SENT`       | sender (requester)             | `ESCROW_REQUEST_PAID`       |
  | request `REJECTED`   | sender (requester)             | `ESCROW_REQUEST_DECLINED`   |
  | request `CANCELLED`  | recipient                      | `ESCROW_REQUEST_CANCELLED`  |

  `EXPIRED` and `FAILED` text nobody, as before. A sender whose identity has no phone is skipped.

- **Links**: the create workflow mints the claim short URL before it writes `CREATED`, so the id
  is on the row when the event goes out; a transfer created without a `claimUrl` sends no text.
  `EscrowTransferRequestService.create` mints the pay short URL before the insert and stores it
  in `pay_short_url_id`. The handler composes both against `SHORT_URL_BASE_URL`.
- **New event**: `escrow.escrowTransferRequest.statusUpdated` (`CREATED`, `SENT`, `CANCELLED`,
  `REJECTED`) is a customer event like any other, so it also reaches customers' webhooks through
  the Outpost bridge and is documented on the webhook events page.
- **Idempotency**: the handler claims `event#<envelope id>`, then per recipient
  `send#<idempotencyKey>#<phone>` before each send, released only when that send throws
  transiently. A redelivery after a partial fan-out, or a republish under a new envelope id,
  never texts a recipient already sent. The keys are the ones the escrow services used
  (`<etr_…>:recipient-notified`, …), and Vibes still dedupes on `submitterMessageId` from them.
  The handler writes nothing to the escrow rows; it skips a transfer whose
  `claim_sms_sent_at` / `terminal_sms_sent_at` the old in-process sender stamped, so an event
  racing the deploy is not texted twice. Claims carry no lease (a DynamoDB conditional put with a
  30-day TTL), so every call is bounded instead: Vibes and Twilio abort after 10 s and a query
  after 8 s, well inside the 30 s function timeout, so a hung call throws and the claims release.
- **Errors**: a provider fault (network, timeout, 5xx, 429, credentials) throws, so SNS and Lambda retry
  and a persistent one parks in `cfx-sms-notification-handler-dlq-<stage>`. A provider 4xx
  (`VIBES_ERROR` / `TWILIO_ERROR`), a stored body that fails validation and a missing row are
  terminal: logged with `SMS_NOTIFICATION_ERROR` and dropped. A `DISABLED` template is skipped at
  info level. A retry logs `SMS_NOTIFICATION_RETRY`, which pages nobody (the DLQ-age alarm catches
  one that persists). The `SMS_NOTIFICATION_ERROR` metric filter drives
  `cfx-sms-notification-handler-errors-<stage>`.
- **Subscription**: no filter policy, as on every customer-topic consumer; the handler passes
  over non-trigger events before any claim.
- **Test send**: the admin dashboard holds no SMS credentials and publishes nothing. It invokes
  the handler synchronously (`lambda:InvokeFunction` on that one function) with
  `{ kind: "smsTemplateTestSend", templateVersionId, phoneNumber, renderCase, idempotencyKey }`;
  the handler checks the `smstv_…` version, renders it with the registry samples for the case and
  sends it prefixed `[TEST] `, even when the template is `DISABLED`, and returns the refusal code
  to the editor. No input carries free text, so a caller cannot text arbitrary copy through the
  short code.
- **Database**: `sms_notification_handler_ro`, in no group. Its migrations grant `SELECT` on the
  tables it reads and nothing else: SMS templates, escrow transfers and requests, identity, the
  customer read's customer, organization and workspace, and for §7.2 the rows email rehydrates
  (deposits, manual mints, withdrawals, transfers, ledger accounts and their Solana balance, card
  transactions and cards, payment instruments) and the dashboard roster (membership, user, role).

Runbook: [sms-notification-handler.md](../runbooks/sms-notification-handler.md).

### 7.2 Texting the customer's own team

Every event email covers also texts the customer's own dashboard members, once the customer opts
in. Same events, same gates, same audience rules as email; only the channel and the gate differ.

- **Events**: `CUSTOMER_TEAM_SMS_EVENTS` is `Object.keys(EMAIL_EVENT_TEMPLATES)`, imported from
  `@cfxlabsinc/notification-services/email-triggers` (the email map moved there so both handlers
  read one list). A new email trigger texts too, and the handler's tests fail until it maps to a
  template.
- **Gate**: the customer's `notifications.sms` (`{ userGroups, userIds, events }` on
  `b2b.customer_dashboard_workspace`), read by `CustomerNotificationPreferencesService.shouldSendSms`
  through the same event → toggle rules as email. The default is every event `DISABLED` and no
  recipients, so nothing texts until a customer chooses members and events on the customer
  dashboard's `/settings/company-notifications/sms` (permission `company-notifications/sms:w`,
  granted to every role holding `company-notifications/email:w`). The always-send statuses
  (`DEPOSITED`, `COMPLETED`, `SUCCEEDED`, `AUTHORIZED`) text whoever the customer chose, as email
  mails them.
- **Recipients**: `CustomerNotificationService.getSmsRecipients` returns the customer's ACTIVE
  members whose user is in `userIds` or whose role is in `userGroups`, at
  `customer_dashboard_user.data.phoneNumber` (E.164, from their profile), deduped by phone. No
  creator rule and no per-member preference. A member with no phone is skipped and counted in an
  info log line.
- **Rehydration**: the email builders' reads and gates, family by family (`customerTeam.ts`):
  wire and ACH deposits only, withdrawals on the four rails, no `FAILED` transfer, card
  authorizations and reversals only, the customer's own card, three payment-instrument types. A
  status email skips texts nobody. The row's current status must match the envelope's, so a
  redrive texts no status the row has moved past.
- **Templates** (`CUSTOMER_TEAM_TEMPLATES` in `customerTeamSms.ts`, one per family and status;
  every one reachable, a test holds both directions):

  | Family             | Statuses and templates                                                                            |
  | ------------------ | ------------------------------------------------------------------------------------------------- |
  | deposit, mint      | `PENDING` / `DEPOSITED` / `CANCELLED` → `CUSTOMER_DEPOSIT_{PENDING,COMPLETED,CANCELLED}`          |
  | withdrawal         | `PENDING` / `COMPLETED` / `RETURNED` → `CUSTOMER_WITHDRAWAL_*`                                    |
  | transfer           | `PENDING` / `SUCCEEDED` / `CANCELLED` → `CUSTOMER_TRANSFER_{PENDING,COMPLETED,CANCELLED}`         |
  | card transaction   | `PENDING` / `AUTHORIZED` / `COMPLETED` / `DECLINED` → `CUSTOMER_CARD_TRANSACTION_*`, `isReversal` |
  | identity           | `PENDING_VERIFICATION` / `ACTIVE` / `REVIEW` / `DISABLED` / `BLOCKED` → `CUSTOMER_IDENTITY_*`     |
  | organization       | `PENDING_VERIFICATION` / `ACTIVE` / `DISABLED` → `CUSTOMER_ORGANIZATION_*`                        |
  | ledger account     | created → `CUSTOMER_LEDGER_ACCOUNT_CREATED`                                                       |
  | payment instrument | `PENDING_VERIFICATION` / `ACTIVE` / `BLOCKED` → `CUSTOMER_PAYMENT_INSTRUMENT_*`                   |

  Defaults are one GSM-7 segment with no link (`MoveMoney: deposit of $1,250.00 to Operations
account completed`), and on: the preference gate, not the template switch, keeps them quiet.
  Every payload carries `dashboardUrl` (`APP_BASE_URL` plus the page the email links), the only
  link an operator may add.

- **Idempotency**: the envelope claim as for escrow, then per phone
  `send#<key>:<template>#<phone>`, and Vibes' `submitterMessageId` from
  `<key>:<template>#<phone>`, so dedupe never drops a teammate. `<key>` is the object id for the
  families whose statuses only move forward (deposits, mints, withdrawals, transfers, cards,
  ledger accounts), so a republish, or `deposit.deposit.created` followed by `statusUpdated` for
  the same `PENDING`, texts nobody twice. It is the envelope id for identities, organizations and
  payment instruments, whose status can return to one it held (`ACTIVE` → `BLOCKED` → `ACTIVE`):
  each return texts, as each emails. A redelivery after a partial fan-out texts only those owed.
- **Inherited gap**: `shouldSend` gates a manual mint's status update on `PROCESSING`, which the
  builder never sends (it sends on `COMPLETED`), and has no rule for `deposit.deposit.created`.
  So neither texts the customer-chosen recipients today, exactly as neither emails the
  customer-configured ones. Fixing the rule changes email too and is left to its own change.
- **Errors**: as for escrow, per recipient. A fault releases only that phone's claim and throws. A
  provider refusal drops that number with `SMS_NOTIFICATION_ERROR` and the fan-out continues. An
  unrenderable stored template drops the event with the token, since every send would fail alike.
  The two data-integrity throws email has (a transfer neither side of which the customer sees, a
  deposit with no quote) throw here too and park in the DLQ.

## 8. Delivery

| PR    | Scope                                                                                                                                                                                                                    |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| #4741 | `@cfxlabsinc/text-templates` (email moves onto it), `slack_template` tables for both audiences, `CUSTOMER_*` feed routing, every Slack consumer rendering through templates, `/slack-templates`, the AiPrise review card |
| #4746 | SMS templates (§7) and `/sms-templates`; SMS from customer events (§7.1): the escrow events → `sms-notification-handler`, the only holder of Vibes/Twilio creds                                                          |
