diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ff03cc4..8e0e2b5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,9 +1,14 @@ name: Validate plugin on: + workflow_dispatch: pull_request: push: branches: [main] + release: + types: [published] + schedule: + - cron: "17 9 * * 1" permissions: contents: read @@ -51,3 +56,35 @@ jobs: - name: Test utility fixture contracts run: python scripts/test_fixtures.py + + - name: Test Sent API contract regressions + run: python scripts/test_contracts.py + + live-contract-and-routing: + if: github.event_name == 'schedule' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + SENT_ROUTING_EVAL_MODEL: ${{ vars.SENT_ROUTING_EVAL_MODEL || 'gpt-5-mini' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install validators + run: python -m pip install -r requirements-dev.txt + + - name: Compare live Sent OpenAPI with manifest + run: python scripts/check_live_contract.py + + - name: Run model-backed routing evaluation + if: env.OPENAI_API_KEY != '' + run: python scripts/run_model_routing_eval.py + + - name: Explain skipped model evaluation + if: env.OPENAI_API_KEY == '' + run: echo "OPENAI_API_KEY is not configured; live contract drift still ran, but model routing evaluation was skipped." diff --git a/adapter-sources/claude/commands/mdr-analyze.md b/adapter-sources/claude/commands/mdr-analyze.md index a0a791b..53c68b1 100644 --- a/adapter-sources/claude/commands/mdr-analyze.md +++ b/adapter-sources/claude/commands/mdr-analyze.md @@ -1,13 +1,7 @@ --- -description: Analyze Sent Message Delivery Reports (MDRs) across SMS, WhatsApp, and RCS to find where a funnel is leaking +description: Analyze Sent message-delivery performance --- -Invoke the `messaging-performance-analyzer` skill. +Invoke the `messaging-performance-analyzer` skill with the user's request unchanged: -Begin by pinning the question. Ask: -1. Which channel is this about — SMS, WhatsApp, RCS, or a cross-channel comparison? -2. What's the specific concern? ("delivery dropped today", "this template underperforms in Brazil", "RCS not reaching T-Mobile recipients", "leads aren't replying") -3. The cohort: which template / campaign / agent, which country/countries, which tenant(s), which time window? -4. The data source: CSV/JSON dump, paste, log file path, or a database the user can query? - -If the question is too vague to answer (e.g. "how are we doing"), narrow it before pulling data. Always require a defined cohort and a named channel. For RCS funnels, also ask whether SMS fallback is in play so the analysis can separate it. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/rcs-onboard.md b/adapter-sources/claude/commands/rcs-onboard.md index 243c2d8..557da4d 100644 --- a/adapter-sources/claude/commands/rcs-onboard.md +++ b/adapter-sources/claude/commands/rcs-onboard.md @@ -1,14 +1,7 @@ --- -description: Create + verify an RCS Business Messaging (RBM) agent for sending RCS via Sent, including capability + fallback decisions +description: Prepare an RCS launch through Sent --- -Invoke the `rcs-agent-onboarding` skill. +Invoke the `rcs-agent-onboarding` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh agent creation, or are you debugging a stuck verification / launch review? -2. The tenant's public brand identity (display name, logo, brand color) and verified domains -3. The use case (transactional, OTP, customer care, promotional, multi-use) -4. Which capabilities will the agent actually use (suggested replies, suggested actions, rich cards, carousel, attachments)? -5. The SMS fallback policy when a recipient isn't RCS-capable (`sms` via the same Sender Profile, `none`, or application-routed) - -Then walk the agent identity setup, capability declaration, verification + launch-review path, and per-carrier rollout expectations. Flag anything that mismatches the tenant's public brand or requires re-review. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/sender-plan.md b/adapter-sources/claude/commands/sender-plan.md index e69c0b4..62dd067 100644 --- a/adapter-sources/claude/commands/sender-plan.md +++ b/adapter-sources/claude/commands/sender-plan.md @@ -1,14 +1,7 @@ --- -description: Plan multi-tenant architecture around Sent's Sender Profile — data model, channel routing across SMS/WhatsApp/RCS, rate limits, lifecycle +description: Design Sent Sender Profile architecture --- -Invoke the `sender-profile-architect` skill. +Invoke the `sender-profile-architect` skill with the user's request unchanged: -Begin by asking: -1. Which channels does the tenant need (SMS, WhatsApp, RCS, or a mix)? -2. Tenant scale — current and 12-month projection (10? 1,000? 100,000?) -3. Isolation requirements — any regulated tenants (PHI, government, data-residency)? -4. Existing stack — datastore, queue, cache, language/framework -5. The specific design question — data model, webhook routing, rate limits, or lifecycle? - -Then walk the Sender Profile framing and produce a concrete model / sequence / state-machine sketch tailored to the answers. Recommend pooled by default; recommend silos only when the answers justify them. Reference the channel-specific onboarding skills (`sms-10dlc-registration`, `waba-embedded-signup`, `rcs-agent-onboarding`) for the parts that fall outside the architecture concern. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/sent.md b/adapter-sources/claude/commands/sent.md index 2540d8d..8b14399 100644 --- a/adapter-sources/claude/commands/sent.md +++ b/adapter-sources/claude/commands/sent.md @@ -1,7 +1,7 @@ --- -description: Sent meta dispatcher — routes to the right SMS/WhatsApp/RCS skill on Sent +description: Route a request through the Sent skill set --- -Invoke the `sent` skill to triage which Sent workflow the user wants and route to the corresponding direct-operation or specialist skill. +Invoke the `sent` skill with the user's request unchanged: -Ask the clarifying questions in that skill before routing. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/sms-register.md b/adapter-sources/claude/commands/sms-register.md index 56c4900..879fbcf 100644 --- a/adapter-sources/claude/commands/sms-register.md +++ b/adapter-sources/claude/commands/sms-register.md @@ -1,14 +1,7 @@ --- -description: Register a brand + campaign with The Campaign Registry (TCR) for 10DLC A2P SMS on Sent +description: Prepare a Sent US 10DLC registration --- -Invoke the `sms-10dlc-registration` skill. +Invoke the `sms-10dlc-registration` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh registration, or are you debugging a rejection / low vetting score? -2. The legal entity that's sending (parent company vs sub-brand) and its strongest external identifier (EIN, DUNS, GIIN, LEI) -3. The specific use case for this campaign — transactional notifications, 2FA, customer care, marketing, mixed? -4. Expected volume per day and which carriers (T-Mobile / AT&T / Verizon / all)? -5. The opt-in mechanism (how recipients agreed to receive these messages) - -Then walk the Brand → Campaign sequence, pick the narrowest accurate TCR use case, produce sample messages that match production traffic and include opt-out language, and call out any attributes likely to trigger a downgrade. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/template-ui.md b/adapter-sources/claude/commands/template-ui.md index 5d6139e..dbe92c6 100644 --- a/adapter-sources/claude/commands/template-ui.md +++ b/adapter-sources/claude/commands/template-ui.md @@ -1,13 +1,7 @@ --- -description: Design and implement the tenant-facing UI for submitting WhatsApp templates to Sent +description: Design a Sent template-builder interface --- -Invoke the `template-builder-ui` skill. +Invoke the `template-builder-ui` skill with the user's request unchanged: -Begin by asking: -1. The frontend framework (React, Vue, Svelte, etc.) and existing design system -2. Whether the editor is a greenfield build or extending something -3. Which validation layers already exist on the backend (so the UI doesn't duplicate or conflict) -4. Realtime channel availability (WebSocket, SSE) for template-status updates - -Then walk the editor anatomy (category first, then components, then live preview), produce component scaffolds, and surface the policy rules the UI must encode. Cross-reference `waba-template-author` for category rules. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/waba-auth.md b/adapter-sources/claude/commands/waba-auth.md index f57ee08..b83a44a 100644 --- a/adapter-sources/claude/commands/waba-auth.md +++ b/adapter-sources/claude/commands/waba-auth.md @@ -1,13 +1,7 @@ --- -description: Implement Meta's WhatsApp Embedded Signup flow end-to-end (or debug a stuck one) +description: Guide WABA onboarding through Sent --- -Invoke the `waba-embedded-signup` skill. +Invoke the `waba-embedded-signup` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh integration or debugging an existing one? -2. Tech Provider / Solution Partner status with Meta? -3. Existing Meta app state — products added (WhatsApp, FBL)? `config_id` created? Redirect URIs allowlisted? -4. If debugging, which step is stuck? (Dialog won't open, code exchange failing, webhooks not firing, etc.) - -Then walk the prerequisite checklist, the launch + exchange + register + subscribe sequence, and persist the SPS state at each step. For debugging, jump to the "Common Stuck States" table and triage from there. +$ARGUMENTS diff --git a/adapter-sources/claude/commands/waba-template.md b/adapter-sources/claude/commands/waba-template.md index 2f4c9eb..5e7806f 100644 --- a/adapter-sources/claude/commands/waba-template.md +++ b/adapter-sources/claude/commands/waba-template.md @@ -1,13 +1,7 @@ --- -description: Author a WhatsApp Business API template and classify it as utility, marketing, or authentication per Meta's policy +description: Author a WhatsApp template for Sent --- -Invoke the `waba-template-author` skill. +Invoke the `waba-template-author` skill with the user's request unchanged: -Begin by asking the user: -1. The use case in one sentence ("After a customer pays, confirm the order…") -2. The recipient's relationship to the event (did they trigger it, or is the business reaching out cold?) -3. Whether this is a code / OTP (authentication template) or a regular template -4. The target language (BCP-47 code, e.g. `en_US`, `pt_BR`) - -Then walk the decision tree, draft the components, and produce a submission-ready Cloud API payload. Flag any wording or imagery that's likely to flip the category at Meta's review. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/mdr-analyze.md b/claude-plugins/sent/.claude/commands/mdr-analyze.md index a0a791b..53c68b1 100644 --- a/claude-plugins/sent/.claude/commands/mdr-analyze.md +++ b/claude-plugins/sent/.claude/commands/mdr-analyze.md @@ -1,13 +1,7 @@ --- -description: Analyze Sent Message Delivery Reports (MDRs) across SMS, WhatsApp, and RCS to find where a funnel is leaking +description: Analyze Sent message-delivery performance --- -Invoke the `messaging-performance-analyzer` skill. +Invoke the `messaging-performance-analyzer` skill with the user's request unchanged: -Begin by pinning the question. Ask: -1. Which channel is this about — SMS, WhatsApp, RCS, or a cross-channel comparison? -2. What's the specific concern? ("delivery dropped today", "this template underperforms in Brazil", "RCS not reaching T-Mobile recipients", "leads aren't replying") -3. The cohort: which template / campaign / agent, which country/countries, which tenant(s), which time window? -4. The data source: CSV/JSON dump, paste, log file path, or a database the user can query? - -If the question is too vague to answer (e.g. "how are we doing"), narrow it before pulling data. Always require a defined cohort and a named channel. For RCS funnels, also ask whether SMS fallback is in play so the analysis can separate it. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/rcs-onboard.md b/claude-plugins/sent/.claude/commands/rcs-onboard.md index 243c2d8..557da4d 100644 --- a/claude-plugins/sent/.claude/commands/rcs-onboard.md +++ b/claude-plugins/sent/.claude/commands/rcs-onboard.md @@ -1,14 +1,7 @@ --- -description: Create + verify an RCS Business Messaging (RBM) agent for sending RCS via Sent, including capability + fallback decisions +description: Prepare an RCS launch through Sent --- -Invoke the `rcs-agent-onboarding` skill. +Invoke the `rcs-agent-onboarding` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh agent creation, or are you debugging a stuck verification / launch review? -2. The tenant's public brand identity (display name, logo, brand color) and verified domains -3. The use case (transactional, OTP, customer care, promotional, multi-use) -4. Which capabilities will the agent actually use (suggested replies, suggested actions, rich cards, carousel, attachments)? -5. The SMS fallback policy when a recipient isn't RCS-capable (`sms` via the same Sender Profile, `none`, or application-routed) - -Then walk the agent identity setup, capability declaration, verification + launch-review path, and per-carrier rollout expectations. Flag anything that mismatches the tenant's public brand or requires re-review. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/sender-plan.md b/claude-plugins/sent/.claude/commands/sender-plan.md index e69c0b4..62dd067 100644 --- a/claude-plugins/sent/.claude/commands/sender-plan.md +++ b/claude-plugins/sent/.claude/commands/sender-plan.md @@ -1,14 +1,7 @@ --- -description: Plan multi-tenant architecture around Sent's Sender Profile — data model, channel routing across SMS/WhatsApp/RCS, rate limits, lifecycle +description: Design Sent Sender Profile architecture --- -Invoke the `sender-profile-architect` skill. +Invoke the `sender-profile-architect` skill with the user's request unchanged: -Begin by asking: -1. Which channels does the tenant need (SMS, WhatsApp, RCS, or a mix)? -2. Tenant scale — current and 12-month projection (10? 1,000? 100,000?) -3. Isolation requirements — any regulated tenants (PHI, government, data-residency)? -4. Existing stack — datastore, queue, cache, language/framework -5. The specific design question — data model, webhook routing, rate limits, or lifecycle? - -Then walk the Sender Profile framing and produce a concrete model / sequence / state-machine sketch tailored to the answers. Recommend pooled by default; recommend silos only when the answers justify them. Reference the channel-specific onboarding skills (`sms-10dlc-registration`, `waba-embedded-signup`, `rcs-agent-onboarding`) for the parts that fall outside the architecture concern. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/sent.md b/claude-plugins/sent/.claude/commands/sent.md index 2540d8d..8b14399 100644 --- a/claude-plugins/sent/.claude/commands/sent.md +++ b/claude-plugins/sent/.claude/commands/sent.md @@ -1,7 +1,7 @@ --- -description: Sent meta dispatcher — routes to the right SMS/WhatsApp/RCS skill on Sent +description: Route a request through the Sent skill set --- -Invoke the `sent` skill to triage which Sent workflow the user wants and route to the corresponding direct-operation or specialist skill. +Invoke the `sent` skill with the user's request unchanged: -Ask the clarifying questions in that skill before routing. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/sms-register.md b/claude-plugins/sent/.claude/commands/sms-register.md index 56c4900..879fbcf 100644 --- a/claude-plugins/sent/.claude/commands/sms-register.md +++ b/claude-plugins/sent/.claude/commands/sms-register.md @@ -1,14 +1,7 @@ --- -description: Register a brand + campaign with The Campaign Registry (TCR) for 10DLC A2P SMS on Sent +description: Prepare a Sent US 10DLC registration --- -Invoke the `sms-10dlc-registration` skill. +Invoke the `sms-10dlc-registration` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh registration, or are you debugging a rejection / low vetting score? -2. The legal entity that's sending (parent company vs sub-brand) and its strongest external identifier (EIN, DUNS, GIIN, LEI) -3. The specific use case for this campaign — transactional notifications, 2FA, customer care, marketing, mixed? -4. Expected volume per day and which carriers (T-Mobile / AT&T / Verizon / all)? -5. The opt-in mechanism (how recipients agreed to receive these messages) - -Then walk the Brand → Campaign sequence, pick the narrowest accurate TCR use case, produce sample messages that match production traffic and include opt-out language, and call out any attributes likely to trigger a downgrade. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/template-ui.md b/claude-plugins/sent/.claude/commands/template-ui.md index 5d6139e..dbe92c6 100644 --- a/claude-plugins/sent/.claude/commands/template-ui.md +++ b/claude-plugins/sent/.claude/commands/template-ui.md @@ -1,13 +1,7 @@ --- -description: Design and implement the tenant-facing UI for submitting WhatsApp templates to Sent +description: Design a Sent template-builder interface --- -Invoke the `template-builder-ui` skill. +Invoke the `template-builder-ui` skill with the user's request unchanged: -Begin by asking: -1. The frontend framework (React, Vue, Svelte, etc.) and existing design system -2. Whether the editor is a greenfield build or extending something -3. Which validation layers already exist on the backend (so the UI doesn't duplicate or conflict) -4. Realtime channel availability (WebSocket, SSE) for template-status updates - -Then walk the editor anatomy (category first, then components, then live preview), produce component scaffolds, and surface the policy rules the UI must encode. Cross-reference `waba-template-author` for category rules. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/waba-auth.md b/claude-plugins/sent/.claude/commands/waba-auth.md index f57ee08..b83a44a 100644 --- a/claude-plugins/sent/.claude/commands/waba-auth.md +++ b/claude-plugins/sent/.claude/commands/waba-auth.md @@ -1,13 +1,7 @@ --- -description: Implement Meta's WhatsApp Embedded Signup flow end-to-end (or debug a stuck one) +description: Guide WABA onboarding through Sent --- -Invoke the `waba-embedded-signup` skill. +Invoke the `waba-embedded-signup` skill with the user's request unchanged: -Begin by asking: -1. Is this a fresh integration or debugging an existing one? -2. Tech Provider / Solution Partner status with Meta? -3. Existing Meta app state — products added (WhatsApp, FBL)? `config_id` created? Redirect URIs allowlisted? -4. If debugging, which step is stuck? (Dialog won't open, code exchange failing, webhooks not firing, etc.) - -Then walk the prerequisite checklist, the launch + exchange + register + subscribe sequence, and persist the SPS state at each step. For debugging, jump to the "Common Stuck States" table and triage from there. +$ARGUMENTS diff --git a/claude-plugins/sent/.claude/commands/waba-template.md b/claude-plugins/sent/.claude/commands/waba-template.md index 2f4c9eb..5e7806f 100644 --- a/claude-plugins/sent/.claude/commands/waba-template.md +++ b/claude-plugins/sent/.claude/commands/waba-template.md @@ -1,13 +1,7 @@ --- -description: Author a WhatsApp Business API template and classify it as utility, marketing, or authentication per Meta's policy +description: Author a WhatsApp template for Sent --- -Invoke the `waba-template-author` skill. +Invoke the `waba-template-author` skill with the user's request unchanged: -Begin by asking the user: -1. The use case in one sentence ("After a customer pays, confirm the order…") -2. The recipient's relationship to the event (did they trigger it, or is the business reaching out cold?) -3. Whether this is a code / OTP (authentication template) or a regular template -4. The target language (BCP-47 code, e.g. `en_US`, `pt_BR`) - -Then walk the decision tree, draft the components, and produce a submission-ready Cloud API payload. Flag any wording or imagery that's likely to flip the category at Meta's review. +$ARGUMENTS diff --git a/claude-plugins/sent/skills/messaging-performance-analyzer/SKILL.md b/claude-plugins/sent/skills/messaging-performance-analyzer/SKILL.md index 8421fef..c7b0d4d 100644 --- a/claude-plugins/sent/skills/messaging-performance-analyzer/SKILL.md +++ b/claude-plugins/sent/skills/messaging-performance-analyzer/SKILL.md @@ -44,7 +44,7 @@ Restate the user’s exact question as a measurable comparison. “WhatsApp is b Capture these dimensions before calculating anything: profile or sender identity, template ID/name, channel, country, send window, recipient segment, and whether fallback or multi-channel broadcast was requested. -**Example.** If a user says “RCS fallback stopped working,” define the cohort as messages sent with `channel: ["rcs", "sms"]` during the affected window, then compare RCS statuses, SMS fallback statuses, and duplicate recipient/channel pairs separately. +**Example.** If a user says “RCS fallback stopped working,” define the cohort as sends that omitted `channel` or used `channel: ["sent"]`, then compare the selected `payload.channel` and message activities. Analyze any explicit multi-channel arrays separately as broadcasts. ### 2. Build cohorts from Sent message IDs @@ -79,7 +79,7 @@ SMS, WhatsApp, and RCS fail differently. Do not average them together unless the |---|---|---| | SMS | Country, sender/profile, 10DLC campaign, opt-out, carrier family | Compliance status, brand/campaign readiness, opt-out logs, throughput patterns. | | WhatsApp | Template, language, category, recipient country, quality/tier symptoms | Template status, read receipts, conversation window, Meta-side errors if present. | -| RCS | Agent readiness, fallback behavior, capability gaps, rich content rendering | Sent RCS setup status, fallback SMS results, capability/error details if present. | +| RCS | Agent readiness, automatic routing, pinned-channel failures, text/suggestion-chip rendering | Sent RCS setup status, selected route, and exact activity/error details. | ### 6. Quantify impact before recommending fixes @@ -101,7 +101,7 @@ Do not label a campaign “carrier filtered” from a small sample without compa Do not treat `READ` as a universal stage. Sent documents read receipts for WhatsApp and RCS; SMS generally does not support read receipts. -Do not collapse RCS fallback into SMS delivery. For `channel: ["rcs", "sms"]`, count RCS attempts and SMS attempts separately, then report recipient-level success if the user asks for it. +Do not mistake broadcast for fallback. Omitted `channel` or `["sent"]` enables automatic routing; one explicit channel pins delivery; multiple explicit values create separate messages. Count every returned `message_id` once and report the selected channel. ## Verification checklist diff --git a/claude-plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md b/claude-plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md index 833d844..a24bd47 100644 --- a/claude-plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md +++ b/claude-plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md @@ -85,9 +85,11 @@ If `DELIVERED` is healthy and `READ` is low across all cohorts, the cause is alm ### Symptom: RCS funnel "looks broken" -RCS is two funnels stitched together. Capability check happens before delivery; most "RCS broken" reports are actually "the audience isn't RCS-capable." +RCS routing and delivery are separate stages. Capability selection happens before delivery; many "RCS broken" reports are audiences that were not routed to RCS. -- If the Sender Profile uses fallback (`"channel": ["rcs", "sms"]`), the SMS fallback leg has its own `message_id` and its own lifecycle. Count separately. Never roll fallback SMS into RCS delivery. +- Omitted `channel` or `["sent"]` enables automatic routing. Inspect the returned message record and `payload.channel` to see what Sent selected. +- `["rcs"]` pins the send to RCS and is the cleanest cohort for isolating an RCS launch or payload problem. +- Multiple explicit channels are broadcast and create separate message IDs. Count them separately and never call one leg fallback. - Per-carrier RCS approval is real — an agent can be launched on one carrier and not on another. Symptoms scoped to one carrier point at agent state; hand off to `rcs-agent-onboarding`. ## Cross-skill handoff matrix diff --git a/claude-plugins/sent/skills/rcs-agent-onboarding/SKILL.md b/claude-plugins/sent/skills/rcs-agent-onboarding/SKILL.md index 4570ec1..1aa3df6 100644 --- a/claude-plugins/sent/skills/rcs-agent-onboarding/SKILL.md +++ b/claude-plugins/sent/skills/rcs-agent-onboarding/SKILL.md @@ -1,155 +1,88 @@ --- name: rcs-agent-onboarding -description: Guides RCS/RBM onboarding for Sent customers by preparing agent identity, launch evidence, carrier-approval handoff, SMS fallback, and post-launch verification. Use when a user says RCS agent, RBM, rich business messaging, carrier launch, RCS approval, RCS fallback, branded messages, agent verification, capability check, or wants to send RCS through Sent. +description: Guides current Sent RCS and RBM onboarding, launch evidence, carrier approval, text and suggestion-chip templates, Sender Profile readiness, and safe routing. Use for RCS launch, fallback, pinned-channel tests, or broadcast prevention. --- - +Sent RCS setup is not self-service. Sent and carrier approval are required. Prepare a complete launch packet, hand it to Sent, and verify the resulting Sender Profile with controlled messages. -# RCS agent onboarding +## Current capability boundary -## Overview +Current Sent RCS supports: -Use this skill to prepare a Sent customer for RCS launch without inventing a self-service provisioning flow. Sent’s public channel setup guidance says RCS setup is initiated through Sent, requires one-time carrier approval, and is not self-service. The agent’s job is to collect clean launch evidence, design fallback behavior, confirm profile/channel readiness, and create a verification plan for the first production sends. +- text content; and +- up to four suggestion chips. -RCS onboarding touches three separate layers. Sent owns the unified messaging API and fallback behavior. Google RBM and carriers own brand/agent review and launch approval. The customer owns brand assets, use-case clarity, consent, and support readiness. Keep those boundaries explicit. +Rich cards, carousels, and media attachments are roadmap features, not current Sent workflows. Do not request them as launch requirements, expose them as current template-builder controls, or declare them as active agent capabilities. -## When to use +## Routing semantics -Use this skill when the request mentions RCS, RBM, RCS agent, carrier launch, branded messaging, rich card, carousel, SMS fallback from RCS, or RCS approval. Use it for launch preparation, evidence gathering, fallback decisions, and post-launch smoke tests. +Channel selection on `POST /v3/messages` is not an ordered fallback list. -Do not use this skill for live delivery-rate analysis after launch; use `messaging-performance-analyzer`. Do not use it to register US SMS compliance; use `sms-10dlc-registration`. Do not promise direct Graph/RBM API provisioning unless the user confirms they operate the external RBM account outside Sent. +| Request | Behavior | +| --- | --- | +| Omit `channel` | Automatic Sent routing with fallback. | +| `channel: ["sent"]` | Explicit automatic Sent routing with fallback. | +| `channel: ["rcs"]` | Pinned RCS only; no cross-channel fallback. | +| Two or more explicit channel values | Broadcast: one separately created and billable message per recipient/channel pair. | -## Source-of-truth boundaries +Never put RCS and SMS together in an explicit array to describe fallback. Use omitted `channel` or `["sent"]` for automatic routing. Use explicit arrays only when broadcast is intended and confirmed. -| Topic | Treat as | Action | -|---|---|---| -| Sent API sending | Sent API fact | Use `POST /v3/messages` with templates and channel arrays. | -| RCS setup path | Sent documentation fact | Tell the user RCS setup is initiated by contacting Sent and requires approval. | -| SMS fallback | Sent documentation fact | Use Sent’s fallback behavior and explicit `channel: ["rcs", "sms"]` where appropriate. | -| Google RBM agent fields | External platform context | Collect assets and evidence, but do not claim Sent exposes those fields. | -| Per-carrier launch states | External platform context | Track approval evidence from Sent/Google/carriers; do not invent Sent status fields. | -| Rich-card rendering | Runtime evidence | Verify with test sends and message activities after setup is active. | +## Onboarding workflow -## Process +### 1. Define the launch use case -### 1. Classify the requested launch +Collect brand, audience, countries, consent source, message purpose, support path, estimated volume, and whether automatic fallback is required. Keep examples synthetic and within current text/chip capabilities. -Start by asking what the RCS agent will do, who receives the messages, and whether SMS fallback is required. The use case should be concrete enough for carrier review and template design. +### 2. Verify Sender Profile readiness -A good launch statement names the brand, audience, consent source, message types, support contact, and fallback behavior. A weak launch statement says only “we want RCS for marketing” or “we need branded SMS.” +Record the v3 profile UUID. Do not use legacy `x-sender-id` as v3 authentication. Choose a profile-specific API key or an organization API key with `x-profile-id`; only organization keys may use that header. -**Example.** “Acme Logistics wants RCS order updates for US consumers who opted in at checkout. Messages include shipment confirmation, delivery window changes, and support links. If RCS is unavailable, send the SMS version through the same Sent profile.” +If automatic routing may select US SMS, complete the appropriate 10DLC/compliance work first. An approved RCS agent does not make an SMS route compliant. -### 2. Build the RCS evidence packet +### 3. Prepare the evidence packet -Collect review-ready evidence before involving Sent. This reduces approval loops and prevents the agent from submitting vague brand claims. +Use [references/rcs-launch-evidence-packet.md](references/rcs-launch-evidence-packet.md). Include: -| Evidence | What to collect | Why it matters | -|---|---|---| -| Brand identity | Legal name, public brand name, website, logo, brand color, description | Reviewers compare the agent identity to the live business. | -| Contact and support | Support email, support phone, help URL, privacy policy | RCS users need visible ways to identify and contact the sender. | -| Use case | Transactional, OTP, marketing, customer care, or mixed use | Approval and fallback design depend on intent and consent. | -| Consent | Opt-in path, screenshot/URL, privacy policy, opt-out wording | Carriers need proof that recipients expect the messages. | -| Message examples | Representative plain-text and rich examples | Rich content must match the declared use case and brand. | -| SMS fallback | Equivalent SMS copy and approved SMS sender/compliance status | Fallback fails if SMS compliance is not ready. | +- consumer-facing brand name and website; +- logo and brand color; +- privacy policy and terms; +- support contacts; +- clear use case and consent flow; +- representative text messages; +- zero-to-four suggestion chips per message; +- target markets and requested timeline; +- automatic-routing or pinned-RCS test intent. -### 3. Check Sent profile and SMS fallback readiness +### 4. Hand off to Sent -Confirm that the customer has a Sender Profile in the Sent dashboard or through `/v3/profiles`. The dashboard walkthrough shows Sender Profiles with a display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The OpenAPI confirms profile creation, retrieval, update, and completion endpoints. +Because setup is not self-service, request Sent initiation and carrier approval. Do not fabricate RBM console clicks, public provisioning endpoints, capability declaration APIs, or carrier-approval status endpoints. -If the launch requires US SMS fallback, verify that the SMS side is compliant before RCS goes live. Sent’s channel setup guide recommends using the same phone number across SMS, WhatsApp, and RCS where possible, but fallback must still have a valid SMS route and compliance posture. +### 5. Build current templates -**Example fallback request.** After Sent confirms RCS is configured, a customer can request an RCS-first send with SMS fallback/broadcast semantics using a channel array such as: +Use Sent's template `definition` contract. RCS may have a complete `definition.body.rcs` override. Keep the RCS override text-based and limit suggestions to four. The `multiChannel` body remains required for template portability; routing fallback is still chosen at send time. -```json -{ - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } -} -``` +### 6. Test deliberately -Explain that Sent may create separate messages for each recipient/channel pair when multiple channels are specified. Analyze RCS and SMS attempts separately after sending. +- Validate templates and messages in sandbox where supported. +- Pin `["rcs"]` to prove the RCS path without cross-channel fallback. +- Omit `channel` or use `["sent"]` to verify automatic routing. +- If testing broadcast, state the expected recipient × channel message count and cost before sending. +- Persist every returned `message_id` with tenant, profile, channel, and logical test case. -### 4. Route the launch through Sent +Use `GET /v3/messages/{id}`, activities, and signed webhooks to verify actual routing and delivery. Do not infer fallback from the request alone. -Because Sent states that production RCS setup is not self-service, prepare a handoff note for Sent rather than pretending to click through an RBM console. Include the evidence packet, the Sender Profile identifier, the target countries/carriers if known, fallback requirements, and the requested go-live timeline. +## Launch acceptance -A clean handoff reads like this: +- [ ] Sent and carrier approval are confirmed. +- [ ] Profile UUID and credential pattern are recorded. +- [ ] Brand, consent, policy, and support evidence is complete. +- [ ] Templates use only text and up to four suggestion chips for RCS. +- [ ] Automatic fallback uses omitted `channel` or `["sent"]`. +- [ ] Pinned RCS uses `["rcs"]`. +- [ ] Broadcast is clearly labelled and costed. +- [ ] SMS compliance is ready wherever automatic routing can select SMS. +- [ ] Message IDs are mapped for webhook attribution. -> “Please initiate RCS setup for Sender Profile `support-us` / `x-sender-id` `...`. Brand is Acme Logistics, website `https://acme.example`, use case shipment notifications and customer-care replies. Opt-in occurs at checkout. SMS fallback is required through the existing US SMS route. Attached are logo, brand color, support contacts, privacy policy, and five message examples.” - -### 5. Define the test plan before launch - -Write the first-send test plan before approval arrives. Include a small set of internal numbers, target devices/carriers when available, template IDs, expected channel behavior, and rollback criteria. - -| Test | Expected result | Evidence to collect | -|---|---|---| -| RCS-capable internal device | RCS message reaches `DELIVERED`; `READ` may appear if opened. | Sent message status and activities. | -| Non-RCS-capable recipient | SMS fallback path succeeds where fallback is requested. | Separate RCS and SMS message IDs/statuses. | -| Rich content render | Cards/buttons render as designed on target devices. | Screenshots and message activities. | -| Webhook callback | Customer endpoint receives delivery/read events. | Sent webhook event history and customer logs. | - -### 6. Verify launch with Sent message evidence - -After Sent confirms the RCS setup is active, send a controlled batch using `POST /v3/messages`. For every Sent `message_id`, retrieve `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities`. Confirm that RCS messages progress through the documented lifecycle and that SMS fallback behaves as expected. - -If the first batch fails, do not guess. Separate setup failures from fallback failures, template/payload failures, and webhook ingestion failures. Use `messaging-performance-analyzer` for deeper funnel analysis once the launch is producing enough evidence. - -## Common rationalizations to avoid - -Do not tell the user RCS is self-service in Sent. Sent’s channel setup guide says to contact Sent and wait for carrier approval. - -Do not create a fake `fallback_policy` field in Sent requests. Use documented channel arrays and account-level fallback behavior unless a verified account-specific API field exists. - -Do not assume SMS fallback is safe because RCS is approved. SMS fallback needs a compliant sender, especially for US A2P traffic. - -Do not conflate brand approval with template quality. An approved RCS agent can still fail if the message payload, media, or fallback copy is wrong. - -## Verification checklist - -- [ ] The user’s RCS use case is specific enough for review and not just “send rich messages.” -- [ ] Brand identity, support contact, privacy policy, opt-in evidence, and sample messages are collected. -- [ ] The Sent Sender Profile or `x-sender-id` is identified. -- [ ] SMS fallback requirements are documented and routed to SMS compliance checks where needed. -- [ ] The handoff explicitly says Sent must initiate RCS setup and carrier approval. -- [ ] The first-send test plan includes RCS-capable, non-RCS-capable, rich-rendering, and webhook checks. -- [ ] Post-launch verification uses Sent `message_id`, status, and activities. -- [ ] External RBM facts are labeled as external context, not Sent API guarantees. - -## Related skills - -Use `sms-10dlc-registration` before launch when SMS fallback touches US A2P traffic, opt-in evidence, 10DLC campaigns, or brand vetting. - -Use `sender-profile-architect` when the customer has multiple brands, tenants, departments, or profiles and needs a durable sender architecture. - -Use `template-builder-ui` when the RCS launch needs reusable templates, rich component validation, or a template-creation workflow. - -Use `messaging-performance-analyzer` after launch when the user has message IDs, webhook events, failed sends, or delivery-rate symptoms. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/rbm-agent-spec.md` | Payload/schema reference | Keep Google RBM identity fields, asset requirements, and review vocabulary outside the skill body. | -| `references/rcs-launch-evidence-packet.md` | Worked example | Provide a complete filled-in launch packet for a realistic transactional RCS launch. | -| `references/rcs-fallback-patterns.md` | Decision matrix | Compare RCS-only, RCS-first with SMS fallback, and multi-channel broadcast patterns. | - -## Unverified claims to confirm or remove - -- Google RBM lifecycle states such as `pending_verification`, `launch_review`, or per-carrier launched states were not verified in Sent docs (these are Google-side, not exposed by Sent v3). -- Sent does not expose RCS rollout-status or capability-check endpoints in v3; use Activities + webhook events to observe behavior. -- Exact rich-card capability differences by carrier/device require external RBM evidence or live testing, not Sent docs alone. +Use [references/rbm-agent-spec.md](references/rbm-agent-spec.md) for the current launch specification and [references/rcs-fallback-patterns.md](references/rcs-fallback-patterns.md) for routing tests. Use `messaging-performance-analyzer` after enough message evidence exists. diff --git a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md index 6099f15..070bd30 100644 --- a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md +++ b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md @@ -1,53 +1,29 @@ -# RBM Agent Spec — Reference +# Current Sent RCS launch specification -Supporting reference for `rcs-agent-onboarding`. This doc separates **what Sent exposes** about an RCS Agent from **what lives in Google RBM** (the underlying carrier platform). Keep that line bright: anything not in the Sent v3 docs snapshot is external context and should be linked, not mirrored. +## Approval boundary -Canonical Google source: [RCS Business Messaging docs](https://developers.google.com/business-communications/rcs-business-messaging). +RCS onboarding is coordinated through Sent and requires carrier approval. There is no public self-service provisioning flow in the current Sent v3 API. -## Sent's RCS setup model (verified) +## Required evidence -- **Not self-service.** Per Sent's channel-setup docs, RCS setup requires a one-time carrier approval that has to be initiated by contacting Sent (`support@sent.dm`). There is no dashboard button or v3 API endpoint that provisions an RCS Agent end-to-end on its own. -- **Post-approval visibility.** Once Sent (with Google + the carriers) has finished the approval cycle, the RCS Agent — Sent calls it a **Branded Sender** for RCS — appears in the dashboard alongside the customer's other channels. -- **No rollout-status or capability-check endpoint.** The v3 API does not surface a per-carrier launch state, an agent-ID field, or a recipient capability probe. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and webhook events (see `references/rcs-fallback-patterns.md`). +- Consumer-facing brand name and website +- Logo and primary brand color +- Privacy policy and terms +- Customer support details +- Consent/opt-in description +- Message purpose, audience, market, and volume +- Representative text messages +- Suggestion-chip labels/actions when used +- Sender Profile UUID and desired launch timeline -If a request implies "click here to create my RCS agent", correct it. The handoff is human-initiated by emailing `support@sent.dm` with the launch evidence packet. +## Supported message capability -## Sent-side terminology (verified) +Current Sent RCS guidance supports text and up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap features. Do not make them current approval prerequisites or capability declarations. -These are the terms Sent uses in its public docs and dashboard. Use them when talking to a Sent customer instead of Google's RBM API names. +## Identity and credentials -| Sent term | What it is | -|---|---| -| **RCS Agent** | The branded RCS sender identity that recipients see. Created on Sent's side after carrier approval. | -| **Branded Sender** | Sent's umbrella term for the per-channel sender identity (the RCS Agent for RCS, the WABA-attached phone number for WhatsApp, the 10DLC long code for SMS). | -| **Rich Card** | A single card with media, title, description, and suggestion chips. | -| **Carousel Card** | A horizontally-swiped collection of up to **10** Rich Cards. | -| **Suggestion Chip** | A tap-to-act chip below or inside a message. Three documented kinds: quick reply, open URL, dial number. | +Use the Sender Profile UUID. A profile key sends `x-api-key` alone; an organization key may scope with `x-profile-id`. `x-sender-id` is legacy v1/v2 terminology. -## Google RBM-side concepts (external — link, do not mirror) +## Approval states -The following live entirely in Google's RBM platform and are **not** exposed in the Sent v3 API. Don't pretend Sent surfaces them; treat them as platform context the customer hears about during review. - -- Agent identity fields (`displayName`, `logoUri`, `heroUri`, `color`, `verifiedDomains`, contact info) — set during Sent's onboarding handoff, not via Sent v3 API. See [Google's agent reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents). -- RBM capabilities the agent must declare (suggested replies, suggested actions, standalone rich card, rich card carousel, file/image/video/audio attachments). See [Google's capabilities guide](https://developers.google.com/business-communications/rcs-business-messaging/guides/build/capabilities). -- RBM use case taxonomy (`TRANSACTIONAL`, `OTP`, `PROMOTIONAL`, `CUSTOMER_CARE`, `MULTI_USE`). -- Verification and launch-review lifecycle (Google review → per-carrier review → carrier-specific `ENABLED` / `PENDING` rollout). Typical turnaround: 1-7 business days for verification; longer for launch. -- Per-carrier rejection codes and rejection reasons. See [Google's launch guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). -- Capability-check endpoint (`https://rcsbusinessmessaging.googleapis.com/v1/users/{phoneNumber}:capabilities`) — Google-side, requires direct RBM API access, not part of Sent v3. - -## What Sent does NOT expose (gap notes) - -These would be useful for an agent but are not in the v3 docs snapshot: - -- A field on the message resource indicating per-carrier launch state for the originating RCS Agent. -- A capability-check endpoint on Sent's side. -- A public `fallback_policy` field — channel selection is done via the `channel` array on the send request (see `references/rcs-fallback-patterns.md`). -- A structured RBM rejection code on failed messages. The `message.failed` webhook carries `payload.message_status = FAILED`; the human-readable reason lives in the `description` of the message detail fetched via `GET /v3/messages/{id}`. - -## Anti-patterns - -- Claiming Sent surfaces an `agentId` or per-carrier rollout state in v3 — it doesn't. -- Promising the customer they can self-serve an RCS Agent through the dashboard — the docs explicitly say to contact `support@sent.dm`. -- Restating Google's agent JSON schema in this file — link to Google's docs instead so this skill stays small and current. -- Using Google-side names (`displayName`, `verifiedDomains`) when talking to a Sent customer about their dashboard. Use Sent's terms (RCS Agent, Branded Sender, Rich Card, Carousel Card, Suggestion Chip). -- Treating a Carousel Card as unbounded — Sent's docs cap it at 10 Rich Cards. +Carrier approval state is an operational Sent/carrier process. Do not invent an API enum or per-carrier status endpoint. Store the evidence Sent provides, date it, and surface unknown values safely. diff --git a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md index 4458c92..52aa8f7 100644 --- a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md +++ b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md @@ -1,136 +1,55 @@ -# RCS Fallback Patterns — Reference +# RCS routing and fallback patterns -Supporting reference for `rcs-agent-onboarding`. Covers how Sent expresses RCS-to-SMS fallback (the `channel` array on the send request), what the documented webhook events tell you, and where the boundary sits between Sent-verified behavior and Google RBM-side semantics. +## Automatic routing with fallback -External error semantics live at [Google's RBM error reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/rest) — this doc only covers Sent's wrapping. - -## How fallback is expressed (verified) - -Sent does **not** expose a separate `fallback_policy` field in v3. Fallback intent is expressed entirely by the ordered `channel` array on `POST /v3/messages`: +Omit `channel` or send: ```json { - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } + "to": ["+12025550100"], + "channel": ["sent"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -| Array | Behavior | -|---|---| -| `["rcs"]` | RCS-only. If RCS can't deliver, the message fails. No SMS attempt. | -| `["rcs", "sms"]` | Documented fallback pattern. SMS is the explicit fallback target. | -| `["sms", "whatsapp", "rcs"]` | Per the v3 docs, an array with multiple channels creates **one message per channel** — all dispatch. This is a multi-channel broadcast, not a waterfall. | -| `["sms"]` | SMS-only. Used during agent provisioning before RCS is live. | -| (omitted) | Sent picks the optimal channel automatically based on the recipient's `available_channels`. | - -Two things worth surfacing to a customer: - -1. **Multi-channel arrays are broadcast, not waterfall.** The v3 docs describe `["sms", "whatsapp", "rcs"]` as producing one message per channel that all dispatch. If you want a strict RCS-first-with-SMS-fallback waterfall, the documented shape is `["rcs", "sms"]`. Anything longer needs explicit confirmation with Sent. -2. **No `fallback_policy` field exists in v3.** Documentation, dashboards, or examples that reference one are inferring a v2 concept. Use the channel array. - -## When fallback fires (inferred — confirm before promising) - -Sent's docs verify that RCS "falls back to SMS automatically for non-RCS-capable recipients" and that `["rcs", "sms"]` makes that explicit. The docs do **not** enumerate every trigger condition (capability mismatch vs. carrier-pending vs. RBM outage). Treat the following as inferred and confirm with Sent if a customer needs exact semantics: - -- Recipient device not RCS-capable -- Recipient on a carrier where the RCS Agent is not yet rolled out -- RBM transient unreachability - -For day-to-day customer guidance, "if RCS can't deliver, SMS is attempted" is the documented promise. The why-it-fell-back detail surfaces in the message's failure description (see below). - -## Content trimming on fallback (external) - -The v3 docs don't specify what happens to rich content (Rich Cards, Carousel Cards, Suggestion Chips) when a message authored for RCS falls back to SMS. SMS has no equivalent for any of those components. - -The safe default is to **author SMS-side content explicitly** rather than rely on automatic trimming. The `template-builder-ui` skill covers the dual-authoring workflow. - -If a customer needs an exact answer about Sent's trimming behavior, escalate to `support@sent.dm` — it's not in v3 docs. +Sent selects the available route. This is the cross-channel fallback mode. -## Verified webhook events for message lifecycle - -Sent's quickstart docs verify this lifecycle. Sub-types follow `message.`: - -| Event | Meaning | -|---|---| -| `message.queued` | Send accepted, waiting to dispatch | -| `message.routed` | Assigned to a carrier/provider | -| `message.sent` | Dispatched to the carrier/RCS/WhatsApp provider | -| `message.delivered` | Confirmed delivery to device | -| `message.read` | Recipient opened. RCS and WhatsApp only. | -| `message.failed` | Delivery failed at any stage. `payload.message_status = FAILED`. Fetch `GET /v3/messages/{id}` for the reason. | - -Webhook payload shape (top-level): +## Pinned RCS ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "to": ["+12025550100"], + "channel": ["rcs"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -The `payload.channel` field is what tells you which channel actually delivered. To distinguish "RCS delivered" from "SMS fallback delivered" for the same logical send, inspect `payload.channel` on the `message.delivered` event. - -### Events that are NOT verified — do not assume they exist - -Earlier drafts of this skill referenced events like `message.channel_selected` and `message.fallback_triggered`. **Those names are not in the v3 docs.** The only verified `message.*` sub-types are `queued`, `routed`, `sent`, `delivered`, `read`, `failed`. - -If a customer's integration depends on a dedicated "fallback fired" event, reconstruct it from what's documented: +This requests RCS only and has no cross-channel fallback. Use it to isolate RCS launch or payload failures. -- For an `["rcs", "sms"]` send, observe `message.delivered` events and check `payload.channel` — `"sms"` on what was meant to be an RCS-first send is the fallback signal. -- For a failed RCS attempt that succeeded via SMS, the v3 docs don't promise an explicit pairing. Reconstruct by correlating `message.failed` (RCS) with a separate `message.delivered` (SMS) sharing the same logical send. -- If you genuinely need a dedicated fallback event, ask Sent — don't invent the name. +## Broadcast -### Send-time failure codes (verified) +Any array containing two or more explicit channel values is broadcast. Sent creates a separate message for each recipient/channel pair. This can duplicate content and charges. -On `message.failed`, fetch the message and read `description`. Verified codes that may appear: +Before a broadcast, calculate: -| Code | Meaning | -|---|---| -| `ERR_CONSENT_BLOCKED` | Recipient is opted out or on suppression list. No provider call. | -| `ERR_ROUTE_DENIED` | No active route could deliver to the requested channel/country. | -| `ERR_TEMPLATE_PARAMS_INVALID` | Required template variables missing or failed regex validation. | - -Per-carrier RBM rejection codes are external (Google) and not surfaced as a structured field in v3. - -## Testing fallback in lower environments - -Sent's v3 docs document **sandbox mode** as the testing affordance: add `"sandbox": true` to the request body and the API returns a realistic fake response without a provider call. Response includes `X-Sandbox: true` header. - -Sandbox mode is documented for `POST /v3/messages` and most other mutation endpoints. - -The v3 docs do **not** document a `force_fallback` flag, a test-recipient registry, or per-carrier launch-state overrides. Earlier drafts referenced these — treat as inferred / unverified. If a customer needs to exercise the SMS-fallback path specifically in a lower environment, the documented approach is: +```text +messages created = recipient count × explicit channel count +``` -1. Send with `sandbox: true` to validate request shape without side effects. -2. To exercise the real fallback path against the real provider chain, send with a small recipient list including known-non-RCS-capable numbers. -3. For pre-launch testing, send with `["sms"]` first to confirm SMS compliance and webhook plumbing, then introduce `["rcs", "sms"]` once RCS is approved. +Require explicit user intent and show the count/cost impact. -## What's NOT in v3 (gap notes) +## Observability -- A dedicated `message.fallback_triggered` webhook event. -- A `message.channel_selected` event. -- A `fallback_policy` field on the Sender Profile or on the send request. -- A `force_fallback` flag for non-prod testing. -- An MDR export schema documenting `attempted_channels` / `delivered_channel` fields. -- Per-carrier rollout-status fields. +The send response returns per-recipient message IDs and channels. Persist each record. Webhook message events use `field: "message"`, a message `sub_type`, and `payload.channel`. Determine what happened from the created message records and their activities—not from the ordering of the request array. -Anything above that appears in customer-facing guidance should be flagged as inferred and confirmed with Sent before relying on it. +## Test matrix -## Anti-patterns +| Test | Request | Expected evidence | +| --- | --- | --- | +| RCS path | `["rcs"]` | One RCS attempt per recipient, no SMS message ID | +| Automatic routing | omitted / `["sent"]` | One selected route per recipient according to availability | +| Broadcast | two explicit channels | Two message IDs per recipient | -- Inventing a `fallback_policy` field — it doesn't exist in v3; use the `channel` array. -- Inventing `message.fallback_triggered` or `message.channel_selected` webhook events — they're not in the documented lifecycle. -- Sending with `["rcs"]` and expecting SMS to back it up. RCS-only means RCS-or-fail. -- Treating a long channel array like `["rcs", "whatsapp", "sms"]` as a waterfall. Per v3 docs, multiple channels create one message per channel (broadcast). Use `["rcs", "sms"]` for the documented fallback shape. -- Assuming Sent auto-trims rich RCS content gracefully into SMS. Trimming behavior isn't in v3 docs — author SMS variants explicitly. -- Using `sandbox: true` in production traffic — sandbox is for tests, not real sends. +There is no documented `fallback_policy` or `force_fallback` field. Do not invent dedicated fallback webhook names. diff --git a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md index 945576f..a4d9344 100644 --- a/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md +++ b/claude-plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md @@ -1,117 +1,42 @@ -# RCS Launch Evidence Packet — Reference +# RCS launch evidence packet -Supporting reference for `rcs-agent-onboarding`. Describes the evidence Sent needs from a customer before initiating the RCS handoff to Google + the carriers. Sent's docs say RCS setup is not self-service and must be requested through `support@sent.dm`; the packet is what makes that email actionable. +## Brand -The carrier-side launch review is external — authoritative source is [Google's RBM launch documentation](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). This doc only covers what Sent itself collects, and how that overlaps with the broader KYC/compliance form the customer already fills in. +- Legal and consumer-facing brand names +- Public website +- Square logo and brand color +- Privacy policy and terms URLs +- Support email, phone, or URL -## Why a packet +## Use case and consent -The dashboard handoff to `support@sent.dm` is the single biggest lever a customer has on launch latency. A complete packet means Sent's team can take it to the carriers without round-tripping the customer for missing material. A vague packet means the agent sits idle for weeks. +- Audience and target countries +- Transactional, authentication, marketing, support, or mixed intent +- Exact opt-in flow and proof +- Message frequency and estimated volume +- STOP/HELP handling where SMS can be selected by automatic routing -## Overlap with the existing compliance form (verified) +## Current message examples -The Sent dashboard already collects most of the brand-identity and use-case evidence as part of KYC and the compliance form. Reuse those answers — don't ask the customer to write everything from scratch. +Provide at least five representative text messages. For each, include zero-to-four suggestion chips and what each chip does. Do not include rich-card, carousel, or media-attachment requirements; those are not current Sent capabilities. -Fields the dashboard's compliance form already captures (per Sent's docs): +## Sender Profile -**Business identity** -- Legal business name -- Business registration number -- Business type / structure -- Industry category -- EIN / tax ID (US) -- Business address -- Business phone number -- Contact email +- v3 profile UUID +- Credential pattern: profile key or organization key plus `x-profile-id` +- Relevant numbers and markets +- SMS compliance state if automatic routing can select SMS -**Messaging / use-case** -- Use-case selection (Authentication, Notifications, Marketing, Customer Service, High Volume) -- Campaign description -- Sample messages per use case -- Opt-in mechanism (URL or description) -- Opt-out language +## Routing plan -For an RCS launch, these answers map directly into what the carriers want to see. If they're already complete and current, the RCS packet is mostly assembly, not authoring. +Choose one or more test modes: -## RCS-specific evidence (additive to compliance form) +- automatic routing: omitted `channel` or `["sent"]`; +- pinned RCS: `["rcs"]`; +- intentional broadcast: multiple explicit channels with expected message count and cost. -The bits below are required for RCS review but are **not** part of Sent's general compliance form — the customer has to supply them specifically for the RCS handoff. +Do not describe an explicit multi-channel array as fallback. -### 1. Brand authorization +## Handoff note -- Letter of authorization (LOA) signed by an officer of the brand confirming the customer is authorized to operate this RCS Agent -- For franchises / resellers: documentation of the licensing arrangement - -### 2. Sample message gallery for RCS - -For every Sent-side RCS component the agent will use, include at least one realistic sample showing it in use: - -- Plain text body -- Suggestion Chip — quick reply -- Suggestion Chip — open URL -- Suggestion Chip — dial number -- Rich Card (with media, title, description, chip) -- Carousel Card (up to 10 Rich Cards) -- Any attachment type the agent will send (image, video, file) - -Samples must use real brand assets (logo, color, copy voice). Placeholders are a common rejection reason. - -### 3. Brand assets - -- Square logo (RBM has minimum dimensions; check [Google's agent docs](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents)) -- Brand color (hex) -- Optional hero/banner image -- Public website URL (must match the brand the agent represents) - -### 4. SMS fallback plan - -A one-paragraph statement of what happens when the recipient isn't RCS-capable. Sent's documented fallback mechanism is the `channel` array on the send request — see `references/rcs-fallback-patterns.md` for the option set. If the customer needs US SMS as fallback, confirm 10DLC compliance is already in place (see `sms-10dlc-registration`). - -### 5. End-user support contact - -A phone number, email, or in-product support URL recipients can reach with questions. Google and carriers both probe this contact during review, so it has to actually answer. - -## Pre-handoff checklist - -Before emailing `support@sent.dm`, confirm: - -- [ ] Compliance form is complete and current in the Sent dashboard -- [ ] Use-case selection on the form matches the RCS Agent's intended use -- [ ] Sample messages on the form cover the RCS use case (not just SMS) -- [ ] Opt-in mechanism (URL or description) is filled in and accurate -- [ ] Opt-out language is documented -- [ ] LOA signed and dated within last 12 months -- [ ] At least one sample per RCS component the agent will use, with real brand assets -- [ ] Brand logo, color, website URL ready to attach -- [ ] SMS fallback plan written down, with channel-array shape (e.g. `["rcs", "sms"]`) -- [ ] If US SMS is the fallback, 10DLC registration already complete -- [ ] Support contact is live and answers within stated SLA -- [ ] Every URL that will appear in a sample message resolves on the brand's public domain - -## Per-carrier nuance (external) - -Each carrier reviews independently after Google approves. The specifics shift over time and are not documented in Sent's v3 docs — treat carrier-specific copy requirements, opt-in language minimums, and throttling windows as external. Always check [Google's per-carrier guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch) for the current rules. - -Patterns that broadly hold: -- Marketing use cases get scrutinized harder than transactional / OTP. -- Carriers may require additional opt-in disclosure language beyond Google's. -- Even after `ENABLED`, carriers may stage rollout by recipient volume in the first weeks. - -## Common rejection reasons (Google-side, external) - -These come from Google and the carriers, not Sent. Listed here as a checklist, not a substitute for the [official guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). - -| Reason | Remediation | -|---|---| -| Brand assets in sample don't match the agent identity | Re-render samples with the actual logo, color, brand name | -| Use case description and declared use case disagree | Either change the declared use case or rewrite the description; resubmit | -| Opt-in disclosure missing channel name | Update the opt-in surface to name RCS (or "text messages including RCS") and re-screenshot | -| Sample exercises a capability not declared on the agent | Either declare the capability or remove the sample | -| Support contact unreachable | Wire up the contact and confirm before resubmitting | -| Same packet recycled across multiple agents with different brands | Each agent needs its own packet — Google catches this | - -## After submission - -- Sent does not expose a per-carrier rollout-status endpoint in v3. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and the webhook events listed in `references/rcs-fallback-patterns.md`. -- A carrier coming back with changes requested will produce a short reason — feed that back to the customer and update the relevant packet artifact before resubmitting. -- Some carriers stay pending for weeks even after Google approves; that's an external timeline, not a Sent issue. +Ask Sent to initiate RCS setup and carrier review for the named profile. Attach brand/consent evidence, message examples, target markets, support details, routing plan, and requested launch window. Avoid claims about approval timing that Sent or carriers have not confirmed. diff --git a/claude-plugins/sent/skills/sender-profile-architect/SKILL.md b/claude-plugins/sent/skills/sender-profile-architect/SKILL.md index b0b2fa3..1310177 100644 --- a/claude-plugins/sent/skills/sender-profile-architect/SKILL.md +++ b/claude-plugins/sent/skills/sender-profile-architect/SKILL.md @@ -1,182 +1,121 @@ --- name: sender-profile-architect -description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, or multi-channel messaging systems, including profile boundaries, account-level API key blast radius, webhooks, compliance inheritance, and channel readiness. Use when a user says sender profile, x-sender-id, profile setup, multi-tenant messaging, brand isolation, department sender, webhook routing, tenant offboarding, or asks how to model SMS, WhatsApp, and RCS senders in Sent. +description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, and multi-channel systems. Use for API-key scoping, x-profile-id, isolation, inheritance, sharing, billing, WABA, 10DLC campaigns, webhooks, or tenant offboarding. --- - +A Sender Profile is the operational boundary for tenant identity, channel configuration, inherited resources, billing, and credentials. Use this skill before provisioning when a poor boundary would mix brands, compliance posture, rate-limit impact, or webhook ownership. -# Sender profile architect +## Recommended tenancy model -## Overview +When tenants require isolation, recommend one Sent organization with one Sender Profile per tenant. A shared profile is appropriate only when the tenants genuinely share one brand, sender resources, compliance posture, billing/rate-limit expectations, and operational blast radius. -Use this skill to decide how a customer should map brands, tenants, departments, and channels onto Sent Sender Profiles. A Sender Profile is the durable boundary for sender identity and channel configuration. The Sent dashboard shows each profile with display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The v3 API exposes profile creation, listing, retrieval, update, deletion, and completion. +Do not recommend pooled-by-default architecture. Make the isolation decision explicit using [references/multi-tenancy-patterns.md](references/multi-tenancy-patterns.md). -Good profile architecture prevents three recurring failures: messages sent from the wrong brand, compliance resources shared across incompatible use cases, and webhook/event data that cannot be routed back to the correct tenant. +## Authentication patterns -## When to use +Sent v3 supports both: -Use this skill when the user asks how to create Sender Profiles, split one customer into multiple senders, model a marketplace or ISV, isolate brands, route webhooks, reason about account-level API key blast radius, complete profile setup, or safely offboard a tenant. Use it whenever the request mentions `x-sender-id`, Sender Profile, profile completion, multi-tenant messaging, brand hierarchy, SMS/WhatsApp/RCS sender setup, or webhook routing. +| Pattern | Headers | Blast radius | +| --- | --- | --- | +| Profile-specific API key | `x-api-key` | Profile-scoped credentials and rate-limit context. Do not add `x-profile-id`. | +| Organization API key acting for a child | `x-api-key` plus `x-profile-id: ` | Organization credential can reach permitted child profiles; rate limits remain in the organization pool. | -Do not use this skill to decide 10DLC use cases in detail, write WhatsApp template copy, onboard RCS approval, or analyze delivery failures. Hand those to the related skills once the profile boundary is clear. +Only organization keys may send `x-profile-id`. A profile key that sends it receives `403`. A profile outside the organization returns `404`. `X-Profile-Id` can be echoed in scoped responses. -## Profile boundary principle +`x-sender-id` is legacy v1/v2 terminology only. Do not use it for v3 authentication or routing. -Create a separate Sender Profile when the sender identity, compliance evidence, webhook routing, operational ownership, or channel readiness must be isolated. Reuse a profile when the same legal/brand identity sends the same class of traffic and should share compliance posture and operational controls. +Choose profile keys when tenant-level credential isolation and revocation are primary. Choose organization-key scoping for centrally controlled integrations that can protect a broader credential and deliberately accept a shared organization rate-limit pool. -| Split signal | Create separate profiles when | Reuse a profile when | -|---|---|---| -| Brand identity | The recipient sees different brand names or support contacts. | The recipient sees one brand across all messages. | -| Compliance | 10DLC brand/campaign, opt-in source, or use case differs materially. | Compliance evidence and use case are the same. | -| Channel configuration | SMS, WhatsApp, or RCS resources belong to different brands or regions. | Channels represent one sender identity. | -| Webhook routing | Events must land in different tenant queues or data stores. | One team owns all events and reconciliation. | -| Lifecycle | One sender may be paused, restricted, or offboarded independently. | Senders always launch, pause, and retire together. | +## Profile creation model -## Process +Create with `POST /v3/profiles`. `name` is required. Current optional areas include: -### 1. Draw the recipient-visible sender model +- identity: `icon`, `description`, `short_name`; +- sharing: `allow_contact_sharing`, `allow_template_sharing`; +- inheritance: `inherit_contacts`, `inherit_templates`, `inherit_tcr_brand`, `inherit_tcr_campaign`; +- billing: `billing_model`, `billing_contact`, and ephemeral `payment_details`; +- dedicated WABA credentials: `whatsapp_business_account` with `waba_id`, optional `phone_number_id`, and `access_token`; +- a dedicated brand: `brand.contact`, `brand.business`, and `brand.compliance`. -Start with what the recipient sees, not with internal account hierarchy. Ask: “Would the recipient reasonably think these messages came from the same sender?” If the answer is no, use separate profiles. +Do not add a separate brand endpoint. A dedicated brand is created with the profile; campaigns are managed under `/v3/profiles/{profileId}/campaigns`. -**Example.** A healthcare ISV serves three clinic chains. Each chain has its own patient-facing brand, privacy policy, and support phone. Create one profile per clinic chain. Do not put all clinics behind a single ISV profile just because the same platform sends the messages. +### Inheritance rules -### 2. Map each profile to channel readiness +- `inherit_tcr_brand: true` means the profile uses the organization's brand and cannot submit its own `brand` object. +- `inherit_tcr_campaign: true` makes inherited campaigns read-only for that profile. +- An inherited brand with `inherit_tcr_campaign: false` is a supported dedicated-campaign pattern. +- Sharing flags expose a profile's contacts/templates; inheritance flags consume organization resources. Treat those directions separately. -For each proposed profile, list SMS, WhatsApp, and RCS readiness separately. Sent’s channel setup guidance covers production setup for all three channels and recommends using the same phone number across SMS, WhatsApp, and RCS where possible. That recommendation does not override compliance or brand isolation. +### Billing and number references -| Channel | Profile-level questions | Follow-up skill | -|---|---|---| -| SMS | Is US A2P involved? Which brand/campaign and opt-in evidence apply? | `sms-10dlc-registration` | -| WhatsApp | Which WABA/phone number identity maps to this brand? Are templates approved? | `waba-embedded-signup`, `waba-template-author` | -| RCS | Has Sent initiated setup and carrier approval for this profile? Is SMS fallback ready? | `rcs-agent-onboarding` | +`billing_model` currently supports `profile`, `organization`, and `profile_and_organization`. A profile or fallback billing model requires `billing_contact` when none exists. Card fields are forwarded to the payment processor and must not be logged or persisted. -### 3. Create or update the Sent profile +Profile update can manage `sending_phone_number_profile_id`, `sending_whatsapp_number_profile_id`, `sending_phone_number`, `whatsapp_phone_number`, and `allow_number_change_during_onboarding`. Model reference IDs and direct numbers separately, and prevent cycles when one profile references another. -Use Sent’s profile API where API access is appropriate, or the dashboard when the user is operating manually. The verified v3 API includes: +## WABA choices -| Operation | Endpoint | Use | -|---|---|---| -| Create profile | `POST /v3/profiles` | Create a sender boundary for a brand, department, tenant, or use case. | -| List profiles | `GET /v3/profiles` | Audit existing profile boundaries before creating duplicates. | -| Retrieve profile | `GET /v3/profiles/{profileId}` | Inspect detailed profile configuration. | -| Update profile | `PATCH /v3/profiles/{profileId}` | Change profile configuration/settings. | -| Delete profile | `DELETE /v3/profiles/{profileId}` | Soft-delete a profile after traffic, webhooks, and credentials are drained. | -| Complete setup | `POST /v3/profiles/{profileId}/complete` | Validate prerequisites and start the profile completion workflow. | +There are three distinct paths: -Use idempotency keys on create/update/complete calls when the integration might retry. The OpenAPI exposes an optional `Idempotency-Key` header for those operations. +1. Organization Embedded Signup in the dashboard. +2. Child profile inheritance by omitting `whatsapp_business_account` after the organization has a WABA. +3. Dedicated profile WABA using `waba_id` and `access_token`; `phone_number_id` is optional. -### 4. Attach compliance and channel prerequisites before completion +There is no public endpoint that starts organization Embedded Signup. Direct credentials on `POST /v3/profiles` are not an “Embedded Signup endpoint.” Use `waba-embedded-signup` for the operational flow. -The profile completion endpoint validates prerequisites such as profile data, brand, campaigns, and channel connections. For US A2P SMS, create or attach Sent brand and campaign resources before completing the profile. The verified brand/campaign endpoints are `/v3/brands` and `/v3/brands/{brandId}/campaigns`. +## 10DLC and campaigns -Do not invent field names such as `tcr_brand_id` or `waba_phone_id` unless the actual API response includes them. Store Sent IDs returned by the API and any returned provider identifiers separately, with clear names. +Use a profile `brand` object for a dedicated brand. Manage campaigns at: -**Example data model.** +- `GET|POST /v3/profiles/{profileId}/campaigns` +- `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` -```text -sender_profiles -- sent_profile_id -- Sent Profile.id (UUID) -- x_sender_id -- per-profile, v2 legacy; useful for dashboard cross-ref -- name -- Sent Profile.name -- short_name -- Sent Profile.short_name -- description -- Sent Profile.description -- sent_status -- mirrors Sent Profile.status: incomplete|pending_review|approved|rejected -- status_app_level -- finer-grained internal lifecycle (do not conflate with sent_status) -- sms_ready_app_level -- whatsapp_ready_app_level -- rcs_ready_app_level - -sender_profile_resources -- sent_profile_id -- channel -- sent_resource_id -- e.g., /v3/brands/{brandId}, /v3/brands/{brandId}/campaigns/{id} -- provider_resource_type -- e.g., tcr_brand, tcr_campaign, waba, waba_phone, rbm_agent -- provider_resource_id -- status_last_seen_at -``` - -### 5. Design webhook routing around Sent event evidence - -Sent’s v3 webhook API supports creating/listing webhooks, retrieving event types, viewing webhook events, testing a webhook, toggling status, and rotating signing secrets. Use those endpoints to verify configuration and delivery before blaming channel infrastructure. - -Route inbound events by stable identifiers present in the Sent payload. If the exact event payload fields are not documented for the customer’s account, log full events in a secure staging environment and derive the routing map from observed Sent fields rather than assumed provider keys. - -**Example.** If a marketplace needs tenant-specific queues, route first by Sent profile or sender identifier if present in the event. Fall back to a mapping table from Sent message ID to tenant/profile created at send time. Avoid making provider IDs the only routing key. - -### 6. Model profile lifecycle as an application state machine - -Sent exposes profile APIs and completion behavior, but your application may need richer internal states. Label them as application states so future agents do not mistake them for Sent enums. - -| Application state | Meaning | Exit condition | -|---|---|---| -| `draft` | Profile data is being collected. | Required identity and owner fields are present. | -| `compliance_pending` | Brand/campaign/channel evidence is being prepared. | Required compliance resources exist or have been submitted. | -| `completion_started` | `/v3/profiles/{profileId}/complete` returned accepted/started behavior. | Webhook/callback or follow-up status indicates completion result. | -| `active` | Profile is approved for intended channels. | Traffic is allowed and test sends pass. | -| `restricted` | One or more channels is blocked, paused, or missing approval. | Root cause resolved and profile retested. | -| `retiring` | Sends are drained and webhooks/credentials are being removed. | No active sends, subscriptions, or credentials remain. | - -### 7. Plan tenant offboarding before the first send +Use `sms-10dlc-registration` for the payload and policy layer. -Offboarding is easiest when profile boundaries are clean. To retire a profile, stop new sends, drain in-flight messages, export relevant message/activity evidence, disable or reroute webhooks, revoke or rotate credentials, delete/soft-delete the profile when safe, and retain compliance records according to the customer’s policy. +## Completion and status handling -## Common rationalizations to avoid +Complete a profile with `POST /v3/profiles/{profileId}/complete` and a required `webHookUrl`: -Do not use one profile for every tenant just because it is easy. Over-splitting creates unnecessary compliance and operational work. - -Do not use one shared profile for distinct recipient-visible brands. Under-splitting creates wrong-sender and compliance-contamination failures. - -Do not treat internal tenant ID as a substitute for Sender Profile ID. The application can map tenant ID to profile ID, but outbound sends and webhook reconciliation need Sent identifiers. - -Do not hardcode provider identifiers as routing keys before verifying Sent webhook payloads. Sent’s event shape is the integration contract. - -Do not rotate webhook secrets casually. Secret rotation immediately invalidates the old secret, so coordinate with the receiving endpoint. - -## Verification checklist - -- [ ] Each proposed profile has a recipient-visible rationale. -- [ ] SMS, WhatsApp, and RCS readiness are tracked separately per profile. -- [ ] US A2P SMS profiles have brand/campaign work routed to compliance before completion. -- [ ] The implementation stores Sent profile IDs and any provider IDs as separate fields. -- [ ] Profile creation/update/complete calls use idempotency keys where retries are possible. -- [ ] Webhook routing is based on Sent event fields or a send-time Sent message ID mapping. -- [ ] Application lifecycle states are not presented as Sent API enum values. -- [ ] Offboarding drains sends, webhooks, credentials, and retained evidence. - -## Related skills +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -Use `sms-10dlc-registration` when a profile needs US A2P SMS brand/campaign registration, opt-in review, or 10DLC troubleshooting. +Status is surface-specific: -Use `waba-embedded-signup` when the architecture includes WhatsApp WABA/phone-number connection or Embedded Signup. +- Create response currently demonstrates lowercase `incomplete`. +- Completion `202` means processing started and does not contain a final status. +- Completion `200` currently demonstrates lowercase `completed` for an already-complete profile. +- Completion callbacks can report `COMPLETED`, `SUBMITTED`, or `failed`. +- REST guides and OpenAPI publish different profile status sets. -Use `rcs-agent-onboarding` when the profile needs RCS approval, launch evidence, or fallback design. +Do not assert a closed REST enum. Preserve unknown strings and record the endpoint/callback surface that produced them. -Use `template-builder-ui` when the architecture decision depends on reusable cross-channel template lifecycle. +## Webhook attribution -Use `messaging-performance-analyzer` after launch when webhook, delivery, or activity evidence shows a performance issue. +Sent events do not contain your application tenant ID. Before sending, persist the returned `message_id` with the tenant and profile. Route outbound status events through that mapping. For inbound messages, map the receiving number/profile resource to the tenant. -Use the `sent` skill for shared Sent terminology and routing. +```text +message_id -> tenant_id, profile_id, logical_send_id, channel +receiving_number -> tenant_id, profile_id +``` -## Suggested bundled references and scripts +Do not infer tenant ownership from `account_id` alone. Multiple tenant profiles can belong to one organization. -| File | Type | Purpose | -|---|---|---| -| `references/multi-tenancy-patterns.md` | Architecture reference | Keep detailed routing, rate-limit, idempotency, and offboarding patterns outside the skill body. | -| `references/sender-profile-data-model.md` | Schema reference | Provide recommended application tables and mapping fields for Sent profile integrations. | -| `references/profile-boundary-examples.md` | Worked examples | Show ISV, marketplace, multi-brand enterprise, and department-level profile splits. | +## Design checklist -## Unverified claims to confirm or remove +- [ ] Tenant/brand isolation decision is explicit. +- [ ] Credential pattern and rate-limit/blast radius are documented. +- [ ] Sharing and inheritance directions are intentional. +- [ ] Billing ownership is named. +- [ ] Number references cannot form cycles. +- [ ] WABA path is organization signup, inheritance, or dedicated credentials—not an invented hybrid. +- [ ] Dedicated brand/campaign paths are profile-based. +- [ ] `message_id` and inbound-number mappings support webhook attribution. +- [ ] Unknown profile statuses are tolerated. +- [ ] Tenant offboarding revokes credentials, disables sends, detaches resources safely, and retains audit evidence. -- API keys are issued per **customer account** (not per profile); `x-sender-id` is per-profile and visible in the dashboard but is v2 legacy for routing — v3 uses `x-api-key` alone. Design key blast-radius around the account, not the profile. -- Sent's profile `status` enum is `incomplete | pending_review | approved | rejected`. If your application tracks finer-grained internal lifecycle states (e.g. `partially_active`, `restricted`, `restoring`), they should not be conflated with the Sent profile `status` field — label them as application states. -- Provider-specific routing keys for WhatsApp/RCS/SMS (e.g., `phone_number_id`, `agentId`, TCR campaign ID) should not be required as primary routing keys unless observed in Sent event payloads or docs. The verified v3 webhook payload exposes `account_id`, `message_id`, `channel`, `inbound_number`, `outbound_number`, `template_id` — route on those plus a `message_id` → profile map persisted at send time. +See [references/sender-profile-data-model.md](references/sender-profile-data-model.md) and [references/profile-boundary-examples.md](references/profile-boundary-examples.md) for implementation patterns. diff --git a/claude-plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md b/claude-plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md index bc35e13..a00be20 100644 --- a/claude-plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md +++ b/claude-plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md @@ -1,127 +1,66 @@ -# Multi-Tenancy Patterns for Messaging Apps on Sent — Reference +# Multi-tenancy patterns -Supporting reference for `sender-profile-architect`. Patterns that are *specific to messaging workloads* on Sent — high write volume, webhook fan-in across SMS/WhatsApp/RCS, and the compliance constraints carriers, Meta, and Google impose. Generic multi-tenant SaaS theory is covered exhaustively elsewhere; this doc only captures what changes when SMS, WhatsApp, and RCS run through Sent. +## Preferred: one profile per isolated tenant -## What a Sender Profile owns +Use one organization and one Sender Profile per tenant when tenants have distinct brands, consent evidence, 10DLC campaigns, WABAs, phone numbers, billing, credentials, or incident blast radius. -A Sender Profile is *one tenant's sending identity* across the channels that profile uses. It carries `name`, `description`, `short_name`, `status` (`incomplete | pending_review | approved | rejected`), and a `settings` block of `{default_channel, webhook_url, timezone, language}`. Each channel attaches separately: +Benefits: -- **SMS** — TCR Brand (`/v3/brands`) + at least one Campaign (`/v3/brands/{brandId}/campaigns`), plus one or more phone numbers / short codes. -- **WhatsApp** — Meta WABA + WABA phone numbers (configured via the Sent dashboard / Channels page). -- **RCS** — Google RBM agent (not self-service; via Sent support). +- profile-specific key issuance and revocation; +- explicit organization-key scoping through `x-profile-id`; +- separate inheritance and sharing choices; +- clean tenant offboarding; +- reliable number/profile and message/profile attribution. -A tenant may have multiple profiles (one per brand, region, or use case). Auth is a single account-level `x-api-key`; that key can operate on any profile the account owns. +Organization-key scoping does not create a separate rate-limit pool: the organization's pool remains shared. -## Webhook Routing (the hot path) +## Shared profile -Sent fans channel events into a unified payload shape: +Use a shared profile only if all participants genuinely operate as one sender identity with one brand, resource set, compliance posture, billing/rate-limit expectations, and incident boundary. Lower object count is not sufficient justification. -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} -``` - -Top-level fields: `field`, `sub_type`, `timestamp`, `payload`. `sub_type` follows `.` (e.g., `message.delivered`, `message.failed`, `message.read`). - -Routing back to a Sender Profile uses what your application persisted at send time, joined on stable IDs in the payload: - -| Channel | Verified payload fields | Used to find | -|---|---|---| -| All | `payload.message_id` | the profile that owns this outbound message | -| All | `payload.account_id` | the customer account | -| All | `payload.channel` + `payload.outbound_number` | the configured sender | -| All | `payload.template_id` | the template / its owning profile | - -Narrow webhook subscriptions with `event_filters`: - -```json -"event_filters": { "message": ["delivered", "failed"] } -``` - -Shape: `{: [, ...]}`. Combine with `event_types: ["message"]` to subscribe to the `message` parent and only fire on the listed sub-types. - -ACK fast (≤ webhook `timeout_seconds`, default 30s, max 120s; Sent retries up to `retry_count`, default 3, max 5). Synchronous business logic in the webhook handler kills throughput because three platforms upstream all retry on slow / 5xx responses. - -Two failure modes to design out: - -- **Cold routing key.** A webhook arrives for an `outbound_number` or `template_id` you haven't mapped (the tenant added a number out-of-band, or a template was created in another environment). Log, return 200, alert ops — don't drop the event. -- **Slow routing-key lookup.** Cache the `message_id` → profile mapping aggressively, but back it with durable storage so cold pods resolve correctly. +A shared profile makes tenant credential isolation and webhook attribution application responsibilities. Document that tradeoff. -## Per-Channel Rate-Limit Accounting +## Hybrid inheritance -You account for limits at four layers. Track per-channel; bill at the profile. +Common supported patterns include: -| Source | Limit | Where it comes from | -|---|---|---| -| **Sent — standard endpoints** | 200 req/min, burst 50 | Sent API gateway | -| **Sent — sensitive endpoints** | 10 req/min, burst 5 (e.g., `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/users`, `POST /v3/profiles/{id}/complete`) | Sent API gateway | -| **Sent — message sending tier** | Starter 60/min · Growth 300/min · Enterprise custom | Sent plan tier | -| **Sent — webhook test** | 60/min | Sent API gateway | -| **SMS — TCR campaign TPS** | Per-campaign throughput, assigned after vetting | TCR + carrier reconciliation | -| **WhatsApp — phone-number tier** | 1K / 10K / 100K / unlimited business-initiated conversations per 24h, plus Cloud API CPS | Meta — readable from the phone-number record | -| **RCS — agent QPS** | Google RBM | Google | -| **Your per-profile quota** | Whatever you actually sell | Your billing layer | +| Brand | Campaign | Flags | +| --- | --- | --- | +| Organization brand | Organization campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Organization brand | Dedicated profile campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | +| Dedicated profile brand | Dedicated profile campaign | both false, with `brand` at profile creation | -Rate-limit responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (on 429) `Retry-After`. The error code on 429 is `BUSINESS_002`. +Do not set `brand` while `inherit_tcr_brand` is true. Inherited campaigns are read-only. -Bill against the Sender Profile, not the customer account — an account with three brands gets three meters. +## Credentials -## Idempotency +- A profile key uses only `x-api-key`. +- An organization key may add `x-profile-id` for a child. +- A profile key with `x-profile-id` receives `403`. +- `x-sender-id` is legacy v1/v2 terminology. -Header: `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Cached for **24 hours**, scoped **per customer account**. +Store credentials in a secret manager. Never expose organization keys to tenant-controlled runtimes. -Concurrent requests with the same key → second returns `409 CONFLICT_001`. Replays carry `Idempotent-Replayed: true` and `X-Original-Request-Id: ` headers. +## Webhook fan-out -**Same key + different payload → returns the cached response for the *first* payload, not a new one.** Use a unique key per distinct operation. A safe pattern: hash `(profile_id, operation, client_intent_id)` into the key so a re-send of the same business intent is idempotent but a different intent on the same profile is not. +Outbound events carry `payload.message_id`, not your tenant ID. Persist: -Supported on all profile mutations: -- `POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}` -- `POST /v3/profiles/{id}/complete` - -And on every other mutation endpoint listed in the snapshot (`/v3/messages`, `/v3/contacts`, `/v3/templates`, `/v3/brands`, `/v3/brands/{id}/campaigns`, `/v3/webhooks`, `/v3/users`). - -Sandbox mode (`"sandbox": true` in the body) stacks with idempotency — validates the request, returns a realistic fake response, and caches it for 24 hours. Useful for CI per-tenant smoke tests. - -## Outbound Message Idempotency - -Outbound message sends should also be idempotent on the tuple `(profile_id, channel, client_message_id)` at the application layer. Persist the intent to send *before* the upstream `POST /v3/messages` — if the call succeeds but your write fails, a retry would otherwise duplicate. Pair this with a stable `Idempotency-Key` on the Sent request itself so Sent collapses the duplicate even if your row write reaches Sent first. - -## State Reconciliation (per channel) - -The Profile resource exposes a coarse `status` (`incomplete | pending_review | approved | rejected`). Per-channel readiness (TCR vetting score, WhatsApp messaging tier, RBM launch state) is not in this snapshot — re-fetch from the dashboard or upstream APIs on a schedule: - -- **TCR / SMS** — campaign vetting score updates and carrier-level filtering changes don't always fire webhooks. Reconcile daily for healthy campaigns, hourly when state was recently changing. -- **WhatsApp** — phone-number quality rating and messaging tier change without webhooks. Re-fetch every few hours; alert on transitions. -- **RCS** — agent launch state and carrier rollout status update silently. Re-fetch daily for launched agents and more often during initial verification. - -Track when each channel was last reconciled so dashboards can show how stale each profile is. Don't conflate this internal staleness with Sent's `status` field. - -## Channel-Specific Anti-Patterns +```text +message_id -> tenant_id, profile_id, channel, logical_send_id +``` -- **WhatsApp** — Sharing one Meta System User token across multiple Sender Profiles. Token revocation now disables every profile. -- **SMS** — Reusing a TCR campaign across tenants. The campaign vetting score follows whoever the brand says it is — share at your peril. -- **RCS** — Hardcoding the RBM agent into the application instead of attaching it to a Sender Profile. Multi-region or multi-brand tenants will need multiple agents and the code path forks. -- **All** — Synchronous webhook processing. Throughput dies and the platforms retry aggressively. -- **All** — Claiming "data residency" by application-level filtering when the legal commitment is storage-level isolation. +Inbound events carry destination number/channel information. Persist: -## Tenant Offboarding +```text +channel + destination_number -> tenant_id, profile_id +``` -When a tenant churns, run the per-channel teardown — not just a state flag: +Verify signatures before lookup, deduplicate events, and route unknown mappings to a quarantine queue. Never guess the tenant from organization `account_id`. -- **SMS** — deactivate the TCR campaign(s), release the phone number(s) per Sent's release flow. -- **WhatsApp** — unsubscribe your app from the WABA, revoke the System User token. -- **RCS** — unlaunch / suspend the RBM agent (via Sent support). +## Offboarding -Then `DELETE /v3/profiles/{id}` to soft-delete the profile (use an `Idempotency-Key`). Disable or delete webhook subscriptions that fan into this profile. Schedule message-content deletion per your retention policy. +1. Block new application sends. +2. Revoke profile keys; rotate organization credentials if exposure is possible. +3. Disable or reroute webhooks and number references. +4. Preserve message/profile mappings for retention and disputes. +5. Delete the profile only after resource ownership and compliance retention are resolved. diff --git a/claude-plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md b/claude-plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md index c6d8ee0..74efd92 100644 --- a/claude-plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md +++ b/claude-plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md @@ -1,111 +1,50 @@ -# Profile Boundary Examples — Reference +# Profile boundary examples -Supporting reference for `sender-profile-architect`. Worked examples for "where should the Sender Profile boundary go?" — the question that determines blast radius, billing granularity, and onboarding pain. Each example covers when the boundary makes sense, when it doesn't, ops considerations, and how the choice ripples into 10DLC registration and WABA setup. +## Marketplace with fifty merchants -The default rule of thumb: **one Sender Profile per legal entity per distinct sending identity**. The examples below are when to break that rule. +Use one organization and one Sender Profile per merchant. Give independently operated merchant runtimes profile keys; keep an organization key only in the trusted control plane. Each merchant can inherit organization templates while owning a dedicated campaign under the inherited organization brand if policy permits. -## What's confirmed in v3 (read before using the examples) +## One enterprise brand with regional teams -- **Auth.** A single account-level `x-api-key` operates on every profile the account owns. There is no per-profile API key in v3. -- **`x-sender-id`.** Visible per-profile in the dashboard (Profiles page maps to `/v3/profiles` endpoints), but is v2 legacy for routing; v3 needs only `x-api-key`. Treat it as a per-profile identifier you can read, not as the auth header. -- **Profile `status`.** The Sent-side enum is `incomplete | pending_review | approved | rejected`. Anything finer-grained ("partially_active", "restricted", "restoring") is an application-level label. -- **Org-level user roles.** Owner / Admin / Billing / Developer. Owner is the only role with billing-ownership transfer rights; the Profile resource's per-call `role` field surfaces `admin | billing | developer` (not Owner) for the authenticated user's role in that profile. -- **10DLC.** Brand and Campaign are first-class Sent resources at `/v3/brands` and `/v3/brands/{brandId}/campaigns` — register them before completing the SMS portion of a profile. +A shared profile may be defensible when every team uses the same legal/consumer brand, consent posture, WABA/numbers, billing, and operations. If a region requires a distinct number, campaign, credential, or incident boundary, split it into its own profile. -## 1. One profile per legal entity (single-brand SaaS) +## Dedicated WABA tenant -A small SaaS with one corporate identity sending password resets, billing reminders, and product nudges from a single sender name. +Create the profile with: -**Picks this when:** all messages legitimately come from the same business, same brand voice, same vetting story. +```json +{ + "name": "Acme Support", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -**Doesn't pick this when:** the company runs multiple consumer brands under one corporate parent (those want separate profiles even if the same lawyer signs both TCR forms). +`phone_number_id` is optional. The access token must be injected from a secure runtime, never included in logs, fixtures, support tickets, or responses. -**Ops:** -- One webhook, one secret to rotate, one set of API keys. Lowest operational overhead. -- Single TCR Brand registration; multiple TCR Campaigns under it for distinct use cases (transactional vs marketing). -- Single WABA, one System User token. WABA quality rating reflects all sending behavior. -- Billing is a single line item — easy for finance, hard to attribute internally if multiple product teams share the profile. +## Organization WABA inheritance -**10DLC:** one Brand, multiple Campaigns. If transactional and marketing share one Campaign, carrier filtering will be harsher than necessary — split them. +After organization Embedded Signup is complete, omit `whatsapp_business_account` on the child profile. Omitting it without an organization WABA returns `422`. This is inheritance, not an API-started Embedded Signup flow. -**WABA:** one WABA, one phone number to start; tier upgrades benefit everything sent through this profile. +## Dedicated 10DLC brand -## 2. One profile per channel (WhatsApp-only vs SMS-fallback chains) +Set `inherit_tcr_brand: false` and include `brand` with `POST /v3/profiles`. Create campaigns through `/v3/profiles/{profileId}/campaigns`. Do not create a free-standing brand resource. -A tenant explicitly wants channel isolation — e.g. a WhatsApp-only consumer brand whose ops team should never accidentally send SMS, or a region where RCS is the primary channel with SMS strictly as fallback. +## Shared SMS number reference -**Picks this when:** legal, compliance, or product policy requires that channel-by-channel sending be controllable independently and visible independently in billing. +Use `sending_phone_number_profile_id` when a profile intentionally reuses another profile's SMS configuration. Record the source profile and prevent circular references. A direct `sending_phone_number` is a different mode and should not be conflated with the profile reference. -**Doesn't pick this when:** the channels are genuinely interchangeable for the same user journey. Sent's whole point is unifying sending — splitting profiles by channel often duplicates work without adding isolation. +## Webhook routing -**Ops:** -- Multiple webhook subscriptions, multiple secrets. Secret-rotation blast radius is smaller per channel, larger in aggregate. -- The "fallback" pattern (try WhatsApp, fall back to SMS) becomes an application-level orchestration across two profiles instead of one. Manageable, but inbound replies on the SMS profile won't carry the original WhatsApp `wamid` context — your app has to stitch threads. -- Cost tracking is cleanly per-channel. +When the send response returns message IDs, write all of them before treating the operation as accepted: -**10DLC:** the SMS-only profile carries the TCR registration; the WhatsApp-only profile has none. Don't register TCR for a profile that won't send SMS. +```text +message A -> tenant 42, profile P42, channel sms +message B -> tenant 42, profile P42, channel whatsapp +``` -**WABA:** the WhatsApp profile carries the WABA. Quality scoring is isolated — a bad SMS campaign won't pull WhatsApp tier down. - -## 3. Per-department profiles (sales, support, marketing) on the same brand - -A mid-sized company wants Sales, Support, and Marketing to send under the same overall brand but with different sender names, different vetting stories, and different billing meters. - -**Picks this when:** internal billing attribution matters (Marketing's budget is separate from Support's), or each department's send volume / patterns are different enough that mixing them would hurt vetting (Marketing's bulk sends would tank Support's quality rating). - -**Doesn't pick this when:** the departments truly send identical-looking traffic under one external brand. Splitting buys complexity without changing what carriers see. - -**Ops:** -- Three webhooks (or one webhook routing on `payload` fields), three sets of templates — but one shared account-level `x-api-key`. Reusable copy (e.g. WhatsApp templates) has to be authored per profile or copied between them. -- Quota / rate-limit accounting is per profile — Marketing can be throttled without affecting Support. -- Suspension blast radius is per profile — a Meta quality drop on Marketing doesn't pause Support. - -**10DLC:** typically one TCR Brand (same legal entity) but separate Campaigns per department's use case. Some tenants register multiple Brands if Marketing operates as a distinct legal entity. - -**WABA:** can be one WABA with multiple phone numbers split across profiles, or one WABA per profile. One-WABA-multiple-profiles complicates Sent-side modeling (each profile attaches to the same WABA); separate WABAs are cleaner if the departments genuinely want isolation. - -## 4. Per-tenant profile in a B2B2C platform (one Sent customer hosting many merchants) - -A platform — appointment-booking SaaS, e-commerce host, marketing platform — has one Sent contract but serves hundreds of downstream merchants who each need their own sender identity. - -**Picks this when:** each merchant is a distinct end-business that needs to appear as themselves to recipients, comply with TCR / Meta independently, and have their own billing meter. - -**Doesn't pick this when:** all merchants legitimately send "from" the platform brand. Then one profile is correct and merchant attribution is an internal concern. - -**Ops:** -- Profile provisioning is part of the merchant onboarding flow — this is where the WABA Embedded Signup (`waba-embedded-signup`) and 10DLC registration (`sms-10dlc-registration`) skills get invoked hundreds of times. -- Webhook fan-in: one Sent webhook per profile is unmanageable at hundreds of profiles. Either configure all profiles to one webhook URL and route on payload fields (`payload.account_id`, `payload.outbound_number`, `payload.template_id`, plus a `message_id`-to-profile map you keep at send time), or run per-profile webhooks behind a routing layer. The single-URL pattern has one secret to rotate and a larger blast radius. -- API-key blast radius: v3 issues a single account-level `x-api-key`, so a compromised key affects every merchant under that account. If per-merchant key isolation is a requirement, give each merchant its own Sent customer account (not just its own profile) — that's the only boundary that produces a distinct API key today. -- Billing: per-merchant meters fall out of per-profile accounting cleanly. - -**10DLC:** each merchant is its own TCR Brand and Campaign. The platform does not register *its own* Brand on behalf of merchants — the merchant signs. Plan for per-merchant TCR vetting time (days, not seconds). - -**WABA:** each merchant goes through Embedded Signup to attach their own WABA. The platform is the Tech Provider on Meta's side. Plan for the support burden — Meta's "phone number already in use" errors land on the platform. - -## 5. Per-region / per-geo profiles - -A business serving multiple regions wants to honor local regulations, language defaults, and regulator-specific sender identities (e.g. EU brand vs US brand vs LATAM brand). - -**Picks this when:** regions have genuinely different regulators (US 10DLC + Brazil's regs + EU's WhatsApp rules), different timezones for send-window enforcement, or different localized sender display names. - -**Doesn't pick this when:** the regional split is only a marketing convenience and all sending is from one legal entity with one global compliance posture. - -**Ops:** -- Per-region webhooks make per-region failover and on-call rotation straightforward. -- Per-region rate limits avoid one region's burst exhausting another's budget. -- Per-region secret rotation contains blast radius geographically. -- Cross-region analytics need a join layer — the per-profile billing/usage data has to be aggregated for the global view. - -**10DLC:** US-only concept. The US-region profile is the one with TCR; non-US profiles ignore TCR entirely. Don't try to register a non-US brand with TCR "for completeness". - -**WABA:** WABAs can serve global recipients, but pricing tiers and template approval workflows differ by recipient country, not by WABA region. Per-region WABAs typically map to per-region Meta Business Manager assets and per-region System User tokens — that's the operational reason to split, not regulatory. - -## Decision heuristic - -When you're not sure whether to split, ask in order: - -1. **Are these messages legally from the same entity?** No → split. -2. **Will one identity's bad behavior unfairly impact another's quality rating / TCR vetting?** Yes → split. -3. **Does anyone need to see per-X billing or rate limiting (X = brand, department, merchant, region)?** Yes → split. -4. **Do these channels actually share a user journey or are they being kept apart for ops reasons?** Kept apart → split. -5. **Otherwise:** one profile. Splits are cheap to add later; merges are not. +Multiple explicit channels create multiple messages. Route each webhook by `message_id`; do not expect an application tenant ID in the event. diff --git a/claude-plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md b/claude-plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md index d416557..91e3120 100644 --- a/claude-plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md +++ b/claude-plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md @@ -1,159 +1,98 @@ -# Sender Profile Data Model — Reference - -Supporting reference for `sender-profile-architect`. Captures the conceptual model Sent exposes for a Sender Profile and the resources that attach to it, as verified against the Sent v3 docs snapshot. - -This doc is **conceptual**, not a schema dump. Resource shapes evolve; the relationships below are what stabilize them. - -## Authentication context - -In v3, authentication is header-only with a single key per account: - -```http -x-api-key: +# Sender Profile data model + +## Core records + +```text +organization + id + organization_key_secret_ref + +tenant + id + organization_id + sent_profile_id + profile_key_secret_ref + credential_pattern + billing_model + +profile_policy + profile_id + allow_contact_sharing + allow_template_sharing + inherit_contacts + inherit_templates + inherit_tcr_brand + inherit_tcr_campaign + +channel_binding + profile_id + channel + direct_number + source_profile_id + waba_id + +message_attribution + message_id + logical_send_id + tenant_id + profile_id + channel + +inbound_route + channel + destination_number + tenant_id + profile_id ``` -The dashboard exposes a per-profile `x-sender-id` value for inspection and v2 legacy routing, but v3 API auth needs only `x-api-key`. API keys are issued at the **customer account** level, not per profile — a single API key can list, retrieve, create, update, complete, or delete any profile the account owns via `/v3/profiles*` endpoints. +`source_profile_id` models `sending_phone_number_profile_id` and `sending_whatsapp_number_profile_id`. Enforce referential integrity and prevent cycles. -## Sender Profile resource (verified) +## Profile request fields -The verified Profile model in Sent v3: +Create supports identity, sharing/inheritance, billing, dedicated WABA credentials, and a profile-owned `brand`. Update additionally supports number reference/direct-number fields and onboarding number-change policy. -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | Stable, Sent-issued | -| `name` | string | Display name | -| `icon` | string \| null | URL | -| `description` | string \| null | Human-readable description | -| `short_name` | string \| null | Compact label | -| `role` | `admin` \| `billing` \| `developer` \| null | The calling user's role within this profile | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status — these are the **only** Sent-side enum values | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | +Brand request fields are grouped into: -Note: `role` is the *calling user's* role in this profile, not a property of the profile itself. The org-level role enum (Owner / Admin / Billing / Developer) is the full set; the field omits `Owner` because that's billing-owner-only. +- `contact`: representative and business-facing contact data; +- `business`: legal identity, tax/entity type, address, country, URL; +- `compliance`: vertical, brand relationship, primary use case, TCR flag, number prefix, destination countries, notes. -## Sender Profile -> Channels -> Webhooks +Treat request camelCase inside `brand` separately from snake_case response fields. Do not round-trip by blindly serializing a response object as a create request. -``` -Customer account (one x-api-key) - │ - ├── SenderProfile (1..N) - │ ├── id, name, short_name, description, icon - │ ├── status: incomplete | pending_review | approved | rejected - │ ├── role (caller's role: admin | billing | developer) - │ ├── settings.default_channel ── routes if /v3/messages omits "channel" - │ ├── settings.webhook_url ── per-profile webhook destination - │ ├── settings.timezone, .language - │ │ - │ └── Channels (attached via dashboard / compliance flow) - │ ├── SMS → Brand (TCR) → Campaign(s) - │ ├── WhatsApp → WABA + phone number(s) - │ └── RCS → RBM agent (via Sent support) - │ - └── Webhooks (configured separately; can be per-profile or shared) - ├── id, display_name, endpoint_url, is_active - ├── event_types: ["message", "templates", ...] - ├── event_filters: {: [, ...]} - ├── signing_secret, retry_count (1-5, default 3), - │ timeout_seconds (5-120, default 30) - └── last_delivery_attempt_at, last_successful_delivery_at, - consecutive_failures -``` +## Authentication invariant -Webhook payloads have the shape: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} +```text +profile key -> x-api-key only +organization key -> x-api-key + optional x-profile-id ``` -`payload.account_id` is the customer-account-level UUID. The payload does not currently include a top-level `profile_id`; routing back to a profile relies on the channel/numbers/template recorded at send time. - -## How channels attach to a profile - -Channels are configured per-profile through the Sent dashboard and compliance flow. Each channel attachment carries the regulator-facing identity for that channel: - -| Channel | Attached identity | Sent endpoints | -|---|---|---| -| SMS | A TCR-registered brand + at least one campaign, plus phone numbers / short codes | `/v3/brands`, `/v3/brands/{brandId}/campaigns` | -| WhatsApp | A Meta WABA + one or more WABA phone numbers (linked through Sent's Channels / WABA Embedded Signup flow) | Dashboard-only configuration in v3 | -| RCS | A Google RBM agent (not self-service; initiated via `support@sent.dm`) | Dashboard-only configuration in v3 | - -Two consequences fall out of this model: +Only the organization pattern may include `x-profile-id`. Rate limits for organization-scoped requests remain in the organization pool. -1. **Provisioning is per-channel and largely dashboard-driven.** A profile's `status` (`incomplete | pending_review | approved | rejected`) is the rollup; per-channel readiness is tracked alongside it but not in the verified Profile resource. If your application tracks finer-grained channel readiness it should label those as internal app states, not Sent enum values. -2. **Channel identifiers are the inbound routing keys.** The data model is what makes "look up the Sender Profile from an inbound phone number / WABA / RBM agent" possible — but in v3 those joins are made on data you persist at send time, not on a `profile_id` carried in the inbound webhook payload. +## Campaign ownership -## Brands and Campaigns (SMS / 10DLC) +Campaigns belong to the brand selected through a profile but are operated through profile paths: -The word *brand* shows up at two layers — keep them distinct: - -- **Profile metadata** — the `name`, `description`, and `short_name` on the Sender Profile, used for display and as default sender identity. -- **TCR Brand** — the legal-entity record registered with The Campaign Registry, mandatory for US 10DLC SMS. Sent exposes these as first-class resources: - - `GET /v3/brands`, `POST /v3/brands`, `PUT /v3/brands/{brandId}`, `DELETE /v3/brands/{brandId}` - - `POST /v3/brands/{brandId}/campaigns`, `PUT /v3/brands/{brandId}/campaigns/{id}`, `DELETE /v3/brands/{brandId}/campaigns/{id}` - -``` -SenderProfile (Sent) - └── SMS channel - └── TCR Brand (1) - └── TCR Campaign (1..N) ─── carrier filtering happens here +```text +/v3/profiles/{profileId}/campaigns +/v3/profiles/{profileId}/campaigns/{campaignId} ``` -For 10DLC details and use-case selection, see `sms-10dlc-registration`. - -WhatsApp has no direct analogue of TCR Campaigns — per-template approval (via `/v3/templates`) plays a similar role. RCS uses one RBM agent per profile per region. - -## Webhooks - -Webhooks are configured at the account level and can be scoped to a profile via `settings.webhook_url`, or you can configure a single webhook URL and route on the payload (`payload.account_id`, `payload.channel`, `payload.outbound_number`, `payload.template_id`). +An inherited brand can have profile-owned campaigns when campaign inheritance is disabled. -Key webhook config fields: +## Status storage -- `event_types` — list of parent event types subscribed (e.g., `["message"]`, `["message", "templates"]`). -- `event_filters` — narrow within a parent type: `{"message": ["delivered", "failed"]}` only fires for `message.delivered` and `message.failed`. -- `signing_secret` — rotate via `POST /v3/webhooks/{id}/rotate-secret` (sensitive endpoint: 10 req/min limit). Rotation invalidates the old secret immediately. -- `retry_count` 1-5 (default 3), `timeout_seconds` 5-120 (default 30) — Sent will retry up to `retry_count` times if your endpoint times out or 5xxs. -- `consecutive_failures` — surfaced so you can monitor a failing webhook before it's auto-disabled. - -## Idempotency - -`POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}`, and `POST /v3/profiles/{id}/complete` all support `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Keys are cached **per customer account for 24 hours**. See `references/multi-tenancy-patterns.md` for the full idempotency/sandbox behavior across endpoints. - -## ER summary +Store at least: +```text +profile_id +status_raw +status_surface # create_response, rest_profile, completion_200, completion_callback +observed_at +payload_version ``` -Customer account ── x-api-key (one per account) - │ 1..N - ▼ -SenderProfile { id, name, status, role, settings } - │ - ├── Brand (TCR) ── Campaign(s) ── carrier-filtered SMS senders - ├── WABA ── PhoneNumber(s) (configured via dashboard) - └── RBM Agent (configured via Sent support) - -Webhooks (separate resource; can be many-to-one with profiles) - └── event_types[], event_filters{}, signing_secret, retry/timeout config -``` - -The single architectural rule that follows: every channel-specific record your application stores should reference the Sent **profile `id`**, not the customer `account_id` directly. Account is a foreign key on profile. -## What to confirm against the live OpenAPI +Known examples vary in case and vocabulary. Do not normalize unknown values into a closed enum. -Before writing application code, confirm the endpoint and schema details against the live Sent v3 OpenAPI at https://docs.sent.dm: +## Secret boundaries -- Exact channel-attachment fields on the Profile resource (the snapshot doesn't enumerate per-channel sub-objects). -- Whether `payload.profile_id` is added to webhooks in future API revisions. -- API-key creation + scope semantics (API Keys is dashboard-only in this snapshot). +WABA `access_token` and payment card fields are write-only operational secrets. Never log, echo, or persist raw values in profile records. Keep only secret-manager references and non-sensitive identifiers such as `waba_id`. diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/SKILL.md b/claude-plugins/sent/skills/sms-10dlc-registration/SKILL.md index fd26358..365004d 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/SKILL.md +++ b/claude-plugins/sent/skills/sms-10dlc-registration/SKILL.md @@ -1,165 +1,108 @@ --- name: sms-10dlc-registration -description: Prepares Sent US A2P SMS 10DLC compliance by collecting business, opt-in, brand, campaign, sample-message, and profile-completion evidence. Use when a user says 10DLC, A2P, TCR, campaign registry, brand vetting, SMS compliance, US texting, long code registration, opt-in proof, opt-out language, carrier filtering, or needs to register SMS through Sent. +description: Prepares and validates Sent US A2P 10DLC brand and campaign registration through Sender Profiles, including inheritance, all campaign use cases, opt-in evidence, sample-message policy, autoresponses, sandbox validation, TCR status, and rejection remediation. --- - +Use this skill for US A2P SMS over 10-digit long codes. Separate the compliance evidence packet from the exact Sent API request; they have different schemas and validators. -# SMS 10DLC registration +## Current Sent resource model -## Overview +There is no standalone brand CRUD path in the current v3 API. -Use this skill to prepare US A2P SMS compliance for Sent. Sent’s compliance documentation states that compliance is a prerequisite for sending messages and that 10DLC registration is mandatory for A2P messaging to US numbers. Sent handles TCR registration as part of the compliance process, while the customer must provide accurate business identity, consent, use-case, sample-message, and opt-out evidence. +- Create a dedicated brand inside `POST /v3/profiles` using `brand` and `inherit_tcr_brand: false`. +- List/create campaigns with `GET|POST /v3/profiles/{profileId}/campaigns`. +- Update/delete with `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. -The Sent v3 API exposes Sent-facing brand and campaign resources through `/v3/brands` and `/v3/brands/{brandId}/campaigns`. Profile completion through `/v3/profiles/{profileId}/complete` validates profile, brand, and campaign prerequisites before the profile is ready. +Reject guidance that reintroduces a free-standing brand path. -## When to use +## Choose inheritance deliberately -Use this skill when the request mentions 10DLC, A2P, TCR, brand registration, campaign registration, SMS compliance, US long code, EIN, opt-in proof, sample messages, opt-out, HELP/STOP language, vetting, rejected campaign, or carrier filtering caused by compliance. Use it before enabling US SMS sending or SMS fallback for RCS. +| Brand | Campaign | Settings | +| --- | --- | --- | +| Inherit both | Organization brand and campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Inherit brand, own campaign | Shared legal brand with tenant-specific traffic | brand true, campaign false | +| Own both | Dedicated tenant/business | both false and supply `brand` during profile creation | -Do not use this skill for non-US country compliance unless the user supplies a Sent compliance source for that country. Do not use it to analyze live delivery failures except to identify whether compliance status is the likely next check. +Inherited campaigns are read-only. A profile cannot supply `brand` while brand inheritance is true. -## Required evidence +## Two validation layers -Collect evidence before creating or updating Sent brand/campaign resources. Bad evidence creates review loops and downstream filtering risk. +### Evidence readiness packet -| Evidence | What to capture | Sent-grounded reason | -|---|---|---| -| Legal business identity | Legal business name, address, EIN/tax ID, entity type | Sent’s compliance guide lists these as required inputs. | -| Public web presence | Live website URL and privacy policy URL | Sent requires a live website and privacy policy for compliance review. | -| Opt-in mechanism | URL, screenshot, form text, checkbox language, or checkout flow | Sent requires an opt-in mechanism URL. | -| Use-case description | Clear description of what messages are sent and why | Sent requires use-case description. | -| Sample messages | Realistic messages matching the declared use case | Sent requires sample messages. | -| Opt-out instructions | STOP/HELP or equivalent instructions where applicable | Sent requires opt-out instructions. | -| Sender Profile | Sent profile ID or dashboard profile being completed | Profile completion validates compliance prerequisites. | +The private packet uses the explicit internal version `sent-10dlc-evidence/v1` and snake_case evidence fields. It is not an API payload. -## Process +```bash +python scripts/validate_10dlc_packet.py evidence.json +``` -### 1. Decide whether this is US A2P SMS +Collect legal identity, public website/policy links, consent proof, message flow, opt-in/opt-out/help responses and keywords, use cases, and realistic samples. See [references/10dlc-evidence-checklist.md](references/10dlc-evidence-checklist.md). -Confirm destination country, traffic type, and sender type. This skill applies to US A2P SMS over 10DLC. If the user is sending only WhatsApp, RCS without SMS fallback, short code, toll-free, or non-US traffic, document the difference and route to the appropriate compliance workflow. +### Sent campaign request -**Example.** “We send appointment reminders from a SaaS platform to US patients using local long-code numbers” is US A2P SMS and needs 10DLC. “We send only WhatsApp utility templates” is not a 10DLC workflow, though WhatsApp has its own template and business requirements. +The API request uses exact camelCase and a `campaign` wrapper: -### 2. Normalize the business identity + +```json +{ + "campaign": { + "name": "Acme account notifications", + "description": "Account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in in account settings before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} +``` -Use the exact legal business name and tax ID records. Do not “clean up” the name to a marketing brand if the tax record uses another legal entity. Mismatches between legal identity, website, and opt-in flow are common rejection causes. +Validate it with: -If the customer is an ISV registering many customers, decide whether each customer needs its own profile/brand/campaign boundary with `sender-profile-architect`. Do not put unrelated customers under one brand because it is faster. +```bash +python scripts/validate_campaign_payload.py campaign.json +``` -### 3. Classify the campaign by use case +## API use cases -Pick the narrowest truthful campaign use case. Mixed-use campaigns can be valid, but they invite broader review and more filtering risk if the sample messages do not match the declared intent. +Support all 13 current values: -| Declared intent | Better sample | Bad sample | -|---|---|---| -| Account notification | “Acme: Your password was changed. If this was not you, visit https://acme.example/security. Reply STOP to opt out.” | “Huge sale today. Click now.” | -| Delivery notification | “Acme: Order 1234 is out for delivery today. Track: https://acme.example/t/1234. Reply STOP to opt out.” | “Your package is coming. Also buy these add-ons.” | -| Customer care | “Acme Support: We received your request and will respond shortly. Reply STOP to opt out.” | “Thanks for contacting us. Get 20% off now.” | -| Marketing | “Acme: Spring sale starts today. Use code SPRING. Reply STOP to opt out.” | Transactional description with promotional samples. | +`MARKETING`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `FRAUD_ALERT`, `TWO_FA`, `DELIVERY_NOTIFICATION`, `SECURITY_ALERT`, `M2M`, `MIXED`, `HIGHER_EDUCATION`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, and `LOW_VOLUME`. -Keep detailed TCR taxonomy and carrier-specific advice in a reference file. In the skill body, use only enough taxonomy to keep the submission honest. +Each use case structurally accepts 1–5 samples, each no longer than 1,024 characters. The compliance layer requires at least two samples for marketing and mixed traffic, including low-volume mixed. Keep that policy distinction visible instead of pretending OpenAPI requires two for all traffic. -### 4. Create or update Sent brand resources +## Volume and status -Use Sent’s brand endpoints when API work is in scope. The verified v3 API includes: +`volume` is optional and, when supplied, is a numeric string. Values below `"2000"` use the documented low-volume tier; `"2000"` is the boundary to the next tier. -| Operation | Endpoint | Notes | -|---|---|---| -| Create brand | `POST /v3/brands` | Creates a new brand and associated information. | -| List brands | `GET /v3/brands` | Retrieves brands for the authenticated customer, including inherited brands where applicable. | -| Update brand | `PUT /v3/brands/{brandId}` | Cannot update brands already submitted to TCR or inherited brands. | -| Delete brand | `DELETE /v3/brands/{brandId}` | Deletes a brand that belongs to the authenticated customer. | +Campaign responses currently expose statuses `SENT_CREATED`, `ACTIVE`, and `EXPIRED`, plus `submittedToTCR`. Preserve unknown future status strings. Do not confuse a successful Sent record creation with TCR submission or carrier activation. -Use optional `Idempotency-Key` headers on create/update calls when retrying. Store the Sent brand ID returned by the API. Store any returned TCR identifiers separately only if the API response exposes them. +## Safe workflow -### 5. Create or update Sent campaign resources +1. Confirm this is US A2P 10DLC traffic and the actual sending business is identified. +2. Select brand/campaign inheritance. +3. Validate the versioned evidence packet. +4. Create or confirm the profile brand. +5. Translate evidence into the exact camelCase campaign request. +6. Validate locally and use `sandbox: true`. +7. Show the payload and obtain confirmation before a real create/update/delete. +8. Store profile ID, campaign ID, `submittedToTCR`, raw status, and review evidence. +9. Complete the profile with required `webHookUrl` only after prerequisites are ready. -Create campaigns under the relevant Sent brand. The verified v3 API says each campaign must include at least one use case with sample messages. - -| Operation | Endpoint | Notes | -|---|---|---| -| Create campaign | `POST /v3/brands/{brandId}/campaigns` | Links the campaign to the brand and requires use-case/sample-message data. | -| List campaigns | `GET /v3/brands/{brandId}/campaigns` | Retrieves campaigns and their use cases/sample messages. | -| Update campaign | `PUT /v3/brands/{brandId}/campaigns/{campaignId}` | Cannot update campaigns already submitted to TCR. | -| Delete campaign | `DELETE /v3/brands/{brandId}/campaigns/{campaignId}` | Deletes a campaign within the brand. | - -Do not claim a public `tcr_campaign_id` field unless the actual response includes it. Refer to the Sent campaign ID for Sent API operations. - -### 6. Complete the Sender Profile setup - -After profile data, brand, and campaign prerequisites are ready, call or trigger profile completion through `POST /v3/profiles/{profileId}/complete`. The OpenAPI describes this as the final step in the profile compliance workflow, validating prerequisites and connecting profile configuration in the background. - -If completion fails, fix the missing prerequisite rather than creating duplicate brands or campaigns. Duplicate compliance objects increase confusion and can lead to sending from the wrong profile. - -### 7. Prepare the review-ready submission summary - -End the workflow with a compact summary the user can paste into Sent support, a dashboard form, or an internal ticket. Include legal identity, website, privacy policy, opt-in URL/evidence, use-case description, sample messages, opt-out instructions, Sent profile ID, Sent brand ID, Sent campaign ID, and any unresolved questions. - -**Example summary.** - -> “Acme Logistics LLC, EIN ending 1234, sends US SMS delivery notifications to customers who opt in at checkout. Website and privacy policy are live. Opt-in screenshot and URL are attached. Campaign use case is delivery notification. Sample messages match shipment status only and include opt-out instructions. Sent profile `...`, Sent brand `...`, Sent campaign `...` are ready for completion.” - -## Common rationalizations to avoid - -Do not register a marketing campaign as a utility or account-notification campaign because it may be cheaper or easier. The samples, opt-in flow, and actual traffic must match. - -Do not submit placeholder websites, private staging URLs, or missing privacy policies. Sent’s compliance guide calls for live URLs. - -Do not reuse one brand/campaign for unrelated customers. Compliance belongs to the sender and use case, not just the platform sending the API call. - -Do not edit a submitted brand or campaign in place if the API says submitted objects cannot be updated. Create the right correction path with Sent. - -Do not promise exact approval times beyond Sent’s guidance. Sent says TCR registration typically completes within 3 to 7 business days after the Sent compliance form is approved, with additional propagation time possible. - -## Verification checklist - -- [ ] The traffic is confirmed as US A2P SMS over a long-code route. -- [ ] Legal business identity matches tax and website evidence. -- [ ] Website and privacy policy URLs are live. -- [ ] Opt-in evidence is concrete and matches the declared use case. -- [ ] Sample messages are realistic and match the use case. -- [ ] Opt-out instructions are included where applicable and consistent with the user experience. -- [ ] Sent brand and campaign IDs are stored separately from any provider/TCR identifiers. -- [ ] Profile completion is run only after profile, brand, and campaign prerequisites are ready. -- [ ] Unverified throughput, carrier, or pricing claims are not presented as Sent facts. - -## Related skills - -Use `sender-profile-architect` when deciding whether brands, tenants, departments, or use cases need separate Sender Profiles. - -Use `rcs-agent-onboarding` when 10DLC work is needed for SMS fallback from RCS. - -Use `messaging-performance-analyzer` when registered traffic still shows delivery failures or filtering symptoms. - -Use `template-builder-ui` when the customer needs reusable SMS template copy that matches the registered use case. - -Use the `sent` skill for shared Sent terminology and routing. - -## Bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/tcr-use-cases.md` | Lookup table | TCR use-case taxonomy, sample-message patterns, and rejection reasons. | -| `references/10dlc-evidence-checklist.md` | Worked example | Field-by-field checklist for Sent's 10DLC compliance form. | -| `references/10dlc-rejection-remediation.md` | Decision matrix | Common TCR / carrier rejection codes mapped to fix steps and re-submission etiquette. | -| `scripts/validate_10dlc_packet.py` | Validation script | Pre-flight validator for a packet JSON. Run from the skill root: `python scripts/validate_10dlc_packet.py packet.json`. | -| `scripts/fixtures/good.json` | Fixture | Complete valid packet (passes validator). | -| `scripts/fixtures/bad.json` | Fixture | Packet with missing fields / invalid EIN / short sample (validator exits non-zero). | - -## Unverified claims to confirm or remove - -- Sent's `/v3/brands` and `/v3/brands/{id}/campaigns` endpoints exist; their internal mapping to TCR identifiers is opaque to the customer. Store the Sent brand and campaign IDs returned by the API — don't claim a public `tcr_brand_id` or `tcr_campaign_id` field unless an API response surfaces it. -- Exact throughput limits, per-carrier caps, and vetting-score-to-throughput mapping are not in Sent's docs. The snapshot only confirms account-wide tier limits (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom) — these are not TCR / carrier per-campaign throughput numbers. -- Country-specific compliance, routing, and pricing claims beyond Sent's listed country-specific document requirements (AU, BE, PL, ZA, SE, TH, UK) require a current Sent source. +Never use real consumer data in fixtures or samples. Use [references/tcr-use-cases.md](references/tcr-use-cases.md) for classification and [references/10dlc-rejection-remediation.md](references/10dlc-rejection-remediation.md) for failures. diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md b/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md index e848027..ddaf6b9 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md +++ b/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md @@ -1,140 +1,46 @@ -# 10DLC Evidence Checklist — Sent Compliance Form +# 10DLC evidence checklist -Supporting reference for `sms-10dlc-registration`. This is the definitive list of fields a tenant must supply on Sent's compliance form before Sent files the TCR brand + campaign on their behalf. Field names below match the verified compliance form in the Sent dashboard. The deeper TCR payload (`Brand.entityName`, `Campaign.privacyPolicyLink`, etc.) is filed by Sent internally — keep that taxonomy in `references/tcr-use-cases.md`. +The evidence packet is an internal readiness artifact with `schema_version: "sent-10dlc-evidence/v1"`. Its snake_case fields are not the Sent API contract. -Use this alongside `references/tcr-use-cases.md` when authoring the form, validating an incoming packet, or debugging why a customer's submission failed pre-flight checks. Run `scripts/validate_10dlc_packet.py` against a JSON dump of the packet for the mechanical checks. +## Business identity -## Required fields — business identity +- [ ] Legal business name and EIN match registration records. +- [ ] Public website represents the same recognizable brand. +- [ ] Physical address, business phone, and compliance contact are current. +- [ ] Privacy policy and terms links are public HTTPS pages. -These appear on the KYC + compliance pages and are required for every tenant regardless of country. +## Consent -### 1. Legal business name +- [ ] Opt-in method is named: web form, keyword, paper, or spoken consent. +- [ ] Proof URL or equivalent evidence is accessible to a reviewer. +- [ ] Consent text names the brand and message type. +- [ ] The checkbox is not preselected. +- [ ] Frequency, message/data-rate notice, HELP, and STOP disclosures are present where required. +- [ ] Marketing spoken consent includes the additional confirmation required by policy. -- **Format:** Exact name as registered with the state or national business registry. Include the suffix (`LLC`, `Inc.`, `Corp.`, `LLP`). -- **Common mistakes:** - - Submitting a DBA, marketing brand, or trade name instead of the legal entity. Sent will reject if the EIN/tax-ID-to-name match fails at the issuer lookup step. - - Trailing punctuation (`Acme, Inc.,`) — strip the trailing comma. - - Mismatched capitalization vs registry records. -- **Sent tip:** If the customer uses a different consumer-facing brand, that goes in campaign sample messages, not here. +## Message flow -### 2. Business registration number +Describe the sequence from consumer action through confirmation and recurring messages. A link alone is not a message flow. -- **Format:** The jurisdiction's company / corporate registration number (e.g. state filing number in the US, Companies House number in the UK). -- **Common mistakes:** - - Confusing this with the EIN / tax ID — they're separate fields. - - Submitting the partner / member number instead of the entity's filing number. +## Autoresponses -### 3. Business type / structure +- [ ] `optinMessage` names the brand and explains HELP/STOP, frequency, and rates where required. +- [ ] `optoutMessage` confirms unsubscribe and no further messages. +- [ ] `helpMessage` names the brand and provides a support method. +- [ ] `optinKeywords`, `optoutKeywords`, and `helpKeywords` are documented. +- [ ] STOP is in opt-out keywords and HELP is in help keywords. -- **Format:** One of the dashboard's entity-type options (Private Profit, Public Profit, Non-Profit, Government, Sole Proprietor, etc.). -- **Common mistakes:** - - Sole proprietors marking themselves as Private Profit. Sole-prop senders are subject to additional restrictions and Sent must know. - - Non-profits marking themselves as Private Profit to avoid extra documentation. +## Campaign translation -### 4. Industry category +Map evidence into these Sent camelCase fields only at API serialization time: -- **Format:** Pick the closest match from the dropdown. Drives review path and downstream carrier filtering posture. -- **Common mistakes:** - - Picking "Other" when a specific match exists. - - Misclassifying regulated industries (health, finance, gambling) as general retail. +```text +message_flow -> messageFlow +privacy_policy_url -> privacyPolicyLink +terms_and_conditions_url -> termsAndConditionsLink +autoresponses.optinMessage -> optinMessage +use_cases[].messaging_use_case_us -> useCases[].messagingUseCaseUs +use_cases[].sample_messages -> useCases[].sampleMessages +``` -### 5. EIN / tax ID - -- **Format:** Federal tax ID for the country. US EIN is nine digits, optionally hyphenated after the first two: `12-3456789` or `123456789`. Regex: `^\d{2}-?\d{7}$`. -- **Common mistakes:** - - Confusing EIN with SSN (sole proprietors). Sole props with no EIN should pick the sole-proprietor entity type — see `references/tcr-use-cases.md`. - - Submitting a state tax ID instead of the federal EIN. - - Typos in the first two digits (the IRS prefix). These fail the IRS match every time. -- **Non-US tenants:** Use the equivalent national tax ID (e.g. VAT number, ABN, GST registration). - -### 6. Business address - -- **Format:** Street, city, state / region, postal code, country (ISO 3166-1 alpha-2). PO boxes are not accepted for primary brand address. -- **Common mistakes:** - - Using a mail-forwarding or virtual-office address that doesn't match the address on file with the tax authority for the EIN. - - Country code mismatches (`USA` vs `US`). - -### 7. Business phone number - -- **Format:** Full E.164 phone number reachable for compliance contact. -- **Common mistakes:** - - Submitting a number that goes to a marketing IVR with no path to a human. - - Submitting a personal mobile when the tenant is a registered entity. - -### 8. Contact email - -- **Format:** Monitored inbox for compliance correspondence. -- **Common mistakes:** - - Submitting `noreply@…`. Sent's compliance team replies to this address and reviewers test it. - - Submitting the founder's personal email when the company has compliance ownership in a separate team. - -## Required fields — messaging / use case - -These appear in the messaging-compliance section of the form and are filed against the TCR campaign. - -### 9. Use-case selection - -- **Format:** One of: **Authentication**, **Notifications**, **Marketing**, **Customer Service**, **High Volume**. -- **Common mistakes:** - - Picking **Marketing** for a transactional flow because it sounds friendlier. Use-case affects review bar and carrier filtering posture — pick the narrowest accurate option. - - Picking **High Volume** for genuinely low-volume traffic to "future-proof" — Sent maps this to the TCR `MIXED` flow which has a higher review bar. -- **Sent tip:** The dashboard's **Suggest** button auto-fills `Campaign description` based on the use case. Edit it to match the tenant's actual flow before submitting. - -### 10. Campaign description - -- **Format:** 1-3 sentences explaining what messages the tenant sends, to whom, and when. The dashboard's **Suggest** button drafts this; edit before submitting. -- **Common mistakes:** - - Generic descriptions ("transactional messages"). Reviewers want specifics: "Shipping and delivery updates for orders placed on acme.example.com." - - Description doesn't match the use-case option (e.g. describing promotional content under **Notifications**). - -### 11. Sample messages (per use case) - -- **Format:** Plain-text examples of actual production messages, with `{Variable}` placeholders. Should include the brand name. -- **Common mistakes:** - - Under 20 characters. Reviewers flag these as too generic. - - Including a URL shortener that isn't on the brand's verified-domains list. - - Sample doesn't match the declared use case (promo content in an Authentication sample). -- **Note on opt-out language in samples:** Including `Reply STOP to opt out.` in samples is good carrier hygiene and most reviewers expect to see it, but Sent's compliance form treats opt-out **keywords** as a separate field (see #13). Putting STOP in samples does not satisfy field #13 and vice versa. - -### 12. Opt-in mechanism (URL or description) - -- **Format:** Public URL showing the exact form, checkbox, or flow where end users consent to receive SMS — or a written description of the opt-in flow if no public URL exists. -- **Common mistakes:** - - Linking to a checkout flow without showing the SMS-consent checkbox. - - Consent language that bundles SMS with marketing email — reviewers want SMS-specific consent. - - Pre-checked consent boxes (forbidden under most state laws). - -### 13. Opt-out instructions (Opt Keywords tab) - -- **Format:** Managed in the Sent dashboard under **Compliance → Opt Keywords**, not as free-text on the compliance form. At minimum: `STOP` to opt out, `START` to resume. Help keyword (`HELP`) is configured here too. -- **Common mistakes:** - - Assuming the keywords are inferred from sample messages — they're configured separately and must be set explicitly. - - Customizing the auto-reply text without a brand-name reference. - - Forgetting `START` — required for re-opt-in after a `STOP`. - -## US-specific extras (required for 10DLC) - -| Field | Format | Common mistakes | -|---|---|---| -| **Live website URL** | Full URL with scheme that resolves at submission time. | Staging or preview URLs; coming-soon pages; non-HTTPS. | -| **Privacy policy URL** | Direct URL to a public privacy policy that mentions SMS data handling. | Pointing at the homepage; policy doesn't mention SMS / frequency / data sharing; 404 or auth-walled URL. | -| **Opt-in mechanism URL or screenshot** | Same as field #12 but a publicly hosted URL is strongly preferred for US 10DLC review. | Auth-walled flows; staging-only flows. | -| **Opt-out instructions (STOP / START)** | Configured via the **Opt Keywords** dashboard tab. | Configured per profile, not per brand — make sure the right profile is selected. | - -## Country-specific extras - -Selected countries require additional uploaded documents in the KYC stage. These are gated by the country selected during KYC, not by use case: - -| Country | Extra docs | -|---|---| -| Australia (AU) | Utility Bill | -| Belgium (BE) | Proof of Local Address, Passport, Business Registration Certificate | -| Poland (PL) | Proof of Local Address | -| South Africa (ZA) | Proof of Local Address | -| Sweden (SE) | Proof of Local Address | -| Thailand (TH) | Proof of Worldwide Address, Business Registration Certificate | -| United Kingdom (UK) | Proof of Local Address | - -## After the form is submitted - -Sent's pre-flight runs `scripts/validate_10dlc_packet.py` against a JSON dump of these answers. Issues at this stage are cheap to fix — once filed with TCR a rejection costs days of round-trip. See `references/10dlc-rejection-remediation.md` for what to do when an account error code, TCR, or a carrier bounces a submission that passed pre-flight. +Run the evidence validator before translation and the campaign validator after translation. diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md b/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md index 38a3432..b15101b 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md +++ b/claude-plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md @@ -1,126 +1,35 @@ -# 10DLC Rejection Remediation — Reference +# 10DLC rejection remediation -Supporting reference for `sms-10dlc-registration`. When a tenant says "my SMS isn't going through", the failure can be in three layers: **Sent account state** (the customer isn't fully onboarded), **TCR-side** (the brand or campaign was rejected before reaching carriers), or **carrier-side** (T-Mobile / AT&T / Verizon / MVNO declined a TCR-approved campaign). Each layer has a different fix path — always identify the layer first. +## Diagnose the layer -Pair with `references/10dlc-evidence-checklist.md` (what should be in the packet) and `references/tcr-use-cases.md` (taxonomy). Run `scripts/validate_10dlc_packet.py` against the corrected packet before re-submitting. +| Layer | Evidence | +| --- | --- | +| Local evidence packet | Version, missing consent proof, invalid URLs, incomplete autoresponses | +| Sent request validation | Wrong camelCase, unsupported field, invalid use case, sample count/length, volume type | +| TCR submission | `submittedToTCR`, registry error/reason, brand or campaign status | +| Carrier operations | Campaign active but filtering, DCA election, content mismatch | -## Layer 0 — Sent account-state errors (verified) +Do not create a new campaign until the failing layer is known. -Before assuming TCR or carrier rejection, check whether the API call itself is returning a Sent account-state error. These are returned synchronously from the v3 API and indicate the tenant isn't fully activated yet. +## Common fixes -| Sent error code | HTTP | Account states that produce it | What to do | -|---|---|---|---| -| `AUTH_006` | 403 | `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, `KYC_RESUBMISSION_REQUESTED` | KYC isn't complete. Finish KYC in the dashboard. If `KYC_RESUBMISSION_REQUESTED`, the compliance team is waiting on revised docs. | -| `AUTH_007` | 403 | `KYC_COMPLETED`, `MESSAGE_COMPLIANCE_COMPLETED` | KYC is done but no messaging channel is configured. Complete the channel + brand/campaign step in the dashboard. | -| `AUTH_005` | 403 | (post-`MESSAGE_COMPLIANCE_COMPLETED`, pre-activation) | Everything is filed; Sent is finishing internal activation. Wait and re-poll. | -| `BUSINESS_003` | 422 | (any active account) | Insufficient account balance. Not a compliance issue — top up billing. Common source of post-registration sending failures once a tenant goes live. | -| `BUSINESS_005` | 422 | (any active account) | A referenced template is still `PENDING` or `REJECTED` (Sent template lifecycle, not TCR). Confirm the SMS template at `/v3/templates/{id}` is `APPROVED` before sending. | +- Wrong endpoint model: operate campaigns under the profile and create a dedicated brand within profile creation. +- Inheritance conflict: disable `inherit_tcr_campaign` before managing a dedicated campaign; do not supply `brand` while brand inheritance is true. +- Wrong field casing: serialize `useCases`, `messagingUseCaseUs`, and `sampleMessages` exactly. +- Too few samples: provide 1–5 structurally; provide at least two for marketing/mixed compliance. +- Weak opt-in: add observable proof and a complete message flow. +- Autoresponse failure: add brand, STOP/HELP, unsubscribe confirmation, and support details as applicable. +- Traffic mismatch: select a use case that matches actual content rather than rewriting only the example. -If you're seeing any of the above, it is **not** a TCR or carrier rejection. Resolve the account-state issue first, then verify whether downstream layers are clean. +## Status interpretation -## Layer 1 — TCR-side rejections (external) +`SENT_CREATED` means a Sent campaign record exists. `submittedToTCR: true` records registry submission. `ACTIVE` means operational activation, while `EXPIRED` is not send-ready. Preserve unknown status strings and any `tcrSyncError` rather than replacing them with a guessed category. -TCR rejected the brand or campaign before it reached carriers. Faster turnaround. Most often: missing data, EIN-name mismatch, content that violates TCR policy. Sent surfaces these on the Sender Profile / Compliance status surfaces; the specific rejection strings come from TCR and are not part of Sent's public error catalog. +## Resubmission -## Layer 2 — Carrier-side rejections (external) - -TCR approved the campaign, but one or more of T-Mobile, AT&T, Verizon, or an MVNO declined. The campaign's per-carrier state will show `DECLINED` or `SUSPENDED`. Carrier decisions are slower to reverse and require evidence updates before re-submission. Per-carrier rejection codes are owned by each carrier — track them externally. - -## 1. Brand verification failure (`UNVERIFIED`, `VETTING_FAILED`) - -- **Detect:** TCR brand status moves to `UNVERIFIED` or external vetting returns `FAILED`. Sent surfaces this on the Compliance status of the brand. -- **Root causes:** - - EIN doesn't match the IRS record for the supplied legal name. - - Brand address doesn't match the IRS or state filing. - - Submitted entity type (`PRIVATE_PROFIT` vs `NON_PROFIT`) contradicts the IRS record. -- **Fix steps:** - 1. Re-pull the tenant's IRS EIN confirmation letter (Form CP-575) and reconcile name, address, and entity type field-by-field. - 2. Correct the brand record in Sent. Resubmit for vetting (costs another vet fee). - 3. If the legal name has genuinely changed, the tenant needs an IRS Form 147C confirming the current name before re-vetting. -- **Re-submission etiquette:** Don't re-submit the same data hoping for a different reviewer. Re-vetting with unchanged data is logged as a duplicate and may slow the next legitimate re-vet. - -## 2. EIN-to-legal-name mismatch - -- **Detect:** Pre-flight (`scripts/validate_10dlc_packet.py`) catches format issues; TCR catches semantic mismatch. -- **Root causes:** - - Tenant submitted a DBA instead of legal entity name. - - Recent legal name change not yet reflected with the IRS. - - Typo in EIN. -- **Fix steps:** - 1. Confirm the EIN against the IRS confirmation letter — not the tenant's accounting system. - 2. Update legal name to match exactly (including suffix). - 3. If a legitimate name change has occurred, request Form 147C from IRS before re-filing. -- **Re-submission etiquette:** Note the changed fields in the resubmission cover letter. Reviewers approve corrections faster when the delta is explicit. - -## 3. Campaign content does not match declared use case - -- **Detect:** TCR returns a use-case-mismatch verdict or carriers return a content-violation verdict. Often surfaces as an Authentication / 2FA campaign getting downgraded after a sample promo message slips in. -- **Root causes:** - - Promotional language in samples for **Notifications**, **Authentication**, or **Customer Service**. - - Samples mention a discount, sale, or call-to-buy. - - Sample uses a transactional voice but the declared use case is **Marketing**. -- **Fix steps:** - 1. Re-classify: if any sample is genuinely promotional, split into two campaigns rather than reclassifying everything as **High Volume** (see `references/tcr-use-cases.md`). - 2. Rewrite samples to mirror only the traffic that belongs in the declared use case. - 3. For ambiguous samples, lead with the trigger event (`Your order #1029 has shipped`) — reviewers parse the first sentence hardest. -- **Re-submission etiquette:** Resubmit with the trimmed samples. If splitting into two campaigns, file them sequentially, not in parallel, so the first one's vetting score informs the second. - -## 4. Opt-in evidence insufficient - -- **Detect:** TCR flags the opt-in URL or it gets flagged in manual review. -- **Root causes:** - - URL points at a homepage rather than the specific consent form. - - Consent language bundles SMS with email or push. - - Pre-checked consent box. - - Consent is buried in terms of service rather than at the point of phone-number capture. -- **Fix steps:** - 1. Update the live opt-in surface so the SMS checkbox is unchecked by default and the consent text is SMS-specific. - 2. Take a fresh screenshot with timestamp and host it publicly. - 3. Re-file with the new URL or screenshot as the opt-in evidence. -- **Re-submission etiquette:** Don't paste a URL that requires login. If the consent flow is behind auth, host a public mock that mirrors the production UX. - -## 5. Sample messages too generic - -- **Detect:** TCR flags samples as too generic, or carriers downgrade vetting after launch. -- **Root causes:** - - Samples under 20 characters or under 3 sentences. - - Missing brand name, recipient context, or opt-out language. - - Samples are paraphrases instead of literal production sends. -- **Fix steps:** - 1. Pull 5 real sends (with PII redacted) from staging. - 2. Replace generic samples with the redacted real sends, keeping brand name intact. - 3. Make sure samples cover the variety the campaign will actually send. -- **Re-submission etiquette:** Number the samples in the cover note so a reviewer can confirm each one passes their content scan. - -## 6. Opt-out configuration missing or inconsistent - -- **Detect:** Sample messages don't reference opt-out, or the **Compliance → Opt Keywords** tab has STOP / START / HELP unconfigured for the active profile. -- **Root causes:** - - Opt Keywords tab not configured for the profile sending the traffic. - - Brand name missing from the opt-out confirmation auto-reply. - - Samples don't mention the opt-out instruction (even though the rule is in Opt Keywords, carriers still expect to see hints in samples). -- **Fix steps:** - 1. Configure **STOP** and **START** at minimum in **Compliance → Opt Keywords** for the active profile. - 2. Verify the auto-reply text references the brand and the word `STOP`. - 3. Add a hint like `Reply STOP to unsubscribe.` to samples — it's not the Sent-required field but most carriers expect it. -- **Re-submission etiquette:** Note in the cover that both the Opt Keywords config and the samples were updated together. - -## 7. Prohibited content category - -- **Detect:** TCR flags `SHAFT` (sex, hate, alcohol, firearms, tobacco) or a similar prohibited-content verdict. Some carriers extend this list (cannabis, payday loans, debt collection). -- **Root causes:** - - Campaign content falls into an outright prohibited category for US carriers. - - Age-gated category declared but no age-verification at opt-in. - - Affiliate marketing without disclosure. -- **Fix steps:** - 1. If outright prohibited (e.g. cannabis on Verizon), 10DLC is not the right channel. Surface this back to the tenant; the registration cannot succeed. - 2. If age-gated, add age verification at opt-in and update samples to reference the gate. - 3. If affiliate marketing, declare it honestly and add disclosure in the message body. -- **Re-submission etiquette:** Don't shop the same prohibited campaign to different reviewers. Address the category restriction or recommend an alternative channel (e.g. WhatsApp, RCS, email). - -## General re-submission etiquette - -- Fix one class of issue at a time. Bundling unrelated changes into a single re-submission makes it hard for reviewers to confirm each fix. -- Include a short cover note listing what changed since the last submission. -- Re-run `scripts/validate_10dlc_packet.py` before every re-submission. Mechanical failures are free to catch and expensive to re-file for. -- Track rejection codes on the Sender Profile so repeat rejections trigger a manual review before another re-file. +1. Save the original response and reason. +2. Fix the versioned evidence packet if necessary. +3. Rebuild and validate the API request. +4. Use `sandbox: true`. +5. Show the exact diff. +6. Obtain confirmation before the real mutation. diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md b/claude-plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md index f1e564d..34085e5 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md +++ b/claude-plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md @@ -1,96 +1,35 @@ -# TCR Use Cases & Vetting — Reference +# TCR use cases -Supporting reference for `sms-10dlc-registration`. The Campaign Registry's use-case taxonomy and the practical effect of each choice on throughput and carrier filtering. The canonical list is on the [TCR website](https://www.campaignregistry.com/) — use this doc for the *interpretation* in a multi-tenant Sent deployment. +## Current Sent API values -## Sent's compliance-form taxonomy (verified) +| Value | Typical traffic | +| --- | --- | +| `MARKETING` | Promotions, offers, product announcements | +| `ACCOUNT_NOTIFICATION` | Account changes, balances, non-security notices | +| `CUSTOMER_CARE` | Support conversations and case updates | +| `FRAUD_ALERT` | Suspected fraud notifications | +| `TWO_FA` | One-time passcodes and two-factor authentication | +| `DELIVERY_NOTIFICATION` | Shipment and delivery updates | +| `SECURITY_ALERT` | Security events distinct from general account notices | +| `M2M` | Machine-to-machine operational traffic | +| `MIXED` | Multiple standard use cases in one campaign | +| `HIGHER_EDUCATION` | College or university communications | +| `POLLING_VOTING` | Polling and voting interactions | +| `PUBLIC_SERVICE_ANNOUNCEMENT` | Public-interest or government notices | +| `LOW_VOLUME` | Low-volume mixed traffic | -Sent's compliance form exposes a **five-option** use-case selector to the tenant, not the full TCR taxonomy. Pick one of these on the form: +Select what the business will actually send. Do not use `MIXED` or `LOW_VOLUME` to hide a dominant high-risk use case. -| Sent compliance-form option | Typical underlying TCR use case(s) | -|---|---| -| **Authentication** | `2FA` | -| **Notifications** | `ACCOUNT_NOTIFICATION`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `SECURITY_ALERT`, `PUBLIC_SERVICE_ANNOUNCEMENT` | -| **Marketing** | `MARKETING` | -| **Customer Service** | `CUSTOMER_CARE` | -| **High Volume** | `MIXED` / high-throughput senders (Sent maps internally) | +## Sample rules -Sent files the TCR-side payload on the tenant's behalf — the deeper TCR taxonomy below is for interpreting that mapping and reasoning about why a use-case selection might trigger more or less filtering. The tenant only sees and chooses one of the five form values. +The API structure permits 1–5 samples per use case, up to 1,024 characters each. Compliance policy requires at least two samples for `MARKETING`, `MIXED`, and low-volume mixed traffic. Samples should: -## TCR use cases sorted by throughput & filtering risk (external interpretation) +- name the recognizable consumer brand; +- look like real production messages; +- match the selected use case and described message flow; +- include opt-out language when applicable; +- use synthetic names, codes, numbers, and URLs. -| Use case | Typical TPS (post-vetting) | Filtering risk | When to pick | -|---|---|---|---| -| `2FA` | Highest | Lowest | Codes only. No promotional content. | -| `ACCOUNT_NOTIFICATION` | High | Low | Order, shipping, appointment, payment, security notifications. Triggered by the recipient's action. | -| `CUSTOMER_CARE` | Medium-high | Low | Two-way support replies. Mostly inbound-driven. | -| `DELIVERY_NOTIFICATION` | High | Low | Specialized variant of ACCOUNT_NOTIFICATION for couriers. | -| `FRAUD_ALERT` | High | Low | Account-security alerts and fraud confirmations. | -| `HIGHER_EDUCATION` | Medium | Medium | School-affiliated communications. Required for many edu senders. | -| `LOW_VOLUME` | Low | Low | Pilots, internal tools, < 6,000 messages/day. | -| `MARKETING` | Medium | High | Promo, discount, win-back. Highest review bar; opt-in must be airtight. | -| `MIXED` | Medium | High | Last resort. Raises the bar without raising throughput. | -| `POLITICAL` | Medium | High | Political campaigns (US). Regulated category. | -| `POLLING_VOTING` | Medium | Medium | Surveys, polls, non-political voting. | -| `PUBLIC_SERVICE_ANNOUNCEMENT` | Medium | Low | Non-profit / government PSAs. | -| `SECURITY_ALERT` | High | Low | Specialized variant of FRAUD_ALERT for non-financial security. | -| `SOCIAL` | Medium | Medium | Person-to-person-feeling but business-sent (matchmaking, social apps). | +## Volume -Specific TPS numbers and vetting-score-to-throughput thresholds are **external** — Sent's docs only confirm the tier-level account-wide rate caps (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom), not TCR / carrier per-campaign throughput. - -## Brand external vetting (external context) - -Brands can submit for **External Vetting** via TCR-approved vetting providers. This produces a `vettingScore` that: - -- Unlocks higher per-campaign TPS caps from each carrier. -- Reduces the filtering aggressiveness applied to the campaign's traffic. -- Cannot be inherited from another brand — every brand vets independently. - -The exact vetting-score-to-throughput mapping is set by each carrier and not exposed in Sent's docs. Treat as external (TCR + carrier docs). - -## Required campaign attributes (TCR-side, external) - -Every TCR campaign declares these. They're not directly user-visible on Sent's compliance form, but Sent files them from the form data plus dashboard configuration: - -- **Subscriber opt-in** — How recipients agreed to receive these messages. Captured on Sent's compliance form as the **opt-in mechanism URL or description**. -- **Opt-out keywords** — Minimum `STOP`. Managed on Sent in the **Compliance → Opt Keywords** tab; not embedded per sample message. -- **Help keywords** — Minimum `HELP`. Reply should describe what the campaign is and how to opt out. -- **Embedded link** — Whether your messages include URLs (`yes` / `no`). -- **Embedded phone** — Whether your messages include phone numbers (`yes` / `no`). -- **Affiliate marketing** — Whether the campaign promotes affiliate offers (`yes` / `no`). `yes` is heavily scrutinized. -- **Age-gated content** — Alcohol, gambling, firearms, tobacco. `yes` requires age verification at opt-in. -- **Direct lending** — Whether the campaign is for direct loans. Subject to additional review. - -## Sample messages - -TCR requires 2-5 sample messages per campaign. Carrier filters use these to validate live traffic. Mismatches between samples and production are the most common reason for downgrade. - -Good sample: -> {Brand Name}: Your order #1029 has shipped. Track: https://example.com/track/1029. - -Bad sample (don't do): -> Your order has shipped! - -Bad because: no brand name, no link/tracking specifics that mirror production. - -Note: Opt-out language (`Reply STOP to opt out.`) is conventional in samples and many carriers expect to see it, but the **rule** that every Sent-filed sample must literally embed `STOP` is not confirmed in Sent's docs — Sent treats opt-out as a separate field on the compliance form (Opt Keywords tab) rather than embedded in each sample. Sample-level opt-out is good carrier hygiene; the Sent-required field lives elsewhere. - -## Per-carrier filtering notes (external) - -TCR-approved doesn't mean delivered. Each major US carrier filters independently: - -- **T-Mobile** — Most aggressive. Strict on URL shorteners (use a branded short-link domain on your verified domains, not generic `bit.ly`). -- **AT&T** — Stricter on message-volume spikes than on content. Pace sends. -- **Verizon** — More content-sensitive on `MARKETING` and `MIXED`. Promotional content in an `ACCOUNT_NOTIFICATION` campaign gets caught here first. - -Track per-carrier delivery in the MDR funnel and reconcile per-carrier approval state on the campaign daily. Specific carrier rejection codes are external (Twilio / Bandwidth / Sinch / direct-aggregator docs). - -## Common rejection reasons (external taxonomy) - -| Reason | What it means | Fix | -|---|---|---| -| `Use case mismatch` | Sample messages don't fit the declared use case | Re-classify campaign or rewrite samples | -| `Missing opt-out language` | Samples don't include STOP / HELP language | Add it everywhere | -| `Brand name not in sender ID` | Recipient can't see who's texting | Add `{Brand Name}:` prefix to messages | -| `Affiliate disclosed = no, content suggests affiliate` | Lied on the attribute | Declare honestly | -| `Embedded link not declared` | Samples have links, attribute says no | Update the attribute | -| `Insufficient vetting score for requested TPS` | Vetting too low for the throughput tier | Re-vet at a higher tier, or accept lower TPS | +`volume` is a numeric string. Values below `2000` are in the documented low-volume tier. Exactly `2000` crosses the tier boundary, so validate `1999` and `2000` separately. diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json index ee07bc8..f532cb6 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json @@ -1,26 +1,21 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "123-456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "2175550101", - "contact_email": "compliance-at-example.com", - "website": "https://example.com", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": [], - "start": ["START"], - "help": ["HELP"] + "schema_version": "unversioned-packet", + "legal_business_name": "", + "ein": "123", + "business_phone": "2025550100", + "contact_email": "invalid", + "website": "not-a-public-url", + "privacy_policy_url": "missing-scheme.example", + "terms_and_conditions_url": "", + "opt_in_evidence": {}, + "message_flow": "", + "autoresponses": { + "optinMessage": "", + "optoutMessage": "", + "helpMessage": "", + "optinKeywords": "YES", + "optoutKeywords": "UNSUBSCRIBE", + "helpKeywords": "INFO" }, - "use_cases": [ - { - "selection": "Transactional", - "description": "Shipping and delivery updates.", - "sample_messages": [ - "Order shipped!" - ] - } - ] + "use_cases": [] } diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json new file mode 100644 index 0000000..0cea7bf --- /dev/null +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json @@ -0,0 +1,16 @@ +{ + "campaign": { + "name": "Bad campaign", + "description": "Uses the wrong field names and too few marketing samples.", + "type": "App", + "use_cases": [], + "useCases": [ + { + "messagingUseCaseUs": "MARKETING", + "sampleMessages": ["One sample only"] + } + ], + "volume": 2000 + }, + "sandbox": true +} diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json new file mode 100644 index 0000000..bc312c7 --- /dev/null +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json @@ -0,0 +1,26 @@ +{ + "campaign": { + "name": "Acme account notifications", + "description": "Synthetic account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in through the account settings form before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json index c40ddb4..1ca09fa 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json @@ -1,34 +1,33 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", + "schema_version": "sent-10dlc-evidence/v1", + "legal_business_name": "Acme Example LLC", "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", + "business_address": "123 Example Street, New York, NY 10001, US", + "business_phone": "+12025550100", "contact_email": "compliance@example.com", "website": "https://example.com", "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup?sms=consent", - "opt_keywords": { - "stop": ["STOP", "UNSUBSCRIBE", "CANCEL"], - "start": ["START", "UNSTOP"], - "help": ["HELP", "INFO"] + "terms_and_conditions_url": "https://example.com/terms", + "opt_in_evidence": { + "method": "web_form", + "description": "A synthetic checkout form uses an unchecked SMS consent box.", + "proof_url": "https://example.com/sms-consent" + }, + "message_flow": "Customers submit the web form and receive the opt-in confirmation before notifications begin.", + "autoresponses": { + "optinMessage": "Acme Example: You are subscribed. Msg frequency varies. Msg and data rates may apply. Reply HELP for help or STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" }, "use_cases": [ { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders placed on example.com.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Track: https://example.com/track/1029. Reply STOP to opt out.", - "Example: Order #1042 is out for delivery today. Reply STOP to opt out." - ] - }, - { - "selection": "Authentication", - "description": "One-time login codes for example.com account holders.", + "messaging_use_case_us": "MARKETING", "sample_messages": [ - "Example: Your verification code is 482910. It expires in 10 minutes." + "Acme Example: Save 10% on your next order. Reply STOP to opt out.", + "Acme Example: New items are available. Reply STOP to opt out." ] } ] diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py index d59ee0d..29547e4 100644 --- a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py @@ -1,48 +1,9 @@ #!/usr/bin/env python3 -"""Validate a Sent 10DLC compliance packet before it is filed with TCR. +"""Validate the internal 10DLC evidence packet schema. -Usage: - python validate_10dlc_packet.py - -The packet is a JSON object representing the answers a tenant submitted on -Sent's compliance form. Field names match the verified Sent compliance form -in the dashboard (see references/10dlc-evidence-checklist.md). The validator -checks the mechanical things that are cheap to catch locally and expensive -to discover after TCR or a carrier rejects the submission. Semantic checks -(use-case match, content policy) are out of scope. - -Expected shape: - - { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", - "contact_email": "compliance@example.com", - "website": "https://example.com", - "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": ["STOP"], - "start": ["START"], - "help": ["HELP"] - }, - "use_cases": [ - { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Reply STOP to opt out." - ] - } - ] - } - -Exits 0 with `OK` on success. On failure, prints one issue per line in the -form `: : ` and exits 1. +This packet is readiness evidence, not the Sent campaign API request. Its +snake_case fields are namespaced by an explicit schema version so they cannot +be mistaken for Sent's camelCase contract. """ from __future__ import annotations @@ -51,217 +12,138 @@ import json import re import sys +from pathlib import Path from typing import Any -# Required top-level keys on the packet. Match the verified Sent compliance -# form field names — business identity, US-required URLs, opt keywords, and -# use cases. -REQUIRED_TOP_LEVEL = ( + +SCHEMA_VERSION = "sent-10dlc-evidence/v1" +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +EIN_RE = re.compile(r"^\d{2}-?\d{7}$") +PHONE_RE = re.compile(r"^\+\d{1,15}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +REQUIRED = { + "schema_version", "legal_business_name", - "business_registration_number", - "business_type", - "industry_category", "ein", "business_address", "business_phone", "contact_email", "website", "privacy_policy_url", - "opt_in_mechanism_url", - "opt_keywords", + "terms_and_conditions_url", + "opt_in_evidence", + "message_flow", + "autoresponses", "use_cases", -) - -# Required keys on each use case. -REQUIRED_USE_CASE = ( - "selection", - "description", - "sample_messages", -) - -# Verified use-case selection values from the Sent compliance form. -VALID_USE_CASE_SELECTIONS = ( - "Authentication", - "Notifications", - "Marketing", - "Customer Service", - "High Volume", -) - -URL_FIELDS = ("website", "privacy_policy_url", "opt_in_mechanism_url") - -# Permissive URL regex: scheme + host + optional path. Catches obvious junk -# (no scheme, internal whitespace, missing host) without trying to be a full -# RFC 3986 validator. -URL_RE = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE) - -# EIN: 9 digits, optionally hyphenated after the first two. -EIN_RE = re.compile(r"^\d{2}-?\d{7}$") - -# E.164 phone number: leading +, then 1-15 digits. -PHONE_RE = re.compile(r"^\+\d{1,15}$") - -# Permissive email regex — local@domain.tld. -EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - -# Minimum length of a sample message before reviewers flag it as too generic. -MIN_SAMPLE_LEN = 20 +} -def _is_nonempty_string(value: Any) -> bool: - return isinstance(value, str) and value.strip() != "" +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) -def validate(packet: dict[str, Any], path: str) -> list[str]: - """Return a list of issue strings. Empty list means the packet is valid.""" +def validate(packet: Any, path: str = "") -> list[str]: issues: list[str] = [] def issue(field: str, reason: str) -> None: issues.append(f"{path}: {field}: {reason}") if not isinstance(packet, dict): - issue("", "packet must be a JSON object") + issue("", "must be a JSON object") return issues - - # Top-level required keys. - for key in REQUIRED_TOP_LEVEL: - if key not in packet: - issue(key, "missing required field") - elif key == "use_cases": - if not isinstance(packet[key], list) or len(packet[key]) == 0: - issue(key, "must be a non-empty list") - elif key == "opt_keywords": - if not isinstance(packet[key], dict): - issue(key, "must be a JSON object with stop/start/help keys") - elif not _is_nonempty_string(packet[key]): + for key in sorted(REQUIRED - set(packet)): + issue(key, "missing required field") + if packet.get("schema_version") != SCHEMA_VERSION: + issue("schema_version", f"must equal {SCHEMA_VERSION!r}") + for key in ("legal_business_name", "business_address", "message_flow"): + if key in packet and not _text(packet[key]): issue(key, "must be a non-empty string") + if _text(packet.get("ein")) and not EIN_RE.fullmatch(packet["ein"]): + issue("ein", "must contain nine digits, optionally formatted NN-NNNNNNN") + if _text(packet.get("business_phone")) and not PHONE_RE.fullmatch(packet["business_phone"]): + issue("business_phone", "must be E.164") + if _text(packet.get("contact_email")) and not EMAIL_RE.fullmatch(packet["contact_email"]): + issue("contact_email", "must be a valid email") + for key in ("website", "privacy_policy_url", "terms_and_conditions_url"): + if key in packet and (not _text(packet[key]) or not URL_RE.fullmatch(packet[key])): + issue(key, "must be a public HTTPS URL") + + opt_in = packet.get("opt_in_evidence") + if not isinstance(opt_in, dict): + issue("opt_in_evidence", "must be an object") + else: + for key in ("method", "description", "proof_url"): + if not _text(opt_in.get(key)): + issue(f"opt_in_evidence.{key}", "must be a non-empty string") + proof = opt_in.get("proof_url") + if _text(proof) and not URL_RE.fullmatch(proof): + issue("opt_in_evidence.proof_url", "must be a public HTTPS URL") + + autoresponses = packet.get("autoresponses") + if not isinstance(autoresponses, dict): + issue("autoresponses", "must be an object") + else: + for key in ("optinMessage", "optoutMessage", "helpMessage", "optinKeywords", "optoutKeywords", "helpKeywords"): + if not _text(autoresponses.get(key)): + issue(f"autoresponses.{key}", "must be a non-empty string") + if "STOP" not in str(autoresponses.get("optoutKeywords", "")).upper().split(","): + issue("autoresponses.optoutKeywords", "must include STOP") + if "HELP" not in str(autoresponses.get("helpKeywords", "")).upper().split(","): + issue("autoresponses.helpKeywords", "must include HELP") - # URL fields. - for key in URL_FIELDS: - value = packet.get(key) - if _is_nonempty_string(value) and not URL_RE.match(value): - issue(key, f"not a valid URL: {value!r}") - - # EIN format. - ein = packet.get("ein") - if _is_nonempty_string(ein) and not EIN_RE.match(ein): - issue("ein", f"must match ^\\d{{2}}-?\\d{{7}}$ (got {ein!r})") - - # Business phone format (E.164). - phone = packet.get("business_phone") - if _is_nonempty_string(phone) and not PHONE_RE.match(phone): - issue( - "business_phone", - f"must be E.164 (+CCNNNNNNNNNN, got {phone!r})", - ) - - # Contact email format. - email = packet.get("contact_email") - if _is_nonempty_string(email) and not EMAIL_RE.match(email): - issue("contact_email", f"not a valid email address: {email!r}") - - # Opt keywords — at minimum STOP must be configured. - opt_keywords = packet.get("opt_keywords") - if isinstance(opt_keywords, dict): - stop = opt_keywords.get("stop") - if not isinstance(stop, list) or not any( - _is_nonempty_string(kw) and kw.strip().upper() == "STOP" - for kw in stop - ): - issue( - "opt_keywords.stop", - "must include 'STOP' as an opt-out keyword (configured in " - "the Compliance → Opt Keywords dashboard tab)", - ) - - # Use cases. use_cases = packet.get("use_cases") - if isinstance(use_cases, list): - for i, uc in enumerate(use_cases): - prefix = f"use_cases[{i}]" - if not isinstance(uc, dict): - issue(prefix, "must be a JSON object") + if not isinstance(use_cases, list) or not use_cases: + issue("use_cases", "must be a non-empty array") + else: + for index, use_case in enumerate(use_cases): + field = f"use_cases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") continue - - for key in REQUIRED_USE_CASE: - if key not in uc: - issue(f"{prefix}.{key}", "missing required field") - - selection = uc.get("selection") - description = uc.get("description") - if "selection" in uc: - if not _is_nonempty_string(selection): - issue(f"{prefix}.selection", "must be a non-empty string") - elif selection not in VALID_USE_CASE_SELECTIONS: - issue( - f"{prefix}.selection", - f"must be one of {VALID_USE_CASE_SELECTIONS} " - f"(got {selection!r})", - ) - if "description" in uc and not _is_nonempty_string(description): - issue(f"{prefix}.description", "must be a non-empty string") - - samples = uc.get("sample_messages") - if "sample_messages" in uc: - if not isinstance(samples, list) or len(samples) == 0: - issue( - f"{prefix}.sample_messages", - "must be a non-empty list (>=1 sample per use case)", - ) - else: - for j, sample in enumerate(samples): - sprefix = f"{prefix}.sample_messages[{j}]" - if not _is_nonempty_string(sample): - issue(sprefix, "must be a non-empty string") - elif len(sample.strip()) < MIN_SAMPLE_LEN: - issue( - sprefix, - f"sample is {len(sample.strip())} chars; " - f"reviewers flag samples under {MIN_SAMPLE_LEN} as too generic", - ) - + if use_case.get("messaging_use_case_us") not in USE_CASES: + issue(f"{field}.messaging_use_case_us", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sample_messages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sample_messages", "must contain 1–5 samples") + elif any(not _text(sample) or len(sample) > 1024 for sample in samples): + issue(f"{field}.sample_messages", "samples must be non-empty strings of at most 1,024 characters") return issues def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="validate_10dlc_packet.py", - description=( - "Validate a Sent 10DLC compliance packet (JSON) before filing " - "with The Campaign Registry. Checks required fields, URL / EIN " - "/ phone / email format, use-case selection, sample-message " - "length, and opt-out keyword configuration. See " - "references/10dlc-evidence-checklist.md for the field-by-field " - "rationale." - ), - ) - parser.add_argument("packet", help="Path to the packet JSON file") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("packet", type=Path) args = parser.parse_args(argv) - - path = args.packet - try: - with open(path, encoding="utf-8") as f: - packet = json.load(f) - except FileNotFoundError: - print(f"{path}: : not found", file=sys.stderr) - return 1 - except json.JSONDecodeError as e: - print(f"{path}: : invalid JSON ({e})", file=sys.stderr) + packet = json.loads(args.packet.read_text(encoding="utf-8")) + except OSError as exc: + print(f"{args.packet}: : {exc}", file=sys.stderr) return 1 - except OSError as e: - print(f"{path}: : could not read ({e})", file=sys.stderr) + except json.JSONDecodeError as exc: + print(f"{args.packet}: : invalid JSON ({exc})", file=sys.stderr) return 1 - - issues = validate(packet, path) + issues = validate(packet, str(args.packet)) if issues: - for line in issues: - print(line, file=sys.stderr) + print("\n".join(issues), file=sys.stderr) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py new file mode 100644 index 0000000..a3e54f5 --- /dev/null +++ b/claude-plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Validate the exact Sent campaign request used by profile campaign endpoints.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +POLICY_TWO_SAMPLE_CASES = {"MARKETING", "MIXED", "LOW_VOLUME"} +CAMPAIGN_REQUIRED = {"name", "description", "type", "useCases"} +CAMPAIGN_OPTIONAL = { + "volume", + "messageFlow", + "privacyPolicyLink", + "termsAndConditionsLink", + "optinMessage", + "optoutMessage", + "helpMessage", + "optinKeywords", + "optoutKeywords", + "helpKeywords", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +VOLUME_RE = re.compile(r"^\d+$") + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def validate(payload: Any, path: str = "") -> list[str]: + issues: list[str] = [] + + def issue(field: str, reason: str) -> None: + issues.append(f"{path}: {field}: {reason}") + + if not isinstance(payload, dict): + issue("", "must be a JSON object") + return issues + for key in sorted(set(payload) - {"campaign", "sandbox"}): + issue(key, "unsupported top-level field") + if "sandbox" in payload and not isinstance(payload["sandbox"], bool): + issue("sandbox", "must be boolean") + campaign = payload.get("campaign") + if not isinstance(campaign, dict): + issue("campaign", "required and must be an object") + return issues + allowed = CAMPAIGN_REQUIRED | CAMPAIGN_OPTIONAL + for key in sorted(set(campaign) - allowed): + issue(f"campaign.{key}", "unsupported field; use the exact camelCase Sent contract") + for key in sorted(CAMPAIGN_REQUIRED - set(campaign)): + issue(f"campaign.{key}", "missing required field") + for key in ("name", "description", "type"): + if key in campaign and not _text(campaign[key]): + issue(f"campaign.{key}", "must be a non-empty string") + volume = campaign.get("volume") + if volume is not None and (not isinstance(volume, str) or not VOLUME_RE.fullmatch(volume)): + issue("campaign.volume", "must be a numeric string such as '1999' or '2000'") + for key in ("privacyPolicyLink", "termsAndConditionsLink"): + value = campaign.get(key) + if value is not None and (not _text(value) or not URL_RE.fullmatch(value)): + issue(f"campaign.{key}", "must be a public HTTPS URL or null") + for key in ("optinKeywords", "optoutKeywords", "helpKeywords"): + value = campaign.get(key) + if value is not None and (not _text(value) or len(value) > 255): + issue(f"campaign.{key}", "must be a non-empty string of at most 255 characters or null") + + use_cases = campaign.get("useCases") + if not isinstance(use_cases, list) or not use_cases: + issue("campaign.useCases", "must be a non-empty array") + return issues + for index, use_case in enumerate(use_cases): + field = f"campaign.useCases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") + continue + for key in sorted(set(use_case) - {"messagingUseCaseUs", "sampleMessages"}): + issue(f"{field}.{key}", "unsupported field") + selected = use_case.get("messagingUseCaseUs") + if selected not in USE_CASES: + issue(f"{field}.messagingUseCaseUs", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sampleMessages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sampleMessages", "must contain 1–5 samples") + continue + for sample_index, sample in enumerate(samples): + if not _text(sample): + issue(f"{field}.sampleMessages[{sample_index}]", "must be a non-empty string") + elif len(sample) > 1024: + issue(f"{field}.sampleMessages[{sample_index}]", "must be at most 1,024 characters") + if selected in POLICY_TWO_SAMPLE_CASES and len(samples) < 2: + issue( + f"{field}.sampleMessages", + f"{selected} requires at least two samples under the compliance policy layer", + ) + return issues + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("payload", type=Path) + args = parser.parse_args() + try: + payload = json.loads(args.payload.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"{args.payload}: {exc}") + return 2 + issues = validate(payload, str(args.payload)) + if issues: + print("\n".join(issues)) + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-plugins/sent/skills/template-builder-ui/SKILL.md b/claude-plugins/sent/skills/template-builder-ui/SKILL.md index 327fb15..cfc2ccf 100644 --- a/claude-plugins/sent/skills/template-builder-ui/SKILL.md +++ b/claude-plugins/sent/skills/template-builder-ui/SKILL.md @@ -1,171 +1,109 @@ --- name: template-builder-ui -description: Designs and audits a Sent template builder UI for cross-channel SMS, WhatsApp, and RCS templates, including component editing, variable samples, status handling, channel-specific validation, and submission workflows. Use when a user says template builder, template editor, Sent templates, WhatsApp template UI, RCS template, SMS template, Meta import, JSON template, approval status, or wants a product spec for template creation. Do not use for generic templating engines (Jinja, Handlebars, Mustache, email/HTML templates) or non-Sent template systems. +description: Designs and audits tenant-facing Sent template builders, previews, validation, lifecycle UX, and API payload mapping. Use for template editor forms, variables, channel overrides, WhatsApp review, RCS suggestion chips, authentication templates, and safe submission flows. --- - +Design the interface around Sent's v3 `definition` contract. The UI may import Meta material, but its canonical saved and submitted model must never be Meta's `components[]` payload. -# Template builder UI +## Product model -## Overview +Use one draft object with: -Use this skill to design or improve a Sent template builder UI. Sent templates are reusable message blueprints across SMS, WhatsApp, and RCS. The UI must let users create valid templates, preview channel-specific rendering, supply variable samples, submit for review where required, and understand status without exposing irrelevant provider internals. +- optional `category` and `language`; +- required `definition.body.multiChannel`; +- optional complete body overrides for `sms`, `whatsapp`, and `rcs`; +- optional `definition.header`, `footer`, `buttons`, `definitionVersion`, and `authenticationConfig`; +- submission controls for `creation_source`, `submit_for_review`, and `sandbox`. -The Sent v3 template API supports create, list, retrieve, update, and delete operations. The first-message workflow sends templates through `POST /v3/messages` using a `template.id`. A good UI therefore optimizes both authoring and later sendability. +Do not expose top-level create fields named `name`, `channels`, `body`, `header`, or `buttons`. If the product needs an internal display label, keep it outside the Sent create payload. -## When to use +## Recommended editor sequence -Use this skill when the user asks for a template builder, template editor, template management UI, template validation, Meta import flow, JSON template builder, WhatsApp approval UI, RCS rich template editor, SMS template preview, template status page, or a product/engineering spec for Sent templates. +1. Capture intent and category. +2. Write the `multiChannel` body. +3. Insert variables as structured entities. +4. Add optional per-channel overrides. +5. Add header, footer, and buttons where supported. +6. Review live previews and accessibility. +7. Validate locally and with `sandbox: true`. +8. Save a draft, then explicitly submit for provider review. -Do not use this skill to write final WhatsApp template copy; use `waba-template-author`. Do not use it to decide Sender Profile boundaries; use `sender-profile-architect`. Do not use it to diagnose delivery failures after sends; use `messaging-performance-analyzer`. +Category should not block the first keystroke, but it must be visible before submission because it affects authentication rules and WhatsApp policy review. -## Product principles +## Variable UX -A Sent template UI should make the valid path obvious and the invalid path hard. Users should understand three things at all times: what channels the template targets, what variables need examples, and whether the template is editable, pending, approved, or rejected. +Inserting a variable creates both: -| Principle | UI behavior | Why it matters | -|---|---|---| -| Channel-first editing | User chooses SMS, WhatsApp, RCS, or combinations before components. | Component support differs by channel. | -| Variable-first validation | Every variable has a sample value before review/submission. | Reviewers and test sends need concrete rendered examples. | -| Status-aware actions | Drafts can be edited; pending/approved/rejected states guide next action. | Users should not unknowingly break reviewed content. | -| Provider-specific details are scoped | WhatsApp category and Meta import appear only where WhatsApp applies. | Keeps cross-channel UI from becoming WhatsApp-only. | -| JSON escape hatch | Advanced users can paste/edit JSON with schema validation. | Sent docs include JSON definition as a creation path. | +- a placeholder such as `{{0:variable}}`; and +- a matching entity with `id`, `name`, `type`, and `props.sample`. -## Process +Renumber atomically when variables move. Never let users edit placeholder syntax independently of the entity table. Show a clear error for naked `{{1}}` or IDs without definitions. -### 1. Start with the template intent and channels +## Validation matrix -Ask what the template is for before showing component controls. Intent drives category, variables, and review risk. Then ask which channels the user wants to support. +Apply the exact rules in [references/template-validation-matrix.md](references/template-validation-matrix.md), including: -**Example.** “Order shipped” targeting SMS, WhatsApp, and RCS should start from one intent but render differently: SMS may be plain text, WhatsApp may need a utility category and sample variables, and RCS may use richer actions if configured. +- a 1,024-character maximum for every body; +- 60 characters for header and footer; +- no footer variables; +- 10 buttons total; +- button types `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE` with their per-type limits; +- no invented quick-reply-versus-CTA exclusivity; +- `authenticationConfig` and authentication restrictions; +- complete, independently valid channel overrides. -### 2. Model the Sent template lifecycle +Run the bundled `waba-template-author` linter against serialized JSON. Server validation remains authoritative. -Use Sent’s documented statuses in the UI: Draft, Pending, Approved, and Rejected. Do not introduce provider-only states as global Sent states unless Sent event types or API responses expose them for the account. +## Channel previews -| Status | UI meaning | Allowed primary action | -|---|---|---| -| Draft | Saved but not submitted. | Edit, preview, validate, submit. | -| Pending | Submitted for review/approval where required. | View, cancel if supported, duplicate. | -| Approved | Available for production sends where channel setup allows. | Use in send flow, duplicate for revision. | -| Rejected | Review failed or validation blocked approval. | View reason, revise, resubmit or duplicate. | +### SMS -Although the OpenAPI says `PUT /v3/templates/{id}` can update name, category, language, definition, or submit for review, the UI should still protect approved templates with a “duplicate and revise” path when auditability matters. Present immutability as a product-safety choice, not a Sent API fact. +Preview plain text and estimated GSM/UCS-2 segments. Make clear that segment estimates affect billing and are not template body limits. -### 3. Back the UI with Sent template endpoints +### WhatsApp -Keep the UI contract aligned to the verified v3 template operations. +Preview header, body, footer, and buttons. Show sample values, category, language, and provider-review impact. -| UI action | Endpoint | Notes | -|---|---|---| -| Create template | `POST /v3/templates` | Create with header, body, footer, buttons, and review/draft intent. | -| List/search templates | `GET /v3/templates?page=&pageSize=&search=&status=&category=` | Support filtering by status, category, and search. | -| Open template detail | `GET /v3/templates/{id}` | Show name, category, language, status, and definition. | -| Save/update | `PUT /v3/templates/{id}` | Update editable fields or submit for review. | -| Delete | `DELETE /v3/templates/{id}` | Optionally delete from Meta where supported by the API request. | -| Send test after approval | `POST /v3/messages` | Use `template.id` and channel selection. | +### RCS -Use optional `Idempotency-Key` headers when create/update requests may be retried by the frontend or backend. +Current Sent RCS guidance supports text plus up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap capabilities, not current Sent builder controls. Do not generate capability declarations for unavailable features. -### 4. Design the editor around components +Channel routing belongs to the send flow, not the template editor. If routing is shown in a simulator: -Represent the template as a structured definition rather than one text blob. Sent’s docs describe template components such as header, body, footer, and buttons, with practical support differences across SMS, RCS, and WhatsApp. +- omitted `channel` or `["sent"]` means automatic routing and fallback; +- `["rcs"]` pins RCS with no cross-channel fallback; +- multiple explicit values mean broadcast and separate billable messages. -| Component | UI guidance | Channel notes | -|---|---|---| -| Header | Optional title/media area with clear preview. | Most relevant to WhatsApp/RCS; validate per selected channel. | -| Body | Required main content with variable insertion. | Needed across channels; SMS preview should show plain-text length behavior. | -| Footer | Optional low-emphasis text. | Useful for compliance or context where supported. | -| Buttons/actions | Explicit button type and target. | Validate per channel; do not allow unsupported combinations. | -| Variables | Named or positional placeholders with sample values. | Samples are required for review and testing. | +Never describe an explicit RCS-plus-SMS array as ordered fallback. -### 5. Make validation staged and explainable +## Save and review behavior -Run validation in layers so users know whether a problem is a Sent schema issue, a channel support issue, or a policy/review issue. +Use `sandbox: true` for validation. Save with `submit_for_review: false`. Before switching it to `true`, show: -| Layer | Example error | Fix | -|---|---|---| -| Required fields | “Body is required.” | Add body content. | -| Variable samples | “`{{order_id}}` has no sample value.” | Add a realistic sample. | -| Channel support | “SMS cannot render this rich button.” | Remove button for SMS or split channel variants. | -| WhatsApp review risk | “Marketing language in a utility template may be rejected or reclassified.” | Change category or remove promotional content. | -| JSON schema | “Definition does not match Sent template shape.” | Correct JSON before save. | +- category and language; +- rendered previews with sample values; +- channel overrides; +- button actions; +- any warnings; +- the fact that provider review is an external state change. -**Example validation.** If a utility WhatsApp template says “Your order shipped. Add 20% off accessories today,” the UI should warn that promotional content conflicts with utility intent. For SMS, the same content may be syntactically valid but still must align with 10DLC use-case registration. +Do not autosubmit on save. -### 6. Support Sent’s creation paths +## Lifecycle UX -Sent's dashboard exposes four template creation flows at `app.sent.dm/dashboard/templates`. Mirror them by name and intent. +Resource status values currently include `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Keep an unknown-state renderer. -| Path | Best for | UI requirement | -|---|---|---| -| Create from Sample | New users and common templates | Curated examples with editable variables. | -| Create from Scratch | Product teams building custom flows | Guided component editor. | -| Import from Meta | Existing WhatsApp template libraries | Import review, mapping, and status reconciliation. | -| Create From Definition | Developers and migrations | Schema validation, diff view, and clear errors. | +WhatsApp template webhook events use `field: "templates"`, no `sub_type`, and no `event`; the status is `payload.status`. Provider values can include `CATEGORY_UPDATED`, `DISABLED`, and other future strings. See [references/template-status-handling.md](references/template-status-handling.md). -### 7. Preview the send path, not only the design +## Accessibility and failure recovery -An approved template is only useful if it can be sent. Add a test-send preview that asks for Sender Profile/channel context, recipient test number, variable values, and sandbox/production mode where applicable. Show that production sending uses `POST /v3/messages` with the selected `template.id`. +- Associate every error with a field and a summary. +- Do not rely on preview color alone. +- Preserve user edits after validation failures. +- Keep raw JSON inspection available for advanced users. +- Label imported Meta JSON as “Meta Cloud API source” until converted. +- Provide a diff for server normalization and provider-driven category/status changes. -## Common rationalizations to avoid - -Do not build a WhatsApp-only UI and call it a Sent template builder. Sent templates span SMS, WhatsApp, and RCS. - -Do not mark name/language/category immutable as a Sent API fact. The verified update endpoint can update those fields; immutability is a product governance decision. - -Do not show provider policy warnings globally. Only show WhatsApp-specific category/review warnings when WhatsApp is selected. - -Do not hide sample values in an advanced panel. Missing or unrealistic samples are a common review and testing failure. - -Do not rely on frontend validation alone. The backend should validate the Sent request shape and preserve API error messages for users. - -## Verification checklist - -- [ ] The UI starts with template intent and target channels. -- [ ] Statuses match Sent’s documented Draft, Pending, Approved, and Rejected states. -- [ ] Template CRUD maps to verified `/v3/templates` endpoints. -- [ ] Variables cannot be submitted without sample values. -- [ ] Component validation is channel-aware for SMS, WhatsApp, and RCS. -- [ ] WhatsApp-specific category/review warnings are scoped to WhatsApp templates. -- [ ] JSON definition mode validates schema before save. -- [ ] Test-send preview uses `POST /v3/messages` with `template.id` after approval/readiness checks. - -## Related skills - -Use `waba-template-author` when the task is to write or classify WhatsApp template content. - -Use `sms-10dlc-registration` when SMS template copy must match a US A2P campaign use case or opt-out evidence. - -Use `rcs-agent-onboarding` when RCS templates depend on agent approval, fallback behavior, or rich-rendering tests. - -Use `sender-profile-architect` when template ownership, profile scoping, or tenant boundaries are unclear. - -Use `messaging-performance-analyzer` when an approved template sends poorly or webhook evidence shows failures. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/template-validation-matrix.md` | Lookup table | List component support, variable rules, and channel-specific restrictions without bloating the skill body. | -| `references/template-ui-wireflows.md` | Worked examples | Show sample, scratch, Meta import, and JSON creation flows. | -| `references/template-status-handling.md` | Decision matrix | Map Sent status and provider review outcomes to UI actions. | - -## Unverified claims to confirm or remove - -- Whether template name/language/category are immutable after first save is not documented in the snapshot; the `PUT /v3/templates/{id}` endpoint accepts these fields, so behavior should be verified against the live OpenAPI before assuming. Treat product-side locking as a governance choice, not an API fact. -- Mixed-button rules (quick-reply XOR CTA, ordering, per-category constraints) remain external Meta concerns — link to Meta's WhatsApp template docs, do not mirror. -- Template-status webhook event names follow the `.` pattern (the snapshot confirms the envelope) but the snapshot does not enumerate template-specific events. Discover the exact names via `GET /v3/webhooks/event-types` for the account. +Use [references/template-ui-wireflows.md](references/template-ui-wireflows.md) for state transitions. Use `waba-template-author` for copy and policy judgment, `sent-templates` for existing-resource operations, and `rcs-agent-onboarding` for RCS launch readiness. diff --git a/claude-plugins/sent/skills/template-builder-ui/references/template-status-handling.md b/claude-plugins/sent/skills/template-builder-ui/references/template-status-handling.md index 0f856c2..d9c1b87 100644 --- a/claude-plugins/sent/skills/template-builder-ui/references/template-status-handling.md +++ b/claude-plugins/sent/skills/template-builder-ui/references/template-status-handling.md @@ -1,75 +1,43 @@ -# Template Status Handling - -The lifecycle states a Sent template moves through, which fields are editable in each, how the UI gets notified of upstream changes, and how to handle resubmission. The builder UI tracks every template against the state machine below. - -## Status enum - -Sent's template status enum is exactly four states the UI renders, regardless of underlying channel: `Draft`, `Pending`, `Approved`, `Rejected`. There is **no `PAUSED` state in Sent.** Meta's upstream `PAUSED` flag exists Meta-side only and is not surfaced into the Sent template-status enum. - -| State | Meaning | Source | -|---|---|---| -| `Draft` | Authored locally, not yet submitted | Sent-internal | -| `Pending` | Submitted, awaiting review (WhatsApp = Meta review, typically 24–48 hours per Sent docs; RCS = Google review; SMS does not gate on review) | Set by Sent on submit, cleared by upstream callback | -| `Approved` | Live and sendable | Set by Sent on upstream approval | -| `Rejected` | Upstream rejected the submission | Set by Sent on upstream rejection, carries a `rejection_reason` payload | - -Other upstream states (Meta `PAUSED`, `DISABLED`, `IN_APPEAL`, etc.) do not appear as Sent statuses. If your UI needs to surface a Meta-only signal (e.g. a deliverability pause), treat it as a secondary annotation on an `Approved` row — never bucket those rows as `Rejected`. - -**During `Pending`, SMS sends still work.** Per Sent docs, a template can be sent over SMS while it is still awaiting WhatsApp approval — the `Pending` status gates only the channels that require upstream review. Reflect this in the UI: don't grey out the entire row, only the WhatsApp/RCS send actions. - -## Editable fields by state - -| Field | Draft | Pending | Approved | Rejected | -|---|:-:|:-:|:-:|:-:| -| Name | edit | locked | locked | locked | -| Language | edit | locked | locked | locked | -| Category | edit | locked | locked (Meta may silently change it — see below) | locked | -| Channel | edit | locked | locked | locked | -| Body | edit | locked | edit (creates v2) | edit | -| Header type | edit | locked | locked | edit | -| Header text/media | edit | locked | edit | edit | -| Footer | edit | locked | edit | edit | -| Button types (radio) | edit | locked | locked | edit | -| Button labels | edit | locked | edit | edit | -| Variable samples | edit | locked | edit | edit | - -Editing an **Approved** template's editable fields does not mutate the live template — it creates a new version on submit, while the prior version continues to send until the new one is approved. Make this obvious in the editor footer ("Submitting creates v2; v1 keeps sending until v2 is approved"). - -In **Pending**, surface a "Withdraw and edit" affordance — it calls the Sent withdraw endpoint and moves the template back to Draft. - -> The locks above are a **product-governance** choice. The Sent v3 `PUT /v3/templates/{id}` endpoint accepts name/language/category in its request body; whether those fields are truly immutable server-side after first save is not documented in the snapshot and should be verified against the live OpenAPI before relying on it. - -## Webhook vs polling - -Sent webhook events follow a top-level `field` + `sub_type` envelope, with `sub_type` formatted as `.` (e.g., `message.delivered`, `message.failed`). Template status changes are inferred to follow the same pattern (e.g., `template.approved`, `template.rejected`, or a single `template.status_changed`) — the snapshot confirms the envelope but does not enumerate template-specific events. **Discover the exact event names via `GET /v3/webhooks/event-types` for your account** and subscribe via `POST /v3/webhooks` with the relevant `event_types` / `event_filters` shape. - -Two reasonable UI approaches: - -- **Webhook + realtime fanout** (preferred). The backend receives the Sent webhook, fans out to the relevant tenant's realtime channel (Pusher / Ably / Supabase Realtime / WebSocket), and the list row updates in place. Lowest latency, no client polling load. Use when you already have a realtime layer for other reasons. -- **Short polling** (acceptable fallback). The list view polls `GET /v3/templates?status=pending` every 5–10s while any row is in Pending; stops polling when none remain. Simpler to ship; more network load. Use when you don't yet have realtime infrastructure. - -Do *not* poll per-row — always poll the list filter — and do not poll forever. Cap at e.g. 30 min after submit; beyond that, the tenant must refresh. - -## Rejection reason display: Meta vs Sent - -Two distinct rejection sources, and the UI should render them differently: - -- **Meta-surfaced rejection.** The `rejection_reason` Sent forwards from Meta's template-status update. Render this as a sticky banner with the human-readable reason on top, the raw Meta enum (e.g. `INVALID_FORMAT`, `ABUSIVE_CONTENT`, `INCORRECT_CATEGORY`) collapsed by default, and a Sent-maintained remediation hint mapped from the enum. -- **Sent-surfaced rejection.** When Sent's own pre-submission validation (the server-side mirror of the matrix) rejects the payload before forwarding to Meta. Render with a different icon and label ("Caught by Sent before submission") so tenants don't think Meta reviewed the template. - -Never show the raw Meta JSON — it's noisy and changes shape. Always go through the Sent-normalized rejection-reason API. - -## Resubmission flow - -After editing a Rejected template: - -1. The Submit button triggers a *new submission attempt* against the existing template record. The template ID stays stable; only the `attempt_id` increments. -2. The list row transitions Rejected → Pending in place; do not create a duplicate row. -3. The rejection-reason banner is dismissed automatically when the new attempt enters Pending. -4. On Approval, the row turns green and the banner stays gone. On a fresh Rejection, render the new reason — and add a "previous reasons" disclosure showing the prior failures so tenants can see they're not regressing. - -For Approved templates, "resubmit" is really "submit a new version" — covered above in Editable fields. - -## Silent re-categorization - -Meta may re-categorize an Approved template (most commonly `UTILITY` → `MARKETING`) without changing its status. If Sent surfaces this (event name not enumerated in the snapshot — verify against `GET /v3/webhooks/event-types`), the list-row UI should render a one-time dismissible banner ("Meta moved this template to Marketing — it will now bill at marketing rates"). Tenants who miss this end up surprised by billing. See the `template-builder-ui` SKILL.md for the editor-side treatment. +# Template status handling + +## Resource states + +Sent template resources currently surface `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Use an unknown state in the UI because contracts evolve. + +## Provider webhook states + +WhatsApp approval events use this envelope: + +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "CATEGORY_UPDATED", + "language": "en_US", + "category": "MARKETING", + "channel": "whatsapp", + "reason": "Provider category update" + } +} +``` + +Template events have `field: "templates"` and no `sub_type` or `event`. Known provider values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; values such as `PAUSED` and `DISABLED` may be forwarded verbatim. + +## UI behavior + +| Value | UI response | +| --- | --- | +| `DRAFT` | Editable; offer validate and submit actions. | +| `PENDING` | Lock provider-reviewed fields and show submission time. | +| `APPROVED` | Show usable status and immutable submitted content. | +| `REJECTED` | Show the reason and create a revision path. | +| `PAUSED` / `DISABLED` | Block new WhatsApp usage and surface remediation. | +| `CATEGORY_UPDATED` | Show old/new category when known and re-evaluate pricing/policy UX. | +| Unknown | Preserve raw value, use a neutral badge, and avoid destructive assumptions. | + +Verify webhook signatures, deduplicate transitions, retrieve the current resource before overwriting local state, and tolerate delayed or out-of-order deliveries. Polling may be used as recovery, not as evidence that invented webhook event names exist. diff --git a/claude-plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md b/claude-plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md index dcde0a4..62e3087 100644 --- a/claude-plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md +++ b/claude-plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md @@ -1,104 +1,49 @@ -# Template UI Wireflows +# Template builder wireflows -The user journeys a Sent template builder must support. Each flow is a UX spec — entry point, screen sequence, state at each step, validation gates, exit conditions — written so a frontend engineer can implement against it without re-deriving the requirements. +## New draft -The dashboard surface lives at `app.sent.dm/dashboard/templates`. The list view supports search/filter by **name**, **category** (Marketing / Utility), **description**, and **status** (Draft / Approved / Pending / Rejected). Each row exposes per-template actions: **edit**, **delete**, **view analytics**. +`intent → multiChannel body → variables → overrides → optional components → previews → validation → draft save` -There are four template creation flows, exposed in the dashboard exactly as: **Create from Scratch**, **Import from Meta**, **Create From Definition**, **Create from Sample**. +- The internal draft may have a product label, but serialization drops it because create does not accept top-level `name`. +- Variable insertion writes the placeholder and entity together. +- Switching a channel override off preserves it locally until the user confirms deletion. -## Flow 1: Create from Scratch +## Validate -**Entry:** Templates list (`app.sent.dm/dashboard/templates`) → "Create from Scratch" action. +`serialize → local linter → fix field errors → sandbox request → reconcile server response` -1. **Channel select** (if the tenant has more than one channel provisioned). One-step picker: SMS / WhatsApp / RCS. Locks the validator set (see `references/template-validation-matrix.md`). -2. **Category picker** (WhatsApp only). `UTILITY` / `MARKETING` / `AUTHENTICATION`, each with one-line definition + example. -3. **Name + language** form. Name validated against `^[a-z][a-z0-9_]{0,511}$`; show `_v1` suffix nudge. Language is a typed-search picker over BCP-47 codes. -4. **Editor + preview split**. Component editor left, live preview right, sticky sample editor bottom-right. -5. **Submit gate.** Submit button disabled until all blocking validations pass. On click: confirm modal that shows the final payload diff ("here's what we're sending to Meta"). -6. **Optimistic insert.** Template row appears in the list with `Pending` status before the network round-trips. -7. **Status polling / webhook subscription.** See `references/template-status-handling.md`. +- Mark Meta `components[]` imports as unconverted and block Sent submission. +- Preserve the last valid server response separately from the working draft. +- Server normalization should appear as a reviewable diff. -## Flow 2: Create from Sample (clone) +## Submit for review -**Entry:** Templates list → "Create from Sample" action, or existing template row → "Duplicate". +`draft → final preview → confirmation → submit_for_review=true → PENDING or returned state` -1. Open the editor pre-filled with the source template's full state (curated sample, or an existing template). -2. **Name field is empty and focused** — the user must pick a new name; the original `(name, language)` pair is treated as permanent by the builder. -3. The category and channel are pre-selected and locked to match the source (changing channel is "create new", not "duplicate"). -4. From here: identical to Flow 1 from step 4 onward. +Do not autosubmit. The confirmation displays category, language, samples, buttons, overrides, and warnings. -Clone is the most-used "create" path in practice — make it 1-click from any approved template, including across languages (duplicate + change language). +## Status changes -## Flow 3: Import from Meta +`DRAFT → PENDING → APPROVED | REJECTED | PAUSED | unknown` -**Entry:** Templates list → "Import from Meta" action (WhatsApp only). +This is a UI model, not a promise of an exhaustive provider state machine. Template webhooks can forward `CATEGORY_UPDATED`, `DISABLED`, or future values. Render unknown states and keep raw values. -1. Modal: paste the raw template JSON from the Meta dashboard's "View JSON" panel. -2. Parser maps Meta's shape into Sent's internal template model. -3. **Diff preview**: show fields that didn't round-trip cleanly (e.g. Meta has a property Sent doesn't model yet). Tenant can proceed or cancel. -4. Land in the editor (Flow 1 from step 4) with all parsed fields filled. Validation runs immediately so any Meta-side state that violates current rules surfaces as inline errors. -5. Sent treats the import as a *draft* — the imported template is not auto-submitted; the tenant must click Submit. +## Rejection recovery -## Flow 4: Create From Definition (advanced) +`webhook/poll → retrieve current resource → show reason → fork editable revision → lint → sandbox → confirm resubmission` -**Entry:** Templates list → "Create From Definition" action, or Editor → "Edit as JSON" toggle (available on Draft only). +Avoid destructive in-place edits when content is locked. Keep the submitted version and revision history visible. -1. Editor swaps to (or opens directly into) a monaco-style JSON pane showing Sent's internal template schema. -2. **Schema-guided autocomplete** (using the same JSON schema that drives the matrix). -3. Live validation + preview continue to run against the JSON. -4. "Back to form" toggle round-trips if the JSON is currently valid; greyed out if not (don't silently discard). -5. Submit gate is identical to Flow 1. +## RCS preview -This is the escape hatch for power users and for tenants whose IDE-driven workflows generate templates programmatically. +`text → zero-to-four suggestion chips → device preview` -## Flow 5: Edit +Do not add rich-card, carousel, or attachment branches to current Sent workflows. If a mockup illustrates future capability, label it roadmap-only and exclude it from serialized requests. -**Entry:** Existing template row → "edit" action. +## Routing simulator -State of the editor depends on the template's lifecycle state (see `references/template-status-handling.md`): +If the product includes a send simulator, keep routing semantics explicit: -- **Draft** → all fields editable. -- **Pending** → editor is read-only with a "Pending review — edit blocked" banner. Offer "Withdraw and edit" which moves the template back to Draft via the Sent API. -- **Approved** → name, language, and category are locked. Body, header (text only, not media swap), footer, button labels (not button types) are editable. Editing an Approved template creates a *new version* on submit — surface this prominently ("Submitting will create v2; v1 keeps sending until v2 is approved"). -- **Rejected** → all fields editable; the rejection-reason banner is sticky at the top of the editor (see Flow 8). - -## Flow 6: Status transitions visible in UI - -Every list row shows a status pill. Sent's status enum is exactly `Draft` / `Pending` / `Approved` / `Rejected` — there is no `PAUSED` state surfaced in Sent. Allowed transitions: - -``` -Draft ─submit→ Pending ─approved upstream→ Approved - └─rejected upstream→ Rejected ─edit→ Draft -``` - -Pills use Sent's design-system tokens — not raw Meta colors — so the same component renders for SMS templates and RCS templates. - -Transitions animate (fade pill color), and the row's last-updated timestamp updates on each transition so tenants can correlate with WhatsApp review SLA (per Sent docs, typically 24–48 hours). - -## Flow 7: Submit - -The submit click is the *only* destructive step in the builder. Treat it carefully: - -1. **Pre-submit checks** (client-side): - - Run the full validation matrix; any blocking failure aborts. - - Open a confirmation modal showing: final preview render, the JSON that will hit Meta, and a "What happens next?" explainer (review SLA, billing implication for marketing, etc.). -2. **POST to Sent's template-submit endpoint** (`POST /v3/templates`, optionally with `Idempotency-Key`). Show a button spinner. -3. **On 2xx**: dismiss the modal; insert an optimistic `Pending` row into the list; clear the draft state. -4. **On 4xx**: surface the Sent error message inline in the modal — do not dismiss; do not lose the user's work. -5. **Subscribe to status** — see `references/template-status-handling.md` for the webhook-vs-polling tradeoff. - -## Flow 8: Reject-recover - -When a template lands in `Rejected`: - -1. The list row's status pill is red and clickable. -2. Clicking opens the editor with a sticky banner at the top: human-readable rejection reason, the raw Meta `reason` collapsed by default, and a "Remediation" callout that maps the rejection code to a concrete fix ("Variable samples contained promotional language — rewrite samples as neutral data and resubmit"). -3. The editor pre-focuses the field most likely to be wrong (e.g. the offending sample input). -4. The tenant edits and clicks Submit — the resubmission creates a new attempt with a new `attempt_id` server-side; the list row updates in place rather than duplicating. - -## Cross-cutting notes - -- Every flow that lands in the editor reuses the same editor component — channel + category state determine which sub-forms render. There is *not* a separate editor per channel. -- Every flow respects the validation matrix at every keystroke; submit is the only network call. -- Optimistic UI is acceptable for insertions (Draft creation, submit) but not for status transitions — those must come from the server. -- All confirmation modals are dismissable with Escape; the Submit modal additionally requires explicit confirm (no Enter-key auto-submit). +- no `channel` / `["sent"]`: automatic routing and fallback; +- one explicit channel: pinned; +- two or more explicit channels: broadcast with one message per recipient/channel pair. diff --git a/claude-plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md b/claude-plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md index f25a368..41964f5 100644 --- a/claude-plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md +++ b/claude-plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md @@ -1,106 +1,60 @@ -# Template Validation Matrix - -Per-channel rules a Sent template builder UI must enforce client-side, with the surface treatment for each failure mode. Use this as the single source of truth when wiring validators into the editor — the same matrix should drive the submission handler, so a "soft warning" never silently becomes a hard reject downstream. - -**Surface treatments** referenced below: - -- **Inline error** — red text under the field, focused on save attempt, does not block typing. -- **Blocked save** — the Submit button is disabled until resolved; tooltip explains why. -- **Soft warning** — amber banner or icon, save still allowed, but the tenant must acknowledge. - -## Component support matrix (Sent-confirmed) - -| | SMS | RCS | WhatsApp | -|---|:-:|:-:|:-:| -| Header | ❌ | ✅ | ✅ | -| Body | ✅ | ✅ | ✅ | -| Footer | ❌ | ✅ | ✅ | -| Buttons | ❌ | ✅ | ✅ | - -Header types (where supported): `TEXT`, `IMAGE`, `VIDEO`, `DOCUMENT`. - -Button types (where supported): `QUICK_REPLY`, `URL`, `PHONE_NUMBER`. - -Template categories (apply to WhatsApp review; not a Sent enum extension): `UTILITY`, `MARKETING`, `AUTHENTICATION`. No others. - -## Character limits (Sent-confirmed) - -| Channel | Component | Limit | -|---|---|---| -| SMS | Body | 160 chars per segment (GSM-7); longer messages split | -| WhatsApp | Body | 1028 chars | -| WhatsApp | Header (text) | 60 chars (optional component) | -| WhatsApp | Footer | 60 chars (optional component) | -| RCS | Body | 1028 chars | -| RCS | Header | 60 chars (optional component) | - -## SMS - -SMS templates support **Body only** — no Header, Footer, or Buttons. All rules below apply to body content. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Body length (GSM-7) | 160 chars per segment | Inline char + segment counter; soft warning at 4+ segments | -| Body length (UCS-2) | 70 chars per segment (triggered by any non-GSM-7 char, e.g. emoji, curly quotes) | Detect encoding on every keystroke; soft warning at first UCS-2 char ("This template now bills as Unicode — N segments") | -| Total body length | Hard cap at 1600 chars (10 UCS-2 segments) | Blocked save above cap | -| Opt-out language | A2P templates must include `Reply STOP to opt out` (or equivalent) at least once across the campaign's template set | Soft warning per template; campaign-level check elsewhere | -| Link shortening | Public domain shorteners (bit.ly, tinyurl) are heavily filtered by carriers | Soft warning when a known shortener domain appears in the body; suggest the tenant's branded short domain | -| Variable placeholders | Use the placeholder format the SMS template model expects (named or ordinal — see https://docs.sent.dm). Stay consistent across all SMS templates in a project. | Inline error on mismatched placeholder style | -| Sender ID injection | Alphanumeric sender IDs not allowed in US 10DLC | Blocked save if the tenant tries to set a non-numeric sender on a US campaign | -| Public URL preview | Long URLs eat segments fast | Show effective char-with-URL count; soft warning if URL is >40 chars | - -For campaign-level filtering rules that gate which SMS templates are sendable, use `sms-10dlc-registration`. - -## WhatsApp - -Rules are *category-aware* — `UTILITY`, `MARKETING`, and `AUTHENTICATION` each have a different shape. The builder's category picker (per `SKILL.md`) reshapes which validators apply. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Name format | `^[a-z][a-z0-9_]{0,511}$` | Inline error; auto-snake_case the input | -| Name+language permanence | Treat as immutable as a product-governance choice (the Sent v3 `PUT /v3/templates/{id}` accepts these fields, but the UI should still steer tenants to a versioned `_v2` flow for auditability) | Blocked save when editing; show `_v1` suffix nudge for new versions | -| Body length | 1028 chars across all categories | Inline char counter; blocked save above cap | -| Body required | Required for utility/marketing; auth body is fixed by Meta | Blocked save if empty | -| Header type | One of: none / `TEXT` / `IMAGE` / `VIDEO` / `DOCUMENT` | Radio control — invalid combos unreachable | -| Header (text) length | 60 chars, max 1 variable | Inline error | -| Header (media) sample | Sample upload required at submit | Blocked save without a sample asset | -| Footer length | 60 chars, no variables | Inline error; strip `{{` on paste | -| Buttons — mutually exclusive | `QUICK_REPLY` XOR CTAs (`URL` + `PHONE_NUMBER`). Mixing is a Meta reject. | Top-level button-type radio prevents construction; never allow per-button type picks | -| Quick replies | Max 3, 25 char labels | Add button hidden at 3; inline error on label length | -| CTA buttons | Max 2 total, mix of `URL` + `PHONE_NUMBER` allowed | Add button hidden at 2 | -| URL CTA variables | Max 1 trailing variable, must be the URL suffix (`https://example.com/orders/{{1}}`) | Inline error on inline variables | -| Variable placeholders | `{{n}}` ordinal, monotonically increasing from `{{1}}` | Autocomplete next index; inline error on gaps (`{{1}}` then `{{3}}`) | -| Variable samples | Required at submit, non-empty | Sticky sample editor + blocked save until all filled | -| Sample neutrality | Promotional words in samples (off / sale / free / now / discount / deal …) trigger Meta re-categorization | Soft warning on the offending sample field | -| Authentication body | Fixed copy with `{{1}}` for OTP | Body field becomes read-only when category = `AUTHENTICATION` | -| Authentication buttons | Single Copy code / One-tap button + `code_expiration_minutes` | Component editor swaps shape | - -For the category decision tree the picker presents, use `waba-template-author`. Mixed-button policy details remain an external Meta concern — link, do not mirror. - -## RCS - -RCS templates are richer (rich cards, suggested replies, suggested actions) but the channel mandates an SMS-text fallback for any device that can't render RCS. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| SMS fallback body | Required, follows the SMS rules above | Blocked save without fallback; auto-derive from card title+description as a starting point | -| Body length | 1028 chars | Inline counter | -| Header (text) length | 60 chars | Inline counter | -| Rich card title | 200 chars | Inline counter | -| Rich card description | 2000 chars | Inline counter | -| Card media | Image or video; image ≤ 100 KB for "short height", ≤ 2 MB max; video ≤ 100 MB | Blocked save on oversize; soft warning at >50 KB image for short-height layout | -| Card orientation | Vertical or horizontal | Radio; preview swaps layout | -| Suggested replies | Max 11 across the message; 25 chars each | Add button hidden at 11; inline error on label length | -| Suggested actions | Max 11 across the message (shared cap with replies); types include dial, openUrl, viewLocation, shareLocation, createCalendarEvent | Add button hidden when total = 11 | -| Action URL | Must be HTTPS, valid URL | Inline error | -| Carousel cards | 2-10 cards, all same orientation | Blocked save outside range; orientation locked after first card | -| Variable placeholders | Match Sent's RCS template placeholder format — see https://docs.sent.dm | Inline error on mismatched style | -| Fallback parity | Variables in the rich content must also resolve in the SMS fallback | Soft warning if a variable appears only in one | - -For agent-level capability gating that determines whether RCS is an option for a recipient, use `rcs-agent-onboarding`. - -## Cross-channel notes - -- The builder may target *one channel at a time* — do not let tenants compose a "WhatsApp + RCS combined template" in the same form. Channel selection is the first decision after category (where applicable) and locks the validator set. -- All three channels run validation on every keystroke (debounced) so the submit button reflects current state. -- The matrix is the contract between client validation and server validation — keep it codified (JSON schema or equivalent) and re-export to both sides. +# Template validation matrix + +This matrix maps UI controls to the body of `POST /v3/templates`. + +## Request root + +| Field | Client rule | +| --- | --- | +| `definition` | Required object. | +| `category` | Optional `UTILITY`, `MARKETING`, or `AUTHENTICATION`. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional string. | +| `submit_for_review` | Boolean; default to false in the editor. | +| `sandbox` | Boolean; use true for validation. | + +Reject top-level `name`, `channels`, `body`, `header`, `buttons`, and `components`. A `components[]` import is Meta Cloud API source and requires conversion. + +## Definition + +| Field | Rule | +| --- | --- | +| `body.multiChannel` | Required body content. | +| `body.sms`, `body.whatsapp`, `body.rcs` | Optional complete overrides using the same content schema. | +| `header.template` | Optional, maximum 60 characters. | +| `footer.template` | Optional, maximum 60 characters, no variables. | +| Body `template` | Required non-empty text, maximum 1,024 characters. | +| `definitionVersion` | Optional string; preserve when editing. | +| `authenticationConfig` | Only for `AUTHENTICATION`. | + +Every placeholder uses `{{id:type}}`, for example `{{0:variable}}`. Each must map one-to-one to a variable with a non-negative integer `id`, readable `name`, matching `type`, and non-empty `props.sample`. IDs are unique within a body. + +## Buttons + +| Type | Total allowed | Required properties | +| --- | ---: | --- | +| `QUICK_REPLY` | Up to the overall total of 10 | `text`, `quickReplyType` | +| `URL` | 2 | `text`, `urlType`, `url` | +| `VOICE_CALL` | 1 | `text`, `countryCode`, `phoneNumber` | +| `PHONE_NUMBER` | 1 | `text`, `countryCode`, `phoneNumber` | +| `COPY_CODE` | 1 | `text`, `offerCode` | + +There are at most 10 buttons in total, and button text is at most 25 characters. Quick replies and CTA buttons may coexist. + +## Authentication + +`authenticationConfig` accepts `addSecurityRecommendation` and optional `codeExpirationMinutes` from 1 through 90. Authentication templates should contain one code variable and one copy-code action, with no promotion, unrelated media, URL, or call action. + +## Channel-specific product rules + +| Channel | Current UI capability | +| --- | --- | +| SMS | Plain text preview and segment estimate. | +| WhatsApp | Header, body, footer, variables, and supported buttons. | +| RCS | Text and up to four suggestion chips. | + +Do not expose current Sent controls for RCS rich cards, carousels, or media attachments. They are roadmap features. Do not require an SMS fallback body; automatic routing is a send-time choice made by omitting `channel` or using `["sent"]`. + +## Server round trip + +Client validation is advisory. Serialize the exact Sent request, run the bundled linter, validate with `sandbox: true`, and reconcile server errors by field. Never silently rewrite submitted copy. diff --git a/claude-plugins/sent/skills/waba-embedded-signup/SKILL.md b/claude-plugins/sent/skills/waba-embedded-signup/SKILL.md index 3211f12..f1fa1c6 100644 --- a/claude-plugins/sent/skills/waba-embedded-signup/SKILL.md +++ b/claude-plugins/sent/skills/waba-embedded-signup/SKILL.md @@ -1,168 +1,124 @@ --- name: waba-embedded-signup -description: Guides WhatsApp Business Account connection for Sent Sender Profiles, including Embedded Signup planning, WABA and phone-number mapping, token/security handling, webhook readiness, and profile completion. Use when a user says Embedded Signup, WABA, connect WhatsApp, WhatsApp sender, phone number ID, Facebook Login for Business, Meta Business, sender profile WhatsApp setup, or webhooks not firing after WhatsApp signup. +description: Guides WhatsApp Business Account onboarding through Sent, separating dashboard Embedded Signup, organization WABA inheritance, and direct child-profile credentials. Use for WABA connection, Meta signup, profile creation, access-token handling, phone number mapping, completion callbacks, or WhatsApp onboarding failures. --- - +Keep three integration paths distinct. Calling all of them “Embedded Signup” creates wrong API designs and unsafe credential handling. -# WABA Embedded Signup +## The three paths -## Overview +| Path | Where it starts | Profile behavior | +| --- | --- | --- | +| Organization Embedded Signup | Sent dashboard | Connects the organization's WABA through the hosted Meta flow. There is no public Sent endpoint that starts this flow. | +| Organization WABA inheritance | `POST /v3/profiles` | Omit `whatsapp_business_account`; the child inherits the organization's connected WABA. | +| Dedicated child-profile WABA | `POST /v3/profiles` | Supply `whatsapp_business_account.waba_id` and `.access_token`; `phone_number_id` is optional. | -Use this skill to connect a WhatsApp Business Account (WABA) and phone number to a Sent Sender Profile without confusing Sent-side setup with Meta-side implementation details. Sent’s dashboard exposes Sender Profiles and WhatsApp configuration status. Sent’s profile API exposes profile CRUD and a profile-completion workflow. The public Sent sources reviewed for this rewrite did not expose a dedicated Embedded Signup endpoint, so direct Meta Graph flows should be treated as external integration context unless the user confirms that their application owns that flow. +If credentials are omitted and the organization has no connected WABA, profile creation returns `422`. Direct WABA credentials are a profile-creation feature, not a public “Embedded Signup endpoint.” -The safest workflow is to decide the integration path first: Sent-managed WhatsApp setup, customer-managed Meta Embedded Signup connected back to Sent, or a hybrid implementation coordinated with Sent. +## Authentication -## When to use +Use either: -Use this skill when the user mentions Embedded Signup, WABA, WhatsApp Business Account, WhatsApp phone number, phone number ID, Facebook Login for Business, Meta Business Manager, connecting WhatsApp to Sent, WhatsApp sender setup, Sender Profile WhatsApp status, or webhook delivery after WhatsApp onboarding. +- a profile-specific key in `x-api-key`; or +- an organization key in `x-api-key` plus `x-profile-id` when operating for an existing child profile. -Do not use this skill to author WhatsApp templates; use `waba-template-author`. Do not use it to build a generic Meta app unless the user explicitly asks for a Meta-side implementation. Do not claim Sent exposes Embedded Signup endpoints unless the account/docs confirm them. +Only organization keys may use `x-profile-id`; profile keys receive `403`. `x-sender-id` is legacy v1/v2 terminology. -## Process +## Path A: organization Embedded Signup -### 1. Decide the integration path first +1. An authorized organization administrator opens the Sent dashboard WhatsApp connection flow. +2. The hosted Meta Embedded Signup UI collects the Meta authorization and WABA/number choices. +3. Confirm the organization shows a connected WABA before creating inheriting children. +4. Record non-secret identifiers and audit who completed the action. -Start every session by asking which path applies. The answer changes what the agent should do next. +Do not invent a `POST /embedded-signup` or token-exchange endpoint in Sent's public API. If building your own Meta Tech Provider integration outside the Sent dashboard, follow Meta's current documentation and keep that system separate from the Sent API contract. -| Path | Use when | Agent role | -|---|---|---| -| Sent-managed setup | The customer wants Sent to guide or operate WhatsApp connection. | Prepare Sender Profile, business evidence, phone-number details, and handoff notes. | -| Customer-managed Embedded Signup | The customer’s app launches Meta Embedded Signup and passes results to Sent. | Review Meta-side security and mapping, then align results to Sent profile completion. | -| Migration/import | The customer already has a WABA/phone number and needs it represented in Sent. | Collect WABA/phone identifiers, ownership evidence, and profile mapping. | +Meta's browser `postMessage` events use an `event` field and nested data/session information. Do not rewrite them as Sent webhook `sub_type` envelopes. -If the user cannot answer, default to Sent-managed setup and avoid prescribing Graph API calls. +## Path B: inherit the organization WABA -### 2. Identify the Sender Profile +Omit `whatsapp_business_account`: -Locate or create the Sender Profile that will own the WhatsApp sender identity. Use the Sent dashboard or `/v3/profiles`. Record the Sent profile ID, `x-sender-id` if visible, display name, brand description, and intended WhatsApp phone number. - -A WhatsApp number should map to the same recipient-visible brand represented by the profile. If the number belongs to a different brand, department, or tenant, use `sender-profile-architect` before proceeding. - -### 3. Collect WhatsApp onboarding evidence - -Collect the minimum evidence needed for Sent or Meta review. - -| Evidence | Why it matters | -|---|---| -| Business legal name and Meta Business identity | Confirms the WABA belongs to the intended sender. | -| Public website and privacy policy | Supports business verification and template review. | -| Phone number and ownership/control evidence | Prevents connecting the wrong sender. | -| Display name | Must match the business identity recipients expect. | -| Use cases and example messages | Drives template authoring and policy review. | -| Webhook endpoint and owner | Needed to verify event delivery after connection. | - -### 4. Map external identifiers without making them the Sent contract - -If the user provides WABA ID, phone-number ID, Meta Business ID, or System User details, store them as external provider identifiers mapped to the Sent profile. Do not make those IDs the primary application sender key. Use Sent profile ID and Sent message IDs for Sent operations. - -**Example mapping.** - -```text -sent_profile_id: 2b1b... -x_sender_id: support_us -channel: whatsapp -provider: meta -provider_business_id: external value, if available -provider_waba_id: external value, if available -provider_phone_number_id: external value, if available -status_source: Sent dashboard/API or Meta integration logs +```json +{ + "name": "Tenant Support", + "description": "Synthetic child profile", + "short_name": "SUPPORT", + "inherit_templates": true, + "billing_model": "organization", + "sandbox": true +} ``` -### 5. Complete or re-check the Sent profile - -Use `/v3/profiles/{profileId}/complete` when prerequisites are ready and API completion is in scope. The OpenAPI describes profile completion as a background process that validates prerequisites and connects profile configuration. If completion returns missing prerequisites, fix those inputs rather than creating duplicate profiles. - -### 6. Verify webhook readiness - -Use Sent webhook endpoints to confirm event delivery. Verify the webhook exists, the relevant event types are available, and a test event reaches the customer endpoint via `POST /v3/webhooks/{id}/test`. +Use this only after the organization WABA is connected. Inheritance means the tenant shares that WABA boundary; confirm this matches the tenant/brand architecture. -Rotate webhook secrets only when needed and coordinate deployment, because secret rotation invalidates the old secret immediately. +## Path C: dedicated WABA credentials -## Meta-side implementation review - -Use this section only when the user confirms that their application owns Embedded Signup. Label the work as Meta-side. Validate security, mapping, and callback handling before connecting results to Sent. - -| Area | Check | -|---|---| -| Launch context | Embedded Signup is launched from the right app, business, and allowed origin. | -| Callback handling | The app captures the signup result, not just a UI success state. | -| Token exchange | Authorization codes/tokens are exchanged server-side, never in public frontend storage. | -| Scope verification | Returned permissions/granular scopes match the required WABA and phone-number access. | -| Identifier lookup | WABA ID and phone-number ID are read back and mapped to the Sent profile. | -| Phone registration | Registration is completed only if the integration path requires the customer app to do it. | -| App subscription | Webhook subscription is completed only if the integration path requires direct Meta callbacks. | -| Secret handling | Tokens and webhook secrets are encrypted, rotated, and not logged. | - -Do not assume the customer app must call every Meta endpoint. Sent may abstract parts of onboarding depending on the customer’s setup. - -## Troubleshooting patterns - -| Symptom | First check | Likely next step | -|---|---|---| -| Sender Profile still shows WhatsApp not configured | Sent profile prerequisites and completion status | Confirm whether Sent-managed setup or external Embedded Signup results were expected. | -| User completed Meta flow but Sent cannot send | Mapping between external phone/WABA and Sent profile | Provide identifiers/evidence to Sent or update integration mapping. | -| Templates remain unavailable | Template status and WhatsApp business review | Use `waba-template-author` and Sent template status. | -| Webhooks not firing | Sent webhook test and event history | Fix endpoint/subscription before blaming WhatsApp delivery. | -| Wrong tenant receives events | Profile/message ID mapping | Use `sender-profile-architect` to redesign routing. | - -## Common rationalizations to avoid - -Do not treat a Meta UI success screen as proof that Sent can send WhatsApp messages. Verify Sent profile/channel status and test sends. - -Do not store access tokens in browser storage or logs. Treat Meta tokens and Sent webhook secrets as production credentials. +```json +{ + "name": "Dedicated Tenant", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -Do not assume a WABA can be reused across unrelated brands or tenants. Recipient-visible identity and operational ownership matter. +`waba_id` and `access_token` are required. `phone_number_id` is optional: when omitted, the current contract describes provisioning and registration during onboarding. -Do not hardcode Graph API version, scope names, or endpoint sequences in this skill body. Keep those in a reference file and re-check Meta docs or Sent implementation guidance before use. +The token needs the applicable WhatsApp Business messaging and management permissions. Inject it from a secret manager. Never log it, echo it, write it to fixtures, return it to the browser, include it in support output, or retain it in general profile storage. Sent does not return it in API responses. -Do not rotate Sent webhook secrets without coordinating the receiving endpoint. +## Complete the profile -## Verification checklist +Call `POST /v3/profiles/{profileId}/complete` with the required `webHookUrl`: -- [ ] The integration path is identified as Sent-managed, customer-managed Embedded Signup, or migration/import. -- [ ] The correct Sent Sender Profile is identified before external identifiers are mapped. -- [ ] WhatsApp business identity, phone-number evidence, display name, and use cases are collected. -- [ ] WABA/phone-number IDs are stored as external identifiers, not as the primary Sent sender key. -- [ ] Profile completion is run or checked after prerequisites are ready. -- [ ] Sent webhook existence, event types, event history, and test delivery are verified. -- [ ] Meta Graph steps are only prescribed when the user confirms direct ownership of Embedded Signup. -- [ ] Tokens, webhook secrets, and callback data are handled server-side and securely. +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -## Related skills +- `202` means background processing started; there is no final status in that response. +- `200` can mean the profile was already complete and currently demonstrates lowercase `completed`. +- The completion callback can report `COMPLETED`, `SUBMITTED`, or `failed`. -Use `sender-profile-architect` when deciding whether a WABA or phone number belongs in a separate Sender Profile. +Treat the completion callback as its own integration surface. Its envelope uses `event`, not `sub_type`: -Use `waba-template-author` when the WhatsApp sender needs templates written, categorized, submitted, or revised. +```json +{ + "event": "COMPLETED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" +} +``` -Use `template-builder-ui` when building the UI that imports or manages WhatsApp templates inside Sent. +Preserve unknown event strings. Verify authenticity using the mechanism Sent documents for the callback endpoint and make processing idempotent. -Use `messaging-performance-analyzer` when WhatsApp sends are connected but delivery/read/webhook outcomes are poor. +## Verify operational readiness -Use the `sent` skill for shared Sent terminology and routing. +- Profile WABA ID matches the intended business. +- Selected number is mapped to the intended profile. +- Template sharing/inheritance is intentional. +- A test template can be created with `sandbox: true`. +- The completion callback is reachable and idempotent. +- Returned message IDs are stored against the tenant/profile before webhook processing. +- Tokens and payment values are absent from logs. -## Suggested bundled references and scripts +For ordinary message and template webhooks, follow Sent's current events reference; those are separate from Meta browser events and profile-completion callbacks. -| File | Type | Purpose | -|---|---|---| -| `references/waba-embedded-signup-spec.md` | External platform reference | Keep Meta launch, token exchange, WABA lookup, phone registration, and subscription details out of the skill body. | -| `references/whatsapp-sender-profile-mapping.md` | Schema reference | Define how Sent profile IDs map to WABA IDs, phone-number IDs, display names, and status evidence. | -| `references/waba-onboarding-runbook.md` | Worked example | Show Sent-managed and customer-managed onboarding examples end-to-end. | +## Failure routing -## Unverified claims to confirm or remove +| Failure | Next action | +| --- | --- | +| `422` when credentials are omitted | Connect the organization WABA or provide dedicated credentials. | +| `403` with profile key and `x-profile-id` | Remove `x-profile-id` or use an authorized organization key. | +| Wrong WABA/number | Stop before completion and correct the profile mapping. | +| Expired/under-scoped token | Replace it securely; never print it while diagnosing. | +| Completion remains submitted | Inspect prerequisite and callback evidence; do not assume final failure from the `202`. | -- Sent does not expose a public Embedded Signup endpoint; WhatsApp connection is dashboard-only via Channels → WhatsApp (confirmed against Sent v3 docs snapshot, 2026-05-19; the Channels page is explicitly listed as "dashboard config; not directly in v3 API"). -- Required Meta app type, Tech Provider/Solution Partner status, granular scopes, and Graph endpoint sequence are external Meta claims, not Sent API facts. -- Mandatory direct phone-number registration or WABA subscription by the customer app depends on integration path and was not verified as a universal Sent requirement. -- Sent's webhook envelope is confirmed as `{field, sub_type, timestamp, payload}` with sub-types of the form `.` (e.g., `message.delivered`). WhatsApp-specific sub-types are not enumerated in the snapshot — discover them empirically against your account. +Use [references/waba-embedded-signup-spec.md](references/waba-embedded-signup-spec.md), [references/waba-onboarding-runbook.md](references/waba-onboarding-runbook.md), and [references/whatsapp-sender-profile-mapping.md](references/whatsapp-sender-profile-mapping.md). Use `sender-profile-architect` for tenant boundaries and `waba-template-author` for the first template. diff --git a/claude-plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md b/claude-plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md index 9d6f675..f032b9b 100644 --- a/claude-plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md +++ b/claude-plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md @@ -1,89 +1,48 @@ -# WABA Embedded Signup — Implementation Reference +# WABA integration specification -Supporting reference for `waba-embedded-signup`. The Sent v3 docs snapshot (2026-05-19) confirms that **Sent does not expose a public Embedded Signup API endpoint**. The customer-facing surface for connecting WhatsApp to Sent is the **dashboard's Channels → WhatsApp tab**, which is explicitly listed in Sent's "Dashboard pages → API endpoints map" as `(dashboard config; not directly in v3 API)`. The dashboard internally initiates Meta's Facebook Login for Business / Embedded Signup flow on the tenant's behalf. +## Capability boundaries -What this means for an integrator: +- Organization Embedded Signup is launched in the Sent dashboard. +- No public Sent endpoint starts that hosted signup. +- `POST /v3/profiles` can inherit the organization's WABA or accept a dedicated WABA. +- Direct profile credentials are not an Embedded Signup endpoint. -- **You do not call a Sent endpoint to start Embedded Signup.** You direct the tenant to their Sent dashboard. -- The Meta-side authentication, token exchange, WABA discovery, phone-number registration, app subscription, and app review state are owned by **Meta** and abstracted by the Sent dashboard. They are not surfaced as Sent API operations. -- After dashboard completion, the WhatsApp wiring is bound to the tenant's Sender Profile and routable via Sent's normal v3 API (`POST /v3/messages`, etc.). +## Dedicated credentials -Anything below this line is **external Meta documentation context** — included only so an operator debugging a stuck dashboard flow knows what is happening behind the scenes. Authoritative source: Meta — [Embedded Signup](https://developers.facebook.com/docs/whatsapp/embedded-signup), [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). Meta bumps the Graph API version regularly — always check the live Meta docs for the current version, scope names, and field names. +```text +whatsapp_business_account + waba_id required string + access_token required secret string + phone_number_id optional string +``` -## Sent-side surface (what the API does and doesn't expose) +Omitting `phone_number_id` invokes the current provisioning behavior documented by the profile contract. The token is write-only operational input and must never appear in API output, logs, fixtures, traces, analytics, or support text. -| Concern | Where it lives | -|---|---| -| Start Embedded Signup | Dashboard → Channels → WhatsApp → "Connect" (no public Sent API) | -| WABA / phone-number binding | Dashboard (not in v3 API) | -| Mark profile setup complete | `POST /v3/profiles/{id}/complete` (idempotent, sensitive endpoint — 10/min, burst 5) | -| Profile status after binding | `GET /v3/profiles/{id}` → `status` ∈ `incomplete` \| `pending_review` \| `approved` \| `rejected` | -| Webhook config | `POST /v3/webhooks`, `PUT /v3/webhooks/{id}`, `POST /v3/webhooks/{id}/test`, `POST /v3/webhooks/{id}/rotate-secret` (sensitive — 10/min, burst 5) | -| Auth header | `x-api-key: ` — single header, account-scoped. No `x-sender-id` in v3. | +## Inheritance -## Customer-facing dashboard flow (what the tenant sees) +Omit the entire `whatsapp_business_account` object to inherit. This succeeds only if the organization has completed Embedded Signup; otherwise expect `422`. -This mirrors the live flow on the dashboard's Channels page; it is what a tenant should be guided through, not an API sequence: +## Auth matrix -1. Dashboard → **Channels** → **WhatsApp** tab → click **Connect**. -2. Meta consent popup opens (Facebook Login for Business surface, initiated by Sent). -3. Tenant selects (or creates) a **WABA** under their Meta Business Portfolio. -4. Tenant grants Sent permission to **manage WhatsApp messages and templates** on that WABA. -5. Tenant adds a **Meta payment method** (separate from Sent billing — Meta charges per-conversation independently). -6. Dashboard reflects channel setup completion; the WhatsApp wiring is bound to the tenant's Sender Profile. -7. API credentials (the `x-api-key`) can be copied from the post-setup screen or retrieved later from the dashboard's API Keys page. +| Key | `x-profile-id` | Result | +| --- | --- | --- | +| Profile key | Omitted | Profile-scoped operation. | +| Profile key | Present | `403`. | +| Organization key | Valid child UUID | Child-scoped operation; organization rate-limit pool. | +| Organization key | Unowned UUID | `404`. | -The runbook (`references/waba-onboarding-runbook.md`) walks through this end-to-end with failure modes and recovery steps. +`x-sender-id` belongs to legacy v1/v2 guidance. -## Webhook envelope (Sent-confirmed) +## Profile completion -After WhatsApp is connected, Sent emits webhooks for that profile's messages using the universal envelope: +The request requires `webHookUrl`. A `202` only confirms processing began. A `200` can report an already-complete profile. Completion callback event values include `COMPLETED`, `SUBMITTED`, and `failed`; do not treat that vocabulary as the REST profile enum. ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "whatsapp", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "event": "SUBMITTED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" } ``` -Sub-types follow `.` (`message.queued`, `message.routed`, `message.sent`, `message.delivered`, `message.failed`, and on WhatsApp/RCS only, `message.read`). - -WhatsApp-specific sub-types beyond the universal `message.*` family (e.g., template approval/rejection notifications) are not enumerated in the v3 snapshot. To discover what your account currently subscribes to: - -1. List configured webhooks: `GET /v3/webhooks`. -2. Inspect a single webhook's `event_types` and `event_filters` fields. -3. Subscribe broadly to the `message` parent type and observe what arrives in production — fold the observed sub-types into your routing. - -## Webhook signature verification - -The webhook model (verified) exposes `signing_secret` as a per-webhook field; the exact HMAC algorithm and header name are not specified in the snapshot. Rotate via `POST /v3/webhooks/{id}/rotate-secret` — the old secret is invalidated immediately, so coordinate with the receiver before rotating. - -## Meta-side context (for operators only — link, do not reimplement) - -When a dashboard tenant is stuck and you need to know what the dashboard is doing on their behalf, the underlying Meta flow looks like this — read Meta's docs for current details: - -- Meta app type, Tech Provider / Solution Partner status, granular scopes, Graph version, redirect URI allowlisting → [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- OAuth code → System User token exchange → [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). -- WABA / phone-number lookup, phone-number registration with PIN, app subscription to WABA → [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). -- App review state, business verification, payment method, quality rating → Meta Business Suite UI. - -If a tenant is genuinely operating their own Meta app (not using the Sent-managed dashboard flow), they own all of the above and should be referred to Meta's docs directly. Sent's API does not replace that. - -## What is not in the v3 docs snapshot - -- The exact shape of the request body for `POST /v3/profiles/{id}/complete` for WhatsApp wiring (the snapshot confirms the endpoint exists and is sensitive; the per-channel payload is not published). -- The webhook signature algorithm / header used to verify Sent → receiver deliveries. -- The mapping shape between Sender Profile and the WABA / phone-number IDs the dashboard binds to it. -- WhatsApp-specific webhook sub-types (e.g., template lifecycle events). - -Treat each of these as "discover via your account" rather than "code to a spec". +Completion callbacks use `event`, not `sub_type`. Meta Embedded Signup browser messages also use an `event` field but have a different producer and payload. Keep the two handlers distinct. diff --git a/claude-plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md b/claude-plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md index bdb4098..d12cf88 100644 --- a/claude-plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md +++ b/claude-plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md @@ -1,120 +1,50 @@ -# WABA Onboarding Runbook — Dashboard Flow +# WABA onboarding runbook -Operator-facing companion to `waba-embedded-signup`. Walks the **Sent dashboard** WhatsApp connection flow in time order and tells you, for each step, what success looks like, the failure modes you should expect, and how to recover **without** restarting the whole signup. +## 1. Choose the path -The v3 snapshot confirms there is **no public Sent Embedded Signup API endpoint** — the Channels page in the dashboard is the surface, and it initiates Meta's Facebook Login for Business flow internally. This runbook reflects that reality; for the broader skill workflow and integration-path decision, see `waba-embedded-signup` SKILL.md. +- Organization needs its first WABA: use dashboard Embedded Signup. +- Child shares the organization WABA: create a profile and omit credentials. +- Child owns a dedicated WABA: create a profile with `waba_id` and `access_token`; optionally include `phone_number_id`. -## 0. Pre-flight (per-tenant gates) +Record why the choice matches brand, tenant, compliance, and blast-radius requirements. -Before the "Continue Channel Setup" button is meaningful for a tenant, two account-level gates must be true: +## 2. Prepare access -- [ ] **KYC approved.** Per the verified onboarding state machine, the account must have reached `KYC_COMPLETED` (state 5+). Before that, the v3 API returns `AUTH_006` and the dashboard blocks the Channels page. Compliance form fields (business identity, use cases, opt-in evidence) come from the dashboard's KYC + compliance pages. -- [ ] **Meta Business Portfolio ready.** The tenant must already have (or create during the flow) a Meta Business Portfolio under which a WABA will be selected or created. Sent does not provision this on the tenant's behalf. +Use a profile key alone or an organization key with `x-profile-id` for existing-child operations. Do not use `x-profile-id` with a profile key. Remove legacy `x-sender-id` examples. -If KYC is still in `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`, finish that first. The dashboard's onboarding checklist surfaces the next required step. +## 3. Handle secrets -## 1. Click "Continue Channel Setup" in the dashboard +- Receive tokens only through a protected server-side path. +- Store them in a secret manager if your system must retain them. +- Redact request bodies before logging. +- Never send tokens back to the frontend. +- Do not include tokens in screenshots, fixtures, errors, or support tickets. -After KYC, the dashboard surfaces a **Continue Channel Setup** entry that lands on the **Channels** page. +## 4. Create or update the profile -**Success looks like:** The Channels page loads and shows a **WhatsApp** tab with a **Connect** action. +Use `sandbox: true` first. On a dedicated WABA path, confirm that the returned non-secret WABA and number identifiers match intent. On inheritance, treat `422` as evidence the organization WABA prerequisite is absent. -**Common failure modes:** -- Button is missing / disabled → account state hasn't reached `KYC_COMPLETED`. Finish KYC first. -- API returns `AUTH_007` against `/v3/messages` for a tenant who thinks they're set up → they're at `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED` but haven't completed channel setup. They need to land on this page. +## 5. Complete -**Recovery without restarting:** Re-check `GET /v3/me` or the dashboard's onboarding indicator. Channel setup itself has no API; route the tenant back to the dashboard. +Send `webHookUrl` to `/v3/profiles/{profileId}/complete`. Persist the request ID and profile ID. A `202` is not final approval. -## 2. Select the phone number +The callback handler: -In the Channels → WhatsApp flow, the tenant selects the phone number that will be used for the WABA's first sender. Sent docs note that **this selection is not easy to change later** — once a phone number is bound to a Sender Profile, swapping it requires Meta-side migration plus a dashboard re-bind. +1. verifies the callback; +2. reads top-level `event`; +3. deduplicates by profile/event and delivery identity when available; +4. preserves unknown event strings; +5. records `COMPLETED`, `SUBMITTED`, or `failed` without coercing REST status. -**Success looks like:** The phone number is captured and the flow advances to Meta login. The number should be an E.164 line the tenant controls, not currently registered to another WABA they care about. +## 6. Smoke test -**Common failure modes:** -- Tenant picks a number that's already on a WABA they intend to keep separate → after Meta consent they'll discover the number is "in use elsewhere" and have to detach in WhatsApp Manager. -- Tenant picks a personal line they later want back for WhatsApp Consumer → that's a one-way door; warn upfront. +- Create a synthetic draft template with the Sent `definition` request shape. +- Validate with `sandbox: true`. +- Submit only after explicit review. +- Send to a controlled recipient. +- Persist the returned `message_id` with tenant/profile attribution. +- Verify the normal Sent message webhook separately from the completion callback. -**Recovery without restarting:** Within the same flow you can usually back out and pick a different number. After completion, switching numbers requires Meta-side migration and a fresh dashboard binding. +## 7. Rollback -## 3. Log in with Facebook/Meta and grant Sent permission - -The dashboard launches Meta's Embedded Signup popup (Facebook Login for Business). The tenant: - -- Logs in with their Meta account that admins the Business Portfolio. -- Selects (or creates) the **WABA** to bind. -- Grants Sent permission to **manage messages and templates** on that WABA. - -**Success looks like:** The popup closes with success; the dashboard reflects the connected WABA name. - -**Common failure modes:** -- Popup closes immediately → ad-blocker or popup-blocker. Disable for the Sent dashboard origin. -- Tenant chose "Create a new WhatsApp Business Account" inside the dialog and got stuck on business verification → not a Sent issue; tenant must finish verification in Meta Business Suite, then return. -- Tenant unchecked WhatsApp permissions in the consent screen → the binding will fail or be unusable. Re-launch Connect and accept all required permissions. - -**Recovery without restarting:** Re-launch Connect from the dashboard. The tenant only re-confirms permissions; previously-captured fields (like the chosen phone number) typically persist. - -## 4. Add Meta payment method - -Meta charges per-conversation for WhatsApp Business messaging, separately from Sent's billing. The tenant must add a payment method to the WABA in WhatsApp Manager / Meta Business Suite. - -**Success looks like:** Payment method status is "Active" in WhatsApp Manager. Sent's dashboard may surface a "Meta payment required" warning until this is true. - -**Common failure modes:** -- Card declines → tenant retries with a different card in Meta Business Suite. -- Tenant conflates this with Sent billing → clarify: Sent bills Sent fees; Meta bills WhatsApp conversation fees directly to the WABA. - -**Recovery without restarting:** Meta payment is set on the WABA, independent of the Sent flow — the tenant can complete this without re-doing steps 1–3. - -## 5. Confirm channel setup completion in dashboard - -After the WABA binding and Meta payment are in place, the dashboard reflects channel setup as complete. Internally, the account state should advance to `MESSAGE_COMPLIANCE_COMPLETED` and then to activated. The API surface that signals "I am done" is `POST /v3/profiles/{id}/complete` (idempotent, sensitive — 10/min, burst 5). Inspect `GET /v3/profiles/{id}` and look for `status` ∈ `pending_review` → `approved`. - -**Success looks like:** Profile `status` reaches `approved`; the dashboard shows the WhatsApp channel as connected. - -**Common failure modes:** -- Profile stays `pending_review` → Sent-side review is still running. Surface the status to the tenant; do not retry `complete` in a loop (rate-limited). -- Profile lands at `rejected` → KYC or compliance evidence was insufficient; the dashboard explains the reason. Fix in KYC + re-run. -- API returns `AUTH_005` against sends → the account state is at step 6 waiting for final Sent-side activation. No tenant action needed; wait. - -**Recovery without restarting:** `POST /v3/profiles/{id}/complete` is idempotent — calling again with the same input is safe. Do not delete and re-create the profile to "reset" status. - -## 6. Copy API credentials - -Once `status = approved`, API credentials are available: - -- On the post-setup screen, or -- Anytime from the dashboard's **API Keys** page (the snapshot lists this as `(dashboard-only; not in v3 API spec)` — there is no API to mint or list keys). - -Auth in v3 is a single header: `x-api-key: `. There is no `x-sender-id` in v3 — that's v2 legacy. The key is account-scoped. - -**Success looks like:** A test request to `GET /v3/me` with the key returns 200. - -**Common failure modes:** -- `AUTH_001` (401, missing header) → header name wrong; must be `x-api-key`. -- `AUTH_002` (401, invalid key) → key was rotated or copied with whitespace. -- `AUTH_007` (403, no channel configured) → key is valid but the account is at `KYC_COMPLETED` / `MESSAGE_COMPLIANCE_COMPLETED` without a finished channel. Re-check step 5. -- `AUTH_005` (403, pending final activation) → wait for Sent activation; not a credential problem. - -**Recovery without restarting:** Re-copy the key from the dashboard. Treat the key as a secret — never log it. Use the sandbox mode (`"sandbox": true` in mutation request bodies) for integration tests so you don't burn budget. - -## Stuck-state triage cheat-sheet - -| Symptom in production | Step | First thing to check | -|---|---|---| -| "Continue Channel Setup" missing | 0–1 | Account state — finish KYC first | -| Channels page rejects the chosen number | 2 | Number already on another WABA | -| Meta popup closes immediately | 3 | Popup/ad-blocker on dashboard origin | -| Popup completes but dashboard shows "not connected" | 3 | Tenant unchecked permissions; re-launch Connect | -| Dashboard shows "Meta payment required" | 4 | Add payment in WhatsApp Manager | -| Profile stuck `pending_review` | 5 | Sent-side review; do not re-POST `complete` in a loop | -| API send returns `AUTH_007` | 5 | Channel setup not actually complete | -| API send returns `AUTH_005` | 5 | Final Sent activation pending; no action | -| `x-api-key` returns `AUTH_002` | 6 | Re-copy from dashboard; check whitespace | - -## What this runbook deliberately does not cover - -- Customer apps that own their **own** Meta App and run Embedded Signup themselves (rather than using the Sent-managed dashboard flow). That path is owned by Meta — see `references/waba-embedded-signup-spec.md` and Meta's [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- Template authoring and submission — see `waba-template-author`. -- Multi-tenant Sender Profile design — see `sender-profile-architect`. -- Post-connection delivery debugging — see `messaging-performance-analyzer`. +If the WABA or number is wrong, stop new sends, revoke exposed credentials, correct profile mapping, and retain audit evidence. Avoid deleting a profile until number ownership and message retention are resolved. diff --git a/claude-plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md b/claude-plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md index bb74cb0..1102d4b 100644 --- a/claude-plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md +++ b/claude-plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md @@ -1,103 +1,40 @@ -# WhatsApp ↔ Sent Sender Profile Mapping - -How Meta-side entities created during Embedded Signup map onto Sent's Sender Profile model. Read this before deciding how many profiles to create per tenant, or when debugging why a webhook landed on the wrong profile. - -For the broader multi-channel architecture (one profile owns SMS + WhatsApp + RCS halves; how to split tenants across profiles), see `sender-profile-architect`. - -## The entities - -**Meta side:** -- **Business Manager / Business Portfolio** — the tenant's legal/operational umbrella in Meta Business Suite. -- **WABA (WhatsApp Business Account)** — owns templates and phone numbers; the unit Meta bills. -- **Phone Number** — a single E.164 number registered for Cloud API on a WABA. -- **System User** — long-lived identity holding the access token used to call Graph API on behalf of the tenant. - -**Sent side (v3 — schema verified against snapshot):** - -A Sender Profile is: - -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | The Sent-side primary key. | -| `name` | string | Display name. | -| `icon` | string \| null | URL. | -| `description` | string \| null | | -| `short_name` | string \| null | | -| `role` | `admin` \| `billing` \| `developer` \| null | Caller's role on this profile. | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status. | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | - -There is **no public `channels.whatsapp` sub-resource** on the Profile in the v3 docs snapshot. Per-channel WhatsApp wiring (WABA ID, phone-number ID) is performed via the dashboard Channels page, which is explicitly listed as "dashboard config; not directly in v3 API". Treat WABA and phone-number IDs as external provider identifiers that the dashboard binds to the profile; do not invent v3 endpoints to mutate that binding. - -Auth in v3 is a single header — `x-api-key: ` — at the account level. `x-sender-id` is **v2 legacy** and is exposed per profile in the dashboard for routing, not as a v3 API auth requirement. - -## ASCII map - -``` -Tenant - │ - ├── Business Manager (1) - │ │ - │ ├── WABA #A ────────────────────────► Sender Profile P1 (id, status=approved) - │ │ ├── Phone +1 555 0100 ◄────────┤ (dashboard-bound) - │ │ ├── Phone +1 555 0101 ◄──┐ │ - │ │ └── Templates │ │ - │ │ │ │ - │ │ └────► Sender Profile P2 - │ │ │ (different phone, same WABA) - │ │ - │ └── WABA #B ────────────────────────► Sender Profile P3 - │ └── Phone +44 20 7946 0000 ◄────┤ - │ - └── System User (1) ──► token held in vault, referenced by all of P1/P2/P3 -``` - -## Cardinality rules (operational, not enforced by v3 API) - -| Relationship | Cardinality | Notes | -|---|---|---| -| Business Manager → WABA | 1 : N | A tenant may operate multiple WABAs (per region or brand). | -| WABA → Phone Number | 1 : N | Up to 25 per WABA per Meta's current limits. | -| Phone Number → Sender Profile | 1 : 1 | **Hard rule.** Each phone number routes to exactly one profile; sharing breaks inbound routing. | -| WABA → Sender Profile | 1 : N | Multiple profiles may bind to the same WABA, each pinning a different phone number. | -| System User → WABA | 1 : N | One System User token can hold scopes for many WABAs. | -| Sender Profile → WhatsApp wiring | 0 : 1 | A profile has at most one WhatsApp binding. SMS / RCS bindings are independent. | - -## What `POST /v3/profiles/{id}/complete` actually does - -`POST /v3/profiles/{id}/complete` is confirmed in the v3 snapshot as the profile-completion endpoint. It supports `Idempotency-Key` and is classified as a sensitive endpoint (10 req/min, burst 5). It transitions the profile out of `incomplete` once prerequisites are met. - -The exact request/response shape for the completion call (which fields must be present, what gets persisted) is **not enumerated in the v3 snapshot**. Treat the completion call as a commit: prerequisites (KYC + channel config done via the dashboard) must already be true; the endpoint signals "I am ready". Check the live OpenAPI at [docs.sent.dm](https://docs.sent.dm) before wiring a tenant-facing integration. - -## Routing inbound events back to a profile - -Sent's webhook envelope (verified) is: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { "account_id": "...", "message_id": "...", "channel": "whatsapp", "inbound_number": "+1...", "outbound_number": "+1...", "template_id": "..." } -} +# WhatsApp Sender Profile mapping + +## Recommended records + +```text +tenant_id +sent_profile_id +waba_mode # organization_inherited | dedicated +waba_id +whatsapp_phone_number_id +whatsapp_phone_number +profile_key_secret_ref +status_raw +status_surface ``` -For WhatsApp inbound, the payload carries `account_id`, `message_id`, and the inbound/outbound E.164 numbers. WhatsApp-specific sub-types beyond the generic `message.*` family (e.g., template-status events) are not enumerated in the snapshot — discover them empirically against your account by subscribing broadly and observing what arrives. +Do not store `access_token` in this record. Use a secret-manager reference when retention is necessary. -## Detaching a WABA without losing message history +## Mapping invariants -There is no v3 API endpoint documented for detaching a WhatsApp binding from a profile. The Channels page in the dashboard is the surface. Operationally: +- A dedicated WABA profile has a recorded `waba_id` matching the intended tenant. +- An inherited profile explicitly records that it shares the organization WABA. +- A WhatsApp number maps to one current tenant/profile route unless the product has a documented coexistence model. +- `message_id` is persisted with tenant and profile before webhook events arrive. +- Unknown REST or completion callback statuses are stored verbatim with their surface. -1. Stop sending on the profile. -2. Wait for in-flight deliveries to settle (delivery webhooks drain within ~24h for normal traffic, longer for slower carriers). -3. Use the dashboard Channels page to remove the WhatsApp binding. -4. On the Meta side, unsubscribe your Tech Provider app from the WABA via Graph API if you held the subscription directly. +## Auth ownership -Historical MDRs remain queryable by `message_id` — message history is not deleted when the binding is removed. +Profile keys minimize tenant credential blast radius. Organization keys with `x-profile-id` centralize control but share the organization rate-limit pool and expand credential impact. Never expose the organization key to tenant code. -## Migrating a phone number between WABAs +## Event surfaces -Meta supports moving a phone number between WABAs and the phone-number ID is stable across the move. On Sent's side, the dashboard Channels page is the supported surface to re-bind. Since the v3 docs do not publish the channel-config mutation endpoint, do not encode a `PATCH /v3/profiles/{id}/channels/whatsapp` call in client integrations — operate via the dashboard until the API is published. +| Surface | Discriminator | +| --- | --- | +| Meta Embedded Signup browser message | `event` plus Meta session/data fields | +| Sent profile completion callback | top-level `event` | +| Sent message webhook | `field: "message"` plus `sub_type` | +| Sent template webhook | `field: "templates"`, no `sub_type` | -Templates are WABA-scoped and do **not** transfer with the phone number — re-author or re-import on the new WABA. +Do not copy envelopes between these integrations. They have different producers, authenticity checks, and retry behavior. diff --git a/claude-plugins/sent/skills/waba-template-author/SKILL.md b/claude-plugins/sent/skills/waba-template-author/SKILL.md index 40d25b1..c11baf2 100644 --- a/claude-plugins/sent/skills/waba-template-author/SKILL.md +++ b/claude-plugins/sent/skills/waba-template-author/SKILL.md @@ -1,197 +1,168 @@ --- name: waba-template-author -description: Writes, classifies, and revises WhatsApp templates for Sent, including utility, marketing, and authentication category decisions, variable samples, component structure, rejection-risk review, and Sent template submission. Use when a user says WhatsApp template, WABA template, template category, utility vs marketing, authentication template, Meta rejection, template samples, buttons, or wants approved WhatsApp copy in Sent. +description: Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads. --- - - -# WABA template author - -## Overview - -Use this skill to write WhatsApp template content that can be represented as a Sent template, submitted for WhatsApp review where required, and later sent through `POST /v3/messages` with `template.id`. The skill’s job is not just to produce polished copy; it must choose the right category, structure components correctly, provide realistic sample values, and flag review risks before submission. - -Sent stores templates as reusable message blueprints across SMS, WhatsApp, and RCS. WhatsApp review and category enforcement come from Meta, but the Sent-facing workflow uses Sent’s `/v3/templates` endpoints and Sent template statuses. - -## When to use - -Use this skill when the user asks for WhatsApp template copy, WABA template creation, utility/marketing/authentication classification, template rejection fixes, variable samples, buttons, headers, template categories, Meta approval risk, or a Sent template payload for WhatsApp. - -Do not use this skill to design the whole template-management UI; use `template-builder-ui`. Do not use it to connect a WABA or phone number; use `waba-embedded-signup`. Do not use it to register SMS compliance; use `sms-10dlc-registration`. - -## Category decision - -Pick the narrowest truthful WhatsApp category. Do not force promotional content into utility. The category should match the recipient’s expectation, the opt-in context, and the actual copy. - -| Category | Use when | Avoid when | -|---|---|---| -| Utility | The message is tied to an existing transaction, account, order, appointment, or service request. | The copy includes upsell, acquisition, abandoned cart, discount, or broad engagement language. | -| Marketing | The message promotes, re-engages, cross-sells, announces offers, or encourages optional action not tied to an existing transaction. | The message is purely required service/account information. | -| Authentication | The message delivers one-time passcodes or verification flows. | The message includes non-authentication content or marketing. | - -**Example.** “Your order 1234 shipped and arrives tomorrow” is utility. “Your order shipped — add accessories for 20% off” is marketing risk because it adds promotional content. - -## Process - -### 1. Capture the business intent - -Ask what event triggers the template, who receives it, what action the recipient should take, and whether the message contains any promotion. Write those answers before drafting copy. - -A strong intent statement is specific: “Send a delivery reschedule link after a courier misses the first attempt.” A weak one says: “Notify users about updates.” - -### 2. Choose the category before writing copy - -Drafting before category selection often creates copy that fails review. Choose utility, marketing, or authentication first, then write within that boundary. - -If the user wants utility but includes promotional language, explain the conflict and offer two options: remove promotion and keep utility, or keep promotion and classify as marketing. - -### 3. Draft the component structure - -Represent the template in Sent-compatible component language: header, body, footer, buttons, variables, and samples. Keep the component set as simple as the use case allows. - -| Component | Guidance | -|---|---| -| Header | Use only when it clarifies identity or context. Avoid promotional headers for utility templates. | -| Body | Put the required message and variables here. Keep the first sentence clear without needing the button. | -| Footer | Use for low-emphasis context such as opt-out or support where appropriate. | -| Buttons | Use quick replies or call-to-action buttons only when they directly support the message intent. | -| Variables | Use stable names and provide realistic samples for every variable. | - -### 4. Write with review risk in mind - -Use concise, literal copy. Avoid vague urgency, misleading scarcity, or mixed intents. Do not include sensitive data unless the use case requires it and the customer confirms it is acceptable. - -**Utility example.** - -```text -Name: order_shipped_update -Category: Utility -Language: en_US -Body: Hi {{first_name}}, your {{brand_name}} order {{order_id}} has shipped and is expected on {{delivery_date}}. Track it here: {{tracking_url}}. -Samples: - first_name: Alex - brand_name: Acme - order_id: A12345 - delivery_date: May 22 - tracking_url: https://acme.example/t/A12345 -Button: Track order -> {{tracking_url}} -``` - -**Marketing example.** - -```text -Name: spring_sale_announcement -Category: Marketing -Language: en_US -Body: Hi {{first_name}}, {{brand_name}} spring deals are live. Use code {{promo_code}} by {{end_date}} to save on selected items. -Samples: - first_name: Alex - brand_name: Acme - promo_code: SPRING20 - end_date: May 31 -Button: Shop now -> https://acme.example/sale +# WhatsApp Template Author + +Use this skill to turn a messaging intent into a valid body for `POST /v3/templates`, review it for WhatsApp policy risk, and explain the resulting lifecycle. Sent's template request is not Meta's Cloud API `components[]` shape. + +## Source precedence + +When official sources disagree: + +1. Use the live Sent v3 OpenAPI for paths, request fields, and response shapes. +2. Use the most specific current Sent guide for lifecycle and policy semantics. +3. Preserve unknown provider values instead of forcing them into a closed enum. + +The canonical references are the Sent template-definition guide, the v3 OpenAPI, and the webhook events reference. Do not use snapshot-era v2 examples. + +## Authoring workflow + +### 1. Establish intent and category + +Collect the business event, recipient expectation, requested action, language, channel overrides, and realistic sample values. Choose: + +- `UTILITY` for a specific non-promotional transaction, account, or service event. +- `MARKETING` for promotions, offers, re-engagement, product discovery, or mixed promotional content. +- `AUTHENTICATION` for one-time verification codes and supported authentication flows. + +If content mixes utility and promotion, classify it as marketing or split it. See [references/waba-template-categories.md](references/waba-template-categories.md). + +### 2. Build the Sent create request + +`POST /v3/templates` accepts these top-level fields: + +| Field | Requirement | +| --- | --- | +| `definition` | Required. Contains `header`, `body`, `footer`, `buttons`, optional `definitionVersion`, and optional `authenticationConfig`. | +| `category` | Optional: `UTILITY`, `MARKETING`, or `AUTHENTICATION`; omit for detection only when ambiguity is acceptable. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional source string; `from-api` is the documented default. | +| `submit_for_review` | Optional Boolean; default `false`. Draft and validate before review. | +| `sandbox` | Optional Boolean for validation without side effects. | + +Do not put `name`, `channels`, `body`, `header`, `buttons`, or `components` at the request root. `name` exists on update/response surfaces, not on the current create request. + +```json +{ + "category": "UTILITY", + "language": "en_US", + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} has shipped.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": null, + "whatsapp": null, + "rcs": null + }, + "footer": null, + "buttons": null, + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true +} ``` -### 5. Convert the draft into a Sent template operation +Use `definition.body.multiChannel` as the channel-neutral body. `sms`, `whatsapp`, and `rcs` are complete channel overrides, not fragments. Keep each body at or below 1,024 characters. -Use Sent’s template API for creation and lifecycle management. The verified operations are: +### 3. Define variables exactly -| Operation | Endpoint | Use | -|---|---|---| -| Create template | `POST /v3/templates` | Save a draft or submit a new template. | -| List templates | `GET /v3/templates` | Find templates by search, status, or category. | -| Retrieve template | `GET /v3/templates/{id}` | Inspect status and definition. | -| Update template | `PUT /v3/templates/{id}` | Revise name, category, language, definition, or submit for review. | -| Delete template | `DELETE /v3/templates/{id}` | Delete the Sent template, optionally deleting from Meta where supported. | +Use placeholders such as `{{0:variable}}`, `{{1:link}}`, or `{{2:media}}`. Each placeholder needs one matching definition with: -Use Sent’s documented template statuses in user-facing instructions: `PENDING`, `APPROVED`, `REJECTED` (per the Sent docs snapshot, Template Models section). Sent does **not** surface `PAUSED` — that is Meta-side only. If Meta returns additional statuses for a WhatsApp account, quote them as Meta-side evidence rather than Sent-surfaced statuses. +- a unique non-negative integer `id`; +- a readable `name`; +- a matching `type`; +- `props.sample` with realistic review and preview data. -### 6. Add variable samples before submission +Keep placeholder IDs and variable IDs aligned inside every body override. Never output naked `{{1}}` placeholders in a Sent request. -Every placeholder needs a realistic sample. Samples should look like production data and should not add claims that the body does not support. +### 4. Add supported buttons -**Bad sample pattern.** Body says “Your appointment is confirmed,” but sample data includes “50% off visit.” This can create category confusion. +Sent currently recognizes `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE`. Enforce: -**Good sample pattern.** Body and sample values all support the same transactional use case. +- 10 buttons total; +- at most 2 URL buttons; +- at most 1 voice-call button; +- at most 1 phone-number button; +- at most 1 copy-code button; +- quick replies may use the remaining slots, up to the total of 10. -### 7. Revise rejected templates from the reason, not from guesses +Buttons use `id`, `type`, and `props`. Labels are at most 25 characters. Require type-specific properties: `quickReplyType`; `urlType` and `url`; `countryCode` and `phoneNumber`; or `offerCode`. Quick replies and calls-to-action may coexist—do not invent an XOR rule. -If a template is rejected, retrieve the Sent template detail/status and any available rejection reason. Then change only what the reason justifies. Category mismatch, missing samples, unsupported components, and promotional language in utility templates require different fixes. +### 5. Handle authentication templates -| Rejection symptom | Likely correction | -|---|---| -| Category mismatch | Change category or remove conflicting copy. | -| Missing/weak samples | Add realistic variable samples. | -| Unsupported component | Simplify header/buttons or split channel variants. | -| Policy concern | Remove misleading, sensitive, or prohibited content. | -| Language mismatch | Correct language code and localized text. | +For `AUTHENTICATION`, use `definition.authenticationConfig`: -### 8. Confirm sendability after approval - -After approval, confirm the template can be used with the intended Sender Profile/channel and sent through `POST /v3/messages` with `template.id`. If delivery later fails, hand off to `messaging-performance-analyzer` rather than rewriting approved copy blindly. - -## Common rationalizations to avoid - -Do not call a template utility if it includes discounts, upsells, abandoned-cart messaging, or broad engagement language. - -Do not omit sample values because the placeholders are obvious. Review and test flows need rendered examples. - -Do not treat Meta Cloud API payload examples as the Sent API contract. Use Sent `/v3/templates` for Sent integrations. - -Do not introduce `PAUSED` as a Sent template status. Sent surfaces only `APPROVED`, `PENDING`, and `REJECTED` — PAUSED is Meta-side and is not reflected in the Sent template status. When Meta pauses, the Sent status stays as it was, and individual sends start failing instead — diagnose via `messaging-performance-analyzer`. +```json +{ + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 +} +``` -Do not rewrite a rejected template without reading the actual rejection reason when available. +Expiration is 1–90 minutes. Keep authentication content to the verification purpose, use one code variable and the supported copy-code action, and do not add marketing language, unrelated links, media, or promotional buttons. -## Verification checklist +### 6. Validate before submission -- [ ] The trigger event, audience, recipient action, and promotional content are documented. -- [ ] Category is chosen before copy is drafted. -- [ ] The body is clear without relying on a button. -- [ ] Every variable has a realistic sample value. -- [ ] Component choices match the selected channel and use case. -- [ ] Sent template API endpoints are used for create/list/get/update/delete. -- [ ] Status handling uses only Sent's surfaced set — `APPROVED`, `PENDING`, `REJECTED` (no `PAUSED`). -- [ ] Rejection fixes map to observed reasons, not generic rewrites. -- [ ] Approved templates are tested through Sent sending with `template.id` before broad rollout. +Run: -## Related skills +```bash +python scripts/lint_waba_template.py template.json +``` -Use `template-builder-ui` when the task is UI design, component validation, JSON editor behavior, or template-management product specs. +The linter validates the Sent request shape, variables, the 1,024-character limit, channel overrides, every current button type, per-type limits, and authentication configuration. A Meta Cloud API example with `components[]` must fail with an explicit conversion error. -Use `waba-embedded-signup` when the WhatsApp sender/WABA/phone number is not connected to Sent yet. +Use `sandbox: true` and `submit_for_review: false` while integrating. When the user is ready for provider review, show the final payload and explain that submission changes external state before proceeding. -Use `sender-profile-architect` when the template belongs to a specific tenant, brand, department, or profile boundary. +### 7. Track the right lifecycle surface -Use `sms-10dlc-registration` when WhatsApp copy will be mirrored to SMS and must align with US A2P use-case registration. +Sent template resources use the known states `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Do not claim this is every value the API may ever return. -Use `messaging-performance-analyzer` when approved WhatsApp templates have poor delivery, read, or webhook outcomes. +Template webhooks are WhatsApp approval events. They use `field: "templates"`, omit `sub_type` and `event`, and carry the provider status in `payload.status`: -Use the `sent` skill for shared Sent terminology and routing. +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": null + } +} +``` -## Bundled references and scripts +Common forwarded values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`. Meta can also send values such as `PAUSED` or `DISABLED`. Persist the raw string, handle known values, and safely surface unknown ones. See [references/template-rejection-playbook.md](references/template-rejection-playbook.md). -| File | Type | Purpose | -|---|---|---| -| `references/waba-template-categories.md` | Policy lookup table | Meta category boundaries, component rules, and Cloud API submission shape. | -| `references/waba-template-examples.md` | Worked examples | Copy-pasteable payloads for utility, marketing, and authentication templates. | -| `references/template-rejection-playbook.md` | Decision matrix | Map rejection reasons to precise edits and resubmission etiquette. | -| `scripts/lint_waba_template.py` | Validation script | Stdlib lint for a template JSON payload (placeholder numbering, samples, category-risk phrases, button structure, language code). Run from the skill root: `python scripts/lint_waba_template.py template.json`. | -| `scripts/fixtures/utility_good.json` | Fixture | Passing fixture for the linter. | -| `scripts/fixtures/utility_bad.json` | Fixture | Failing fixture (wrong placeholder order, promo phrasing in utility). | +## Boundaries -## Unverified claims to confirm or remove +Use `template-builder-ui` for editor architecture and client-side validation UX. Use `sent-templates` to list, inspect, or delete existing templates through the connected Sent tools. Use `waba-embedded-signup` for WABA connection. Use `rcs-agent-onboarding` for current RCS launch capabilities. -- Exact category-pricing behavior and Meta rejection-code semantics are external Meta policy context, not Sent API facts. -- Cross-check the Sent template schema against the live OpenAPI at `https://docs.sent.dm` before promoting code to production. +Meta Cloud API payloads may appear in [references/waba-template-examples.md](references/waba-template-examples.md), but every such example must be clearly labelled non-Sent and must never be passed to the Sent linter as a valid request. diff --git a/claude-plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md b/claude-plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md index 60d36c1..b8c8a0d 100644 --- a/claude-plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md +++ b/claude-plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md @@ -1,182 +1,62 @@ -# Template Rejection Playbook — Reference +# Template rejection and lifecycle playbook -What to do when Meta rejects a WhatsApp template, silently re-categorizes it, -or pauses it after delivery starts. Companion to `references/waba-template-categories.md` -and `references/waba-template-examples.md`. Authoritative source for Meta-side codes is -the official [Cloud API template docs](https://developers.facebook.com/docs/whatsapp/message-templates). -Sent-surfaced statuses and codes come from the Sent docs snapshot referenced -above. +Use this reference when a Sent template is pending, rejected, paused, disabled, or recategorized by the WhatsApp provider. -Every entry: what triggers it, how to detect it from the rejection / status -payload, and what to change before resubmitting. +## Keep lifecycle surfaces separate -## Sent-surfaced template states (not Meta's) +Sent template resources have these known states: -Sent's template `status` set is exactly `APPROVED`, `PENDING`, `REJECTED` — -**no `PAUSED`**. When Meta pauses a template (quality rating drop, opt-out -spike), Sent's template status does **not** change; it stays whatever it was -(typically `APPROVED`). Sends against a Meta-paused template start failing -asynchronously — surface that via the `message.failed` webhook or the -`GET /v3/messages/{id}/activities` endpoint, not via a template-status poll. +- `DRAFT` +- `PENDING` +- `APPROVED` +- `REJECTED` +- `PAUSED` -When a send is attempted against a Sent template whose `status` is `PENDING` -or `REJECTED`, the batch is rejected synchronously with: +The template webhook is a provider-forwarding surface. Common `payload.status` values are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; provider values such as `PAUSED` and `DISABLED` can also arrive. These lists serve different purposes. Persist the original status string and surface unknown values safely. -| Code | HTTP | Meaning | -|---|---|---| -| `BUSINESS_005` | 422 | "WhatsApp template not approved (still PENDING / REJECTED)" | - -So the two failure modes are distinct: - -- Template never reached `APPROVED` in Sent → `BUSINESS_005` on send. -- Template is `APPROVED` in Sent but Meta-paused → per-message failure on the - webhook / activities feed; the template `status` you see in Sent is unchanged. - -For diagnosing post-approval send failures, hand off to -`messaging-performance-analyzer`. - -## Category mismatch (utility classified, marketing content) - -**What it looks like:** Template is `APPROVED` but the returned `category` is -`MARKETING` even though you submitted `UTILITY`. Or, after first send, the -status moves to `PENDING` → `APPROVED` again with a flipped category. - -**How to detect:** Compare submitted `category` to the post-approval `category` -on the webhook. Re-categorization is the most common silent failure. - -**Revise:** -- Strip any sentence that does not refer to the triggering event. -- Replace generic CTAs ("Shop now", "Browse more") with event-specific ones - ("View order", "Track package"). -- Remove second-person calls to action that are not the entity in the message. -- Resubmit under a new version suffix (`_v2`) — the old name is locked. - -## Promotional content in utility category - -**Banned in utility body, footer, header, button labels:** - -- "buy now", "shop now", "order today" -- "limited time", "exclusive", "hurry", "ends soon" -- "special offer", "best deal", "lowest price" -- "discount", "% off", "sale", "free shipping" -- Discount codes like `SAVE20`, even as a variable sample -- Promotional emojis in body or header text (🎉 🛍️ 💸 🔥) - -**Revise:** strip the phrase; if the use case genuinely includes a promo, -flip to `MARKETING` rather than masking the wording. - -## Missing variable samples - -**What it looks like:** Submission fails with `TAG_CONTENT_MISMATCH` or the -template is rejected with "variable example missing". - -**Sent's required shape:** +## Correct template event envelope ```json { - "type": "BODY", - "text": "Hi {{1}}, your order #{{2}} has shipped.", - "example": { "body_text": [["Jordan", "A1029"]] } + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "REJECTED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": "Promotional content is not utility content." + } } ``` -`body_text` is an array of arrays — the outer array is "one row per -variable group", and the inner array has one sample per `{{n}}`. Forgetting -the outer array is the single most common cause. - -For URL CTAs: - -```json -{ "type": "URL", "text": "Track", "url": "https://example.com/orders/{{1}}", - "example": ["https://example.com/orders/A1029"] } -``` - -`example` is a flat array here (not nested). - -## Authentication template with code formatting error - -**What it looks like:** Submission fails with "invalid component" on an -authentication template, or the OTP button does not appear in the rendered -template preview. - -**Revise:** -- Use `category: "AUTHENTICATION"` and the dedicated auth component shape, not - a UTILITY template with a `{{1}}` for the code. -- The body component must use `add_security_recommendation: true` or include - the platform-managed security recommendation; no freeform `{{1}}` for the code. -- The button component must be `OTP` with `otp_type` of `COPY_CODE` or `AUTOFILL`. -- `code_expiration_minutes` belongs on the FOOTER component, not the body. - -See `references/waba-template-examples.md` for two valid AUTH payloads. - -## Button URL doesn't match domain - -**What it looks like:** Rejection with "URL does not match business domain" -or the template is approved but later paused for the same reason. - -**Revise:** -- Confirm the WABA's verified business domain matches the CTA URL host. -- Subdomains often need to be added separately if Meta's domain check is - strict — `app.example.com` and `example.com` are not interchangeable. -- For URL CTAs with a variable, the example URL must resolve to the same - registered domain. -- If you're a multi-tenant platform on Sent, the WABA in question must own - the domain — you cannot deep-link to a tenant subdomain not registered - under that WABA. - -## Language code mismatch - -**What it looks like:** Rejection with `INVALID_LANGUAGE`, or two templates -under the same conceptual name behave inconsistently across recipients. - -**Revise:** -- Use BCP-47 codes with the underscore separator: `en_US`, `pt_BR`, `es_MX`. -- Not `en`, not `en-US`, not `en_us`. -- Each language is a *separate template* with its own approval. You cannot - submit one template and have it cover several locales. - -## Generic placeholders (template too generic) - -**What it looks like:** Rejection with a content reason ("does not meet -template quality standards") even though the wording is neutral. - -**Triggers:** -- Bodies like "{{1}}, here is an update for you" — Meta cannot tell what - category the template is for, so they default to reject. -- Sample values like `Test`, `Sample`, `XYZ` — these read as if the template - was never going to be used in production. +Template events use `field: "templates"` and omit both `sub_type` and `event`. Message events are different and do use `sub_type`. -**Revise:** add at least one variable that proves the use case (an order ID, -an appointment time, an account-event timestamp) and use realistic sample -values that match. +## Response procedure -## Resubmission etiquette and timing +1. Verify the webhook signature using the raw body and reject stale timestamps. +2. Deduplicate on template ID plus status transition. +3. Persist the raw payload and reason. +4. Retrieve the current Sent template before editing; webhooks can be delayed or reordered. +5. Map the reason to the smallest justified change. +6. Convert any Meta-shaped source into the Sent `definition` contract. +7. Run the local linter and use `sandbox: true`. +8. Show the final diff and obtain confirmation before review submission. -- **One change per resubmission.** If you change wording *and* category *and* - variables, you cannot tell which change unblocked the template. -- **Use a new version suffix.** `_v1` → `_v2`. Submitting under the same - `(name, language)` will fail with name-conflict if the prior template is - still in any state other than `DELETED`. -- **Wait for review before re-resubmitting.** Spamming submissions of the - same template body slows the queue for the WABA. -- **For Meta-paused templates** (Sent template status still `APPROVED` but - sends are failing on the webhook / activities feed), revise the content - before resubmitting under a new version — Meta paused for a reason and an - untouched resubmission lands in the same place. Confirm the failures via - `messaging-performance-analyzer` before rewriting. -- **For silent re-categorization,** resubmit the *strictest* version of the - wording even if you intend to send marketing content from it — once the - category is set, marketing-priced sends still work fine under a stricter - template. +## Common remediations -## Quick triage table +| Symptom | Appropriate response | +| --- | --- | +| Utility content recategorized | Remove promotion or deliberately use `MARKETING`; do not argue from transactional context alone. | +| Missing or unrealistic samples | Add `props.sample` for every placeholder without using customer data. | +| Invalid variable format | Replace naked placeholders with `{{0:variable}}` and align IDs. | +| Unsupported create shape | Move fields into `definition`; reject Meta `components[]` as a Sent request. | +| Button validation | Enforce 10 total and per-type limits; allow quick replies and CTA buttons to coexist. | +| `PAUSED` or `DISABLED` | Stop new WhatsApp sends with the template, preserve the provider value, and surface it for review. | +| Unknown status | Store and display it; do not silently coerce it to rejected or approved. | -| Symptom | Most likely cause | First fix | -|---|---|---| -| Approved but category flipped | Promotional language Meta detected | Strip wording, resubmit as `_v2` | -| `INVALID_FORMAT` | Component schema typo | Re-check component types and required fields | -| `TAG_CONTENT_MISMATCH` | Variables vs. samples count mismatch | Provide one sample per `{{n}}` in the right shape | -| `META_POLICY_VIOLATION` | Restricted content (alcohol, finance, etc.) | Check Meta's restricted-content policy for the WABA's vertical | -| `INVALID_LANGUAGE` | Bad locale code | Use BCP-47 with underscore | -| Sends fail with `BUSINESS_005` | Sent template still `PENDING` or `REJECTED` | Wait for Sent approval, or fix the rejection and resubmit as `_v2` | -| Sends fail post-approval (per-message failures, Sent status unchanged) | Meta-paused template (PAUSED is Meta-side, not reflected in Sent) | Diagnose via `messaging-performance-analyzer`; revise wording and resubmit `_v2` | +Do not claim provider approval timing as a guarantee, and do not repeatedly resubmit unchanged content. diff --git a/claude-plugins/sent/skills/waba-template-author/references/waba-template-categories.md b/claude-plugins/sent/skills/waba-template-author/references/waba-template-categories.md index 0c6085a..0338ba0 100644 --- a/claude-plugins/sent/skills/waba-template-author/references/waba-template-categories.md +++ b/claude-plugins/sent/skills/waba-template-author/references/waba-template-categories.md @@ -1,218 +1,39 @@ -# WABA Template Categories — Reference +# WhatsApp template categories -Supporting reference for `waba-template-author`. Excerpts from Meta's template -policy plus practical mappings to common use cases. Authoritative external -source: [WhatsApp Business Platform > Message Templates](https://developers.facebook.com/docs/whatsapp/message-templates). +Supporting policy reference for `waba-template-author`. The request contract comes from Sent; category review is ultimately performed by Meta for WhatsApp. -> The Meta policy evolves frequently — verify the current rules in the official -> docs before relying on edge cases. This reference captures the stable patterns -> and boundaries; assume any specific number or category boundary may have -> changed since this file was written. +## Decision order -## Sent's Template Categories and Statuses +1. Identify why the recipient expects the message. +2. Identify the single action the message asks them to take. +3. Remove optional promotional language and classify again. +4. If promotion remains, use `MARKETING`. +5. If the sole purpose is a verification code, use `AUTHENTICATION`. +6. Otherwise use `UTILITY` only when the message is tied to a specific transaction, account, or service event. -Sent surfaces exactly **three** categories — Meta's three, no others: +## Category guide -- `UTILITY` -- `MARKETING` -- `AUTHENTICATION` +| Category | Suitable intent | Common rejection or recategorization risk | +| --- | --- | --- | +| `UTILITY` | Order state, appointment reminder, account change, service interruption, requested support update | Discounts, upsells, product discovery, vague re-engagement, or calls to purchase | +| `MARKETING` | Offers, launches, recommendations, reminders to shop, abandoned-cart messages, mixed promotional content | Missing consent, misleading urgency, or attempting to disguise promotion as utility | +| `AUTHENTICATION` | OTP, login verification, account recovery code | Free-form content, promotional text, unrelated links/media, or multiple actions | -Sent's template `status` set is exactly: +Transactional context does not make promotional content utility. “Your receipt is ready” is utility; “Your receipt is ready—buy again for 20% off” is marketing. -- `APPROVED` -- `PENDING` -- `REJECTED` +## Authentication restrictions -**There is no `PAUSED` status in Sent.** Meta may internally pause a template -after delivery starts (quality rating drop, opt-out spike), but Sent does **not** -surface PAUSED — the Sent template status remains whatever it was (typically -`APPROVED`), while sends against the paused template start failing at send time. -Detect that via failed activities / message webhooks, not via the template -status. See `references/template-rejection-playbook.md` for the workflow. +- Set top-level `category` to `AUTHENTICATION`. +- Include `definition.authenticationConfig`. +- `codeExpirationMinutes`, when present, is an integer from 1 through 90. +- Keep the body to the verification purpose and one code variable. +- Use the supported `COPY_CODE` action for the code. +- Do not add promotion, unrelated URLs, media, or extra calls to action. -## The Three Categories +## Variables and samples -### Utility -Triggered by a user action or a recurring event the user already opted into. The -message is *about that event*. Lowest-priced category. +Provider reviewers see samples. Every placeholder such as `{{0:variable}}` must have the same numeric ID in the channel's variables array and a realistic `props.sample`. Do not use real customer data or secrets in samples. -**Eligible use cases (non-exhaustive):** -- Order placed / shipped / delivered -- Appointment confirmation, reminder, change -- Booking confirmation -- Account balance, statement, invoice -- Password change confirmation (note: *not* the OTP itself — that's authentication) -- Service status: outage, restoration, maintenance window -- Payment received / failed / overdue notice -- Recurring statement or subscription renewal notice -- Form / application status update +## Revision discipline -**Disqualifiers — if any of these are true, Meta will reclassify to marketing:** -- The message cross-sells, upsells, or invites the user back -- The CTA button leads anywhere other than the entity the message is about - (an order-confirmation button must link to that order, not the homepage) -- Promotional language ("hurry", "limited", "exclusive", "sale", "deal") -- Discount codes, percentages off, or "use code X" content -- Promotional imagery in a media header - -### Marketing -Business-initiated outreach with the intent to drive a new action — purchase, -visit, signup, re-engagement. Highest-priced category. - -**Eligible use cases:** -- Promo / sale announcement -- Abandoned cart recovery -- Welcome-back / win-back -- Newsletter / content drop -- Product launch -- Event invitation (when the event is promotional, not transactional) -- Survey or feedback request (when not tied to a specific transaction) - -There is no penalty for marketing templates; the penalty is for *labeling* -them as utility. When in doubt, ship as marketing. - -### Authentication -A separate template type, not just a category. Used for one-time codes, login -verification, account-recovery codes. - -**Distinct rules:** -- Body is constrained: "{{1}} is your verification code." plus optional security - disclaimer ("For your security, do not share this code.") -- Buttons collapse to a single button: "Copy code" or "Autofill" (one-tap, mobile only) -- Supports `code_expiration_minutes` field -- Cannot include marketing or utility content -- Lower per-message cost; some regions price authentication separately - -## Component Rules - -### Header (optional) -- Sent header `type`: `TEXT` | `IMAGE` | `VIDEO` | `DOCUMENT` (no `LOCATION` at the Sent layer) -- Text: max 60 chars, max 1 variable -- Media: provide a sample URL or media handle at submission - -### Body (required) -- Max 1024 chars -- Supports `{{1}}`, `{{2}}`, … placeholders -- Variables must be sequential (no gaps); every variable needs a sample -- No URLs in body (link via CTA buttons instead) for most categories; exceptions exist - -### Footer (optional) -- Max 60 chars -- No variables -- Common use: compliance text, opt-out instructions - -### Buttons (optional) -- *Either* quick replies *or* CTA buttons, never both -- Quick replies: up to 3, max 25 chars per label -- CTAs: up to 2, types: `URL` or `PHONE_NUMBER` - - URL CTAs may include one trailing variable: `https://example.com/orders/{{1}}` - - URL CTAs require an example URL for submission - -## Sent CreateTemplateRequest Shape - -Sent's `POST /v3/templates` accepts the following structured shape (from -the live Sent API documentation at https://docs.sent.dm): - -``` -{ - name, - category, // "UTILITY" | "MARKETING" | "AUTHENTICATION" - language, // BCP-47 with underscore, e.g. "en_US" - body: { - content, // body text with {{1}}, {{2}} placeholders - variables?: [ - { name, type: "text" | "number" | "date", example? } - ] - }, - header?: { - type: "TEXT" | "IMAGE" | "VIDEO" | "DOCUMENT", - content - }, - footer?: { content }, - buttons?: [ - { - type: "QUICK_REPLY" | "URL" | "PHONE_NUMBER", - text, - url?, // required when type = URL - phone_number? // required when type = PHONE_NUMBER - } - ], - channels?, // subset of ["sms", "whatsapp", "rcs"] - sandbox? // bool -} -``` - -Worked Sent request: - -```json -{ - "name": "order_confirmation_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} has been confirmed and will ship soon. Track it any time below.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] - }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to unsubscribe." }, - "buttons": [ - { - "type": "URL", - "text": "Track order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"], - "sandbox": false -} -``` - -Notes: - -- The Sent body is `body.content`, not `text` (Meta-side Cloud API uses `text` - on the BODY component). Variable samples live in `body.variables[].example` - rather than a separate Cloud API `example.body_text` block. -- `header.type` is restricted to **TEXT, IMAGE, VIDEO, DOCUMENT** at the Sent - layer. `LOCATION` is not a Sent header type. -- `buttons[].type` is restricted to **QUICK_REPLY, URL, PHONE_NUMBER** at the - Sent layer. Authentication-only Cloud API button types (`OTP`, `COPY_CODE`, - `AUTOFILL`) are Meta-side and are not Sent button types. -- `channels` accepts a subset of `["sms", "whatsapp", "rcs"]`. WhatsApp templates - always include `"whatsapp"`. -- `language` follows BCP-47 with the underscore separator (`en_US`, `pt_BR`, - `es_MX`). Not `en`, not `en-US`. - -## Common Rejection Reasons (from Meta's API) - -| Code / phrase | Meaning | Fix | -|---|---|---| -| `INVALID_FORMAT` | Component schema broken | Re-validate against the component rules above | -| `TAG_CONTENT_MISMATCH` | Variable count differs from samples | Provide a sample for every `{{n}}` | -| `META_POLICY_VIOLATION` | Content violates content policy | Remove promotional content, slurs, or restricted-category content | -| `INVALID_LANGUAGE` | Language code unsupported | Use BCP-47 (`en_US`, not `en`) and one from Meta's supported list | -| Silent reclassification | Approved but category changed | Body/buttons/header read promotional even if the use case is utility | - -## Worked Examples — Decision Tree in Action - -**"Your order #1029 has shipped. Track it here."** -- Triggered by purchase? Yes. About the purchase? Yes. CTA is order-specific? Yes. -- → **UTILITY**, single URL CTA. - -**"Your order #1029 has shipped. Check out our new arrivals!"** -- Triggered by purchase? Yes. About the purchase? No — second sentence is a promo. -- → **MARKETING** (or split into two templates). - -**"Your account password was changed."** -- Triggered by user action? Yes (they changed it). About that action? Yes. -- → **UTILITY**. (The OTP that authorized the change is **AUTHENTICATION**, separate template.) - -**"We miss you — here's 20% off your next order."** -- Business-initiated, re-engagement, discount. -- → **MARKETING**, unambiguously. - -**"Your code is 729451. For your security, do not share this code."** -- One-time code for login. -- → **AUTHENTICATION** template type. Not a utility template with a code in the body. +When Meta returns `REJECTED` or `CATEGORY_UPDATED`, retain the raw reason, change only what it supports, lint again, and resubmit deliberately. Do not repeatedly submit unchanged content. diff --git a/claude-plugins/sent/skills/waba-template-author/references/waba-template-examples.md b/claude-plugins/sent/skills/waba-template-author/references/waba-template-examples.md index d72c939..9adc811 100644 --- a/claude-plugins/sent/skills/waba-template-author/references/waba-template-examples.md +++ b/claude-plugins/sent/skills/waba-template-author/references/waba-template-examples.md @@ -1,244 +1,93 @@ -# WABA Template Examples — Reference +# Sent template examples -Worked, copy-pasteable WhatsApp template payloads grouped by Meta category. -Companion to `references/waba-template-categories.md`. Every example is a complete -**Sent `POST /v3/templates`** request body following the CreateTemplateRequest -shape. Variable placeholders use `{{1}}`, `{{2}}` in the body content, and -each variable has a named entry with `type` and `example` in `body.variables`. +All examples in the first section are bodies for `POST /v3/templates` and are expected to pass `scripts/lint_waba_template.py`. Synthetic values are used throughout. -> Synthetic values only — no real WABA IDs, brand names, or customer data. - -## Utility - -### 1. Order confirmation - -Triggered by checkout. About that order. Single URL CTA bound to the order. +## Utility with a WhatsApp override + ```json { - "name": "order_confirmation_v1", "category": "UTILITY", "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} is confirmed. We will let you know when it ships.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, your appointment is on {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + }, + "whatsapp": { + "type": "body", + "template": "Hello {{0:variable}}. Your appointment is confirmed for {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + } + }, + "footer": {"type": "text", "template": "Acme Scheduling", "variables": []}, + "buttons": [ + {"id": 1, "type": "QUICK_REPLY", "props": {"text": "Confirm", "quickReplyType": "custom"}}, + {"id": 2, "type": "URL", "props": {"text": "Manage booking", "urlType": "static", "url": "https://example.com/bookings"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": null }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "View order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why this is approved as utility: every component refers to the order, the CTA -deep-links to that order, no promotional language, no cross-sell. - -### 2. Shipping update - -Triggered by carrier scan. Variables numbered 1..3 sequentially. - -```json -{ - "name": "shipping_update_v2", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "package_id", "type": "text", "example": "A1029" }, - { "name": "delivery_eta", "type": "text", "example": "6 PM today" } - ] - }, - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: status-only language, sample values are neutral, CTA points to the -tracking page for that specific package. - -### 3. Appointment reminder - -Triggered by user-booked appointment. Quick-reply buttons stay within the same -appointment — confirm or reschedule, no upsell. - -```json -{ - "name": "appointment_reminder_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, this is a reminder of your appointment with {{2}} on {{3}} at {{4}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "provider_name", "type": "text", "example": "Dr. Patel" }, - { "name": "appointment_date", "type": "date", "example": "2026-05-18" }, - { "name": "appointment_time", "type": "text", "example": "10:30 AM" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Confirm" }, - { "type": "QUICK_REPLY", "text": "Reschedule" } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: every button action is tied to the appointment itself; no -"Book another visit" or other cross-sell. - -## Marketing - -### 1. Promo announcement - -Business-initiated, discount code in the body. Unambiguously marketing. - -```json -{ - "name": "spring_promo_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our spring sale is on — use code {{2}} for 20% off through Sunday.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "SPRING20" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale" - } - ], - "channels": ["whatsapp"] -} -``` - -### 2. Re-engagement (win-back) - -```json -{ - "name": "winback_30d_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, we miss you! Here is 15% off your next order with code {{2}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "COMEBACK15" } - ] - }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Shop deals" }, - { "type": "QUICK_REPLY", "text": "Browse new" } - ], - "channels": ["whatsapp"] -} -``` - -### 3. Seasonal announcement (image header) - -```json -{ - "name": "holiday_drop_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our holiday collection just dropped. Take a look before it sells out.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" } - ] - }, - "header": { - "type": "IMAGE", - "content": "https://example.com/assets/holiday-2026.jpg" - }, - "buttons": [ - { - "type": "URL", - "text": "See collection", - "url": "https://example.com/holiday" - } - ], - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` ## Authentication -Authentication templates are submitted under Sent's `AUTHENTICATION` category. -The Cloud API-specific `OTP` button types (`COPY_CODE`, `AUTOFILL`) are -Meta-side concepts and are not part of Sent's `buttons[].type` enum -(`QUICK_REPLY | URL | PHONE_NUMBER`). At the Sent layer, model the code as a -single body variable. - -### 1. One-time code - + ```json { - "name": "login_otp_v1", "category": "AUTHENTICATION", "language": "en_US", - "body": { - "content": "{{1}} is your verification code. For your security, do not share this code.", - "variables": [ - { "name": "code", "type": "text", "example": "729451" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Your verification code is {{0:variable}}.", + "variables": [ + {"id": 0, "name": "verificationCode", "type": "variable", "props": {"sample": "482193"}} + ] + } + }, + "footer": null, + "buttons": [ + {"id": 1, "type": "COPY_CODE", "props": {"text": "Copy code", "offerCode": "482193"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": { + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 + } }, - "footer": { "content": "This code expires in 10 minutes." }, - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` -### 2. Password reset confirmation (utility-shaped, plain confirmation) +## Meta Cloud API example — not a Sent request -If you only want a confirmation (no code), submit it as `UTILITY`: +The following abbreviated shape is deliberately separate. It must not pass the Sent linter or be posted to `POST /v3/templates`; convert its `components[]` into Sent's `definition` structure first. ```json { - "name": "password_changed_v1", - "category": "UTILITY", + "name": "order_update", "language": "en_US", - "body": { - "content": "Your account password was changed on {{1}}. If this was not you, contact support.", - "variables": [ - { "name": "changed_at", "type": "text", "example": "Mon May 18, 10:32 AM" } - ] - }, - "channels": ["whatsapp"] + "category": "UTILITY", + "components": [ + {"type": "BODY", "text": "Your order {{1}} has shipped."} + ] } ``` - -## Things to copy - -- Variable numbering in `body.content` is always `{{1}}, {{2}}, {{3}}` in order - of first appearance. -- `body.variables` is an array with one entry per `{{n}}`, in the same order. - Each entry has a `name`, a `type` (`text` | `number` | `date`), and an - `example` that matches the type. -- URL buttons take a `url`. Variables in URLs (`https://example.com/o/{{1}}`) - reuse the body's variable ordering. -- Phone-number buttons take a `phone_number` instead of `url`. -- Footers cannot contain variables — keep compliance-only language there. -- `channels` selects the channels the template should be available on. For a - WhatsApp-only template, use `["whatsapp"]`. diff --git a/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json b/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json index 3d3c1d3..16cb470 100644 --- a/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json +++ b/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json @@ -1,23 +1,11 @@ { - "name": "shipping_update_bad_v1", + "name": "meta_cloud_shape", "language": "en_US", "category": "UTILITY", "components": [ { "type": "BODY", - "text": "Hi {{2}}, your package #{{1}} is on the way. Buy now — limited time!", - "example": { "body_text": [["A1029", "Jordan"]] } - }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale", - "example": ["https://example.com/sale"] - } - ] + "text": "This is Meta's components[] format, not the Sent v3 request body." } ] } diff --git a/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json b/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json index 445c118..2b33ccc 100644 --- a/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json +++ b/claude-plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json @@ -1,27 +1,80 @@ { - "name": "shipping_update_v2", - "language": "en_US", "category": "UTILITY", - "components": [ - { - "type": "BODY", - "text": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "example": { "body_text": [["Jordan", "A1029", "6 PM today"]] } + "language": "en_US", + "definition": { + "header": { + "type": "text", + "template": "Order update", + "variables": [] + }, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} is ready.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": { + "type": "body", + "template": "Order {{1:variable}} is ready.", + "variables": [ + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + } }, - { - "type": "FOOTER", - "text": "Reply STOP to opt out." + "footer": { + "type": "text", + "template": "Acme Support", + "variables": [] }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}", - "example": ["https://example.com/track/A1029"] - } - ] - } - ] + "buttons": [ + { + "id": 1, + "type": "QUICK_REPLY", + "props": {"text": "Got it", "quickReplyType": "custom"} + }, + { + "id": 2, + "type": "URL", + "props": {"text": "Track order", "urlType": "static", "url": "https://example.com/track"} + }, + { + "id": 3, + "type": "VOICE_CALL", + "props": {"text": "Voice support", "countryCode": "US", "phoneNumber": "+12025550100"} + }, + { + "id": 4, + "type": "PHONE_NUMBER", + "props": {"text": "Call support", "countryCode": "US", "phoneNumber": "+12025550101"} + }, + { + "id": 5, + "type": "COPY_CODE", + "props": {"text": "Copy reference", "offerCode": "A-1042"} + } + ], + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } diff --git a/claude-plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py b/claude-plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py index 131dbd0..ccb5a1e 100644 --- a/claude-plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py +++ b/claude-plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py @@ -1,32 +1,9 @@ #!/usr/bin/env python3 -"""Lint a Sent WhatsApp template payload against Meta's category + structural rules. - -Usage: - python lint_waba_template.py - -Exits 0 with "OK" if the template passes all checks. -Exits non-zero and prints each issue with the offending field on failure. - -Checks performed: - * Required top-level keys: name, language, category, components. - * category is one of UTILITY, MARKETING, AUTHENTICATION (Sent's three). - * language matches BCP-47 (lowercase locale, optional uppercase region: en, en_US). - * Exactly one BODY component is present with non-empty text - (AUTHENTICATION templates are exempt — they use managed body content). - * Placeholders {{1}}..{{N}} in body text are numbered 1..N sequentially with no gaps. - * Sample value count under example.body_text matches the placeholder count. - * For UTILITY templates: - - Promotional phrases ("buy now", "limited time", "special offer", - "discount", "sale", "free shipping") trigger warnings. - - "click here to purchase" triggers a hard failure (clear cross-sell). - * HEADER component (if present): `format` must be one of TEXT, IMAGE, VIDEO, - DOCUMENT (Sent's allowed CreateTemplateRequest header types). - * BUTTONS component (if present) is either <=3 QUICK_REPLY OR <=2 CTA - (URL/PHONE_NUMBER/OTP) — never mixed. Per-button checks: - - type must be one of QUICK_REPLY, URL, PHONE_NUMBER (Sent's button - enum) or OTP (Cloud API authentication-only, warned). - - URL buttons require a non-empty `url` field. - - PHONE_NUMBER buttons require a non-empty `phone_number` field. +"""Lint the JSON body sent to ``POST /v3/templates``. + +This validator intentionally accepts the Sent v3 request contract, not Meta's +Cloud API ``components[]`` format. Meta payloads are useful reference material, +but must be labelled and converted before they are sent to Sent. """ from __future__ import annotations @@ -35,13 +12,33 @@ import json import re import sys +from collections import Counter from pathlib import Path from typing import Any -VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} -LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") -PLACEHOLDER_RE = re.compile(r"\{\{(\d+)\}\}") +TOP_LEVEL_FIELDS = { + "category", + "language", + "definition", + "creation_source", + "submit_for_review", + "sandbox", +} +CREATE_UNSUPPORTED_FIELDS = {"name", "channels", "body", "header", "buttons", "components"} +VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} +VALID_BODY_CHANNELS = {"multiChannel", "sms", "whatsapp", "rcs"} +VALID_BUTTON_TYPES = {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"} +BUTTON_LIMITS = { + "QUICK_REPLY": 10, + "URL": 2, + "VOICE_CALL": 1, + "PHONE_NUMBER": 1, + "COPY_CODE": 1, +} +LANGUAGE_RE = re.compile(r"^[a-z]{2}(?:_[A-Z]{2})?$") +PLACEHOLDER_RE = re.compile(r"\{\{(\d+):(variable|link|media)\}\}") +ANY_PLACEHOLDER_RE = re.compile(r"\{\{[^{}]+\}\}") PROMO_WARN_PHRASES = ( "buy now", "limited time", @@ -52,13 +49,6 @@ ) PROMO_FAIL_PHRASES = ("click here to purchase",) -CTA_BUTTON_TYPES = {"URL", "PHONE_NUMBER", "OTP"} -QUICK_REPLY_TYPE = "QUICK_REPLY" - -# Sent CreateTemplateRequest enums (snapshot Template Models section). -SENT_HEADER_TYPES = {"TEXT", "IMAGE", "VIDEO", "DOCUMENT"} -SENT_BUTTON_TYPES = {"QUICK_REPLY", "URL", "PHONE_NUMBER"} - class LintResult: def __init__(self) -> None: @@ -76,243 +66,218 @@ def failed(self) -> bool: return bool(self.errors) -def _components_by_type(components: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: - grouped: dict[str, list[dict[str, Any]]] = {} - for c in components: - t = c.get("type") - if not isinstance(t, str): - continue - grouped.setdefault(t.upper(), []).append(c) - return grouped - - -def _check_top_level(payload: dict[str, Any], result: LintResult) -> None: - for key in ("name", "language", "category", "components"): - if key not in payload: - result.error(key, f"missing required top-level key '{key}'") - - category = payload.get("category") - if isinstance(category, str) and category not in VALID_CATEGORIES: - result.error( - "category", - f"category '{category}' must be one of {sorted(VALID_CATEGORIES)}", - ) - - language = payload.get("language") - if isinstance(language, str) and not LANGUAGE_RE.match(language): - result.error( - "language", - f"language '{language}' must match BCP-47 form (e.g. 'en' or 'en_US')", - ) - - components = payload.get("components") - if components is not None and not isinstance(components, list): - result.error("components", "components must be a list") - - -def _check_body(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - grouped = _components_by_type(components) - category = payload.get("category") - body_list = grouped.get("BODY", []) - - if category == "AUTHENTICATION": - # Authentication body uses managed content (e.g. add_security_recommendation); - # freeform text is not required and placeholder checks do not apply. - return - - if not body_list: - result.error("components", "exactly one BODY component is required") +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _reject_unknown_fields( + value: dict[str, Any], allowed: set[str], field: str, result: LintResult +) -> None: + for key in sorted(set(value) - allowed): + result.error(f"{field}.{key}" if field else key, "field is not part of the Sent v3 request contract") + + +def _check_variable( + variable: Any, + field: str, + expected_kind: str | None, + result: LintResult, +) -> int | None: + if not isinstance(variable, dict): + result.error(field, "variable must be an object") + return None + for key in ("id", "name", "type", "props"): + if key not in variable: + result.error(f"{field}.{key}", "missing required variable field") + variable_id = variable.get("id") + if not isinstance(variable_id, int) or variable_id < 0: + result.error(f"{field}.id", "must be a non-negative integer") + variable_id = None + if not _nonempty(variable.get("name")): + result.error(f"{field}.name", "must be a non-empty string") + kind = variable.get("type") + if kind not in {"variable", "link", "media"}: + result.error(f"{field}.type", "must be variable, link, or media") + elif expected_kind is not None and kind != expected_kind: + result.error(f"{field}.type", f"placeholder declares {expected_kind!r}, but variable declares {kind!r}") + props = variable.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + elif not _nonempty(props.get("sample")): + result.error(f"{field}.props.sample", "must be a non-empty review and preview sample") + return variable_id + + +def _check_content(content: Any, field: str, result: LintResult) -> None: + if not isinstance(content, dict): + result.error(field, "must be an object") return - if len(body_list) > 1: - result.error("components", "more than one BODY component is not allowed") - - body = body_list[0] - text = body.get("text") - if not isinstance(text, str) or not text.strip(): - result.error("components[BODY].text", "BODY text must be a non-empty string") + _reject_unknown_fields(content, {"type", "template", "variables"}, field, result) + template = content.get("template") + if not _nonempty(template): + result.error(f"{field}.template", "must be a non-empty string") return + if len(template) > 1024: + result.error(f"{field}.template", f"body exceeds the 1,024-character limit ({len(template)})") - placeholders = [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text)] - if placeholders: - unique_sorted = sorted(set(placeholders)) - expected = list(range(1, len(unique_sorted) + 1)) - if unique_sorted != expected: - result.error( - "components[BODY].text", - f"placeholders must be sequential starting at 1, got {unique_sorted}", - ) - first_occurrence = [] - seen: set[int] = set() - for n in placeholders: - if n not in seen: - first_occurrence.append(n) - seen.add(n) - if first_occurrence != sorted(first_occurrence): - result.error( - "components[BODY].text", - f"placeholders must first appear in numeric order, got {first_occurrence}", - ) - - example = body.get("example", {}) - body_text = example.get("body_text") if isinstance(example, dict) else None - placeholder_count = len(set(placeholders)) - - if placeholder_count == 0: - if body_text: - result.warn( - "components[BODY].example.body_text", - "body has no placeholders but example.body_text is present", - ) - return - - if not isinstance(body_text, list) or not body_text: - result.error( - "components[BODY].example.body_text", - "example.body_text must be a non-empty list of sample rows", - ) - return - first_row = body_text[0] - if not isinstance(first_row, list): - result.error( - "components[BODY].example.body_text", - "example.body_text must be array-of-arrays (one row per variable group)", - ) - return - if len(first_row) != placeholder_count: + placeholders = [(int(match.group(1)), match.group(2)) for match in PLACEHOLDER_RE.finditer(template)] + malformed = [match.group(0) for match in ANY_PLACEHOLDER_RE.finditer(template) if not PLACEHOLDER_RE.fullmatch(match.group(0))] + if malformed: result.error( - "components[BODY].example.body_text", - f"sample count {len(first_row)} does not match placeholder count {placeholder_count}", + f"{field}.template", + "use Sent placeholders such as '{{0:variable}}'; malformed: " + ", ".join(malformed), ) - -def _check_utility_promo(payload: dict[str, Any], result: LintResult) -> None: - if payload.get("category") != "UTILITY": - return - components = payload.get("components") - if not isinstance(components, list): - return - body_list = _components_by_type(components).get("BODY", []) - if not body_list: + variables = content.get("variables", []) + if variables is None: + variables = [] + if not isinstance(variables, list): + result.error(f"{field}.variables", "must be an array") return - text = body_list[0].get("text") - if not isinstance(text, str): - return - lowered = text.lower() - for phrase in PROMO_FAIL_PHRASES: - if phrase in lowered: - result.error( - "components[BODY].text", - f"UTILITY body contains banned promotional phrase '{phrase}'", - ) - for phrase in PROMO_WARN_PHRASES: - if phrase in lowered: - result.warn( - "components[BODY].text", - f"UTILITY body contains promotional phrase '{phrase}' — Meta is likely to re-categorize as MARKETING", - ) - -def _check_header(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - headers = _components_by_type(components).get("HEADER", []) - if not headers: - return - header = headers[0] - # In the Cloud API shape used by these fixtures, HEADER type discriminator - # is `format`; in Sent's shape it would be `type`. Accept either. - fmt = header.get("format") - if fmt is None: - fmt = header.get("type") - if not isinstance(fmt, str): - result.error( - "components[HEADER].format", - "HEADER component requires a 'format' (TEXT/IMAGE/VIDEO/DOCUMENT)", + expected = {variable_id: kind for variable_id, kind in placeholders} + if len(expected) != len({variable_id for variable_id, _ in placeholders}): + result.error(f"{field}.template", "one placeholder id cannot be reused with different types") + actual_ids: list[int] = [] + for index, variable in enumerate(variables): + variable_id = variable.get("id") if isinstance(variable, dict) else None + checked_id = _check_variable( + variable, + f"{field}.variables[{index}]", + expected.get(variable_id) if isinstance(variable_id, int) else None, + result, ) + if checked_id is not None: + actual_ids.append(checked_id) + duplicates = [str(key) for key, count in Counter(actual_ids).items() if count > 1] + if duplicates: + result.error(f"{field}.variables", "duplicate variable ids: " + ", ".join(duplicates)) + missing = sorted(set(expected) - set(actual_ids)) + extra = sorted(set(actual_ids) - set(expected)) + if missing: + result.error(f"{field}.variables", f"missing definitions for placeholder ids {missing}") + if extra: + result.error(f"{field}.variables", f"variables without matching placeholders: {extra}") + + +def _check_header_or_footer(value: Any, field: str, limit: int, result: LintResult) -> None: + if value is None: return - if fmt.upper() not in SENT_HEADER_TYPES: - result.error( - "components[HEADER].format", - f"header format '{fmt}' must be one of {sorted(SENT_HEADER_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - - -def _check_buttons(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): + if not isinstance(value, dict): + result.error(field, "must be an object or null") return - button_components = _components_by_type(components).get("BUTTONS", []) - if not button_components: + _reject_unknown_fields(value, {"type", "template", "variables"}, field, result) + template = value.get("template") + if not isinstance(template, str): + result.error(f"{field}.template", "must be a string") return - buttons = button_components[0].get("buttons") - if not isinstance(buttons, list) or not buttons: - result.error("components[BUTTONS].buttons", "buttons list must be non-empty") + if len(template) > limit: + result.error(f"{field}.template", f"exceeds the {limit}-character limit") + if field.endswith("footer") and (ANY_PLACEHOLDER_RE.search(template) or value.get("variables")): + result.error(field, "footer variables are not supported") + elif field.endswith("header"): + _check_content({"template": template, "variables": value.get("variables", [])}, field, result) + + +def _check_button(button: Any, index: int, result: LintResult) -> str | None: + field = f"definition.buttons[{index}]" + if not isinstance(button, dict): + result.error(field, "button must be an object") + return None + _reject_unknown_fields(button, {"id", "type", "props"}, field, result) + button_type = button.get("type") + if button_type not in VALID_BUTTON_TYPES: + result.error(f"{field}.type", f"must be one of {sorted(VALID_BUTTON_TYPES)}") + return None + props = button.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + return button_type + text = props.get("text") + if not _nonempty(text) or len(text) > 25: + result.error(f"{field}.props.text", "must be 1–25 characters") + if button_type == "QUICK_REPLY" and not _nonempty(props.get("quickReplyType")): + result.error(f"{field}.props.quickReplyType", "is required for QUICK_REPLY") + elif button_type == "URL": + if not _nonempty(props.get("urlType")): + result.error(f"{field}.props.urlType", "is required for URL") + if not _nonempty(props.get("url")): + result.error(f"{field}.props.url", "is required for URL") + elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}: + if not _nonempty(props.get("countryCode")): + result.error(f"{field}.props.countryCode", f"is required for {button_type}") + if not _nonempty(props.get("phoneNumber")): + result.error(f"{field}.props.phoneNumber", f"is required for {button_type}") + elif button_type == "COPY_CODE" and not _nonempty(props.get("offerCode")): + result.error(f"{field}.props.offerCode", "is required for COPY_CODE") + return button_type + + +def _check_definition(payload: dict[str, Any], result: LintResult) -> None: + definition = payload.get("definition") + if not isinstance(definition, dict): + result.error("definition", "required and must be an object") return - types = [] - for i, b in enumerate(buttons): - t = b.get("type") - if not isinstance(t, str): - result.error(f"components[BUTTONS].buttons[{i}].type", "button type missing") - continue - upper = t.upper() - types.append(upper) - - # Sent's button type enum is QUICK_REPLY|URL|PHONE_NUMBER. Cloud-API OTP - # buttons (auth-only) are surfaced as a warning rather than a hard fail - # because Cloud-API-shaped fixtures use them; they are not part of the - # Sent CreateTemplateRequest button enum. - if upper not in SENT_BUTTON_TYPES and upper != "OTP": - result.error( - f"components[BUTTONS].buttons[{i}].type", - f"button type '{t}' must be one of {sorted(SENT_BUTTON_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - continue - if upper == "OTP": - result.warn( - f"components[BUTTONS].buttons[{i}].type", - "'OTP' is a Cloud-API auth-only button type; not part of Sent's " - "button enum (QUICK_REPLY/URL/PHONE_NUMBER)", + _reject_unknown_fields( + definition, + {"header", "body", "footer", "buttons", "definitionVersion", "authenticationConfig"}, + "definition", + result, + ) + body = definition.get("body") + if not isinstance(body, dict): + result.error("definition.body", "required and must be an object") + else: + _reject_unknown_fields(body, VALID_BODY_CHANNELS, "definition.body", result) + if body.get("multiChannel") is None: + result.error("definition.body.multiChannel", "is required as the channel-neutral body") + for channel, content in body.items(): + if channel in VALID_BODY_CHANNELS and content is not None: + _check_content(content, f"definition.body.{channel}", result) + + _check_header_or_footer(definition.get("header"), "definition.header", 60, result) + _check_header_or_footer(definition.get("footer"), "definition.footer", 60, result) + + buttons = definition.get("buttons", []) + if buttons is None: + buttons = [] + if not isinstance(buttons, list): + result.error("definition.buttons", "must be an array or null") + buttons = [] + elif len(buttons) > 10: + result.error("definition.buttons", f"at most 10 buttons are allowed, got {len(buttons)}") + counts = Counter(filter(None, (_check_button(button, index, result) for index, button in enumerate(buttons)))) + for button_type, limit in BUTTON_LIMITS.items(): + if counts[button_type] > limit: + result.error("definition.buttons", f"{button_type} allows at most {limit}, got {counts[button_type]}") + + authentication = definition.get("authenticationConfig") + category = payload.get("category") + if authentication is not None: + if category != "AUTHENTICATION": + result.error("definition.authenticationConfig", "is only valid for AUTHENTICATION templates") + if not isinstance(authentication, dict): + result.error("definition.authenticationConfig", "must be an object or null") + else: + _reject_unknown_fields( + authentication, + {"addSecurityRecommendation", "codeExpirationMinutes"}, + "definition.authenticationConfig", + result, ) - - if upper == "URL": - url = b.get("url") - if not isinstance(url, str) or not url.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].url", - "URL buttons require a non-empty 'url' field", - ) - if upper == "PHONE_NUMBER": - phone = b.get("phone_number") - if not isinstance(phone, str) or not phone.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].phone_number", - "PHONE_NUMBER buttons require a non-empty 'phone_number' field", - ) - - quick = sum(1 for t in types if t == QUICK_REPLY_TYPE) - cta = sum(1 for t in types if t in CTA_BUTTON_TYPES) - if quick and cta: - result.error( - "components[BUTTONS].buttons", - "cannot mix QUICK_REPLY and CTA buttons in the same template", - ) - return - if quick > 3: - result.error( - "components[BUTTONS].buttons", - f"at most 3 QUICK_REPLY buttons allowed, got {quick}", - ) - if cta > 2: - result.error( - "components[BUTTONS].buttons", - f"at most 2 CTA buttons allowed, got {cta}", - ) + recommendation = authentication.get("addSecurityRecommendation") + if recommendation is not None and not isinstance(recommendation, bool): + result.error("definition.authenticationConfig.addSecurityRecommendation", "must be boolean") + expiration = authentication.get("codeExpirationMinutes") + if expiration is not None and (not isinstance(expiration, int) or not 1 <= expiration <= 90): + result.error("definition.authenticationConfig.codeExpirationMinutes", "must be an integer from 1 to 90") + if category == "AUTHENTICATION": + if authentication is None: + result.error("definition.authenticationConfig", "is required for AUTHENTICATION templates") + if any(button_type != "COPY_CODE" for button_type in counts): + result.error("definition.buttons", "AUTHENTICATION templates may only use COPY_CODE buttons") + if counts["COPY_CODE"] != 1: + result.error("definition.buttons", "AUTHENTICATION templates require exactly one COPY_CODE button") def lint_template(payload: Any) -> LintResult: @@ -320,52 +285,63 @@ def lint_template(payload: Any) -> LintResult: if not isinstance(payload, dict): result.error("", "template payload must be a JSON object") return result - _check_top_level(payload, result) - _check_body(payload, result) - _check_utility_promo(payload, result) - _check_header(payload, result) - _check_buttons(payload, result) + if "components" in payload: + result.error( + "components", + "Meta Cloud API components[] is not a Sent payload; convert it to definition before POST /v3/templates", + ) + for field in sorted(CREATE_UNSUPPORTED_FIELDS & set(payload)): + result.error(field, "unsupported top-level create field") + _reject_unknown_fields(payload, TOP_LEVEL_FIELDS, "", result) + category = payload.get("category") + if category is not None and category not in VALID_CATEGORIES: + result.error("category", f"must be one of {sorted(VALID_CATEGORIES)} or null") + language = payload.get("language") + if language is not None and (not isinstance(language, str) or not LANGUAGE_RE.fullmatch(language)): + result.error("language", "must look like en or en_US") + for field in ("submit_for_review", "sandbox"): + if field in payload and not isinstance(payload[field], bool): + result.error(field, "must be boolean") + _check_definition(payload, result) + + if category == "UTILITY": + body = payload.get("definition", {}).get("body", {}).get("multiChannel", {}) + text = body.get("template", "") if isinstance(body, dict) else "" + lowered = text.lower() + for phrase in PROMO_FAIL_PHRASES: + if phrase in lowered: + result.error("definition.body.multiChannel.template", f"UTILITY body contains promotional phrase {phrase!r}") + for phrase in PROMO_WARN_PHRASES: + if phrase in lowered: + result.warn("definition.body.multiChannel.template", f"Meta may reclassify promotional phrase {phrase!r} as MARKETING") return result def _format(prefix: str, entries: list[tuple[str, str]]) -> str: - return "\n".join(f"{prefix} {field}: {msg}" for field, msg in entries) + return "\n".join(f"{prefix} {field}: {message}" for field, message in entries) -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser( - prog="lint_waba_template.py", - description="Lint a Sent WhatsApp template payload against Meta's structural and category rules.", - ) - parser.add_argument( - "path", - type=Path, - help="Path to a JSON file containing a WhatsApp template payload.", - ) +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path, help="Sent POST /v3/templates request JSON") args = parser.parse_args(argv) - try: - raw = args.path.read_text(encoding="utf-8") + payload = json.loads(args.path.read_text(encoding="utf-8")) except OSError as exc: print(f"could not read {args.path}: {exc}", file=sys.stderr) return 2 - try: - payload = json.loads(raw) except json.JSONDecodeError as exc: print(f"invalid JSON in {args.path}: {exc}", file=sys.stderr) return 2 - result = lint_template(payload) - if result.warnings: print(_format("WARN", result.warnings)) if result.errors: print(_format("FAIL", result.errors)) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + raise SystemExit(main()) diff --git a/evals/rcs-agent-onboarding.yaml b/evals/rcs-agent-onboarding.yaml index 93471cd..f592816 100644 --- a/evals/rcs-agent-onboarding.yaml +++ b/evals/rcs-agent-onboarding.yaml @@ -6,12 +6,15 @@ cases: - query: "Walk me through onboarding a new tenant for RCS Business Messaging on Sent." expect: trigger rationale: Tenant onboarding for RCS with Sent context. Matches "onboarding a tenant for RCS" trigger. - - query: "Should I declare rich cards and carousels as agent capabilities, or keep it minimal?" + - query: "Can I launch rich cards and carousels through Sent today?" expect: trigger - rationale: Capability-declaration decision specific to RBM agent setup — covered explicitly in this skill. + rationale: Current-capability question for Sent RCS; the skill must explain that text and up to four suggestion chips are current while richer formats are roadmap. - query: "Help me set up an OAuth agent in Rails." expect: no_trigger rationale: '"Agent" here means an OAuth client/service-account abstraction in a Rails app, not an RBM agent. No RCS, no RBM, no carrier context.' - - query: "Our RCS messages are dropping — most recipients fall back to SMS. What's wrong?" + - query: "Our automatically routed messages rarely select RCS. What's wrong?" expect: ambiguous - rationale: Could be capability mismatch (rcs-agent-onboarding — agent not launched in that carrier, capability check) OR a delivery-funnel analysis (messaging-performance-analyzer). Prefer messaging-performance-analyzer first to localize the broken stage; route here if the diagnosis points to agent launch state. + rationale: Could be capability or approval mismatch, or a live delivery/routing analysis. Prefer messaging-performance-analyzer first, then route here if evidence points to launch state. + - query: "Use an explicit RCS and SMS channel array as an ordered fallback for this send." + expect: trigger + rationale: Hard routing-safety case; the skill must reject ordered-fallback semantics and explain automatic routing versus broadcast. diff --git a/evals/sender-profile-architect.yaml b/evals/sender-profile-architect.yaml index 1df6503..0d245aa 100644 --- a/evals/sender-profile-architect.yaml +++ b/evals/sender-profile-architect.yaml @@ -6,9 +6,12 @@ cases: - query: "How do I route an inbound WhatsApp webhook to the right tenant in my multi-tenant app?" expect: trigger rationale: Webhook fan-out + tenant scoping — verbatim "Use when" trigger in the description. - - query: "What's the right way to scope rate limits per Sender Profile across SMS, WhatsApp, and RCS?" + - query: "How do profile API keys and organization keys with x-profile-id change rate-limit and credential blast radius?" expect: trigger - rationale: Per-channel rate-limit accounting tied to the Sender Profile abstraction — core scope of this skill. + rationale: Direct authentication, organization-pool rate-limit, and blast-radius architecture question. + - query: "Can I use x-profile-id with a profile-specific API key?" + expect: trigger + rationale: Hard authentication negative; only organization keys may use x-profile-id and profile keys receive 403. - query: "Help me design a database schema for a CRM." expect: no_trigger rationale: Generic schema-design question with no Sent / messaging / multi-tenant-sender context. Belongs to a general data-modeling lifecycle skill, not this domain skill. diff --git a/evals/sms-10dlc-registration.yaml b/evals/sms-10dlc-registration.yaml index 659e992..e5d829b 100644 --- a/evals/sms-10dlc-registration.yaml +++ b/evals/sms-10dlc-registration.yaml @@ -1,14 +1,17 @@ skill: sms-10dlc-registration cases: - - query: "How do I register a brand and campaign with TCR?" + - query: "How do I create a dedicated brand on a Sender Profile and then register its campaign?" expect: trigger - rationale: Direct TCR brand+campaign registration ask — the primary path this skill walks. + rationale: Direct current-profile resource model for TCR brand and campaign registration. - query: "Our 10DLC campaign keeps getting filtered by T-Mobile — what's wrong?" expect: trigger rationale: Carrier filtering + 10DLC keywords from the description; this skill covers vetting-score and rejection-debugging. - query: "What use-case category should I pick for shipping notifications on TCR?" expect: trigger rationale: Campaign use-case classification — covered explicitly by this skill. + - query: "Create my campaign through a standalone v3 brand endpoint." + expect: trigger + rationale: Hard endpoint negative; the skill must reject the retired standalone-brand model and use profile campaign paths. - query: "Help me with European SMS sender ID registration." expect: no_trigger rationale: TCR / 10DLC is US-only. EU sender-ID registration is a different regulatory regime (e.g. UK MEF, France's "Numérotation") and out of scope. diff --git a/evals/template-builder-ui.yaml b/evals/template-builder-ui.yaml index 3597c52..f939f7b 100644 --- a/evals/template-builder-ui.yaml +++ b/evals/template-builder-ui.yaml @@ -6,9 +6,12 @@ cases: - query: "Build me a live WhatsApp-style preview component with header / body / footer / buttons." expect: trigger rationale: Component editor + WhatsApp preview — explicit "Use when" triggers in the description. - - query: "How do I port Meta's template-submission rules into client-side form validation?" + - query: "How do I serialize our editor into Sent's definition.body.multiChannel contract?" expect: trigger - rationale: Client-side validation of Meta submission rules — explicitly called out in the description. + rationale: Client-side serialization and validation of the current Sent request contract is core scope. + - query: "Should our RCS editor expose rich cards and carousels as current Sent features?" + expect: trigger + rationale: Hard capability negative; current builder guidance is text plus up to four suggestion chips. - query: "Help me build a Handlebars template for a transactional email." expect: no_trigger rationale: Handlebars / email templating is generic web templating, not WhatsApp template authoring UI. "Template" keyword overlap only. diff --git a/evals/waba-embedded-signup.yaml b/evals/waba-embedded-signup.yaml index 507fefd..6284e37 100644 --- a/evals/waba-embedded-signup.yaml +++ b/evals/waba-embedded-signup.yaml @@ -3,15 +3,15 @@ cases: - query: "Walk me through connecting a customer's WhatsApp via Embedded Signup." expect: trigger rationale: Direct embedded-signup onboarding ask — canonical scope of this skill. - - query: "I'm getting the WA_EMBEDDED_SIGNUP postMessage but the code-for-token exchange is failing." + - query: "Should this child profile inherit our organization WABA or receive dedicated waba_id credentials?" expect: trigger - rationale: Stuck-flow debugging at the exact event boundary this skill covers. Mentions the sessionInfo event name verbatim. - - query: "How do I set up Facebook Login for Business as a Tech Provider on Sent?" + rationale: Direct decision between the two supported child-profile WABA paths. + - query: "Is there a public Sent endpoint that starts organization Embedded Signup?" expect: trigger - rationale: FBL + Tech Provider role — both verbatim triggers in the description. + rationale: Hard API-boundary case; organization Embedded Signup starts in the dashboard, not a public endpoint. - query: "Help me implement Sign in with Google for my Next.js app." expect: no_trigger rationale: Generic OAuth / social sign-in, not Meta's Embedded Signup. No WhatsApp / WABA / Meta context. - - query: "After Embedded Signup completes, how do I subscribe to the right webhooks and start sending?" + - query: "How do I complete the profile with webHookUrl and safely process its event callback?" expect: trigger - rationale: Post-signup webhook subscription is explicitly in scope ("post-signup webhook subscription"). Not ambiguous — same skill owns the full end-to-end. + rationale: Profile completion and its event-based callback are part of the end-to-end WABA onboarding flow. diff --git a/evals/waba-template-author.yaml b/evals/waba-template-author.yaml index 579fb1e..079a5af 100644 --- a/evals/waba-template-author.yaml +++ b/evals/waba-template-author.yaml @@ -9,6 +9,9 @@ cases: - query: "Draft a WhatsApp template for a one-time password (OTP) message." expect: trigger rationale: OTP / authentication template authoring — explicitly listed use case in the description. + - query: "Convert this Meta components array into a valid Sent POST /v3/templates definition payload." + expect: trigger + rationale: Hard contract-conversion case; Cloud API components must not be mistaken for the Sent create request. - query: "Help me draft a Jinja2 email template for our welcome series." expect: no_trigger rationale: Jinja2 / email templating is generic templating, not WABA template authoring. "Template" keyword overlap only. diff --git a/packages/sent/skills/messaging-performance-analyzer/SKILL.md b/packages/sent/skills/messaging-performance-analyzer/SKILL.md index 8421fef..c7b0d4d 100644 --- a/packages/sent/skills/messaging-performance-analyzer/SKILL.md +++ b/packages/sent/skills/messaging-performance-analyzer/SKILL.md @@ -44,7 +44,7 @@ Restate the user’s exact question as a measurable comparison. “WhatsApp is b Capture these dimensions before calculating anything: profile or sender identity, template ID/name, channel, country, send window, recipient segment, and whether fallback or multi-channel broadcast was requested. -**Example.** If a user says “RCS fallback stopped working,” define the cohort as messages sent with `channel: ["rcs", "sms"]` during the affected window, then compare RCS statuses, SMS fallback statuses, and duplicate recipient/channel pairs separately. +**Example.** If a user says “RCS fallback stopped working,” define the cohort as sends that omitted `channel` or used `channel: ["sent"]`, then compare the selected `payload.channel` and message activities. Analyze any explicit multi-channel arrays separately as broadcasts. ### 2. Build cohorts from Sent message IDs @@ -79,7 +79,7 @@ SMS, WhatsApp, and RCS fail differently. Do not average them together unless the |---|---|---| | SMS | Country, sender/profile, 10DLC campaign, opt-out, carrier family | Compliance status, brand/campaign readiness, opt-out logs, throughput patterns. | | WhatsApp | Template, language, category, recipient country, quality/tier symptoms | Template status, read receipts, conversation window, Meta-side errors if present. | -| RCS | Agent readiness, fallback behavior, capability gaps, rich content rendering | Sent RCS setup status, fallback SMS results, capability/error details if present. | +| RCS | Agent readiness, automatic routing, pinned-channel failures, text/suggestion-chip rendering | Sent RCS setup status, selected route, and exact activity/error details. | ### 6. Quantify impact before recommending fixes @@ -101,7 +101,7 @@ Do not label a campaign “carrier filtered” from a small sample without compa Do not treat `READ` as a universal stage. Sent documents read receipts for WhatsApp and RCS; SMS generally does not support read receipts. -Do not collapse RCS fallback into SMS delivery. For `channel: ["rcs", "sms"]`, count RCS attempts and SMS attempts separately, then report recipient-level success if the user asks for it. +Do not mistake broadcast for fallback. Omitted `channel` or `["sent"]` enables automatic routing; one explicit channel pins delivery; multiple explicit values create separate messages. Count every returned `message_id` once and report the selected channel. ## Verification checklist diff --git a/packages/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md b/packages/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md index 833d844..a24bd47 100644 --- a/packages/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md +++ b/packages/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md @@ -85,9 +85,11 @@ If `DELIVERED` is healthy and `READ` is low across all cohorts, the cause is alm ### Symptom: RCS funnel "looks broken" -RCS is two funnels stitched together. Capability check happens before delivery; most "RCS broken" reports are actually "the audience isn't RCS-capable." +RCS routing and delivery are separate stages. Capability selection happens before delivery; many "RCS broken" reports are audiences that were not routed to RCS. -- If the Sender Profile uses fallback (`"channel": ["rcs", "sms"]`), the SMS fallback leg has its own `message_id` and its own lifecycle. Count separately. Never roll fallback SMS into RCS delivery. +- Omitted `channel` or `["sent"]` enables automatic routing. Inspect the returned message record and `payload.channel` to see what Sent selected. +- `["rcs"]` pins the send to RCS and is the cleanest cohort for isolating an RCS launch or payload problem. +- Multiple explicit channels are broadcast and create separate message IDs. Count them separately and never call one leg fallback. - Per-carrier RCS approval is real — an agent can be launched on one carrier and not on another. Symptoms scoped to one carrier point at agent state; hand off to `rcs-agent-onboarding`. ## Cross-skill handoff matrix diff --git a/packages/sent/skills/rcs-agent-onboarding/SKILL.md b/packages/sent/skills/rcs-agent-onboarding/SKILL.md index 4570ec1..1aa3df6 100644 --- a/packages/sent/skills/rcs-agent-onboarding/SKILL.md +++ b/packages/sent/skills/rcs-agent-onboarding/SKILL.md @@ -1,155 +1,88 @@ --- name: rcs-agent-onboarding -description: Guides RCS/RBM onboarding for Sent customers by preparing agent identity, launch evidence, carrier-approval handoff, SMS fallback, and post-launch verification. Use when a user says RCS agent, RBM, rich business messaging, carrier launch, RCS approval, RCS fallback, branded messages, agent verification, capability check, or wants to send RCS through Sent. +description: Guides current Sent RCS and RBM onboarding, launch evidence, carrier approval, text and suggestion-chip templates, Sender Profile readiness, and safe routing. Use for RCS launch, fallback, pinned-channel tests, or broadcast prevention. --- - +Sent RCS setup is not self-service. Sent and carrier approval are required. Prepare a complete launch packet, hand it to Sent, and verify the resulting Sender Profile with controlled messages. -# RCS agent onboarding +## Current capability boundary -## Overview +Current Sent RCS supports: -Use this skill to prepare a Sent customer for RCS launch without inventing a self-service provisioning flow. Sent’s public channel setup guidance says RCS setup is initiated through Sent, requires one-time carrier approval, and is not self-service. The agent’s job is to collect clean launch evidence, design fallback behavior, confirm profile/channel readiness, and create a verification plan for the first production sends. +- text content; and +- up to four suggestion chips. -RCS onboarding touches three separate layers. Sent owns the unified messaging API and fallback behavior. Google RBM and carriers own brand/agent review and launch approval. The customer owns brand assets, use-case clarity, consent, and support readiness. Keep those boundaries explicit. +Rich cards, carousels, and media attachments are roadmap features, not current Sent workflows. Do not request them as launch requirements, expose them as current template-builder controls, or declare them as active agent capabilities. -## When to use +## Routing semantics -Use this skill when the request mentions RCS, RBM, RCS agent, carrier launch, branded messaging, rich card, carousel, SMS fallback from RCS, or RCS approval. Use it for launch preparation, evidence gathering, fallback decisions, and post-launch smoke tests. +Channel selection on `POST /v3/messages` is not an ordered fallback list. -Do not use this skill for live delivery-rate analysis after launch; use `messaging-performance-analyzer`. Do not use it to register US SMS compliance; use `sms-10dlc-registration`. Do not promise direct Graph/RBM API provisioning unless the user confirms they operate the external RBM account outside Sent. +| Request | Behavior | +| --- | --- | +| Omit `channel` | Automatic Sent routing with fallback. | +| `channel: ["sent"]` | Explicit automatic Sent routing with fallback. | +| `channel: ["rcs"]` | Pinned RCS only; no cross-channel fallback. | +| Two or more explicit channel values | Broadcast: one separately created and billable message per recipient/channel pair. | -## Source-of-truth boundaries +Never put RCS and SMS together in an explicit array to describe fallback. Use omitted `channel` or `["sent"]` for automatic routing. Use explicit arrays only when broadcast is intended and confirmed. -| Topic | Treat as | Action | -|---|---|---| -| Sent API sending | Sent API fact | Use `POST /v3/messages` with templates and channel arrays. | -| RCS setup path | Sent documentation fact | Tell the user RCS setup is initiated by contacting Sent and requires approval. | -| SMS fallback | Sent documentation fact | Use Sent’s fallback behavior and explicit `channel: ["rcs", "sms"]` where appropriate. | -| Google RBM agent fields | External platform context | Collect assets and evidence, but do not claim Sent exposes those fields. | -| Per-carrier launch states | External platform context | Track approval evidence from Sent/Google/carriers; do not invent Sent status fields. | -| Rich-card rendering | Runtime evidence | Verify with test sends and message activities after setup is active. | +## Onboarding workflow -## Process +### 1. Define the launch use case -### 1. Classify the requested launch +Collect brand, audience, countries, consent source, message purpose, support path, estimated volume, and whether automatic fallback is required. Keep examples synthetic and within current text/chip capabilities. -Start by asking what the RCS agent will do, who receives the messages, and whether SMS fallback is required. The use case should be concrete enough for carrier review and template design. +### 2. Verify Sender Profile readiness -A good launch statement names the brand, audience, consent source, message types, support contact, and fallback behavior. A weak launch statement says only “we want RCS for marketing” or “we need branded SMS.” +Record the v3 profile UUID. Do not use legacy `x-sender-id` as v3 authentication. Choose a profile-specific API key or an organization API key with `x-profile-id`; only organization keys may use that header. -**Example.** “Acme Logistics wants RCS order updates for US consumers who opted in at checkout. Messages include shipment confirmation, delivery window changes, and support links. If RCS is unavailable, send the SMS version through the same Sent profile.” +If automatic routing may select US SMS, complete the appropriate 10DLC/compliance work first. An approved RCS agent does not make an SMS route compliant. -### 2. Build the RCS evidence packet +### 3. Prepare the evidence packet -Collect review-ready evidence before involving Sent. This reduces approval loops and prevents the agent from submitting vague brand claims. +Use [references/rcs-launch-evidence-packet.md](references/rcs-launch-evidence-packet.md). Include: -| Evidence | What to collect | Why it matters | -|---|---|---| -| Brand identity | Legal name, public brand name, website, logo, brand color, description | Reviewers compare the agent identity to the live business. | -| Contact and support | Support email, support phone, help URL, privacy policy | RCS users need visible ways to identify and contact the sender. | -| Use case | Transactional, OTP, marketing, customer care, or mixed use | Approval and fallback design depend on intent and consent. | -| Consent | Opt-in path, screenshot/URL, privacy policy, opt-out wording | Carriers need proof that recipients expect the messages. | -| Message examples | Representative plain-text and rich examples | Rich content must match the declared use case and brand. | -| SMS fallback | Equivalent SMS copy and approved SMS sender/compliance status | Fallback fails if SMS compliance is not ready. | +- consumer-facing brand name and website; +- logo and brand color; +- privacy policy and terms; +- support contacts; +- clear use case and consent flow; +- representative text messages; +- zero-to-four suggestion chips per message; +- target markets and requested timeline; +- automatic-routing or pinned-RCS test intent. -### 3. Check Sent profile and SMS fallback readiness +### 4. Hand off to Sent -Confirm that the customer has a Sender Profile in the Sent dashboard or through `/v3/profiles`. The dashboard walkthrough shows Sender Profiles with a display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The OpenAPI confirms profile creation, retrieval, update, and completion endpoints. +Because setup is not self-service, request Sent initiation and carrier approval. Do not fabricate RBM console clicks, public provisioning endpoints, capability declaration APIs, or carrier-approval status endpoints. -If the launch requires US SMS fallback, verify that the SMS side is compliant before RCS goes live. Sent’s channel setup guide recommends using the same phone number across SMS, WhatsApp, and RCS where possible, but fallback must still have a valid SMS route and compliance posture. +### 5. Build current templates -**Example fallback request.** After Sent confirms RCS is configured, a customer can request an RCS-first send with SMS fallback/broadcast semantics using a channel array such as: +Use Sent's template `definition` contract. RCS may have a complete `definition.body.rcs` override. Keep the RCS override text-based and limit suggestions to four. The `multiChannel` body remains required for template portability; routing fallback is still chosen at send time. -```json -{ - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } -} -``` +### 6. Test deliberately -Explain that Sent may create separate messages for each recipient/channel pair when multiple channels are specified. Analyze RCS and SMS attempts separately after sending. +- Validate templates and messages in sandbox where supported. +- Pin `["rcs"]` to prove the RCS path without cross-channel fallback. +- Omit `channel` or use `["sent"]` to verify automatic routing. +- If testing broadcast, state the expected recipient × channel message count and cost before sending. +- Persist every returned `message_id` with tenant, profile, channel, and logical test case. -### 4. Route the launch through Sent +Use `GET /v3/messages/{id}`, activities, and signed webhooks to verify actual routing and delivery. Do not infer fallback from the request alone. -Because Sent states that production RCS setup is not self-service, prepare a handoff note for Sent rather than pretending to click through an RBM console. Include the evidence packet, the Sender Profile identifier, the target countries/carriers if known, fallback requirements, and the requested go-live timeline. +## Launch acceptance -A clean handoff reads like this: +- [ ] Sent and carrier approval are confirmed. +- [ ] Profile UUID and credential pattern are recorded. +- [ ] Brand, consent, policy, and support evidence is complete. +- [ ] Templates use only text and up to four suggestion chips for RCS. +- [ ] Automatic fallback uses omitted `channel` or `["sent"]`. +- [ ] Pinned RCS uses `["rcs"]`. +- [ ] Broadcast is clearly labelled and costed. +- [ ] SMS compliance is ready wherever automatic routing can select SMS. +- [ ] Message IDs are mapped for webhook attribution. -> “Please initiate RCS setup for Sender Profile `support-us` / `x-sender-id` `...`. Brand is Acme Logistics, website `https://acme.example`, use case shipment notifications and customer-care replies. Opt-in occurs at checkout. SMS fallback is required through the existing US SMS route. Attached are logo, brand color, support contacts, privacy policy, and five message examples.” - -### 5. Define the test plan before launch - -Write the first-send test plan before approval arrives. Include a small set of internal numbers, target devices/carriers when available, template IDs, expected channel behavior, and rollback criteria. - -| Test | Expected result | Evidence to collect | -|---|---|---| -| RCS-capable internal device | RCS message reaches `DELIVERED`; `READ` may appear if opened. | Sent message status and activities. | -| Non-RCS-capable recipient | SMS fallback path succeeds where fallback is requested. | Separate RCS and SMS message IDs/statuses. | -| Rich content render | Cards/buttons render as designed on target devices. | Screenshots and message activities. | -| Webhook callback | Customer endpoint receives delivery/read events. | Sent webhook event history and customer logs. | - -### 6. Verify launch with Sent message evidence - -After Sent confirms the RCS setup is active, send a controlled batch using `POST /v3/messages`. For every Sent `message_id`, retrieve `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities`. Confirm that RCS messages progress through the documented lifecycle and that SMS fallback behaves as expected. - -If the first batch fails, do not guess. Separate setup failures from fallback failures, template/payload failures, and webhook ingestion failures. Use `messaging-performance-analyzer` for deeper funnel analysis once the launch is producing enough evidence. - -## Common rationalizations to avoid - -Do not tell the user RCS is self-service in Sent. Sent’s channel setup guide says to contact Sent and wait for carrier approval. - -Do not create a fake `fallback_policy` field in Sent requests. Use documented channel arrays and account-level fallback behavior unless a verified account-specific API field exists. - -Do not assume SMS fallback is safe because RCS is approved. SMS fallback needs a compliant sender, especially for US A2P traffic. - -Do not conflate brand approval with template quality. An approved RCS agent can still fail if the message payload, media, or fallback copy is wrong. - -## Verification checklist - -- [ ] The user’s RCS use case is specific enough for review and not just “send rich messages.” -- [ ] Brand identity, support contact, privacy policy, opt-in evidence, and sample messages are collected. -- [ ] The Sent Sender Profile or `x-sender-id` is identified. -- [ ] SMS fallback requirements are documented and routed to SMS compliance checks where needed. -- [ ] The handoff explicitly says Sent must initiate RCS setup and carrier approval. -- [ ] The first-send test plan includes RCS-capable, non-RCS-capable, rich-rendering, and webhook checks. -- [ ] Post-launch verification uses Sent `message_id`, status, and activities. -- [ ] External RBM facts are labeled as external context, not Sent API guarantees. - -## Related skills - -Use `sms-10dlc-registration` before launch when SMS fallback touches US A2P traffic, opt-in evidence, 10DLC campaigns, or brand vetting. - -Use `sender-profile-architect` when the customer has multiple brands, tenants, departments, or profiles and needs a durable sender architecture. - -Use `template-builder-ui` when the RCS launch needs reusable templates, rich component validation, or a template-creation workflow. - -Use `messaging-performance-analyzer` after launch when the user has message IDs, webhook events, failed sends, or delivery-rate symptoms. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/rbm-agent-spec.md` | Payload/schema reference | Keep Google RBM identity fields, asset requirements, and review vocabulary outside the skill body. | -| `references/rcs-launch-evidence-packet.md` | Worked example | Provide a complete filled-in launch packet for a realistic transactional RCS launch. | -| `references/rcs-fallback-patterns.md` | Decision matrix | Compare RCS-only, RCS-first with SMS fallback, and multi-channel broadcast patterns. | - -## Unverified claims to confirm or remove - -- Google RBM lifecycle states such as `pending_verification`, `launch_review`, or per-carrier launched states were not verified in Sent docs (these are Google-side, not exposed by Sent v3). -- Sent does not expose RCS rollout-status or capability-check endpoints in v3; use Activities + webhook events to observe behavior. -- Exact rich-card capability differences by carrier/device require external RBM evidence or live testing, not Sent docs alone. +Use [references/rbm-agent-spec.md](references/rbm-agent-spec.md) for the current launch specification and [references/rcs-fallback-patterns.md](references/rcs-fallback-patterns.md) for routing tests. Use `messaging-performance-analyzer` after enough message evidence exists. diff --git a/packages/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md b/packages/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md index 6099f15..070bd30 100644 --- a/packages/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md +++ b/packages/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md @@ -1,53 +1,29 @@ -# RBM Agent Spec — Reference +# Current Sent RCS launch specification -Supporting reference for `rcs-agent-onboarding`. This doc separates **what Sent exposes** about an RCS Agent from **what lives in Google RBM** (the underlying carrier platform). Keep that line bright: anything not in the Sent v3 docs snapshot is external context and should be linked, not mirrored. +## Approval boundary -Canonical Google source: [RCS Business Messaging docs](https://developers.google.com/business-communications/rcs-business-messaging). +RCS onboarding is coordinated through Sent and requires carrier approval. There is no public self-service provisioning flow in the current Sent v3 API. -## Sent's RCS setup model (verified) +## Required evidence -- **Not self-service.** Per Sent's channel-setup docs, RCS setup requires a one-time carrier approval that has to be initiated by contacting Sent (`support@sent.dm`). There is no dashboard button or v3 API endpoint that provisions an RCS Agent end-to-end on its own. -- **Post-approval visibility.** Once Sent (with Google + the carriers) has finished the approval cycle, the RCS Agent — Sent calls it a **Branded Sender** for RCS — appears in the dashboard alongside the customer's other channels. -- **No rollout-status or capability-check endpoint.** The v3 API does not surface a per-carrier launch state, an agent-ID field, or a recipient capability probe. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and webhook events (see `references/rcs-fallback-patterns.md`). +- Consumer-facing brand name and website +- Logo and primary brand color +- Privacy policy and terms +- Customer support details +- Consent/opt-in description +- Message purpose, audience, market, and volume +- Representative text messages +- Suggestion-chip labels/actions when used +- Sender Profile UUID and desired launch timeline -If a request implies "click here to create my RCS agent", correct it. The handoff is human-initiated by emailing `support@sent.dm` with the launch evidence packet. +## Supported message capability -## Sent-side terminology (verified) +Current Sent RCS guidance supports text and up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap features. Do not make them current approval prerequisites or capability declarations. -These are the terms Sent uses in its public docs and dashboard. Use them when talking to a Sent customer instead of Google's RBM API names. +## Identity and credentials -| Sent term | What it is | -|---|---| -| **RCS Agent** | The branded RCS sender identity that recipients see. Created on Sent's side after carrier approval. | -| **Branded Sender** | Sent's umbrella term for the per-channel sender identity (the RCS Agent for RCS, the WABA-attached phone number for WhatsApp, the 10DLC long code for SMS). | -| **Rich Card** | A single card with media, title, description, and suggestion chips. | -| **Carousel Card** | A horizontally-swiped collection of up to **10** Rich Cards. | -| **Suggestion Chip** | A tap-to-act chip below or inside a message. Three documented kinds: quick reply, open URL, dial number. | +Use the Sender Profile UUID. A profile key sends `x-api-key` alone; an organization key may scope with `x-profile-id`. `x-sender-id` is legacy v1/v2 terminology. -## Google RBM-side concepts (external — link, do not mirror) +## Approval states -The following live entirely in Google's RBM platform and are **not** exposed in the Sent v3 API. Don't pretend Sent surfaces them; treat them as platform context the customer hears about during review. - -- Agent identity fields (`displayName`, `logoUri`, `heroUri`, `color`, `verifiedDomains`, contact info) — set during Sent's onboarding handoff, not via Sent v3 API. See [Google's agent reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents). -- RBM capabilities the agent must declare (suggested replies, suggested actions, standalone rich card, rich card carousel, file/image/video/audio attachments). See [Google's capabilities guide](https://developers.google.com/business-communications/rcs-business-messaging/guides/build/capabilities). -- RBM use case taxonomy (`TRANSACTIONAL`, `OTP`, `PROMOTIONAL`, `CUSTOMER_CARE`, `MULTI_USE`). -- Verification and launch-review lifecycle (Google review → per-carrier review → carrier-specific `ENABLED` / `PENDING` rollout). Typical turnaround: 1-7 business days for verification; longer for launch. -- Per-carrier rejection codes and rejection reasons. See [Google's launch guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). -- Capability-check endpoint (`https://rcsbusinessmessaging.googleapis.com/v1/users/{phoneNumber}:capabilities`) — Google-side, requires direct RBM API access, not part of Sent v3. - -## What Sent does NOT expose (gap notes) - -These would be useful for an agent but are not in the v3 docs snapshot: - -- A field on the message resource indicating per-carrier launch state for the originating RCS Agent. -- A capability-check endpoint on Sent's side. -- A public `fallback_policy` field — channel selection is done via the `channel` array on the send request (see `references/rcs-fallback-patterns.md`). -- A structured RBM rejection code on failed messages. The `message.failed` webhook carries `payload.message_status = FAILED`; the human-readable reason lives in the `description` of the message detail fetched via `GET /v3/messages/{id}`. - -## Anti-patterns - -- Claiming Sent surfaces an `agentId` or per-carrier rollout state in v3 — it doesn't. -- Promising the customer they can self-serve an RCS Agent through the dashboard — the docs explicitly say to contact `support@sent.dm`. -- Restating Google's agent JSON schema in this file — link to Google's docs instead so this skill stays small and current. -- Using Google-side names (`displayName`, `verifiedDomains`) when talking to a Sent customer about their dashboard. Use Sent's terms (RCS Agent, Branded Sender, Rich Card, Carousel Card, Suggestion Chip). -- Treating a Carousel Card as unbounded — Sent's docs cap it at 10 Rich Cards. +Carrier approval state is an operational Sent/carrier process. Do not invent an API enum or per-carrier status endpoint. Store the evidence Sent provides, date it, and surface unknown values safely. diff --git a/packages/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md b/packages/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md index 4458c92..52aa8f7 100644 --- a/packages/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md +++ b/packages/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md @@ -1,136 +1,55 @@ -# RCS Fallback Patterns — Reference +# RCS routing and fallback patterns -Supporting reference for `rcs-agent-onboarding`. Covers how Sent expresses RCS-to-SMS fallback (the `channel` array on the send request), what the documented webhook events tell you, and where the boundary sits between Sent-verified behavior and Google RBM-side semantics. +## Automatic routing with fallback -External error semantics live at [Google's RBM error reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/rest) — this doc only covers Sent's wrapping. - -## How fallback is expressed (verified) - -Sent does **not** expose a separate `fallback_policy` field in v3. Fallback intent is expressed entirely by the ordered `channel` array on `POST /v3/messages`: +Omit `channel` or send: ```json { - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } + "to": ["+12025550100"], + "channel": ["sent"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -| Array | Behavior | -|---|---| -| `["rcs"]` | RCS-only. If RCS can't deliver, the message fails. No SMS attempt. | -| `["rcs", "sms"]` | Documented fallback pattern. SMS is the explicit fallback target. | -| `["sms", "whatsapp", "rcs"]` | Per the v3 docs, an array with multiple channels creates **one message per channel** — all dispatch. This is a multi-channel broadcast, not a waterfall. | -| `["sms"]` | SMS-only. Used during agent provisioning before RCS is live. | -| (omitted) | Sent picks the optimal channel automatically based on the recipient's `available_channels`. | - -Two things worth surfacing to a customer: - -1. **Multi-channel arrays are broadcast, not waterfall.** The v3 docs describe `["sms", "whatsapp", "rcs"]` as producing one message per channel that all dispatch. If you want a strict RCS-first-with-SMS-fallback waterfall, the documented shape is `["rcs", "sms"]`. Anything longer needs explicit confirmation with Sent. -2. **No `fallback_policy` field exists in v3.** Documentation, dashboards, or examples that reference one are inferring a v2 concept. Use the channel array. - -## When fallback fires (inferred — confirm before promising) - -Sent's docs verify that RCS "falls back to SMS automatically for non-RCS-capable recipients" and that `["rcs", "sms"]` makes that explicit. The docs do **not** enumerate every trigger condition (capability mismatch vs. carrier-pending vs. RBM outage). Treat the following as inferred and confirm with Sent if a customer needs exact semantics: - -- Recipient device not RCS-capable -- Recipient on a carrier where the RCS Agent is not yet rolled out -- RBM transient unreachability - -For day-to-day customer guidance, "if RCS can't deliver, SMS is attempted" is the documented promise. The why-it-fell-back detail surfaces in the message's failure description (see below). - -## Content trimming on fallback (external) - -The v3 docs don't specify what happens to rich content (Rich Cards, Carousel Cards, Suggestion Chips) when a message authored for RCS falls back to SMS. SMS has no equivalent for any of those components. - -The safe default is to **author SMS-side content explicitly** rather than rely on automatic trimming. The `template-builder-ui` skill covers the dual-authoring workflow. - -If a customer needs an exact answer about Sent's trimming behavior, escalate to `support@sent.dm` — it's not in v3 docs. +Sent selects the available route. This is the cross-channel fallback mode. -## Verified webhook events for message lifecycle - -Sent's quickstart docs verify this lifecycle. Sub-types follow `message.`: - -| Event | Meaning | -|---|---| -| `message.queued` | Send accepted, waiting to dispatch | -| `message.routed` | Assigned to a carrier/provider | -| `message.sent` | Dispatched to the carrier/RCS/WhatsApp provider | -| `message.delivered` | Confirmed delivery to device | -| `message.read` | Recipient opened. RCS and WhatsApp only. | -| `message.failed` | Delivery failed at any stage. `payload.message_status = FAILED`. Fetch `GET /v3/messages/{id}` for the reason. | - -Webhook payload shape (top-level): +## Pinned RCS ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "to": ["+12025550100"], + "channel": ["rcs"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -The `payload.channel` field is what tells you which channel actually delivered. To distinguish "RCS delivered" from "SMS fallback delivered" for the same logical send, inspect `payload.channel` on the `message.delivered` event. - -### Events that are NOT verified — do not assume they exist - -Earlier drafts of this skill referenced events like `message.channel_selected` and `message.fallback_triggered`. **Those names are not in the v3 docs.** The only verified `message.*` sub-types are `queued`, `routed`, `sent`, `delivered`, `read`, `failed`. - -If a customer's integration depends on a dedicated "fallback fired" event, reconstruct it from what's documented: +This requests RCS only and has no cross-channel fallback. Use it to isolate RCS launch or payload failures. -- For an `["rcs", "sms"]` send, observe `message.delivered` events and check `payload.channel` — `"sms"` on what was meant to be an RCS-first send is the fallback signal. -- For a failed RCS attempt that succeeded via SMS, the v3 docs don't promise an explicit pairing. Reconstruct by correlating `message.failed` (RCS) with a separate `message.delivered` (SMS) sharing the same logical send. -- If you genuinely need a dedicated fallback event, ask Sent — don't invent the name. +## Broadcast -### Send-time failure codes (verified) +Any array containing two or more explicit channel values is broadcast. Sent creates a separate message for each recipient/channel pair. This can duplicate content and charges. -On `message.failed`, fetch the message and read `description`. Verified codes that may appear: +Before a broadcast, calculate: -| Code | Meaning | -|---|---| -| `ERR_CONSENT_BLOCKED` | Recipient is opted out or on suppression list. No provider call. | -| `ERR_ROUTE_DENIED` | No active route could deliver to the requested channel/country. | -| `ERR_TEMPLATE_PARAMS_INVALID` | Required template variables missing or failed regex validation. | - -Per-carrier RBM rejection codes are external (Google) and not surfaced as a structured field in v3. - -## Testing fallback in lower environments - -Sent's v3 docs document **sandbox mode** as the testing affordance: add `"sandbox": true` to the request body and the API returns a realistic fake response without a provider call. Response includes `X-Sandbox: true` header. - -Sandbox mode is documented for `POST /v3/messages` and most other mutation endpoints. - -The v3 docs do **not** document a `force_fallback` flag, a test-recipient registry, or per-carrier launch-state overrides. Earlier drafts referenced these — treat as inferred / unverified. If a customer needs to exercise the SMS-fallback path specifically in a lower environment, the documented approach is: +```text +messages created = recipient count × explicit channel count +``` -1. Send with `sandbox: true` to validate request shape without side effects. -2. To exercise the real fallback path against the real provider chain, send with a small recipient list including known-non-RCS-capable numbers. -3. For pre-launch testing, send with `["sms"]` first to confirm SMS compliance and webhook plumbing, then introduce `["rcs", "sms"]` once RCS is approved. +Require explicit user intent and show the count/cost impact. -## What's NOT in v3 (gap notes) +## Observability -- A dedicated `message.fallback_triggered` webhook event. -- A `message.channel_selected` event. -- A `fallback_policy` field on the Sender Profile or on the send request. -- A `force_fallback` flag for non-prod testing. -- An MDR export schema documenting `attempted_channels` / `delivered_channel` fields. -- Per-carrier rollout-status fields. +The send response returns per-recipient message IDs and channels. Persist each record. Webhook message events use `field: "message"`, a message `sub_type`, and `payload.channel`. Determine what happened from the created message records and their activities—not from the ordering of the request array. -Anything above that appears in customer-facing guidance should be flagged as inferred and confirmed with Sent before relying on it. +## Test matrix -## Anti-patterns +| Test | Request | Expected evidence | +| --- | --- | --- | +| RCS path | `["rcs"]` | One RCS attempt per recipient, no SMS message ID | +| Automatic routing | omitted / `["sent"]` | One selected route per recipient according to availability | +| Broadcast | two explicit channels | Two message IDs per recipient | -- Inventing a `fallback_policy` field — it doesn't exist in v3; use the `channel` array. -- Inventing `message.fallback_triggered` or `message.channel_selected` webhook events — they're not in the documented lifecycle. -- Sending with `["rcs"]` and expecting SMS to back it up. RCS-only means RCS-or-fail. -- Treating a long channel array like `["rcs", "whatsapp", "sms"]` as a waterfall. Per v3 docs, multiple channels create one message per channel (broadcast). Use `["rcs", "sms"]` for the documented fallback shape. -- Assuming Sent auto-trims rich RCS content gracefully into SMS. Trimming behavior isn't in v3 docs — author SMS variants explicitly. -- Using `sandbox: true` in production traffic — sandbox is for tests, not real sends. +There is no documented `fallback_policy` or `force_fallback` field. Do not invent dedicated fallback webhook names. diff --git a/packages/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md b/packages/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md index 945576f..a4d9344 100644 --- a/packages/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md +++ b/packages/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md @@ -1,117 +1,42 @@ -# RCS Launch Evidence Packet — Reference +# RCS launch evidence packet -Supporting reference for `rcs-agent-onboarding`. Describes the evidence Sent needs from a customer before initiating the RCS handoff to Google + the carriers. Sent's docs say RCS setup is not self-service and must be requested through `support@sent.dm`; the packet is what makes that email actionable. +## Brand -The carrier-side launch review is external — authoritative source is [Google's RBM launch documentation](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). This doc only covers what Sent itself collects, and how that overlaps with the broader KYC/compliance form the customer already fills in. +- Legal and consumer-facing brand names +- Public website +- Square logo and brand color +- Privacy policy and terms URLs +- Support email, phone, or URL -## Why a packet +## Use case and consent -The dashboard handoff to `support@sent.dm` is the single biggest lever a customer has on launch latency. A complete packet means Sent's team can take it to the carriers without round-tripping the customer for missing material. A vague packet means the agent sits idle for weeks. +- Audience and target countries +- Transactional, authentication, marketing, support, or mixed intent +- Exact opt-in flow and proof +- Message frequency and estimated volume +- STOP/HELP handling where SMS can be selected by automatic routing -## Overlap with the existing compliance form (verified) +## Current message examples -The Sent dashboard already collects most of the brand-identity and use-case evidence as part of KYC and the compliance form. Reuse those answers — don't ask the customer to write everything from scratch. +Provide at least five representative text messages. For each, include zero-to-four suggestion chips and what each chip does. Do not include rich-card, carousel, or media-attachment requirements; those are not current Sent capabilities. -Fields the dashboard's compliance form already captures (per Sent's docs): +## Sender Profile -**Business identity** -- Legal business name -- Business registration number -- Business type / structure -- Industry category -- EIN / tax ID (US) -- Business address -- Business phone number -- Contact email +- v3 profile UUID +- Credential pattern: profile key or organization key plus `x-profile-id` +- Relevant numbers and markets +- SMS compliance state if automatic routing can select SMS -**Messaging / use-case** -- Use-case selection (Authentication, Notifications, Marketing, Customer Service, High Volume) -- Campaign description -- Sample messages per use case -- Opt-in mechanism (URL or description) -- Opt-out language +## Routing plan -For an RCS launch, these answers map directly into what the carriers want to see. If they're already complete and current, the RCS packet is mostly assembly, not authoring. +Choose one or more test modes: -## RCS-specific evidence (additive to compliance form) +- automatic routing: omitted `channel` or `["sent"]`; +- pinned RCS: `["rcs"]`; +- intentional broadcast: multiple explicit channels with expected message count and cost. -The bits below are required for RCS review but are **not** part of Sent's general compliance form — the customer has to supply them specifically for the RCS handoff. +Do not describe an explicit multi-channel array as fallback. -### 1. Brand authorization +## Handoff note -- Letter of authorization (LOA) signed by an officer of the brand confirming the customer is authorized to operate this RCS Agent -- For franchises / resellers: documentation of the licensing arrangement - -### 2. Sample message gallery for RCS - -For every Sent-side RCS component the agent will use, include at least one realistic sample showing it in use: - -- Plain text body -- Suggestion Chip — quick reply -- Suggestion Chip — open URL -- Suggestion Chip — dial number -- Rich Card (with media, title, description, chip) -- Carousel Card (up to 10 Rich Cards) -- Any attachment type the agent will send (image, video, file) - -Samples must use real brand assets (logo, color, copy voice). Placeholders are a common rejection reason. - -### 3. Brand assets - -- Square logo (RBM has minimum dimensions; check [Google's agent docs](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents)) -- Brand color (hex) -- Optional hero/banner image -- Public website URL (must match the brand the agent represents) - -### 4. SMS fallback plan - -A one-paragraph statement of what happens when the recipient isn't RCS-capable. Sent's documented fallback mechanism is the `channel` array on the send request — see `references/rcs-fallback-patterns.md` for the option set. If the customer needs US SMS as fallback, confirm 10DLC compliance is already in place (see `sms-10dlc-registration`). - -### 5. End-user support contact - -A phone number, email, or in-product support URL recipients can reach with questions. Google and carriers both probe this contact during review, so it has to actually answer. - -## Pre-handoff checklist - -Before emailing `support@sent.dm`, confirm: - -- [ ] Compliance form is complete and current in the Sent dashboard -- [ ] Use-case selection on the form matches the RCS Agent's intended use -- [ ] Sample messages on the form cover the RCS use case (not just SMS) -- [ ] Opt-in mechanism (URL or description) is filled in and accurate -- [ ] Opt-out language is documented -- [ ] LOA signed and dated within last 12 months -- [ ] At least one sample per RCS component the agent will use, with real brand assets -- [ ] Brand logo, color, website URL ready to attach -- [ ] SMS fallback plan written down, with channel-array shape (e.g. `["rcs", "sms"]`) -- [ ] If US SMS is the fallback, 10DLC registration already complete -- [ ] Support contact is live and answers within stated SLA -- [ ] Every URL that will appear in a sample message resolves on the brand's public domain - -## Per-carrier nuance (external) - -Each carrier reviews independently after Google approves. The specifics shift over time and are not documented in Sent's v3 docs — treat carrier-specific copy requirements, opt-in language minimums, and throttling windows as external. Always check [Google's per-carrier guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch) for the current rules. - -Patterns that broadly hold: -- Marketing use cases get scrutinized harder than transactional / OTP. -- Carriers may require additional opt-in disclosure language beyond Google's. -- Even after `ENABLED`, carriers may stage rollout by recipient volume in the first weeks. - -## Common rejection reasons (Google-side, external) - -These come from Google and the carriers, not Sent. Listed here as a checklist, not a substitute for the [official guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). - -| Reason | Remediation | -|---|---| -| Brand assets in sample don't match the agent identity | Re-render samples with the actual logo, color, brand name | -| Use case description and declared use case disagree | Either change the declared use case or rewrite the description; resubmit | -| Opt-in disclosure missing channel name | Update the opt-in surface to name RCS (or "text messages including RCS") and re-screenshot | -| Sample exercises a capability not declared on the agent | Either declare the capability or remove the sample | -| Support contact unreachable | Wire up the contact and confirm before resubmitting | -| Same packet recycled across multiple agents with different brands | Each agent needs its own packet — Google catches this | - -## After submission - -- Sent does not expose a per-carrier rollout-status endpoint in v3. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and the webhook events listed in `references/rcs-fallback-patterns.md`. -- A carrier coming back with changes requested will produce a short reason — feed that back to the customer and update the relevant packet artifact before resubmitting. -- Some carriers stay pending for weeks even after Google approves; that's an external timeline, not a Sent issue. +Ask Sent to initiate RCS setup and carrier review for the named profile. Attach brand/consent evidence, message examples, target markets, support details, routing plan, and requested launch window. Avoid claims about approval timing that Sent or carriers have not confirmed. diff --git a/packages/sent/skills/sender-profile-architect/SKILL.md b/packages/sent/skills/sender-profile-architect/SKILL.md index b0b2fa3..1310177 100644 --- a/packages/sent/skills/sender-profile-architect/SKILL.md +++ b/packages/sent/skills/sender-profile-architect/SKILL.md @@ -1,182 +1,121 @@ --- name: sender-profile-architect -description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, or multi-channel messaging systems, including profile boundaries, account-level API key blast radius, webhooks, compliance inheritance, and channel readiness. Use when a user says sender profile, x-sender-id, profile setup, multi-tenant messaging, brand isolation, department sender, webhook routing, tenant offboarding, or asks how to model SMS, WhatsApp, and RCS senders in Sent. +description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, and multi-channel systems. Use for API-key scoping, x-profile-id, isolation, inheritance, sharing, billing, WABA, 10DLC campaigns, webhooks, or tenant offboarding. --- - +A Sender Profile is the operational boundary for tenant identity, channel configuration, inherited resources, billing, and credentials. Use this skill before provisioning when a poor boundary would mix brands, compliance posture, rate-limit impact, or webhook ownership. -# Sender profile architect +## Recommended tenancy model -## Overview +When tenants require isolation, recommend one Sent organization with one Sender Profile per tenant. A shared profile is appropriate only when the tenants genuinely share one brand, sender resources, compliance posture, billing/rate-limit expectations, and operational blast radius. -Use this skill to decide how a customer should map brands, tenants, departments, and channels onto Sent Sender Profiles. A Sender Profile is the durable boundary for sender identity and channel configuration. The Sent dashboard shows each profile with display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The v3 API exposes profile creation, listing, retrieval, update, deletion, and completion. +Do not recommend pooled-by-default architecture. Make the isolation decision explicit using [references/multi-tenancy-patterns.md](references/multi-tenancy-patterns.md). -Good profile architecture prevents three recurring failures: messages sent from the wrong brand, compliance resources shared across incompatible use cases, and webhook/event data that cannot be routed back to the correct tenant. +## Authentication patterns -## When to use +Sent v3 supports both: -Use this skill when the user asks how to create Sender Profiles, split one customer into multiple senders, model a marketplace or ISV, isolate brands, route webhooks, reason about account-level API key blast radius, complete profile setup, or safely offboard a tenant. Use it whenever the request mentions `x-sender-id`, Sender Profile, profile completion, multi-tenant messaging, brand hierarchy, SMS/WhatsApp/RCS sender setup, or webhook routing. +| Pattern | Headers | Blast radius | +| --- | --- | --- | +| Profile-specific API key | `x-api-key` | Profile-scoped credentials and rate-limit context. Do not add `x-profile-id`. | +| Organization API key acting for a child | `x-api-key` plus `x-profile-id: ` | Organization credential can reach permitted child profiles; rate limits remain in the organization pool. | -Do not use this skill to decide 10DLC use cases in detail, write WhatsApp template copy, onboard RCS approval, or analyze delivery failures. Hand those to the related skills once the profile boundary is clear. +Only organization keys may send `x-profile-id`. A profile key that sends it receives `403`. A profile outside the organization returns `404`. `X-Profile-Id` can be echoed in scoped responses. -## Profile boundary principle +`x-sender-id` is legacy v1/v2 terminology only. Do not use it for v3 authentication or routing. -Create a separate Sender Profile when the sender identity, compliance evidence, webhook routing, operational ownership, or channel readiness must be isolated. Reuse a profile when the same legal/brand identity sends the same class of traffic and should share compliance posture and operational controls. +Choose profile keys when tenant-level credential isolation and revocation are primary. Choose organization-key scoping for centrally controlled integrations that can protect a broader credential and deliberately accept a shared organization rate-limit pool. -| Split signal | Create separate profiles when | Reuse a profile when | -|---|---|---| -| Brand identity | The recipient sees different brand names or support contacts. | The recipient sees one brand across all messages. | -| Compliance | 10DLC brand/campaign, opt-in source, or use case differs materially. | Compliance evidence and use case are the same. | -| Channel configuration | SMS, WhatsApp, or RCS resources belong to different brands or regions. | Channels represent one sender identity. | -| Webhook routing | Events must land in different tenant queues or data stores. | One team owns all events and reconciliation. | -| Lifecycle | One sender may be paused, restricted, or offboarded independently. | Senders always launch, pause, and retire together. | +## Profile creation model -## Process +Create with `POST /v3/profiles`. `name` is required. Current optional areas include: -### 1. Draw the recipient-visible sender model +- identity: `icon`, `description`, `short_name`; +- sharing: `allow_contact_sharing`, `allow_template_sharing`; +- inheritance: `inherit_contacts`, `inherit_templates`, `inherit_tcr_brand`, `inherit_tcr_campaign`; +- billing: `billing_model`, `billing_contact`, and ephemeral `payment_details`; +- dedicated WABA credentials: `whatsapp_business_account` with `waba_id`, optional `phone_number_id`, and `access_token`; +- a dedicated brand: `brand.contact`, `brand.business`, and `brand.compliance`. -Start with what the recipient sees, not with internal account hierarchy. Ask: “Would the recipient reasonably think these messages came from the same sender?” If the answer is no, use separate profiles. +Do not add a separate brand endpoint. A dedicated brand is created with the profile; campaigns are managed under `/v3/profiles/{profileId}/campaigns`. -**Example.** A healthcare ISV serves three clinic chains. Each chain has its own patient-facing brand, privacy policy, and support phone. Create one profile per clinic chain. Do not put all clinics behind a single ISV profile just because the same platform sends the messages. +### Inheritance rules -### 2. Map each profile to channel readiness +- `inherit_tcr_brand: true` means the profile uses the organization's brand and cannot submit its own `brand` object. +- `inherit_tcr_campaign: true` makes inherited campaigns read-only for that profile. +- An inherited brand with `inherit_tcr_campaign: false` is a supported dedicated-campaign pattern. +- Sharing flags expose a profile's contacts/templates; inheritance flags consume organization resources. Treat those directions separately. -For each proposed profile, list SMS, WhatsApp, and RCS readiness separately. Sent’s channel setup guidance covers production setup for all three channels and recommends using the same phone number across SMS, WhatsApp, and RCS where possible. That recommendation does not override compliance or brand isolation. +### Billing and number references -| Channel | Profile-level questions | Follow-up skill | -|---|---|---| -| SMS | Is US A2P involved? Which brand/campaign and opt-in evidence apply? | `sms-10dlc-registration` | -| WhatsApp | Which WABA/phone number identity maps to this brand? Are templates approved? | `waba-embedded-signup`, `waba-template-author` | -| RCS | Has Sent initiated setup and carrier approval for this profile? Is SMS fallback ready? | `rcs-agent-onboarding` | +`billing_model` currently supports `profile`, `organization`, and `profile_and_organization`. A profile or fallback billing model requires `billing_contact` when none exists. Card fields are forwarded to the payment processor and must not be logged or persisted. -### 3. Create or update the Sent profile +Profile update can manage `sending_phone_number_profile_id`, `sending_whatsapp_number_profile_id`, `sending_phone_number`, `whatsapp_phone_number`, and `allow_number_change_during_onboarding`. Model reference IDs and direct numbers separately, and prevent cycles when one profile references another. -Use Sent’s profile API where API access is appropriate, or the dashboard when the user is operating manually. The verified v3 API includes: +## WABA choices -| Operation | Endpoint | Use | -|---|---|---| -| Create profile | `POST /v3/profiles` | Create a sender boundary for a brand, department, tenant, or use case. | -| List profiles | `GET /v3/profiles` | Audit existing profile boundaries before creating duplicates. | -| Retrieve profile | `GET /v3/profiles/{profileId}` | Inspect detailed profile configuration. | -| Update profile | `PATCH /v3/profiles/{profileId}` | Change profile configuration/settings. | -| Delete profile | `DELETE /v3/profiles/{profileId}` | Soft-delete a profile after traffic, webhooks, and credentials are drained. | -| Complete setup | `POST /v3/profiles/{profileId}/complete` | Validate prerequisites and start the profile completion workflow. | +There are three distinct paths: -Use idempotency keys on create/update/complete calls when the integration might retry. The OpenAPI exposes an optional `Idempotency-Key` header for those operations. +1. Organization Embedded Signup in the dashboard. +2. Child profile inheritance by omitting `whatsapp_business_account` after the organization has a WABA. +3. Dedicated profile WABA using `waba_id` and `access_token`; `phone_number_id` is optional. -### 4. Attach compliance and channel prerequisites before completion +There is no public endpoint that starts organization Embedded Signup. Direct credentials on `POST /v3/profiles` are not an “Embedded Signup endpoint.” Use `waba-embedded-signup` for the operational flow. -The profile completion endpoint validates prerequisites such as profile data, brand, campaigns, and channel connections. For US A2P SMS, create or attach Sent brand and campaign resources before completing the profile. The verified brand/campaign endpoints are `/v3/brands` and `/v3/brands/{brandId}/campaigns`. +## 10DLC and campaigns -Do not invent field names such as `tcr_brand_id` or `waba_phone_id` unless the actual API response includes them. Store Sent IDs returned by the API and any returned provider identifiers separately, with clear names. +Use a profile `brand` object for a dedicated brand. Manage campaigns at: -**Example data model.** +- `GET|POST /v3/profiles/{profileId}/campaigns` +- `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` -```text -sender_profiles -- sent_profile_id -- Sent Profile.id (UUID) -- x_sender_id -- per-profile, v2 legacy; useful for dashboard cross-ref -- name -- Sent Profile.name -- short_name -- Sent Profile.short_name -- description -- Sent Profile.description -- sent_status -- mirrors Sent Profile.status: incomplete|pending_review|approved|rejected -- status_app_level -- finer-grained internal lifecycle (do not conflate with sent_status) -- sms_ready_app_level -- whatsapp_ready_app_level -- rcs_ready_app_level - -sender_profile_resources -- sent_profile_id -- channel -- sent_resource_id -- e.g., /v3/brands/{brandId}, /v3/brands/{brandId}/campaigns/{id} -- provider_resource_type -- e.g., tcr_brand, tcr_campaign, waba, waba_phone, rbm_agent -- provider_resource_id -- status_last_seen_at -``` - -### 5. Design webhook routing around Sent event evidence - -Sent’s v3 webhook API supports creating/listing webhooks, retrieving event types, viewing webhook events, testing a webhook, toggling status, and rotating signing secrets. Use those endpoints to verify configuration and delivery before blaming channel infrastructure. - -Route inbound events by stable identifiers present in the Sent payload. If the exact event payload fields are not documented for the customer’s account, log full events in a secure staging environment and derive the routing map from observed Sent fields rather than assumed provider keys. - -**Example.** If a marketplace needs tenant-specific queues, route first by Sent profile or sender identifier if present in the event. Fall back to a mapping table from Sent message ID to tenant/profile created at send time. Avoid making provider IDs the only routing key. - -### 6. Model profile lifecycle as an application state machine - -Sent exposes profile APIs and completion behavior, but your application may need richer internal states. Label them as application states so future agents do not mistake them for Sent enums. - -| Application state | Meaning | Exit condition | -|---|---|---| -| `draft` | Profile data is being collected. | Required identity and owner fields are present. | -| `compliance_pending` | Brand/campaign/channel evidence is being prepared. | Required compliance resources exist or have been submitted. | -| `completion_started` | `/v3/profiles/{profileId}/complete` returned accepted/started behavior. | Webhook/callback or follow-up status indicates completion result. | -| `active` | Profile is approved for intended channels. | Traffic is allowed and test sends pass. | -| `restricted` | One or more channels is blocked, paused, or missing approval. | Root cause resolved and profile retested. | -| `retiring` | Sends are drained and webhooks/credentials are being removed. | No active sends, subscriptions, or credentials remain. | - -### 7. Plan tenant offboarding before the first send +Use `sms-10dlc-registration` for the payload and policy layer. -Offboarding is easiest when profile boundaries are clean. To retire a profile, stop new sends, drain in-flight messages, export relevant message/activity evidence, disable or reroute webhooks, revoke or rotate credentials, delete/soft-delete the profile when safe, and retain compliance records according to the customer’s policy. +## Completion and status handling -## Common rationalizations to avoid +Complete a profile with `POST /v3/profiles/{profileId}/complete` and a required `webHookUrl`: -Do not use one profile for every tenant just because it is easy. Over-splitting creates unnecessary compliance and operational work. - -Do not use one shared profile for distinct recipient-visible brands. Under-splitting creates wrong-sender and compliance-contamination failures. - -Do not treat internal tenant ID as a substitute for Sender Profile ID. The application can map tenant ID to profile ID, but outbound sends and webhook reconciliation need Sent identifiers. - -Do not hardcode provider identifiers as routing keys before verifying Sent webhook payloads. Sent’s event shape is the integration contract. - -Do not rotate webhook secrets casually. Secret rotation immediately invalidates the old secret, so coordinate with the receiving endpoint. - -## Verification checklist - -- [ ] Each proposed profile has a recipient-visible rationale. -- [ ] SMS, WhatsApp, and RCS readiness are tracked separately per profile. -- [ ] US A2P SMS profiles have brand/campaign work routed to compliance before completion. -- [ ] The implementation stores Sent profile IDs and any provider IDs as separate fields. -- [ ] Profile creation/update/complete calls use idempotency keys where retries are possible. -- [ ] Webhook routing is based on Sent event fields or a send-time Sent message ID mapping. -- [ ] Application lifecycle states are not presented as Sent API enum values. -- [ ] Offboarding drains sends, webhooks, credentials, and retained evidence. - -## Related skills +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -Use `sms-10dlc-registration` when a profile needs US A2P SMS brand/campaign registration, opt-in review, or 10DLC troubleshooting. +Status is surface-specific: -Use `waba-embedded-signup` when the architecture includes WhatsApp WABA/phone-number connection or Embedded Signup. +- Create response currently demonstrates lowercase `incomplete`. +- Completion `202` means processing started and does not contain a final status. +- Completion `200` currently demonstrates lowercase `completed` for an already-complete profile. +- Completion callbacks can report `COMPLETED`, `SUBMITTED`, or `failed`. +- REST guides and OpenAPI publish different profile status sets. -Use `rcs-agent-onboarding` when the profile needs RCS approval, launch evidence, or fallback design. +Do not assert a closed REST enum. Preserve unknown strings and record the endpoint/callback surface that produced them. -Use `template-builder-ui` when the architecture decision depends on reusable cross-channel template lifecycle. +## Webhook attribution -Use `messaging-performance-analyzer` after launch when webhook, delivery, or activity evidence shows a performance issue. +Sent events do not contain your application tenant ID. Before sending, persist the returned `message_id` with the tenant and profile. Route outbound status events through that mapping. For inbound messages, map the receiving number/profile resource to the tenant. -Use the `sent` skill for shared Sent terminology and routing. +```text +message_id -> tenant_id, profile_id, logical_send_id, channel +receiving_number -> tenant_id, profile_id +``` -## Suggested bundled references and scripts +Do not infer tenant ownership from `account_id` alone. Multiple tenant profiles can belong to one organization. -| File | Type | Purpose | -|---|---|---| -| `references/multi-tenancy-patterns.md` | Architecture reference | Keep detailed routing, rate-limit, idempotency, and offboarding patterns outside the skill body. | -| `references/sender-profile-data-model.md` | Schema reference | Provide recommended application tables and mapping fields for Sent profile integrations. | -| `references/profile-boundary-examples.md` | Worked examples | Show ISV, marketplace, multi-brand enterprise, and department-level profile splits. | +## Design checklist -## Unverified claims to confirm or remove +- [ ] Tenant/brand isolation decision is explicit. +- [ ] Credential pattern and rate-limit/blast radius are documented. +- [ ] Sharing and inheritance directions are intentional. +- [ ] Billing ownership is named. +- [ ] Number references cannot form cycles. +- [ ] WABA path is organization signup, inheritance, or dedicated credentials—not an invented hybrid. +- [ ] Dedicated brand/campaign paths are profile-based. +- [ ] `message_id` and inbound-number mappings support webhook attribution. +- [ ] Unknown profile statuses are tolerated. +- [ ] Tenant offboarding revokes credentials, disables sends, detaches resources safely, and retains audit evidence. -- API keys are issued per **customer account** (not per profile); `x-sender-id` is per-profile and visible in the dashboard but is v2 legacy for routing — v3 uses `x-api-key` alone. Design key blast-radius around the account, not the profile. -- Sent's profile `status` enum is `incomplete | pending_review | approved | rejected`. If your application tracks finer-grained internal lifecycle states (e.g. `partially_active`, `restricted`, `restoring`), they should not be conflated with the Sent profile `status` field — label them as application states. -- Provider-specific routing keys for WhatsApp/RCS/SMS (e.g., `phone_number_id`, `agentId`, TCR campaign ID) should not be required as primary routing keys unless observed in Sent event payloads or docs. The verified v3 webhook payload exposes `account_id`, `message_id`, `channel`, `inbound_number`, `outbound_number`, `template_id` — route on those plus a `message_id` → profile map persisted at send time. +See [references/sender-profile-data-model.md](references/sender-profile-data-model.md) and [references/profile-boundary-examples.md](references/profile-boundary-examples.md) for implementation patterns. diff --git a/packages/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md b/packages/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md index bc35e13..a00be20 100644 --- a/packages/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md +++ b/packages/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md @@ -1,127 +1,66 @@ -# Multi-Tenancy Patterns for Messaging Apps on Sent — Reference +# Multi-tenancy patterns -Supporting reference for `sender-profile-architect`. Patterns that are *specific to messaging workloads* on Sent — high write volume, webhook fan-in across SMS/WhatsApp/RCS, and the compliance constraints carriers, Meta, and Google impose. Generic multi-tenant SaaS theory is covered exhaustively elsewhere; this doc only captures what changes when SMS, WhatsApp, and RCS run through Sent. +## Preferred: one profile per isolated tenant -## What a Sender Profile owns +Use one organization and one Sender Profile per tenant when tenants have distinct brands, consent evidence, 10DLC campaigns, WABAs, phone numbers, billing, credentials, or incident blast radius. -A Sender Profile is *one tenant's sending identity* across the channels that profile uses. It carries `name`, `description`, `short_name`, `status` (`incomplete | pending_review | approved | rejected`), and a `settings` block of `{default_channel, webhook_url, timezone, language}`. Each channel attaches separately: +Benefits: -- **SMS** — TCR Brand (`/v3/brands`) + at least one Campaign (`/v3/brands/{brandId}/campaigns`), plus one or more phone numbers / short codes. -- **WhatsApp** — Meta WABA + WABA phone numbers (configured via the Sent dashboard / Channels page). -- **RCS** — Google RBM agent (not self-service; via Sent support). +- profile-specific key issuance and revocation; +- explicit organization-key scoping through `x-profile-id`; +- separate inheritance and sharing choices; +- clean tenant offboarding; +- reliable number/profile and message/profile attribution. -A tenant may have multiple profiles (one per brand, region, or use case). Auth is a single account-level `x-api-key`; that key can operate on any profile the account owns. +Organization-key scoping does not create a separate rate-limit pool: the organization's pool remains shared. -## Webhook Routing (the hot path) +## Shared profile -Sent fans channel events into a unified payload shape: +Use a shared profile only if all participants genuinely operate as one sender identity with one brand, resource set, compliance posture, billing/rate-limit expectations, and incident boundary. Lower object count is not sufficient justification. -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} -``` - -Top-level fields: `field`, `sub_type`, `timestamp`, `payload`. `sub_type` follows `.` (e.g., `message.delivered`, `message.failed`, `message.read`). - -Routing back to a Sender Profile uses what your application persisted at send time, joined on stable IDs in the payload: - -| Channel | Verified payload fields | Used to find | -|---|---|---| -| All | `payload.message_id` | the profile that owns this outbound message | -| All | `payload.account_id` | the customer account | -| All | `payload.channel` + `payload.outbound_number` | the configured sender | -| All | `payload.template_id` | the template / its owning profile | - -Narrow webhook subscriptions with `event_filters`: - -```json -"event_filters": { "message": ["delivered", "failed"] } -``` - -Shape: `{: [, ...]}`. Combine with `event_types: ["message"]` to subscribe to the `message` parent and only fire on the listed sub-types. - -ACK fast (≤ webhook `timeout_seconds`, default 30s, max 120s; Sent retries up to `retry_count`, default 3, max 5). Synchronous business logic in the webhook handler kills throughput because three platforms upstream all retry on slow / 5xx responses. - -Two failure modes to design out: - -- **Cold routing key.** A webhook arrives for an `outbound_number` or `template_id` you haven't mapped (the tenant added a number out-of-band, or a template was created in another environment). Log, return 200, alert ops — don't drop the event. -- **Slow routing-key lookup.** Cache the `message_id` → profile mapping aggressively, but back it with durable storage so cold pods resolve correctly. +A shared profile makes tenant credential isolation and webhook attribution application responsibilities. Document that tradeoff. -## Per-Channel Rate-Limit Accounting +## Hybrid inheritance -You account for limits at four layers. Track per-channel; bill at the profile. +Common supported patterns include: -| Source | Limit | Where it comes from | -|---|---|---| -| **Sent — standard endpoints** | 200 req/min, burst 50 | Sent API gateway | -| **Sent — sensitive endpoints** | 10 req/min, burst 5 (e.g., `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/users`, `POST /v3/profiles/{id}/complete`) | Sent API gateway | -| **Sent — message sending tier** | Starter 60/min · Growth 300/min · Enterprise custom | Sent plan tier | -| **Sent — webhook test** | 60/min | Sent API gateway | -| **SMS — TCR campaign TPS** | Per-campaign throughput, assigned after vetting | TCR + carrier reconciliation | -| **WhatsApp — phone-number tier** | 1K / 10K / 100K / unlimited business-initiated conversations per 24h, plus Cloud API CPS | Meta — readable from the phone-number record | -| **RCS — agent QPS** | Google RBM | Google | -| **Your per-profile quota** | Whatever you actually sell | Your billing layer | +| Brand | Campaign | Flags | +| --- | --- | --- | +| Organization brand | Organization campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Organization brand | Dedicated profile campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | +| Dedicated profile brand | Dedicated profile campaign | both false, with `brand` at profile creation | -Rate-limit responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (on 429) `Retry-After`. The error code on 429 is `BUSINESS_002`. +Do not set `brand` while `inherit_tcr_brand` is true. Inherited campaigns are read-only. -Bill against the Sender Profile, not the customer account — an account with three brands gets three meters. +## Credentials -## Idempotency +- A profile key uses only `x-api-key`. +- An organization key may add `x-profile-id` for a child. +- A profile key with `x-profile-id` receives `403`. +- `x-sender-id` is legacy v1/v2 terminology. -Header: `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Cached for **24 hours**, scoped **per customer account**. +Store credentials in a secret manager. Never expose organization keys to tenant-controlled runtimes. -Concurrent requests with the same key → second returns `409 CONFLICT_001`. Replays carry `Idempotent-Replayed: true` and `X-Original-Request-Id: ` headers. +## Webhook fan-out -**Same key + different payload → returns the cached response for the *first* payload, not a new one.** Use a unique key per distinct operation. A safe pattern: hash `(profile_id, operation, client_intent_id)` into the key so a re-send of the same business intent is idempotent but a different intent on the same profile is not. +Outbound events carry `payload.message_id`, not your tenant ID. Persist: -Supported on all profile mutations: -- `POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}` -- `POST /v3/profiles/{id}/complete` - -And on every other mutation endpoint listed in the snapshot (`/v3/messages`, `/v3/contacts`, `/v3/templates`, `/v3/brands`, `/v3/brands/{id}/campaigns`, `/v3/webhooks`, `/v3/users`). - -Sandbox mode (`"sandbox": true` in the body) stacks with idempotency — validates the request, returns a realistic fake response, and caches it for 24 hours. Useful for CI per-tenant smoke tests. - -## Outbound Message Idempotency - -Outbound message sends should also be idempotent on the tuple `(profile_id, channel, client_message_id)` at the application layer. Persist the intent to send *before* the upstream `POST /v3/messages` — if the call succeeds but your write fails, a retry would otherwise duplicate. Pair this with a stable `Idempotency-Key` on the Sent request itself so Sent collapses the duplicate even if your row write reaches Sent first. - -## State Reconciliation (per channel) - -The Profile resource exposes a coarse `status` (`incomplete | pending_review | approved | rejected`). Per-channel readiness (TCR vetting score, WhatsApp messaging tier, RBM launch state) is not in this snapshot — re-fetch from the dashboard or upstream APIs on a schedule: - -- **TCR / SMS** — campaign vetting score updates and carrier-level filtering changes don't always fire webhooks. Reconcile daily for healthy campaigns, hourly when state was recently changing. -- **WhatsApp** — phone-number quality rating and messaging tier change without webhooks. Re-fetch every few hours; alert on transitions. -- **RCS** — agent launch state and carrier rollout status update silently. Re-fetch daily for launched agents and more often during initial verification. - -Track when each channel was last reconciled so dashboards can show how stale each profile is. Don't conflate this internal staleness with Sent's `status` field. - -## Channel-Specific Anti-Patterns +```text +message_id -> tenant_id, profile_id, channel, logical_send_id +``` -- **WhatsApp** — Sharing one Meta System User token across multiple Sender Profiles. Token revocation now disables every profile. -- **SMS** — Reusing a TCR campaign across tenants. The campaign vetting score follows whoever the brand says it is — share at your peril. -- **RCS** — Hardcoding the RBM agent into the application instead of attaching it to a Sender Profile. Multi-region or multi-brand tenants will need multiple agents and the code path forks. -- **All** — Synchronous webhook processing. Throughput dies and the platforms retry aggressively. -- **All** — Claiming "data residency" by application-level filtering when the legal commitment is storage-level isolation. +Inbound events carry destination number/channel information. Persist: -## Tenant Offboarding +```text +channel + destination_number -> tenant_id, profile_id +``` -When a tenant churns, run the per-channel teardown — not just a state flag: +Verify signatures before lookup, deduplicate events, and route unknown mappings to a quarantine queue. Never guess the tenant from organization `account_id`. -- **SMS** — deactivate the TCR campaign(s), release the phone number(s) per Sent's release flow. -- **WhatsApp** — unsubscribe your app from the WABA, revoke the System User token. -- **RCS** — unlaunch / suspend the RBM agent (via Sent support). +## Offboarding -Then `DELETE /v3/profiles/{id}` to soft-delete the profile (use an `Idempotency-Key`). Disable or delete webhook subscriptions that fan into this profile. Schedule message-content deletion per your retention policy. +1. Block new application sends. +2. Revoke profile keys; rotate organization credentials if exposure is possible. +3. Disable or reroute webhooks and number references. +4. Preserve message/profile mappings for retention and disputes. +5. Delete the profile only after resource ownership and compliance retention are resolved. diff --git a/packages/sent/skills/sender-profile-architect/references/profile-boundary-examples.md b/packages/sent/skills/sender-profile-architect/references/profile-boundary-examples.md index c6d8ee0..74efd92 100644 --- a/packages/sent/skills/sender-profile-architect/references/profile-boundary-examples.md +++ b/packages/sent/skills/sender-profile-architect/references/profile-boundary-examples.md @@ -1,111 +1,50 @@ -# Profile Boundary Examples — Reference +# Profile boundary examples -Supporting reference for `sender-profile-architect`. Worked examples for "where should the Sender Profile boundary go?" — the question that determines blast radius, billing granularity, and onboarding pain. Each example covers when the boundary makes sense, when it doesn't, ops considerations, and how the choice ripples into 10DLC registration and WABA setup. +## Marketplace with fifty merchants -The default rule of thumb: **one Sender Profile per legal entity per distinct sending identity**. The examples below are when to break that rule. +Use one organization and one Sender Profile per merchant. Give independently operated merchant runtimes profile keys; keep an organization key only in the trusted control plane. Each merchant can inherit organization templates while owning a dedicated campaign under the inherited organization brand if policy permits. -## What's confirmed in v3 (read before using the examples) +## One enterprise brand with regional teams -- **Auth.** A single account-level `x-api-key` operates on every profile the account owns. There is no per-profile API key in v3. -- **`x-sender-id`.** Visible per-profile in the dashboard (Profiles page maps to `/v3/profiles` endpoints), but is v2 legacy for routing; v3 needs only `x-api-key`. Treat it as a per-profile identifier you can read, not as the auth header. -- **Profile `status`.** The Sent-side enum is `incomplete | pending_review | approved | rejected`. Anything finer-grained ("partially_active", "restricted", "restoring") is an application-level label. -- **Org-level user roles.** Owner / Admin / Billing / Developer. Owner is the only role with billing-ownership transfer rights; the Profile resource's per-call `role` field surfaces `admin | billing | developer` (not Owner) for the authenticated user's role in that profile. -- **10DLC.** Brand and Campaign are first-class Sent resources at `/v3/brands` and `/v3/brands/{brandId}/campaigns` — register them before completing the SMS portion of a profile. +A shared profile may be defensible when every team uses the same legal/consumer brand, consent posture, WABA/numbers, billing, and operations. If a region requires a distinct number, campaign, credential, or incident boundary, split it into its own profile. -## 1. One profile per legal entity (single-brand SaaS) +## Dedicated WABA tenant -A small SaaS with one corporate identity sending password resets, billing reminders, and product nudges from a single sender name. +Create the profile with: -**Picks this when:** all messages legitimately come from the same business, same brand voice, same vetting story. +```json +{ + "name": "Acme Support", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -**Doesn't pick this when:** the company runs multiple consumer brands under one corporate parent (those want separate profiles even if the same lawyer signs both TCR forms). +`phone_number_id` is optional. The access token must be injected from a secure runtime, never included in logs, fixtures, support tickets, or responses. -**Ops:** -- One webhook, one secret to rotate, one set of API keys. Lowest operational overhead. -- Single TCR Brand registration; multiple TCR Campaigns under it for distinct use cases (transactional vs marketing). -- Single WABA, one System User token. WABA quality rating reflects all sending behavior. -- Billing is a single line item — easy for finance, hard to attribute internally if multiple product teams share the profile. +## Organization WABA inheritance -**10DLC:** one Brand, multiple Campaigns. If transactional and marketing share one Campaign, carrier filtering will be harsher than necessary — split them. +After organization Embedded Signup is complete, omit `whatsapp_business_account` on the child profile. Omitting it without an organization WABA returns `422`. This is inheritance, not an API-started Embedded Signup flow. -**WABA:** one WABA, one phone number to start; tier upgrades benefit everything sent through this profile. +## Dedicated 10DLC brand -## 2. One profile per channel (WhatsApp-only vs SMS-fallback chains) +Set `inherit_tcr_brand: false` and include `brand` with `POST /v3/profiles`. Create campaigns through `/v3/profiles/{profileId}/campaigns`. Do not create a free-standing brand resource. -A tenant explicitly wants channel isolation — e.g. a WhatsApp-only consumer brand whose ops team should never accidentally send SMS, or a region where RCS is the primary channel with SMS strictly as fallback. +## Shared SMS number reference -**Picks this when:** legal, compliance, or product policy requires that channel-by-channel sending be controllable independently and visible independently in billing. +Use `sending_phone_number_profile_id` when a profile intentionally reuses another profile's SMS configuration. Record the source profile and prevent circular references. A direct `sending_phone_number` is a different mode and should not be conflated with the profile reference. -**Doesn't pick this when:** the channels are genuinely interchangeable for the same user journey. Sent's whole point is unifying sending — splitting profiles by channel often duplicates work without adding isolation. +## Webhook routing -**Ops:** -- Multiple webhook subscriptions, multiple secrets. Secret-rotation blast radius is smaller per channel, larger in aggregate. -- The "fallback" pattern (try WhatsApp, fall back to SMS) becomes an application-level orchestration across two profiles instead of one. Manageable, but inbound replies on the SMS profile won't carry the original WhatsApp `wamid` context — your app has to stitch threads. -- Cost tracking is cleanly per-channel. +When the send response returns message IDs, write all of them before treating the operation as accepted: -**10DLC:** the SMS-only profile carries the TCR registration; the WhatsApp-only profile has none. Don't register TCR for a profile that won't send SMS. +```text +message A -> tenant 42, profile P42, channel sms +message B -> tenant 42, profile P42, channel whatsapp +``` -**WABA:** the WhatsApp profile carries the WABA. Quality scoring is isolated — a bad SMS campaign won't pull WhatsApp tier down. - -## 3. Per-department profiles (sales, support, marketing) on the same brand - -A mid-sized company wants Sales, Support, and Marketing to send under the same overall brand but with different sender names, different vetting stories, and different billing meters. - -**Picks this when:** internal billing attribution matters (Marketing's budget is separate from Support's), or each department's send volume / patterns are different enough that mixing them would hurt vetting (Marketing's bulk sends would tank Support's quality rating). - -**Doesn't pick this when:** the departments truly send identical-looking traffic under one external brand. Splitting buys complexity without changing what carriers see. - -**Ops:** -- Three webhooks (or one webhook routing on `payload` fields), three sets of templates — but one shared account-level `x-api-key`. Reusable copy (e.g. WhatsApp templates) has to be authored per profile or copied between them. -- Quota / rate-limit accounting is per profile — Marketing can be throttled without affecting Support. -- Suspension blast radius is per profile — a Meta quality drop on Marketing doesn't pause Support. - -**10DLC:** typically one TCR Brand (same legal entity) but separate Campaigns per department's use case. Some tenants register multiple Brands if Marketing operates as a distinct legal entity. - -**WABA:** can be one WABA with multiple phone numbers split across profiles, or one WABA per profile. One-WABA-multiple-profiles complicates Sent-side modeling (each profile attaches to the same WABA); separate WABAs are cleaner if the departments genuinely want isolation. - -## 4. Per-tenant profile in a B2B2C platform (one Sent customer hosting many merchants) - -A platform — appointment-booking SaaS, e-commerce host, marketing platform — has one Sent contract but serves hundreds of downstream merchants who each need their own sender identity. - -**Picks this when:** each merchant is a distinct end-business that needs to appear as themselves to recipients, comply with TCR / Meta independently, and have their own billing meter. - -**Doesn't pick this when:** all merchants legitimately send "from" the platform brand. Then one profile is correct and merchant attribution is an internal concern. - -**Ops:** -- Profile provisioning is part of the merchant onboarding flow — this is where the WABA Embedded Signup (`waba-embedded-signup`) and 10DLC registration (`sms-10dlc-registration`) skills get invoked hundreds of times. -- Webhook fan-in: one Sent webhook per profile is unmanageable at hundreds of profiles. Either configure all profiles to one webhook URL and route on payload fields (`payload.account_id`, `payload.outbound_number`, `payload.template_id`, plus a `message_id`-to-profile map you keep at send time), or run per-profile webhooks behind a routing layer. The single-URL pattern has one secret to rotate and a larger blast radius. -- API-key blast radius: v3 issues a single account-level `x-api-key`, so a compromised key affects every merchant under that account. If per-merchant key isolation is a requirement, give each merchant its own Sent customer account (not just its own profile) — that's the only boundary that produces a distinct API key today. -- Billing: per-merchant meters fall out of per-profile accounting cleanly. - -**10DLC:** each merchant is its own TCR Brand and Campaign. The platform does not register *its own* Brand on behalf of merchants — the merchant signs. Plan for per-merchant TCR vetting time (days, not seconds). - -**WABA:** each merchant goes through Embedded Signup to attach their own WABA. The platform is the Tech Provider on Meta's side. Plan for the support burden — Meta's "phone number already in use" errors land on the platform. - -## 5. Per-region / per-geo profiles - -A business serving multiple regions wants to honor local regulations, language defaults, and regulator-specific sender identities (e.g. EU brand vs US brand vs LATAM brand). - -**Picks this when:** regions have genuinely different regulators (US 10DLC + Brazil's regs + EU's WhatsApp rules), different timezones for send-window enforcement, or different localized sender display names. - -**Doesn't pick this when:** the regional split is only a marketing convenience and all sending is from one legal entity with one global compliance posture. - -**Ops:** -- Per-region webhooks make per-region failover and on-call rotation straightforward. -- Per-region rate limits avoid one region's burst exhausting another's budget. -- Per-region secret rotation contains blast radius geographically. -- Cross-region analytics need a join layer — the per-profile billing/usage data has to be aggregated for the global view. - -**10DLC:** US-only concept. The US-region profile is the one with TCR; non-US profiles ignore TCR entirely. Don't try to register a non-US brand with TCR "for completeness". - -**WABA:** WABAs can serve global recipients, but pricing tiers and template approval workflows differ by recipient country, not by WABA region. Per-region WABAs typically map to per-region Meta Business Manager assets and per-region System User tokens — that's the operational reason to split, not regulatory. - -## Decision heuristic - -When you're not sure whether to split, ask in order: - -1. **Are these messages legally from the same entity?** No → split. -2. **Will one identity's bad behavior unfairly impact another's quality rating / TCR vetting?** Yes → split. -3. **Does anyone need to see per-X billing or rate limiting (X = brand, department, merchant, region)?** Yes → split. -4. **Do these channels actually share a user journey or are they being kept apart for ops reasons?** Kept apart → split. -5. **Otherwise:** one profile. Splits are cheap to add later; merges are not. +Multiple explicit channels create multiple messages. Route each webhook by `message_id`; do not expect an application tenant ID in the event. diff --git a/packages/sent/skills/sender-profile-architect/references/sender-profile-data-model.md b/packages/sent/skills/sender-profile-architect/references/sender-profile-data-model.md index d416557..91e3120 100644 --- a/packages/sent/skills/sender-profile-architect/references/sender-profile-data-model.md +++ b/packages/sent/skills/sender-profile-architect/references/sender-profile-data-model.md @@ -1,159 +1,98 @@ -# Sender Profile Data Model — Reference - -Supporting reference for `sender-profile-architect`. Captures the conceptual model Sent exposes for a Sender Profile and the resources that attach to it, as verified against the Sent v3 docs snapshot. - -This doc is **conceptual**, not a schema dump. Resource shapes evolve; the relationships below are what stabilize them. - -## Authentication context - -In v3, authentication is header-only with a single key per account: - -```http -x-api-key: +# Sender Profile data model + +## Core records + +```text +organization + id + organization_key_secret_ref + +tenant + id + organization_id + sent_profile_id + profile_key_secret_ref + credential_pattern + billing_model + +profile_policy + profile_id + allow_contact_sharing + allow_template_sharing + inherit_contacts + inherit_templates + inherit_tcr_brand + inherit_tcr_campaign + +channel_binding + profile_id + channel + direct_number + source_profile_id + waba_id + +message_attribution + message_id + logical_send_id + tenant_id + profile_id + channel + +inbound_route + channel + destination_number + tenant_id + profile_id ``` -The dashboard exposes a per-profile `x-sender-id` value for inspection and v2 legacy routing, but v3 API auth needs only `x-api-key`. API keys are issued at the **customer account** level, not per profile — a single API key can list, retrieve, create, update, complete, or delete any profile the account owns via `/v3/profiles*` endpoints. +`source_profile_id` models `sending_phone_number_profile_id` and `sending_whatsapp_number_profile_id`. Enforce referential integrity and prevent cycles. -## Sender Profile resource (verified) +## Profile request fields -The verified Profile model in Sent v3: +Create supports identity, sharing/inheritance, billing, dedicated WABA credentials, and a profile-owned `brand`. Update additionally supports number reference/direct-number fields and onboarding number-change policy. -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | Stable, Sent-issued | -| `name` | string | Display name | -| `icon` | string \| null | URL | -| `description` | string \| null | Human-readable description | -| `short_name` | string \| null | Compact label | -| `role` | `admin` \| `billing` \| `developer` \| null | The calling user's role within this profile | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status — these are the **only** Sent-side enum values | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | +Brand request fields are grouped into: -Note: `role` is the *calling user's* role in this profile, not a property of the profile itself. The org-level role enum (Owner / Admin / Billing / Developer) is the full set; the field omits `Owner` because that's billing-owner-only. +- `contact`: representative and business-facing contact data; +- `business`: legal identity, tax/entity type, address, country, URL; +- `compliance`: vertical, brand relationship, primary use case, TCR flag, number prefix, destination countries, notes. -## Sender Profile -> Channels -> Webhooks +Treat request camelCase inside `brand` separately from snake_case response fields. Do not round-trip by blindly serializing a response object as a create request. -``` -Customer account (one x-api-key) - │ - ├── SenderProfile (1..N) - │ ├── id, name, short_name, description, icon - │ ├── status: incomplete | pending_review | approved | rejected - │ ├── role (caller's role: admin | billing | developer) - │ ├── settings.default_channel ── routes if /v3/messages omits "channel" - │ ├── settings.webhook_url ── per-profile webhook destination - │ ├── settings.timezone, .language - │ │ - │ └── Channels (attached via dashboard / compliance flow) - │ ├── SMS → Brand (TCR) → Campaign(s) - │ ├── WhatsApp → WABA + phone number(s) - │ └── RCS → RBM agent (via Sent support) - │ - └── Webhooks (configured separately; can be per-profile or shared) - ├── id, display_name, endpoint_url, is_active - ├── event_types: ["message", "templates", ...] - ├── event_filters: {: [, ...]} - ├── signing_secret, retry_count (1-5, default 3), - │ timeout_seconds (5-120, default 30) - └── last_delivery_attempt_at, last_successful_delivery_at, - consecutive_failures -``` +## Authentication invariant -Webhook payloads have the shape: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} +```text +profile key -> x-api-key only +organization key -> x-api-key + optional x-profile-id ``` -`payload.account_id` is the customer-account-level UUID. The payload does not currently include a top-level `profile_id`; routing back to a profile relies on the channel/numbers/template recorded at send time. - -## How channels attach to a profile - -Channels are configured per-profile through the Sent dashboard and compliance flow. Each channel attachment carries the regulator-facing identity for that channel: - -| Channel | Attached identity | Sent endpoints | -|---|---|---| -| SMS | A TCR-registered brand + at least one campaign, plus phone numbers / short codes | `/v3/brands`, `/v3/brands/{brandId}/campaigns` | -| WhatsApp | A Meta WABA + one or more WABA phone numbers (linked through Sent's Channels / WABA Embedded Signup flow) | Dashboard-only configuration in v3 | -| RCS | A Google RBM agent (not self-service; initiated via `support@sent.dm`) | Dashboard-only configuration in v3 | - -Two consequences fall out of this model: +Only the organization pattern may include `x-profile-id`. Rate limits for organization-scoped requests remain in the organization pool. -1. **Provisioning is per-channel and largely dashboard-driven.** A profile's `status` (`incomplete | pending_review | approved | rejected`) is the rollup; per-channel readiness is tracked alongside it but not in the verified Profile resource. If your application tracks finer-grained channel readiness it should label those as internal app states, not Sent enum values. -2. **Channel identifiers are the inbound routing keys.** The data model is what makes "look up the Sender Profile from an inbound phone number / WABA / RBM agent" possible — but in v3 those joins are made on data you persist at send time, not on a `profile_id` carried in the inbound webhook payload. +## Campaign ownership -## Brands and Campaigns (SMS / 10DLC) +Campaigns belong to the brand selected through a profile but are operated through profile paths: -The word *brand* shows up at two layers — keep them distinct: - -- **Profile metadata** — the `name`, `description`, and `short_name` on the Sender Profile, used for display and as default sender identity. -- **TCR Brand** — the legal-entity record registered with The Campaign Registry, mandatory for US 10DLC SMS. Sent exposes these as first-class resources: - - `GET /v3/brands`, `POST /v3/brands`, `PUT /v3/brands/{brandId}`, `DELETE /v3/brands/{brandId}` - - `POST /v3/brands/{brandId}/campaigns`, `PUT /v3/brands/{brandId}/campaigns/{id}`, `DELETE /v3/brands/{brandId}/campaigns/{id}` - -``` -SenderProfile (Sent) - └── SMS channel - └── TCR Brand (1) - └── TCR Campaign (1..N) ─── carrier filtering happens here +```text +/v3/profiles/{profileId}/campaigns +/v3/profiles/{profileId}/campaigns/{campaignId} ``` -For 10DLC details and use-case selection, see `sms-10dlc-registration`. - -WhatsApp has no direct analogue of TCR Campaigns — per-template approval (via `/v3/templates`) plays a similar role. RCS uses one RBM agent per profile per region. - -## Webhooks - -Webhooks are configured at the account level and can be scoped to a profile via `settings.webhook_url`, or you can configure a single webhook URL and route on the payload (`payload.account_id`, `payload.channel`, `payload.outbound_number`, `payload.template_id`). +An inherited brand can have profile-owned campaigns when campaign inheritance is disabled. -Key webhook config fields: +## Status storage -- `event_types` — list of parent event types subscribed (e.g., `["message"]`, `["message", "templates"]`). -- `event_filters` — narrow within a parent type: `{"message": ["delivered", "failed"]}` only fires for `message.delivered` and `message.failed`. -- `signing_secret` — rotate via `POST /v3/webhooks/{id}/rotate-secret` (sensitive endpoint: 10 req/min limit). Rotation invalidates the old secret immediately. -- `retry_count` 1-5 (default 3), `timeout_seconds` 5-120 (default 30) — Sent will retry up to `retry_count` times if your endpoint times out or 5xxs. -- `consecutive_failures` — surfaced so you can monitor a failing webhook before it's auto-disabled. - -## Idempotency - -`POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}`, and `POST /v3/profiles/{id}/complete` all support `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Keys are cached **per customer account for 24 hours**. See `references/multi-tenancy-patterns.md` for the full idempotency/sandbox behavior across endpoints. - -## ER summary +Store at least: +```text +profile_id +status_raw +status_surface # create_response, rest_profile, completion_200, completion_callback +observed_at +payload_version ``` -Customer account ── x-api-key (one per account) - │ 1..N - ▼ -SenderProfile { id, name, status, role, settings } - │ - ├── Brand (TCR) ── Campaign(s) ── carrier-filtered SMS senders - ├── WABA ── PhoneNumber(s) (configured via dashboard) - └── RBM Agent (configured via Sent support) - -Webhooks (separate resource; can be many-to-one with profiles) - └── event_types[], event_filters{}, signing_secret, retry/timeout config -``` - -The single architectural rule that follows: every channel-specific record your application stores should reference the Sent **profile `id`**, not the customer `account_id` directly. Account is a foreign key on profile. -## What to confirm against the live OpenAPI +Known examples vary in case and vocabulary. Do not normalize unknown values into a closed enum. -Before writing application code, confirm the endpoint and schema details against the live Sent v3 OpenAPI at https://docs.sent.dm: +## Secret boundaries -- Exact channel-attachment fields on the Profile resource (the snapshot doesn't enumerate per-channel sub-objects). -- Whether `payload.profile_id` is added to webhooks in future API revisions. -- API-key creation + scope semantics (API Keys is dashboard-only in this snapshot). +WABA `access_token` and payment card fields are write-only operational secrets. Never log, echo, or persist raw values in profile records. Keep only secret-manager references and non-sensitive identifiers such as `waba_id`. diff --git a/packages/sent/skills/sms-10dlc-registration/SKILL.md b/packages/sent/skills/sms-10dlc-registration/SKILL.md index fd26358..365004d 100644 --- a/packages/sent/skills/sms-10dlc-registration/SKILL.md +++ b/packages/sent/skills/sms-10dlc-registration/SKILL.md @@ -1,165 +1,108 @@ --- name: sms-10dlc-registration -description: Prepares Sent US A2P SMS 10DLC compliance by collecting business, opt-in, brand, campaign, sample-message, and profile-completion evidence. Use when a user says 10DLC, A2P, TCR, campaign registry, brand vetting, SMS compliance, US texting, long code registration, opt-in proof, opt-out language, carrier filtering, or needs to register SMS through Sent. +description: Prepares and validates Sent US A2P 10DLC brand and campaign registration through Sender Profiles, including inheritance, all campaign use cases, opt-in evidence, sample-message policy, autoresponses, sandbox validation, TCR status, and rejection remediation. --- - +Use this skill for US A2P SMS over 10-digit long codes. Separate the compliance evidence packet from the exact Sent API request; they have different schemas and validators. -# SMS 10DLC registration +## Current Sent resource model -## Overview +There is no standalone brand CRUD path in the current v3 API. -Use this skill to prepare US A2P SMS compliance for Sent. Sent’s compliance documentation states that compliance is a prerequisite for sending messages and that 10DLC registration is mandatory for A2P messaging to US numbers. Sent handles TCR registration as part of the compliance process, while the customer must provide accurate business identity, consent, use-case, sample-message, and opt-out evidence. +- Create a dedicated brand inside `POST /v3/profiles` using `brand` and `inherit_tcr_brand: false`. +- List/create campaigns with `GET|POST /v3/profiles/{profileId}/campaigns`. +- Update/delete with `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. -The Sent v3 API exposes Sent-facing brand and campaign resources through `/v3/brands` and `/v3/brands/{brandId}/campaigns`. Profile completion through `/v3/profiles/{profileId}/complete` validates profile, brand, and campaign prerequisites before the profile is ready. +Reject guidance that reintroduces a free-standing brand path. -## When to use +## Choose inheritance deliberately -Use this skill when the request mentions 10DLC, A2P, TCR, brand registration, campaign registration, SMS compliance, US long code, EIN, opt-in proof, sample messages, opt-out, HELP/STOP language, vetting, rejected campaign, or carrier filtering caused by compliance. Use it before enabling US SMS sending or SMS fallback for RCS. +| Brand | Campaign | Settings | +| --- | --- | --- | +| Inherit both | Organization brand and campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Inherit brand, own campaign | Shared legal brand with tenant-specific traffic | brand true, campaign false | +| Own both | Dedicated tenant/business | both false and supply `brand` during profile creation | -Do not use this skill for non-US country compliance unless the user supplies a Sent compliance source for that country. Do not use it to analyze live delivery failures except to identify whether compliance status is the likely next check. +Inherited campaigns are read-only. A profile cannot supply `brand` while brand inheritance is true. -## Required evidence +## Two validation layers -Collect evidence before creating or updating Sent brand/campaign resources. Bad evidence creates review loops and downstream filtering risk. +### Evidence readiness packet -| Evidence | What to capture | Sent-grounded reason | -|---|---|---| -| Legal business identity | Legal business name, address, EIN/tax ID, entity type | Sent’s compliance guide lists these as required inputs. | -| Public web presence | Live website URL and privacy policy URL | Sent requires a live website and privacy policy for compliance review. | -| Opt-in mechanism | URL, screenshot, form text, checkbox language, or checkout flow | Sent requires an opt-in mechanism URL. | -| Use-case description | Clear description of what messages are sent and why | Sent requires use-case description. | -| Sample messages | Realistic messages matching the declared use case | Sent requires sample messages. | -| Opt-out instructions | STOP/HELP or equivalent instructions where applicable | Sent requires opt-out instructions. | -| Sender Profile | Sent profile ID or dashboard profile being completed | Profile completion validates compliance prerequisites. | +The private packet uses the explicit internal version `sent-10dlc-evidence/v1` and snake_case evidence fields. It is not an API payload. -## Process +```bash +python scripts/validate_10dlc_packet.py evidence.json +``` -### 1. Decide whether this is US A2P SMS +Collect legal identity, public website/policy links, consent proof, message flow, opt-in/opt-out/help responses and keywords, use cases, and realistic samples. See [references/10dlc-evidence-checklist.md](references/10dlc-evidence-checklist.md). -Confirm destination country, traffic type, and sender type. This skill applies to US A2P SMS over 10DLC. If the user is sending only WhatsApp, RCS without SMS fallback, short code, toll-free, or non-US traffic, document the difference and route to the appropriate compliance workflow. +### Sent campaign request -**Example.** “We send appointment reminders from a SaaS platform to US patients using local long-code numbers” is US A2P SMS and needs 10DLC. “We send only WhatsApp utility templates” is not a 10DLC workflow, though WhatsApp has its own template and business requirements. +The API request uses exact camelCase and a `campaign` wrapper: -### 2. Normalize the business identity + +```json +{ + "campaign": { + "name": "Acme account notifications", + "description": "Account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in in account settings before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} +``` -Use the exact legal business name and tax ID records. Do not “clean up” the name to a marketing brand if the tax record uses another legal entity. Mismatches between legal identity, website, and opt-in flow are common rejection causes. +Validate it with: -If the customer is an ISV registering many customers, decide whether each customer needs its own profile/brand/campaign boundary with `sender-profile-architect`. Do not put unrelated customers under one brand because it is faster. +```bash +python scripts/validate_campaign_payload.py campaign.json +``` -### 3. Classify the campaign by use case +## API use cases -Pick the narrowest truthful campaign use case. Mixed-use campaigns can be valid, but they invite broader review and more filtering risk if the sample messages do not match the declared intent. +Support all 13 current values: -| Declared intent | Better sample | Bad sample | -|---|---|---| -| Account notification | “Acme: Your password was changed. If this was not you, visit https://acme.example/security. Reply STOP to opt out.” | “Huge sale today. Click now.” | -| Delivery notification | “Acme: Order 1234 is out for delivery today. Track: https://acme.example/t/1234. Reply STOP to opt out.” | “Your package is coming. Also buy these add-ons.” | -| Customer care | “Acme Support: We received your request and will respond shortly. Reply STOP to opt out.” | “Thanks for contacting us. Get 20% off now.” | -| Marketing | “Acme: Spring sale starts today. Use code SPRING. Reply STOP to opt out.” | Transactional description with promotional samples. | +`MARKETING`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `FRAUD_ALERT`, `TWO_FA`, `DELIVERY_NOTIFICATION`, `SECURITY_ALERT`, `M2M`, `MIXED`, `HIGHER_EDUCATION`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, and `LOW_VOLUME`. -Keep detailed TCR taxonomy and carrier-specific advice in a reference file. In the skill body, use only enough taxonomy to keep the submission honest. +Each use case structurally accepts 1–5 samples, each no longer than 1,024 characters. The compliance layer requires at least two samples for marketing and mixed traffic, including low-volume mixed. Keep that policy distinction visible instead of pretending OpenAPI requires two for all traffic. -### 4. Create or update Sent brand resources +## Volume and status -Use Sent’s brand endpoints when API work is in scope. The verified v3 API includes: +`volume` is optional and, when supplied, is a numeric string. Values below `"2000"` use the documented low-volume tier; `"2000"` is the boundary to the next tier. -| Operation | Endpoint | Notes | -|---|---|---| -| Create brand | `POST /v3/brands` | Creates a new brand and associated information. | -| List brands | `GET /v3/brands` | Retrieves brands for the authenticated customer, including inherited brands where applicable. | -| Update brand | `PUT /v3/brands/{brandId}` | Cannot update brands already submitted to TCR or inherited brands. | -| Delete brand | `DELETE /v3/brands/{brandId}` | Deletes a brand that belongs to the authenticated customer. | +Campaign responses currently expose statuses `SENT_CREATED`, `ACTIVE`, and `EXPIRED`, plus `submittedToTCR`. Preserve unknown future status strings. Do not confuse a successful Sent record creation with TCR submission or carrier activation. -Use optional `Idempotency-Key` headers on create/update calls when retrying. Store the Sent brand ID returned by the API. Store any returned TCR identifiers separately only if the API response exposes them. +## Safe workflow -### 5. Create or update Sent campaign resources +1. Confirm this is US A2P 10DLC traffic and the actual sending business is identified. +2. Select brand/campaign inheritance. +3. Validate the versioned evidence packet. +4. Create or confirm the profile brand. +5. Translate evidence into the exact camelCase campaign request. +6. Validate locally and use `sandbox: true`. +7. Show the payload and obtain confirmation before a real create/update/delete. +8. Store profile ID, campaign ID, `submittedToTCR`, raw status, and review evidence. +9. Complete the profile with required `webHookUrl` only after prerequisites are ready. -Create campaigns under the relevant Sent brand. The verified v3 API says each campaign must include at least one use case with sample messages. - -| Operation | Endpoint | Notes | -|---|---|---| -| Create campaign | `POST /v3/brands/{brandId}/campaigns` | Links the campaign to the brand and requires use-case/sample-message data. | -| List campaigns | `GET /v3/brands/{brandId}/campaigns` | Retrieves campaigns and their use cases/sample messages. | -| Update campaign | `PUT /v3/brands/{brandId}/campaigns/{campaignId}` | Cannot update campaigns already submitted to TCR. | -| Delete campaign | `DELETE /v3/brands/{brandId}/campaigns/{campaignId}` | Deletes a campaign within the brand. | - -Do not claim a public `tcr_campaign_id` field unless the actual response includes it. Refer to the Sent campaign ID for Sent API operations. - -### 6. Complete the Sender Profile setup - -After profile data, brand, and campaign prerequisites are ready, call or trigger profile completion through `POST /v3/profiles/{profileId}/complete`. The OpenAPI describes this as the final step in the profile compliance workflow, validating prerequisites and connecting profile configuration in the background. - -If completion fails, fix the missing prerequisite rather than creating duplicate brands or campaigns. Duplicate compliance objects increase confusion and can lead to sending from the wrong profile. - -### 7. Prepare the review-ready submission summary - -End the workflow with a compact summary the user can paste into Sent support, a dashboard form, or an internal ticket. Include legal identity, website, privacy policy, opt-in URL/evidence, use-case description, sample messages, opt-out instructions, Sent profile ID, Sent brand ID, Sent campaign ID, and any unresolved questions. - -**Example summary.** - -> “Acme Logistics LLC, EIN ending 1234, sends US SMS delivery notifications to customers who opt in at checkout. Website and privacy policy are live. Opt-in screenshot and URL are attached. Campaign use case is delivery notification. Sample messages match shipment status only and include opt-out instructions. Sent profile `...`, Sent brand `...`, Sent campaign `...` are ready for completion.” - -## Common rationalizations to avoid - -Do not register a marketing campaign as a utility or account-notification campaign because it may be cheaper or easier. The samples, opt-in flow, and actual traffic must match. - -Do not submit placeholder websites, private staging URLs, or missing privacy policies. Sent’s compliance guide calls for live URLs. - -Do not reuse one brand/campaign for unrelated customers. Compliance belongs to the sender and use case, not just the platform sending the API call. - -Do not edit a submitted brand or campaign in place if the API says submitted objects cannot be updated. Create the right correction path with Sent. - -Do not promise exact approval times beyond Sent’s guidance. Sent says TCR registration typically completes within 3 to 7 business days after the Sent compliance form is approved, with additional propagation time possible. - -## Verification checklist - -- [ ] The traffic is confirmed as US A2P SMS over a long-code route. -- [ ] Legal business identity matches tax and website evidence. -- [ ] Website and privacy policy URLs are live. -- [ ] Opt-in evidence is concrete and matches the declared use case. -- [ ] Sample messages are realistic and match the use case. -- [ ] Opt-out instructions are included where applicable and consistent with the user experience. -- [ ] Sent brand and campaign IDs are stored separately from any provider/TCR identifiers. -- [ ] Profile completion is run only after profile, brand, and campaign prerequisites are ready. -- [ ] Unverified throughput, carrier, or pricing claims are not presented as Sent facts. - -## Related skills - -Use `sender-profile-architect` when deciding whether brands, tenants, departments, or use cases need separate Sender Profiles. - -Use `rcs-agent-onboarding` when 10DLC work is needed for SMS fallback from RCS. - -Use `messaging-performance-analyzer` when registered traffic still shows delivery failures or filtering symptoms. - -Use `template-builder-ui` when the customer needs reusable SMS template copy that matches the registered use case. - -Use the `sent` skill for shared Sent terminology and routing. - -## Bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/tcr-use-cases.md` | Lookup table | TCR use-case taxonomy, sample-message patterns, and rejection reasons. | -| `references/10dlc-evidence-checklist.md` | Worked example | Field-by-field checklist for Sent's 10DLC compliance form. | -| `references/10dlc-rejection-remediation.md` | Decision matrix | Common TCR / carrier rejection codes mapped to fix steps and re-submission etiquette. | -| `scripts/validate_10dlc_packet.py` | Validation script | Pre-flight validator for a packet JSON. Run from the skill root: `python scripts/validate_10dlc_packet.py packet.json`. | -| `scripts/fixtures/good.json` | Fixture | Complete valid packet (passes validator). | -| `scripts/fixtures/bad.json` | Fixture | Packet with missing fields / invalid EIN / short sample (validator exits non-zero). | - -## Unverified claims to confirm or remove - -- Sent's `/v3/brands` and `/v3/brands/{id}/campaigns` endpoints exist; their internal mapping to TCR identifiers is opaque to the customer. Store the Sent brand and campaign IDs returned by the API — don't claim a public `tcr_brand_id` or `tcr_campaign_id` field unless an API response surfaces it. -- Exact throughput limits, per-carrier caps, and vetting-score-to-throughput mapping are not in Sent's docs. The snapshot only confirms account-wide tier limits (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom) — these are not TCR / carrier per-campaign throughput numbers. -- Country-specific compliance, routing, and pricing claims beyond Sent's listed country-specific document requirements (AU, BE, PL, ZA, SE, TH, UK) require a current Sent source. +Never use real consumer data in fixtures or samples. Use [references/tcr-use-cases.md](references/tcr-use-cases.md) for classification and [references/10dlc-rejection-remediation.md](references/10dlc-rejection-remediation.md) for failures. diff --git a/packages/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md b/packages/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md index e848027..ddaf6b9 100644 --- a/packages/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md +++ b/packages/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md @@ -1,140 +1,46 @@ -# 10DLC Evidence Checklist — Sent Compliance Form +# 10DLC evidence checklist -Supporting reference for `sms-10dlc-registration`. This is the definitive list of fields a tenant must supply on Sent's compliance form before Sent files the TCR brand + campaign on their behalf. Field names below match the verified compliance form in the Sent dashboard. The deeper TCR payload (`Brand.entityName`, `Campaign.privacyPolicyLink`, etc.) is filed by Sent internally — keep that taxonomy in `references/tcr-use-cases.md`. +The evidence packet is an internal readiness artifact with `schema_version: "sent-10dlc-evidence/v1"`. Its snake_case fields are not the Sent API contract. -Use this alongside `references/tcr-use-cases.md` when authoring the form, validating an incoming packet, or debugging why a customer's submission failed pre-flight checks. Run `scripts/validate_10dlc_packet.py` against a JSON dump of the packet for the mechanical checks. +## Business identity -## Required fields — business identity +- [ ] Legal business name and EIN match registration records. +- [ ] Public website represents the same recognizable brand. +- [ ] Physical address, business phone, and compliance contact are current. +- [ ] Privacy policy and terms links are public HTTPS pages. -These appear on the KYC + compliance pages and are required for every tenant regardless of country. +## Consent -### 1. Legal business name +- [ ] Opt-in method is named: web form, keyword, paper, or spoken consent. +- [ ] Proof URL or equivalent evidence is accessible to a reviewer. +- [ ] Consent text names the brand and message type. +- [ ] The checkbox is not preselected. +- [ ] Frequency, message/data-rate notice, HELP, and STOP disclosures are present where required. +- [ ] Marketing spoken consent includes the additional confirmation required by policy. -- **Format:** Exact name as registered with the state or national business registry. Include the suffix (`LLC`, `Inc.`, `Corp.`, `LLP`). -- **Common mistakes:** - - Submitting a DBA, marketing brand, or trade name instead of the legal entity. Sent will reject if the EIN/tax-ID-to-name match fails at the issuer lookup step. - - Trailing punctuation (`Acme, Inc.,`) — strip the trailing comma. - - Mismatched capitalization vs registry records. -- **Sent tip:** If the customer uses a different consumer-facing brand, that goes in campaign sample messages, not here. +## Message flow -### 2. Business registration number +Describe the sequence from consumer action through confirmation and recurring messages. A link alone is not a message flow. -- **Format:** The jurisdiction's company / corporate registration number (e.g. state filing number in the US, Companies House number in the UK). -- **Common mistakes:** - - Confusing this with the EIN / tax ID — they're separate fields. - - Submitting the partner / member number instead of the entity's filing number. +## Autoresponses -### 3. Business type / structure +- [ ] `optinMessage` names the brand and explains HELP/STOP, frequency, and rates where required. +- [ ] `optoutMessage` confirms unsubscribe and no further messages. +- [ ] `helpMessage` names the brand and provides a support method. +- [ ] `optinKeywords`, `optoutKeywords`, and `helpKeywords` are documented. +- [ ] STOP is in opt-out keywords and HELP is in help keywords. -- **Format:** One of the dashboard's entity-type options (Private Profit, Public Profit, Non-Profit, Government, Sole Proprietor, etc.). -- **Common mistakes:** - - Sole proprietors marking themselves as Private Profit. Sole-prop senders are subject to additional restrictions and Sent must know. - - Non-profits marking themselves as Private Profit to avoid extra documentation. +## Campaign translation -### 4. Industry category +Map evidence into these Sent camelCase fields only at API serialization time: -- **Format:** Pick the closest match from the dropdown. Drives review path and downstream carrier filtering posture. -- **Common mistakes:** - - Picking "Other" when a specific match exists. - - Misclassifying regulated industries (health, finance, gambling) as general retail. +```text +message_flow -> messageFlow +privacy_policy_url -> privacyPolicyLink +terms_and_conditions_url -> termsAndConditionsLink +autoresponses.optinMessage -> optinMessage +use_cases[].messaging_use_case_us -> useCases[].messagingUseCaseUs +use_cases[].sample_messages -> useCases[].sampleMessages +``` -### 5. EIN / tax ID - -- **Format:** Federal tax ID for the country. US EIN is nine digits, optionally hyphenated after the first two: `12-3456789` or `123456789`. Regex: `^\d{2}-?\d{7}$`. -- **Common mistakes:** - - Confusing EIN with SSN (sole proprietors). Sole props with no EIN should pick the sole-proprietor entity type — see `references/tcr-use-cases.md`. - - Submitting a state tax ID instead of the federal EIN. - - Typos in the first two digits (the IRS prefix). These fail the IRS match every time. -- **Non-US tenants:** Use the equivalent national tax ID (e.g. VAT number, ABN, GST registration). - -### 6. Business address - -- **Format:** Street, city, state / region, postal code, country (ISO 3166-1 alpha-2). PO boxes are not accepted for primary brand address. -- **Common mistakes:** - - Using a mail-forwarding or virtual-office address that doesn't match the address on file with the tax authority for the EIN. - - Country code mismatches (`USA` vs `US`). - -### 7. Business phone number - -- **Format:** Full E.164 phone number reachable for compliance contact. -- **Common mistakes:** - - Submitting a number that goes to a marketing IVR with no path to a human. - - Submitting a personal mobile when the tenant is a registered entity. - -### 8. Contact email - -- **Format:** Monitored inbox for compliance correspondence. -- **Common mistakes:** - - Submitting `noreply@…`. Sent's compliance team replies to this address and reviewers test it. - - Submitting the founder's personal email when the company has compliance ownership in a separate team. - -## Required fields — messaging / use case - -These appear in the messaging-compliance section of the form and are filed against the TCR campaign. - -### 9. Use-case selection - -- **Format:** One of: **Authentication**, **Notifications**, **Marketing**, **Customer Service**, **High Volume**. -- **Common mistakes:** - - Picking **Marketing** for a transactional flow because it sounds friendlier. Use-case affects review bar and carrier filtering posture — pick the narrowest accurate option. - - Picking **High Volume** for genuinely low-volume traffic to "future-proof" — Sent maps this to the TCR `MIXED` flow which has a higher review bar. -- **Sent tip:** The dashboard's **Suggest** button auto-fills `Campaign description` based on the use case. Edit it to match the tenant's actual flow before submitting. - -### 10. Campaign description - -- **Format:** 1-3 sentences explaining what messages the tenant sends, to whom, and when. The dashboard's **Suggest** button drafts this; edit before submitting. -- **Common mistakes:** - - Generic descriptions ("transactional messages"). Reviewers want specifics: "Shipping and delivery updates for orders placed on acme.example.com." - - Description doesn't match the use-case option (e.g. describing promotional content under **Notifications**). - -### 11. Sample messages (per use case) - -- **Format:** Plain-text examples of actual production messages, with `{Variable}` placeholders. Should include the brand name. -- **Common mistakes:** - - Under 20 characters. Reviewers flag these as too generic. - - Including a URL shortener that isn't on the brand's verified-domains list. - - Sample doesn't match the declared use case (promo content in an Authentication sample). -- **Note on opt-out language in samples:** Including `Reply STOP to opt out.` in samples is good carrier hygiene and most reviewers expect to see it, but Sent's compliance form treats opt-out **keywords** as a separate field (see #13). Putting STOP in samples does not satisfy field #13 and vice versa. - -### 12. Opt-in mechanism (URL or description) - -- **Format:** Public URL showing the exact form, checkbox, or flow where end users consent to receive SMS — or a written description of the opt-in flow if no public URL exists. -- **Common mistakes:** - - Linking to a checkout flow without showing the SMS-consent checkbox. - - Consent language that bundles SMS with marketing email — reviewers want SMS-specific consent. - - Pre-checked consent boxes (forbidden under most state laws). - -### 13. Opt-out instructions (Opt Keywords tab) - -- **Format:** Managed in the Sent dashboard under **Compliance → Opt Keywords**, not as free-text on the compliance form. At minimum: `STOP` to opt out, `START` to resume. Help keyword (`HELP`) is configured here too. -- **Common mistakes:** - - Assuming the keywords are inferred from sample messages — they're configured separately and must be set explicitly. - - Customizing the auto-reply text without a brand-name reference. - - Forgetting `START` — required for re-opt-in after a `STOP`. - -## US-specific extras (required for 10DLC) - -| Field | Format | Common mistakes | -|---|---|---| -| **Live website URL** | Full URL with scheme that resolves at submission time. | Staging or preview URLs; coming-soon pages; non-HTTPS. | -| **Privacy policy URL** | Direct URL to a public privacy policy that mentions SMS data handling. | Pointing at the homepage; policy doesn't mention SMS / frequency / data sharing; 404 or auth-walled URL. | -| **Opt-in mechanism URL or screenshot** | Same as field #12 but a publicly hosted URL is strongly preferred for US 10DLC review. | Auth-walled flows; staging-only flows. | -| **Opt-out instructions (STOP / START)** | Configured via the **Opt Keywords** dashboard tab. | Configured per profile, not per brand — make sure the right profile is selected. | - -## Country-specific extras - -Selected countries require additional uploaded documents in the KYC stage. These are gated by the country selected during KYC, not by use case: - -| Country | Extra docs | -|---|---| -| Australia (AU) | Utility Bill | -| Belgium (BE) | Proof of Local Address, Passport, Business Registration Certificate | -| Poland (PL) | Proof of Local Address | -| South Africa (ZA) | Proof of Local Address | -| Sweden (SE) | Proof of Local Address | -| Thailand (TH) | Proof of Worldwide Address, Business Registration Certificate | -| United Kingdom (UK) | Proof of Local Address | - -## After the form is submitted - -Sent's pre-flight runs `scripts/validate_10dlc_packet.py` against a JSON dump of these answers. Issues at this stage are cheap to fix — once filed with TCR a rejection costs days of round-trip. See `references/10dlc-rejection-remediation.md` for what to do when an account error code, TCR, or a carrier bounces a submission that passed pre-flight. +Run the evidence validator before translation and the campaign validator after translation. diff --git a/packages/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md b/packages/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md index 38a3432..b15101b 100644 --- a/packages/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md +++ b/packages/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md @@ -1,126 +1,35 @@ -# 10DLC Rejection Remediation — Reference +# 10DLC rejection remediation -Supporting reference for `sms-10dlc-registration`. When a tenant says "my SMS isn't going through", the failure can be in three layers: **Sent account state** (the customer isn't fully onboarded), **TCR-side** (the brand or campaign was rejected before reaching carriers), or **carrier-side** (T-Mobile / AT&T / Verizon / MVNO declined a TCR-approved campaign). Each layer has a different fix path — always identify the layer first. +## Diagnose the layer -Pair with `references/10dlc-evidence-checklist.md` (what should be in the packet) and `references/tcr-use-cases.md` (taxonomy). Run `scripts/validate_10dlc_packet.py` against the corrected packet before re-submitting. +| Layer | Evidence | +| --- | --- | +| Local evidence packet | Version, missing consent proof, invalid URLs, incomplete autoresponses | +| Sent request validation | Wrong camelCase, unsupported field, invalid use case, sample count/length, volume type | +| TCR submission | `submittedToTCR`, registry error/reason, brand or campaign status | +| Carrier operations | Campaign active but filtering, DCA election, content mismatch | -## Layer 0 — Sent account-state errors (verified) +Do not create a new campaign until the failing layer is known. -Before assuming TCR or carrier rejection, check whether the API call itself is returning a Sent account-state error. These are returned synchronously from the v3 API and indicate the tenant isn't fully activated yet. +## Common fixes -| Sent error code | HTTP | Account states that produce it | What to do | -|---|---|---|---| -| `AUTH_006` | 403 | `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, `KYC_RESUBMISSION_REQUESTED` | KYC isn't complete. Finish KYC in the dashboard. If `KYC_RESUBMISSION_REQUESTED`, the compliance team is waiting on revised docs. | -| `AUTH_007` | 403 | `KYC_COMPLETED`, `MESSAGE_COMPLIANCE_COMPLETED` | KYC is done but no messaging channel is configured. Complete the channel + brand/campaign step in the dashboard. | -| `AUTH_005` | 403 | (post-`MESSAGE_COMPLIANCE_COMPLETED`, pre-activation) | Everything is filed; Sent is finishing internal activation. Wait and re-poll. | -| `BUSINESS_003` | 422 | (any active account) | Insufficient account balance. Not a compliance issue — top up billing. Common source of post-registration sending failures once a tenant goes live. | -| `BUSINESS_005` | 422 | (any active account) | A referenced template is still `PENDING` or `REJECTED` (Sent template lifecycle, not TCR). Confirm the SMS template at `/v3/templates/{id}` is `APPROVED` before sending. | +- Wrong endpoint model: operate campaigns under the profile and create a dedicated brand within profile creation. +- Inheritance conflict: disable `inherit_tcr_campaign` before managing a dedicated campaign; do not supply `brand` while brand inheritance is true. +- Wrong field casing: serialize `useCases`, `messagingUseCaseUs`, and `sampleMessages` exactly. +- Too few samples: provide 1–5 structurally; provide at least two for marketing/mixed compliance. +- Weak opt-in: add observable proof and a complete message flow. +- Autoresponse failure: add brand, STOP/HELP, unsubscribe confirmation, and support details as applicable. +- Traffic mismatch: select a use case that matches actual content rather than rewriting only the example. -If you're seeing any of the above, it is **not** a TCR or carrier rejection. Resolve the account-state issue first, then verify whether downstream layers are clean. +## Status interpretation -## Layer 1 — TCR-side rejections (external) +`SENT_CREATED` means a Sent campaign record exists. `submittedToTCR: true` records registry submission. `ACTIVE` means operational activation, while `EXPIRED` is not send-ready. Preserve unknown status strings and any `tcrSyncError` rather than replacing them with a guessed category. -TCR rejected the brand or campaign before it reached carriers. Faster turnaround. Most often: missing data, EIN-name mismatch, content that violates TCR policy. Sent surfaces these on the Sender Profile / Compliance status surfaces; the specific rejection strings come from TCR and are not part of Sent's public error catalog. +## Resubmission -## Layer 2 — Carrier-side rejections (external) - -TCR approved the campaign, but one or more of T-Mobile, AT&T, Verizon, or an MVNO declined. The campaign's per-carrier state will show `DECLINED` or `SUSPENDED`. Carrier decisions are slower to reverse and require evidence updates before re-submission. Per-carrier rejection codes are owned by each carrier — track them externally. - -## 1. Brand verification failure (`UNVERIFIED`, `VETTING_FAILED`) - -- **Detect:** TCR brand status moves to `UNVERIFIED` or external vetting returns `FAILED`. Sent surfaces this on the Compliance status of the brand. -- **Root causes:** - - EIN doesn't match the IRS record for the supplied legal name. - - Brand address doesn't match the IRS or state filing. - - Submitted entity type (`PRIVATE_PROFIT` vs `NON_PROFIT`) contradicts the IRS record. -- **Fix steps:** - 1. Re-pull the tenant's IRS EIN confirmation letter (Form CP-575) and reconcile name, address, and entity type field-by-field. - 2. Correct the brand record in Sent. Resubmit for vetting (costs another vet fee). - 3. If the legal name has genuinely changed, the tenant needs an IRS Form 147C confirming the current name before re-vetting. -- **Re-submission etiquette:** Don't re-submit the same data hoping for a different reviewer. Re-vetting with unchanged data is logged as a duplicate and may slow the next legitimate re-vet. - -## 2. EIN-to-legal-name mismatch - -- **Detect:** Pre-flight (`scripts/validate_10dlc_packet.py`) catches format issues; TCR catches semantic mismatch. -- **Root causes:** - - Tenant submitted a DBA instead of legal entity name. - - Recent legal name change not yet reflected with the IRS. - - Typo in EIN. -- **Fix steps:** - 1. Confirm the EIN against the IRS confirmation letter — not the tenant's accounting system. - 2. Update legal name to match exactly (including suffix). - 3. If a legitimate name change has occurred, request Form 147C from IRS before re-filing. -- **Re-submission etiquette:** Note the changed fields in the resubmission cover letter. Reviewers approve corrections faster when the delta is explicit. - -## 3. Campaign content does not match declared use case - -- **Detect:** TCR returns a use-case-mismatch verdict or carriers return a content-violation verdict. Often surfaces as an Authentication / 2FA campaign getting downgraded after a sample promo message slips in. -- **Root causes:** - - Promotional language in samples for **Notifications**, **Authentication**, or **Customer Service**. - - Samples mention a discount, sale, or call-to-buy. - - Sample uses a transactional voice but the declared use case is **Marketing**. -- **Fix steps:** - 1. Re-classify: if any sample is genuinely promotional, split into two campaigns rather than reclassifying everything as **High Volume** (see `references/tcr-use-cases.md`). - 2. Rewrite samples to mirror only the traffic that belongs in the declared use case. - 3. For ambiguous samples, lead with the trigger event (`Your order #1029 has shipped`) — reviewers parse the first sentence hardest. -- **Re-submission etiquette:** Resubmit with the trimmed samples. If splitting into two campaigns, file them sequentially, not in parallel, so the first one's vetting score informs the second. - -## 4. Opt-in evidence insufficient - -- **Detect:** TCR flags the opt-in URL or it gets flagged in manual review. -- **Root causes:** - - URL points at a homepage rather than the specific consent form. - - Consent language bundles SMS with email or push. - - Pre-checked consent box. - - Consent is buried in terms of service rather than at the point of phone-number capture. -- **Fix steps:** - 1. Update the live opt-in surface so the SMS checkbox is unchecked by default and the consent text is SMS-specific. - 2. Take a fresh screenshot with timestamp and host it publicly. - 3. Re-file with the new URL or screenshot as the opt-in evidence. -- **Re-submission etiquette:** Don't paste a URL that requires login. If the consent flow is behind auth, host a public mock that mirrors the production UX. - -## 5. Sample messages too generic - -- **Detect:** TCR flags samples as too generic, or carriers downgrade vetting after launch. -- **Root causes:** - - Samples under 20 characters or under 3 sentences. - - Missing brand name, recipient context, or opt-out language. - - Samples are paraphrases instead of literal production sends. -- **Fix steps:** - 1. Pull 5 real sends (with PII redacted) from staging. - 2. Replace generic samples with the redacted real sends, keeping brand name intact. - 3. Make sure samples cover the variety the campaign will actually send. -- **Re-submission etiquette:** Number the samples in the cover note so a reviewer can confirm each one passes their content scan. - -## 6. Opt-out configuration missing or inconsistent - -- **Detect:** Sample messages don't reference opt-out, or the **Compliance → Opt Keywords** tab has STOP / START / HELP unconfigured for the active profile. -- **Root causes:** - - Opt Keywords tab not configured for the profile sending the traffic. - - Brand name missing from the opt-out confirmation auto-reply. - - Samples don't mention the opt-out instruction (even though the rule is in Opt Keywords, carriers still expect to see hints in samples). -- **Fix steps:** - 1. Configure **STOP** and **START** at minimum in **Compliance → Opt Keywords** for the active profile. - 2. Verify the auto-reply text references the brand and the word `STOP`. - 3. Add a hint like `Reply STOP to unsubscribe.` to samples — it's not the Sent-required field but most carriers expect it. -- **Re-submission etiquette:** Note in the cover that both the Opt Keywords config and the samples were updated together. - -## 7. Prohibited content category - -- **Detect:** TCR flags `SHAFT` (sex, hate, alcohol, firearms, tobacco) or a similar prohibited-content verdict. Some carriers extend this list (cannabis, payday loans, debt collection). -- **Root causes:** - - Campaign content falls into an outright prohibited category for US carriers. - - Age-gated category declared but no age-verification at opt-in. - - Affiliate marketing without disclosure. -- **Fix steps:** - 1. If outright prohibited (e.g. cannabis on Verizon), 10DLC is not the right channel. Surface this back to the tenant; the registration cannot succeed. - 2. If age-gated, add age verification at opt-in and update samples to reference the gate. - 3. If affiliate marketing, declare it honestly and add disclosure in the message body. -- **Re-submission etiquette:** Don't shop the same prohibited campaign to different reviewers. Address the category restriction or recommend an alternative channel (e.g. WhatsApp, RCS, email). - -## General re-submission etiquette - -- Fix one class of issue at a time. Bundling unrelated changes into a single re-submission makes it hard for reviewers to confirm each fix. -- Include a short cover note listing what changed since the last submission. -- Re-run `scripts/validate_10dlc_packet.py` before every re-submission. Mechanical failures are free to catch and expensive to re-file for. -- Track rejection codes on the Sender Profile so repeat rejections trigger a manual review before another re-file. +1. Save the original response and reason. +2. Fix the versioned evidence packet if necessary. +3. Rebuild and validate the API request. +4. Use `sandbox: true`. +5. Show the exact diff. +6. Obtain confirmation before the real mutation. diff --git a/packages/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md b/packages/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md index f1e564d..34085e5 100644 --- a/packages/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md +++ b/packages/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md @@ -1,96 +1,35 @@ -# TCR Use Cases & Vetting — Reference +# TCR use cases -Supporting reference for `sms-10dlc-registration`. The Campaign Registry's use-case taxonomy and the practical effect of each choice on throughput and carrier filtering. The canonical list is on the [TCR website](https://www.campaignregistry.com/) — use this doc for the *interpretation* in a multi-tenant Sent deployment. +## Current Sent API values -## Sent's compliance-form taxonomy (verified) +| Value | Typical traffic | +| --- | --- | +| `MARKETING` | Promotions, offers, product announcements | +| `ACCOUNT_NOTIFICATION` | Account changes, balances, non-security notices | +| `CUSTOMER_CARE` | Support conversations and case updates | +| `FRAUD_ALERT` | Suspected fraud notifications | +| `TWO_FA` | One-time passcodes and two-factor authentication | +| `DELIVERY_NOTIFICATION` | Shipment and delivery updates | +| `SECURITY_ALERT` | Security events distinct from general account notices | +| `M2M` | Machine-to-machine operational traffic | +| `MIXED` | Multiple standard use cases in one campaign | +| `HIGHER_EDUCATION` | College or university communications | +| `POLLING_VOTING` | Polling and voting interactions | +| `PUBLIC_SERVICE_ANNOUNCEMENT` | Public-interest or government notices | +| `LOW_VOLUME` | Low-volume mixed traffic | -Sent's compliance form exposes a **five-option** use-case selector to the tenant, not the full TCR taxonomy. Pick one of these on the form: +Select what the business will actually send. Do not use `MIXED` or `LOW_VOLUME` to hide a dominant high-risk use case. -| Sent compliance-form option | Typical underlying TCR use case(s) | -|---|---| -| **Authentication** | `2FA` | -| **Notifications** | `ACCOUNT_NOTIFICATION`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `SECURITY_ALERT`, `PUBLIC_SERVICE_ANNOUNCEMENT` | -| **Marketing** | `MARKETING` | -| **Customer Service** | `CUSTOMER_CARE` | -| **High Volume** | `MIXED` / high-throughput senders (Sent maps internally) | +## Sample rules -Sent files the TCR-side payload on the tenant's behalf — the deeper TCR taxonomy below is for interpreting that mapping and reasoning about why a use-case selection might trigger more or less filtering. The tenant only sees and chooses one of the five form values. +The API structure permits 1–5 samples per use case, up to 1,024 characters each. Compliance policy requires at least two samples for `MARKETING`, `MIXED`, and low-volume mixed traffic. Samples should: -## TCR use cases sorted by throughput & filtering risk (external interpretation) +- name the recognizable consumer brand; +- look like real production messages; +- match the selected use case and described message flow; +- include opt-out language when applicable; +- use synthetic names, codes, numbers, and URLs. -| Use case | Typical TPS (post-vetting) | Filtering risk | When to pick | -|---|---|---|---| -| `2FA` | Highest | Lowest | Codes only. No promotional content. | -| `ACCOUNT_NOTIFICATION` | High | Low | Order, shipping, appointment, payment, security notifications. Triggered by the recipient's action. | -| `CUSTOMER_CARE` | Medium-high | Low | Two-way support replies. Mostly inbound-driven. | -| `DELIVERY_NOTIFICATION` | High | Low | Specialized variant of ACCOUNT_NOTIFICATION for couriers. | -| `FRAUD_ALERT` | High | Low | Account-security alerts and fraud confirmations. | -| `HIGHER_EDUCATION` | Medium | Medium | School-affiliated communications. Required for many edu senders. | -| `LOW_VOLUME` | Low | Low | Pilots, internal tools, < 6,000 messages/day. | -| `MARKETING` | Medium | High | Promo, discount, win-back. Highest review bar; opt-in must be airtight. | -| `MIXED` | Medium | High | Last resort. Raises the bar without raising throughput. | -| `POLITICAL` | Medium | High | Political campaigns (US). Regulated category. | -| `POLLING_VOTING` | Medium | Medium | Surveys, polls, non-political voting. | -| `PUBLIC_SERVICE_ANNOUNCEMENT` | Medium | Low | Non-profit / government PSAs. | -| `SECURITY_ALERT` | High | Low | Specialized variant of FRAUD_ALERT for non-financial security. | -| `SOCIAL` | Medium | Medium | Person-to-person-feeling but business-sent (matchmaking, social apps). | +## Volume -Specific TPS numbers and vetting-score-to-throughput thresholds are **external** — Sent's docs only confirm the tier-level account-wide rate caps (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom), not TCR / carrier per-campaign throughput. - -## Brand external vetting (external context) - -Brands can submit for **External Vetting** via TCR-approved vetting providers. This produces a `vettingScore` that: - -- Unlocks higher per-campaign TPS caps from each carrier. -- Reduces the filtering aggressiveness applied to the campaign's traffic. -- Cannot be inherited from another brand — every brand vets independently. - -The exact vetting-score-to-throughput mapping is set by each carrier and not exposed in Sent's docs. Treat as external (TCR + carrier docs). - -## Required campaign attributes (TCR-side, external) - -Every TCR campaign declares these. They're not directly user-visible on Sent's compliance form, but Sent files them from the form data plus dashboard configuration: - -- **Subscriber opt-in** — How recipients agreed to receive these messages. Captured on Sent's compliance form as the **opt-in mechanism URL or description**. -- **Opt-out keywords** — Minimum `STOP`. Managed on Sent in the **Compliance → Opt Keywords** tab; not embedded per sample message. -- **Help keywords** — Minimum `HELP`. Reply should describe what the campaign is and how to opt out. -- **Embedded link** — Whether your messages include URLs (`yes` / `no`). -- **Embedded phone** — Whether your messages include phone numbers (`yes` / `no`). -- **Affiliate marketing** — Whether the campaign promotes affiliate offers (`yes` / `no`). `yes` is heavily scrutinized. -- **Age-gated content** — Alcohol, gambling, firearms, tobacco. `yes` requires age verification at opt-in. -- **Direct lending** — Whether the campaign is for direct loans. Subject to additional review. - -## Sample messages - -TCR requires 2-5 sample messages per campaign. Carrier filters use these to validate live traffic. Mismatches between samples and production are the most common reason for downgrade. - -Good sample: -> {Brand Name}: Your order #1029 has shipped. Track: https://example.com/track/1029. - -Bad sample (don't do): -> Your order has shipped! - -Bad because: no brand name, no link/tracking specifics that mirror production. - -Note: Opt-out language (`Reply STOP to opt out.`) is conventional in samples and many carriers expect to see it, but the **rule** that every Sent-filed sample must literally embed `STOP` is not confirmed in Sent's docs — Sent treats opt-out as a separate field on the compliance form (Opt Keywords tab) rather than embedded in each sample. Sample-level opt-out is good carrier hygiene; the Sent-required field lives elsewhere. - -## Per-carrier filtering notes (external) - -TCR-approved doesn't mean delivered. Each major US carrier filters independently: - -- **T-Mobile** — Most aggressive. Strict on URL shorteners (use a branded short-link domain on your verified domains, not generic `bit.ly`). -- **AT&T** — Stricter on message-volume spikes than on content. Pace sends. -- **Verizon** — More content-sensitive on `MARKETING` and `MIXED`. Promotional content in an `ACCOUNT_NOTIFICATION` campaign gets caught here first. - -Track per-carrier delivery in the MDR funnel and reconcile per-carrier approval state on the campaign daily. Specific carrier rejection codes are external (Twilio / Bandwidth / Sinch / direct-aggregator docs). - -## Common rejection reasons (external taxonomy) - -| Reason | What it means | Fix | -|---|---|---| -| `Use case mismatch` | Sample messages don't fit the declared use case | Re-classify campaign or rewrite samples | -| `Missing opt-out language` | Samples don't include STOP / HELP language | Add it everywhere | -| `Brand name not in sender ID` | Recipient can't see who's texting | Add `{Brand Name}:` prefix to messages | -| `Affiliate disclosed = no, content suggests affiliate` | Lied on the attribute | Declare honestly | -| `Embedded link not declared` | Samples have links, attribute says no | Update the attribute | -| `Insufficient vetting score for requested TPS` | Vetting too low for the throughput tier | Re-vet at a higher tier, or accept lower TPS | +`volume` is a numeric string. Values below `2000` are in the documented low-volume tier. Exactly `2000` crosses the tier boundary, so validate `1999` and `2000` separately. diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json index ee07bc8..f532cb6 100644 --- a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json +++ b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json @@ -1,26 +1,21 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "123-456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "2175550101", - "contact_email": "compliance-at-example.com", - "website": "https://example.com", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": [], - "start": ["START"], - "help": ["HELP"] + "schema_version": "unversioned-packet", + "legal_business_name": "", + "ein": "123", + "business_phone": "2025550100", + "contact_email": "invalid", + "website": "not-a-public-url", + "privacy_policy_url": "missing-scheme.example", + "terms_and_conditions_url": "", + "opt_in_evidence": {}, + "message_flow": "", + "autoresponses": { + "optinMessage": "", + "optoutMessage": "", + "helpMessage": "", + "optinKeywords": "YES", + "optoutKeywords": "UNSUBSCRIBE", + "helpKeywords": "INFO" }, - "use_cases": [ - { - "selection": "Transactional", - "description": "Shipping and delivery updates.", - "sample_messages": [ - "Order shipped!" - ] - } - ] + "use_cases": [] } diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json new file mode 100644 index 0000000..0cea7bf --- /dev/null +++ b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json @@ -0,0 +1,16 @@ +{ + "campaign": { + "name": "Bad campaign", + "description": "Uses the wrong field names and too few marketing samples.", + "type": "App", + "use_cases": [], + "useCases": [ + { + "messagingUseCaseUs": "MARKETING", + "sampleMessages": ["One sample only"] + } + ], + "volume": 2000 + }, + "sandbox": true +} diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json new file mode 100644 index 0000000..bc312c7 --- /dev/null +++ b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json @@ -0,0 +1,26 @@ +{ + "campaign": { + "name": "Acme account notifications", + "description": "Synthetic account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in through the account settings form before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json index c40ddb4..1ca09fa 100644 --- a/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json +++ b/packages/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json @@ -1,34 +1,33 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", + "schema_version": "sent-10dlc-evidence/v1", + "legal_business_name": "Acme Example LLC", "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", + "business_address": "123 Example Street, New York, NY 10001, US", + "business_phone": "+12025550100", "contact_email": "compliance@example.com", "website": "https://example.com", "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup?sms=consent", - "opt_keywords": { - "stop": ["STOP", "UNSUBSCRIBE", "CANCEL"], - "start": ["START", "UNSTOP"], - "help": ["HELP", "INFO"] + "terms_and_conditions_url": "https://example.com/terms", + "opt_in_evidence": { + "method": "web_form", + "description": "A synthetic checkout form uses an unchecked SMS consent box.", + "proof_url": "https://example.com/sms-consent" + }, + "message_flow": "Customers submit the web form and receive the opt-in confirmation before notifications begin.", + "autoresponses": { + "optinMessage": "Acme Example: You are subscribed. Msg frequency varies. Msg and data rates may apply. Reply HELP for help or STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" }, "use_cases": [ { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders placed on example.com.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Track: https://example.com/track/1029. Reply STOP to opt out.", - "Example: Order #1042 is out for delivery today. Reply STOP to opt out." - ] - }, - { - "selection": "Authentication", - "description": "One-time login codes for example.com account holders.", + "messaging_use_case_us": "MARKETING", "sample_messages": [ - "Example: Your verification code is 482910. It expires in 10 minutes." + "Acme Example: Save 10% on your next order. Reply STOP to opt out.", + "Acme Example: New items are available. Reply STOP to opt out." ] } ] diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py b/packages/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py index d59ee0d..29547e4 100644 --- a/packages/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py +++ b/packages/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py @@ -1,48 +1,9 @@ #!/usr/bin/env python3 -"""Validate a Sent 10DLC compliance packet before it is filed with TCR. +"""Validate the internal 10DLC evidence packet schema. -Usage: - python validate_10dlc_packet.py - -The packet is a JSON object representing the answers a tenant submitted on -Sent's compliance form. Field names match the verified Sent compliance form -in the dashboard (see references/10dlc-evidence-checklist.md). The validator -checks the mechanical things that are cheap to catch locally and expensive -to discover after TCR or a carrier rejects the submission. Semantic checks -(use-case match, content policy) are out of scope. - -Expected shape: - - { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", - "contact_email": "compliance@example.com", - "website": "https://example.com", - "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": ["STOP"], - "start": ["START"], - "help": ["HELP"] - }, - "use_cases": [ - { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Reply STOP to opt out." - ] - } - ] - } - -Exits 0 with `OK` on success. On failure, prints one issue per line in the -form `: : ` and exits 1. +This packet is readiness evidence, not the Sent campaign API request. Its +snake_case fields are namespaced by an explicit schema version so they cannot +be mistaken for Sent's camelCase contract. """ from __future__ import annotations @@ -51,217 +12,138 @@ import json import re import sys +from pathlib import Path from typing import Any -# Required top-level keys on the packet. Match the verified Sent compliance -# form field names — business identity, US-required URLs, opt keywords, and -# use cases. -REQUIRED_TOP_LEVEL = ( + +SCHEMA_VERSION = "sent-10dlc-evidence/v1" +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +EIN_RE = re.compile(r"^\d{2}-?\d{7}$") +PHONE_RE = re.compile(r"^\+\d{1,15}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +REQUIRED = { + "schema_version", "legal_business_name", - "business_registration_number", - "business_type", - "industry_category", "ein", "business_address", "business_phone", "contact_email", "website", "privacy_policy_url", - "opt_in_mechanism_url", - "opt_keywords", + "terms_and_conditions_url", + "opt_in_evidence", + "message_flow", + "autoresponses", "use_cases", -) - -# Required keys on each use case. -REQUIRED_USE_CASE = ( - "selection", - "description", - "sample_messages", -) - -# Verified use-case selection values from the Sent compliance form. -VALID_USE_CASE_SELECTIONS = ( - "Authentication", - "Notifications", - "Marketing", - "Customer Service", - "High Volume", -) - -URL_FIELDS = ("website", "privacy_policy_url", "opt_in_mechanism_url") - -# Permissive URL regex: scheme + host + optional path. Catches obvious junk -# (no scheme, internal whitespace, missing host) without trying to be a full -# RFC 3986 validator. -URL_RE = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE) - -# EIN: 9 digits, optionally hyphenated after the first two. -EIN_RE = re.compile(r"^\d{2}-?\d{7}$") - -# E.164 phone number: leading +, then 1-15 digits. -PHONE_RE = re.compile(r"^\+\d{1,15}$") - -# Permissive email regex — local@domain.tld. -EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - -# Minimum length of a sample message before reviewers flag it as too generic. -MIN_SAMPLE_LEN = 20 +} -def _is_nonempty_string(value: Any) -> bool: - return isinstance(value, str) and value.strip() != "" +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) -def validate(packet: dict[str, Any], path: str) -> list[str]: - """Return a list of issue strings. Empty list means the packet is valid.""" +def validate(packet: Any, path: str = "") -> list[str]: issues: list[str] = [] def issue(field: str, reason: str) -> None: issues.append(f"{path}: {field}: {reason}") if not isinstance(packet, dict): - issue("", "packet must be a JSON object") + issue("", "must be a JSON object") return issues - - # Top-level required keys. - for key in REQUIRED_TOP_LEVEL: - if key not in packet: - issue(key, "missing required field") - elif key == "use_cases": - if not isinstance(packet[key], list) or len(packet[key]) == 0: - issue(key, "must be a non-empty list") - elif key == "opt_keywords": - if not isinstance(packet[key], dict): - issue(key, "must be a JSON object with stop/start/help keys") - elif not _is_nonempty_string(packet[key]): + for key in sorted(REQUIRED - set(packet)): + issue(key, "missing required field") + if packet.get("schema_version") != SCHEMA_VERSION: + issue("schema_version", f"must equal {SCHEMA_VERSION!r}") + for key in ("legal_business_name", "business_address", "message_flow"): + if key in packet and not _text(packet[key]): issue(key, "must be a non-empty string") + if _text(packet.get("ein")) and not EIN_RE.fullmatch(packet["ein"]): + issue("ein", "must contain nine digits, optionally formatted NN-NNNNNNN") + if _text(packet.get("business_phone")) and not PHONE_RE.fullmatch(packet["business_phone"]): + issue("business_phone", "must be E.164") + if _text(packet.get("contact_email")) and not EMAIL_RE.fullmatch(packet["contact_email"]): + issue("contact_email", "must be a valid email") + for key in ("website", "privacy_policy_url", "terms_and_conditions_url"): + if key in packet and (not _text(packet[key]) or not URL_RE.fullmatch(packet[key])): + issue(key, "must be a public HTTPS URL") + + opt_in = packet.get("opt_in_evidence") + if not isinstance(opt_in, dict): + issue("opt_in_evidence", "must be an object") + else: + for key in ("method", "description", "proof_url"): + if not _text(opt_in.get(key)): + issue(f"opt_in_evidence.{key}", "must be a non-empty string") + proof = opt_in.get("proof_url") + if _text(proof) and not URL_RE.fullmatch(proof): + issue("opt_in_evidence.proof_url", "must be a public HTTPS URL") + + autoresponses = packet.get("autoresponses") + if not isinstance(autoresponses, dict): + issue("autoresponses", "must be an object") + else: + for key in ("optinMessage", "optoutMessage", "helpMessage", "optinKeywords", "optoutKeywords", "helpKeywords"): + if not _text(autoresponses.get(key)): + issue(f"autoresponses.{key}", "must be a non-empty string") + if "STOP" not in str(autoresponses.get("optoutKeywords", "")).upper().split(","): + issue("autoresponses.optoutKeywords", "must include STOP") + if "HELP" not in str(autoresponses.get("helpKeywords", "")).upper().split(","): + issue("autoresponses.helpKeywords", "must include HELP") - # URL fields. - for key in URL_FIELDS: - value = packet.get(key) - if _is_nonempty_string(value) and not URL_RE.match(value): - issue(key, f"not a valid URL: {value!r}") - - # EIN format. - ein = packet.get("ein") - if _is_nonempty_string(ein) and not EIN_RE.match(ein): - issue("ein", f"must match ^\\d{{2}}-?\\d{{7}}$ (got {ein!r})") - - # Business phone format (E.164). - phone = packet.get("business_phone") - if _is_nonempty_string(phone) and not PHONE_RE.match(phone): - issue( - "business_phone", - f"must be E.164 (+CCNNNNNNNNNN, got {phone!r})", - ) - - # Contact email format. - email = packet.get("contact_email") - if _is_nonempty_string(email) and not EMAIL_RE.match(email): - issue("contact_email", f"not a valid email address: {email!r}") - - # Opt keywords — at minimum STOP must be configured. - opt_keywords = packet.get("opt_keywords") - if isinstance(opt_keywords, dict): - stop = opt_keywords.get("stop") - if not isinstance(stop, list) or not any( - _is_nonempty_string(kw) and kw.strip().upper() == "STOP" - for kw in stop - ): - issue( - "opt_keywords.stop", - "must include 'STOP' as an opt-out keyword (configured in " - "the Compliance → Opt Keywords dashboard tab)", - ) - - # Use cases. use_cases = packet.get("use_cases") - if isinstance(use_cases, list): - for i, uc in enumerate(use_cases): - prefix = f"use_cases[{i}]" - if not isinstance(uc, dict): - issue(prefix, "must be a JSON object") + if not isinstance(use_cases, list) or not use_cases: + issue("use_cases", "must be a non-empty array") + else: + for index, use_case in enumerate(use_cases): + field = f"use_cases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") continue - - for key in REQUIRED_USE_CASE: - if key not in uc: - issue(f"{prefix}.{key}", "missing required field") - - selection = uc.get("selection") - description = uc.get("description") - if "selection" in uc: - if not _is_nonempty_string(selection): - issue(f"{prefix}.selection", "must be a non-empty string") - elif selection not in VALID_USE_CASE_SELECTIONS: - issue( - f"{prefix}.selection", - f"must be one of {VALID_USE_CASE_SELECTIONS} " - f"(got {selection!r})", - ) - if "description" in uc and not _is_nonempty_string(description): - issue(f"{prefix}.description", "must be a non-empty string") - - samples = uc.get("sample_messages") - if "sample_messages" in uc: - if not isinstance(samples, list) or len(samples) == 0: - issue( - f"{prefix}.sample_messages", - "must be a non-empty list (>=1 sample per use case)", - ) - else: - for j, sample in enumerate(samples): - sprefix = f"{prefix}.sample_messages[{j}]" - if not _is_nonempty_string(sample): - issue(sprefix, "must be a non-empty string") - elif len(sample.strip()) < MIN_SAMPLE_LEN: - issue( - sprefix, - f"sample is {len(sample.strip())} chars; " - f"reviewers flag samples under {MIN_SAMPLE_LEN} as too generic", - ) - + if use_case.get("messaging_use_case_us") not in USE_CASES: + issue(f"{field}.messaging_use_case_us", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sample_messages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sample_messages", "must contain 1–5 samples") + elif any(not _text(sample) or len(sample) > 1024 for sample in samples): + issue(f"{field}.sample_messages", "samples must be non-empty strings of at most 1,024 characters") return issues def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="validate_10dlc_packet.py", - description=( - "Validate a Sent 10DLC compliance packet (JSON) before filing " - "with The Campaign Registry. Checks required fields, URL / EIN " - "/ phone / email format, use-case selection, sample-message " - "length, and opt-out keyword configuration. See " - "references/10dlc-evidence-checklist.md for the field-by-field " - "rationale." - ), - ) - parser.add_argument("packet", help="Path to the packet JSON file") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("packet", type=Path) args = parser.parse_args(argv) - - path = args.packet - try: - with open(path, encoding="utf-8") as f: - packet = json.load(f) - except FileNotFoundError: - print(f"{path}: : not found", file=sys.stderr) - return 1 - except json.JSONDecodeError as e: - print(f"{path}: : invalid JSON ({e})", file=sys.stderr) + packet = json.loads(args.packet.read_text(encoding="utf-8")) + except OSError as exc: + print(f"{args.packet}: : {exc}", file=sys.stderr) return 1 - except OSError as e: - print(f"{path}: : could not read ({e})", file=sys.stderr) + except json.JSONDecodeError as exc: + print(f"{args.packet}: : invalid JSON ({exc})", file=sys.stderr) return 1 - - issues = validate(packet, path) + issues = validate(packet, str(args.packet)) if issues: - for line in issues: - print(line, file=sys.stderr) + print("\n".join(issues), file=sys.stderr) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/packages/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py b/packages/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py new file mode 100644 index 0000000..a3e54f5 --- /dev/null +++ b/packages/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Validate the exact Sent campaign request used by profile campaign endpoints.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +POLICY_TWO_SAMPLE_CASES = {"MARKETING", "MIXED", "LOW_VOLUME"} +CAMPAIGN_REQUIRED = {"name", "description", "type", "useCases"} +CAMPAIGN_OPTIONAL = { + "volume", + "messageFlow", + "privacyPolicyLink", + "termsAndConditionsLink", + "optinMessage", + "optoutMessage", + "helpMessage", + "optinKeywords", + "optoutKeywords", + "helpKeywords", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +VOLUME_RE = re.compile(r"^\d+$") + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def validate(payload: Any, path: str = "") -> list[str]: + issues: list[str] = [] + + def issue(field: str, reason: str) -> None: + issues.append(f"{path}: {field}: {reason}") + + if not isinstance(payload, dict): + issue("", "must be a JSON object") + return issues + for key in sorted(set(payload) - {"campaign", "sandbox"}): + issue(key, "unsupported top-level field") + if "sandbox" in payload and not isinstance(payload["sandbox"], bool): + issue("sandbox", "must be boolean") + campaign = payload.get("campaign") + if not isinstance(campaign, dict): + issue("campaign", "required and must be an object") + return issues + allowed = CAMPAIGN_REQUIRED | CAMPAIGN_OPTIONAL + for key in sorted(set(campaign) - allowed): + issue(f"campaign.{key}", "unsupported field; use the exact camelCase Sent contract") + for key in sorted(CAMPAIGN_REQUIRED - set(campaign)): + issue(f"campaign.{key}", "missing required field") + for key in ("name", "description", "type"): + if key in campaign and not _text(campaign[key]): + issue(f"campaign.{key}", "must be a non-empty string") + volume = campaign.get("volume") + if volume is not None and (not isinstance(volume, str) or not VOLUME_RE.fullmatch(volume)): + issue("campaign.volume", "must be a numeric string such as '1999' or '2000'") + for key in ("privacyPolicyLink", "termsAndConditionsLink"): + value = campaign.get(key) + if value is not None and (not _text(value) or not URL_RE.fullmatch(value)): + issue(f"campaign.{key}", "must be a public HTTPS URL or null") + for key in ("optinKeywords", "optoutKeywords", "helpKeywords"): + value = campaign.get(key) + if value is not None and (not _text(value) or len(value) > 255): + issue(f"campaign.{key}", "must be a non-empty string of at most 255 characters or null") + + use_cases = campaign.get("useCases") + if not isinstance(use_cases, list) or not use_cases: + issue("campaign.useCases", "must be a non-empty array") + return issues + for index, use_case in enumerate(use_cases): + field = f"campaign.useCases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") + continue + for key in sorted(set(use_case) - {"messagingUseCaseUs", "sampleMessages"}): + issue(f"{field}.{key}", "unsupported field") + selected = use_case.get("messagingUseCaseUs") + if selected not in USE_CASES: + issue(f"{field}.messagingUseCaseUs", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sampleMessages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sampleMessages", "must contain 1–5 samples") + continue + for sample_index, sample in enumerate(samples): + if not _text(sample): + issue(f"{field}.sampleMessages[{sample_index}]", "must be a non-empty string") + elif len(sample) > 1024: + issue(f"{field}.sampleMessages[{sample_index}]", "must be at most 1,024 characters") + if selected in POLICY_TWO_SAMPLE_CASES and len(samples) < 2: + issue( + f"{field}.sampleMessages", + f"{selected} requires at least two samples under the compliance policy layer", + ) + return issues + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("payload", type=Path) + args = parser.parse_args() + try: + payload = json.loads(args.payload.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"{args.payload}: {exc}") + return 2 + issues = validate(payload, str(args.payload)) + if issues: + print("\n".join(issues)) + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sent/skills/template-builder-ui/SKILL.md b/packages/sent/skills/template-builder-ui/SKILL.md index 327fb15..cfc2ccf 100644 --- a/packages/sent/skills/template-builder-ui/SKILL.md +++ b/packages/sent/skills/template-builder-ui/SKILL.md @@ -1,171 +1,109 @@ --- name: template-builder-ui -description: Designs and audits a Sent template builder UI for cross-channel SMS, WhatsApp, and RCS templates, including component editing, variable samples, status handling, channel-specific validation, and submission workflows. Use when a user says template builder, template editor, Sent templates, WhatsApp template UI, RCS template, SMS template, Meta import, JSON template, approval status, or wants a product spec for template creation. Do not use for generic templating engines (Jinja, Handlebars, Mustache, email/HTML templates) or non-Sent template systems. +description: Designs and audits tenant-facing Sent template builders, previews, validation, lifecycle UX, and API payload mapping. Use for template editor forms, variables, channel overrides, WhatsApp review, RCS suggestion chips, authentication templates, and safe submission flows. --- - +Design the interface around Sent's v3 `definition` contract. The UI may import Meta material, but its canonical saved and submitted model must never be Meta's `components[]` payload. -# Template builder UI +## Product model -## Overview +Use one draft object with: -Use this skill to design or improve a Sent template builder UI. Sent templates are reusable message blueprints across SMS, WhatsApp, and RCS. The UI must let users create valid templates, preview channel-specific rendering, supply variable samples, submit for review where required, and understand status without exposing irrelevant provider internals. +- optional `category` and `language`; +- required `definition.body.multiChannel`; +- optional complete body overrides for `sms`, `whatsapp`, and `rcs`; +- optional `definition.header`, `footer`, `buttons`, `definitionVersion`, and `authenticationConfig`; +- submission controls for `creation_source`, `submit_for_review`, and `sandbox`. -The Sent v3 template API supports create, list, retrieve, update, and delete operations. The first-message workflow sends templates through `POST /v3/messages` using a `template.id`. A good UI therefore optimizes both authoring and later sendability. +Do not expose top-level create fields named `name`, `channels`, `body`, `header`, or `buttons`. If the product needs an internal display label, keep it outside the Sent create payload. -## When to use +## Recommended editor sequence -Use this skill when the user asks for a template builder, template editor, template management UI, template validation, Meta import flow, JSON template builder, WhatsApp approval UI, RCS rich template editor, SMS template preview, template status page, or a product/engineering spec for Sent templates. +1. Capture intent and category. +2. Write the `multiChannel` body. +3. Insert variables as structured entities. +4. Add optional per-channel overrides. +5. Add header, footer, and buttons where supported. +6. Review live previews and accessibility. +7. Validate locally and with `sandbox: true`. +8. Save a draft, then explicitly submit for provider review. -Do not use this skill to write final WhatsApp template copy; use `waba-template-author`. Do not use it to decide Sender Profile boundaries; use `sender-profile-architect`. Do not use it to diagnose delivery failures after sends; use `messaging-performance-analyzer`. +Category should not block the first keystroke, but it must be visible before submission because it affects authentication rules and WhatsApp policy review. -## Product principles +## Variable UX -A Sent template UI should make the valid path obvious and the invalid path hard. Users should understand three things at all times: what channels the template targets, what variables need examples, and whether the template is editable, pending, approved, or rejected. +Inserting a variable creates both: -| Principle | UI behavior | Why it matters | -|---|---|---| -| Channel-first editing | User chooses SMS, WhatsApp, RCS, or combinations before components. | Component support differs by channel. | -| Variable-first validation | Every variable has a sample value before review/submission. | Reviewers and test sends need concrete rendered examples. | -| Status-aware actions | Drafts can be edited; pending/approved/rejected states guide next action. | Users should not unknowingly break reviewed content. | -| Provider-specific details are scoped | WhatsApp category and Meta import appear only where WhatsApp applies. | Keeps cross-channel UI from becoming WhatsApp-only. | -| JSON escape hatch | Advanced users can paste/edit JSON with schema validation. | Sent docs include JSON definition as a creation path. | +- a placeholder such as `{{0:variable}}`; and +- a matching entity with `id`, `name`, `type`, and `props.sample`. -## Process +Renumber atomically when variables move. Never let users edit placeholder syntax independently of the entity table. Show a clear error for naked `{{1}}` or IDs without definitions. -### 1. Start with the template intent and channels +## Validation matrix -Ask what the template is for before showing component controls. Intent drives category, variables, and review risk. Then ask which channels the user wants to support. +Apply the exact rules in [references/template-validation-matrix.md](references/template-validation-matrix.md), including: -**Example.** “Order shipped” targeting SMS, WhatsApp, and RCS should start from one intent but render differently: SMS may be plain text, WhatsApp may need a utility category and sample variables, and RCS may use richer actions if configured. +- a 1,024-character maximum for every body; +- 60 characters for header and footer; +- no footer variables; +- 10 buttons total; +- button types `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE` with their per-type limits; +- no invented quick-reply-versus-CTA exclusivity; +- `authenticationConfig` and authentication restrictions; +- complete, independently valid channel overrides. -### 2. Model the Sent template lifecycle +Run the bundled `waba-template-author` linter against serialized JSON. Server validation remains authoritative. -Use Sent’s documented statuses in the UI: Draft, Pending, Approved, and Rejected. Do not introduce provider-only states as global Sent states unless Sent event types or API responses expose them for the account. +## Channel previews -| Status | UI meaning | Allowed primary action | -|---|---|---| -| Draft | Saved but not submitted. | Edit, preview, validate, submit. | -| Pending | Submitted for review/approval where required. | View, cancel if supported, duplicate. | -| Approved | Available for production sends where channel setup allows. | Use in send flow, duplicate for revision. | -| Rejected | Review failed or validation blocked approval. | View reason, revise, resubmit or duplicate. | +### SMS -Although the OpenAPI says `PUT /v3/templates/{id}` can update name, category, language, definition, or submit for review, the UI should still protect approved templates with a “duplicate and revise” path when auditability matters. Present immutability as a product-safety choice, not a Sent API fact. +Preview plain text and estimated GSM/UCS-2 segments. Make clear that segment estimates affect billing and are not template body limits. -### 3. Back the UI with Sent template endpoints +### WhatsApp -Keep the UI contract aligned to the verified v3 template operations. +Preview header, body, footer, and buttons. Show sample values, category, language, and provider-review impact. -| UI action | Endpoint | Notes | -|---|---|---| -| Create template | `POST /v3/templates` | Create with header, body, footer, buttons, and review/draft intent. | -| List/search templates | `GET /v3/templates?page=&pageSize=&search=&status=&category=` | Support filtering by status, category, and search. | -| Open template detail | `GET /v3/templates/{id}` | Show name, category, language, status, and definition. | -| Save/update | `PUT /v3/templates/{id}` | Update editable fields or submit for review. | -| Delete | `DELETE /v3/templates/{id}` | Optionally delete from Meta where supported by the API request. | -| Send test after approval | `POST /v3/messages` | Use `template.id` and channel selection. | +### RCS -Use optional `Idempotency-Key` headers when create/update requests may be retried by the frontend or backend. +Current Sent RCS guidance supports text plus up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap capabilities, not current Sent builder controls. Do not generate capability declarations for unavailable features. -### 4. Design the editor around components +Channel routing belongs to the send flow, not the template editor. If routing is shown in a simulator: -Represent the template as a structured definition rather than one text blob. Sent’s docs describe template components such as header, body, footer, and buttons, with practical support differences across SMS, RCS, and WhatsApp. +- omitted `channel` or `["sent"]` means automatic routing and fallback; +- `["rcs"]` pins RCS with no cross-channel fallback; +- multiple explicit values mean broadcast and separate billable messages. -| Component | UI guidance | Channel notes | -|---|---|---| -| Header | Optional title/media area with clear preview. | Most relevant to WhatsApp/RCS; validate per selected channel. | -| Body | Required main content with variable insertion. | Needed across channels; SMS preview should show plain-text length behavior. | -| Footer | Optional low-emphasis text. | Useful for compliance or context where supported. | -| Buttons/actions | Explicit button type and target. | Validate per channel; do not allow unsupported combinations. | -| Variables | Named or positional placeholders with sample values. | Samples are required for review and testing. | +Never describe an explicit RCS-plus-SMS array as ordered fallback. -### 5. Make validation staged and explainable +## Save and review behavior -Run validation in layers so users know whether a problem is a Sent schema issue, a channel support issue, or a policy/review issue. +Use `sandbox: true` for validation. Save with `submit_for_review: false`. Before switching it to `true`, show: -| Layer | Example error | Fix | -|---|---|---| -| Required fields | “Body is required.” | Add body content. | -| Variable samples | “`{{order_id}}` has no sample value.” | Add a realistic sample. | -| Channel support | “SMS cannot render this rich button.” | Remove button for SMS or split channel variants. | -| WhatsApp review risk | “Marketing language in a utility template may be rejected or reclassified.” | Change category or remove promotional content. | -| JSON schema | “Definition does not match Sent template shape.” | Correct JSON before save. | +- category and language; +- rendered previews with sample values; +- channel overrides; +- button actions; +- any warnings; +- the fact that provider review is an external state change. -**Example validation.** If a utility WhatsApp template says “Your order shipped. Add 20% off accessories today,” the UI should warn that promotional content conflicts with utility intent. For SMS, the same content may be syntactically valid but still must align with 10DLC use-case registration. +Do not autosubmit on save. -### 6. Support Sent’s creation paths +## Lifecycle UX -Sent's dashboard exposes four template creation flows at `app.sent.dm/dashboard/templates`. Mirror them by name and intent. +Resource status values currently include `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Keep an unknown-state renderer. -| Path | Best for | UI requirement | -|---|---|---| -| Create from Sample | New users and common templates | Curated examples with editable variables. | -| Create from Scratch | Product teams building custom flows | Guided component editor. | -| Import from Meta | Existing WhatsApp template libraries | Import review, mapping, and status reconciliation. | -| Create From Definition | Developers and migrations | Schema validation, diff view, and clear errors. | +WhatsApp template webhook events use `field: "templates"`, no `sub_type`, and no `event`; the status is `payload.status`. Provider values can include `CATEGORY_UPDATED`, `DISABLED`, and other future strings. See [references/template-status-handling.md](references/template-status-handling.md). -### 7. Preview the send path, not only the design +## Accessibility and failure recovery -An approved template is only useful if it can be sent. Add a test-send preview that asks for Sender Profile/channel context, recipient test number, variable values, and sandbox/production mode where applicable. Show that production sending uses `POST /v3/messages` with the selected `template.id`. +- Associate every error with a field and a summary. +- Do not rely on preview color alone. +- Preserve user edits after validation failures. +- Keep raw JSON inspection available for advanced users. +- Label imported Meta JSON as “Meta Cloud API source” until converted. +- Provide a diff for server normalization and provider-driven category/status changes. -## Common rationalizations to avoid - -Do not build a WhatsApp-only UI and call it a Sent template builder. Sent templates span SMS, WhatsApp, and RCS. - -Do not mark name/language/category immutable as a Sent API fact. The verified update endpoint can update those fields; immutability is a product governance decision. - -Do not show provider policy warnings globally. Only show WhatsApp-specific category/review warnings when WhatsApp is selected. - -Do not hide sample values in an advanced panel. Missing or unrealistic samples are a common review and testing failure. - -Do not rely on frontend validation alone. The backend should validate the Sent request shape and preserve API error messages for users. - -## Verification checklist - -- [ ] The UI starts with template intent and target channels. -- [ ] Statuses match Sent’s documented Draft, Pending, Approved, and Rejected states. -- [ ] Template CRUD maps to verified `/v3/templates` endpoints. -- [ ] Variables cannot be submitted without sample values. -- [ ] Component validation is channel-aware for SMS, WhatsApp, and RCS. -- [ ] WhatsApp-specific category/review warnings are scoped to WhatsApp templates. -- [ ] JSON definition mode validates schema before save. -- [ ] Test-send preview uses `POST /v3/messages` with `template.id` after approval/readiness checks. - -## Related skills - -Use `waba-template-author` when the task is to write or classify WhatsApp template content. - -Use `sms-10dlc-registration` when SMS template copy must match a US A2P campaign use case or opt-out evidence. - -Use `rcs-agent-onboarding` when RCS templates depend on agent approval, fallback behavior, or rich-rendering tests. - -Use `sender-profile-architect` when template ownership, profile scoping, or tenant boundaries are unclear. - -Use `messaging-performance-analyzer` when an approved template sends poorly or webhook evidence shows failures. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/template-validation-matrix.md` | Lookup table | List component support, variable rules, and channel-specific restrictions without bloating the skill body. | -| `references/template-ui-wireflows.md` | Worked examples | Show sample, scratch, Meta import, and JSON creation flows. | -| `references/template-status-handling.md` | Decision matrix | Map Sent status and provider review outcomes to UI actions. | - -## Unverified claims to confirm or remove - -- Whether template name/language/category are immutable after first save is not documented in the snapshot; the `PUT /v3/templates/{id}` endpoint accepts these fields, so behavior should be verified against the live OpenAPI before assuming. Treat product-side locking as a governance choice, not an API fact. -- Mixed-button rules (quick-reply XOR CTA, ordering, per-category constraints) remain external Meta concerns — link to Meta's WhatsApp template docs, do not mirror. -- Template-status webhook event names follow the `.` pattern (the snapshot confirms the envelope) but the snapshot does not enumerate template-specific events. Discover the exact names via `GET /v3/webhooks/event-types` for the account. +Use [references/template-ui-wireflows.md](references/template-ui-wireflows.md) for state transitions. Use `waba-template-author` for copy and policy judgment, `sent-templates` for existing-resource operations, and `rcs-agent-onboarding` for RCS launch readiness. diff --git a/packages/sent/skills/template-builder-ui/references/template-status-handling.md b/packages/sent/skills/template-builder-ui/references/template-status-handling.md index 0f856c2..d9c1b87 100644 --- a/packages/sent/skills/template-builder-ui/references/template-status-handling.md +++ b/packages/sent/skills/template-builder-ui/references/template-status-handling.md @@ -1,75 +1,43 @@ -# Template Status Handling - -The lifecycle states a Sent template moves through, which fields are editable in each, how the UI gets notified of upstream changes, and how to handle resubmission. The builder UI tracks every template against the state machine below. - -## Status enum - -Sent's template status enum is exactly four states the UI renders, regardless of underlying channel: `Draft`, `Pending`, `Approved`, `Rejected`. There is **no `PAUSED` state in Sent.** Meta's upstream `PAUSED` flag exists Meta-side only and is not surfaced into the Sent template-status enum. - -| State | Meaning | Source | -|---|---|---| -| `Draft` | Authored locally, not yet submitted | Sent-internal | -| `Pending` | Submitted, awaiting review (WhatsApp = Meta review, typically 24–48 hours per Sent docs; RCS = Google review; SMS does not gate on review) | Set by Sent on submit, cleared by upstream callback | -| `Approved` | Live and sendable | Set by Sent on upstream approval | -| `Rejected` | Upstream rejected the submission | Set by Sent on upstream rejection, carries a `rejection_reason` payload | - -Other upstream states (Meta `PAUSED`, `DISABLED`, `IN_APPEAL`, etc.) do not appear as Sent statuses. If your UI needs to surface a Meta-only signal (e.g. a deliverability pause), treat it as a secondary annotation on an `Approved` row — never bucket those rows as `Rejected`. - -**During `Pending`, SMS sends still work.** Per Sent docs, a template can be sent over SMS while it is still awaiting WhatsApp approval — the `Pending` status gates only the channels that require upstream review. Reflect this in the UI: don't grey out the entire row, only the WhatsApp/RCS send actions. - -## Editable fields by state - -| Field | Draft | Pending | Approved | Rejected | -|---|:-:|:-:|:-:|:-:| -| Name | edit | locked | locked | locked | -| Language | edit | locked | locked | locked | -| Category | edit | locked | locked (Meta may silently change it — see below) | locked | -| Channel | edit | locked | locked | locked | -| Body | edit | locked | edit (creates v2) | edit | -| Header type | edit | locked | locked | edit | -| Header text/media | edit | locked | edit | edit | -| Footer | edit | locked | edit | edit | -| Button types (radio) | edit | locked | locked | edit | -| Button labels | edit | locked | edit | edit | -| Variable samples | edit | locked | edit | edit | - -Editing an **Approved** template's editable fields does not mutate the live template — it creates a new version on submit, while the prior version continues to send until the new one is approved. Make this obvious in the editor footer ("Submitting creates v2; v1 keeps sending until v2 is approved"). - -In **Pending**, surface a "Withdraw and edit" affordance — it calls the Sent withdraw endpoint and moves the template back to Draft. - -> The locks above are a **product-governance** choice. The Sent v3 `PUT /v3/templates/{id}` endpoint accepts name/language/category in its request body; whether those fields are truly immutable server-side after first save is not documented in the snapshot and should be verified against the live OpenAPI before relying on it. - -## Webhook vs polling - -Sent webhook events follow a top-level `field` + `sub_type` envelope, with `sub_type` formatted as `.` (e.g., `message.delivered`, `message.failed`). Template status changes are inferred to follow the same pattern (e.g., `template.approved`, `template.rejected`, or a single `template.status_changed`) — the snapshot confirms the envelope but does not enumerate template-specific events. **Discover the exact event names via `GET /v3/webhooks/event-types` for your account** and subscribe via `POST /v3/webhooks` with the relevant `event_types` / `event_filters` shape. - -Two reasonable UI approaches: - -- **Webhook + realtime fanout** (preferred). The backend receives the Sent webhook, fans out to the relevant tenant's realtime channel (Pusher / Ably / Supabase Realtime / WebSocket), and the list row updates in place. Lowest latency, no client polling load. Use when you already have a realtime layer for other reasons. -- **Short polling** (acceptable fallback). The list view polls `GET /v3/templates?status=pending` every 5–10s while any row is in Pending; stops polling when none remain. Simpler to ship; more network load. Use when you don't yet have realtime infrastructure. - -Do *not* poll per-row — always poll the list filter — and do not poll forever. Cap at e.g. 30 min after submit; beyond that, the tenant must refresh. - -## Rejection reason display: Meta vs Sent - -Two distinct rejection sources, and the UI should render them differently: - -- **Meta-surfaced rejection.** The `rejection_reason` Sent forwards from Meta's template-status update. Render this as a sticky banner with the human-readable reason on top, the raw Meta enum (e.g. `INVALID_FORMAT`, `ABUSIVE_CONTENT`, `INCORRECT_CATEGORY`) collapsed by default, and a Sent-maintained remediation hint mapped from the enum. -- **Sent-surfaced rejection.** When Sent's own pre-submission validation (the server-side mirror of the matrix) rejects the payload before forwarding to Meta. Render with a different icon and label ("Caught by Sent before submission") so tenants don't think Meta reviewed the template. - -Never show the raw Meta JSON — it's noisy and changes shape. Always go through the Sent-normalized rejection-reason API. - -## Resubmission flow - -After editing a Rejected template: - -1. The Submit button triggers a *new submission attempt* against the existing template record. The template ID stays stable; only the `attempt_id` increments. -2. The list row transitions Rejected → Pending in place; do not create a duplicate row. -3. The rejection-reason banner is dismissed automatically when the new attempt enters Pending. -4. On Approval, the row turns green and the banner stays gone. On a fresh Rejection, render the new reason — and add a "previous reasons" disclosure showing the prior failures so tenants can see they're not regressing. - -For Approved templates, "resubmit" is really "submit a new version" — covered above in Editable fields. - -## Silent re-categorization - -Meta may re-categorize an Approved template (most commonly `UTILITY` → `MARKETING`) without changing its status. If Sent surfaces this (event name not enumerated in the snapshot — verify against `GET /v3/webhooks/event-types`), the list-row UI should render a one-time dismissible banner ("Meta moved this template to Marketing — it will now bill at marketing rates"). Tenants who miss this end up surprised by billing. See the `template-builder-ui` SKILL.md for the editor-side treatment. +# Template status handling + +## Resource states + +Sent template resources currently surface `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Use an unknown state in the UI because contracts evolve. + +## Provider webhook states + +WhatsApp approval events use this envelope: + +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "CATEGORY_UPDATED", + "language": "en_US", + "category": "MARKETING", + "channel": "whatsapp", + "reason": "Provider category update" + } +} +``` + +Template events have `field: "templates"` and no `sub_type` or `event`. Known provider values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; values such as `PAUSED` and `DISABLED` may be forwarded verbatim. + +## UI behavior + +| Value | UI response | +| --- | --- | +| `DRAFT` | Editable; offer validate and submit actions. | +| `PENDING` | Lock provider-reviewed fields and show submission time. | +| `APPROVED` | Show usable status and immutable submitted content. | +| `REJECTED` | Show the reason and create a revision path. | +| `PAUSED` / `DISABLED` | Block new WhatsApp usage and surface remediation. | +| `CATEGORY_UPDATED` | Show old/new category when known and re-evaluate pricing/policy UX. | +| Unknown | Preserve raw value, use a neutral badge, and avoid destructive assumptions. | + +Verify webhook signatures, deduplicate transitions, retrieve the current resource before overwriting local state, and tolerate delayed or out-of-order deliveries. Polling may be used as recovery, not as evidence that invented webhook event names exist. diff --git a/packages/sent/skills/template-builder-ui/references/template-ui-wireflows.md b/packages/sent/skills/template-builder-ui/references/template-ui-wireflows.md index dcde0a4..62e3087 100644 --- a/packages/sent/skills/template-builder-ui/references/template-ui-wireflows.md +++ b/packages/sent/skills/template-builder-ui/references/template-ui-wireflows.md @@ -1,104 +1,49 @@ -# Template UI Wireflows +# Template builder wireflows -The user journeys a Sent template builder must support. Each flow is a UX spec — entry point, screen sequence, state at each step, validation gates, exit conditions — written so a frontend engineer can implement against it without re-deriving the requirements. +## New draft -The dashboard surface lives at `app.sent.dm/dashboard/templates`. The list view supports search/filter by **name**, **category** (Marketing / Utility), **description**, and **status** (Draft / Approved / Pending / Rejected). Each row exposes per-template actions: **edit**, **delete**, **view analytics**. +`intent → multiChannel body → variables → overrides → optional components → previews → validation → draft save` -There are four template creation flows, exposed in the dashboard exactly as: **Create from Scratch**, **Import from Meta**, **Create From Definition**, **Create from Sample**. +- The internal draft may have a product label, but serialization drops it because create does not accept top-level `name`. +- Variable insertion writes the placeholder and entity together. +- Switching a channel override off preserves it locally until the user confirms deletion. -## Flow 1: Create from Scratch +## Validate -**Entry:** Templates list (`app.sent.dm/dashboard/templates`) → "Create from Scratch" action. +`serialize → local linter → fix field errors → sandbox request → reconcile server response` -1. **Channel select** (if the tenant has more than one channel provisioned). One-step picker: SMS / WhatsApp / RCS. Locks the validator set (see `references/template-validation-matrix.md`). -2. **Category picker** (WhatsApp only). `UTILITY` / `MARKETING` / `AUTHENTICATION`, each with one-line definition + example. -3. **Name + language** form. Name validated against `^[a-z][a-z0-9_]{0,511}$`; show `_v1` suffix nudge. Language is a typed-search picker over BCP-47 codes. -4. **Editor + preview split**. Component editor left, live preview right, sticky sample editor bottom-right. -5. **Submit gate.** Submit button disabled until all blocking validations pass. On click: confirm modal that shows the final payload diff ("here's what we're sending to Meta"). -6. **Optimistic insert.** Template row appears in the list with `Pending` status before the network round-trips. -7. **Status polling / webhook subscription.** See `references/template-status-handling.md`. +- Mark Meta `components[]` imports as unconverted and block Sent submission. +- Preserve the last valid server response separately from the working draft. +- Server normalization should appear as a reviewable diff. -## Flow 2: Create from Sample (clone) +## Submit for review -**Entry:** Templates list → "Create from Sample" action, or existing template row → "Duplicate". +`draft → final preview → confirmation → submit_for_review=true → PENDING or returned state` -1. Open the editor pre-filled with the source template's full state (curated sample, or an existing template). -2. **Name field is empty and focused** — the user must pick a new name; the original `(name, language)` pair is treated as permanent by the builder. -3. The category and channel are pre-selected and locked to match the source (changing channel is "create new", not "duplicate"). -4. From here: identical to Flow 1 from step 4 onward. +Do not autosubmit. The confirmation displays category, language, samples, buttons, overrides, and warnings. -Clone is the most-used "create" path in practice — make it 1-click from any approved template, including across languages (duplicate + change language). +## Status changes -## Flow 3: Import from Meta +`DRAFT → PENDING → APPROVED | REJECTED | PAUSED | unknown` -**Entry:** Templates list → "Import from Meta" action (WhatsApp only). +This is a UI model, not a promise of an exhaustive provider state machine. Template webhooks can forward `CATEGORY_UPDATED`, `DISABLED`, or future values. Render unknown states and keep raw values. -1. Modal: paste the raw template JSON from the Meta dashboard's "View JSON" panel. -2. Parser maps Meta's shape into Sent's internal template model. -3. **Diff preview**: show fields that didn't round-trip cleanly (e.g. Meta has a property Sent doesn't model yet). Tenant can proceed or cancel. -4. Land in the editor (Flow 1 from step 4) with all parsed fields filled. Validation runs immediately so any Meta-side state that violates current rules surfaces as inline errors. -5. Sent treats the import as a *draft* — the imported template is not auto-submitted; the tenant must click Submit. +## Rejection recovery -## Flow 4: Create From Definition (advanced) +`webhook/poll → retrieve current resource → show reason → fork editable revision → lint → sandbox → confirm resubmission` -**Entry:** Templates list → "Create From Definition" action, or Editor → "Edit as JSON" toggle (available on Draft only). +Avoid destructive in-place edits when content is locked. Keep the submitted version and revision history visible. -1. Editor swaps to (or opens directly into) a monaco-style JSON pane showing Sent's internal template schema. -2. **Schema-guided autocomplete** (using the same JSON schema that drives the matrix). -3. Live validation + preview continue to run against the JSON. -4. "Back to form" toggle round-trips if the JSON is currently valid; greyed out if not (don't silently discard). -5. Submit gate is identical to Flow 1. +## RCS preview -This is the escape hatch for power users and for tenants whose IDE-driven workflows generate templates programmatically. +`text → zero-to-four suggestion chips → device preview` -## Flow 5: Edit +Do not add rich-card, carousel, or attachment branches to current Sent workflows. If a mockup illustrates future capability, label it roadmap-only and exclude it from serialized requests. -**Entry:** Existing template row → "edit" action. +## Routing simulator -State of the editor depends on the template's lifecycle state (see `references/template-status-handling.md`): +If the product includes a send simulator, keep routing semantics explicit: -- **Draft** → all fields editable. -- **Pending** → editor is read-only with a "Pending review — edit blocked" banner. Offer "Withdraw and edit" which moves the template back to Draft via the Sent API. -- **Approved** → name, language, and category are locked. Body, header (text only, not media swap), footer, button labels (not button types) are editable. Editing an Approved template creates a *new version* on submit — surface this prominently ("Submitting will create v2; v1 keeps sending until v2 is approved"). -- **Rejected** → all fields editable; the rejection-reason banner is sticky at the top of the editor (see Flow 8). - -## Flow 6: Status transitions visible in UI - -Every list row shows a status pill. Sent's status enum is exactly `Draft` / `Pending` / `Approved` / `Rejected` — there is no `PAUSED` state surfaced in Sent. Allowed transitions: - -``` -Draft ─submit→ Pending ─approved upstream→ Approved - └─rejected upstream→ Rejected ─edit→ Draft -``` - -Pills use Sent's design-system tokens — not raw Meta colors — so the same component renders for SMS templates and RCS templates. - -Transitions animate (fade pill color), and the row's last-updated timestamp updates on each transition so tenants can correlate with WhatsApp review SLA (per Sent docs, typically 24–48 hours). - -## Flow 7: Submit - -The submit click is the *only* destructive step in the builder. Treat it carefully: - -1. **Pre-submit checks** (client-side): - - Run the full validation matrix; any blocking failure aborts. - - Open a confirmation modal showing: final preview render, the JSON that will hit Meta, and a "What happens next?" explainer (review SLA, billing implication for marketing, etc.). -2. **POST to Sent's template-submit endpoint** (`POST /v3/templates`, optionally with `Idempotency-Key`). Show a button spinner. -3. **On 2xx**: dismiss the modal; insert an optimistic `Pending` row into the list; clear the draft state. -4. **On 4xx**: surface the Sent error message inline in the modal — do not dismiss; do not lose the user's work. -5. **Subscribe to status** — see `references/template-status-handling.md` for the webhook-vs-polling tradeoff. - -## Flow 8: Reject-recover - -When a template lands in `Rejected`: - -1. The list row's status pill is red and clickable. -2. Clicking opens the editor with a sticky banner at the top: human-readable rejection reason, the raw Meta `reason` collapsed by default, and a "Remediation" callout that maps the rejection code to a concrete fix ("Variable samples contained promotional language — rewrite samples as neutral data and resubmit"). -3. The editor pre-focuses the field most likely to be wrong (e.g. the offending sample input). -4. The tenant edits and clicks Submit — the resubmission creates a new attempt with a new `attempt_id` server-side; the list row updates in place rather than duplicating. - -## Cross-cutting notes - -- Every flow that lands in the editor reuses the same editor component — channel + category state determine which sub-forms render. There is *not* a separate editor per channel. -- Every flow respects the validation matrix at every keystroke; submit is the only network call. -- Optimistic UI is acceptable for insertions (Draft creation, submit) but not for status transitions — those must come from the server. -- All confirmation modals are dismissable with Escape; the Submit modal additionally requires explicit confirm (no Enter-key auto-submit). +- no `channel` / `["sent"]`: automatic routing and fallback; +- one explicit channel: pinned; +- two or more explicit channels: broadcast with one message per recipient/channel pair. diff --git a/packages/sent/skills/template-builder-ui/references/template-validation-matrix.md b/packages/sent/skills/template-builder-ui/references/template-validation-matrix.md index f25a368..41964f5 100644 --- a/packages/sent/skills/template-builder-ui/references/template-validation-matrix.md +++ b/packages/sent/skills/template-builder-ui/references/template-validation-matrix.md @@ -1,106 +1,60 @@ -# Template Validation Matrix - -Per-channel rules a Sent template builder UI must enforce client-side, with the surface treatment for each failure mode. Use this as the single source of truth when wiring validators into the editor — the same matrix should drive the submission handler, so a "soft warning" never silently becomes a hard reject downstream. - -**Surface treatments** referenced below: - -- **Inline error** — red text under the field, focused on save attempt, does not block typing. -- **Blocked save** — the Submit button is disabled until resolved; tooltip explains why. -- **Soft warning** — amber banner or icon, save still allowed, but the tenant must acknowledge. - -## Component support matrix (Sent-confirmed) - -| | SMS | RCS | WhatsApp | -|---|:-:|:-:|:-:| -| Header | ❌ | ✅ | ✅ | -| Body | ✅ | ✅ | ✅ | -| Footer | ❌ | ✅ | ✅ | -| Buttons | ❌ | ✅ | ✅ | - -Header types (where supported): `TEXT`, `IMAGE`, `VIDEO`, `DOCUMENT`. - -Button types (where supported): `QUICK_REPLY`, `URL`, `PHONE_NUMBER`. - -Template categories (apply to WhatsApp review; not a Sent enum extension): `UTILITY`, `MARKETING`, `AUTHENTICATION`. No others. - -## Character limits (Sent-confirmed) - -| Channel | Component | Limit | -|---|---|---| -| SMS | Body | 160 chars per segment (GSM-7); longer messages split | -| WhatsApp | Body | 1028 chars | -| WhatsApp | Header (text) | 60 chars (optional component) | -| WhatsApp | Footer | 60 chars (optional component) | -| RCS | Body | 1028 chars | -| RCS | Header | 60 chars (optional component) | - -## SMS - -SMS templates support **Body only** — no Header, Footer, or Buttons. All rules below apply to body content. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Body length (GSM-7) | 160 chars per segment | Inline char + segment counter; soft warning at 4+ segments | -| Body length (UCS-2) | 70 chars per segment (triggered by any non-GSM-7 char, e.g. emoji, curly quotes) | Detect encoding on every keystroke; soft warning at first UCS-2 char ("This template now bills as Unicode — N segments") | -| Total body length | Hard cap at 1600 chars (10 UCS-2 segments) | Blocked save above cap | -| Opt-out language | A2P templates must include `Reply STOP to opt out` (or equivalent) at least once across the campaign's template set | Soft warning per template; campaign-level check elsewhere | -| Link shortening | Public domain shorteners (bit.ly, tinyurl) are heavily filtered by carriers | Soft warning when a known shortener domain appears in the body; suggest the tenant's branded short domain | -| Variable placeholders | Use the placeholder format the SMS template model expects (named or ordinal — see https://docs.sent.dm). Stay consistent across all SMS templates in a project. | Inline error on mismatched placeholder style | -| Sender ID injection | Alphanumeric sender IDs not allowed in US 10DLC | Blocked save if the tenant tries to set a non-numeric sender on a US campaign | -| Public URL preview | Long URLs eat segments fast | Show effective char-with-URL count; soft warning if URL is >40 chars | - -For campaign-level filtering rules that gate which SMS templates are sendable, use `sms-10dlc-registration`. - -## WhatsApp - -Rules are *category-aware* — `UTILITY`, `MARKETING`, and `AUTHENTICATION` each have a different shape. The builder's category picker (per `SKILL.md`) reshapes which validators apply. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Name format | `^[a-z][a-z0-9_]{0,511}$` | Inline error; auto-snake_case the input | -| Name+language permanence | Treat as immutable as a product-governance choice (the Sent v3 `PUT /v3/templates/{id}` accepts these fields, but the UI should still steer tenants to a versioned `_v2` flow for auditability) | Blocked save when editing; show `_v1` suffix nudge for new versions | -| Body length | 1028 chars across all categories | Inline char counter; blocked save above cap | -| Body required | Required for utility/marketing; auth body is fixed by Meta | Blocked save if empty | -| Header type | One of: none / `TEXT` / `IMAGE` / `VIDEO` / `DOCUMENT` | Radio control — invalid combos unreachable | -| Header (text) length | 60 chars, max 1 variable | Inline error | -| Header (media) sample | Sample upload required at submit | Blocked save without a sample asset | -| Footer length | 60 chars, no variables | Inline error; strip `{{` on paste | -| Buttons — mutually exclusive | `QUICK_REPLY` XOR CTAs (`URL` + `PHONE_NUMBER`). Mixing is a Meta reject. | Top-level button-type radio prevents construction; never allow per-button type picks | -| Quick replies | Max 3, 25 char labels | Add button hidden at 3; inline error on label length | -| CTA buttons | Max 2 total, mix of `URL` + `PHONE_NUMBER` allowed | Add button hidden at 2 | -| URL CTA variables | Max 1 trailing variable, must be the URL suffix (`https://example.com/orders/{{1}}`) | Inline error on inline variables | -| Variable placeholders | `{{n}}` ordinal, monotonically increasing from `{{1}}` | Autocomplete next index; inline error on gaps (`{{1}}` then `{{3}}`) | -| Variable samples | Required at submit, non-empty | Sticky sample editor + blocked save until all filled | -| Sample neutrality | Promotional words in samples (off / sale / free / now / discount / deal …) trigger Meta re-categorization | Soft warning on the offending sample field | -| Authentication body | Fixed copy with `{{1}}` for OTP | Body field becomes read-only when category = `AUTHENTICATION` | -| Authentication buttons | Single Copy code / One-tap button + `code_expiration_minutes` | Component editor swaps shape | - -For the category decision tree the picker presents, use `waba-template-author`. Mixed-button policy details remain an external Meta concern — link, do not mirror. - -## RCS - -RCS templates are richer (rich cards, suggested replies, suggested actions) but the channel mandates an SMS-text fallback for any device that can't render RCS. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| SMS fallback body | Required, follows the SMS rules above | Blocked save without fallback; auto-derive from card title+description as a starting point | -| Body length | 1028 chars | Inline counter | -| Header (text) length | 60 chars | Inline counter | -| Rich card title | 200 chars | Inline counter | -| Rich card description | 2000 chars | Inline counter | -| Card media | Image or video; image ≤ 100 KB for "short height", ≤ 2 MB max; video ≤ 100 MB | Blocked save on oversize; soft warning at >50 KB image for short-height layout | -| Card orientation | Vertical or horizontal | Radio; preview swaps layout | -| Suggested replies | Max 11 across the message; 25 chars each | Add button hidden at 11; inline error on label length | -| Suggested actions | Max 11 across the message (shared cap with replies); types include dial, openUrl, viewLocation, shareLocation, createCalendarEvent | Add button hidden when total = 11 | -| Action URL | Must be HTTPS, valid URL | Inline error | -| Carousel cards | 2-10 cards, all same orientation | Blocked save outside range; orientation locked after first card | -| Variable placeholders | Match Sent's RCS template placeholder format — see https://docs.sent.dm | Inline error on mismatched style | -| Fallback parity | Variables in the rich content must also resolve in the SMS fallback | Soft warning if a variable appears only in one | - -For agent-level capability gating that determines whether RCS is an option for a recipient, use `rcs-agent-onboarding`. - -## Cross-channel notes - -- The builder may target *one channel at a time* — do not let tenants compose a "WhatsApp + RCS combined template" in the same form. Channel selection is the first decision after category (where applicable) and locks the validator set. -- All three channels run validation on every keystroke (debounced) so the submit button reflects current state. -- The matrix is the contract between client validation and server validation — keep it codified (JSON schema or equivalent) and re-export to both sides. +# Template validation matrix + +This matrix maps UI controls to the body of `POST /v3/templates`. + +## Request root + +| Field | Client rule | +| --- | --- | +| `definition` | Required object. | +| `category` | Optional `UTILITY`, `MARKETING`, or `AUTHENTICATION`. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional string. | +| `submit_for_review` | Boolean; default to false in the editor. | +| `sandbox` | Boolean; use true for validation. | + +Reject top-level `name`, `channels`, `body`, `header`, `buttons`, and `components`. A `components[]` import is Meta Cloud API source and requires conversion. + +## Definition + +| Field | Rule | +| --- | --- | +| `body.multiChannel` | Required body content. | +| `body.sms`, `body.whatsapp`, `body.rcs` | Optional complete overrides using the same content schema. | +| `header.template` | Optional, maximum 60 characters. | +| `footer.template` | Optional, maximum 60 characters, no variables. | +| Body `template` | Required non-empty text, maximum 1,024 characters. | +| `definitionVersion` | Optional string; preserve when editing. | +| `authenticationConfig` | Only for `AUTHENTICATION`. | + +Every placeholder uses `{{id:type}}`, for example `{{0:variable}}`. Each must map one-to-one to a variable with a non-negative integer `id`, readable `name`, matching `type`, and non-empty `props.sample`. IDs are unique within a body. + +## Buttons + +| Type | Total allowed | Required properties | +| --- | ---: | --- | +| `QUICK_REPLY` | Up to the overall total of 10 | `text`, `quickReplyType` | +| `URL` | 2 | `text`, `urlType`, `url` | +| `VOICE_CALL` | 1 | `text`, `countryCode`, `phoneNumber` | +| `PHONE_NUMBER` | 1 | `text`, `countryCode`, `phoneNumber` | +| `COPY_CODE` | 1 | `text`, `offerCode` | + +There are at most 10 buttons in total, and button text is at most 25 characters. Quick replies and CTA buttons may coexist. + +## Authentication + +`authenticationConfig` accepts `addSecurityRecommendation` and optional `codeExpirationMinutes` from 1 through 90. Authentication templates should contain one code variable and one copy-code action, with no promotion, unrelated media, URL, or call action. + +## Channel-specific product rules + +| Channel | Current UI capability | +| --- | --- | +| SMS | Plain text preview and segment estimate. | +| WhatsApp | Header, body, footer, variables, and supported buttons. | +| RCS | Text and up to four suggestion chips. | + +Do not expose current Sent controls for RCS rich cards, carousels, or media attachments. They are roadmap features. Do not require an SMS fallback body; automatic routing is a send-time choice made by omitting `channel` or using `["sent"]`. + +## Server round trip + +Client validation is advisory. Serialize the exact Sent request, run the bundled linter, validate with `sandbox: true`, and reconcile server errors by field. Never silently rewrite submitted copy. diff --git a/packages/sent/skills/waba-embedded-signup/SKILL.md b/packages/sent/skills/waba-embedded-signup/SKILL.md index 3211f12..f1fa1c6 100644 --- a/packages/sent/skills/waba-embedded-signup/SKILL.md +++ b/packages/sent/skills/waba-embedded-signup/SKILL.md @@ -1,168 +1,124 @@ --- name: waba-embedded-signup -description: Guides WhatsApp Business Account connection for Sent Sender Profiles, including Embedded Signup planning, WABA and phone-number mapping, token/security handling, webhook readiness, and profile completion. Use when a user says Embedded Signup, WABA, connect WhatsApp, WhatsApp sender, phone number ID, Facebook Login for Business, Meta Business, sender profile WhatsApp setup, or webhooks not firing after WhatsApp signup. +description: Guides WhatsApp Business Account onboarding through Sent, separating dashboard Embedded Signup, organization WABA inheritance, and direct child-profile credentials. Use for WABA connection, Meta signup, profile creation, access-token handling, phone number mapping, completion callbacks, or WhatsApp onboarding failures. --- - +Keep three integration paths distinct. Calling all of them “Embedded Signup” creates wrong API designs and unsafe credential handling. -# WABA Embedded Signup +## The three paths -## Overview +| Path | Where it starts | Profile behavior | +| --- | --- | --- | +| Organization Embedded Signup | Sent dashboard | Connects the organization's WABA through the hosted Meta flow. There is no public Sent endpoint that starts this flow. | +| Organization WABA inheritance | `POST /v3/profiles` | Omit `whatsapp_business_account`; the child inherits the organization's connected WABA. | +| Dedicated child-profile WABA | `POST /v3/profiles` | Supply `whatsapp_business_account.waba_id` and `.access_token`; `phone_number_id` is optional. | -Use this skill to connect a WhatsApp Business Account (WABA) and phone number to a Sent Sender Profile without confusing Sent-side setup with Meta-side implementation details. Sent’s dashboard exposes Sender Profiles and WhatsApp configuration status. Sent’s profile API exposes profile CRUD and a profile-completion workflow. The public Sent sources reviewed for this rewrite did not expose a dedicated Embedded Signup endpoint, so direct Meta Graph flows should be treated as external integration context unless the user confirms that their application owns that flow. +If credentials are omitted and the organization has no connected WABA, profile creation returns `422`. Direct WABA credentials are a profile-creation feature, not a public “Embedded Signup endpoint.” -The safest workflow is to decide the integration path first: Sent-managed WhatsApp setup, customer-managed Meta Embedded Signup connected back to Sent, or a hybrid implementation coordinated with Sent. +## Authentication -## When to use +Use either: -Use this skill when the user mentions Embedded Signup, WABA, WhatsApp Business Account, WhatsApp phone number, phone number ID, Facebook Login for Business, Meta Business Manager, connecting WhatsApp to Sent, WhatsApp sender setup, Sender Profile WhatsApp status, or webhook delivery after WhatsApp onboarding. +- a profile-specific key in `x-api-key`; or +- an organization key in `x-api-key` plus `x-profile-id` when operating for an existing child profile. -Do not use this skill to author WhatsApp templates; use `waba-template-author`. Do not use it to build a generic Meta app unless the user explicitly asks for a Meta-side implementation. Do not claim Sent exposes Embedded Signup endpoints unless the account/docs confirm them. +Only organization keys may use `x-profile-id`; profile keys receive `403`. `x-sender-id` is legacy v1/v2 terminology. -## Process +## Path A: organization Embedded Signup -### 1. Decide the integration path first +1. An authorized organization administrator opens the Sent dashboard WhatsApp connection flow. +2. The hosted Meta Embedded Signup UI collects the Meta authorization and WABA/number choices. +3. Confirm the organization shows a connected WABA before creating inheriting children. +4. Record non-secret identifiers and audit who completed the action. -Start every session by asking which path applies. The answer changes what the agent should do next. +Do not invent a `POST /embedded-signup` or token-exchange endpoint in Sent's public API. If building your own Meta Tech Provider integration outside the Sent dashboard, follow Meta's current documentation and keep that system separate from the Sent API contract. -| Path | Use when | Agent role | -|---|---|---| -| Sent-managed setup | The customer wants Sent to guide or operate WhatsApp connection. | Prepare Sender Profile, business evidence, phone-number details, and handoff notes. | -| Customer-managed Embedded Signup | The customer’s app launches Meta Embedded Signup and passes results to Sent. | Review Meta-side security and mapping, then align results to Sent profile completion. | -| Migration/import | The customer already has a WABA/phone number and needs it represented in Sent. | Collect WABA/phone identifiers, ownership evidence, and profile mapping. | +Meta's browser `postMessage` events use an `event` field and nested data/session information. Do not rewrite them as Sent webhook `sub_type` envelopes. -If the user cannot answer, default to Sent-managed setup and avoid prescribing Graph API calls. +## Path B: inherit the organization WABA -### 2. Identify the Sender Profile +Omit `whatsapp_business_account`: -Locate or create the Sender Profile that will own the WhatsApp sender identity. Use the Sent dashboard or `/v3/profiles`. Record the Sent profile ID, `x-sender-id` if visible, display name, brand description, and intended WhatsApp phone number. - -A WhatsApp number should map to the same recipient-visible brand represented by the profile. If the number belongs to a different brand, department, or tenant, use `sender-profile-architect` before proceeding. - -### 3. Collect WhatsApp onboarding evidence - -Collect the minimum evidence needed for Sent or Meta review. - -| Evidence | Why it matters | -|---|---| -| Business legal name and Meta Business identity | Confirms the WABA belongs to the intended sender. | -| Public website and privacy policy | Supports business verification and template review. | -| Phone number and ownership/control evidence | Prevents connecting the wrong sender. | -| Display name | Must match the business identity recipients expect. | -| Use cases and example messages | Drives template authoring and policy review. | -| Webhook endpoint and owner | Needed to verify event delivery after connection. | - -### 4. Map external identifiers without making them the Sent contract - -If the user provides WABA ID, phone-number ID, Meta Business ID, or System User details, store them as external provider identifiers mapped to the Sent profile. Do not make those IDs the primary application sender key. Use Sent profile ID and Sent message IDs for Sent operations. - -**Example mapping.** - -```text -sent_profile_id: 2b1b... -x_sender_id: support_us -channel: whatsapp -provider: meta -provider_business_id: external value, if available -provider_waba_id: external value, if available -provider_phone_number_id: external value, if available -status_source: Sent dashboard/API or Meta integration logs +```json +{ + "name": "Tenant Support", + "description": "Synthetic child profile", + "short_name": "SUPPORT", + "inherit_templates": true, + "billing_model": "organization", + "sandbox": true +} ``` -### 5. Complete or re-check the Sent profile - -Use `/v3/profiles/{profileId}/complete` when prerequisites are ready and API completion is in scope. The OpenAPI describes profile completion as a background process that validates prerequisites and connects profile configuration. If completion returns missing prerequisites, fix those inputs rather than creating duplicate profiles. - -### 6. Verify webhook readiness - -Use Sent webhook endpoints to confirm event delivery. Verify the webhook exists, the relevant event types are available, and a test event reaches the customer endpoint via `POST /v3/webhooks/{id}/test`. +Use this only after the organization WABA is connected. Inheritance means the tenant shares that WABA boundary; confirm this matches the tenant/brand architecture. -Rotate webhook secrets only when needed and coordinate deployment, because secret rotation invalidates the old secret immediately. +## Path C: dedicated WABA credentials -## Meta-side implementation review - -Use this section only when the user confirms that their application owns Embedded Signup. Label the work as Meta-side. Validate security, mapping, and callback handling before connecting results to Sent. - -| Area | Check | -|---|---| -| Launch context | Embedded Signup is launched from the right app, business, and allowed origin. | -| Callback handling | The app captures the signup result, not just a UI success state. | -| Token exchange | Authorization codes/tokens are exchanged server-side, never in public frontend storage. | -| Scope verification | Returned permissions/granular scopes match the required WABA and phone-number access. | -| Identifier lookup | WABA ID and phone-number ID are read back and mapped to the Sent profile. | -| Phone registration | Registration is completed only if the integration path requires the customer app to do it. | -| App subscription | Webhook subscription is completed only if the integration path requires direct Meta callbacks. | -| Secret handling | Tokens and webhook secrets are encrypted, rotated, and not logged. | - -Do not assume the customer app must call every Meta endpoint. Sent may abstract parts of onboarding depending on the customer’s setup. - -## Troubleshooting patterns - -| Symptom | First check | Likely next step | -|---|---|---| -| Sender Profile still shows WhatsApp not configured | Sent profile prerequisites and completion status | Confirm whether Sent-managed setup or external Embedded Signup results were expected. | -| User completed Meta flow but Sent cannot send | Mapping between external phone/WABA and Sent profile | Provide identifiers/evidence to Sent or update integration mapping. | -| Templates remain unavailable | Template status and WhatsApp business review | Use `waba-template-author` and Sent template status. | -| Webhooks not firing | Sent webhook test and event history | Fix endpoint/subscription before blaming WhatsApp delivery. | -| Wrong tenant receives events | Profile/message ID mapping | Use `sender-profile-architect` to redesign routing. | - -## Common rationalizations to avoid - -Do not treat a Meta UI success screen as proof that Sent can send WhatsApp messages. Verify Sent profile/channel status and test sends. - -Do not store access tokens in browser storage or logs. Treat Meta tokens and Sent webhook secrets as production credentials. +```json +{ + "name": "Dedicated Tenant", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -Do not assume a WABA can be reused across unrelated brands or tenants. Recipient-visible identity and operational ownership matter. +`waba_id` and `access_token` are required. `phone_number_id` is optional: when omitted, the current contract describes provisioning and registration during onboarding. -Do not hardcode Graph API version, scope names, or endpoint sequences in this skill body. Keep those in a reference file and re-check Meta docs or Sent implementation guidance before use. +The token needs the applicable WhatsApp Business messaging and management permissions. Inject it from a secret manager. Never log it, echo it, write it to fixtures, return it to the browser, include it in support output, or retain it in general profile storage. Sent does not return it in API responses. -Do not rotate Sent webhook secrets without coordinating the receiving endpoint. +## Complete the profile -## Verification checklist +Call `POST /v3/profiles/{profileId}/complete` with the required `webHookUrl`: -- [ ] The integration path is identified as Sent-managed, customer-managed Embedded Signup, or migration/import. -- [ ] The correct Sent Sender Profile is identified before external identifiers are mapped. -- [ ] WhatsApp business identity, phone-number evidence, display name, and use cases are collected. -- [ ] WABA/phone-number IDs are stored as external identifiers, not as the primary Sent sender key. -- [ ] Profile completion is run or checked after prerequisites are ready. -- [ ] Sent webhook existence, event types, event history, and test delivery are verified. -- [ ] Meta Graph steps are only prescribed when the user confirms direct ownership of Embedded Signup. -- [ ] Tokens, webhook secrets, and callback data are handled server-side and securely. +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -## Related skills +- `202` means background processing started; there is no final status in that response. +- `200` can mean the profile was already complete and currently demonstrates lowercase `completed`. +- The completion callback can report `COMPLETED`, `SUBMITTED`, or `failed`. -Use `sender-profile-architect` when deciding whether a WABA or phone number belongs in a separate Sender Profile. +Treat the completion callback as its own integration surface. Its envelope uses `event`, not `sub_type`: -Use `waba-template-author` when the WhatsApp sender needs templates written, categorized, submitted, or revised. +```json +{ + "event": "COMPLETED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" +} +``` -Use `template-builder-ui` when building the UI that imports or manages WhatsApp templates inside Sent. +Preserve unknown event strings. Verify authenticity using the mechanism Sent documents for the callback endpoint and make processing idempotent. -Use `messaging-performance-analyzer` when WhatsApp sends are connected but delivery/read/webhook outcomes are poor. +## Verify operational readiness -Use the `sent` skill for shared Sent terminology and routing. +- Profile WABA ID matches the intended business. +- Selected number is mapped to the intended profile. +- Template sharing/inheritance is intentional. +- A test template can be created with `sandbox: true`. +- The completion callback is reachable and idempotent. +- Returned message IDs are stored against the tenant/profile before webhook processing. +- Tokens and payment values are absent from logs. -## Suggested bundled references and scripts +For ordinary message and template webhooks, follow Sent's current events reference; those are separate from Meta browser events and profile-completion callbacks. -| File | Type | Purpose | -|---|---|---| -| `references/waba-embedded-signup-spec.md` | External platform reference | Keep Meta launch, token exchange, WABA lookup, phone registration, and subscription details out of the skill body. | -| `references/whatsapp-sender-profile-mapping.md` | Schema reference | Define how Sent profile IDs map to WABA IDs, phone-number IDs, display names, and status evidence. | -| `references/waba-onboarding-runbook.md` | Worked example | Show Sent-managed and customer-managed onboarding examples end-to-end. | +## Failure routing -## Unverified claims to confirm or remove +| Failure | Next action | +| --- | --- | +| `422` when credentials are omitted | Connect the organization WABA or provide dedicated credentials. | +| `403` with profile key and `x-profile-id` | Remove `x-profile-id` or use an authorized organization key. | +| Wrong WABA/number | Stop before completion and correct the profile mapping. | +| Expired/under-scoped token | Replace it securely; never print it while diagnosing. | +| Completion remains submitted | Inspect prerequisite and callback evidence; do not assume final failure from the `202`. | -- Sent does not expose a public Embedded Signup endpoint; WhatsApp connection is dashboard-only via Channels → WhatsApp (confirmed against Sent v3 docs snapshot, 2026-05-19; the Channels page is explicitly listed as "dashboard config; not directly in v3 API"). -- Required Meta app type, Tech Provider/Solution Partner status, granular scopes, and Graph endpoint sequence are external Meta claims, not Sent API facts. -- Mandatory direct phone-number registration or WABA subscription by the customer app depends on integration path and was not verified as a universal Sent requirement. -- Sent's webhook envelope is confirmed as `{field, sub_type, timestamp, payload}` with sub-types of the form `.` (e.g., `message.delivered`). WhatsApp-specific sub-types are not enumerated in the snapshot — discover them empirically against your account. +Use [references/waba-embedded-signup-spec.md](references/waba-embedded-signup-spec.md), [references/waba-onboarding-runbook.md](references/waba-onboarding-runbook.md), and [references/whatsapp-sender-profile-mapping.md](references/whatsapp-sender-profile-mapping.md). Use `sender-profile-architect` for tenant boundaries and `waba-template-author` for the first template. diff --git a/packages/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md b/packages/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md index 9d6f675..f032b9b 100644 --- a/packages/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md +++ b/packages/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md @@ -1,89 +1,48 @@ -# WABA Embedded Signup — Implementation Reference +# WABA integration specification -Supporting reference for `waba-embedded-signup`. The Sent v3 docs snapshot (2026-05-19) confirms that **Sent does not expose a public Embedded Signup API endpoint**. The customer-facing surface for connecting WhatsApp to Sent is the **dashboard's Channels → WhatsApp tab**, which is explicitly listed in Sent's "Dashboard pages → API endpoints map" as `(dashboard config; not directly in v3 API)`. The dashboard internally initiates Meta's Facebook Login for Business / Embedded Signup flow on the tenant's behalf. +## Capability boundaries -What this means for an integrator: +- Organization Embedded Signup is launched in the Sent dashboard. +- No public Sent endpoint starts that hosted signup. +- `POST /v3/profiles` can inherit the organization's WABA or accept a dedicated WABA. +- Direct profile credentials are not an Embedded Signup endpoint. -- **You do not call a Sent endpoint to start Embedded Signup.** You direct the tenant to their Sent dashboard. -- The Meta-side authentication, token exchange, WABA discovery, phone-number registration, app subscription, and app review state are owned by **Meta** and abstracted by the Sent dashboard. They are not surfaced as Sent API operations. -- After dashboard completion, the WhatsApp wiring is bound to the tenant's Sender Profile and routable via Sent's normal v3 API (`POST /v3/messages`, etc.). +## Dedicated credentials -Anything below this line is **external Meta documentation context** — included only so an operator debugging a stuck dashboard flow knows what is happening behind the scenes. Authoritative source: Meta — [Embedded Signup](https://developers.facebook.com/docs/whatsapp/embedded-signup), [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). Meta bumps the Graph API version regularly — always check the live Meta docs for the current version, scope names, and field names. +```text +whatsapp_business_account + waba_id required string + access_token required secret string + phone_number_id optional string +``` -## Sent-side surface (what the API does and doesn't expose) +Omitting `phone_number_id` invokes the current provisioning behavior documented by the profile contract. The token is write-only operational input and must never appear in API output, logs, fixtures, traces, analytics, or support text. -| Concern | Where it lives | -|---|---| -| Start Embedded Signup | Dashboard → Channels → WhatsApp → "Connect" (no public Sent API) | -| WABA / phone-number binding | Dashboard (not in v3 API) | -| Mark profile setup complete | `POST /v3/profiles/{id}/complete` (idempotent, sensitive endpoint — 10/min, burst 5) | -| Profile status after binding | `GET /v3/profiles/{id}` → `status` ∈ `incomplete` \| `pending_review` \| `approved` \| `rejected` | -| Webhook config | `POST /v3/webhooks`, `PUT /v3/webhooks/{id}`, `POST /v3/webhooks/{id}/test`, `POST /v3/webhooks/{id}/rotate-secret` (sensitive — 10/min, burst 5) | -| Auth header | `x-api-key: ` — single header, account-scoped. No `x-sender-id` in v3. | +## Inheritance -## Customer-facing dashboard flow (what the tenant sees) +Omit the entire `whatsapp_business_account` object to inherit. This succeeds only if the organization has completed Embedded Signup; otherwise expect `422`. -This mirrors the live flow on the dashboard's Channels page; it is what a tenant should be guided through, not an API sequence: +## Auth matrix -1. Dashboard → **Channels** → **WhatsApp** tab → click **Connect**. -2. Meta consent popup opens (Facebook Login for Business surface, initiated by Sent). -3. Tenant selects (or creates) a **WABA** under their Meta Business Portfolio. -4. Tenant grants Sent permission to **manage WhatsApp messages and templates** on that WABA. -5. Tenant adds a **Meta payment method** (separate from Sent billing — Meta charges per-conversation independently). -6. Dashboard reflects channel setup completion; the WhatsApp wiring is bound to the tenant's Sender Profile. -7. API credentials (the `x-api-key`) can be copied from the post-setup screen or retrieved later from the dashboard's API Keys page. +| Key | `x-profile-id` | Result | +| --- | --- | --- | +| Profile key | Omitted | Profile-scoped operation. | +| Profile key | Present | `403`. | +| Organization key | Valid child UUID | Child-scoped operation; organization rate-limit pool. | +| Organization key | Unowned UUID | `404`. | -The runbook (`references/waba-onboarding-runbook.md`) walks through this end-to-end with failure modes and recovery steps. +`x-sender-id` belongs to legacy v1/v2 guidance. -## Webhook envelope (Sent-confirmed) +## Profile completion -After WhatsApp is connected, Sent emits webhooks for that profile's messages using the universal envelope: +The request requires `webHookUrl`. A `202` only confirms processing began. A `200` can report an already-complete profile. Completion callback event values include `COMPLETED`, `SUBMITTED`, and `failed`; do not treat that vocabulary as the REST profile enum. ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "whatsapp", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "event": "SUBMITTED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" } ``` -Sub-types follow `.` (`message.queued`, `message.routed`, `message.sent`, `message.delivered`, `message.failed`, and on WhatsApp/RCS only, `message.read`). - -WhatsApp-specific sub-types beyond the universal `message.*` family (e.g., template approval/rejection notifications) are not enumerated in the v3 snapshot. To discover what your account currently subscribes to: - -1. List configured webhooks: `GET /v3/webhooks`. -2. Inspect a single webhook's `event_types` and `event_filters` fields. -3. Subscribe broadly to the `message` parent type and observe what arrives in production — fold the observed sub-types into your routing. - -## Webhook signature verification - -The webhook model (verified) exposes `signing_secret` as a per-webhook field; the exact HMAC algorithm and header name are not specified in the snapshot. Rotate via `POST /v3/webhooks/{id}/rotate-secret` — the old secret is invalidated immediately, so coordinate with the receiver before rotating. - -## Meta-side context (for operators only — link, do not reimplement) - -When a dashboard tenant is stuck and you need to know what the dashboard is doing on their behalf, the underlying Meta flow looks like this — read Meta's docs for current details: - -- Meta app type, Tech Provider / Solution Partner status, granular scopes, Graph version, redirect URI allowlisting → [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- OAuth code → System User token exchange → [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). -- WABA / phone-number lookup, phone-number registration with PIN, app subscription to WABA → [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). -- App review state, business verification, payment method, quality rating → Meta Business Suite UI. - -If a tenant is genuinely operating their own Meta app (not using the Sent-managed dashboard flow), they own all of the above and should be referred to Meta's docs directly. Sent's API does not replace that. - -## What is not in the v3 docs snapshot - -- The exact shape of the request body for `POST /v3/profiles/{id}/complete` for WhatsApp wiring (the snapshot confirms the endpoint exists and is sensitive; the per-channel payload is not published). -- The webhook signature algorithm / header used to verify Sent → receiver deliveries. -- The mapping shape between Sender Profile and the WABA / phone-number IDs the dashboard binds to it. -- WhatsApp-specific webhook sub-types (e.g., template lifecycle events). - -Treat each of these as "discover via your account" rather than "code to a spec". +Completion callbacks use `event`, not `sub_type`. Meta Embedded Signup browser messages also use an `event` field but have a different producer and payload. Keep the two handlers distinct. diff --git a/packages/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md b/packages/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md index bdb4098..d12cf88 100644 --- a/packages/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md +++ b/packages/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md @@ -1,120 +1,50 @@ -# WABA Onboarding Runbook — Dashboard Flow +# WABA onboarding runbook -Operator-facing companion to `waba-embedded-signup`. Walks the **Sent dashboard** WhatsApp connection flow in time order and tells you, for each step, what success looks like, the failure modes you should expect, and how to recover **without** restarting the whole signup. +## 1. Choose the path -The v3 snapshot confirms there is **no public Sent Embedded Signup API endpoint** — the Channels page in the dashboard is the surface, and it initiates Meta's Facebook Login for Business flow internally. This runbook reflects that reality; for the broader skill workflow and integration-path decision, see `waba-embedded-signup` SKILL.md. +- Organization needs its first WABA: use dashboard Embedded Signup. +- Child shares the organization WABA: create a profile and omit credentials. +- Child owns a dedicated WABA: create a profile with `waba_id` and `access_token`; optionally include `phone_number_id`. -## 0. Pre-flight (per-tenant gates) +Record why the choice matches brand, tenant, compliance, and blast-radius requirements. -Before the "Continue Channel Setup" button is meaningful for a tenant, two account-level gates must be true: +## 2. Prepare access -- [ ] **KYC approved.** Per the verified onboarding state machine, the account must have reached `KYC_COMPLETED` (state 5+). Before that, the v3 API returns `AUTH_006` and the dashboard blocks the Channels page. Compliance form fields (business identity, use cases, opt-in evidence) come from the dashboard's KYC + compliance pages. -- [ ] **Meta Business Portfolio ready.** The tenant must already have (or create during the flow) a Meta Business Portfolio under which a WABA will be selected or created. Sent does not provision this on the tenant's behalf. +Use a profile key alone or an organization key with `x-profile-id` for existing-child operations. Do not use `x-profile-id` with a profile key. Remove legacy `x-sender-id` examples. -If KYC is still in `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`, finish that first. The dashboard's onboarding checklist surfaces the next required step. +## 3. Handle secrets -## 1. Click "Continue Channel Setup" in the dashboard +- Receive tokens only through a protected server-side path. +- Store them in a secret manager if your system must retain them. +- Redact request bodies before logging. +- Never send tokens back to the frontend. +- Do not include tokens in screenshots, fixtures, errors, or support tickets. -After KYC, the dashboard surfaces a **Continue Channel Setup** entry that lands on the **Channels** page. +## 4. Create or update the profile -**Success looks like:** The Channels page loads and shows a **WhatsApp** tab with a **Connect** action. +Use `sandbox: true` first. On a dedicated WABA path, confirm that the returned non-secret WABA and number identifiers match intent. On inheritance, treat `422` as evidence the organization WABA prerequisite is absent. -**Common failure modes:** -- Button is missing / disabled → account state hasn't reached `KYC_COMPLETED`. Finish KYC first. -- API returns `AUTH_007` against `/v3/messages` for a tenant who thinks they're set up → they're at `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED` but haven't completed channel setup. They need to land on this page. +## 5. Complete -**Recovery without restarting:** Re-check `GET /v3/me` or the dashboard's onboarding indicator. Channel setup itself has no API; route the tenant back to the dashboard. +Send `webHookUrl` to `/v3/profiles/{profileId}/complete`. Persist the request ID and profile ID. A `202` is not final approval. -## 2. Select the phone number +The callback handler: -In the Channels → WhatsApp flow, the tenant selects the phone number that will be used for the WABA's first sender. Sent docs note that **this selection is not easy to change later** — once a phone number is bound to a Sender Profile, swapping it requires Meta-side migration plus a dashboard re-bind. +1. verifies the callback; +2. reads top-level `event`; +3. deduplicates by profile/event and delivery identity when available; +4. preserves unknown event strings; +5. records `COMPLETED`, `SUBMITTED`, or `failed` without coercing REST status. -**Success looks like:** The phone number is captured and the flow advances to Meta login. The number should be an E.164 line the tenant controls, not currently registered to another WABA they care about. +## 6. Smoke test -**Common failure modes:** -- Tenant picks a number that's already on a WABA they intend to keep separate → after Meta consent they'll discover the number is "in use elsewhere" and have to detach in WhatsApp Manager. -- Tenant picks a personal line they later want back for WhatsApp Consumer → that's a one-way door; warn upfront. +- Create a synthetic draft template with the Sent `definition` request shape. +- Validate with `sandbox: true`. +- Submit only after explicit review. +- Send to a controlled recipient. +- Persist the returned `message_id` with tenant/profile attribution. +- Verify the normal Sent message webhook separately from the completion callback. -**Recovery without restarting:** Within the same flow you can usually back out and pick a different number. After completion, switching numbers requires Meta-side migration and a fresh dashboard binding. +## 7. Rollback -## 3. Log in with Facebook/Meta and grant Sent permission - -The dashboard launches Meta's Embedded Signup popup (Facebook Login for Business). The tenant: - -- Logs in with their Meta account that admins the Business Portfolio. -- Selects (or creates) the **WABA** to bind. -- Grants Sent permission to **manage messages and templates** on that WABA. - -**Success looks like:** The popup closes with success; the dashboard reflects the connected WABA name. - -**Common failure modes:** -- Popup closes immediately → ad-blocker or popup-blocker. Disable for the Sent dashboard origin. -- Tenant chose "Create a new WhatsApp Business Account" inside the dialog and got stuck on business verification → not a Sent issue; tenant must finish verification in Meta Business Suite, then return. -- Tenant unchecked WhatsApp permissions in the consent screen → the binding will fail or be unusable. Re-launch Connect and accept all required permissions. - -**Recovery without restarting:** Re-launch Connect from the dashboard. The tenant only re-confirms permissions; previously-captured fields (like the chosen phone number) typically persist. - -## 4. Add Meta payment method - -Meta charges per-conversation for WhatsApp Business messaging, separately from Sent's billing. The tenant must add a payment method to the WABA in WhatsApp Manager / Meta Business Suite. - -**Success looks like:** Payment method status is "Active" in WhatsApp Manager. Sent's dashboard may surface a "Meta payment required" warning until this is true. - -**Common failure modes:** -- Card declines → tenant retries with a different card in Meta Business Suite. -- Tenant conflates this with Sent billing → clarify: Sent bills Sent fees; Meta bills WhatsApp conversation fees directly to the WABA. - -**Recovery without restarting:** Meta payment is set on the WABA, independent of the Sent flow — the tenant can complete this without re-doing steps 1–3. - -## 5. Confirm channel setup completion in dashboard - -After the WABA binding and Meta payment are in place, the dashboard reflects channel setup as complete. Internally, the account state should advance to `MESSAGE_COMPLIANCE_COMPLETED` and then to activated. The API surface that signals "I am done" is `POST /v3/profiles/{id}/complete` (idempotent, sensitive — 10/min, burst 5). Inspect `GET /v3/profiles/{id}` and look for `status` ∈ `pending_review` → `approved`. - -**Success looks like:** Profile `status` reaches `approved`; the dashboard shows the WhatsApp channel as connected. - -**Common failure modes:** -- Profile stays `pending_review` → Sent-side review is still running. Surface the status to the tenant; do not retry `complete` in a loop (rate-limited). -- Profile lands at `rejected` → KYC or compliance evidence was insufficient; the dashboard explains the reason. Fix in KYC + re-run. -- API returns `AUTH_005` against sends → the account state is at step 6 waiting for final Sent-side activation. No tenant action needed; wait. - -**Recovery without restarting:** `POST /v3/profiles/{id}/complete` is idempotent — calling again with the same input is safe. Do not delete and re-create the profile to "reset" status. - -## 6. Copy API credentials - -Once `status = approved`, API credentials are available: - -- On the post-setup screen, or -- Anytime from the dashboard's **API Keys** page (the snapshot lists this as `(dashboard-only; not in v3 API spec)` — there is no API to mint or list keys). - -Auth in v3 is a single header: `x-api-key: `. There is no `x-sender-id` in v3 — that's v2 legacy. The key is account-scoped. - -**Success looks like:** A test request to `GET /v3/me` with the key returns 200. - -**Common failure modes:** -- `AUTH_001` (401, missing header) → header name wrong; must be `x-api-key`. -- `AUTH_002` (401, invalid key) → key was rotated or copied with whitespace. -- `AUTH_007` (403, no channel configured) → key is valid but the account is at `KYC_COMPLETED` / `MESSAGE_COMPLIANCE_COMPLETED` without a finished channel. Re-check step 5. -- `AUTH_005` (403, pending final activation) → wait for Sent activation; not a credential problem. - -**Recovery without restarting:** Re-copy the key from the dashboard. Treat the key as a secret — never log it. Use the sandbox mode (`"sandbox": true` in mutation request bodies) for integration tests so you don't burn budget. - -## Stuck-state triage cheat-sheet - -| Symptom in production | Step | First thing to check | -|---|---|---| -| "Continue Channel Setup" missing | 0–1 | Account state — finish KYC first | -| Channels page rejects the chosen number | 2 | Number already on another WABA | -| Meta popup closes immediately | 3 | Popup/ad-blocker on dashboard origin | -| Popup completes but dashboard shows "not connected" | 3 | Tenant unchecked permissions; re-launch Connect | -| Dashboard shows "Meta payment required" | 4 | Add payment in WhatsApp Manager | -| Profile stuck `pending_review` | 5 | Sent-side review; do not re-POST `complete` in a loop | -| API send returns `AUTH_007` | 5 | Channel setup not actually complete | -| API send returns `AUTH_005` | 5 | Final Sent activation pending; no action | -| `x-api-key` returns `AUTH_002` | 6 | Re-copy from dashboard; check whitespace | - -## What this runbook deliberately does not cover - -- Customer apps that own their **own** Meta App and run Embedded Signup themselves (rather than using the Sent-managed dashboard flow). That path is owned by Meta — see `references/waba-embedded-signup-spec.md` and Meta's [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- Template authoring and submission — see `waba-template-author`. -- Multi-tenant Sender Profile design — see `sender-profile-architect`. -- Post-connection delivery debugging — see `messaging-performance-analyzer`. +If the WABA or number is wrong, stop new sends, revoke exposed credentials, correct profile mapping, and retain audit evidence. Avoid deleting a profile until number ownership and message retention are resolved. diff --git a/packages/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md b/packages/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md index bb74cb0..1102d4b 100644 --- a/packages/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md +++ b/packages/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md @@ -1,103 +1,40 @@ -# WhatsApp ↔ Sent Sender Profile Mapping - -How Meta-side entities created during Embedded Signup map onto Sent's Sender Profile model. Read this before deciding how many profiles to create per tenant, or when debugging why a webhook landed on the wrong profile. - -For the broader multi-channel architecture (one profile owns SMS + WhatsApp + RCS halves; how to split tenants across profiles), see `sender-profile-architect`. - -## The entities - -**Meta side:** -- **Business Manager / Business Portfolio** — the tenant's legal/operational umbrella in Meta Business Suite. -- **WABA (WhatsApp Business Account)** — owns templates and phone numbers; the unit Meta bills. -- **Phone Number** — a single E.164 number registered for Cloud API on a WABA. -- **System User** — long-lived identity holding the access token used to call Graph API on behalf of the tenant. - -**Sent side (v3 — schema verified against snapshot):** - -A Sender Profile is: - -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | The Sent-side primary key. | -| `name` | string | Display name. | -| `icon` | string \| null | URL. | -| `description` | string \| null | | -| `short_name` | string \| null | | -| `role` | `admin` \| `billing` \| `developer` \| null | Caller's role on this profile. | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status. | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | - -There is **no public `channels.whatsapp` sub-resource** on the Profile in the v3 docs snapshot. Per-channel WhatsApp wiring (WABA ID, phone-number ID) is performed via the dashboard Channels page, which is explicitly listed as "dashboard config; not directly in v3 API". Treat WABA and phone-number IDs as external provider identifiers that the dashboard binds to the profile; do not invent v3 endpoints to mutate that binding. - -Auth in v3 is a single header — `x-api-key: ` — at the account level. `x-sender-id` is **v2 legacy** and is exposed per profile in the dashboard for routing, not as a v3 API auth requirement. - -## ASCII map - -``` -Tenant - │ - ├── Business Manager (1) - │ │ - │ ├── WABA #A ────────────────────────► Sender Profile P1 (id, status=approved) - │ │ ├── Phone +1 555 0100 ◄────────┤ (dashboard-bound) - │ │ ├── Phone +1 555 0101 ◄──┐ │ - │ │ └── Templates │ │ - │ │ │ │ - │ │ └────► Sender Profile P2 - │ │ │ (different phone, same WABA) - │ │ - │ └── WABA #B ────────────────────────► Sender Profile P3 - │ └── Phone +44 20 7946 0000 ◄────┤ - │ - └── System User (1) ──► token held in vault, referenced by all of P1/P2/P3 -``` - -## Cardinality rules (operational, not enforced by v3 API) - -| Relationship | Cardinality | Notes | -|---|---|---| -| Business Manager → WABA | 1 : N | A tenant may operate multiple WABAs (per region or brand). | -| WABA → Phone Number | 1 : N | Up to 25 per WABA per Meta's current limits. | -| Phone Number → Sender Profile | 1 : 1 | **Hard rule.** Each phone number routes to exactly one profile; sharing breaks inbound routing. | -| WABA → Sender Profile | 1 : N | Multiple profiles may bind to the same WABA, each pinning a different phone number. | -| System User → WABA | 1 : N | One System User token can hold scopes for many WABAs. | -| Sender Profile → WhatsApp wiring | 0 : 1 | A profile has at most one WhatsApp binding. SMS / RCS bindings are independent. | - -## What `POST /v3/profiles/{id}/complete` actually does - -`POST /v3/profiles/{id}/complete` is confirmed in the v3 snapshot as the profile-completion endpoint. It supports `Idempotency-Key` and is classified as a sensitive endpoint (10 req/min, burst 5). It transitions the profile out of `incomplete` once prerequisites are met. - -The exact request/response shape for the completion call (which fields must be present, what gets persisted) is **not enumerated in the v3 snapshot**. Treat the completion call as a commit: prerequisites (KYC + channel config done via the dashboard) must already be true; the endpoint signals "I am ready". Check the live OpenAPI at [docs.sent.dm](https://docs.sent.dm) before wiring a tenant-facing integration. - -## Routing inbound events back to a profile - -Sent's webhook envelope (verified) is: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { "account_id": "...", "message_id": "...", "channel": "whatsapp", "inbound_number": "+1...", "outbound_number": "+1...", "template_id": "..." } -} +# WhatsApp Sender Profile mapping + +## Recommended records + +```text +tenant_id +sent_profile_id +waba_mode # organization_inherited | dedicated +waba_id +whatsapp_phone_number_id +whatsapp_phone_number +profile_key_secret_ref +status_raw +status_surface ``` -For WhatsApp inbound, the payload carries `account_id`, `message_id`, and the inbound/outbound E.164 numbers. WhatsApp-specific sub-types beyond the generic `message.*` family (e.g., template-status events) are not enumerated in the snapshot — discover them empirically against your account by subscribing broadly and observing what arrives. +Do not store `access_token` in this record. Use a secret-manager reference when retention is necessary. -## Detaching a WABA without losing message history +## Mapping invariants -There is no v3 API endpoint documented for detaching a WhatsApp binding from a profile. The Channels page in the dashboard is the surface. Operationally: +- A dedicated WABA profile has a recorded `waba_id` matching the intended tenant. +- An inherited profile explicitly records that it shares the organization WABA. +- A WhatsApp number maps to one current tenant/profile route unless the product has a documented coexistence model. +- `message_id` is persisted with tenant and profile before webhook events arrive. +- Unknown REST or completion callback statuses are stored verbatim with their surface. -1. Stop sending on the profile. -2. Wait for in-flight deliveries to settle (delivery webhooks drain within ~24h for normal traffic, longer for slower carriers). -3. Use the dashboard Channels page to remove the WhatsApp binding. -4. On the Meta side, unsubscribe your Tech Provider app from the WABA via Graph API if you held the subscription directly. +## Auth ownership -Historical MDRs remain queryable by `message_id` — message history is not deleted when the binding is removed. +Profile keys minimize tenant credential blast radius. Organization keys with `x-profile-id` centralize control but share the organization rate-limit pool and expand credential impact. Never expose the organization key to tenant code. -## Migrating a phone number between WABAs +## Event surfaces -Meta supports moving a phone number between WABAs and the phone-number ID is stable across the move. On Sent's side, the dashboard Channels page is the supported surface to re-bind. Since the v3 docs do not publish the channel-config mutation endpoint, do not encode a `PATCH /v3/profiles/{id}/channels/whatsapp` call in client integrations — operate via the dashboard until the API is published. +| Surface | Discriminator | +| --- | --- | +| Meta Embedded Signup browser message | `event` plus Meta session/data fields | +| Sent profile completion callback | top-level `event` | +| Sent message webhook | `field: "message"` plus `sub_type` | +| Sent template webhook | `field: "templates"`, no `sub_type` | -Templates are WABA-scoped and do **not** transfer with the phone number — re-author or re-import on the new WABA. +Do not copy envelopes between these integrations. They have different producers, authenticity checks, and retry behavior. diff --git a/packages/sent/skills/waba-template-author/SKILL.md b/packages/sent/skills/waba-template-author/SKILL.md index 40d25b1..c11baf2 100644 --- a/packages/sent/skills/waba-template-author/SKILL.md +++ b/packages/sent/skills/waba-template-author/SKILL.md @@ -1,197 +1,168 @@ --- name: waba-template-author -description: Writes, classifies, and revises WhatsApp templates for Sent, including utility, marketing, and authentication category decisions, variable samples, component structure, rejection-risk review, and Sent template submission. Use when a user says WhatsApp template, WABA template, template category, utility vs marketing, authentication template, Meta rejection, template samples, buttons, or wants approved WhatsApp copy in Sent. +description: Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads. --- - - -# WABA template author - -## Overview - -Use this skill to write WhatsApp template content that can be represented as a Sent template, submitted for WhatsApp review where required, and later sent through `POST /v3/messages` with `template.id`. The skill’s job is not just to produce polished copy; it must choose the right category, structure components correctly, provide realistic sample values, and flag review risks before submission. - -Sent stores templates as reusable message blueprints across SMS, WhatsApp, and RCS. WhatsApp review and category enforcement come from Meta, but the Sent-facing workflow uses Sent’s `/v3/templates` endpoints and Sent template statuses. - -## When to use - -Use this skill when the user asks for WhatsApp template copy, WABA template creation, utility/marketing/authentication classification, template rejection fixes, variable samples, buttons, headers, template categories, Meta approval risk, or a Sent template payload for WhatsApp. - -Do not use this skill to design the whole template-management UI; use `template-builder-ui`. Do not use it to connect a WABA or phone number; use `waba-embedded-signup`. Do not use it to register SMS compliance; use `sms-10dlc-registration`. - -## Category decision - -Pick the narrowest truthful WhatsApp category. Do not force promotional content into utility. The category should match the recipient’s expectation, the opt-in context, and the actual copy. - -| Category | Use when | Avoid when | -|---|---|---| -| Utility | The message is tied to an existing transaction, account, order, appointment, or service request. | The copy includes upsell, acquisition, abandoned cart, discount, or broad engagement language. | -| Marketing | The message promotes, re-engages, cross-sells, announces offers, or encourages optional action not tied to an existing transaction. | The message is purely required service/account information. | -| Authentication | The message delivers one-time passcodes or verification flows. | The message includes non-authentication content or marketing. | - -**Example.** “Your order 1234 shipped and arrives tomorrow” is utility. “Your order shipped — add accessories for 20% off” is marketing risk because it adds promotional content. - -## Process - -### 1. Capture the business intent - -Ask what event triggers the template, who receives it, what action the recipient should take, and whether the message contains any promotion. Write those answers before drafting copy. - -A strong intent statement is specific: “Send a delivery reschedule link after a courier misses the first attempt.” A weak one says: “Notify users about updates.” - -### 2. Choose the category before writing copy - -Drafting before category selection often creates copy that fails review. Choose utility, marketing, or authentication first, then write within that boundary. - -If the user wants utility but includes promotional language, explain the conflict and offer two options: remove promotion and keep utility, or keep promotion and classify as marketing. - -### 3. Draft the component structure - -Represent the template in Sent-compatible component language: header, body, footer, buttons, variables, and samples. Keep the component set as simple as the use case allows. - -| Component | Guidance | -|---|---| -| Header | Use only when it clarifies identity or context. Avoid promotional headers for utility templates. | -| Body | Put the required message and variables here. Keep the first sentence clear without needing the button. | -| Footer | Use for low-emphasis context such as opt-out or support where appropriate. | -| Buttons | Use quick replies or call-to-action buttons only when they directly support the message intent. | -| Variables | Use stable names and provide realistic samples for every variable. | - -### 4. Write with review risk in mind - -Use concise, literal copy. Avoid vague urgency, misleading scarcity, or mixed intents. Do not include sensitive data unless the use case requires it and the customer confirms it is acceptable. - -**Utility example.** - -```text -Name: order_shipped_update -Category: Utility -Language: en_US -Body: Hi {{first_name}}, your {{brand_name}} order {{order_id}} has shipped and is expected on {{delivery_date}}. Track it here: {{tracking_url}}. -Samples: - first_name: Alex - brand_name: Acme - order_id: A12345 - delivery_date: May 22 - tracking_url: https://acme.example/t/A12345 -Button: Track order -> {{tracking_url}} -``` - -**Marketing example.** - -```text -Name: spring_sale_announcement -Category: Marketing -Language: en_US -Body: Hi {{first_name}}, {{brand_name}} spring deals are live. Use code {{promo_code}} by {{end_date}} to save on selected items. -Samples: - first_name: Alex - brand_name: Acme - promo_code: SPRING20 - end_date: May 31 -Button: Shop now -> https://acme.example/sale +# WhatsApp Template Author + +Use this skill to turn a messaging intent into a valid body for `POST /v3/templates`, review it for WhatsApp policy risk, and explain the resulting lifecycle. Sent's template request is not Meta's Cloud API `components[]` shape. + +## Source precedence + +When official sources disagree: + +1. Use the live Sent v3 OpenAPI for paths, request fields, and response shapes. +2. Use the most specific current Sent guide for lifecycle and policy semantics. +3. Preserve unknown provider values instead of forcing them into a closed enum. + +The canonical references are the Sent template-definition guide, the v3 OpenAPI, and the webhook events reference. Do not use snapshot-era v2 examples. + +## Authoring workflow + +### 1. Establish intent and category + +Collect the business event, recipient expectation, requested action, language, channel overrides, and realistic sample values. Choose: + +- `UTILITY` for a specific non-promotional transaction, account, or service event. +- `MARKETING` for promotions, offers, re-engagement, product discovery, or mixed promotional content. +- `AUTHENTICATION` for one-time verification codes and supported authentication flows. + +If content mixes utility and promotion, classify it as marketing or split it. See [references/waba-template-categories.md](references/waba-template-categories.md). + +### 2. Build the Sent create request + +`POST /v3/templates` accepts these top-level fields: + +| Field | Requirement | +| --- | --- | +| `definition` | Required. Contains `header`, `body`, `footer`, `buttons`, optional `definitionVersion`, and optional `authenticationConfig`. | +| `category` | Optional: `UTILITY`, `MARKETING`, or `AUTHENTICATION`; omit for detection only when ambiguity is acceptable. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional source string; `from-api` is the documented default. | +| `submit_for_review` | Optional Boolean; default `false`. Draft and validate before review. | +| `sandbox` | Optional Boolean for validation without side effects. | + +Do not put `name`, `channels`, `body`, `header`, `buttons`, or `components` at the request root. `name` exists on update/response surfaces, not on the current create request. + +```json +{ + "category": "UTILITY", + "language": "en_US", + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} has shipped.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": null, + "whatsapp": null, + "rcs": null + }, + "footer": null, + "buttons": null, + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true +} ``` -### 5. Convert the draft into a Sent template operation +Use `definition.body.multiChannel` as the channel-neutral body. `sms`, `whatsapp`, and `rcs` are complete channel overrides, not fragments. Keep each body at or below 1,024 characters. -Use Sent’s template API for creation and lifecycle management. The verified operations are: +### 3. Define variables exactly -| Operation | Endpoint | Use | -|---|---|---| -| Create template | `POST /v3/templates` | Save a draft or submit a new template. | -| List templates | `GET /v3/templates` | Find templates by search, status, or category. | -| Retrieve template | `GET /v3/templates/{id}` | Inspect status and definition. | -| Update template | `PUT /v3/templates/{id}` | Revise name, category, language, definition, or submit for review. | -| Delete template | `DELETE /v3/templates/{id}` | Delete the Sent template, optionally deleting from Meta where supported. | +Use placeholders such as `{{0:variable}}`, `{{1:link}}`, or `{{2:media}}`. Each placeholder needs one matching definition with: -Use Sent’s documented template statuses in user-facing instructions: `PENDING`, `APPROVED`, `REJECTED` (per the Sent docs snapshot, Template Models section). Sent does **not** surface `PAUSED` — that is Meta-side only. If Meta returns additional statuses for a WhatsApp account, quote them as Meta-side evidence rather than Sent-surfaced statuses. +- a unique non-negative integer `id`; +- a readable `name`; +- a matching `type`; +- `props.sample` with realistic review and preview data. -### 6. Add variable samples before submission +Keep placeholder IDs and variable IDs aligned inside every body override. Never output naked `{{1}}` placeholders in a Sent request. -Every placeholder needs a realistic sample. Samples should look like production data and should not add claims that the body does not support. +### 4. Add supported buttons -**Bad sample pattern.** Body says “Your appointment is confirmed,” but sample data includes “50% off visit.” This can create category confusion. +Sent currently recognizes `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE`. Enforce: -**Good sample pattern.** Body and sample values all support the same transactional use case. +- 10 buttons total; +- at most 2 URL buttons; +- at most 1 voice-call button; +- at most 1 phone-number button; +- at most 1 copy-code button; +- quick replies may use the remaining slots, up to the total of 10. -### 7. Revise rejected templates from the reason, not from guesses +Buttons use `id`, `type`, and `props`. Labels are at most 25 characters. Require type-specific properties: `quickReplyType`; `urlType` and `url`; `countryCode` and `phoneNumber`; or `offerCode`. Quick replies and calls-to-action may coexist—do not invent an XOR rule. -If a template is rejected, retrieve the Sent template detail/status and any available rejection reason. Then change only what the reason justifies. Category mismatch, missing samples, unsupported components, and promotional language in utility templates require different fixes. +### 5. Handle authentication templates -| Rejection symptom | Likely correction | -|---|---| -| Category mismatch | Change category or remove conflicting copy. | -| Missing/weak samples | Add realistic variable samples. | -| Unsupported component | Simplify header/buttons or split channel variants. | -| Policy concern | Remove misleading, sensitive, or prohibited content. | -| Language mismatch | Correct language code and localized text. | +For `AUTHENTICATION`, use `definition.authenticationConfig`: -### 8. Confirm sendability after approval - -After approval, confirm the template can be used with the intended Sender Profile/channel and sent through `POST /v3/messages` with `template.id`. If delivery later fails, hand off to `messaging-performance-analyzer` rather than rewriting approved copy blindly. - -## Common rationalizations to avoid - -Do not call a template utility if it includes discounts, upsells, abandoned-cart messaging, or broad engagement language. - -Do not omit sample values because the placeholders are obvious. Review and test flows need rendered examples. - -Do not treat Meta Cloud API payload examples as the Sent API contract. Use Sent `/v3/templates` for Sent integrations. - -Do not introduce `PAUSED` as a Sent template status. Sent surfaces only `APPROVED`, `PENDING`, and `REJECTED` — PAUSED is Meta-side and is not reflected in the Sent template status. When Meta pauses, the Sent status stays as it was, and individual sends start failing instead — diagnose via `messaging-performance-analyzer`. +```json +{ + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 +} +``` -Do not rewrite a rejected template without reading the actual rejection reason when available. +Expiration is 1–90 minutes. Keep authentication content to the verification purpose, use one code variable and the supported copy-code action, and do not add marketing language, unrelated links, media, or promotional buttons. -## Verification checklist +### 6. Validate before submission -- [ ] The trigger event, audience, recipient action, and promotional content are documented. -- [ ] Category is chosen before copy is drafted. -- [ ] The body is clear without relying on a button. -- [ ] Every variable has a realistic sample value. -- [ ] Component choices match the selected channel and use case. -- [ ] Sent template API endpoints are used for create/list/get/update/delete. -- [ ] Status handling uses only Sent's surfaced set — `APPROVED`, `PENDING`, `REJECTED` (no `PAUSED`). -- [ ] Rejection fixes map to observed reasons, not generic rewrites. -- [ ] Approved templates are tested through Sent sending with `template.id` before broad rollout. +Run: -## Related skills +```bash +python scripts/lint_waba_template.py template.json +``` -Use `template-builder-ui` when the task is UI design, component validation, JSON editor behavior, or template-management product specs. +The linter validates the Sent request shape, variables, the 1,024-character limit, channel overrides, every current button type, per-type limits, and authentication configuration. A Meta Cloud API example with `components[]` must fail with an explicit conversion error. -Use `waba-embedded-signup` when the WhatsApp sender/WABA/phone number is not connected to Sent yet. +Use `sandbox: true` and `submit_for_review: false` while integrating. When the user is ready for provider review, show the final payload and explain that submission changes external state before proceeding. -Use `sender-profile-architect` when the template belongs to a specific tenant, brand, department, or profile boundary. +### 7. Track the right lifecycle surface -Use `sms-10dlc-registration` when WhatsApp copy will be mirrored to SMS and must align with US A2P use-case registration. +Sent template resources use the known states `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Do not claim this is every value the API may ever return. -Use `messaging-performance-analyzer` when approved WhatsApp templates have poor delivery, read, or webhook outcomes. +Template webhooks are WhatsApp approval events. They use `field: "templates"`, omit `sub_type` and `event`, and carry the provider status in `payload.status`: -Use the `sent` skill for shared Sent terminology and routing. +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": null + } +} +``` -## Bundled references and scripts +Common forwarded values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`. Meta can also send values such as `PAUSED` or `DISABLED`. Persist the raw string, handle known values, and safely surface unknown ones. See [references/template-rejection-playbook.md](references/template-rejection-playbook.md). -| File | Type | Purpose | -|---|---|---| -| `references/waba-template-categories.md` | Policy lookup table | Meta category boundaries, component rules, and Cloud API submission shape. | -| `references/waba-template-examples.md` | Worked examples | Copy-pasteable payloads for utility, marketing, and authentication templates. | -| `references/template-rejection-playbook.md` | Decision matrix | Map rejection reasons to precise edits and resubmission etiquette. | -| `scripts/lint_waba_template.py` | Validation script | Stdlib lint for a template JSON payload (placeholder numbering, samples, category-risk phrases, button structure, language code). Run from the skill root: `python scripts/lint_waba_template.py template.json`. | -| `scripts/fixtures/utility_good.json` | Fixture | Passing fixture for the linter. | -| `scripts/fixtures/utility_bad.json` | Fixture | Failing fixture (wrong placeholder order, promo phrasing in utility). | +## Boundaries -## Unverified claims to confirm or remove +Use `template-builder-ui` for editor architecture and client-side validation UX. Use `sent-templates` to list, inspect, or delete existing templates through the connected Sent tools. Use `waba-embedded-signup` for WABA connection. Use `rcs-agent-onboarding` for current RCS launch capabilities. -- Exact category-pricing behavior and Meta rejection-code semantics are external Meta policy context, not Sent API facts. -- Cross-check the Sent template schema against the live OpenAPI at `https://docs.sent.dm` before promoting code to production. +Meta Cloud API payloads may appear in [references/waba-template-examples.md](references/waba-template-examples.md), but every such example must be clearly labelled non-Sent and must never be passed to the Sent linter as a valid request. diff --git a/packages/sent/skills/waba-template-author/references/template-rejection-playbook.md b/packages/sent/skills/waba-template-author/references/template-rejection-playbook.md index 60d36c1..b8c8a0d 100644 --- a/packages/sent/skills/waba-template-author/references/template-rejection-playbook.md +++ b/packages/sent/skills/waba-template-author/references/template-rejection-playbook.md @@ -1,182 +1,62 @@ -# Template Rejection Playbook — Reference +# Template rejection and lifecycle playbook -What to do when Meta rejects a WhatsApp template, silently re-categorizes it, -or pauses it after delivery starts. Companion to `references/waba-template-categories.md` -and `references/waba-template-examples.md`. Authoritative source for Meta-side codes is -the official [Cloud API template docs](https://developers.facebook.com/docs/whatsapp/message-templates). -Sent-surfaced statuses and codes come from the Sent docs snapshot referenced -above. +Use this reference when a Sent template is pending, rejected, paused, disabled, or recategorized by the WhatsApp provider. -Every entry: what triggers it, how to detect it from the rejection / status -payload, and what to change before resubmitting. +## Keep lifecycle surfaces separate -## Sent-surfaced template states (not Meta's) +Sent template resources have these known states: -Sent's template `status` set is exactly `APPROVED`, `PENDING`, `REJECTED` — -**no `PAUSED`**. When Meta pauses a template (quality rating drop, opt-out -spike), Sent's template status does **not** change; it stays whatever it was -(typically `APPROVED`). Sends against a Meta-paused template start failing -asynchronously — surface that via the `message.failed` webhook or the -`GET /v3/messages/{id}/activities` endpoint, not via a template-status poll. +- `DRAFT` +- `PENDING` +- `APPROVED` +- `REJECTED` +- `PAUSED` -When a send is attempted against a Sent template whose `status` is `PENDING` -or `REJECTED`, the batch is rejected synchronously with: +The template webhook is a provider-forwarding surface. Common `payload.status` values are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; provider values such as `PAUSED` and `DISABLED` can also arrive. These lists serve different purposes. Persist the original status string and surface unknown values safely. -| Code | HTTP | Meaning | -|---|---|---| -| `BUSINESS_005` | 422 | "WhatsApp template not approved (still PENDING / REJECTED)" | - -So the two failure modes are distinct: - -- Template never reached `APPROVED` in Sent → `BUSINESS_005` on send. -- Template is `APPROVED` in Sent but Meta-paused → per-message failure on the - webhook / activities feed; the template `status` you see in Sent is unchanged. - -For diagnosing post-approval send failures, hand off to -`messaging-performance-analyzer`. - -## Category mismatch (utility classified, marketing content) - -**What it looks like:** Template is `APPROVED` but the returned `category` is -`MARKETING` even though you submitted `UTILITY`. Or, after first send, the -status moves to `PENDING` → `APPROVED` again with a flipped category. - -**How to detect:** Compare submitted `category` to the post-approval `category` -on the webhook. Re-categorization is the most common silent failure. - -**Revise:** -- Strip any sentence that does not refer to the triggering event. -- Replace generic CTAs ("Shop now", "Browse more") with event-specific ones - ("View order", "Track package"). -- Remove second-person calls to action that are not the entity in the message. -- Resubmit under a new version suffix (`_v2`) — the old name is locked. - -## Promotional content in utility category - -**Banned in utility body, footer, header, button labels:** - -- "buy now", "shop now", "order today" -- "limited time", "exclusive", "hurry", "ends soon" -- "special offer", "best deal", "lowest price" -- "discount", "% off", "sale", "free shipping" -- Discount codes like `SAVE20`, even as a variable sample -- Promotional emojis in body or header text (🎉 🛍️ 💸 🔥) - -**Revise:** strip the phrase; if the use case genuinely includes a promo, -flip to `MARKETING` rather than masking the wording. - -## Missing variable samples - -**What it looks like:** Submission fails with `TAG_CONTENT_MISMATCH` or the -template is rejected with "variable example missing". - -**Sent's required shape:** +## Correct template event envelope ```json { - "type": "BODY", - "text": "Hi {{1}}, your order #{{2}} has shipped.", - "example": { "body_text": [["Jordan", "A1029"]] } + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "REJECTED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": "Promotional content is not utility content." + } } ``` -`body_text` is an array of arrays — the outer array is "one row per -variable group", and the inner array has one sample per `{{n}}`. Forgetting -the outer array is the single most common cause. - -For URL CTAs: - -```json -{ "type": "URL", "text": "Track", "url": "https://example.com/orders/{{1}}", - "example": ["https://example.com/orders/A1029"] } -``` - -`example` is a flat array here (not nested). - -## Authentication template with code formatting error - -**What it looks like:** Submission fails with "invalid component" on an -authentication template, or the OTP button does not appear in the rendered -template preview. - -**Revise:** -- Use `category: "AUTHENTICATION"` and the dedicated auth component shape, not - a UTILITY template with a `{{1}}` for the code. -- The body component must use `add_security_recommendation: true` or include - the platform-managed security recommendation; no freeform `{{1}}` for the code. -- The button component must be `OTP` with `otp_type` of `COPY_CODE` or `AUTOFILL`. -- `code_expiration_minutes` belongs on the FOOTER component, not the body. - -See `references/waba-template-examples.md` for two valid AUTH payloads. - -## Button URL doesn't match domain - -**What it looks like:** Rejection with "URL does not match business domain" -or the template is approved but later paused for the same reason. - -**Revise:** -- Confirm the WABA's verified business domain matches the CTA URL host. -- Subdomains often need to be added separately if Meta's domain check is - strict — `app.example.com` and `example.com` are not interchangeable. -- For URL CTAs with a variable, the example URL must resolve to the same - registered domain. -- If you're a multi-tenant platform on Sent, the WABA in question must own - the domain — you cannot deep-link to a tenant subdomain not registered - under that WABA. - -## Language code mismatch - -**What it looks like:** Rejection with `INVALID_LANGUAGE`, or two templates -under the same conceptual name behave inconsistently across recipients. - -**Revise:** -- Use BCP-47 codes with the underscore separator: `en_US`, `pt_BR`, `es_MX`. -- Not `en`, not `en-US`, not `en_us`. -- Each language is a *separate template* with its own approval. You cannot - submit one template and have it cover several locales. - -## Generic placeholders (template too generic) - -**What it looks like:** Rejection with a content reason ("does not meet -template quality standards") even though the wording is neutral. - -**Triggers:** -- Bodies like "{{1}}, here is an update for you" — Meta cannot tell what - category the template is for, so they default to reject. -- Sample values like `Test`, `Sample`, `XYZ` — these read as if the template - was never going to be used in production. +Template events use `field: "templates"` and omit both `sub_type` and `event`. Message events are different and do use `sub_type`. -**Revise:** add at least one variable that proves the use case (an order ID, -an appointment time, an account-event timestamp) and use realistic sample -values that match. +## Response procedure -## Resubmission etiquette and timing +1. Verify the webhook signature using the raw body and reject stale timestamps. +2. Deduplicate on template ID plus status transition. +3. Persist the raw payload and reason. +4. Retrieve the current Sent template before editing; webhooks can be delayed or reordered. +5. Map the reason to the smallest justified change. +6. Convert any Meta-shaped source into the Sent `definition` contract. +7. Run the local linter and use `sandbox: true`. +8. Show the final diff and obtain confirmation before review submission. -- **One change per resubmission.** If you change wording *and* category *and* - variables, you cannot tell which change unblocked the template. -- **Use a new version suffix.** `_v1` → `_v2`. Submitting under the same - `(name, language)` will fail with name-conflict if the prior template is - still in any state other than `DELETED`. -- **Wait for review before re-resubmitting.** Spamming submissions of the - same template body slows the queue for the WABA. -- **For Meta-paused templates** (Sent template status still `APPROVED` but - sends are failing on the webhook / activities feed), revise the content - before resubmitting under a new version — Meta paused for a reason and an - untouched resubmission lands in the same place. Confirm the failures via - `messaging-performance-analyzer` before rewriting. -- **For silent re-categorization,** resubmit the *strictest* version of the - wording even if you intend to send marketing content from it — once the - category is set, marketing-priced sends still work fine under a stricter - template. +## Common remediations -## Quick triage table +| Symptom | Appropriate response | +| --- | --- | +| Utility content recategorized | Remove promotion or deliberately use `MARKETING`; do not argue from transactional context alone. | +| Missing or unrealistic samples | Add `props.sample` for every placeholder without using customer data. | +| Invalid variable format | Replace naked placeholders with `{{0:variable}}` and align IDs. | +| Unsupported create shape | Move fields into `definition`; reject Meta `components[]` as a Sent request. | +| Button validation | Enforce 10 total and per-type limits; allow quick replies and CTA buttons to coexist. | +| `PAUSED` or `DISABLED` | Stop new WhatsApp sends with the template, preserve the provider value, and surface it for review. | +| Unknown status | Store and display it; do not silently coerce it to rejected or approved. | -| Symptom | Most likely cause | First fix | -|---|---|---| -| Approved but category flipped | Promotional language Meta detected | Strip wording, resubmit as `_v2` | -| `INVALID_FORMAT` | Component schema typo | Re-check component types and required fields | -| `TAG_CONTENT_MISMATCH` | Variables vs. samples count mismatch | Provide one sample per `{{n}}` in the right shape | -| `META_POLICY_VIOLATION` | Restricted content (alcohol, finance, etc.) | Check Meta's restricted-content policy for the WABA's vertical | -| `INVALID_LANGUAGE` | Bad locale code | Use BCP-47 with underscore | -| Sends fail with `BUSINESS_005` | Sent template still `PENDING` or `REJECTED` | Wait for Sent approval, or fix the rejection and resubmit as `_v2` | -| Sends fail post-approval (per-message failures, Sent status unchanged) | Meta-paused template (PAUSED is Meta-side, not reflected in Sent) | Diagnose via `messaging-performance-analyzer`; revise wording and resubmit `_v2` | +Do not claim provider approval timing as a guarantee, and do not repeatedly resubmit unchanged content. diff --git a/packages/sent/skills/waba-template-author/references/waba-template-categories.md b/packages/sent/skills/waba-template-author/references/waba-template-categories.md index 0c6085a..0338ba0 100644 --- a/packages/sent/skills/waba-template-author/references/waba-template-categories.md +++ b/packages/sent/skills/waba-template-author/references/waba-template-categories.md @@ -1,218 +1,39 @@ -# WABA Template Categories — Reference +# WhatsApp template categories -Supporting reference for `waba-template-author`. Excerpts from Meta's template -policy plus practical mappings to common use cases. Authoritative external -source: [WhatsApp Business Platform > Message Templates](https://developers.facebook.com/docs/whatsapp/message-templates). +Supporting policy reference for `waba-template-author`. The request contract comes from Sent; category review is ultimately performed by Meta for WhatsApp. -> The Meta policy evolves frequently — verify the current rules in the official -> docs before relying on edge cases. This reference captures the stable patterns -> and boundaries; assume any specific number or category boundary may have -> changed since this file was written. +## Decision order -## Sent's Template Categories and Statuses +1. Identify why the recipient expects the message. +2. Identify the single action the message asks them to take. +3. Remove optional promotional language and classify again. +4. If promotion remains, use `MARKETING`. +5. If the sole purpose is a verification code, use `AUTHENTICATION`. +6. Otherwise use `UTILITY` only when the message is tied to a specific transaction, account, or service event. -Sent surfaces exactly **three** categories — Meta's three, no others: +## Category guide -- `UTILITY` -- `MARKETING` -- `AUTHENTICATION` +| Category | Suitable intent | Common rejection or recategorization risk | +| --- | --- | --- | +| `UTILITY` | Order state, appointment reminder, account change, service interruption, requested support update | Discounts, upsells, product discovery, vague re-engagement, or calls to purchase | +| `MARKETING` | Offers, launches, recommendations, reminders to shop, abandoned-cart messages, mixed promotional content | Missing consent, misleading urgency, or attempting to disguise promotion as utility | +| `AUTHENTICATION` | OTP, login verification, account recovery code | Free-form content, promotional text, unrelated links/media, or multiple actions | -Sent's template `status` set is exactly: +Transactional context does not make promotional content utility. “Your receipt is ready” is utility; “Your receipt is ready—buy again for 20% off” is marketing. -- `APPROVED` -- `PENDING` -- `REJECTED` +## Authentication restrictions -**There is no `PAUSED` status in Sent.** Meta may internally pause a template -after delivery starts (quality rating drop, opt-out spike), but Sent does **not** -surface PAUSED — the Sent template status remains whatever it was (typically -`APPROVED`), while sends against the paused template start failing at send time. -Detect that via failed activities / message webhooks, not via the template -status. See `references/template-rejection-playbook.md` for the workflow. +- Set top-level `category` to `AUTHENTICATION`. +- Include `definition.authenticationConfig`. +- `codeExpirationMinutes`, when present, is an integer from 1 through 90. +- Keep the body to the verification purpose and one code variable. +- Use the supported `COPY_CODE` action for the code. +- Do not add promotion, unrelated URLs, media, or extra calls to action. -## The Three Categories +## Variables and samples -### Utility -Triggered by a user action or a recurring event the user already opted into. The -message is *about that event*. Lowest-priced category. +Provider reviewers see samples. Every placeholder such as `{{0:variable}}` must have the same numeric ID in the channel's variables array and a realistic `props.sample`. Do not use real customer data or secrets in samples. -**Eligible use cases (non-exhaustive):** -- Order placed / shipped / delivered -- Appointment confirmation, reminder, change -- Booking confirmation -- Account balance, statement, invoice -- Password change confirmation (note: *not* the OTP itself — that's authentication) -- Service status: outage, restoration, maintenance window -- Payment received / failed / overdue notice -- Recurring statement or subscription renewal notice -- Form / application status update +## Revision discipline -**Disqualifiers — if any of these are true, Meta will reclassify to marketing:** -- The message cross-sells, upsells, or invites the user back -- The CTA button leads anywhere other than the entity the message is about - (an order-confirmation button must link to that order, not the homepage) -- Promotional language ("hurry", "limited", "exclusive", "sale", "deal") -- Discount codes, percentages off, or "use code X" content -- Promotional imagery in a media header - -### Marketing -Business-initiated outreach with the intent to drive a new action — purchase, -visit, signup, re-engagement. Highest-priced category. - -**Eligible use cases:** -- Promo / sale announcement -- Abandoned cart recovery -- Welcome-back / win-back -- Newsletter / content drop -- Product launch -- Event invitation (when the event is promotional, not transactional) -- Survey or feedback request (when not tied to a specific transaction) - -There is no penalty for marketing templates; the penalty is for *labeling* -them as utility. When in doubt, ship as marketing. - -### Authentication -A separate template type, not just a category. Used for one-time codes, login -verification, account-recovery codes. - -**Distinct rules:** -- Body is constrained: "{{1}} is your verification code." plus optional security - disclaimer ("For your security, do not share this code.") -- Buttons collapse to a single button: "Copy code" or "Autofill" (one-tap, mobile only) -- Supports `code_expiration_minutes` field -- Cannot include marketing or utility content -- Lower per-message cost; some regions price authentication separately - -## Component Rules - -### Header (optional) -- Sent header `type`: `TEXT` | `IMAGE` | `VIDEO` | `DOCUMENT` (no `LOCATION` at the Sent layer) -- Text: max 60 chars, max 1 variable -- Media: provide a sample URL or media handle at submission - -### Body (required) -- Max 1024 chars -- Supports `{{1}}`, `{{2}}`, … placeholders -- Variables must be sequential (no gaps); every variable needs a sample -- No URLs in body (link via CTA buttons instead) for most categories; exceptions exist - -### Footer (optional) -- Max 60 chars -- No variables -- Common use: compliance text, opt-out instructions - -### Buttons (optional) -- *Either* quick replies *or* CTA buttons, never both -- Quick replies: up to 3, max 25 chars per label -- CTAs: up to 2, types: `URL` or `PHONE_NUMBER` - - URL CTAs may include one trailing variable: `https://example.com/orders/{{1}}` - - URL CTAs require an example URL for submission - -## Sent CreateTemplateRequest Shape - -Sent's `POST /v3/templates` accepts the following structured shape (from -the live Sent API documentation at https://docs.sent.dm): - -``` -{ - name, - category, // "UTILITY" | "MARKETING" | "AUTHENTICATION" - language, // BCP-47 with underscore, e.g. "en_US" - body: { - content, // body text with {{1}}, {{2}} placeholders - variables?: [ - { name, type: "text" | "number" | "date", example? } - ] - }, - header?: { - type: "TEXT" | "IMAGE" | "VIDEO" | "DOCUMENT", - content - }, - footer?: { content }, - buttons?: [ - { - type: "QUICK_REPLY" | "URL" | "PHONE_NUMBER", - text, - url?, // required when type = URL - phone_number? // required when type = PHONE_NUMBER - } - ], - channels?, // subset of ["sms", "whatsapp", "rcs"] - sandbox? // bool -} -``` - -Worked Sent request: - -```json -{ - "name": "order_confirmation_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} has been confirmed and will ship soon. Track it any time below.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] - }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to unsubscribe." }, - "buttons": [ - { - "type": "URL", - "text": "Track order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"], - "sandbox": false -} -``` - -Notes: - -- The Sent body is `body.content`, not `text` (Meta-side Cloud API uses `text` - on the BODY component). Variable samples live in `body.variables[].example` - rather than a separate Cloud API `example.body_text` block. -- `header.type` is restricted to **TEXT, IMAGE, VIDEO, DOCUMENT** at the Sent - layer. `LOCATION` is not a Sent header type. -- `buttons[].type` is restricted to **QUICK_REPLY, URL, PHONE_NUMBER** at the - Sent layer. Authentication-only Cloud API button types (`OTP`, `COPY_CODE`, - `AUTOFILL`) are Meta-side and are not Sent button types. -- `channels` accepts a subset of `["sms", "whatsapp", "rcs"]`. WhatsApp templates - always include `"whatsapp"`. -- `language` follows BCP-47 with the underscore separator (`en_US`, `pt_BR`, - `es_MX`). Not `en`, not `en-US`. - -## Common Rejection Reasons (from Meta's API) - -| Code / phrase | Meaning | Fix | -|---|---|---| -| `INVALID_FORMAT` | Component schema broken | Re-validate against the component rules above | -| `TAG_CONTENT_MISMATCH` | Variable count differs from samples | Provide a sample for every `{{n}}` | -| `META_POLICY_VIOLATION` | Content violates content policy | Remove promotional content, slurs, or restricted-category content | -| `INVALID_LANGUAGE` | Language code unsupported | Use BCP-47 (`en_US`, not `en`) and one from Meta's supported list | -| Silent reclassification | Approved but category changed | Body/buttons/header read promotional even if the use case is utility | - -## Worked Examples — Decision Tree in Action - -**"Your order #1029 has shipped. Track it here."** -- Triggered by purchase? Yes. About the purchase? Yes. CTA is order-specific? Yes. -- → **UTILITY**, single URL CTA. - -**"Your order #1029 has shipped. Check out our new arrivals!"** -- Triggered by purchase? Yes. About the purchase? No — second sentence is a promo. -- → **MARKETING** (or split into two templates). - -**"Your account password was changed."** -- Triggered by user action? Yes (they changed it). About that action? Yes. -- → **UTILITY**. (The OTP that authorized the change is **AUTHENTICATION**, separate template.) - -**"We miss you — here's 20% off your next order."** -- Business-initiated, re-engagement, discount. -- → **MARKETING**, unambiguously. - -**"Your code is 729451. For your security, do not share this code."** -- One-time code for login. -- → **AUTHENTICATION** template type. Not a utility template with a code in the body. +When Meta returns `REJECTED` or `CATEGORY_UPDATED`, retain the raw reason, change only what it supports, lint again, and resubmit deliberately. Do not repeatedly submit unchanged content. diff --git a/packages/sent/skills/waba-template-author/references/waba-template-examples.md b/packages/sent/skills/waba-template-author/references/waba-template-examples.md index d72c939..9adc811 100644 --- a/packages/sent/skills/waba-template-author/references/waba-template-examples.md +++ b/packages/sent/skills/waba-template-author/references/waba-template-examples.md @@ -1,244 +1,93 @@ -# WABA Template Examples — Reference +# Sent template examples -Worked, copy-pasteable WhatsApp template payloads grouped by Meta category. -Companion to `references/waba-template-categories.md`. Every example is a complete -**Sent `POST /v3/templates`** request body following the CreateTemplateRequest -shape. Variable placeholders use `{{1}}`, `{{2}}` in the body content, and -each variable has a named entry with `type` and `example` in `body.variables`. +All examples in the first section are bodies for `POST /v3/templates` and are expected to pass `scripts/lint_waba_template.py`. Synthetic values are used throughout. -> Synthetic values only — no real WABA IDs, brand names, or customer data. - -## Utility - -### 1. Order confirmation - -Triggered by checkout. About that order. Single URL CTA bound to the order. +## Utility with a WhatsApp override + ```json { - "name": "order_confirmation_v1", "category": "UTILITY", "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} is confirmed. We will let you know when it ships.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, your appointment is on {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + }, + "whatsapp": { + "type": "body", + "template": "Hello {{0:variable}}. Your appointment is confirmed for {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + } + }, + "footer": {"type": "text", "template": "Acme Scheduling", "variables": []}, + "buttons": [ + {"id": 1, "type": "QUICK_REPLY", "props": {"text": "Confirm", "quickReplyType": "custom"}}, + {"id": 2, "type": "URL", "props": {"text": "Manage booking", "urlType": "static", "url": "https://example.com/bookings"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": null }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "View order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why this is approved as utility: every component refers to the order, the CTA -deep-links to that order, no promotional language, no cross-sell. - -### 2. Shipping update - -Triggered by carrier scan. Variables numbered 1..3 sequentially. - -```json -{ - "name": "shipping_update_v2", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "package_id", "type": "text", "example": "A1029" }, - { "name": "delivery_eta", "type": "text", "example": "6 PM today" } - ] - }, - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: status-only language, sample values are neutral, CTA points to the -tracking page for that specific package. - -### 3. Appointment reminder - -Triggered by user-booked appointment. Quick-reply buttons stay within the same -appointment — confirm or reschedule, no upsell. - -```json -{ - "name": "appointment_reminder_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, this is a reminder of your appointment with {{2}} on {{3}} at {{4}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "provider_name", "type": "text", "example": "Dr. Patel" }, - { "name": "appointment_date", "type": "date", "example": "2026-05-18" }, - { "name": "appointment_time", "type": "text", "example": "10:30 AM" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Confirm" }, - { "type": "QUICK_REPLY", "text": "Reschedule" } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: every button action is tied to the appointment itself; no -"Book another visit" or other cross-sell. - -## Marketing - -### 1. Promo announcement - -Business-initiated, discount code in the body. Unambiguously marketing. - -```json -{ - "name": "spring_promo_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our spring sale is on — use code {{2}} for 20% off through Sunday.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "SPRING20" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale" - } - ], - "channels": ["whatsapp"] -} -``` - -### 2. Re-engagement (win-back) - -```json -{ - "name": "winback_30d_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, we miss you! Here is 15% off your next order with code {{2}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "COMEBACK15" } - ] - }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Shop deals" }, - { "type": "QUICK_REPLY", "text": "Browse new" } - ], - "channels": ["whatsapp"] -} -``` - -### 3. Seasonal announcement (image header) - -```json -{ - "name": "holiday_drop_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our holiday collection just dropped. Take a look before it sells out.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" } - ] - }, - "header": { - "type": "IMAGE", - "content": "https://example.com/assets/holiday-2026.jpg" - }, - "buttons": [ - { - "type": "URL", - "text": "See collection", - "url": "https://example.com/holiday" - } - ], - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` ## Authentication -Authentication templates are submitted under Sent's `AUTHENTICATION` category. -The Cloud API-specific `OTP` button types (`COPY_CODE`, `AUTOFILL`) are -Meta-side concepts and are not part of Sent's `buttons[].type` enum -(`QUICK_REPLY | URL | PHONE_NUMBER`). At the Sent layer, model the code as a -single body variable. - -### 1. One-time code - + ```json { - "name": "login_otp_v1", "category": "AUTHENTICATION", "language": "en_US", - "body": { - "content": "{{1}} is your verification code. For your security, do not share this code.", - "variables": [ - { "name": "code", "type": "text", "example": "729451" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Your verification code is {{0:variable}}.", + "variables": [ + {"id": 0, "name": "verificationCode", "type": "variable", "props": {"sample": "482193"}} + ] + } + }, + "footer": null, + "buttons": [ + {"id": 1, "type": "COPY_CODE", "props": {"text": "Copy code", "offerCode": "482193"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": { + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 + } }, - "footer": { "content": "This code expires in 10 minutes." }, - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` -### 2. Password reset confirmation (utility-shaped, plain confirmation) +## Meta Cloud API example — not a Sent request -If you only want a confirmation (no code), submit it as `UTILITY`: +The following abbreviated shape is deliberately separate. It must not pass the Sent linter or be posted to `POST /v3/templates`; convert its `components[]` into Sent's `definition` structure first. ```json { - "name": "password_changed_v1", - "category": "UTILITY", + "name": "order_update", "language": "en_US", - "body": { - "content": "Your account password was changed on {{1}}. If this was not you, contact support.", - "variables": [ - { "name": "changed_at", "type": "text", "example": "Mon May 18, 10:32 AM" } - ] - }, - "channels": ["whatsapp"] + "category": "UTILITY", + "components": [ + {"type": "BODY", "text": "Your order {{1}} has shipped."} + ] } ``` - -## Things to copy - -- Variable numbering in `body.content` is always `{{1}}, {{2}}, {{3}}` in order - of first appearance. -- `body.variables` is an array with one entry per `{{n}}`, in the same order. - Each entry has a `name`, a `type` (`text` | `number` | `date`), and an - `example` that matches the type. -- URL buttons take a `url`. Variables in URLs (`https://example.com/o/{{1}}`) - reuse the body's variable ordering. -- Phone-number buttons take a `phone_number` instead of `url`. -- Footers cannot contain variables — keep compliance-only language there. -- `channels` selects the channels the template should be available on. For a - WhatsApp-only template, use `["whatsapp"]`. diff --git a/packages/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json b/packages/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json index 3d3c1d3..16cb470 100644 --- a/packages/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json +++ b/packages/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json @@ -1,23 +1,11 @@ { - "name": "shipping_update_bad_v1", + "name": "meta_cloud_shape", "language": "en_US", "category": "UTILITY", "components": [ { "type": "BODY", - "text": "Hi {{2}}, your package #{{1}} is on the way. Buy now — limited time!", - "example": { "body_text": [["A1029", "Jordan"]] } - }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale", - "example": ["https://example.com/sale"] - } - ] + "text": "This is Meta's components[] format, not the Sent v3 request body." } ] } diff --git a/packages/sent/skills/waba-template-author/scripts/fixtures/utility_good.json b/packages/sent/skills/waba-template-author/scripts/fixtures/utility_good.json index 445c118..2b33ccc 100644 --- a/packages/sent/skills/waba-template-author/scripts/fixtures/utility_good.json +++ b/packages/sent/skills/waba-template-author/scripts/fixtures/utility_good.json @@ -1,27 +1,80 @@ { - "name": "shipping_update_v2", - "language": "en_US", "category": "UTILITY", - "components": [ - { - "type": "BODY", - "text": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "example": { "body_text": [["Jordan", "A1029", "6 PM today"]] } + "language": "en_US", + "definition": { + "header": { + "type": "text", + "template": "Order update", + "variables": [] + }, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} is ready.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": { + "type": "body", + "template": "Order {{1:variable}} is ready.", + "variables": [ + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + } }, - { - "type": "FOOTER", - "text": "Reply STOP to opt out." + "footer": { + "type": "text", + "template": "Acme Support", + "variables": [] }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}", - "example": ["https://example.com/track/A1029"] - } - ] - } - ] + "buttons": [ + { + "id": 1, + "type": "QUICK_REPLY", + "props": {"text": "Got it", "quickReplyType": "custom"} + }, + { + "id": 2, + "type": "URL", + "props": {"text": "Track order", "urlType": "static", "url": "https://example.com/track"} + }, + { + "id": 3, + "type": "VOICE_CALL", + "props": {"text": "Voice support", "countryCode": "US", "phoneNumber": "+12025550100"} + }, + { + "id": 4, + "type": "PHONE_NUMBER", + "props": {"text": "Call support", "countryCode": "US", "phoneNumber": "+12025550101"} + }, + { + "id": 5, + "type": "COPY_CODE", + "props": {"text": "Copy reference", "offerCode": "A-1042"} + } + ], + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } diff --git a/packages/sent/skills/waba-template-author/scripts/lint_waba_template.py b/packages/sent/skills/waba-template-author/scripts/lint_waba_template.py index 131dbd0..ccb5a1e 100644 --- a/packages/sent/skills/waba-template-author/scripts/lint_waba_template.py +++ b/packages/sent/skills/waba-template-author/scripts/lint_waba_template.py @@ -1,32 +1,9 @@ #!/usr/bin/env python3 -"""Lint a Sent WhatsApp template payload against Meta's category + structural rules. - -Usage: - python lint_waba_template.py - -Exits 0 with "OK" if the template passes all checks. -Exits non-zero and prints each issue with the offending field on failure. - -Checks performed: - * Required top-level keys: name, language, category, components. - * category is one of UTILITY, MARKETING, AUTHENTICATION (Sent's three). - * language matches BCP-47 (lowercase locale, optional uppercase region: en, en_US). - * Exactly one BODY component is present with non-empty text - (AUTHENTICATION templates are exempt — they use managed body content). - * Placeholders {{1}}..{{N}} in body text are numbered 1..N sequentially with no gaps. - * Sample value count under example.body_text matches the placeholder count. - * For UTILITY templates: - - Promotional phrases ("buy now", "limited time", "special offer", - "discount", "sale", "free shipping") trigger warnings. - - "click here to purchase" triggers a hard failure (clear cross-sell). - * HEADER component (if present): `format` must be one of TEXT, IMAGE, VIDEO, - DOCUMENT (Sent's allowed CreateTemplateRequest header types). - * BUTTONS component (if present) is either <=3 QUICK_REPLY OR <=2 CTA - (URL/PHONE_NUMBER/OTP) — never mixed. Per-button checks: - - type must be one of QUICK_REPLY, URL, PHONE_NUMBER (Sent's button - enum) or OTP (Cloud API authentication-only, warned). - - URL buttons require a non-empty `url` field. - - PHONE_NUMBER buttons require a non-empty `phone_number` field. +"""Lint the JSON body sent to ``POST /v3/templates``. + +This validator intentionally accepts the Sent v3 request contract, not Meta's +Cloud API ``components[]`` format. Meta payloads are useful reference material, +but must be labelled and converted before they are sent to Sent. """ from __future__ import annotations @@ -35,13 +12,33 @@ import json import re import sys +from collections import Counter from pathlib import Path from typing import Any -VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} -LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") -PLACEHOLDER_RE = re.compile(r"\{\{(\d+)\}\}") +TOP_LEVEL_FIELDS = { + "category", + "language", + "definition", + "creation_source", + "submit_for_review", + "sandbox", +} +CREATE_UNSUPPORTED_FIELDS = {"name", "channels", "body", "header", "buttons", "components"} +VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} +VALID_BODY_CHANNELS = {"multiChannel", "sms", "whatsapp", "rcs"} +VALID_BUTTON_TYPES = {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"} +BUTTON_LIMITS = { + "QUICK_REPLY": 10, + "URL": 2, + "VOICE_CALL": 1, + "PHONE_NUMBER": 1, + "COPY_CODE": 1, +} +LANGUAGE_RE = re.compile(r"^[a-z]{2}(?:_[A-Z]{2})?$") +PLACEHOLDER_RE = re.compile(r"\{\{(\d+):(variable|link|media)\}\}") +ANY_PLACEHOLDER_RE = re.compile(r"\{\{[^{}]+\}\}") PROMO_WARN_PHRASES = ( "buy now", "limited time", @@ -52,13 +49,6 @@ ) PROMO_FAIL_PHRASES = ("click here to purchase",) -CTA_BUTTON_TYPES = {"URL", "PHONE_NUMBER", "OTP"} -QUICK_REPLY_TYPE = "QUICK_REPLY" - -# Sent CreateTemplateRequest enums (snapshot Template Models section). -SENT_HEADER_TYPES = {"TEXT", "IMAGE", "VIDEO", "DOCUMENT"} -SENT_BUTTON_TYPES = {"QUICK_REPLY", "URL", "PHONE_NUMBER"} - class LintResult: def __init__(self) -> None: @@ -76,243 +66,218 @@ def failed(self) -> bool: return bool(self.errors) -def _components_by_type(components: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: - grouped: dict[str, list[dict[str, Any]]] = {} - for c in components: - t = c.get("type") - if not isinstance(t, str): - continue - grouped.setdefault(t.upper(), []).append(c) - return grouped - - -def _check_top_level(payload: dict[str, Any], result: LintResult) -> None: - for key in ("name", "language", "category", "components"): - if key not in payload: - result.error(key, f"missing required top-level key '{key}'") - - category = payload.get("category") - if isinstance(category, str) and category not in VALID_CATEGORIES: - result.error( - "category", - f"category '{category}' must be one of {sorted(VALID_CATEGORIES)}", - ) - - language = payload.get("language") - if isinstance(language, str) and not LANGUAGE_RE.match(language): - result.error( - "language", - f"language '{language}' must match BCP-47 form (e.g. 'en' or 'en_US')", - ) - - components = payload.get("components") - if components is not None and not isinstance(components, list): - result.error("components", "components must be a list") - - -def _check_body(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - grouped = _components_by_type(components) - category = payload.get("category") - body_list = grouped.get("BODY", []) - - if category == "AUTHENTICATION": - # Authentication body uses managed content (e.g. add_security_recommendation); - # freeform text is not required and placeholder checks do not apply. - return - - if not body_list: - result.error("components", "exactly one BODY component is required") +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _reject_unknown_fields( + value: dict[str, Any], allowed: set[str], field: str, result: LintResult +) -> None: + for key in sorted(set(value) - allowed): + result.error(f"{field}.{key}" if field else key, "field is not part of the Sent v3 request contract") + + +def _check_variable( + variable: Any, + field: str, + expected_kind: str | None, + result: LintResult, +) -> int | None: + if not isinstance(variable, dict): + result.error(field, "variable must be an object") + return None + for key in ("id", "name", "type", "props"): + if key not in variable: + result.error(f"{field}.{key}", "missing required variable field") + variable_id = variable.get("id") + if not isinstance(variable_id, int) or variable_id < 0: + result.error(f"{field}.id", "must be a non-negative integer") + variable_id = None + if not _nonempty(variable.get("name")): + result.error(f"{field}.name", "must be a non-empty string") + kind = variable.get("type") + if kind not in {"variable", "link", "media"}: + result.error(f"{field}.type", "must be variable, link, or media") + elif expected_kind is not None and kind != expected_kind: + result.error(f"{field}.type", f"placeholder declares {expected_kind!r}, but variable declares {kind!r}") + props = variable.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + elif not _nonempty(props.get("sample")): + result.error(f"{field}.props.sample", "must be a non-empty review and preview sample") + return variable_id + + +def _check_content(content: Any, field: str, result: LintResult) -> None: + if not isinstance(content, dict): + result.error(field, "must be an object") return - if len(body_list) > 1: - result.error("components", "more than one BODY component is not allowed") - - body = body_list[0] - text = body.get("text") - if not isinstance(text, str) or not text.strip(): - result.error("components[BODY].text", "BODY text must be a non-empty string") + _reject_unknown_fields(content, {"type", "template", "variables"}, field, result) + template = content.get("template") + if not _nonempty(template): + result.error(f"{field}.template", "must be a non-empty string") return + if len(template) > 1024: + result.error(f"{field}.template", f"body exceeds the 1,024-character limit ({len(template)})") - placeholders = [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text)] - if placeholders: - unique_sorted = sorted(set(placeholders)) - expected = list(range(1, len(unique_sorted) + 1)) - if unique_sorted != expected: - result.error( - "components[BODY].text", - f"placeholders must be sequential starting at 1, got {unique_sorted}", - ) - first_occurrence = [] - seen: set[int] = set() - for n in placeholders: - if n not in seen: - first_occurrence.append(n) - seen.add(n) - if first_occurrence != sorted(first_occurrence): - result.error( - "components[BODY].text", - f"placeholders must first appear in numeric order, got {first_occurrence}", - ) - - example = body.get("example", {}) - body_text = example.get("body_text") if isinstance(example, dict) else None - placeholder_count = len(set(placeholders)) - - if placeholder_count == 0: - if body_text: - result.warn( - "components[BODY].example.body_text", - "body has no placeholders but example.body_text is present", - ) - return - - if not isinstance(body_text, list) or not body_text: - result.error( - "components[BODY].example.body_text", - "example.body_text must be a non-empty list of sample rows", - ) - return - first_row = body_text[0] - if not isinstance(first_row, list): - result.error( - "components[BODY].example.body_text", - "example.body_text must be array-of-arrays (one row per variable group)", - ) - return - if len(first_row) != placeholder_count: + placeholders = [(int(match.group(1)), match.group(2)) for match in PLACEHOLDER_RE.finditer(template)] + malformed = [match.group(0) for match in ANY_PLACEHOLDER_RE.finditer(template) if not PLACEHOLDER_RE.fullmatch(match.group(0))] + if malformed: result.error( - "components[BODY].example.body_text", - f"sample count {len(first_row)} does not match placeholder count {placeholder_count}", + f"{field}.template", + "use Sent placeholders such as '{{0:variable}}'; malformed: " + ", ".join(malformed), ) - -def _check_utility_promo(payload: dict[str, Any], result: LintResult) -> None: - if payload.get("category") != "UTILITY": - return - components = payload.get("components") - if not isinstance(components, list): - return - body_list = _components_by_type(components).get("BODY", []) - if not body_list: + variables = content.get("variables", []) + if variables is None: + variables = [] + if not isinstance(variables, list): + result.error(f"{field}.variables", "must be an array") return - text = body_list[0].get("text") - if not isinstance(text, str): - return - lowered = text.lower() - for phrase in PROMO_FAIL_PHRASES: - if phrase in lowered: - result.error( - "components[BODY].text", - f"UTILITY body contains banned promotional phrase '{phrase}'", - ) - for phrase in PROMO_WARN_PHRASES: - if phrase in lowered: - result.warn( - "components[BODY].text", - f"UTILITY body contains promotional phrase '{phrase}' — Meta is likely to re-categorize as MARKETING", - ) - -def _check_header(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - headers = _components_by_type(components).get("HEADER", []) - if not headers: - return - header = headers[0] - # In the Cloud API shape used by these fixtures, HEADER type discriminator - # is `format`; in Sent's shape it would be `type`. Accept either. - fmt = header.get("format") - if fmt is None: - fmt = header.get("type") - if not isinstance(fmt, str): - result.error( - "components[HEADER].format", - "HEADER component requires a 'format' (TEXT/IMAGE/VIDEO/DOCUMENT)", + expected = {variable_id: kind for variable_id, kind in placeholders} + if len(expected) != len({variable_id for variable_id, _ in placeholders}): + result.error(f"{field}.template", "one placeholder id cannot be reused with different types") + actual_ids: list[int] = [] + for index, variable in enumerate(variables): + variable_id = variable.get("id") if isinstance(variable, dict) else None + checked_id = _check_variable( + variable, + f"{field}.variables[{index}]", + expected.get(variable_id) if isinstance(variable_id, int) else None, + result, ) + if checked_id is not None: + actual_ids.append(checked_id) + duplicates = [str(key) for key, count in Counter(actual_ids).items() if count > 1] + if duplicates: + result.error(f"{field}.variables", "duplicate variable ids: " + ", ".join(duplicates)) + missing = sorted(set(expected) - set(actual_ids)) + extra = sorted(set(actual_ids) - set(expected)) + if missing: + result.error(f"{field}.variables", f"missing definitions for placeholder ids {missing}") + if extra: + result.error(f"{field}.variables", f"variables without matching placeholders: {extra}") + + +def _check_header_or_footer(value: Any, field: str, limit: int, result: LintResult) -> None: + if value is None: return - if fmt.upper() not in SENT_HEADER_TYPES: - result.error( - "components[HEADER].format", - f"header format '{fmt}' must be one of {sorted(SENT_HEADER_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - - -def _check_buttons(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): + if not isinstance(value, dict): + result.error(field, "must be an object or null") return - button_components = _components_by_type(components).get("BUTTONS", []) - if not button_components: + _reject_unknown_fields(value, {"type", "template", "variables"}, field, result) + template = value.get("template") + if not isinstance(template, str): + result.error(f"{field}.template", "must be a string") return - buttons = button_components[0].get("buttons") - if not isinstance(buttons, list) or not buttons: - result.error("components[BUTTONS].buttons", "buttons list must be non-empty") + if len(template) > limit: + result.error(f"{field}.template", f"exceeds the {limit}-character limit") + if field.endswith("footer") and (ANY_PLACEHOLDER_RE.search(template) or value.get("variables")): + result.error(field, "footer variables are not supported") + elif field.endswith("header"): + _check_content({"template": template, "variables": value.get("variables", [])}, field, result) + + +def _check_button(button: Any, index: int, result: LintResult) -> str | None: + field = f"definition.buttons[{index}]" + if not isinstance(button, dict): + result.error(field, "button must be an object") + return None + _reject_unknown_fields(button, {"id", "type", "props"}, field, result) + button_type = button.get("type") + if button_type not in VALID_BUTTON_TYPES: + result.error(f"{field}.type", f"must be one of {sorted(VALID_BUTTON_TYPES)}") + return None + props = button.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + return button_type + text = props.get("text") + if not _nonempty(text) or len(text) > 25: + result.error(f"{field}.props.text", "must be 1–25 characters") + if button_type == "QUICK_REPLY" and not _nonempty(props.get("quickReplyType")): + result.error(f"{field}.props.quickReplyType", "is required for QUICK_REPLY") + elif button_type == "URL": + if not _nonempty(props.get("urlType")): + result.error(f"{field}.props.urlType", "is required for URL") + if not _nonempty(props.get("url")): + result.error(f"{field}.props.url", "is required for URL") + elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}: + if not _nonempty(props.get("countryCode")): + result.error(f"{field}.props.countryCode", f"is required for {button_type}") + if not _nonempty(props.get("phoneNumber")): + result.error(f"{field}.props.phoneNumber", f"is required for {button_type}") + elif button_type == "COPY_CODE" and not _nonempty(props.get("offerCode")): + result.error(f"{field}.props.offerCode", "is required for COPY_CODE") + return button_type + + +def _check_definition(payload: dict[str, Any], result: LintResult) -> None: + definition = payload.get("definition") + if not isinstance(definition, dict): + result.error("definition", "required and must be an object") return - types = [] - for i, b in enumerate(buttons): - t = b.get("type") - if not isinstance(t, str): - result.error(f"components[BUTTONS].buttons[{i}].type", "button type missing") - continue - upper = t.upper() - types.append(upper) - - # Sent's button type enum is QUICK_REPLY|URL|PHONE_NUMBER. Cloud-API OTP - # buttons (auth-only) are surfaced as a warning rather than a hard fail - # because Cloud-API-shaped fixtures use them; they are not part of the - # Sent CreateTemplateRequest button enum. - if upper not in SENT_BUTTON_TYPES and upper != "OTP": - result.error( - f"components[BUTTONS].buttons[{i}].type", - f"button type '{t}' must be one of {sorted(SENT_BUTTON_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - continue - if upper == "OTP": - result.warn( - f"components[BUTTONS].buttons[{i}].type", - "'OTP' is a Cloud-API auth-only button type; not part of Sent's " - "button enum (QUICK_REPLY/URL/PHONE_NUMBER)", + _reject_unknown_fields( + definition, + {"header", "body", "footer", "buttons", "definitionVersion", "authenticationConfig"}, + "definition", + result, + ) + body = definition.get("body") + if not isinstance(body, dict): + result.error("definition.body", "required and must be an object") + else: + _reject_unknown_fields(body, VALID_BODY_CHANNELS, "definition.body", result) + if body.get("multiChannel") is None: + result.error("definition.body.multiChannel", "is required as the channel-neutral body") + for channel, content in body.items(): + if channel in VALID_BODY_CHANNELS and content is not None: + _check_content(content, f"definition.body.{channel}", result) + + _check_header_or_footer(definition.get("header"), "definition.header", 60, result) + _check_header_or_footer(definition.get("footer"), "definition.footer", 60, result) + + buttons = definition.get("buttons", []) + if buttons is None: + buttons = [] + if not isinstance(buttons, list): + result.error("definition.buttons", "must be an array or null") + buttons = [] + elif len(buttons) > 10: + result.error("definition.buttons", f"at most 10 buttons are allowed, got {len(buttons)}") + counts = Counter(filter(None, (_check_button(button, index, result) for index, button in enumerate(buttons)))) + for button_type, limit in BUTTON_LIMITS.items(): + if counts[button_type] > limit: + result.error("definition.buttons", f"{button_type} allows at most {limit}, got {counts[button_type]}") + + authentication = definition.get("authenticationConfig") + category = payload.get("category") + if authentication is not None: + if category != "AUTHENTICATION": + result.error("definition.authenticationConfig", "is only valid for AUTHENTICATION templates") + if not isinstance(authentication, dict): + result.error("definition.authenticationConfig", "must be an object or null") + else: + _reject_unknown_fields( + authentication, + {"addSecurityRecommendation", "codeExpirationMinutes"}, + "definition.authenticationConfig", + result, ) - - if upper == "URL": - url = b.get("url") - if not isinstance(url, str) or not url.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].url", - "URL buttons require a non-empty 'url' field", - ) - if upper == "PHONE_NUMBER": - phone = b.get("phone_number") - if not isinstance(phone, str) or not phone.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].phone_number", - "PHONE_NUMBER buttons require a non-empty 'phone_number' field", - ) - - quick = sum(1 for t in types if t == QUICK_REPLY_TYPE) - cta = sum(1 for t in types if t in CTA_BUTTON_TYPES) - if quick and cta: - result.error( - "components[BUTTONS].buttons", - "cannot mix QUICK_REPLY and CTA buttons in the same template", - ) - return - if quick > 3: - result.error( - "components[BUTTONS].buttons", - f"at most 3 QUICK_REPLY buttons allowed, got {quick}", - ) - if cta > 2: - result.error( - "components[BUTTONS].buttons", - f"at most 2 CTA buttons allowed, got {cta}", - ) + recommendation = authentication.get("addSecurityRecommendation") + if recommendation is not None and not isinstance(recommendation, bool): + result.error("definition.authenticationConfig.addSecurityRecommendation", "must be boolean") + expiration = authentication.get("codeExpirationMinutes") + if expiration is not None and (not isinstance(expiration, int) or not 1 <= expiration <= 90): + result.error("definition.authenticationConfig.codeExpirationMinutes", "must be an integer from 1 to 90") + if category == "AUTHENTICATION": + if authentication is None: + result.error("definition.authenticationConfig", "is required for AUTHENTICATION templates") + if any(button_type != "COPY_CODE" for button_type in counts): + result.error("definition.buttons", "AUTHENTICATION templates may only use COPY_CODE buttons") + if counts["COPY_CODE"] != 1: + result.error("definition.buttons", "AUTHENTICATION templates require exactly one COPY_CODE button") def lint_template(payload: Any) -> LintResult: @@ -320,52 +285,63 @@ def lint_template(payload: Any) -> LintResult: if not isinstance(payload, dict): result.error("", "template payload must be a JSON object") return result - _check_top_level(payload, result) - _check_body(payload, result) - _check_utility_promo(payload, result) - _check_header(payload, result) - _check_buttons(payload, result) + if "components" in payload: + result.error( + "components", + "Meta Cloud API components[] is not a Sent payload; convert it to definition before POST /v3/templates", + ) + for field in sorted(CREATE_UNSUPPORTED_FIELDS & set(payload)): + result.error(field, "unsupported top-level create field") + _reject_unknown_fields(payload, TOP_LEVEL_FIELDS, "", result) + category = payload.get("category") + if category is not None and category not in VALID_CATEGORIES: + result.error("category", f"must be one of {sorted(VALID_CATEGORIES)} or null") + language = payload.get("language") + if language is not None and (not isinstance(language, str) or not LANGUAGE_RE.fullmatch(language)): + result.error("language", "must look like en or en_US") + for field in ("submit_for_review", "sandbox"): + if field in payload and not isinstance(payload[field], bool): + result.error(field, "must be boolean") + _check_definition(payload, result) + + if category == "UTILITY": + body = payload.get("definition", {}).get("body", {}).get("multiChannel", {}) + text = body.get("template", "") if isinstance(body, dict) else "" + lowered = text.lower() + for phrase in PROMO_FAIL_PHRASES: + if phrase in lowered: + result.error("definition.body.multiChannel.template", f"UTILITY body contains promotional phrase {phrase!r}") + for phrase in PROMO_WARN_PHRASES: + if phrase in lowered: + result.warn("definition.body.multiChannel.template", f"Meta may reclassify promotional phrase {phrase!r} as MARKETING") return result def _format(prefix: str, entries: list[tuple[str, str]]) -> str: - return "\n".join(f"{prefix} {field}: {msg}" for field, msg in entries) + return "\n".join(f"{prefix} {field}: {message}" for field, message in entries) -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser( - prog="lint_waba_template.py", - description="Lint a Sent WhatsApp template payload against Meta's structural and category rules.", - ) - parser.add_argument( - "path", - type=Path, - help="Path to a JSON file containing a WhatsApp template payload.", - ) +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path, help="Sent POST /v3/templates request JSON") args = parser.parse_args(argv) - try: - raw = args.path.read_text(encoding="utf-8") + payload = json.loads(args.path.read_text(encoding="utf-8")) except OSError as exc: print(f"could not read {args.path}: {exc}", file=sys.stderr) return 2 - try: - payload = json.loads(raw) except json.JSONDecodeError as exc: print(f"invalid JSON in {args.path}: {exc}", file=sys.stderr) return 2 - result = lint_template(payload) - if result.warnings: print(_format("WARN", result.warnings)) if result.errors: print(_format("FAIL", result.errors)) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + raise SystemExit(main()) diff --git a/plugins/sent/skills/messaging-performance-analyzer/SKILL.md b/plugins/sent/skills/messaging-performance-analyzer/SKILL.md index 8421fef..c7b0d4d 100644 --- a/plugins/sent/skills/messaging-performance-analyzer/SKILL.md +++ b/plugins/sent/skills/messaging-performance-analyzer/SKILL.md @@ -44,7 +44,7 @@ Restate the user’s exact question as a measurable comparison. “WhatsApp is b Capture these dimensions before calculating anything: profile or sender identity, template ID/name, channel, country, send window, recipient segment, and whether fallback or multi-channel broadcast was requested. -**Example.** If a user says “RCS fallback stopped working,” define the cohort as messages sent with `channel: ["rcs", "sms"]` during the affected window, then compare RCS statuses, SMS fallback statuses, and duplicate recipient/channel pairs separately. +**Example.** If a user says “RCS fallback stopped working,” define the cohort as sends that omitted `channel` or used `channel: ["sent"]`, then compare the selected `payload.channel` and message activities. Analyze any explicit multi-channel arrays separately as broadcasts. ### 2. Build cohorts from Sent message IDs @@ -79,7 +79,7 @@ SMS, WhatsApp, and RCS fail differently. Do not average them together unless the |---|---|---| | SMS | Country, sender/profile, 10DLC campaign, opt-out, carrier family | Compliance status, brand/campaign readiness, opt-out logs, throughput patterns. | | WhatsApp | Template, language, category, recipient country, quality/tier symptoms | Template status, read receipts, conversation window, Meta-side errors if present. | -| RCS | Agent readiness, fallback behavior, capability gaps, rich content rendering | Sent RCS setup status, fallback SMS results, capability/error details if present. | +| RCS | Agent readiness, automatic routing, pinned-channel failures, text/suggestion-chip rendering | Sent RCS setup status, selected route, and exact activity/error details. | ### 6. Quantify impact before recommending fixes @@ -101,7 +101,7 @@ Do not label a campaign “carrier filtered” from a small sample without compa Do not treat `READ` as a universal stage. Sent documents read receipts for WhatsApp and RCS; SMS generally does not support read receipts. -Do not collapse RCS fallback into SMS delivery. For `channel: ["rcs", "sms"]`, count RCS attempts and SMS attempts separately, then report recipient-level success if the user asks for it. +Do not mistake broadcast for fallback. Omitted `channel` or `["sent"]` enables automatic routing; one explicit channel pins delivery; multiple explicit values create separate messages. Count every returned `message_id` once and report the selected channel. ## Verification checklist diff --git a/plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md b/plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md index 833d844..a24bd47 100644 --- a/plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md +++ b/plugins/sent/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md @@ -85,9 +85,11 @@ If `DELIVERED` is healthy and `READ` is low across all cohorts, the cause is alm ### Symptom: RCS funnel "looks broken" -RCS is two funnels stitched together. Capability check happens before delivery; most "RCS broken" reports are actually "the audience isn't RCS-capable." +RCS routing and delivery are separate stages. Capability selection happens before delivery; many "RCS broken" reports are audiences that were not routed to RCS. -- If the Sender Profile uses fallback (`"channel": ["rcs", "sms"]`), the SMS fallback leg has its own `message_id` and its own lifecycle. Count separately. Never roll fallback SMS into RCS delivery. +- Omitted `channel` or `["sent"]` enables automatic routing. Inspect the returned message record and `payload.channel` to see what Sent selected. +- `["rcs"]` pins the send to RCS and is the cleanest cohort for isolating an RCS launch or payload problem. +- Multiple explicit channels are broadcast and create separate message IDs. Count them separately and never call one leg fallback. - Per-carrier RCS approval is real — an agent can be launched on one carrier and not on another. Symptoms scoped to one carrier point at agent state; hand off to `rcs-agent-onboarding`. ## Cross-skill handoff matrix diff --git a/plugins/sent/skills/rcs-agent-onboarding/SKILL.md b/plugins/sent/skills/rcs-agent-onboarding/SKILL.md index 4570ec1..1aa3df6 100644 --- a/plugins/sent/skills/rcs-agent-onboarding/SKILL.md +++ b/plugins/sent/skills/rcs-agent-onboarding/SKILL.md @@ -1,155 +1,88 @@ --- name: rcs-agent-onboarding -description: Guides RCS/RBM onboarding for Sent customers by preparing agent identity, launch evidence, carrier-approval handoff, SMS fallback, and post-launch verification. Use when a user says RCS agent, RBM, rich business messaging, carrier launch, RCS approval, RCS fallback, branded messages, agent verification, capability check, or wants to send RCS through Sent. +description: Guides current Sent RCS and RBM onboarding, launch evidence, carrier approval, text and suggestion-chip templates, Sender Profile readiness, and safe routing. Use for RCS launch, fallback, pinned-channel tests, or broadcast prevention. --- - +Sent RCS setup is not self-service. Sent and carrier approval are required. Prepare a complete launch packet, hand it to Sent, and verify the resulting Sender Profile with controlled messages. -# RCS agent onboarding +## Current capability boundary -## Overview +Current Sent RCS supports: -Use this skill to prepare a Sent customer for RCS launch without inventing a self-service provisioning flow. Sent’s public channel setup guidance says RCS setup is initiated through Sent, requires one-time carrier approval, and is not self-service. The agent’s job is to collect clean launch evidence, design fallback behavior, confirm profile/channel readiness, and create a verification plan for the first production sends. +- text content; and +- up to four suggestion chips. -RCS onboarding touches three separate layers. Sent owns the unified messaging API and fallback behavior. Google RBM and carriers own brand/agent review and launch approval. The customer owns brand assets, use-case clarity, consent, and support readiness. Keep those boundaries explicit. +Rich cards, carousels, and media attachments are roadmap features, not current Sent workflows. Do not request them as launch requirements, expose them as current template-builder controls, or declare them as active agent capabilities. -## When to use +## Routing semantics -Use this skill when the request mentions RCS, RBM, RCS agent, carrier launch, branded messaging, rich card, carousel, SMS fallback from RCS, or RCS approval. Use it for launch preparation, evidence gathering, fallback decisions, and post-launch smoke tests. +Channel selection on `POST /v3/messages` is not an ordered fallback list. -Do not use this skill for live delivery-rate analysis after launch; use `messaging-performance-analyzer`. Do not use it to register US SMS compliance; use `sms-10dlc-registration`. Do not promise direct Graph/RBM API provisioning unless the user confirms they operate the external RBM account outside Sent. +| Request | Behavior | +| --- | --- | +| Omit `channel` | Automatic Sent routing with fallback. | +| `channel: ["sent"]` | Explicit automatic Sent routing with fallback. | +| `channel: ["rcs"]` | Pinned RCS only; no cross-channel fallback. | +| Two or more explicit channel values | Broadcast: one separately created and billable message per recipient/channel pair. | -## Source-of-truth boundaries +Never put RCS and SMS together in an explicit array to describe fallback. Use omitted `channel` or `["sent"]` for automatic routing. Use explicit arrays only when broadcast is intended and confirmed. -| Topic | Treat as | Action | -|---|---|---| -| Sent API sending | Sent API fact | Use `POST /v3/messages` with templates and channel arrays. | -| RCS setup path | Sent documentation fact | Tell the user RCS setup is initiated by contacting Sent and requires approval. | -| SMS fallback | Sent documentation fact | Use Sent’s fallback behavior and explicit `channel: ["rcs", "sms"]` where appropriate. | -| Google RBM agent fields | External platform context | Collect assets and evidence, but do not claim Sent exposes those fields. | -| Per-carrier launch states | External platform context | Track approval evidence from Sent/Google/carriers; do not invent Sent status fields. | -| Rich-card rendering | Runtime evidence | Verify with test sends and message activities after setup is active. | +## Onboarding workflow -## Process +### 1. Define the launch use case -### 1. Classify the requested launch +Collect brand, audience, countries, consent source, message purpose, support path, estimated volume, and whether automatic fallback is required. Keep examples synthetic and within current text/chip capabilities. -Start by asking what the RCS agent will do, who receives the messages, and whether SMS fallback is required. The use case should be concrete enough for carrier review and template design. +### 2. Verify Sender Profile readiness -A good launch statement names the brand, audience, consent source, message types, support contact, and fallback behavior. A weak launch statement says only “we want RCS for marketing” or “we need branded SMS.” +Record the v3 profile UUID. Do not use legacy `x-sender-id` as v3 authentication. Choose a profile-specific API key or an organization API key with `x-profile-id`; only organization keys may use that header. -**Example.** “Acme Logistics wants RCS order updates for US consumers who opted in at checkout. Messages include shipment confirmation, delivery window changes, and support links. If RCS is unavailable, send the SMS version through the same Sent profile.” +If automatic routing may select US SMS, complete the appropriate 10DLC/compliance work first. An approved RCS agent does not make an SMS route compliant. -### 2. Build the RCS evidence packet +### 3. Prepare the evidence packet -Collect review-ready evidence before involving Sent. This reduces approval loops and prevents the agent from submitting vague brand claims. +Use [references/rcs-launch-evidence-packet.md](references/rcs-launch-evidence-packet.md). Include: -| Evidence | What to collect | Why it matters | -|---|---|---| -| Brand identity | Legal name, public brand name, website, logo, brand color, description | Reviewers compare the agent identity to the live business. | -| Contact and support | Support email, support phone, help URL, privacy policy | RCS users need visible ways to identify and contact the sender. | -| Use case | Transactional, OTP, marketing, customer care, or mixed use | Approval and fallback design depend on intent and consent. | -| Consent | Opt-in path, screenshot/URL, privacy policy, opt-out wording | Carriers need proof that recipients expect the messages. | -| Message examples | Representative plain-text and rich examples | Rich content must match the declared use case and brand. | -| SMS fallback | Equivalent SMS copy and approved SMS sender/compliance status | Fallback fails if SMS compliance is not ready. | +- consumer-facing brand name and website; +- logo and brand color; +- privacy policy and terms; +- support contacts; +- clear use case and consent flow; +- representative text messages; +- zero-to-four suggestion chips per message; +- target markets and requested timeline; +- automatic-routing or pinned-RCS test intent. -### 3. Check Sent profile and SMS fallback readiness +### 4. Hand off to Sent -Confirm that the customer has a Sender Profile in the Sent dashboard or through `/v3/profiles`. The dashboard walkthrough shows Sender Profiles with a display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The OpenAPI confirms profile creation, retrieval, update, and completion endpoints. +Because setup is not self-service, request Sent initiation and carrier approval. Do not fabricate RBM console clicks, public provisioning endpoints, capability declaration APIs, or carrier-approval status endpoints. -If the launch requires US SMS fallback, verify that the SMS side is compliant before RCS goes live. Sent’s channel setup guide recommends using the same phone number across SMS, WhatsApp, and RCS where possible, but fallback must still have a valid SMS route and compliance posture. +### 5. Build current templates -**Example fallback request.** After Sent confirms RCS is configured, a customer can request an RCS-first send with SMS fallback/broadcast semantics using a channel array such as: +Use Sent's template `definition` contract. RCS may have a complete `definition.body.rcs` override. Keep the RCS override text-based and limit suggestions to four. The `multiChannel` body remains required for template portability; routing fallback is still chosen at send time. -```json -{ - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } -} -``` +### 6. Test deliberately -Explain that Sent may create separate messages for each recipient/channel pair when multiple channels are specified. Analyze RCS and SMS attempts separately after sending. +- Validate templates and messages in sandbox where supported. +- Pin `["rcs"]` to prove the RCS path without cross-channel fallback. +- Omit `channel` or use `["sent"]` to verify automatic routing. +- If testing broadcast, state the expected recipient × channel message count and cost before sending. +- Persist every returned `message_id` with tenant, profile, channel, and logical test case. -### 4. Route the launch through Sent +Use `GET /v3/messages/{id}`, activities, and signed webhooks to verify actual routing and delivery. Do not infer fallback from the request alone. -Because Sent states that production RCS setup is not self-service, prepare a handoff note for Sent rather than pretending to click through an RBM console. Include the evidence packet, the Sender Profile identifier, the target countries/carriers if known, fallback requirements, and the requested go-live timeline. +## Launch acceptance -A clean handoff reads like this: +- [ ] Sent and carrier approval are confirmed. +- [ ] Profile UUID and credential pattern are recorded. +- [ ] Brand, consent, policy, and support evidence is complete. +- [ ] Templates use only text and up to four suggestion chips for RCS. +- [ ] Automatic fallback uses omitted `channel` or `["sent"]`. +- [ ] Pinned RCS uses `["rcs"]`. +- [ ] Broadcast is clearly labelled and costed. +- [ ] SMS compliance is ready wherever automatic routing can select SMS. +- [ ] Message IDs are mapped for webhook attribution. -> “Please initiate RCS setup for Sender Profile `support-us` / `x-sender-id` `...`. Brand is Acme Logistics, website `https://acme.example`, use case shipment notifications and customer-care replies. Opt-in occurs at checkout. SMS fallback is required through the existing US SMS route. Attached are logo, brand color, support contacts, privacy policy, and five message examples.” - -### 5. Define the test plan before launch - -Write the first-send test plan before approval arrives. Include a small set of internal numbers, target devices/carriers when available, template IDs, expected channel behavior, and rollback criteria. - -| Test | Expected result | Evidence to collect | -|---|---|---| -| RCS-capable internal device | RCS message reaches `DELIVERED`; `READ` may appear if opened. | Sent message status and activities. | -| Non-RCS-capable recipient | SMS fallback path succeeds where fallback is requested. | Separate RCS and SMS message IDs/statuses. | -| Rich content render | Cards/buttons render as designed on target devices. | Screenshots and message activities. | -| Webhook callback | Customer endpoint receives delivery/read events. | Sent webhook event history and customer logs. | - -### 6. Verify launch with Sent message evidence - -After Sent confirms the RCS setup is active, send a controlled batch using `POST /v3/messages`. For every Sent `message_id`, retrieve `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities`. Confirm that RCS messages progress through the documented lifecycle and that SMS fallback behaves as expected. - -If the first batch fails, do not guess. Separate setup failures from fallback failures, template/payload failures, and webhook ingestion failures. Use `messaging-performance-analyzer` for deeper funnel analysis once the launch is producing enough evidence. - -## Common rationalizations to avoid - -Do not tell the user RCS is self-service in Sent. Sent’s channel setup guide says to contact Sent and wait for carrier approval. - -Do not create a fake `fallback_policy` field in Sent requests. Use documented channel arrays and account-level fallback behavior unless a verified account-specific API field exists. - -Do not assume SMS fallback is safe because RCS is approved. SMS fallback needs a compliant sender, especially for US A2P traffic. - -Do not conflate brand approval with template quality. An approved RCS agent can still fail if the message payload, media, or fallback copy is wrong. - -## Verification checklist - -- [ ] The user’s RCS use case is specific enough for review and not just “send rich messages.” -- [ ] Brand identity, support contact, privacy policy, opt-in evidence, and sample messages are collected. -- [ ] The Sent Sender Profile or `x-sender-id` is identified. -- [ ] SMS fallback requirements are documented and routed to SMS compliance checks where needed. -- [ ] The handoff explicitly says Sent must initiate RCS setup and carrier approval. -- [ ] The first-send test plan includes RCS-capable, non-RCS-capable, rich-rendering, and webhook checks. -- [ ] Post-launch verification uses Sent `message_id`, status, and activities. -- [ ] External RBM facts are labeled as external context, not Sent API guarantees. - -## Related skills - -Use `sms-10dlc-registration` before launch when SMS fallback touches US A2P traffic, opt-in evidence, 10DLC campaigns, or brand vetting. - -Use `sender-profile-architect` when the customer has multiple brands, tenants, departments, or profiles and needs a durable sender architecture. - -Use `template-builder-ui` when the RCS launch needs reusable templates, rich component validation, or a template-creation workflow. - -Use `messaging-performance-analyzer` after launch when the user has message IDs, webhook events, failed sends, or delivery-rate symptoms. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/rbm-agent-spec.md` | Payload/schema reference | Keep Google RBM identity fields, asset requirements, and review vocabulary outside the skill body. | -| `references/rcs-launch-evidence-packet.md` | Worked example | Provide a complete filled-in launch packet for a realistic transactional RCS launch. | -| `references/rcs-fallback-patterns.md` | Decision matrix | Compare RCS-only, RCS-first with SMS fallback, and multi-channel broadcast patterns. | - -## Unverified claims to confirm or remove - -- Google RBM lifecycle states such as `pending_verification`, `launch_review`, or per-carrier launched states were not verified in Sent docs (these are Google-side, not exposed by Sent v3). -- Sent does not expose RCS rollout-status or capability-check endpoints in v3; use Activities + webhook events to observe behavior. -- Exact rich-card capability differences by carrier/device require external RBM evidence or live testing, not Sent docs alone. +Use [references/rbm-agent-spec.md](references/rbm-agent-spec.md) for the current launch specification and [references/rcs-fallback-patterns.md](references/rcs-fallback-patterns.md) for routing tests. Use `messaging-performance-analyzer` after enough message evidence exists. diff --git a/plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md b/plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md index 6099f15..070bd30 100644 --- a/plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md +++ b/plugins/sent/skills/rcs-agent-onboarding/references/rbm-agent-spec.md @@ -1,53 +1,29 @@ -# RBM Agent Spec — Reference +# Current Sent RCS launch specification -Supporting reference for `rcs-agent-onboarding`. This doc separates **what Sent exposes** about an RCS Agent from **what lives in Google RBM** (the underlying carrier platform). Keep that line bright: anything not in the Sent v3 docs snapshot is external context and should be linked, not mirrored. +## Approval boundary -Canonical Google source: [RCS Business Messaging docs](https://developers.google.com/business-communications/rcs-business-messaging). +RCS onboarding is coordinated through Sent and requires carrier approval. There is no public self-service provisioning flow in the current Sent v3 API. -## Sent's RCS setup model (verified) +## Required evidence -- **Not self-service.** Per Sent's channel-setup docs, RCS setup requires a one-time carrier approval that has to be initiated by contacting Sent (`support@sent.dm`). There is no dashboard button or v3 API endpoint that provisions an RCS Agent end-to-end on its own. -- **Post-approval visibility.** Once Sent (with Google + the carriers) has finished the approval cycle, the RCS Agent — Sent calls it a **Branded Sender** for RCS — appears in the dashboard alongside the customer's other channels. -- **No rollout-status or capability-check endpoint.** The v3 API does not surface a per-carrier launch state, an agent-ID field, or a recipient capability probe. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and webhook events (see `references/rcs-fallback-patterns.md`). +- Consumer-facing brand name and website +- Logo and primary brand color +- Privacy policy and terms +- Customer support details +- Consent/opt-in description +- Message purpose, audience, market, and volume +- Representative text messages +- Suggestion-chip labels/actions when used +- Sender Profile UUID and desired launch timeline -If a request implies "click here to create my RCS agent", correct it. The handoff is human-initiated by emailing `support@sent.dm` with the launch evidence packet. +## Supported message capability -## Sent-side terminology (verified) +Current Sent RCS guidance supports text and up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap features. Do not make them current approval prerequisites or capability declarations. -These are the terms Sent uses in its public docs and dashboard. Use them when talking to a Sent customer instead of Google's RBM API names. +## Identity and credentials -| Sent term | What it is | -|---|---| -| **RCS Agent** | The branded RCS sender identity that recipients see. Created on Sent's side after carrier approval. | -| **Branded Sender** | Sent's umbrella term for the per-channel sender identity (the RCS Agent for RCS, the WABA-attached phone number for WhatsApp, the 10DLC long code for SMS). | -| **Rich Card** | A single card with media, title, description, and suggestion chips. | -| **Carousel Card** | A horizontally-swiped collection of up to **10** Rich Cards. | -| **Suggestion Chip** | A tap-to-act chip below or inside a message. Three documented kinds: quick reply, open URL, dial number. | +Use the Sender Profile UUID. A profile key sends `x-api-key` alone; an organization key may scope with `x-profile-id`. `x-sender-id` is legacy v1/v2 terminology. -## Google RBM-side concepts (external — link, do not mirror) +## Approval states -The following live entirely in Google's RBM platform and are **not** exposed in the Sent v3 API. Don't pretend Sent surfaces them; treat them as platform context the customer hears about during review. - -- Agent identity fields (`displayName`, `logoUri`, `heroUri`, `color`, `verifiedDomains`, contact info) — set during Sent's onboarding handoff, not via Sent v3 API. See [Google's agent reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents). -- RBM capabilities the agent must declare (suggested replies, suggested actions, standalone rich card, rich card carousel, file/image/video/audio attachments). See [Google's capabilities guide](https://developers.google.com/business-communications/rcs-business-messaging/guides/build/capabilities). -- RBM use case taxonomy (`TRANSACTIONAL`, `OTP`, `PROMOTIONAL`, `CUSTOMER_CARE`, `MULTI_USE`). -- Verification and launch-review lifecycle (Google review → per-carrier review → carrier-specific `ENABLED` / `PENDING` rollout). Typical turnaround: 1-7 business days for verification; longer for launch. -- Per-carrier rejection codes and rejection reasons. See [Google's launch guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). -- Capability-check endpoint (`https://rcsbusinessmessaging.googleapis.com/v1/users/{phoneNumber}:capabilities`) — Google-side, requires direct RBM API access, not part of Sent v3. - -## What Sent does NOT expose (gap notes) - -These would be useful for an agent but are not in the v3 docs snapshot: - -- A field on the message resource indicating per-carrier launch state for the originating RCS Agent. -- A capability-check endpoint on Sent's side. -- A public `fallback_policy` field — channel selection is done via the `channel` array on the send request (see `references/rcs-fallback-patterns.md`). -- A structured RBM rejection code on failed messages. The `message.failed` webhook carries `payload.message_status = FAILED`; the human-readable reason lives in the `description` of the message detail fetched via `GET /v3/messages/{id}`. - -## Anti-patterns - -- Claiming Sent surfaces an `agentId` or per-carrier rollout state in v3 — it doesn't. -- Promising the customer they can self-serve an RCS Agent through the dashboard — the docs explicitly say to contact `support@sent.dm`. -- Restating Google's agent JSON schema in this file — link to Google's docs instead so this skill stays small and current. -- Using Google-side names (`displayName`, `verifiedDomains`) when talking to a Sent customer about their dashboard. Use Sent's terms (RCS Agent, Branded Sender, Rich Card, Carousel Card, Suggestion Chip). -- Treating a Carousel Card as unbounded — Sent's docs cap it at 10 Rich Cards. +Carrier approval state is an operational Sent/carrier process. Do not invent an API enum or per-carrier status endpoint. Store the evidence Sent provides, date it, and surface unknown values safely. diff --git a/plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md b/plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md index 4458c92..52aa8f7 100644 --- a/plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md +++ b/plugins/sent/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md @@ -1,136 +1,55 @@ -# RCS Fallback Patterns — Reference +# RCS routing and fallback patterns -Supporting reference for `rcs-agent-onboarding`. Covers how Sent expresses RCS-to-SMS fallback (the `channel` array on the send request), what the documented webhook events tell you, and where the boundary sits between Sent-verified behavior and Google RBM-side semantics. +## Automatic routing with fallback -External error semantics live at [Google's RBM error reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/rest) — this doc only covers Sent's wrapping. - -## How fallback is expressed (verified) - -Sent does **not** expose a separate `fallback_policy` field in v3. Fallback intent is expressed entirely by the ordered `channel` array on `POST /v3/messages`: +Omit `channel` or send: ```json { - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } + "to": ["+12025550100"], + "channel": ["sent"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -| Array | Behavior | -|---|---| -| `["rcs"]` | RCS-only. If RCS can't deliver, the message fails. No SMS attempt. | -| `["rcs", "sms"]` | Documented fallback pattern. SMS is the explicit fallback target. | -| `["sms", "whatsapp", "rcs"]` | Per the v3 docs, an array with multiple channels creates **one message per channel** — all dispatch. This is a multi-channel broadcast, not a waterfall. | -| `["sms"]` | SMS-only. Used during agent provisioning before RCS is live. | -| (omitted) | Sent picks the optimal channel automatically based on the recipient's `available_channels`. | - -Two things worth surfacing to a customer: - -1. **Multi-channel arrays are broadcast, not waterfall.** The v3 docs describe `["sms", "whatsapp", "rcs"]` as producing one message per channel that all dispatch. If you want a strict RCS-first-with-SMS-fallback waterfall, the documented shape is `["rcs", "sms"]`. Anything longer needs explicit confirmation with Sent. -2. **No `fallback_policy` field exists in v3.** Documentation, dashboards, or examples that reference one are inferring a v2 concept. Use the channel array. - -## When fallback fires (inferred — confirm before promising) - -Sent's docs verify that RCS "falls back to SMS automatically for non-RCS-capable recipients" and that `["rcs", "sms"]` makes that explicit. The docs do **not** enumerate every trigger condition (capability mismatch vs. carrier-pending vs. RBM outage). Treat the following as inferred and confirm with Sent if a customer needs exact semantics: - -- Recipient device not RCS-capable -- Recipient on a carrier where the RCS Agent is not yet rolled out -- RBM transient unreachability - -For day-to-day customer guidance, "if RCS can't deliver, SMS is attempted" is the documented promise. The why-it-fell-back detail surfaces in the message's failure description (see below). - -## Content trimming on fallback (external) - -The v3 docs don't specify what happens to rich content (Rich Cards, Carousel Cards, Suggestion Chips) when a message authored for RCS falls back to SMS. SMS has no equivalent for any of those components. - -The safe default is to **author SMS-side content explicitly** rather than rely on automatic trimming. The `template-builder-ui` skill covers the dual-authoring workflow. - -If a customer needs an exact answer about Sent's trimming behavior, escalate to `support@sent.dm` — it's not in v3 docs. +Sent selects the available route. This is the cross-channel fallback mode. -## Verified webhook events for message lifecycle - -Sent's quickstart docs verify this lifecycle. Sub-types follow `message.`: - -| Event | Meaning | -|---|---| -| `message.queued` | Send accepted, waiting to dispatch | -| `message.routed` | Assigned to a carrier/provider | -| `message.sent` | Dispatched to the carrier/RCS/WhatsApp provider | -| `message.delivered` | Confirmed delivery to device | -| `message.read` | Recipient opened. RCS and WhatsApp only. | -| `message.failed` | Delivery failed at any stage. `payload.message_status = FAILED`. Fetch `GET /v3/messages/{id}` for the reason. | - -Webhook payload shape (top-level): +## Pinned RCS ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "to": ["+12025550100"], + "channel": ["rcs"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -The `payload.channel` field is what tells you which channel actually delivered. To distinguish "RCS delivered" from "SMS fallback delivered" for the same logical send, inspect `payload.channel` on the `message.delivered` event. - -### Events that are NOT verified — do not assume they exist - -Earlier drafts of this skill referenced events like `message.channel_selected` and `message.fallback_triggered`. **Those names are not in the v3 docs.** The only verified `message.*` sub-types are `queued`, `routed`, `sent`, `delivered`, `read`, `failed`. - -If a customer's integration depends on a dedicated "fallback fired" event, reconstruct it from what's documented: +This requests RCS only and has no cross-channel fallback. Use it to isolate RCS launch or payload failures. -- For an `["rcs", "sms"]` send, observe `message.delivered` events and check `payload.channel` — `"sms"` on what was meant to be an RCS-first send is the fallback signal. -- For a failed RCS attempt that succeeded via SMS, the v3 docs don't promise an explicit pairing. Reconstruct by correlating `message.failed` (RCS) with a separate `message.delivered` (SMS) sharing the same logical send. -- If you genuinely need a dedicated fallback event, ask Sent — don't invent the name. +## Broadcast -### Send-time failure codes (verified) +Any array containing two or more explicit channel values is broadcast. Sent creates a separate message for each recipient/channel pair. This can duplicate content and charges. -On `message.failed`, fetch the message and read `description`. Verified codes that may appear: +Before a broadcast, calculate: -| Code | Meaning | -|---|---| -| `ERR_CONSENT_BLOCKED` | Recipient is opted out or on suppression list. No provider call. | -| `ERR_ROUTE_DENIED` | No active route could deliver to the requested channel/country. | -| `ERR_TEMPLATE_PARAMS_INVALID` | Required template variables missing or failed regex validation. | - -Per-carrier RBM rejection codes are external (Google) and not surfaced as a structured field in v3. - -## Testing fallback in lower environments - -Sent's v3 docs document **sandbox mode** as the testing affordance: add `"sandbox": true` to the request body and the API returns a realistic fake response without a provider call. Response includes `X-Sandbox: true` header. - -Sandbox mode is documented for `POST /v3/messages` and most other mutation endpoints. - -The v3 docs do **not** document a `force_fallback` flag, a test-recipient registry, or per-carrier launch-state overrides. Earlier drafts referenced these — treat as inferred / unverified. If a customer needs to exercise the SMS-fallback path specifically in a lower environment, the documented approach is: +```text +messages created = recipient count × explicit channel count +``` -1. Send with `sandbox: true` to validate request shape without side effects. -2. To exercise the real fallback path against the real provider chain, send with a small recipient list including known-non-RCS-capable numbers. -3. For pre-launch testing, send with `["sms"]` first to confirm SMS compliance and webhook plumbing, then introduce `["rcs", "sms"]` once RCS is approved. +Require explicit user intent and show the count/cost impact. -## What's NOT in v3 (gap notes) +## Observability -- A dedicated `message.fallback_triggered` webhook event. -- A `message.channel_selected` event. -- A `fallback_policy` field on the Sender Profile or on the send request. -- A `force_fallback` flag for non-prod testing. -- An MDR export schema documenting `attempted_channels` / `delivered_channel` fields. -- Per-carrier rollout-status fields. +The send response returns per-recipient message IDs and channels. Persist each record. Webhook message events use `field: "message"`, a message `sub_type`, and `payload.channel`. Determine what happened from the created message records and their activities—not from the ordering of the request array. -Anything above that appears in customer-facing guidance should be flagged as inferred and confirmed with Sent before relying on it. +## Test matrix -## Anti-patterns +| Test | Request | Expected evidence | +| --- | --- | --- | +| RCS path | `["rcs"]` | One RCS attempt per recipient, no SMS message ID | +| Automatic routing | omitted / `["sent"]` | One selected route per recipient according to availability | +| Broadcast | two explicit channels | Two message IDs per recipient | -- Inventing a `fallback_policy` field — it doesn't exist in v3; use the `channel` array. -- Inventing `message.fallback_triggered` or `message.channel_selected` webhook events — they're not in the documented lifecycle. -- Sending with `["rcs"]` and expecting SMS to back it up. RCS-only means RCS-or-fail. -- Treating a long channel array like `["rcs", "whatsapp", "sms"]` as a waterfall. Per v3 docs, multiple channels create one message per channel (broadcast). Use `["rcs", "sms"]` for the documented fallback shape. -- Assuming Sent auto-trims rich RCS content gracefully into SMS. Trimming behavior isn't in v3 docs — author SMS variants explicitly. -- Using `sandbox: true` in production traffic — sandbox is for tests, not real sends. +There is no documented `fallback_policy` or `force_fallback` field. Do not invent dedicated fallback webhook names. diff --git a/plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md b/plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md index 945576f..a4d9344 100644 --- a/plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md +++ b/plugins/sent/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md @@ -1,117 +1,42 @@ -# RCS Launch Evidence Packet — Reference +# RCS launch evidence packet -Supporting reference for `rcs-agent-onboarding`. Describes the evidence Sent needs from a customer before initiating the RCS handoff to Google + the carriers. Sent's docs say RCS setup is not self-service and must be requested through `support@sent.dm`; the packet is what makes that email actionable. +## Brand -The carrier-side launch review is external — authoritative source is [Google's RBM launch documentation](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). This doc only covers what Sent itself collects, and how that overlaps with the broader KYC/compliance form the customer already fills in. +- Legal and consumer-facing brand names +- Public website +- Square logo and brand color +- Privacy policy and terms URLs +- Support email, phone, or URL -## Why a packet +## Use case and consent -The dashboard handoff to `support@sent.dm` is the single biggest lever a customer has on launch latency. A complete packet means Sent's team can take it to the carriers without round-tripping the customer for missing material. A vague packet means the agent sits idle for weeks. +- Audience and target countries +- Transactional, authentication, marketing, support, or mixed intent +- Exact opt-in flow and proof +- Message frequency and estimated volume +- STOP/HELP handling where SMS can be selected by automatic routing -## Overlap with the existing compliance form (verified) +## Current message examples -The Sent dashboard already collects most of the brand-identity and use-case evidence as part of KYC and the compliance form. Reuse those answers — don't ask the customer to write everything from scratch. +Provide at least five representative text messages. For each, include zero-to-four suggestion chips and what each chip does. Do not include rich-card, carousel, or media-attachment requirements; those are not current Sent capabilities. -Fields the dashboard's compliance form already captures (per Sent's docs): +## Sender Profile -**Business identity** -- Legal business name -- Business registration number -- Business type / structure -- Industry category -- EIN / tax ID (US) -- Business address -- Business phone number -- Contact email +- v3 profile UUID +- Credential pattern: profile key or organization key plus `x-profile-id` +- Relevant numbers and markets +- SMS compliance state if automatic routing can select SMS -**Messaging / use-case** -- Use-case selection (Authentication, Notifications, Marketing, Customer Service, High Volume) -- Campaign description -- Sample messages per use case -- Opt-in mechanism (URL or description) -- Opt-out language +## Routing plan -For an RCS launch, these answers map directly into what the carriers want to see. If they're already complete and current, the RCS packet is mostly assembly, not authoring. +Choose one or more test modes: -## RCS-specific evidence (additive to compliance form) +- automatic routing: omitted `channel` or `["sent"]`; +- pinned RCS: `["rcs"]`; +- intentional broadcast: multiple explicit channels with expected message count and cost. -The bits below are required for RCS review but are **not** part of Sent's general compliance form — the customer has to supply them specifically for the RCS handoff. +Do not describe an explicit multi-channel array as fallback. -### 1. Brand authorization +## Handoff note -- Letter of authorization (LOA) signed by an officer of the brand confirming the customer is authorized to operate this RCS Agent -- For franchises / resellers: documentation of the licensing arrangement - -### 2. Sample message gallery for RCS - -For every Sent-side RCS component the agent will use, include at least one realistic sample showing it in use: - -- Plain text body -- Suggestion Chip — quick reply -- Suggestion Chip — open URL -- Suggestion Chip — dial number -- Rich Card (with media, title, description, chip) -- Carousel Card (up to 10 Rich Cards) -- Any attachment type the agent will send (image, video, file) - -Samples must use real brand assets (logo, color, copy voice). Placeholders are a common rejection reason. - -### 3. Brand assets - -- Square logo (RBM has minimum dimensions; check [Google's agent docs](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents)) -- Brand color (hex) -- Optional hero/banner image -- Public website URL (must match the brand the agent represents) - -### 4. SMS fallback plan - -A one-paragraph statement of what happens when the recipient isn't RCS-capable. Sent's documented fallback mechanism is the `channel` array on the send request — see `references/rcs-fallback-patterns.md` for the option set. If the customer needs US SMS as fallback, confirm 10DLC compliance is already in place (see `sms-10dlc-registration`). - -### 5. End-user support contact - -A phone number, email, or in-product support URL recipients can reach with questions. Google and carriers both probe this contact during review, so it has to actually answer. - -## Pre-handoff checklist - -Before emailing `support@sent.dm`, confirm: - -- [ ] Compliance form is complete and current in the Sent dashboard -- [ ] Use-case selection on the form matches the RCS Agent's intended use -- [ ] Sample messages on the form cover the RCS use case (not just SMS) -- [ ] Opt-in mechanism (URL or description) is filled in and accurate -- [ ] Opt-out language is documented -- [ ] LOA signed and dated within last 12 months -- [ ] At least one sample per RCS component the agent will use, with real brand assets -- [ ] Brand logo, color, website URL ready to attach -- [ ] SMS fallback plan written down, with channel-array shape (e.g. `["rcs", "sms"]`) -- [ ] If US SMS is the fallback, 10DLC registration already complete -- [ ] Support contact is live and answers within stated SLA -- [ ] Every URL that will appear in a sample message resolves on the brand's public domain - -## Per-carrier nuance (external) - -Each carrier reviews independently after Google approves. The specifics shift over time and are not documented in Sent's v3 docs — treat carrier-specific copy requirements, opt-in language minimums, and throttling windows as external. Always check [Google's per-carrier guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch) for the current rules. - -Patterns that broadly hold: -- Marketing use cases get scrutinized harder than transactional / OTP. -- Carriers may require additional opt-in disclosure language beyond Google's. -- Even after `ENABLED`, carriers may stage rollout by recipient volume in the first weeks. - -## Common rejection reasons (Google-side, external) - -These come from Google and the carriers, not Sent. Listed here as a checklist, not a substitute for the [official guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). - -| Reason | Remediation | -|---|---| -| Brand assets in sample don't match the agent identity | Re-render samples with the actual logo, color, brand name | -| Use case description and declared use case disagree | Either change the declared use case or rewrite the description; resubmit | -| Opt-in disclosure missing channel name | Update the opt-in surface to name RCS (or "text messages including RCS") and re-screenshot | -| Sample exercises a capability not declared on the agent | Either declare the capability or remove the sample | -| Support contact unreachable | Wire up the contact and confirm before resubmitting | -| Same packet recycled across multiple agents with different brands | Each agent needs its own packet — Google catches this | - -## After submission - -- Sent does not expose a per-carrier rollout-status endpoint in v3. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and the webhook events listed in `references/rcs-fallback-patterns.md`. -- A carrier coming back with changes requested will produce a short reason — feed that back to the customer and update the relevant packet artifact before resubmitting. -- Some carriers stay pending for weeks even after Google approves; that's an external timeline, not a Sent issue. +Ask Sent to initiate RCS setup and carrier review for the named profile. Attach brand/consent evidence, message examples, target markets, support details, routing plan, and requested launch window. Avoid claims about approval timing that Sent or carriers have not confirmed. diff --git a/plugins/sent/skills/sender-profile-architect/SKILL.md b/plugins/sent/skills/sender-profile-architect/SKILL.md index b0b2fa3..1310177 100644 --- a/plugins/sent/skills/sender-profile-architect/SKILL.md +++ b/plugins/sent/skills/sender-profile-architect/SKILL.md @@ -1,182 +1,121 @@ --- name: sender-profile-architect -description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, or multi-channel messaging systems, including profile boundaries, account-level API key blast radius, webhooks, compliance inheritance, and channel readiness. Use when a user says sender profile, x-sender-id, profile setup, multi-tenant messaging, brand isolation, department sender, webhook routing, tenant offboarding, or asks how to model SMS, WhatsApp, and RCS senders in Sent. +description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, and multi-channel systems. Use for API-key scoping, x-profile-id, isolation, inheritance, sharing, billing, WABA, 10DLC campaigns, webhooks, or tenant offboarding. --- - +A Sender Profile is the operational boundary for tenant identity, channel configuration, inherited resources, billing, and credentials. Use this skill before provisioning when a poor boundary would mix brands, compliance posture, rate-limit impact, or webhook ownership. -# Sender profile architect +## Recommended tenancy model -## Overview +When tenants require isolation, recommend one Sent organization with one Sender Profile per tenant. A shared profile is appropriate only when the tenants genuinely share one brand, sender resources, compliance posture, billing/rate-limit expectations, and operational blast radius. -Use this skill to decide how a customer should map brands, tenants, departments, and channels onto Sent Sender Profiles. A Sender Profile is the durable boundary for sender identity and channel configuration. The Sent dashboard shows each profile with display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The v3 API exposes profile creation, listing, retrieval, update, deletion, and completion. +Do not recommend pooled-by-default architecture. Make the isolation decision explicit using [references/multi-tenancy-patterns.md](references/multi-tenancy-patterns.md). -Good profile architecture prevents three recurring failures: messages sent from the wrong brand, compliance resources shared across incompatible use cases, and webhook/event data that cannot be routed back to the correct tenant. +## Authentication patterns -## When to use +Sent v3 supports both: -Use this skill when the user asks how to create Sender Profiles, split one customer into multiple senders, model a marketplace or ISV, isolate brands, route webhooks, reason about account-level API key blast radius, complete profile setup, or safely offboard a tenant. Use it whenever the request mentions `x-sender-id`, Sender Profile, profile completion, multi-tenant messaging, brand hierarchy, SMS/WhatsApp/RCS sender setup, or webhook routing. +| Pattern | Headers | Blast radius | +| --- | --- | --- | +| Profile-specific API key | `x-api-key` | Profile-scoped credentials and rate-limit context. Do not add `x-profile-id`. | +| Organization API key acting for a child | `x-api-key` plus `x-profile-id: ` | Organization credential can reach permitted child profiles; rate limits remain in the organization pool. | -Do not use this skill to decide 10DLC use cases in detail, write WhatsApp template copy, onboard RCS approval, or analyze delivery failures. Hand those to the related skills once the profile boundary is clear. +Only organization keys may send `x-profile-id`. A profile key that sends it receives `403`. A profile outside the organization returns `404`. `X-Profile-Id` can be echoed in scoped responses. -## Profile boundary principle +`x-sender-id` is legacy v1/v2 terminology only. Do not use it for v3 authentication or routing. -Create a separate Sender Profile when the sender identity, compliance evidence, webhook routing, operational ownership, or channel readiness must be isolated. Reuse a profile when the same legal/brand identity sends the same class of traffic and should share compliance posture and operational controls. +Choose profile keys when tenant-level credential isolation and revocation are primary. Choose organization-key scoping for centrally controlled integrations that can protect a broader credential and deliberately accept a shared organization rate-limit pool. -| Split signal | Create separate profiles when | Reuse a profile when | -|---|---|---| -| Brand identity | The recipient sees different brand names or support contacts. | The recipient sees one brand across all messages. | -| Compliance | 10DLC brand/campaign, opt-in source, or use case differs materially. | Compliance evidence and use case are the same. | -| Channel configuration | SMS, WhatsApp, or RCS resources belong to different brands or regions. | Channels represent one sender identity. | -| Webhook routing | Events must land in different tenant queues or data stores. | One team owns all events and reconciliation. | -| Lifecycle | One sender may be paused, restricted, or offboarded independently. | Senders always launch, pause, and retire together. | +## Profile creation model -## Process +Create with `POST /v3/profiles`. `name` is required. Current optional areas include: -### 1. Draw the recipient-visible sender model +- identity: `icon`, `description`, `short_name`; +- sharing: `allow_contact_sharing`, `allow_template_sharing`; +- inheritance: `inherit_contacts`, `inherit_templates`, `inherit_tcr_brand`, `inherit_tcr_campaign`; +- billing: `billing_model`, `billing_contact`, and ephemeral `payment_details`; +- dedicated WABA credentials: `whatsapp_business_account` with `waba_id`, optional `phone_number_id`, and `access_token`; +- a dedicated brand: `brand.contact`, `brand.business`, and `brand.compliance`. -Start with what the recipient sees, not with internal account hierarchy. Ask: “Would the recipient reasonably think these messages came from the same sender?” If the answer is no, use separate profiles. +Do not add a separate brand endpoint. A dedicated brand is created with the profile; campaigns are managed under `/v3/profiles/{profileId}/campaigns`. -**Example.** A healthcare ISV serves three clinic chains. Each chain has its own patient-facing brand, privacy policy, and support phone. Create one profile per clinic chain. Do not put all clinics behind a single ISV profile just because the same platform sends the messages. +### Inheritance rules -### 2. Map each profile to channel readiness +- `inherit_tcr_brand: true` means the profile uses the organization's brand and cannot submit its own `brand` object. +- `inherit_tcr_campaign: true` makes inherited campaigns read-only for that profile. +- An inherited brand with `inherit_tcr_campaign: false` is a supported dedicated-campaign pattern. +- Sharing flags expose a profile's contacts/templates; inheritance flags consume organization resources. Treat those directions separately. -For each proposed profile, list SMS, WhatsApp, and RCS readiness separately. Sent’s channel setup guidance covers production setup for all three channels and recommends using the same phone number across SMS, WhatsApp, and RCS where possible. That recommendation does not override compliance or brand isolation. +### Billing and number references -| Channel | Profile-level questions | Follow-up skill | -|---|---|---| -| SMS | Is US A2P involved? Which brand/campaign and opt-in evidence apply? | `sms-10dlc-registration` | -| WhatsApp | Which WABA/phone number identity maps to this brand? Are templates approved? | `waba-embedded-signup`, `waba-template-author` | -| RCS | Has Sent initiated setup and carrier approval for this profile? Is SMS fallback ready? | `rcs-agent-onboarding` | +`billing_model` currently supports `profile`, `organization`, and `profile_and_organization`. A profile or fallback billing model requires `billing_contact` when none exists. Card fields are forwarded to the payment processor and must not be logged or persisted. -### 3. Create or update the Sent profile +Profile update can manage `sending_phone_number_profile_id`, `sending_whatsapp_number_profile_id`, `sending_phone_number`, `whatsapp_phone_number`, and `allow_number_change_during_onboarding`. Model reference IDs and direct numbers separately, and prevent cycles when one profile references another. -Use Sent’s profile API where API access is appropriate, or the dashboard when the user is operating manually. The verified v3 API includes: +## WABA choices -| Operation | Endpoint | Use | -|---|---|---| -| Create profile | `POST /v3/profiles` | Create a sender boundary for a brand, department, tenant, or use case. | -| List profiles | `GET /v3/profiles` | Audit existing profile boundaries before creating duplicates. | -| Retrieve profile | `GET /v3/profiles/{profileId}` | Inspect detailed profile configuration. | -| Update profile | `PATCH /v3/profiles/{profileId}` | Change profile configuration/settings. | -| Delete profile | `DELETE /v3/profiles/{profileId}` | Soft-delete a profile after traffic, webhooks, and credentials are drained. | -| Complete setup | `POST /v3/profiles/{profileId}/complete` | Validate prerequisites and start the profile completion workflow. | +There are three distinct paths: -Use idempotency keys on create/update/complete calls when the integration might retry. The OpenAPI exposes an optional `Idempotency-Key` header for those operations. +1. Organization Embedded Signup in the dashboard. +2. Child profile inheritance by omitting `whatsapp_business_account` after the organization has a WABA. +3. Dedicated profile WABA using `waba_id` and `access_token`; `phone_number_id` is optional. -### 4. Attach compliance and channel prerequisites before completion +There is no public endpoint that starts organization Embedded Signup. Direct credentials on `POST /v3/profiles` are not an “Embedded Signup endpoint.” Use `waba-embedded-signup` for the operational flow. -The profile completion endpoint validates prerequisites such as profile data, brand, campaigns, and channel connections. For US A2P SMS, create or attach Sent brand and campaign resources before completing the profile. The verified brand/campaign endpoints are `/v3/brands` and `/v3/brands/{brandId}/campaigns`. +## 10DLC and campaigns -Do not invent field names such as `tcr_brand_id` or `waba_phone_id` unless the actual API response includes them. Store Sent IDs returned by the API and any returned provider identifiers separately, with clear names. +Use a profile `brand` object for a dedicated brand. Manage campaigns at: -**Example data model.** +- `GET|POST /v3/profiles/{profileId}/campaigns` +- `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` -```text -sender_profiles -- sent_profile_id -- Sent Profile.id (UUID) -- x_sender_id -- per-profile, v2 legacy; useful for dashboard cross-ref -- name -- Sent Profile.name -- short_name -- Sent Profile.short_name -- description -- Sent Profile.description -- sent_status -- mirrors Sent Profile.status: incomplete|pending_review|approved|rejected -- status_app_level -- finer-grained internal lifecycle (do not conflate with sent_status) -- sms_ready_app_level -- whatsapp_ready_app_level -- rcs_ready_app_level - -sender_profile_resources -- sent_profile_id -- channel -- sent_resource_id -- e.g., /v3/brands/{brandId}, /v3/brands/{brandId}/campaigns/{id} -- provider_resource_type -- e.g., tcr_brand, tcr_campaign, waba, waba_phone, rbm_agent -- provider_resource_id -- status_last_seen_at -``` - -### 5. Design webhook routing around Sent event evidence - -Sent’s v3 webhook API supports creating/listing webhooks, retrieving event types, viewing webhook events, testing a webhook, toggling status, and rotating signing secrets. Use those endpoints to verify configuration and delivery before blaming channel infrastructure. - -Route inbound events by stable identifiers present in the Sent payload. If the exact event payload fields are not documented for the customer’s account, log full events in a secure staging environment and derive the routing map from observed Sent fields rather than assumed provider keys. - -**Example.** If a marketplace needs tenant-specific queues, route first by Sent profile or sender identifier if present in the event. Fall back to a mapping table from Sent message ID to tenant/profile created at send time. Avoid making provider IDs the only routing key. - -### 6. Model profile lifecycle as an application state machine - -Sent exposes profile APIs and completion behavior, but your application may need richer internal states. Label them as application states so future agents do not mistake them for Sent enums. - -| Application state | Meaning | Exit condition | -|---|---|---| -| `draft` | Profile data is being collected. | Required identity and owner fields are present. | -| `compliance_pending` | Brand/campaign/channel evidence is being prepared. | Required compliance resources exist or have been submitted. | -| `completion_started` | `/v3/profiles/{profileId}/complete` returned accepted/started behavior. | Webhook/callback or follow-up status indicates completion result. | -| `active` | Profile is approved for intended channels. | Traffic is allowed and test sends pass. | -| `restricted` | One or more channels is blocked, paused, or missing approval. | Root cause resolved and profile retested. | -| `retiring` | Sends are drained and webhooks/credentials are being removed. | No active sends, subscriptions, or credentials remain. | - -### 7. Plan tenant offboarding before the first send +Use `sms-10dlc-registration` for the payload and policy layer. -Offboarding is easiest when profile boundaries are clean. To retire a profile, stop new sends, drain in-flight messages, export relevant message/activity evidence, disable or reroute webhooks, revoke or rotate credentials, delete/soft-delete the profile when safe, and retain compliance records according to the customer’s policy. +## Completion and status handling -## Common rationalizations to avoid +Complete a profile with `POST /v3/profiles/{profileId}/complete` and a required `webHookUrl`: -Do not use one profile for every tenant just because it is easy. Over-splitting creates unnecessary compliance and operational work. - -Do not use one shared profile for distinct recipient-visible brands. Under-splitting creates wrong-sender and compliance-contamination failures. - -Do not treat internal tenant ID as a substitute for Sender Profile ID. The application can map tenant ID to profile ID, but outbound sends and webhook reconciliation need Sent identifiers. - -Do not hardcode provider identifiers as routing keys before verifying Sent webhook payloads. Sent’s event shape is the integration contract. - -Do not rotate webhook secrets casually. Secret rotation immediately invalidates the old secret, so coordinate with the receiving endpoint. - -## Verification checklist - -- [ ] Each proposed profile has a recipient-visible rationale. -- [ ] SMS, WhatsApp, and RCS readiness are tracked separately per profile. -- [ ] US A2P SMS profiles have brand/campaign work routed to compliance before completion. -- [ ] The implementation stores Sent profile IDs and any provider IDs as separate fields. -- [ ] Profile creation/update/complete calls use idempotency keys where retries are possible. -- [ ] Webhook routing is based on Sent event fields or a send-time Sent message ID mapping. -- [ ] Application lifecycle states are not presented as Sent API enum values. -- [ ] Offboarding drains sends, webhooks, credentials, and retained evidence. - -## Related skills +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -Use `sms-10dlc-registration` when a profile needs US A2P SMS brand/campaign registration, opt-in review, or 10DLC troubleshooting. +Status is surface-specific: -Use `waba-embedded-signup` when the architecture includes WhatsApp WABA/phone-number connection or Embedded Signup. +- Create response currently demonstrates lowercase `incomplete`. +- Completion `202` means processing started and does not contain a final status. +- Completion `200` currently demonstrates lowercase `completed` for an already-complete profile. +- Completion callbacks can report `COMPLETED`, `SUBMITTED`, or `failed`. +- REST guides and OpenAPI publish different profile status sets. -Use `rcs-agent-onboarding` when the profile needs RCS approval, launch evidence, or fallback design. +Do not assert a closed REST enum. Preserve unknown strings and record the endpoint/callback surface that produced them. -Use `template-builder-ui` when the architecture decision depends on reusable cross-channel template lifecycle. +## Webhook attribution -Use `messaging-performance-analyzer` after launch when webhook, delivery, or activity evidence shows a performance issue. +Sent events do not contain your application tenant ID. Before sending, persist the returned `message_id` with the tenant and profile. Route outbound status events through that mapping. For inbound messages, map the receiving number/profile resource to the tenant. -Use the `sent` skill for shared Sent terminology and routing. +```text +message_id -> tenant_id, profile_id, logical_send_id, channel +receiving_number -> tenant_id, profile_id +``` -## Suggested bundled references and scripts +Do not infer tenant ownership from `account_id` alone. Multiple tenant profiles can belong to one organization. -| File | Type | Purpose | -|---|---|---| -| `references/multi-tenancy-patterns.md` | Architecture reference | Keep detailed routing, rate-limit, idempotency, and offboarding patterns outside the skill body. | -| `references/sender-profile-data-model.md` | Schema reference | Provide recommended application tables and mapping fields for Sent profile integrations. | -| `references/profile-boundary-examples.md` | Worked examples | Show ISV, marketplace, multi-brand enterprise, and department-level profile splits. | +## Design checklist -## Unverified claims to confirm or remove +- [ ] Tenant/brand isolation decision is explicit. +- [ ] Credential pattern and rate-limit/blast radius are documented. +- [ ] Sharing and inheritance directions are intentional. +- [ ] Billing ownership is named. +- [ ] Number references cannot form cycles. +- [ ] WABA path is organization signup, inheritance, or dedicated credentials—not an invented hybrid. +- [ ] Dedicated brand/campaign paths are profile-based. +- [ ] `message_id` and inbound-number mappings support webhook attribution. +- [ ] Unknown profile statuses are tolerated. +- [ ] Tenant offboarding revokes credentials, disables sends, detaches resources safely, and retains audit evidence. -- API keys are issued per **customer account** (not per profile); `x-sender-id` is per-profile and visible in the dashboard but is v2 legacy for routing — v3 uses `x-api-key` alone. Design key blast-radius around the account, not the profile. -- Sent's profile `status` enum is `incomplete | pending_review | approved | rejected`. If your application tracks finer-grained internal lifecycle states (e.g. `partially_active`, `restricted`, `restoring`), they should not be conflated with the Sent profile `status` field — label them as application states. -- Provider-specific routing keys for WhatsApp/RCS/SMS (e.g., `phone_number_id`, `agentId`, TCR campaign ID) should not be required as primary routing keys unless observed in Sent event payloads or docs. The verified v3 webhook payload exposes `account_id`, `message_id`, `channel`, `inbound_number`, `outbound_number`, `template_id` — route on those plus a `message_id` → profile map persisted at send time. +See [references/sender-profile-data-model.md](references/sender-profile-data-model.md) and [references/profile-boundary-examples.md](references/profile-boundary-examples.md) for implementation patterns. diff --git a/plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md b/plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md index bc35e13..a00be20 100644 --- a/plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md +++ b/plugins/sent/skills/sender-profile-architect/references/multi-tenancy-patterns.md @@ -1,127 +1,66 @@ -# Multi-Tenancy Patterns for Messaging Apps on Sent — Reference +# Multi-tenancy patterns -Supporting reference for `sender-profile-architect`. Patterns that are *specific to messaging workloads* on Sent — high write volume, webhook fan-in across SMS/WhatsApp/RCS, and the compliance constraints carriers, Meta, and Google impose. Generic multi-tenant SaaS theory is covered exhaustively elsewhere; this doc only captures what changes when SMS, WhatsApp, and RCS run through Sent. +## Preferred: one profile per isolated tenant -## What a Sender Profile owns +Use one organization and one Sender Profile per tenant when tenants have distinct brands, consent evidence, 10DLC campaigns, WABAs, phone numbers, billing, credentials, or incident blast radius. -A Sender Profile is *one tenant's sending identity* across the channels that profile uses. It carries `name`, `description`, `short_name`, `status` (`incomplete | pending_review | approved | rejected`), and a `settings` block of `{default_channel, webhook_url, timezone, language}`. Each channel attaches separately: +Benefits: -- **SMS** — TCR Brand (`/v3/brands`) + at least one Campaign (`/v3/brands/{brandId}/campaigns`), plus one or more phone numbers / short codes. -- **WhatsApp** — Meta WABA + WABA phone numbers (configured via the Sent dashboard / Channels page). -- **RCS** — Google RBM agent (not self-service; via Sent support). +- profile-specific key issuance and revocation; +- explicit organization-key scoping through `x-profile-id`; +- separate inheritance and sharing choices; +- clean tenant offboarding; +- reliable number/profile and message/profile attribution. -A tenant may have multiple profiles (one per brand, region, or use case). Auth is a single account-level `x-api-key`; that key can operate on any profile the account owns. +Organization-key scoping does not create a separate rate-limit pool: the organization's pool remains shared. -## Webhook Routing (the hot path) +## Shared profile -Sent fans channel events into a unified payload shape: +Use a shared profile only if all participants genuinely operate as one sender identity with one brand, resource set, compliance posture, billing/rate-limit expectations, and incident boundary. Lower object count is not sufficient justification. -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} -``` - -Top-level fields: `field`, `sub_type`, `timestamp`, `payload`. `sub_type` follows `.` (e.g., `message.delivered`, `message.failed`, `message.read`). - -Routing back to a Sender Profile uses what your application persisted at send time, joined on stable IDs in the payload: - -| Channel | Verified payload fields | Used to find | -|---|---|---| -| All | `payload.message_id` | the profile that owns this outbound message | -| All | `payload.account_id` | the customer account | -| All | `payload.channel` + `payload.outbound_number` | the configured sender | -| All | `payload.template_id` | the template / its owning profile | - -Narrow webhook subscriptions with `event_filters`: - -```json -"event_filters": { "message": ["delivered", "failed"] } -``` - -Shape: `{: [, ...]}`. Combine with `event_types: ["message"]` to subscribe to the `message` parent and only fire on the listed sub-types. - -ACK fast (≤ webhook `timeout_seconds`, default 30s, max 120s; Sent retries up to `retry_count`, default 3, max 5). Synchronous business logic in the webhook handler kills throughput because three platforms upstream all retry on slow / 5xx responses. - -Two failure modes to design out: - -- **Cold routing key.** A webhook arrives for an `outbound_number` or `template_id` you haven't mapped (the tenant added a number out-of-band, or a template was created in another environment). Log, return 200, alert ops — don't drop the event. -- **Slow routing-key lookup.** Cache the `message_id` → profile mapping aggressively, but back it with durable storage so cold pods resolve correctly. +A shared profile makes tenant credential isolation and webhook attribution application responsibilities. Document that tradeoff. -## Per-Channel Rate-Limit Accounting +## Hybrid inheritance -You account for limits at four layers. Track per-channel; bill at the profile. +Common supported patterns include: -| Source | Limit | Where it comes from | -|---|---|---| -| **Sent — standard endpoints** | 200 req/min, burst 50 | Sent API gateway | -| **Sent — sensitive endpoints** | 10 req/min, burst 5 (e.g., `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/users`, `POST /v3/profiles/{id}/complete`) | Sent API gateway | -| **Sent — message sending tier** | Starter 60/min · Growth 300/min · Enterprise custom | Sent plan tier | -| **Sent — webhook test** | 60/min | Sent API gateway | -| **SMS — TCR campaign TPS** | Per-campaign throughput, assigned after vetting | TCR + carrier reconciliation | -| **WhatsApp — phone-number tier** | 1K / 10K / 100K / unlimited business-initiated conversations per 24h, plus Cloud API CPS | Meta — readable from the phone-number record | -| **RCS — agent QPS** | Google RBM | Google | -| **Your per-profile quota** | Whatever you actually sell | Your billing layer | +| Brand | Campaign | Flags | +| --- | --- | --- | +| Organization brand | Organization campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Organization brand | Dedicated profile campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | +| Dedicated profile brand | Dedicated profile campaign | both false, with `brand` at profile creation | -Rate-limit responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (on 429) `Retry-After`. The error code on 429 is `BUSINESS_002`. +Do not set `brand` while `inherit_tcr_brand` is true. Inherited campaigns are read-only. -Bill against the Sender Profile, not the customer account — an account with three brands gets three meters. +## Credentials -## Idempotency +- A profile key uses only `x-api-key`. +- An organization key may add `x-profile-id` for a child. +- A profile key with `x-profile-id` receives `403`. +- `x-sender-id` is legacy v1/v2 terminology. -Header: `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Cached for **24 hours**, scoped **per customer account**. +Store credentials in a secret manager. Never expose organization keys to tenant-controlled runtimes. -Concurrent requests with the same key → second returns `409 CONFLICT_001`. Replays carry `Idempotent-Replayed: true` and `X-Original-Request-Id: ` headers. +## Webhook fan-out -**Same key + different payload → returns the cached response for the *first* payload, not a new one.** Use a unique key per distinct operation. A safe pattern: hash `(profile_id, operation, client_intent_id)` into the key so a re-send of the same business intent is idempotent but a different intent on the same profile is not. +Outbound events carry `payload.message_id`, not your tenant ID. Persist: -Supported on all profile mutations: -- `POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}` -- `POST /v3/profiles/{id}/complete` - -And on every other mutation endpoint listed in the snapshot (`/v3/messages`, `/v3/contacts`, `/v3/templates`, `/v3/brands`, `/v3/brands/{id}/campaigns`, `/v3/webhooks`, `/v3/users`). - -Sandbox mode (`"sandbox": true` in the body) stacks with idempotency — validates the request, returns a realistic fake response, and caches it for 24 hours. Useful for CI per-tenant smoke tests. - -## Outbound Message Idempotency - -Outbound message sends should also be idempotent on the tuple `(profile_id, channel, client_message_id)` at the application layer. Persist the intent to send *before* the upstream `POST /v3/messages` — if the call succeeds but your write fails, a retry would otherwise duplicate. Pair this with a stable `Idempotency-Key` on the Sent request itself so Sent collapses the duplicate even if your row write reaches Sent first. - -## State Reconciliation (per channel) - -The Profile resource exposes a coarse `status` (`incomplete | pending_review | approved | rejected`). Per-channel readiness (TCR vetting score, WhatsApp messaging tier, RBM launch state) is not in this snapshot — re-fetch from the dashboard or upstream APIs on a schedule: - -- **TCR / SMS** — campaign vetting score updates and carrier-level filtering changes don't always fire webhooks. Reconcile daily for healthy campaigns, hourly when state was recently changing. -- **WhatsApp** — phone-number quality rating and messaging tier change without webhooks. Re-fetch every few hours; alert on transitions. -- **RCS** — agent launch state and carrier rollout status update silently. Re-fetch daily for launched agents and more often during initial verification. - -Track when each channel was last reconciled so dashboards can show how stale each profile is. Don't conflate this internal staleness with Sent's `status` field. - -## Channel-Specific Anti-Patterns +```text +message_id -> tenant_id, profile_id, channel, logical_send_id +``` -- **WhatsApp** — Sharing one Meta System User token across multiple Sender Profiles. Token revocation now disables every profile. -- **SMS** — Reusing a TCR campaign across tenants. The campaign vetting score follows whoever the brand says it is — share at your peril. -- **RCS** — Hardcoding the RBM agent into the application instead of attaching it to a Sender Profile. Multi-region or multi-brand tenants will need multiple agents and the code path forks. -- **All** — Synchronous webhook processing. Throughput dies and the platforms retry aggressively. -- **All** — Claiming "data residency" by application-level filtering when the legal commitment is storage-level isolation. +Inbound events carry destination number/channel information. Persist: -## Tenant Offboarding +```text +channel + destination_number -> tenant_id, profile_id +``` -When a tenant churns, run the per-channel teardown — not just a state flag: +Verify signatures before lookup, deduplicate events, and route unknown mappings to a quarantine queue. Never guess the tenant from organization `account_id`. -- **SMS** — deactivate the TCR campaign(s), release the phone number(s) per Sent's release flow. -- **WhatsApp** — unsubscribe your app from the WABA, revoke the System User token. -- **RCS** — unlaunch / suspend the RBM agent (via Sent support). +## Offboarding -Then `DELETE /v3/profiles/{id}` to soft-delete the profile (use an `Idempotency-Key`). Disable or delete webhook subscriptions that fan into this profile. Schedule message-content deletion per your retention policy. +1. Block new application sends. +2. Revoke profile keys; rotate organization credentials if exposure is possible. +3. Disable or reroute webhooks and number references. +4. Preserve message/profile mappings for retention and disputes. +5. Delete the profile only after resource ownership and compliance retention are resolved. diff --git a/plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md b/plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md index c6d8ee0..74efd92 100644 --- a/plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md +++ b/plugins/sent/skills/sender-profile-architect/references/profile-boundary-examples.md @@ -1,111 +1,50 @@ -# Profile Boundary Examples — Reference +# Profile boundary examples -Supporting reference for `sender-profile-architect`. Worked examples for "where should the Sender Profile boundary go?" — the question that determines blast radius, billing granularity, and onboarding pain. Each example covers when the boundary makes sense, when it doesn't, ops considerations, and how the choice ripples into 10DLC registration and WABA setup. +## Marketplace with fifty merchants -The default rule of thumb: **one Sender Profile per legal entity per distinct sending identity**. The examples below are when to break that rule. +Use one organization and one Sender Profile per merchant. Give independently operated merchant runtimes profile keys; keep an organization key only in the trusted control plane. Each merchant can inherit organization templates while owning a dedicated campaign under the inherited organization brand if policy permits. -## What's confirmed in v3 (read before using the examples) +## One enterprise brand with regional teams -- **Auth.** A single account-level `x-api-key` operates on every profile the account owns. There is no per-profile API key in v3. -- **`x-sender-id`.** Visible per-profile in the dashboard (Profiles page maps to `/v3/profiles` endpoints), but is v2 legacy for routing; v3 needs only `x-api-key`. Treat it as a per-profile identifier you can read, not as the auth header. -- **Profile `status`.** The Sent-side enum is `incomplete | pending_review | approved | rejected`. Anything finer-grained ("partially_active", "restricted", "restoring") is an application-level label. -- **Org-level user roles.** Owner / Admin / Billing / Developer. Owner is the only role with billing-ownership transfer rights; the Profile resource's per-call `role` field surfaces `admin | billing | developer` (not Owner) for the authenticated user's role in that profile. -- **10DLC.** Brand and Campaign are first-class Sent resources at `/v3/brands` and `/v3/brands/{brandId}/campaigns` — register them before completing the SMS portion of a profile. +A shared profile may be defensible when every team uses the same legal/consumer brand, consent posture, WABA/numbers, billing, and operations. If a region requires a distinct number, campaign, credential, or incident boundary, split it into its own profile. -## 1. One profile per legal entity (single-brand SaaS) +## Dedicated WABA tenant -A small SaaS with one corporate identity sending password resets, billing reminders, and product nudges from a single sender name. +Create the profile with: -**Picks this when:** all messages legitimately come from the same business, same brand voice, same vetting story. +```json +{ + "name": "Acme Support", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -**Doesn't pick this when:** the company runs multiple consumer brands under one corporate parent (those want separate profiles even if the same lawyer signs both TCR forms). +`phone_number_id` is optional. The access token must be injected from a secure runtime, never included in logs, fixtures, support tickets, or responses. -**Ops:** -- One webhook, one secret to rotate, one set of API keys. Lowest operational overhead. -- Single TCR Brand registration; multiple TCR Campaigns under it for distinct use cases (transactional vs marketing). -- Single WABA, one System User token. WABA quality rating reflects all sending behavior. -- Billing is a single line item — easy for finance, hard to attribute internally if multiple product teams share the profile. +## Organization WABA inheritance -**10DLC:** one Brand, multiple Campaigns. If transactional and marketing share one Campaign, carrier filtering will be harsher than necessary — split them. +After organization Embedded Signup is complete, omit `whatsapp_business_account` on the child profile. Omitting it without an organization WABA returns `422`. This is inheritance, not an API-started Embedded Signup flow. -**WABA:** one WABA, one phone number to start; tier upgrades benefit everything sent through this profile. +## Dedicated 10DLC brand -## 2. One profile per channel (WhatsApp-only vs SMS-fallback chains) +Set `inherit_tcr_brand: false` and include `brand` with `POST /v3/profiles`. Create campaigns through `/v3/profiles/{profileId}/campaigns`. Do not create a free-standing brand resource. -A tenant explicitly wants channel isolation — e.g. a WhatsApp-only consumer brand whose ops team should never accidentally send SMS, or a region where RCS is the primary channel with SMS strictly as fallback. +## Shared SMS number reference -**Picks this when:** legal, compliance, or product policy requires that channel-by-channel sending be controllable independently and visible independently in billing. +Use `sending_phone_number_profile_id` when a profile intentionally reuses another profile's SMS configuration. Record the source profile and prevent circular references. A direct `sending_phone_number` is a different mode and should not be conflated with the profile reference. -**Doesn't pick this when:** the channels are genuinely interchangeable for the same user journey. Sent's whole point is unifying sending — splitting profiles by channel often duplicates work without adding isolation. +## Webhook routing -**Ops:** -- Multiple webhook subscriptions, multiple secrets. Secret-rotation blast radius is smaller per channel, larger in aggregate. -- The "fallback" pattern (try WhatsApp, fall back to SMS) becomes an application-level orchestration across two profiles instead of one. Manageable, but inbound replies on the SMS profile won't carry the original WhatsApp `wamid` context — your app has to stitch threads. -- Cost tracking is cleanly per-channel. +When the send response returns message IDs, write all of them before treating the operation as accepted: -**10DLC:** the SMS-only profile carries the TCR registration; the WhatsApp-only profile has none. Don't register TCR for a profile that won't send SMS. +```text +message A -> tenant 42, profile P42, channel sms +message B -> tenant 42, profile P42, channel whatsapp +``` -**WABA:** the WhatsApp profile carries the WABA. Quality scoring is isolated — a bad SMS campaign won't pull WhatsApp tier down. - -## 3. Per-department profiles (sales, support, marketing) on the same brand - -A mid-sized company wants Sales, Support, and Marketing to send under the same overall brand but with different sender names, different vetting stories, and different billing meters. - -**Picks this when:** internal billing attribution matters (Marketing's budget is separate from Support's), or each department's send volume / patterns are different enough that mixing them would hurt vetting (Marketing's bulk sends would tank Support's quality rating). - -**Doesn't pick this when:** the departments truly send identical-looking traffic under one external brand. Splitting buys complexity without changing what carriers see. - -**Ops:** -- Three webhooks (or one webhook routing on `payload` fields), three sets of templates — but one shared account-level `x-api-key`. Reusable copy (e.g. WhatsApp templates) has to be authored per profile or copied between them. -- Quota / rate-limit accounting is per profile — Marketing can be throttled without affecting Support. -- Suspension blast radius is per profile — a Meta quality drop on Marketing doesn't pause Support. - -**10DLC:** typically one TCR Brand (same legal entity) but separate Campaigns per department's use case. Some tenants register multiple Brands if Marketing operates as a distinct legal entity. - -**WABA:** can be one WABA with multiple phone numbers split across profiles, or one WABA per profile. One-WABA-multiple-profiles complicates Sent-side modeling (each profile attaches to the same WABA); separate WABAs are cleaner if the departments genuinely want isolation. - -## 4. Per-tenant profile in a B2B2C platform (one Sent customer hosting many merchants) - -A platform — appointment-booking SaaS, e-commerce host, marketing platform — has one Sent contract but serves hundreds of downstream merchants who each need their own sender identity. - -**Picks this when:** each merchant is a distinct end-business that needs to appear as themselves to recipients, comply with TCR / Meta independently, and have their own billing meter. - -**Doesn't pick this when:** all merchants legitimately send "from" the platform brand. Then one profile is correct and merchant attribution is an internal concern. - -**Ops:** -- Profile provisioning is part of the merchant onboarding flow — this is where the WABA Embedded Signup (`waba-embedded-signup`) and 10DLC registration (`sms-10dlc-registration`) skills get invoked hundreds of times. -- Webhook fan-in: one Sent webhook per profile is unmanageable at hundreds of profiles. Either configure all profiles to one webhook URL and route on payload fields (`payload.account_id`, `payload.outbound_number`, `payload.template_id`, plus a `message_id`-to-profile map you keep at send time), or run per-profile webhooks behind a routing layer. The single-URL pattern has one secret to rotate and a larger blast radius. -- API-key blast radius: v3 issues a single account-level `x-api-key`, so a compromised key affects every merchant under that account. If per-merchant key isolation is a requirement, give each merchant its own Sent customer account (not just its own profile) — that's the only boundary that produces a distinct API key today. -- Billing: per-merchant meters fall out of per-profile accounting cleanly. - -**10DLC:** each merchant is its own TCR Brand and Campaign. The platform does not register *its own* Brand on behalf of merchants — the merchant signs. Plan for per-merchant TCR vetting time (days, not seconds). - -**WABA:** each merchant goes through Embedded Signup to attach their own WABA. The platform is the Tech Provider on Meta's side. Plan for the support burden — Meta's "phone number already in use" errors land on the platform. - -## 5. Per-region / per-geo profiles - -A business serving multiple regions wants to honor local regulations, language defaults, and regulator-specific sender identities (e.g. EU brand vs US brand vs LATAM brand). - -**Picks this when:** regions have genuinely different regulators (US 10DLC + Brazil's regs + EU's WhatsApp rules), different timezones for send-window enforcement, or different localized sender display names. - -**Doesn't pick this when:** the regional split is only a marketing convenience and all sending is from one legal entity with one global compliance posture. - -**Ops:** -- Per-region webhooks make per-region failover and on-call rotation straightforward. -- Per-region rate limits avoid one region's burst exhausting another's budget. -- Per-region secret rotation contains blast radius geographically. -- Cross-region analytics need a join layer — the per-profile billing/usage data has to be aggregated for the global view. - -**10DLC:** US-only concept. The US-region profile is the one with TCR; non-US profiles ignore TCR entirely. Don't try to register a non-US brand with TCR "for completeness". - -**WABA:** WABAs can serve global recipients, but pricing tiers and template approval workflows differ by recipient country, not by WABA region. Per-region WABAs typically map to per-region Meta Business Manager assets and per-region System User tokens — that's the operational reason to split, not regulatory. - -## Decision heuristic - -When you're not sure whether to split, ask in order: - -1. **Are these messages legally from the same entity?** No → split. -2. **Will one identity's bad behavior unfairly impact another's quality rating / TCR vetting?** Yes → split. -3. **Does anyone need to see per-X billing or rate limiting (X = brand, department, merchant, region)?** Yes → split. -4. **Do these channels actually share a user journey or are they being kept apart for ops reasons?** Kept apart → split. -5. **Otherwise:** one profile. Splits are cheap to add later; merges are not. +Multiple explicit channels create multiple messages. Route each webhook by `message_id`; do not expect an application tenant ID in the event. diff --git a/plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md b/plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md index d416557..91e3120 100644 --- a/plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md +++ b/plugins/sent/skills/sender-profile-architect/references/sender-profile-data-model.md @@ -1,159 +1,98 @@ -# Sender Profile Data Model — Reference - -Supporting reference for `sender-profile-architect`. Captures the conceptual model Sent exposes for a Sender Profile and the resources that attach to it, as verified against the Sent v3 docs snapshot. - -This doc is **conceptual**, not a schema dump. Resource shapes evolve; the relationships below are what stabilize them. - -## Authentication context - -In v3, authentication is header-only with a single key per account: - -```http -x-api-key: +# Sender Profile data model + +## Core records + +```text +organization + id + organization_key_secret_ref + +tenant + id + organization_id + sent_profile_id + profile_key_secret_ref + credential_pattern + billing_model + +profile_policy + profile_id + allow_contact_sharing + allow_template_sharing + inherit_contacts + inherit_templates + inherit_tcr_brand + inherit_tcr_campaign + +channel_binding + profile_id + channel + direct_number + source_profile_id + waba_id + +message_attribution + message_id + logical_send_id + tenant_id + profile_id + channel + +inbound_route + channel + destination_number + tenant_id + profile_id ``` -The dashboard exposes a per-profile `x-sender-id` value for inspection and v2 legacy routing, but v3 API auth needs only `x-api-key`. API keys are issued at the **customer account** level, not per profile — a single API key can list, retrieve, create, update, complete, or delete any profile the account owns via `/v3/profiles*` endpoints. +`source_profile_id` models `sending_phone_number_profile_id` and `sending_whatsapp_number_profile_id`. Enforce referential integrity and prevent cycles. -## Sender Profile resource (verified) +## Profile request fields -The verified Profile model in Sent v3: +Create supports identity, sharing/inheritance, billing, dedicated WABA credentials, and a profile-owned `brand`. Update additionally supports number reference/direct-number fields and onboarding number-change policy. -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | Stable, Sent-issued | -| `name` | string | Display name | -| `icon` | string \| null | URL | -| `description` | string \| null | Human-readable description | -| `short_name` | string \| null | Compact label | -| `role` | `admin` \| `billing` \| `developer` \| null | The calling user's role within this profile | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status — these are the **only** Sent-side enum values | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | +Brand request fields are grouped into: -Note: `role` is the *calling user's* role in this profile, not a property of the profile itself. The org-level role enum (Owner / Admin / Billing / Developer) is the full set; the field omits `Owner` because that's billing-owner-only. +- `contact`: representative and business-facing contact data; +- `business`: legal identity, tax/entity type, address, country, URL; +- `compliance`: vertical, brand relationship, primary use case, TCR flag, number prefix, destination countries, notes. -## Sender Profile -> Channels -> Webhooks +Treat request camelCase inside `brand` separately from snake_case response fields. Do not round-trip by blindly serializing a response object as a create request. -``` -Customer account (one x-api-key) - │ - ├── SenderProfile (1..N) - │ ├── id, name, short_name, description, icon - │ ├── status: incomplete | pending_review | approved | rejected - │ ├── role (caller's role: admin | billing | developer) - │ ├── settings.default_channel ── routes if /v3/messages omits "channel" - │ ├── settings.webhook_url ── per-profile webhook destination - │ ├── settings.timezone, .language - │ │ - │ └── Channels (attached via dashboard / compliance flow) - │ ├── SMS → Brand (TCR) → Campaign(s) - │ ├── WhatsApp → WABA + phone number(s) - │ └── RCS → RBM agent (via Sent support) - │ - └── Webhooks (configured separately; can be per-profile or shared) - ├── id, display_name, endpoint_url, is_active - ├── event_types: ["message", "templates", ...] - ├── event_filters: {: [, ...]} - ├── signing_secret, retry_count (1-5, default 3), - │ timeout_seconds (5-120, default 30) - └── last_delivery_attempt_at, last_successful_delivery_at, - consecutive_failures -``` +## Authentication invariant -Webhook payloads have the shape: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} +```text +profile key -> x-api-key only +organization key -> x-api-key + optional x-profile-id ``` -`payload.account_id` is the customer-account-level UUID. The payload does not currently include a top-level `profile_id`; routing back to a profile relies on the channel/numbers/template recorded at send time. - -## How channels attach to a profile - -Channels are configured per-profile through the Sent dashboard and compliance flow. Each channel attachment carries the regulator-facing identity for that channel: - -| Channel | Attached identity | Sent endpoints | -|---|---|---| -| SMS | A TCR-registered brand + at least one campaign, plus phone numbers / short codes | `/v3/brands`, `/v3/brands/{brandId}/campaigns` | -| WhatsApp | A Meta WABA + one or more WABA phone numbers (linked through Sent's Channels / WABA Embedded Signup flow) | Dashboard-only configuration in v3 | -| RCS | A Google RBM agent (not self-service; initiated via `support@sent.dm`) | Dashboard-only configuration in v3 | - -Two consequences fall out of this model: +Only the organization pattern may include `x-profile-id`. Rate limits for organization-scoped requests remain in the organization pool. -1. **Provisioning is per-channel and largely dashboard-driven.** A profile's `status` (`incomplete | pending_review | approved | rejected`) is the rollup; per-channel readiness is tracked alongside it but not in the verified Profile resource. If your application tracks finer-grained channel readiness it should label those as internal app states, not Sent enum values. -2. **Channel identifiers are the inbound routing keys.** The data model is what makes "look up the Sender Profile from an inbound phone number / WABA / RBM agent" possible — but in v3 those joins are made on data you persist at send time, not on a `profile_id` carried in the inbound webhook payload. +## Campaign ownership -## Brands and Campaigns (SMS / 10DLC) +Campaigns belong to the brand selected through a profile but are operated through profile paths: -The word *brand* shows up at two layers — keep them distinct: - -- **Profile metadata** — the `name`, `description`, and `short_name` on the Sender Profile, used for display and as default sender identity. -- **TCR Brand** — the legal-entity record registered with The Campaign Registry, mandatory for US 10DLC SMS. Sent exposes these as first-class resources: - - `GET /v3/brands`, `POST /v3/brands`, `PUT /v3/brands/{brandId}`, `DELETE /v3/brands/{brandId}` - - `POST /v3/brands/{brandId}/campaigns`, `PUT /v3/brands/{brandId}/campaigns/{id}`, `DELETE /v3/brands/{brandId}/campaigns/{id}` - -``` -SenderProfile (Sent) - └── SMS channel - └── TCR Brand (1) - └── TCR Campaign (1..N) ─── carrier filtering happens here +```text +/v3/profiles/{profileId}/campaigns +/v3/profiles/{profileId}/campaigns/{campaignId} ``` -For 10DLC details and use-case selection, see `sms-10dlc-registration`. - -WhatsApp has no direct analogue of TCR Campaigns — per-template approval (via `/v3/templates`) plays a similar role. RCS uses one RBM agent per profile per region. - -## Webhooks - -Webhooks are configured at the account level and can be scoped to a profile via `settings.webhook_url`, or you can configure a single webhook URL and route on the payload (`payload.account_id`, `payload.channel`, `payload.outbound_number`, `payload.template_id`). +An inherited brand can have profile-owned campaigns when campaign inheritance is disabled. -Key webhook config fields: +## Status storage -- `event_types` — list of parent event types subscribed (e.g., `["message"]`, `["message", "templates"]`). -- `event_filters` — narrow within a parent type: `{"message": ["delivered", "failed"]}` only fires for `message.delivered` and `message.failed`. -- `signing_secret` — rotate via `POST /v3/webhooks/{id}/rotate-secret` (sensitive endpoint: 10 req/min limit). Rotation invalidates the old secret immediately. -- `retry_count` 1-5 (default 3), `timeout_seconds` 5-120 (default 30) — Sent will retry up to `retry_count` times if your endpoint times out or 5xxs. -- `consecutive_failures` — surfaced so you can monitor a failing webhook before it's auto-disabled. - -## Idempotency - -`POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}`, and `POST /v3/profiles/{id}/complete` all support `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Keys are cached **per customer account for 24 hours**. See `references/multi-tenancy-patterns.md` for the full idempotency/sandbox behavior across endpoints. - -## ER summary +Store at least: +```text +profile_id +status_raw +status_surface # create_response, rest_profile, completion_200, completion_callback +observed_at +payload_version ``` -Customer account ── x-api-key (one per account) - │ 1..N - ▼ -SenderProfile { id, name, status, role, settings } - │ - ├── Brand (TCR) ── Campaign(s) ── carrier-filtered SMS senders - ├── WABA ── PhoneNumber(s) (configured via dashboard) - └── RBM Agent (configured via Sent support) - -Webhooks (separate resource; can be many-to-one with profiles) - └── event_types[], event_filters{}, signing_secret, retry/timeout config -``` - -The single architectural rule that follows: every channel-specific record your application stores should reference the Sent **profile `id`**, not the customer `account_id` directly. Account is a foreign key on profile. -## What to confirm against the live OpenAPI +Known examples vary in case and vocabulary. Do not normalize unknown values into a closed enum. -Before writing application code, confirm the endpoint and schema details against the live Sent v3 OpenAPI at https://docs.sent.dm: +## Secret boundaries -- Exact channel-attachment fields on the Profile resource (the snapshot doesn't enumerate per-channel sub-objects). -- Whether `payload.profile_id` is added to webhooks in future API revisions. -- API-key creation + scope semantics (API Keys is dashboard-only in this snapshot). +WABA `access_token` and payment card fields are write-only operational secrets. Never log, echo, or persist raw values in profile records. Keep only secret-manager references and non-sensitive identifiers such as `waba_id`. diff --git a/plugins/sent/skills/sms-10dlc-registration/SKILL.md b/plugins/sent/skills/sms-10dlc-registration/SKILL.md index fd26358..365004d 100644 --- a/plugins/sent/skills/sms-10dlc-registration/SKILL.md +++ b/plugins/sent/skills/sms-10dlc-registration/SKILL.md @@ -1,165 +1,108 @@ --- name: sms-10dlc-registration -description: Prepares Sent US A2P SMS 10DLC compliance by collecting business, opt-in, brand, campaign, sample-message, and profile-completion evidence. Use when a user says 10DLC, A2P, TCR, campaign registry, brand vetting, SMS compliance, US texting, long code registration, opt-in proof, opt-out language, carrier filtering, or needs to register SMS through Sent. +description: Prepares and validates Sent US A2P 10DLC brand and campaign registration through Sender Profiles, including inheritance, all campaign use cases, opt-in evidence, sample-message policy, autoresponses, sandbox validation, TCR status, and rejection remediation. --- - +Use this skill for US A2P SMS over 10-digit long codes. Separate the compliance evidence packet from the exact Sent API request; they have different schemas and validators. -# SMS 10DLC registration +## Current Sent resource model -## Overview +There is no standalone brand CRUD path in the current v3 API. -Use this skill to prepare US A2P SMS compliance for Sent. Sent’s compliance documentation states that compliance is a prerequisite for sending messages and that 10DLC registration is mandatory for A2P messaging to US numbers. Sent handles TCR registration as part of the compliance process, while the customer must provide accurate business identity, consent, use-case, sample-message, and opt-out evidence. +- Create a dedicated brand inside `POST /v3/profiles` using `brand` and `inherit_tcr_brand: false`. +- List/create campaigns with `GET|POST /v3/profiles/{profileId}/campaigns`. +- Update/delete with `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. -The Sent v3 API exposes Sent-facing brand and campaign resources through `/v3/brands` and `/v3/brands/{brandId}/campaigns`. Profile completion through `/v3/profiles/{profileId}/complete` validates profile, brand, and campaign prerequisites before the profile is ready. +Reject guidance that reintroduces a free-standing brand path. -## When to use +## Choose inheritance deliberately -Use this skill when the request mentions 10DLC, A2P, TCR, brand registration, campaign registration, SMS compliance, US long code, EIN, opt-in proof, sample messages, opt-out, HELP/STOP language, vetting, rejected campaign, or carrier filtering caused by compliance. Use it before enabling US SMS sending or SMS fallback for RCS. +| Brand | Campaign | Settings | +| --- | --- | --- | +| Inherit both | Organization brand and campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Inherit brand, own campaign | Shared legal brand with tenant-specific traffic | brand true, campaign false | +| Own both | Dedicated tenant/business | both false and supply `brand` during profile creation | -Do not use this skill for non-US country compliance unless the user supplies a Sent compliance source for that country. Do not use it to analyze live delivery failures except to identify whether compliance status is the likely next check. +Inherited campaigns are read-only. A profile cannot supply `brand` while brand inheritance is true. -## Required evidence +## Two validation layers -Collect evidence before creating or updating Sent brand/campaign resources. Bad evidence creates review loops and downstream filtering risk. +### Evidence readiness packet -| Evidence | What to capture | Sent-grounded reason | -|---|---|---| -| Legal business identity | Legal business name, address, EIN/tax ID, entity type | Sent’s compliance guide lists these as required inputs. | -| Public web presence | Live website URL and privacy policy URL | Sent requires a live website and privacy policy for compliance review. | -| Opt-in mechanism | URL, screenshot, form text, checkbox language, or checkout flow | Sent requires an opt-in mechanism URL. | -| Use-case description | Clear description of what messages are sent and why | Sent requires use-case description. | -| Sample messages | Realistic messages matching the declared use case | Sent requires sample messages. | -| Opt-out instructions | STOP/HELP or equivalent instructions where applicable | Sent requires opt-out instructions. | -| Sender Profile | Sent profile ID or dashboard profile being completed | Profile completion validates compliance prerequisites. | +The private packet uses the explicit internal version `sent-10dlc-evidence/v1` and snake_case evidence fields. It is not an API payload. -## Process +```bash +python scripts/validate_10dlc_packet.py evidence.json +``` -### 1. Decide whether this is US A2P SMS +Collect legal identity, public website/policy links, consent proof, message flow, opt-in/opt-out/help responses and keywords, use cases, and realistic samples. See [references/10dlc-evidence-checklist.md](references/10dlc-evidence-checklist.md). -Confirm destination country, traffic type, and sender type. This skill applies to US A2P SMS over 10DLC. If the user is sending only WhatsApp, RCS without SMS fallback, short code, toll-free, or non-US traffic, document the difference and route to the appropriate compliance workflow. +### Sent campaign request -**Example.** “We send appointment reminders from a SaaS platform to US patients using local long-code numbers” is US A2P SMS and needs 10DLC. “We send only WhatsApp utility templates” is not a 10DLC workflow, though WhatsApp has its own template and business requirements. +The API request uses exact camelCase and a `campaign` wrapper: -### 2. Normalize the business identity + +```json +{ + "campaign": { + "name": "Acme account notifications", + "description": "Account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in in account settings before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} +``` -Use the exact legal business name and tax ID records. Do not “clean up” the name to a marketing brand if the tax record uses another legal entity. Mismatches between legal identity, website, and opt-in flow are common rejection causes. +Validate it with: -If the customer is an ISV registering many customers, decide whether each customer needs its own profile/brand/campaign boundary with `sender-profile-architect`. Do not put unrelated customers under one brand because it is faster. +```bash +python scripts/validate_campaign_payload.py campaign.json +``` -### 3. Classify the campaign by use case +## API use cases -Pick the narrowest truthful campaign use case. Mixed-use campaigns can be valid, but they invite broader review and more filtering risk if the sample messages do not match the declared intent. +Support all 13 current values: -| Declared intent | Better sample | Bad sample | -|---|---|---| -| Account notification | “Acme: Your password was changed. If this was not you, visit https://acme.example/security. Reply STOP to opt out.” | “Huge sale today. Click now.” | -| Delivery notification | “Acme: Order 1234 is out for delivery today. Track: https://acme.example/t/1234. Reply STOP to opt out.” | “Your package is coming. Also buy these add-ons.” | -| Customer care | “Acme Support: We received your request and will respond shortly. Reply STOP to opt out.” | “Thanks for contacting us. Get 20% off now.” | -| Marketing | “Acme: Spring sale starts today. Use code SPRING. Reply STOP to opt out.” | Transactional description with promotional samples. | +`MARKETING`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `FRAUD_ALERT`, `TWO_FA`, `DELIVERY_NOTIFICATION`, `SECURITY_ALERT`, `M2M`, `MIXED`, `HIGHER_EDUCATION`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, and `LOW_VOLUME`. -Keep detailed TCR taxonomy and carrier-specific advice in a reference file. In the skill body, use only enough taxonomy to keep the submission honest. +Each use case structurally accepts 1–5 samples, each no longer than 1,024 characters. The compliance layer requires at least two samples for marketing and mixed traffic, including low-volume mixed. Keep that policy distinction visible instead of pretending OpenAPI requires two for all traffic. -### 4. Create or update Sent brand resources +## Volume and status -Use Sent’s brand endpoints when API work is in scope. The verified v3 API includes: +`volume` is optional and, when supplied, is a numeric string. Values below `"2000"` use the documented low-volume tier; `"2000"` is the boundary to the next tier. -| Operation | Endpoint | Notes | -|---|---|---| -| Create brand | `POST /v3/brands` | Creates a new brand and associated information. | -| List brands | `GET /v3/brands` | Retrieves brands for the authenticated customer, including inherited brands where applicable. | -| Update brand | `PUT /v3/brands/{brandId}` | Cannot update brands already submitted to TCR or inherited brands. | -| Delete brand | `DELETE /v3/brands/{brandId}` | Deletes a brand that belongs to the authenticated customer. | +Campaign responses currently expose statuses `SENT_CREATED`, `ACTIVE`, and `EXPIRED`, plus `submittedToTCR`. Preserve unknown future status strings. Do not confuse a successful Sent record creation with TCR submission or carrier activation. -Use optional `Idempotency-Key` headers on create/update calls when retrying. Store the Sent brand ID returned by the API. Store any returned TCR identifiers separately only if the API response exposes them. +## Safe workflow -### 5. Create or update Sent campaign resources +1. Confirm this is US A2P 10DLC traffic and the actual sending business is identified. +2. Select brand/campaign inheritance. +3. Validate the versioned evidence packet. +4. Create or confirm the profile brand. +5. Translate evidence into the exact camelCase campaign request. +6. Validate locally and use `sandbox: true`. +7. Show the payload and obtain confirmation before a real create/update/delete. +8. Store profile ID, campaign ID, `submittedToTCR`, raw status, and review evidence. +9. Complete the profile with required `webHookUrl` only after prerequisites are ready. -Create campaigns under the relevant Sent brand. The verified v3 API says each campaign must include at least one use case with sample messages. - -| Operation | Endpoint | Notes | -|---|---|---| -| Create campaign | `POST /v3/brands/{brandId}/campaigns` | Links the campaign to the brand and requires use-case/sample-message data. | -| List campaigns | `GET /v3/brands/{brandId}/campaigns` | Retrieves campaigns and their use cases/sample messages. | -| Update campaign | `PUT /v3/brands/{brandId}/campaigns/{campaignId}` | Cannot update campaigns already submitted to TCR. | -| Delete campaign | `DELETE /v3/brands/{brandId}/campaigns/{campaignId}` | Deletes a campaign within the brand. | - -Do not claim a public `tcr_campaign_id` field unless the actual response includes it. Refer to the Sent campaign ID for Sent API operations. - -### 6. Complete the Sender Profile setup - -After profile data, brand, and campaign prerequisites are ready, call or trigger profile completion through `POST /v3/profiles/{profileId}/complete`. The OpenAPI describes this as the final step in the profile compliance workflow, validating prerequisites and connecting profile configuration in the background. - -If completion fails, fix the missing prerequisite rather than creating duplicate brands or campaigns. Duplicate compliance objects increase confusion and can lead to sending from the wrong profile. - -### 7. Prepare the review-ready submission summary - -End the workflow with a compact summary the user can paste into Sent support, a dashboard form, or an internal ticket. Include legal identity, website, privacy policy, opt-in URL/evidence, use-case description, sample messages, opt-out instructions, Sent profile ID, Sent brand ID, Sent campaign ID, and any unresolved questions. - -**Example summary.** - -> “Acme Logistics LLC, EIN ending 1234, sends US SMS delivery notifications to customers who opt in at checkout. Website and privacy policy are live. Opt-in screenshot and URL are attached. Campaign use case is delivery notification. Sample messages match shipment status only and include opt-out instructions. Sent profile `...`, Sent brand `...`, Sent campaign `...` are ready for completion.” - -## Common rationalizations to avoid - -Do not register a marketing campaign as a utility or account-notification campaign because it may be cheaper or easier. The samples, opt-in flow, and actual traffic must match. - -Do not submit placeholder websites, private staging URLs, or missing privacy policies. Sent’s compliance guide calls for live URLs. - -Do not reuse one brand/campaign for unrelated customers. Compliance belongs to the sender and use case, not just the platform sending the API call. - -Do not edit a submitted brand or campaign in place if the API says submitted objects cannot be updated. Create the right correction path with Sent. - -Do not promise exact approval times beyond Sent’s guidance. Sent says TCR registration typically completes within 3 to 7 business days after the Sent compliance form is approved, with additional propagation time possible. - -## Verification checklist - -- [ ] The traffic is confirmed as US A2P SMS over a long-code route. -- [ ] Legal business identity matches tax and website evidence. -- [ ] Website and privacy policy URLs are live. -- [ ] Opt-in evidence is concrete and matches the declared use case. -- [ ] Sample messages are realistic and match the use case. -- [ ] Opt-out instructions are included where applicable and consistent with the user experience. -- [ ] Sent brand and campaign IDs are stored separately from any provider/TCR identifiers. -- [ ] Profile completion is run only after profile, brand, and campaign prerequisites are ready. -- [ ] Unverified throughput, carrier, or pricing claims are not presented as Sent facts. - -## Related skills - -Use `sender-profile-architect` when deciding whether brands, tenants, departments, or use cases need separate Sender Profiles. - -Use `rcs-agent-onboarding` when 10DLC work is needed for SMS fallback from RCS. - -Use `messaging-performance-analyzer` when registered traffic still shows delivery failures or filtering symptoms. - -Use `template-builder-ui` when the customer needs reusable SMS template copy that matches the registered use case. - -Use the `sent` skill for shared Sent terminology and routing. - -## Bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/tcr-use-cases.md` | Lookup table | TCR use-case taxonomy, sample-message patterns, and rejection reasons. | -| `references/10dlc-evidence-checklist.md` | Worked example | Field-by-field checklist for Sent's 10DLC compliance form. | -| `references/10dlc-rejection-remediation.md` | Decision matrix | Common TCR / carrier rejection codes mapped to fix steps and re-submission etiquette. | -| `scripts/validate_10dlc_packet.py` | Validation script | Pre-flight validator for a packet JSON. Run from the skill root: `python scripts/validate_10dlc_packet.py packet.json`. | -| `scripts/fixtures/good.json` | Fixture | Complete valid packet (passes validator). | -| `scripts/fixtures/bad.json` | Fixture | Packet with missing fields / invalid EIN / short sample (validator exits non-zero). | - -## Unverified claims to confirm or remove - -- Sent's `/v3/brands` and `/v3/brands/{id}/campaigns` endpoints exist; their internal mapping to TCR identifiers is opaque to the customer. Store the Sent brand and campaign IDs returned by the API — don't claim a public `tcr_brand_id` or `tcr_campaign_id` field unless an API response surfaces it. -- Exact throughput limits, per-carrier caps, and vetting-score-to-throughput mapping are not in Sent's docs. The snapshot only confirms account-wide tier limits (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom) — these are not TCR / carrier per-campaign throughput numbers. -- Country-specific compliance, routing, and pricing claims beyond Sent's listed country-specific document requirements (AU, BE, PL, ZA, SE, TH, UK) require a current Sent source. +Never use real consumer data in fixtures or samples. Use [references/tcr-use-cases.md](references/tcr-use-cases.md) for classification and [references/10dlc-rejection-remediation.md](references/10dlc-rejection-remediation.md) for failures. diff --git a/plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md b/plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md index e848027..ddaf6b9 100644 --- a/plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md +++ b/plugins/sent/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md @@ -1,140 +1,46 @@ -# 10DLC Evidence Checklist — Sent Compliance Form +# 10DLC evidence checklist -Supporting reference for `sms-10dlc-registration`. This is the definitive list of fields a tenant must supply on Sent's compliance form before Sent files the TCR brand + campaign on their behalf. Field names below match the verified compliance form in the Sent dashboard. The deeper TCR payload (`Brand.entityName`, `Campaign.privacyPolicyLink`, etc.) is filed by Sent internally — keep that taxonomy in `references/tcr-use-cases.md`. +The evidence packet is an internal readiness artifact with `schema_version: "sent-10dlc-evidence/v1"`. Its snake_case fields are not the Sent API contract. -Use this alongside `references/tcr-use-cases.md` when authoring the form, validating an incoming packet, or debugging why a customer's submission failed pre-flight checks. Run `scripts/validate_10dlc_packet.py` against a JSON dump of the packet for the mechanical checks. +## Business identity -## Required fields — business identity +- [ ] Legal business name and EIN match registration records. +- [ ] Public website represents the same recognizable brand. +- [ ] Physical address, business phone, and compliance contact are current. +- [ ] Privacy policy and terms links are public HTTPS pages. -These appear on the KYC + compliance pages and are required for every tenant regardless of country. +## Consent -### 1. Legal business name +- [ ] Opt-in method is named: web form, keyword, paper, or spoken consent. +- [ ] Proof URL or equivalent evidence is accessible to a reviewer. +- [ ] Consent text names the brand and message type. +- [ ] The checkbox is not preselected. +- [ ] Frequency, message/data-rate notice, HELP, and STOP disclosures are present where required. +- [ ] Marketing spoken consent includes the additional confirmation required by policy. -- **Format:** Exact name as registered with the state or national business registry. Include the suffix (`LLC`, `Inc.`, `Corp.`, `LLP`). -- **Common mistakes:** - - Submitting a DBA, marketing brand, or trade name instead of the legal entity. Sent will reject if the EIN/tax-ID-to-name match fails at the issuer lookup step. - - Trailing punctuation (`Acme, Inc.,`) — strip the trailing comma. - - Mismatched capitalization vs registry records. -- **Sent tip:** If the customer uses a different consumer-facing brand, that goes in campaign sample messages, not here. +## Message flow -### 2. Business registration number +Describe the sequence from consumer action through confirmation and recurring messages. A link alone is not a message flow. -- **Format:** The jurisdiction's company / corporate registration number (e.g. state filing number in the US, Companies House number in the UK). -- **Common mistakes:** - - Confusing this with the EIN / tax ID — they're separate fields. - - Submitting the partner / member number instead of the entity's filing number. +## Autoresponses -### 3. Business type / structure +- [ ] `optinMessage` names the brand and explains HELP/STOP, frequency, and rates where required. +- [ ] `optoutMessage` confirms unsubscribe and no further messages. +- [ ] `helpMessage` names the brand and provides a support method. +- [ ] `optinKeywords`, `optoutKeywords`, and `helpKeywords` are documented. +- [ ] STOP is in opt-out keywords and HELP is in help keywords. -- **Format:** One of the dashboard's entity-type options (Private Profit, Public Profit, Non-Profit, Government, Sole Proprietor, etc.). -- **Common mistakes:** - - Sole proprietors marking themselves as Private Profit. Sole-prop senders are subject to additional restrictions and Sent must know. - - Non-profits marking themselves as Private Profit to avoid extra documentation. +## Campaign translation -### 4. Industry category +Map evidence into these Sent camelCase fields only at API serialization time: -- **Format:** Pick the closest match from the dropdown. Drives review path and downstream carrier filtering posture. -- **Common mistakes:** - - Picking "Other" when a specific match exists. - - Misclassifying regulated industries (health, finance, gambling) as general retail. +```text +message_flow -> messageFlow +privacy_policy_url -> privacyPolicyLink +terms_and_conditions_url -> termsAndConditionsLink +autoresponses.optinMessage -> optinMessage +use_cases[].messaging_use_case_us -> useCases[].messagingUseCaseUs +use_cases[].sample_messages -> useCases[].sampleMessages +``` -### 5. EIN / tax ID - -- **Format:** Federal tax ID for the country. US EIN is nine digits, optionally hyphenated after the first two: `12-3456789` or `123456789`. Regex: `^\d{2}-?\d{7}$`. -- **Common mistakes:** - - Confusing EIN with SSN (sole proprietors). Sole props with no EIN should pick the sole-proprietor entity type — see `references/tcr-use-cases.md`. - - Submitting a state tax ID instead of the federal EIN. - - Typos in the first two digits (the IRS prefix). These fail the IRS match every time. -- **Non-US tenants:** Use the equivalent national tax ID (e.g. VAT number, ABN, GST registration). - -### 6. Business address - -- **Format:** Street, city, state / region, postal code, country (ISO 3166-1 alpha-2). PO boxes are not accepted for primary brand address. -- **Common mistakes:** - - Using a mail-forwarding or virtual-office address that doesn't match the address on file with the tax authority for the EIN. - - Country code mismatches (`USA` vs `US`). - -### 7. Business phone number - -- **Format:** Full E.164 phone number reachable for compliance contact. -- **Common mistakes:** - - Submitting a number that goes to a marketing IVR with no path to a human. - - Submitting a personal mobile when the tenant is a registered entity. - -### 8. Contact email - -- **Format:** Monitored inbox for compliance correspondence. -- **Common mistakes:** - - Submitting `noreply@…`. Sent's compliance team replies to this address and reviewers test it. - - Submitting the founder's personal email when the company has compliance ownership in a separate team. - -## Required fields — messaging / use case - -These appear in the messaging-compliance section of the form and are filed against the TCR campaign. - -### 9. Use-case selection - -- **Format:** One of: **Authentication**, **Notifications**, **Marketing**, **Customer Service**, **High Volume**. -- **Common mistakes:** - - Picking **Marketing** for a transactional flow because it sounds friendlier. Use-case affects review bar and carrier filtering posture — pick the narrowest accurate option. - - Picking **High Volume** for genuinely low-volume traffic to "future-proof" — Sent maps this to the TCR `MIXED` flow which has a higher review bar. -- **Sent tip:** The dashboard's **Suggest** button auto-fills `Campaign description` based on the use case. Edit it to match the tenant's actual flow before submitting. - -### 10. Campaign description - -- **Format:** 1-3 sentences explaining what messages the tenant sends, to whom, and when. The dashboard's **Suggest** button drafts this; edit before submitting. -- **Common mistakes:** - - Generic descriptions ("transactional messages"). Reviewers want specifics: "Shipping and delivery updates for orders placed on acme.example.com." - - Description doesn't match the use-case option (e.g. describing promotional content under **Notifications**). - -### 11. Sample messages (per use case) - -- **Format:** Plain-text examples of actual production messages, with `{Variable}` placeholders. Should include the brand name. -- **Common mistakes:** - - Under 20 characters. Reviewers flag these as too generic. - - Including a URL shortener that isn't on the brand's verified-domains list. - - Sample doesn't match the declared use case (promo content in an Authentication sample). -- **Note on opt-out language in samples:** Including `Reply STOP to opt out.` in samples is good carrier hygiene and most reviewers expect to see it, but Sent's compliance form treats opt-out **keywords** as a separate field (see #13). Putting STOP in samples does not satisfy field #13 and vice versa. - -### 12. Opt-in mechanism (URL or description) - -- **Format:** Public URL showing the exact form, checkbox, or flow where end users consent to receive SMS — or a written description of the opt-in flow if no public URL exists. -- **Common mistakes:** - - Linking to a checkout flow without showing the SMS-consent checkbox. - - Consent language that bundles SMS with marketing email — reviewers want SMS-specific consent. - - Pre-checked consent boxes (forbidden under most state laws). - -### 13. Opt-out instructions (Opt Keywords tab) - -- **Format:** Managed in the Sent dashboard under **Compliance → Opt Keywords**, not as free-text on the compliance form. At minimum: `STOP` to opt out, `START` to resume. Help keyword (`HELP`) is configured here too. -- **Common mistakes:** - - Assuming the keywords are inferred from sample messages — they're configured separately and must be set explicitly. - - Customizing the auto-reply text without a brand-name reference. - - Forgetting `START` — required for re-opt-in after a `STOP`. - -## US-specific extras (required for 10DLC) - -| Field | Format | Common mistakes | -|---|---|---| -| **Live website URL** | Full URL with scheme that resolves at submission time. | Staging or preview URLs; coming-soon pages; non-HTTPS. | -| **Privacy policy URL** | Direct URL to a public privacy policy that mentions SMS data handling. | Pointing at the homepage; policy doesn't mention SMS / frequency / data sharing; 404 or auth-walled URL. | -| **Opt-in mechanism URL or screenshot** | Same as field #12 but a publicly hosted URL is strongly preferred for US 10DLC review. | Auth-walled flows; staging-only flows. | -| **Opt-out instructions (STOP / START)** | Configured via the **Opt Keywords** dashboard tab. | Configured per profile, not per brand — make sure the right profile is selected. | - -## Country-specific extras - -Selected countries require additional uploaded documents in the KYC stage. These are gated by the country selected during KYC, not by use case: - -| Country | Extra docs | -|---|---| -| Australia (AU) | Utility Bill | -| Belgium (BE) | Proof of Local Address, Passport, Business Registration Certificate | -| Poland (PL) | Proof of Local Address | -| South Africa (ZA) | Proof of Local Address | -| Sweden (SE) | Proof of Local Address | -| Thailand (TH) | Proof of Worldwide Address, Business Registration Certificate | -| United Kingdom (UK) | Proof of Local Address | - -## After the form is submitted - -Sent's pre-flight runs `scripts/validate_10dlc_packet.py` against a JSON dump of these answers. Issues at this stage are cheap to fix — once filed with TCR a rejection costs days of round-trip. See `references/10dlc-rejection-remediation.md` for what to do when an account error code, TCR, or a carrier bounces a submission that passed pre-flight. +Run the evidence validator before translation and the campaign validator after translation. diff --git a/plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md b/plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md index 38a3432..b15101b 100644 --- a/plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md +++ b/plugins/sent/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md @@ -1,126 +1,35 @@ -# 10DLC Rejection Remediation — Reference +# 10DLC rejection remediation -Supporting reference for `sms-10dlc-registration`. When a tenant says "my SMS isn't going through", the failure can be in three layers: **Sent account state** (the customer isn't fully onboarded), **TCR-side** (the brand or campaign was rejected before reaching carriers), or **carrier-side** (T-Mobile / AT&T / Verizon / MVNO declined a TCR-approved campaign). Each layer has a different fix path — always identify the layer first. +## Diagnose the layer -Pair with `references/10dlc-evidence-checklist.md` (what should be in the packet) and `references/tcr-use-cases.md` (taxonomy). Run `scripts/validate_10dlc_packet.py` against the corrected packet before re-submitting. +| Layer | Evidence | +| --- | --- | +| Local evidence packet | Version, missing consent proof, invalid URLs, incomplete autoresponses | +| Sent request validation | Wrong camelCase, unsupported field, invalid use case, sample count/length, volume type | +| TCR submission | `submittedToTCR`, registry error/reason, brand or campaign status | +| Carrier operations | Campaign active but filtering, DCA election, content mismatch | -## Layer 0 — Sent account-state errors (verified) +Do not create a new campaign until the failing layer is known. -Before assuming TCR or carrier rejection, check whether the API call itself is returning a Sent account-state error. These are returned synchronously from the v3 API and indicate the tenant isn't fully activated yet. +## Common fixes -| Sent error code | HTTP | Account states that produce it | What to do | -|---|---|---|---| -| `AUTH_006` | 403 | `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, `KYC_RESUBMISSION_REQUESTED` | KYC isn't complete. Finish KYC in the dashboard. If `KYC_RESUBMISSION_REQUESTED`, the compliance team is waiting on revised docs. | -| `AUTH_007` | 403 | `KYC_COMPLETED`, `MESSAGE_COMPLIANCE_COMPLETED` | KYC is done but no messaging channel is configured. Complete the channel + brand/campaign step in the dashboard. | -| `AUTH_005` | 403 | (post-`MESSAGE_COMPLIANCE_COMPLETED`, pre-activation) | Everything is filed; Sent is finishing internal activation. Wait and re-poll. | -| `BUSINESS_003` | 422 | (any active account) | Insufficient account balance. Not a compliance issue — top up billing. Common source of post-registration sending failures once a tenant goes live. | -| `BUSINESS_005` | 422 | (any active account) | A referenced template is still `PENDING` or `REJECTED` (Sent template lifecycle, not TCR). Confirm the SMS template at `/v3/templates/{id}` is `APPROVED` before sending. | +- Wrong endpoint model: operate campaigns under the profile and create a dedicated brand within profile creation. +- Inheritance conflict: disable `inherit_tcr_campaign` before managing a dedicated campaign; do not supply `brand` while brand inheritance is true. +- Wrong field casing: serialize `useCases`, `messagingUseCaseUs`, and `sampleMessages` exactly. +- Too few samples: provide 1–5 structurally; provide at least two for marketing/mixed compliance. +- Weak opt-in: add observable proof and a complete message flow. +- Autoresponse failure: add brand, STOP/HELP, unsubscribe confirmation, and support details as applicable. +- Traffic mismatch: select a use case that matches actual content rather than rewriting only the example. -If you're seeing any of the above, it is **not** a TCR or carrier rejection. Resolve the account-state issue first, then verify whether downstream layers are clean. +## Status interpretation -## Layer 1 — TCR-side rejections (external) +`SENT_CREATED` means a Sent campaign record exists. `submittedToTCR: true` records registry submission. `ACTIVE` means operational activation, while `EXPIRED` is not send-ready. Preserve unknown status strings and any `tcrSyncError` rather than replacing them with a guessed category. -TCR rejected the brand or campaign before it reached carriers. Faster turnaround. Most often: missing data, EIN-name mismatch, content that violates TCR policy. Sent surfaces these on the Sender Profile / Compliance status surfaces; the specific rejection strings come from TCR and are not part of Sent's public error catalog. +## Resubmission -## Layer 2 — Carrier-side rejections (external) - -TCR approved the campaign, but one or more of T-Mobile, AT&T, Verizon, or an MVNO declined. The campaign's per-carrier state will show `DECLINED` or `SUSPENDED`. Carrier decisions are slower to reverse and require evidence updates before re-submission. Per-carrier rejection codes are owned by each carrier — track them externally. - -## 1. Brand verification failure (`UNVERIFIED`, `VETTING_FAILED`) - -- **Detect:** TCR brand status moves to `UNVERIFIED` or external vetting returns `FAILED`. Sent surfaces this on the Compliance status of the brand. -- **Root causes:** - - EIN doesn't match the IRS record for the supplied legal name. - - Brand address doesn't match the IRS or state filing. - - Submitted entity type (`PRIVATE_PROFIT` vs `NON_PROFIT`) contradicts the IRS record. -- **Fix steps:** - 1. Re-pull the tenant's IRS EIN confirmation letter (Form CP-575) and reconcile name, address, and entity type field-by-field. - 2. Correct the brand record in Sent. Resubmit for vetting (costs another vet fee). - 3. If the legal name has genuinely changed, the tenant needs an IRS Form 147C confirming the current name before re-vetting. -- **Re-submission etiquette:** Don't re-submit the same data hoping for a different reviewer. Re-vetting with unchanged data is logged as a duplicate and may slow the next legitimate re-vet. - -## 2. EIN-to-legal-name mismatch - -- **Detect:** Pre-flight (`scripts/validate_10dlc_packet.py`) catches format issues; TCR catches semantic mismatch. -- **Root causes:** - - Tenant submitted a DBA instead of legal entity name. - - Recent legal name change not yet reflected with the IRS. - - Typo in EIN. -- **Fix steps:** - 1. Confirm the EIN against the IRS confirmation letter — not the tenant's accounting system. - 2. Update legal name to match exactly (including suffix). - 3. If a legitimate name change has occurred, request Form 147C from IRS before re-filing. -- **Re-submission etiquette:** Note the changed fields in the resubmission cover letter. Reviewers approve corrections faster when the delta is explicit. - -## 3. Campaign content does not match declared use case - -- **Detect:** TCR returns a use-case-mismatch verdict or carriers return a content-violation verdict. Often surfaces as an Authentication / 2FA campaign getting downgraded after a sample promo message slips in. -- **Root causes:** - - Promotional language in samples for **Notifications**, **Authentication**, or **Customer Service**. - - Samples mention a discount, sale, or call-to-buy. - - Sample uses a transactional voice but the declared use case is **Marketing**. -- **Fix steps:** - 1. Re-classify: if any sample is genuinely promotional, split into two campaigns rather than reclassifying everything as **High Volume** (see `references/tcr-use-cases.md`). - 2. Rewrite samples to mirror only the traffic that belongs in the declared use case. - 3. For ambiguous samples, lead with the trigger event (`Your order #1029 has shipped`) — reviewers parse the first sentence hardest. -- **Re-submission etiquette:** Resubmit with the trimmed samples. If splitting into two campaigns, file them sequentially, not in parallel, so the first one's vetting score informs the second. - -## 4. Opt-in evidence insufficient - -- **Detect:** TCR flags the opt-in URL or it gets flagged in manual review. -- **Root causes:** - - URL points at a homepage rather than the specific consent form. - - Consent language bundles SMS with email or push. - - Pre-checked consent box. - - Consent is buried in terms of service rather than at the point of phone-number capture. -- **Fix steps:** - 1. Update the live opt-in surface so the SMS checkbox is unchecked by default and the consent text is SMS-specific. - 2. Take a fresh screenshot with timestamp and host it publicly. - 3. Re-file with the new URL or screenshot as the opt-in evidence. -- **Re-submission etiquette:** Don't paste a URL that requires login. If the consent flow is behind auth, host a public mock that mirrors the production UX. - -## 5. Sample messages too generic - -- **Detect:** TCR flags samples as too generic, or carriers downgrade vetting after launch. -- **Root causes:** - - Samples under 20 characters or under 3 sentences. - - Missing brand name, recipient context, or opt-out language. - - Samples are paraphrases instead of literal production sends. -- **Fix steps:** - 1. Pull 5 real sends (with PII redacted) from staging. - 2. Replace generic samples with the redacted real sends, keeping brand name intact. - 3. Make sure samples cover the variety the campaign will actually send. -- **Re-submission etiquette:** Number the samples in the cover note so a reviewer can confirm each one passes their content scan. - -## 6. Opt-out configuration missing or inconsistent - -- **Detect:** Sample messages don't reference opt-out, or the **Compliance → Opt Keywords** tab has STOP / START / HELP unconfigured for the active profile. -- **Root causes:** - - Opt Keywords tab not configured for the profile sending the traffic. - - Brand name missing from the opt-out confirmation auto-reply. - - Samples don't mention the opt-out instruction (even though the rule is in Opt Keywords, carriers still expect to see hints in samples). -- **Fix steps:** - 1. Configure **STOP** and **START** at minimum in **Compliance → Opt Keywords** for the active profile. - 2. Verify the auto-reply text references the brand and the word `STOP`. - 3. Add a hint like `Reply STOP to unsubscribe.` to samples — it's not the Sent-required field but most carriers expect it. -- **Re-submission etiquette:** Note in the cover that both the Opt Keywords config and the samples were updated together. - -## 7. Prohibited content category - -- **Detect:** TCR flags `SHAFT` (sex, hate, alcohol, firearms, tobacco) or a similar prohibited-content verdict. Some carriers extend this list (cannabis, payday loans, debt collection). -- **Root causes:** - - Campaign content falls into an outright prohibited category for US carriers. - - Age-gated category declared but no age-verification at opt-in. - - Affiliate marketing without disclosure. -- **Fix steps:** - 1. If outright prohibited (e.g. cannabis on Verizon), 10DLC is not the right channel. Surface this back to the tenant; the registration cannot succeed. - 2. If age-gated, add age verification at opt-in and update samples to reference the gate. - 3. If affiliate marketing, declare it honestly and add disclosure in the message body. -- **Re-submission etiquette:** Don't shop the same prohibited campaign to different reviewers. Address the category restriction or recommend an alternative channel (e.g. WhatsApp, RCS, email). - -## General re-submission etiquette - -- Fix one class of issue at a time. Bundling unrelated changes into a single re-submission makes it hard for reviewers to confirm each fix. -- Include a short cover note listing what changed since the last submission. -- Re-run `scripts/validate_10dlc_packet.py` before every re-submission. Mechanical failures are free to catch and expensive to re-file for. -- Track rejection codes on the Sender Profile so repeat rejections trigger a manual review before another re-file. +1. Save the original response and reason. +2. Fix the versioned evidence packet if necessary. +3. Rebuild and validate the API request. +4. Use `sandbox: true`. +5. Show the exact diff. +6. Obtain confirmation before the real mutation. diff --git a/plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md b/plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md index f1e564d..34085e5 100644 --- a/plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md +++ b/plugins/sent/skills/sms-10dlc-registration/references/tcr-use-cases.md @@ -1,96 +1,35 @@ -# TCR Use Cases & Vetting — Reference +# TCR use cases -Supporting reference for `sms-10dlc-registration`. The Campaign Registry's use-case taxonomy and the practical effect of each choice on throughput and carrier filtering. The canonical list is on the [TCR website](https://www.campaignregistry.com/) — use this doc for the *interpretation* in a multi-tenant Sent deployment. +## Current Sent API values -## Sent's compliance-form taxonomy (verified) +| Value | Typical traffic | +| --- | --- | +| `MARKETING` | Promotions, offers, product announcements | +| `ACCOUNT_NOTIFICATION` | Account changes, balances, non-security notices | +| `CUSTOMER_CARE` | Support conversations and case updates | +| `FRAUD_ALERT` | Suspected fraud notifications | +| `TWO_FA` | One-time passcodes and two-factor authentication | +| `DELIVERY_NOTIFICATION` | Shipment and delivery updates | +| `SECURITY_ALERT` | Security events distinct from general account notices | +| `M2M` | Machine-to-machine operational traffic | +| `MIXED` | Multiple standard use cases in one campaign | +| `HIGHER_EDUCATION` | College or university communications | +| `POLLING_VOTING` | Polling and voting interactions | +| `PUBLIC_SERVICE_ANNOUNCEMENT` | Public-interest or government notices | +| `LOW_VOLUME` | Low-volume mixed traffic | -Sent's compliance form exposes a **five-option** use-case selector to the tenant, not the full TCR taxonomy. Pick one of these on the form: +Select what the business will actually send. Do not use `MIXED` or `LOW_VOLUME` to hide a dominant high-risk use case. -| Sent compliance-form option | Typical underlying TCR use case(s) | -|---|---| -| **Authentication** | `2FA` | -| **Notifications** | `ACCOUNT_NOTIFICATION`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `SECURITY_ALERT`, `PUBLIC_SERVICE_ANNOUNCEMENT` | -| **Marketing** | `MARKETING` | -| **Customer Service** | `CUSTOMER_CARE` | -| **High Volume** | `MIXED` / high-throughput senders (Sent maps internally) | +## Sample rules -Sent files the TCR-side payload on the tenant's behalf — the deeper TCR taxonomy below is for interpreting that mapping and reasoning about why a use-case selection might trigger more or less filtering. The tenant only sees and chooses one of the five form values. +The API structure permits 1–5 samples per use case, up to 1,024 characters each. Compliance policy requires at least two samples for `MARKETING`, `MIXED`, and low-volume mixed traffic. Samples should: -## TCR use cases sorted by throughput & filtering risk (external interpretation) +- name the recognizable consumer brand; +- look like real production messages; +- match the selected use case and described message flow; +- include opt-out language when applicable; +- use synthetic names, codes, numbers, and URLs. -| Use case | Typical TPS (post-vetting) | Filtering risk | When to pick | -|---|---|---|---| -| `2FA` | Highest | Lowest | Codes only. No promotional content. | -| `ACCOUNT_NOTIFICATION` | High | Low | Order, shipping, appointment, payment, security notifications. Triggered by the recipient's action. | -| `CUSTOMER_CARE` | Medium-high | Low | Two-way support replies. Mostly inbound-driven. | -| `DELIVERY_NOTIFICATION` | High | Low | Specialized variant of ACCOUNT_NOTIFICATION for couriers. | -| `FRAUD_ALERT` | High | Low | Account-security alerts and fraud confirmations. | -| `HIGHER_EDUCATION` | Medium | Medium | School-affiliated communications. Required for many edu senders. | -| `LOW_VOLUME` | Low | Low | Pilots, internal tools, < 6,000 messages/day. | -| `MARKETING` | Medium | High | Promo, discount, win-back. Highest review bar; opt-in must be airtight. | -| `MIXED` | Medium | High | Last resort. Raises the bar without raising throughput. | -| `POLITICAL` | Medium | High | Political campaigns (US). Regulated category. | -| `POLLING_VOTING` | Medium | Medium | Surveys, polls, non-political voting. | -| `PUBLIC_SERVICE_ANNOUNCEMENT` | Medium | Low | Non-profit / government PSAs. | -| `SECURITY_ALERT` | High | Low | Specialized variant of FRAUD_ALERT for non-financial security. | -| `SOCIAL` | Medium | Medium | Person-to-person-feeling but business-sent (matchmaking, social apps). | +## Volume -Specific TPS numbers and vetting-score-to-throughput thresholds are **external** — Sent's docs only confirm the tier-level account-wide rate caps (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom), not TCR / carrier per-campaign throughput. - -## Brand external vetting (external context) - -Brands can submit for **External Vetting** via TCR-approved vetting providers. This produces a `vettingScore` that: - -- Unlocks higher per-campaign TPS caps from each carrier. -- Reduces the filtering aggressiveness applied to the campaign's traffic. -- Cannot be inherited from another brand — every brand vets independently. - -The exact vetting-score-to-throughput mapping is set by each carrier and not exposed in Sent's docs. Treat as external (TCR + carrier docs). - -## Required campaign attributes (TCR-side, external) - -Every TCR campaign declares these. They're not directly user-visible on Sent's compliance form, but Sent files them from the form data plus dashboard configuration: - -- **Subscriber opt-in** — How recipients agreed to receive these messages. Captured on Sent's compliance form as the **opt-in mechanism URL or description**. -- **Opt-out keywords** — Minimum `STOP`. Managed on Sent in the **Compliance → Opt Keywords** tab; not embedded per sample message. -- **Help keywords** — Minimum `HELP`. Reply should describe what the campaign is and how to opt out. -- **Embedded link** — Whether your messages include URLs (`yes` / `no`). -- **Embedded phone** — Whether your messages include phone numbers (`yes` / `no`). -- **Affiliate marketing** — Whether the campaign promotes affiliate offers (`yes` / `no`). `yes` is heavily scrutinized. -- **Age-gated content** — Alcohol, gambling, firearms, tobacco. `yes` requires age verification at opt-in. -- **Direct lending** — Whether the campaign is for direct loans. Subject to additional review. - -## Sample messages - -TCR requires 2-5 sample messages per campaign. Carrier filters use these to validate live traffic. Mismatches between samples and production are the most common reason for downgrade. - -Good sample: -> {Brand Name}: Your order #1029 has shipped. Track: https://example.com/track/1029. - -Bad sample (don't do): -> Your order has shipped! - -Bad because: no brand name, no link/tracking specifics that mirror production. - -Note: Opt-out language (`Reply STOP to opt out.`) is conventional in samples and many carriers expect to see it, but the **rule** that every Sent-filed sample must literally embed `STOP` is not confirmed in Sent's docs — Sent treats opt-out as a separate field on the compliance form (Opt Keywords tab) rather than embedded in each sample. Sample-level opt-out is good carrier hygiene; the Sent-required field lives elsewhere. - -## Per-carrier filtering notes (external) - -TCR-approved doesn't mean delivered. Each major US carrier filters independently: - -- **T-Mobile** — Most aggressive. Strict on URL shorteners (use a branded short-link domain on your verified domains, not generic `bit.ly`). -- **AT&T** — Stricter on message-volume spikes than on content. Pace sends. -- **Verizon** — More content-sensitive on `MARKETING` and `MIXED`. Promotional content in an `ACCOUNT_NOTIFICATION` campaign gets caught here first. - -Track per-carrier delivery in the MDR funnel and reconcile per-carrier approval state on the campaign daily. Specific carrier rejection codes are external (Twilio / Bandwidth / Sinch / direct-aggregator docs). - -## Common rejection reasons (external taxonomy) - -| Reason | What it means | Fix | -|---|---|---| -| `Use case mismatch` | Sample messages don't fit the declared use case | Re-classify campaign or rewrite samples | -| `Missing opt-out language` | Samples don't include STOP / HELP language | Add it everywhere | -| `Brand name not in sender ID` | Recipient can't see who's texting | Add `{Brand Name}:` prefix to messages | -| `Affiliate disclosed = no, content suggests affiliate` | Lied on the attribute | Declare honestly | -| `Embedded link not declared` | Samples have links, attribute says no | Update the attribute | -| `Insufficient vetting score for requested TPS` | Vetting too low for the throughput tier | Re-vet at a higher tier, or accept lower TPS | +`volume` is a numeric string. Values below `2000` are in the documented low-volume tier. Exactly `2000` crosses the tier boundary, so validate `1999` and `2000` separately. diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json index ee07bc8..f532cb6 100644 --- a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/bad.json @@ -1,26 +1,21 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "123-456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "2175550101", - "contact_email": "compliance-at-example.com", - "website": "https://example.com", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": [], - "start": ["START"], - "help": ["HELP"] + "schema_version": "unversioned-packet", + "legal_business_name": "", + "ein": "123", + "business_phone": "2025550100", + "contact_email": "invalid", + "website": "not-a-public-url", + "privacy_policy_url": "missing-scheme.example", + "terms_and_conditions_url": "", + "opt_in_evidence": {}, + "message_flow": "", + "autoresponses": { + "optinMessage": "", + "optoutMessage": "", + "helpMessage": "", + "optinKeywords": "YES", + "optoutKeywords": "UNSUBSCRIBE", + "helpKeywords": "INFO" }, - "use_cases": [ - { - "selection": "Transactional", - "description": "Shipping and delivery updates.", - "sample_messages": [ - "Order shipped!" - ] - } - ] + "use_cases": [] } diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json new file mode 100644 index 0000000..0cea7bf --- /dev/null +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json @@ -0,0 +1,16 @@ +{ + "campaign": { + "name": "Bad campaign", + "description": "Uses the wrong field names and too few marketing samples.", + "type": "App", + "use_cases": [], + "useCases": [ + { + "messagingUseCaseUs": "MARKETING", + "sampleMessages": ["One sample only"] + } + ], + "volume": 2000 + }, + "sandbox": true +} diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json new file mode 100644 index 0000000..bc312c7 --- /dev/null +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json @@ -0,0 +1,26 @@ +{ + "campaign": { + "name": "Acme account notifications", + "description": "Synthetic account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in through the account settings form before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json index c40ddb4..1ca09fa 100644 --- a/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/fixtures/good.json @@ -1,34 +1,33 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", + "schema_version": "sent-10dlc-evidence/v1", + "legal_business_name": "Acme Example LLC", "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", + "business_address": "123 Example Street, New York, NY 10001, US", + "business_phone": "+12025550100", "contact_email": "compliance@example.com", "website": "https://example.com", "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup?sms=consent", - "opt_keywords": { - "stop": ["STOP", "UNSUBSCRIBE", "CANCEL"], - "start": ["START", "UNSTOP"], - "help": ["HELP", "INFO"] + "terms_and_conditions_url": "https://example.com/terms", + "opt_in_evidence": { + "method": "web_form", + "description": "A synthetic checkout form uses an unchecked SMS consent box.", + "proof_url": "https://example.com/sms-consent" + }, + "message_flow": "Customers submit the web form and receive the opt-in confirmation before notifications begin.", + "autoresponses": { + "optinMessage": "Acme Example: You are subscribed. Msg frequency varies. Msg and data rates may apply. Reply HELP for help or STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" }, "use_cases": [ { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders placed on example.com.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Track: https://example.com/track/1029. Reply STOP to opt out.", - "Example: Order #1042 is out for delivery today. Reply STOP to opt out." - ] - }, - { - "selection": "Authentication", - "description": "One-time login codes for example.com account holders.", + "messaging_use_case_us": "MARKETING", "sample_messages": [ - "Example: Your verification code is 482910. It expires in 10 minutes." + "Acme Example: Save 10% on your next order. Reply STOP to opt out.", + "Acme Example: New items are available. Reply STOP to opt out." ] } ] diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py b/plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py index d59ee0d..29547e4 100644 --- a/plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py @@ -1,48 +1,9 @@ #!/usr/bin/env python3 -"""Validate a Sent 10DLC compliance packet before it is filed with TCR. +"""Validate the internal 10DLC evidence packet schema. -Usage: - python validate_10dlc_packet.py - -The packet is a JSON object representing the answers a tenant submitted on -Sent's compliance form. Field names match the verified Sent compliance form -in the dashboard (see references/10dlc-evidence-checklist.md). The validator -checks the mechanical things that are cheap to catch locally and expensive -to discover after TCR or a carrier rejects the submission. Semantic checks -(use-case match, content policy) are out of scope. - -Expected shape: - - { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", - "contact_email": "compliance@example.com", - "website": "https://example.com", - "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": ["STOP"], - "start": ["START"], - "help": ["HELP"] - }, - "use_cases": [ - { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Reply STOP to opt out." - ] - } - ] - } - -Exits 0 with `OK` on success. On failure, prints one issue per line in the -form `: : ` and exits 1. +This packet is readiness evidence, not the Sent campaign API request. Its +snake_case fields are namespaced by an explicit schema version so they cannot +be mistaken for Sent's camelCase contract. """ from __future__ import annotations @@ -51,217 +12,138 @@ import json import re import sys +from pathlib import Path from typing import Any -# Required top-level keys on the packet. Match the verified Sent compliance -# form field names — business identity, US-required URLs, opt keywords, and -# use cases. -REQUIRED_TOP_LEVEL = ( + +SCHEMA_VERSION = "sent-10dlc-evidence/v1" +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +EIN_RE = re.compile(r"^\d{2}-?\d{7}$") +PHONE_RE = re.compile(r"^\+\d{1,15}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +REQUIRED = { + "schema_version", "legal_business_name", - "business_registration_number", - "business_type", - "industry_category", "ein", "business_address", "business_phone", "contact_email", "website", "privacy_policy_url", - "opt_in_mechanism_url", - "opt_keywords", + "terms_and_conditions_url", + "opt_in_evidence", + "message_flow", + "autoresponses", "use_cases", -) - -# Required keys on each use case. -REQUIRED_USE_CASE = ( - "selection", - "description", - "sample_messages", -) - -# Verified use-case selection values from the Sent compliance form. -VALID_USE_CASE_SELECTIONS = ( - "Authentication", - "Notifications", - "Marketing", - "Customer Service", - "High Volume", -) - -URL_FIELDS = ("website", "privacy_policy_url", "opt_in_mechanism_url") - -# Permissive URL regex: scheme + host + optional path. Catches obvious junk -# (no scheme, internal whitespace, missing host) without trying to be a full -# RFC 3986 validator. -URL_RE = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE) - -# EIN: 9 digits, optionally hyphenated after the first two. -EIN_RE = re.compile(r"^\d{2}-?\d{7}$") - -# E.164 phone number: leading +, then 1-15 digits. -PHONE_RE = re.compile(r"^\+\d{1,15}$") - -# Permissive email regex — local@domain.tld. -EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - -# Minimum length of a sample message before reviewers flag it as too generic. -MIN_SAMPLE_LEN = 20 +} -def _is_nonempty_string(value: Any) -> bool: - return isinstance(value, str) and value.strip() != "" +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) -def validate(packet: dict[str, Any], path: str) -> list[str]: - """Return a list of issue strings. Empty list means the packet is valid.""" +def validate(packet: Any, path: str = "") -> list[str]: issues: list[str] = [] def issue(field: str, reason: str) -> None: issues.append(f"{path}: {field}: {reason}") if not isinstance(packet, dict): - issue("", "packet must be a JSON object") + issue("", "must be a JSON object") return issues - - # Top-level required keys. - for key in REQUIRED_TOP_LEVEL: - if key not in packet: - issue(key, "missing required field") - elif key == "use_cases": - if not isinstance(packet[key], list) or len(packet[key]) == 0: - issue(key, "must be a non-empty list") - elif key == "opt_keywords": - if not isinstance(packet[key], dict): - issue(key, "must be a JSON object with stop/start/help keys") - elif not _is_nonempty_string(packet[key]): + for key in sorted(REQUIRED - set(packet)): + issue(key, "missing required field") + if packet.get("schema_version") != SCHEMA_VERSION: + issue("schema_version", f"must equal {SCHEMA_VERSION!r}") + for key in ("legal_business_name", "business_address", "message_flow"): + if key in packet and not _text(packet[key]): issue(key, "must be a non-empty string") + if _text(packet.get("ein")) and not EIN_RE.fullmatch(packet["ein"]): + issue("ein", "must contain nine digits, optionally formatted NN-NNNNNNN") + if _text(packet.get("business_phone")) and not PHONE_RE.fullmatch(packet["business_phone"]): + issue("business_phone", "must be E.164") + if _text(packet.get("contact_email")) and not EMAIL_RE.fullmatch(packet["contact_email"]): + issue("contact_email", "must be a valid email") + for key in ("website", "privacy_policy_url", "terms_and_conditions_url"): + if key in packet and (not _text(packet[key]) or not URL_RE.fullmatch(packet[key])): + issue(key, "must be a public HTTPS URL") + + opt_in = packet.get("opt_in_evidence") + if not isinstance(opt_in, dict): + issue("opt_in_evidence", "must be an object") + else: + for key in ("method", "description", "proof_url"): + if not _text(opt_in.get(key)): + issue(f"opt_in_evidence.{key}", "must be a non-empty string") + proof = opt_in.get("proof_url") + if _text(proof) and not URL_RE.fullmatch(proof): + issue("opt_in_evidence.proof_url", "must be a public HTTPS URL") + + autoresponses = packet.get("autoresponses") + if not isinstance(autoresponses, dict): + issue("autoresponses", "must be an object") + else: + for key in ("optinMessage", "optoutMessage", "helpMessage", "optinKeywords", "optoutKeywords", "helpKeywords"): + if not _text(autoresponses.get(key)): + issue(f"autoresponses.{key}", "must be a non-empty string") + if "STOP" not in str(autoresponses.get("optoutKeywords", "")).upper().split(","): + issue("autoresponses.optoutKeywords", "must include STOP") + if "HELP" not in str(autoresponses.get("helpKeywords", "")).upper().split(","): + issue("autoresponses.helpKeywords", "must include HELP") - # URL fields. - for key in URL_FIELDS: - value = packet.get(key) - if _is_nonempty_string(value) and not URL_RE.match(value): - issue(key, f"not a valid URL: {value!r}") - - # EIN format. - ein = packet.get("ein") - if _is_nonempty_string(ein) and not EIN_RE.match(ein): - issue("ein", f"must match ^\\d{{2}}-?\\d{{7}}$ (got {ein!r})") - - # Business phone format (E.164). - phone = packet.get("business_phone") - if _is_nonempty_string(phone) and not PHONE_RE.match(phone): - issue( - "business_phone", - f"must be E.164 (+CCNNNNNNNNNN, got {phone!r})", - ) - - # Contact email format. - email = packet.get("contact_email") - if _is_nonempty_string(email) and not EMAIL_RE.match(email): - issue("contact_email", f"not a valid email address: {email!r}") - - # Opt keywords — at minimum STOP must be configured. - opt_keywords = packet.get("opt_keywords") - if isinstance(opt_keywords, dict): - stop = opt_keywords.get("stop") - if not isinstance(stop, list) or not any( - _is_nonempty_string(kw) and kw.strip().upper() == "STOP" - for kw in stop - ): - issue( - "opt_keywords.stop", - "must include 'STOP' as an opt-out keyword (configured in " - "the Compliance → Opt Keywords dashboard tab)", - ) - - # Use cases. use_cases = packet.get("use_cases") - if isinstance(use_cases, list): - for i, uc in enumerate(use_cases): - prefix = f"use_cases[{i}]" - if not isinstance(uc, dict): - issue(prefix, "must be a JSON object") + if not isinstance(use_cases, list) or not use_cases: + issue("use_cases", "must be a non-empty array") + else: + for index, use_case in enumerate(use_cases): + field = f"use_cases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") continue - - for key in REQUIRED_USE_CASE: - if key not in uc: - issue(f"{prefix}.{key}", "missing required field") - - selection = uc.get("selection") - description = uc.get("description") - if "selection" in uc: - if not _is_nonempty_string(selection): - issue(f"{prefix}.selection", "must be a non-empty string") - elif selection not in VALID_USE_CASE_SELECTIONS: - issue( - f"{prefix}.selection", - f"must be one of {VALID_USE_CASE_SELECTIONS} " - f"(got {selection!r})", - ) - if "description" in uc and not _is_nonempty_string(description): - issue(f"{prefix}.description", "must be a non-empty string") - - samples = uc.get("sample_messages") - if "sample_messages" in uc: - if not isinstance(samples, list) or len(samples) == 0: - issue( - f"{prefix}.sample_messages", - "must be a non-empty list (>=1 sample per use case)", - ) - else: - for j, sample in enumerate(samples): - sprefix = f"{prefix}.sample_messages[{j}]" - if not _is_nonempty_string(sample): - issue(sprefix, "must be a non-empty string") - elif len(sample.strip()) < MIN_SAMPLE_LEN: - issue( - sprefix, - f"sample is {len(sample.strip())} chars; " - f"reviewers flag samples under {MIN_SAMPLE_LEN} as too generic", - ) - + if use_case.get("messaging_use_case_us") not in USE_CASES: + issue(f"{field}.messaging_use_case_us", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sample_messages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sample_messages", "must contain 1–5 samples") + elif any(not _text(sample) or len(sample) > 1024 for sample in samples): + issue(f"{field}.sample_messages", "samples must be non-empty strings of at most 1,024 characters") return issues def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="validate_10dlc_packet.py", - description=( - "Validate a Sent 10DLC compliance packet (JSON) before filing " - "with The Campaign Registry. Checks required fields, URL / EIN " - "/ phone / email format, use-case selection, sample-message " - "length, and opt-out keyword configuration. See " - "references/10dlc-evidence-checklist.md for the field-by-field " - "rationale." - ), - ) - parser.add_argument("packet", help="Path to the packet JSON file") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("packet", type=Path) args = parser.parse_args(argv) - - path = args.packet - try: - with open(path, encoding="utf-8") as f: - packet = json.load(f) - except FileNotFoundError: - print(f"{path}: : not found", file=sys.stderr) - return 1 - except json.JSONDecodeError as e: - print(f"{path}: : invalid JSON ({e})", file=sys.stderr) + packet = json.loads(args.packet.read_text(encoding="utf-8")) + except OSError as exc: + print(f"{args.packet}: : {exc}", file=sys.stderr) return 1 - except OSError as e: - print(f"{path}: : could not read ({e})", file=sys.stderr) + except json.JSONDecodeError as exc: + print(f"{args.packet}: : invalid JSON ({exc})", file=sys.stderr) return 1 - - issues = validate(packet, path) + issues = validate(packet, str(args.packet)) if issues: - for line in issues: - print(line, file=sys.stderr) + print("\n".join(issues), file=sys.stderr) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py b/plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py new file mode 100644 index 0000000..a3e54f5 --- /dev/null +++ b/plugins/sent/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Validate the exact Sent campaign request used by profile campaign endpoints.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +POLICY_TWO_SAMPLE_CASES = {"MARKETING", "MIXED", "LOW_VOLUME"} +CAMPAIGN_REQUIRED = {"name", "description", "type", "useCases"} +CAMPAIGN_OPTIONAL = { + "volume", + "messageFlow", + "privacyPolicyLink", + "termsAndConditionsLink", + "optinMessage", + "optoutMessage", + "helpMessage", + "optinKeywords", + "optoutKeywords", + "helpKeywords", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +VOLUME_RE = re.compile(r"^\d+$") + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def validate(payload: Any, path: str = "") -> list[str]: + issues: list[str] = [] + + def issue(field: str, reason: str) -> None: + issues.append(f"{path}: {field}: {reason}") + + if not isinstance(payload, dict): + issue("", "must be a JSON object") + return issues + for key in sorted(set(payload) - {"campaign", "sandbox"}): + issue(key, "unsupported top-level field") + if "sandbox" in payload and not isinstance(payload["sandbox"], bool): + issue("sandbox", "must be boolean") + campaign = payload.get("campaign") + if not isinstance(campaign, dict): + issue("campaign", "required and must be an object") + return issues + allowed = CAMPAIGN_REQUIRED | CAMPAIGN_OPTIONAL + for key in sorted(set(campaign) - allowed): + issue(f"campaign.{key}", "unsupported field; use the exact camelCase Sent contract") + for key in sorted(CAMPAIGN_REQUIRED - set(campaign)): + issue(f"campaign.{key}", "missing required field") + for key in ("name", "description", "type"): + if key in campaign and not _text(campaign[key]): + issue(f"campaign.{key}", "must be a non-empty string") + volume = campaign.get("volume") + if volume is not None and (not isinstance(volume, str) or not VOLUME_RE.fullmatch(volume)): + issue("campaign.volume", "must be a numeric string such as '1999' or '2000'") + for key in ("privacyPolicyLink", "termsAndConditionsLink"): + value = campaign.get(key) + if value is not None and (not _text(value) or not URL_RE.fullmatch(value)): + issue(f"campaign.{key}", "must be a public HTTPS URL or null") + for key in ("optinKeywords", "optoutKeywords", "helpKeywords"): + value = campaign.get(key) + if value is not None and (not _text(value) or len(value) > 255): + issue(f"campaign.{key}", "must be a non-empty string of at most 255 characters or null") + + use_cases = campaign.get("useCases") + if not isinstance(use_cases, list) or not use_cases: + issue("campaign.useCases", "must be a non-empty array") + return issues + for index, use_case in enumerate(use_cases): + field = f"campaign.useCases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") + continue + for key in sorted(set(use_case) - {"messagingUseCaseUs", "sampleMessages"}): + issue(f"{field}.{key}", "unsupported field") + selected = use_case.get("messagingUseCaseUs") + if selected not in USE_CASES: + issue(f"{field}.messagingUseCaseUs", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sampleMessages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sampleMessages", "must contain 1–5 samples") + continue + for sample_index, sample in enumerate(samples): + if not _text(sample): + issue(f"{field}.sampleMessages[{sample_index}]", "must be a non-empty string") + elif len(sample) > 1024: + issue(f"{field}.sampleMessages[{sample_index}]", "must be at most 1,024 characters") + if selected in POLICY_TWO_SAMPLE_CASES and len(samples) < 2: + issue( + f"{field}.sampleMessages", + f"{selected} requires at least two samples under the compliance policy layer", + ) + return issues + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("payload", type=Path) + args = parser.parse_args() + try: + payload = json.loads(args.payload.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"{args.payload}: {exc}") + return 2 + issues = validate(payload, str(args.payload)) + if issues: + print("\n".join(issues)) + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/sent/skills/template-builder-ui/SKILL.md b/plugins/sent/skills/template-builder-ui/SKILL.md index 327fb15..cfc2ccf 100644 --- a/plugins/sent/skills/template-builder-ui/SKILL.md +++ b/plugins/sent/skills/template-builder-ui/SKILL.md @@ -1,171 +1,109 @@ --- name: template-builder-ui -description: Designs and audits a Sent template builder UI for cross-channel SMS, WhatsApp, and RCS templates, including component editing, variable samples, status handling, channel-specific validation, and submission workflows. Use when a user says template builder, template editor, Sent templates, WhatsApp template UI, RCS template, SMS template, Meta import, JSON template, approval status, or wants a product spec for template creation. Do not use for generic templating engines (Jinja, Handlebars, Mustache, email/HTML templates) or non-Sent template systems. +description: Designs and audits tenant-facing Sent template builders, previews, validation, lifecycle UX, and API payload mapping. Use for template editor forms, variables, channel overrides, WhatsApp review, RCS suggestion chips, authentication templates, and safe submission flows. --- - +Design the interface around Sent's v3 `definition` contract. The UI may import Meta material, but its canonical saved and submitted model must never be Meta's `components[]` payload. -# Template builder UI +## Product model -## Overview +Use one draft object with: -Use this skill to design or improve a Sent template builder UI. Sent templates are reusable message blueprints across SMS, WhatsApp, and RCS. The UI must let users create valid templates, preview channel-specific rendering, supply variable samples, submit for review where required, and understand status without exposing irrelevant provider internals. +- optional `category` and `language`; +- required `definition.body.multiChannel`; +- optional complete body overrides for `sms`, `whatsapp`, and `rcs`; +- optional `definition.header`, `footer`, `buttons`, `definitionVersion`, and `authenticationConfig`; +- submission controls for `creation_source`, `submit_for_review`, and `sandbox`. -The Sent v3 template API supports create, list, retrieve, update, and delete operations. The first-message workflow sends templates through `POST /v3/messages` using a `template.id`. A good UI therefore optimizes both authoring and later sendability. +Do not expose top-level create fields named `name`, `channels`, `body`, `header`, or `buttons`. If the product needs an internal display label, keep it outside the Sent create payload. -## When to use +## Recommended editor sequence -Use this skill when the user asks for a template builder, template editor, template management UI, template validation, Meta import flow, JSON template builder, WhatsApp approval UI, RCS rich template editor, SMS template preview, template status page, or a product/engineering spec for Sent templates. +1. Capture intent and category. +2. Write the `multiChannel` body. +3. Insert variables as structured entities. +4. Add optional per-channel overrides. +5. Add header, footer, and buttons where supported. +6. Review live previews and accessibility. +7. Validate locally and with `sandbox: true`. +8. Save a draft, then explicitly submit for provider review. -Do not use this skill to write final WhatsApp template copy; use `waba-template-author`. Do not use it to decide Sender Profile boundaries; use `sender-profile-architect`. Do not use it to diagnose delivery failures after sends; use `messaging-performance-analyzer`. +Category should not block the first keystroke, but it must be visible before submission because it affects authentication rules and WhatsApp policy review. -## Product principles +## Variable UX -A Sent template UI should make the valid path obvious and the invalid path hard. Users should understand three things at all times: what channels the template targets, what variables need examples, and whether the template is editable, pending, approved, or rejected. +Inserting a variable creates both: -| Principle | UI behavior | Why it matters | -|---|---|---| -| Channel-first editing | User chooses SMS, WhatsApp, RCS, or combinations before components. | Component support differs by channel. | -| Variable-first validation | Every variable has a sample value before review/submission. | Reviewers and test sends need concrete rendered examples. | -| Status-aware actions | Drafts can be edited; pending/approved/rejected states guide next action. | Users should not unknowingly break reviewed content. | -| Provider-specific details are scoped | WhatsApp category and Meta import appear only where WhatsApp applies. | Keeps cross-channel UI from becoming WhatsApp-only. | -| JSON escape hatch | Advanced users can paste/edit JSON with schema validation. | Sent docs include JSON definition as a creation path. | +- a placeholder such as `{{0:variable}}`; and +- a matching entity with `id`, `name`, `type`, and `props.sample`. -## Process +Renumber atomically when variables move. Never let users edit placeholder syntax independently of the entity table. Show a clear error for naked `{{1}}` or IDs without definitions. -### 1. Start with the template intent and channels +## Validation matrix -Ask what the template is for before showing component controls. Intent drives category, variables, and review risk. Then ask which channels the user wants to support. +Apply the exact rules in [references/template-validation-matrix.md](references/template-validation-matrix.md), including: -**Example.** “Order shipped” targeting SMS, WhatsApp, and RCS should start from one intent but render differently: SMS may be plain text, WhatsApp may need a utility category and sample variables, and RCS may use richer actions if configured. +- a 1,024-character maximum for every body; +- 60 characters for header and footer; +- no footer variables; +- 10 buttons total; +- button types `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE` with their per-type limits; +- no invented quick-reply-versus-CTA exclusivity; +- `authenticationConfig` and authentication restrictions; +- complete, independently valid channel overrides. -### 2. Model the Sent template lifecycle +Run the bundled `waba-template-author` linter against serialized JSON. Server validation remains authoritative. -Use Sent’s documented statuses in the UI: Draft, Pending, Approved, and Rejected. Do not introduce provider-only states as global Sent states unless Sent event types or API responses expose them for the account. +## Channel previews -| Status | UI meaning | Allowed primary action | -|---|---|---| -| Draft | Saved but not submitted. | Edit, preview, validate, submit. | -| Pending | Submitted for review/approval where required. | View, cancel if supported, duplicate. | -| Approved | Available for production sends where channel setup allows. | Use in send flow, duplicate for revision. | -| Rejected | Review failed or validation blocked approval. | View reason, revise, resubmit or duplicate. | +### SMS -Although the OpenAPI says `PUT /v3/templates/{id}` can update name, category, language, definition, or submit for review, the UI should still protect approved templates with a “duplicate and revise” path when auditability matters. Present immutability as a product-safety choice, not a Sent API fact. +Preview plain text and estimated GSM/UCS-2 segments. Make clear that segment estimates affect billing and are not template body limits. -### 3. Back the UI with Sent template endpoints +### WhatsApp -Keep the UI contract aligned to the verified v3 template operations. +Preview header, body, footer, and buttons. Show sample values, category, language, and provider-review impact. -| UI action | Endpoint | Notes | -|---|---|---| -| Create template | `POST /v3/templates` | Create with header, body, footer, buttons, and review/draft intent. | -| List/search templates | `GET /v3/templates?page=&pageSize=&search=&status=&category=` | Support filtering by status, category, and search. | -| Open template detail | `GET /v3/templates/{id}` | Show name, category, language, status, and definition. | -| Save/update | `PUT /v3/templates/{id}` | Update editable fields or submit for review. | -| Delete | `DELETE /v3/templates/{id}` | Optionally delete from Meta where supported by the API request. | -| Send test after approval | `POST /v3/messages` | Use `template.id` and channel selection. | +### RCS -Use optional `Idempotency-Key` headers when create/update requests may be retried by the frontend or backend. +Current Sent RCS guidance supports text plus up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap capabilities, not current Sent builder controls. Do not generate capability declarations for unavailable features. -### 4. Design the editor around components +Channel routing belongs to the send flow, not the template editor. If routing is shown in a simulator: -Represent the template as a structured definition rather than one text blob. Sent’s docs describe template components such as header, body, footer, and buttons, with practical support differences across SMS, RCS, and WhatsApp. +- omitted `channel` or `["sent"]` means automatic routing and fallback; +- `["rcs"]` pins RCS with no cross-channel fallback; +- multiple explicit values mean broadcast and separate billable messages. -| Component | UI guidance | Channel notes | -|---|---|---| -| Header | Optional title/media area with clear preview. | Most relevant to WhatsApp/RCS; validate per selected channel. | -| Body | Required main content with variable insertion. | Needed across channels; SMS preview should show plain-text length behavior. | -| Footer | Optional low-emphasis text. | Useful for compliance or context where supported. | -| Buttons/actions | Explicit button type and target. | Validate per channel; do not allow unsupported combinations. | -| Variables | Named or positional placeholders with sample values. | Samples are required for review and testing. | +Never describe an explicit RCS-plus-SMS array as ordered fallback. -### 5. Make validation staged and explainable +## Save and review behavior -Run validation in layers so users know whether a problem is a Sent schema issue, a channel support issue, or a policy/review issue. +Use `sandbox: true` for validation. Save with `submit_for_review: false`. Before switching it to `true`, show: -| Layer | Example error | Fix | -|---|---|---| -| Required fields | “Body is required.” | Add body content. | -| Variable samples | “`{{order_id}}` has no sample value.” | Add a realistic sample. | -| Channel support | “SMS cannot render this rich button.” | Remove button for SMS or split channel variants. | -| WhatsApp review risk | “Marketing language in a utility template may be rejected or reclassified.” | Change category or remove promotional content. | -| JSON schema | “Definition does not match Sent template shape.” | Correct JSON before save. | +- category and language; +- rendered previews with sample values; +- channel overrides; +- button actions; +- any warnings; +- the fact that provider review is an external state change. -**Example validation.** If a utility WhatsApp template says “Your order shipped. Add 20% off accessories today,” the UI should warn that promotional content conflicts with utility intent. For SMS, the same content may be syntactically valid but still must align with 10DLC use-case registration. +Do not autosubmit on save. -### 6. Support Sent’s creation paths +## Lifecycle UX -Sent's dashboard exposes four template creation flows at `app.sent.dm/dashboard/templates`. Mirror them by name and intent. +Resource status values currently include `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Keep an unknown-state renderer. -| Path | Best for | UI requirement | -|---|---|---| -| Create from Sample | New users and common templates | Curated examples with editable variables. | -| Create from Scratch | Product teams building custom flows | Guided component editor. | -| Import from Meta | Existing WhatsApp template libraries | Import review, mapping, and status reconciliation. | -| Create From Definition | Developers and migrations | Schema validation, diff view, and clear errors. | +WhatsApp template webhook events use `field: "templates"`, no `sub_type`, and no `event`; the status is `payload.status`. Provider values can include `CATEGORY_UPDATED`, `DISABLED`, and other future strings. See [references/template-status-handling.md](references/template-status-handling.md). -### 7. Preview the send path, not only the design +## Accessibility and failure recovery -An approved template is only useful if it can be sent. Add a test-send preview that asks for Sender Profile/channel context, recipient test number, variable values, and sandbox/production mode where applicable. Show that production sending uses `POST /v3/messages` with the selected `template.id`. +- Associate every error with a field and a summary. +- Do not rely on preview color alone. +- Preserve user edits after validation failures. +- Keep raw JSON inspection available for advanced users. +- Label imported Meta JSON as “Meta Cloud API source” until converted. +- Provide a diff for server normalization and provider-driven category/status changes. -## Common rationalizations to avoid - -Do not build a WhatsApp-only UI and call it a Sent template builder. Sent templates span SMS, WhatsApp, and RCS. - -Do not mark name/language/category immutable as a Sent API fact. The verified update endpoint can update those fields; immutability is a product governance decision. - -Do not show provider policy warnings globally. Only show WhatsApp-specific category/review warnings when WhatsApp is selected. - -Do not hide sample values in an advanced panel. Missing or unrealistic samples are a common review and testing failure. - -Do not rely on frontend validation alone. The backend should validate the Sent request shape and preserve API error messages for users. - -## Verification checklist - -- [ ] The UI starts with template intent and target channels. -- [ ] Statuses match Sent’s documented Draft, Pending, Approved, and Rejected states. -- [ ] Template CRUD maps to verified `/v3/templates` endpoints. -- [ ] Variables cannot be submitted without sample values. -- [ ] Component validation is channel-aware for SMS, WhatsApp, and RCS. -- [ ] WhatsApp-specific category/review warnings are scoped to WhatsApp templates. -- [ ] JSON definition mode validates schema before save. -- [ ] Test-send preview uses `POST /v3/messages` with `template.id` after approval/readiness checks. - -## Related skills - -Use `waba-template-author` when the task is to write or classify WhatsApp template content. - -Use `sms-10dlc-registration` when SMS template copy must match a US A2P campaign use case or opt-out evidence. - -Use `rcs-agent-onboarding` when RCS templates depend on agent approval, fallback behavior, or rich-rendering tests. - -Use `sender-profile-architect` when template ownership, profile scoping, or tenant boundaries are unclear. - -Use `messaging-performance-analyzer` when an approved template sends poorly or webhook evidence shows failures. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/template-validation-matrix.md` | Lookup table | List component support, variable rules, and channel-specific restrictions without bloating the skill body. | -| `references/template-ui-wireflows.md` | Worked examples | Show sample, scratch, Meta import, and JSON creation flows. | -| `references/template-status-handling.md` | Decision matrix | Map Sent status and provider review outcomes to UI actions. | - -## Unverified claims to confirm or remove - -- Whether template name/language/category are immutable after first save is not documented in the snapshot; the `PUT /v3/templates/{id}` endpoint accepts these fields, so behavior should be verified against the live OpenAPI before assuming. Treat product-side locking as a governance choice, not an API fact. -- Mixed-button rules (quick-reply XOR CTA, ordering, per-category constraints) remain external Meta concerns — link to Meta's WhatsApp template docs, do not mirror. -- Template-status webhook event names follow the `.` pattern (the snapshot confirms the envelope) but the snapshot does not enumerate template-specific events. Discover the exact names via `GET /v3/webhooks/event-types` for the account. +Use [references/template-ui-wireflows.md](references/template-ui-wireflows.md) for state transitions. Use `waba-template-author` for copy and policy judgment, `sent-templates` for existing-resource operations, and `rcs-agent-onboarding` for RCS launch readiness. diff --git a/plugins/sent/skills/template-builder-ui/references/template-status-handling.md b/plugins/sent/skills/template-builder-ui/references/template-status-handling.md index 0f856c2..d9c1b87 100644 --- a/plugins/sent/skills/template-builder-ui/references/template-status-handling.md +++ b/plugins/sent/skills/template-builder-ui/references/template-status-handling.md @@ -1,75 +1,43 @@ -# Template Status Handling - -The lifecycle states a Sent template moves through, which fields are editable in each, how the UI gets notified of upstream changes, and how to handle resubmission. The builder UI tracks every template against the state machine below. - -## Status enum - -Sent's template status enum is exactly four states the UI renders, regardless of underlying channel: `Draft`, `Pending`, `Approved`, `Rejected`. There is **no `PAUSED` state in Sent.** Meta's upstream `PAUSED` flag exists Meta-side only and is not surfaced into the Sent template-status enum. - -| State | Meaning | Source | -|---|---|---| -| `Draft` | Authored locally, not yet submitted | Sent-internal | -| `Pending` | Submitted, awaiting review (WhatsApp = Meta review, typically 24–48 hours per Sent docs; RCS = Google review; SMS does not gate on review) | Set by Sent on submit, cleared by upstream callback | -| `Approved` | Live and sendable | Set by Sent on upstream approval | -| `Rejected` | Upstream rejected the submission | Set by Sent on upstream rejection, carries a `rejection_reason` payload | - -Other upstream states (Meta `PAUSED`, `DISABLED`, `IN_APPEAL`, etc.) do not appear as Sent statuses. If your UI needs to surface a Meta-only signal (e.g. a deliverability pause), treat it as a secondary annotation on an `Approved` row — never bucket those rows as `Rejected`. - -**During `Pending`, SMS sends still work.** Per Sent docs, a template can be sent over SMS while it is still awaiting WhatsApp approval — the `Pending` status gates only the channels that require upstream review. Reflect this in the UI: don't grey out the entire row, only the WhatsApp/RCS send actions. - -## Editable fields by state - -| Field | Draft | Pending | Approved | Rejected | -|---|:-:|:-:|:-:|:-:| -| Name | edit | locked | locked | locked | -| Language | edit | locked | locked | locked | -| Category | edit | locked | locked (Meta may silently change it — see below) | locked | -| Channel | edit | locked | locked | locked | -| Body | edit | locked | edit (creates v2) | edit | -| Header type | edit | locked | locked | edit | -| Header text/media | edit | locked | edit | edit | -| Footer | edit | locked | edit | edit | -| Button types (radio) | edit | locked | locked | edit | -| Button labels | edit | locked | edit | edit | -| Variable samples | edit | locked | edit | edit | - -Editing an **Approved** template's editable fields does not mutate the live template — it creates a new version on submit, while the prior version continues to send until the new one is approved. Make this obvious in the editor footer ("Submitting creates v2; v1 keeps sending until v2 is approved"). - -In **Pending**, surface a "Withdraw and edit" affordance — it calls the Sent withdraw endpoint and moves the template back to Draft. - -> The locks above are a **product-governance** choice. The Sent v3 `PUT /v3/templates/{id}` endpoint accepts name/language/category in its request body; whether those fields are truly immutable server-side after first save is not documented in the snapshot and should be verified against the live OpenAPI before relying on it. - -## Webhook vs polling - -Sent webhook events follow a top-level `field` + `sub_type` envelope, with `sub_type` formatted as `.` (e.g., `message.delivered`, `message.failed`). Template status changes are inferred to follow the same pattern (e.g., `template.approved`, `template.rejected`, or a single `template.status_changed`) — the snapshot confirms the envelope but does not enumerate template-specific events. **Discover the exact event names via `GET /v3/webhooks/event-types` for your account** and subscribe via `POST /v3/webhooks` with the relevant `event_types` / `event_filters` shape. - -Two reasonable UI approaches: - -- **Webhook + realtime fanout** (preferred). The backend receives the Sent webhook, fans out to the relevant tenant's realtime channel (Pusher / Ably / Supabase Realtime / WebSocket), and the list row updates in place. Lowest latency, no client polling load. Use when you already have a realtime layer for other reasons. -- **Short polling** (acceptable fallback). The list view polls `GET /v3/templates?status=pending` every 5–10s while any row is in Pending; stops polling when none remain. Simpler to ship; more network load. Use when you don't yet have realtime infrastructure. - -Do *not* poll per-row — always poll the list filter — and do not poll forever. Cap at e.g. 30 min after submit; beyond that, the tenant must refresh. - -## Rejection reason display: Meta vs Sent - -Two distinct rejection sources, and the UI should render them differently: - -- **Meta-surfaced rejection.** The `rejection_reason` Sent forwards from Meta's template-status update. Render this as a sticky banner with the human-readable reason on top, the raw Meta enum (e.g. `INVALID_FORMAT`, `ABUSIVE_CONTENT`, `INCORRECT_CATEGORY`) collapsed by default, and a Sent-maintained remediation hint mapped from the enum. -- **Sent-surfaced rejection.** When Sent's own pre-submission validation (the server-side mirror of the matrix) rejects the payload before forwarding to Meta. Render with a different icon and label ("Caught by Sent before submission") so tenants don't think Meta reviewed the template. - -Never show the raw Meta JSON — it's noisy and changes shape. Always go through the Sent-normalized rejection-reason API. - -## Resubmission flow - -After editing a Rejected template: - -1. The Submit button triggers a *new submission attempt* against the existing template record. The template ID stays stable; only the `attempt_id` increments. -2. The list row transitions Rejected → Pending in place; do not create a duplicate row. -3. The rejection-reason banner is dismissed automatically when the new attempt enters Pending. -4. On Approval, the row turns green and the banner stays gone. On a fresh Rejection, render the new reason — and add a "previous reasons" disclosure showing the prior failures so tenants can see they're not regressing. - -For Approved templates, "resubmit" is really "submit a new version" — covered above in Editable fields. - -## Silent re-categorization - -Meta may re-categorize an Approved template (most commonly `UTILITY` → `MARKETING`) without changing its status. If Sent surfaces this (event name not enumerated in the snapshot — verify against `GET /v3/webhooks/event-types`), the list-row UI should render a one-time dismissible banner ("Meta moved this template to Marketing — it will now bill at marketing rates"). Tenants who miss this end up surprised by billing. See the `template-builder-ui` SKILL.md for the editor-side treatment. +# Template status handling + +## Resource states + +Sent template resources currently surface `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Use an unknown state in the UI because contracts evolve. + +## Provider webhook states + +WhatsApp approval events use this envelope: + +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "CATEGORY_UPDATED", + "language": "en_US", + "category": "MARKETING", + "channel": "whatsapp", + "reason": "Provider category update" + } +} +``` + +Template events have `field: "templates"` and no `sub_type` or `event`. Known provider values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; values such as `PAUSED` and `DISABLED` may be forwarded verbatim. + +## UI behavior + +| Value | UI response | +| --- | --- | +| `DRAFT` | Editable; offer validate and submit actions. | +| `PENDING` | Lock provider-reviewed fields and show submission time. | +| `APPROVED` | Show usable status and immutable submitted content. | +| `REJECTED` | Show the reason and create a revision path. | +| `PAUSED` / `DISABLED` | Block new WhatsApp usage and surface remediation. | +| `CATEGORY_UPDATED` | Show old/new category when known and re-evaluate pricing/policy UX. | +| Unknown | Preserve raw value, use a neutral badge, and avoid destructive assumptions. | + +Verify webhook signatures, deduplicate transitions, retrieve the current resource before overwriting local state, and tolerate delayed or out-of-order deliveries. Polling may be used as recovery, not as evidence that invented webhook event names exist. diff --git a/plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md b/plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md index dcde0a4..62e3087 100644 --- a/plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md +++ b/plugins/sent/skills/template-builder-ui/references/template-ui-wireflows.md @@ -1,104 +1,49 @@ -# Template UI Wireflows +# Template builder wireflows -The user journeys a Sent template builder must support. Each flow is a UX spec — entry point, screen sequence, state at each step, validation gates, exit conditions — written so a frontend engineer can implement against it without re-deriving the requirements. +## New draft -The dashboard surface lives at `app.sent.dm/dashboard/templates`. The list view supports search/filter by **name**, **category** (Marketing / Utility), **description**, and **status** (Draft / Approved / Pending / Rejected). Each row exposes per-template actions: **edit**, **delete**, **view analytics**. +`intent → multiChannel body → variables → overrides → optional components → previews → validation → draft save` -There are four template creation flows, exposed in the dashboard exactly as: **Create from Scratch**, **Import from Meta**, **Create From Definition**, **Create from Sample**. +- The internal draft may have a product label, but serialization drops it because create does not accept top-level `name`. +- Variable insertion writes the placeholder and entity together. +- Switching a channel override off preserves it locally until the user confirms deletion. -## Flow 1: Create from Scratch +## Validate -**Entry:** Templates list (`app.sent.dm/dashboard/templates`) → "Create from Scratch" action. +`serialize → local linter → fix field errors → sandbox request → reconcile server response` -1. **Channel select** (if the tenant has more than one channel provisioned). One-step picker: SMS / WhatsApp / RCS. Locks the validator set (see `references/template-validation-matrix.md`). -2. **Category picker** (WhatsApp only). `UTILITY` / `MARKETING` / `AUTHENTICATION`, each with one-line definition + example. -3. **Name + language** form. Name validated against `^[a-z][a-z0-9_]{0,511}$`; show `_v1` suffix nudge. Language is a typed-search picker over BCP-47 codes. -4. **Editor + preview split**. Component editor left, live preview right, sticky sample editor bottom-right. -5. **Submit gate.** Submit button disabled until all blocking validations pass. On click: confirm modal that shows the final payload diff ("here's what we're sending to Meta"). -6. **Optimistic insert.** Template row appears in the list with `Pending` status before the network round-trips. -7. **Status polling / webhook subscription.** See `references/template-status-handling.md`. +- Mark Meta `components[]` imports as unconverted and block Sent submission. +- Preserve the last valid server response separately from the working draft. +- Server normalization should appear as a reviewable diff. -## Flow 2: Create from Sample (clone) +## Submit for review -**Entry:** Templates list → "Create from Sample" action, or existing template row → "Duplicate". +`draft → final preview → confirmation → submit_for_review=true → PENDING or returned state` -1. Open the editor pre-filled with the source template's full state (curated sample, or an existing template). -2. **Name field is empty and focused** — the user must pick a new name; the original `(name, language)` pair is treated as permanent by the builder. -3. The category and channel are pre-selected and locked to match the source (changing channel is "create new", not "duplicate"). -4. From here: identical to Flow 1 from step 4 onward. +Do not autosubmit. The confirmation displays category, language, samples, buttons, overrides, and warnings. -Clone is the most-used "create" path in practice — make it 1-click from any approved template, including across languages (duplicate + change language). +## Status changes -## Flow 3: Import from Meta +`DRAFT → PENDING → APPROVED | REJECTED | PAUSED | unknown` -**Entry:** Templates list → "Import from Meta" action (WhatsApp only). +This is a UI model, not a promise of an exhaustive provider state machine. Template webhooks can forward `CATEGORY_UPDATED`, `DISABLED`, or future values. Render unknown states and keep raw values. -1. Modal: paste the raw template JSON from the Meta dashboard's "View JSON" panel. -2. Parser maps Meta's shape into Sent's internal template model. -3. **Diff preview**: show fields that didn't round-trip cleanly (e.g. Meta has a property Sent doesn't model yet). Tenant can proceed or cancel. -4. Land in the editor (Flow 1 from step 4) with all parsed fields filled. Validation runs immediately so any Meta-side state that violates current rules surfaces as inline errors. -5. Sent treats the import as a *draft* — the imported template is not auto-submitted; the tenant must click Submit. +## Rejection recovery -## Flow 4: Create From Definition (advanced) +`webhook/poll → retrieve current resource → show reason → fork editable revision → lint → sandbox → confirm resubmission` -**Entry:** Templates list → "Create From Definition" action, or Editor → "Edit as JSON" toggle (available on Draft only). +Avoid destructive in-place edits when content is locked. Keep the submitted version and revision history visible. -1. Editor swaps to (or opens directly into) a monaco-style JSON pane showing Sent's internal template schema. -2. **Schema-guided autocomplete** (using the same JSON schema that drives the matrix). -3. Live validation + preview continue to run against the JSON. -4. "Back to form" toggle round-trips if the JSON is currently valid; greyed out if not (don't silently discard). -5. Submit gate is identical to Flow 1. +## RCS preview -This is the escape hatch for power users and for tenants whose IDE-driven workflows generate templates programmatically. +`text → zero-to-four suggestion chips → device preview` -## Flow 5: Edit +Do not add rich-card, carousel, or attachment branches to current Sent workflows. If a mockup illustrates future capability, label it roadmap-only and exclude it from serialized requests. -**Entry:** Existing template row → "edit" action. +## Routing simulator -State of the editor depends on the template's lifecycle state (see `references/template-status-handling.md`): +If the product includes a send simulator, keep routing semantics explicit: -- **Draft** → all fields editable. -- **Pending** → editor is read-only with a "Pending review — edit blocked" banner. Offer "Withdraw and edit" which moves the template back to Draft via the Sent API. -- **Approved** → name, language, and category are locked. Body, header (text only, not media swap), footer, button labels (not button types) are editable. Editing an Approved template creates a *new version* on submit — surface this prominently ("Submitting will create v2; v1 keeps sending until v2 is approved"). -- **Rejected** → all fields editable; the rejection-reason banner is sticky at the top of the editor (see Flow 8). - -## Flow 6: Status transitions visible in UI - -Every list row shows a status pill. Sent's status enum is exactly `Draft` / `Pending` / `Approved` / `Rejected` — there is no `PAUSED` state surfaced in Sent. Allowed transitions: - -``` -Draft ─submit→ Pending ─approved upstream→ Approved - └─rejected upstream→ Rejected ─edit→ Draft -``` - -Pills use Sent's design-system tokens — not raw Meta colors — so the same component renders for SMS templates and RCS templates. - -Transitions animate (fade pill color), and the row's last-updated timestamp updates on each transition so tenants can correlate with WhatsApp review SLA (per Sent docs, typically 24–48 hours). - -## Flow 7: Submit - -The submit click is the *only* destructive step in the builder. Treat it carefully: - -1. **Pre-submit checks** (client-side): - - Run the full validation matrix; any blocking failure aborts. - - Open a confirmation modal showing: final preview render, the JSON that will hit Meta, and a "What happens next?" explainer (review SLA, billing implication for marketing, etc.). -2. **POST to Sent's template-submit endpoint** (`POST /v3/templates`, optionally with `Idempotency-Key`). Show a button spinner. -3. **On 2xx**: dismiss the modal; insert an optimistic `Pending` row into the list; clear the draft state. -4. **On 4xx**: surface the Sent error message inline in the modal — do not dismiss; do not lose the user's work. -5. **Subscribe to status** — see `references/template-status-handling.md` for the webhook-vs-polling tradeoff. - -## Flow 8: Reject-recover - -When a template lands in `Rejected`: - -1. The list row's status pill is red and clickable. -2. Clicking opens the editor with a sticky banner at the top: human-readable rejection reason, the raw Meta `reason` collapsed by default, and a "Remediation" callout that maps the rejection code to a concrete fix ("Variable samples contained promotional language — rewrite samples as neutral data and resubmit"). -3. The editor pre-focuses the field most likely to be wrong (e.g. the offending sample input). -4. The tenant edits and clicks Submit — the resubmission creates a new attempt with a new `attempt_id` server-side; the list row updates in place rather than duplicating. - -## Cross-cutting notes - -- Every flow that lands in the editor reuses the same editor component — channel + category state determine which sub-forms render. There is *not* a separate editor per channel. -- Every flow respects the validation matrix at every keystroke; submit is the only network call. -- Optimistic UI is acceptable for insertions (Draft creation, submit) but not for status transitions — those must come from the server. -- All confirmation modals are dismissable with Escape; the Submit modal additionally requires explicit confirm (no Enter-key auto-submit). +- no `channel` / `["sent"]`: automatic routing and fallback; +- one explicit channel: pinned; +- two or more explicit channels: broadcast with one message per recipient/channel pair. diff --git a/plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md b/plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md index f25a368..41964f5 100644 --- a/plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md +++ b/plugins/sent/skills/template-builder-ui/references/template-validation-matrix.md @@ -1,106 +1,60 @@ -# Template Validation Matrix - -Per-channel rules a Sent template builder UI must enforce client-side, with the surface treatment for each failure mode. Use this as the single source of truth when wiring validators into the editor — the same matrix should drive the submission handler, so a "soft warning" never silently becomes a hard reject downstream. - -**Surface treatments** referenced below: - -- **Inline error** — red text under the field, focused on save attempt, does not block typing. -- **Blocked save** — the Submit button is disabled until resolved; tooltip explains why. -- **Soft warning** — amber banner or icon, save still allowed, but the tenant must acknowledge. - -## Component support matrix (Sent-confirmed) - -| | SMS | RCS | WhatsApp | -|---|:-:|:-:|:-:| -| Header | ❌ | ✅ | ✅ | -| Body | ✅ | ✅ | ✅ | -| Footer | ❌ | ✅ | ✅ | -| Buttons | ❌ | ✅ | ✅ | - -Header types (where supported): `TEXT`, `IMAGE`, `VIDEO`, `DOCUMENT`. - -Button types (where supported): `QUICK_REPLY`, `URL`, `PHONE_NUMBER`. - -Template categories (apply to WhatsApp review; not a Sent enum extension): `UTILITY`, `MARKETING`, `AUTHENTICATION`. No others. - -## Character limits (Sent-confirmed) - -| Channel | Component | Limit | -|---|---|---| -| SMS | Body | 160 chars per segment (GSM-7); longer messages split | -| WhatsApp | Body | 1028 chars | -| WhatsApp | Header (text) | 60 chars (optional component) | -| WhatsApp | Footer | 60 chars (optional component) | -| RCS | Body | 1028 chars | -| RCS | Header | 60 chars (optional component) | - -## SMS - -SMS templates support **Body only** — no Header, Footer, or Buttons. All rules below apply to body content. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Body length (GSM-7) | 160 chars per segment | Inline char + segment counter; soft warning at 4+ segments | -| Body length (UCS-2) | 70 chars per segment (triggered by any non-GSM-7 char, e.g. emoji, curly quotes) | Detect encoding on every keystroke; soft warning at first UCS-2 char ("This template now bills as Unicode — N segments") | -| Total body length | Hard cap at 1600 chars (10 UCS-2 segments) | Blocked save above cap | -| Opt-out language | A2P templates must include `Reply STOP to opt out` (or equivalent) at least once across the campaign's template set | Soft warning per template; campaign-level check elsewhere | -| Link shortening | Public domain shorteners (bit.ly, tinyurl) are heavily filtered by carriers | Soft warning when a known shortener domain appears in the body; suggest the tenant's branded short domain | -| Variable placeholders | Use the placeholder format the SMS template model expects (named or ordinal — see https://docs.sent.dm). Stay consistent across all SMS templates in a project. | Inline error on mismatched placeholder style | -| Sender ID injection | Alphanumeric sender IDs not allowed in US 10DLC | Blocked save if the tenant tries to set a non-numeric sender on a US campaign | -| Public URL preview | Long URLs eat segments fast | Show effective char-with-URL count; soft warning if URL is >40 chars | - -For campaign-level filtering rules that gate which SMS templates are sendable, use `sms-10dlc-registration`. - -## WhatsApp - -Rules are *category-aware* — `UTILITY`, `MARKETING`, and `AUTHENTICATION` each have a different shape. The builder's category picker (per `SKILL.md`) reshapes which validators apply. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Name format | `^[a-z][a-z0-9_]{0,511}$` | Inline error; auto-snake_case the input | -| Name+language permanence | Treat as immutable as a product-governance choice (the Sent v3 `PUT /v3/templates/{id}` accepts these fields, but the UI should still steer tenants to a versioned `_v2` flow for auditability) | Blocked save when editing; show `_v1` suffix nudge for new versions | -| Body length | 1028 chars across all categories | Inline char counter; blocked save above cap | -| Body required | Required for utility/marketing; auth body is fixed by Meta | Blocked save if empty | -| Header type | One of: none / `TEXT` / `IMAGE` / `VIDEO` / `DOCUMENT` | Radio control — invalid combos unreachable | -| Header (text) length | 60 chars, max 1 variable | Inline error | -| Header (media) sample | Sample upload required at submit | Blocked save without a sample asset | -| Footer length | 60 chars, no variables | Inline error; strip `{{` on paste | -| Buttons — mutually exclusive | `QUICK_REPLY` XOR CTAs (`URL` + `PHONE_NUMBER`). Mixing is a Meta reject. | Top-level button-type radio prevents construction; never allow per-button type picks | -| Quick replies | Max 3, 25 char labels | Add button hidden at 3; inline error on label length | -| CTA buttons | Max 2 total, mix of `URL` + `PHONE_NUMBER` allowed | Add button hidden at 2 | -| URL CTA variables | Max 1 trailing variable, must be the URL suffix (`https://example.com/orders/{{1}}`) | Inline error on inline variables | -| Variable placeholders | `{{n}}` ordinal, monotonically increasing from `{{1}}` | Autocomplete next index; inline error on gaps (`{{1}}` then `{{3}}`) | -| Variable samples | Required at submit, non-empty | Sticky sample editor + blocked save until all filled | -| Sample neutrality | Promotional words in samples (off / sale / free / now / discount / deal …) trigger Meta re-categorization | Soft warning on the offending sample field | -| Authentication body | Fixed copy with `{{1}}` for OTP | Body field becomes read-only when category = `AUTHENTICATION` | -| Authentication buttons | Single Copy code / One-tap button + `code_expiration_minutes` | Component editor swaps shape | - -For the category decision tree the picker presents, use `waba-template-author`. Mixed-button policy details remain an external Meta concern — link, do not mirror. - -## RCS - -RCS templates are richer (rich cards, suggested replies, suggested actions) but the channel mandates an SMS-text fallback for any device that can't render RCS. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| SMS fallback body | Required, follows the SMS rules above | Blocked save without fallback; auto-derive from card title+description as a starting point | -| Body length | 1028 chars | Inline counter | -| Header (text) length | 60 chars | Inline counter | -| Rich card title | 200 chars | Inline counter | -| Rich card description | 2000 chars | Inline counter | -| Card media | Image or video; image ≤ 100 KB for "short height", ≤ 2 MB max; video ≤ 100 MB | Blocked save on oversize; soft warning at >50 KB image for short-height layout | -| Card orientation | Vertical or horizontal | Radio; preview swaps layout | -| Suggested replies | Max 11 across the message; 25 chars each | Add button hidden at 11; inline error on label length | -| Suggested actions | Max 11 across the message (shared cap with replies); types include dial, openUrl, viewLocation, shareLocation, createCalendarEvent | Add button hidden when total = 11 | -| Action URL | Must be HTTPS, valid URL | Inline error | -| Carousel cards | 2-10 cards, all same orientation | Blocked save outside range; orientation locked after first card | -| Variable placeholders | Match Sent's RCS template placeholder format — see https://docs.sent.dm | Inline error on mismatched style | -| Fallback parity | Variables in the rich content must also resolve in the SMS fallback | Soft warning if a variable appears only in one | - -For agent-level capability gating that determines whether RCS is an option for a recipient, use `rcs-agent-onboarding`. - -## Cross-channel notes - -- The builder may target *one channel at a time* — do not let tenants compose a "WhatsApp + RCS combined template" in the same form. Channel selection is the first decision after category (where applicable) and locks the validator set. -- All three channels run validation on every keystroke (debounced) so the submit button reflects current state. -- The matrix is the contract between client validation and server validation — keep it codified (JSON schema or equivalent) and re-export to both sides. +# Template validation matrix + +This matrix maps UI controls to the body of `POST /v3/templates`. + +## Request root + +| Field | Client rule | +| --- | --- | +| `definition` | Required object. | +| `category` | Optional `UTILITY`, `MARKETING`, or `AUTHENTICATION`. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional string. | +| `submit_for_review` | Boolean; default to false in the editor. | +| `sandbox` | Boolean; use true for validation. | + +Reject top-level `name`, `channels`, `body`, `header`, `buttons`, and `components`. A `components[]` import is Meta Cloud API source and requires conversion. + +## Definition + +| Field | Rule | +| --- | --- | +| `body.multiChannel` | Required body content. | +| `body.sms`, `body.whatsapp`, `body.rcs` | Optional complete overrides using the same content schema. | +| `header.template` | Optional, maximum 60 characters. | +| `footer.template` | Optional, maximum 60 characters, no variables. | +| Body `template` | Required non-empty text, maximum 1,024 characters. | +| `definitionVersion` | Optional string; preserve when editing. | +| `authenticationConfig` | Only for `AUTHENTICATION`. | + +Every placeholder uses `{{id:type}}`, for example `{{0:variable}}`. Each must map one-to-one to a variable with a non-negative integer `id`, readable `name`, matching `type`, and non-empty `props.sample`. IDs are unique within a body. + +## Buttons + +| Type | Total allowed | Required properties | +| --- | ---: | --- | +| `QUICK_REPLY` | Up to the overall total of 10 | `text`, `quickReplyType` | +| `URL` | 2 | `text`, `urlType`, `url` | +| `VOICE_CALL` | 1 | `text`, `countryCode`, `phoneNumber` | +| `PHONE_NUMBER` | 1 | `text`, `countryCode`, `phoneNumber` | +| `COPY_CODE` | 1 | `text`, `offerCode` | + +There are at most 10 buttons in total, and button text is at most 25 characters. Quick replies and CTA buttons may coexist. + +## Authentication + +`authenticationConfig` accepts `addSecurityRecommendation` and optional `codeExpirationMinutes` from 1 through 90. Authentication templates should contain one code variable and one copy-code action, with no promotion, unrelated media, URL, or call action. + +## Channel-specific product rules + +| Channel | Current UI capability | +| --- | --- | +| SMS | Plain text preview and segment estimate. | +| WhatsApp | Header, body, footer, variables, and supported buttons. | +| RCS | Text and up to four suggestion chips. | + +Do not expose current Sent controls for RCS rich cards, carousels, or media attachments. They are roadmap features. Do not require an SMS fallback body; automatic routing is a send-time choice made by omitting `channel` or using `["sent"]`. + +## Server round trip + +Client validation is advisory. Serialize the exact Sent request, run the bundled linter, validate with `sandbox: true`, and reconcile server errors by field. Never silently rewrite submitted copy. diff --git a/plugins/sent/skills/waba-embedded-signup/SKILL.md b/plugins/sent/skills/waba-embedded-signup/SKILL.md index 3211f12..f1fa1c6 100644 --- a/plugins/sent/skills/waba-embedded-signup/SKILL.md +++ b/plugins/sent/skills/waba-embedded-signup/SKILL.md @@ -1,168 +1,124 @@ --- name: waba-embedded-signup -description: Guides WhatsApp Business Account connection for Sent Sender Profiles, including Embedded Signup planning, WABA and phone-number mapping, token/security handling, webhook readiness, and profile completion. Use when a user says Embedded Signup, WABA, connect WhatsApp, WhatsApp sender, phone number ID, Facebook Login for Business, Meta Business, sender profile WhatsApp setup, or webhooks not firing after WhatsApp signup. +description: Guides WhatsApp Business Account onboarding through Sent, separating dashboard Embedded Signup, organization WABA inheritance, and direct child-profile credentials. Use for WABA connection, Meta signup, profile creation, access-token handling, phone number mapping, completion callbacks, or WhatsApp onboarding failures. --- - +Keep three integration paths distinct. Calling all of them “Embedded Signup” creates wrong API designs and unsafe credential handling. -# WABA Embedded Signup +## The three paths -## Overview +| Path | Where it starts | Profile behavior | +| --- | --- | --- | +| Organization Embedded Signup | Sent dashboard | Connects the organization's WABA through the hosted Meta flow. There is no public Sent endpoint that starts this flow. | +| Organization WABA inheritance | `POST /v3/profiles` | Omit `whatsapp_business_account`; the child inherits the organization's connected WABA. | +| Dedicated child-profile WABA | `POST /v3/profiles` | Supply `whatsapp_business_account.waba_id` and `.access_token`; `phone_number_id` is optional. | -Use this skill to connect a WhatsApp Business Account (WABA) and phone number to a Sent Sender Profile without confusing Sent-side setup with Meta-side implementation details. Sent’s dashboard exposes Sender Profiles and WhatsApp configuration status. Sent’s profile API exposes profile CRUD and a profile-completion workflow. The public Sent sources reviewed for this rewrite did not expose a dedicated Embedded Signup endpoint, so direct Meta Graph flows should be treated as external integration context unless the user confirms that their application owns that flow. +If credentials are omitted and the organization has no connected WABA, profile creation returns `422`. Direct WABA credentials are a profile-creation feature, not a public “Embedded Signup endpoint.” -The safest workflow is to decide the integration path first: Sent-managed WhatsApp setup, customer-managed Meta Embedded Signup connected back to Sent, or a hybrid implementation coordinated with Sent. +## Authentication -## When to use +Use either: -Use this skill when the user mentions Embedded Signup, WABA, WhatsApp Business Account, WhatsApp phone number, phone number ID, Facebook Login for Business, Meta Business Manager, connecting WhatsApp to Sent, WhatsApp sender setup, Sender Profile WhatsApp status, or webhook delivery after WhatsApp onboarding. +- a profile-specific key in `x-api-key`; or +- an organization key in `x-api-key` plus `x-profile-id` when operating for an existing child profile. -Do not use this skill to author WhatsApp templates; use `waba-template-author`. Do not use it to build a generic Meta app unless the user explicitly asks for a Meta-side implementation. Do not claim Sent exposes Embedded Signup endpoints unless the account/docs confirm them. +Only organization keys may use `x-profile-id`; profile keys receive `403`. `x-sender-id` is legacy v1/v2 terminology. -## Process +## Path A: organization Embedded Signup -### 1. Decide the integration path first +1. An authorized organization administrator opens the Sent dashboard WhatsApp connection flow. +2. The hosted Meta Embedded Signup UI collects the Meta authorization and WABA/number choices. +3. Confirm the organization shows a connected WABA before creating inheriting children. +4. Record non-secret identifiers and audit who completed the action. -Start every session by asking which path applies. The answer changes what the agent should do next. +Do not invent a `POST /embedded-signup` or token-exchange endpoint in Sent's public API. If building your own Meta Tech Provider integration outside the Sent dashboard, follow Meta's current documentation and keep that system separate from the Sent API contract. -| Path | Use when | Agent role | -|---|---|---| -| Sent-managed setup | The customer wants Sent to guide or operate WhatsApp connection. | Prepare Sender Profile, business evidence, phone-number details, and handoff notes. | -| Customer-managed Embedded Signup | The customer’s app launches Meta Embedded Signup and passes results to Sent. | Review Meta-side security and mapping, then align results to Sent profile completion. | -| Migration/import | The customer already has a WABA/phone number and needs it represented in Sent. | Collect WABA/phone identifiers, ownership evidence, and profile mapping. | +Meta's browser `postMessage` events use an `event` field and nested data/session information. Do not rewrite them as Sent webhook `sub_type` envelopes. -If the user cannot answer, default to Sent-managed setup and avoid prescribing Graph API calls. +## Path B: inherit the organization WABA -### 2. Identify the Sender Profile +Omit `whatsapp_business_account`: -Locate or create the Sender Profile that will own the WhatsApp sender identity. Use the Sent dashboard or `/v3/profiles`. Record the Sent profile ID, `x-sender-id` if visible, display name, brand description, and intended WhatsApp phone number. - -A WhatsApp number should map to the same recipient-visible brand represented by the profile. If the number belongs to a different brand, department, or tenant, use `sender-profile-architect` before proceeding. - -### 3. Collect WhatsApp onboarding evidence - -Collect the minimum evidence needed for Sent or Meta review. - -| Evidence | Why it matters | -|---|---| -| Business legal name and Meta Business identity | Confirms the WABA belongs to the intended sender. | -| Public website and privacy policy | Supports business verification and template review. | -| Phone number and ownership/control evidence | Prevents connecting the wrong sender. | -| Display name | Must match the business identity recipients expect. | -| Use cases and example messages | Drives template authoring and policy review. | -| Webhook endpoint and owner | Needed to verify event delivery after connection. | - -### 4. Map external identifiers without making them the Sent contract - -If the user provides WABA ID, phone-number ID, Meta Business ID, or System User details, store them as external provider identifiers mapped to the Sent profile. Do not make those IDs the primary application sender key. Use Sent profile ID and Sent message IDs for Sent operations. - -**Example mapping.** - -```text -sent_profile_id: 2b1b... -x_sender_id: support_us -channel: whatsapp -provider: meta -provider_business_id: external value, if available -provider_waba_id: external value, if available -provider_phone_number_id: external value, if available -status_source: Sent dashboard/API or Meta integration logs +```json +{ + "name": "Tenant Support", + "description": "Synthetic child profile", + "short_name": "SUPPORT", + "inherit_templates": true, + "billing_model": "organization", + "sandbox": true +} ``` -### 5. Complete or re-check the Sent profile - -Use `/v3/profiles/{profileId}/complete` when prerequisites are ready and API completion is in scope. The OpenAPI describes profile completion as a background process that validates prerequisites and connects profile configuration. If completion returns missing prerequisites, fix those inputs rather than creating duplicate profiles. - -### 6. Verify webhook readiness - -Use Sent webhook endpoints to confirm event delivery. Verify the webhook exists, the relevant event types are available, and a test event reaches the customer endpoint via `POST /v3/webhooks/{id}/test`. +Use this only after the organization WABA is connected. Inheritance means the tenant shares that WABA boundary; confirm this matches the tenant/brand architecture. -Rotate webhook secrets only when needed and coordinate deployment, because secret rotation invalidates the old secret immediately. +## Path C: dedicated WABA credentials -## Meta-side implementation review - -Use this section only when the user confirms that their application owns Embedded Signup. Label the work as Meta-side. Validate security, mapping, and callback handling before connecting results to Sent. - -| Area | Check | -|---|---| -| Launch context | Embedded Signup is launched from the right app, business, and allowed origin. | -| Callback handling | The app captures the signup result, not just a UI success state. | -| Token exchange | Authorization codes/tokens are exchanged server-side, never in public frontend storage. | -| Scope verification | Returned permissions/granular scopes match the required WABA and phone-number access. | -| Identifier lookup | WABA ID and phone-number ID are read back and mapped to the Sent profile. | -| Phone registration | Registration is completed only if the integration path requires the customer app to do it. | -| App subscription | Webhook subscription is completed only if the integration path requires direct Meta callbacks. | -| Secret handling | Tokens and webhook secrets are encrypted, rotated, and not logged. | - -Do not assume the customer app must call every Meta endpoint. Sent may abstract parts of onboarding depending on the customer’s setup. - -## Troubleshooting patterns - -| Symptom | First check | Likely next step | -|---|---|---| -| Sender Profile still shows WhatsApp not configured | Sent profile prerequisites and completion status | Confirm whether Sent-managed setup or external Embedded Signup results were expected. | -| User completed Meta flow but Sent cannot send | Mapping between external phone/WABA and Sent profile | Provide identifiers/evidence to Sent or update integration mapping. | -| Templates remain unavailable | Template status and WhatsApp business review | Use `waba-template-author` and Sent template status. | -| Webhooks not firing | Sent webhook test and event history | Fix endpoint/subscription before blaming WhatsApp delivery. | -| Wrong tenant receives events | Profile/message ID mapping | Use `sender-profile-architect` to redesign routing. | - -## Common rationalizations to avoid - -Do not treat a Meta UI success screen as proof that Sent can send WhatsApp messages. Verify Sent profile/channel status and test sends. - -Do not store access tokens in browser storage or logs. Treat Meta tokens and Sent webhook secrets as production credentials. +```json +{ + "name": "Dedicated Tenant", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -Do not assume a WABA can be reused across unrelated brands or tenants. Recipient-visible identity and operational ownership matter. +`waba_id` and `access_token` are required. `phone_number_id` is optional: when omitted, the current contract describes provisioning and registration during onboarding. -Do not hardcode Graph API version, scope names, or endpoint sequences in this skill body. Keep those in a reference file and re-check Meta docs or Sent implementation guidance before use. +The token needs the applicable WhatsApp Business messaging and management permissions. Inject it from a secret manager. Never log it, echo it, write it to fixtures, return it to the browser, include it in support output, or retain it in general profile storage. Sent does not return it in API responses. -Do not rotate Sent webhook secrets without coordinating the receiving endpoint. +## Complete the profile -## Verification checklist +Call `POST /v3/profiles/{profileId}/complete` with the required `webHookUrl`: -- [ ] The integration path is identified as Sent-managed, customer-managed Embedded Signup, or migration/import. -- [ ] The correct Sent Sender Profile is identified before external identifiers are mapped. -- [ ] WhatsApp business identity, phone-number evidence, display name, and use cases are collected. -- [ ] WABA/phone-number IDs are stored as external identifiers, not as the primary Sent sender key. -- [ ] Profile completion is run or checked after prerequisites are ready. -- [ ] Sent webhook existence, event types, event history, and test delivery are verified. -- [ ] Meta Graph steps are only prescribed when the user confirms direct ownership of Embedded Signup. -- [ ] Tokens, webhook secrets, and callback data are handled server-side and securely. +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -## Related skills +- `202` means background processing started; there is no final status in that response. +- `200` can mean the profile was already complete and currently demonstrates lowercase `completed`. +- The completion callback can report `COMPLETED`, `SUBMITTED`, or `failed`. -Use `sender-profile-architect` when deciding whether a WABA or phone number belongs in a separate Sender Profile. +Treat the completion callback as its own integration surface. Its envelope uses `event`, not `sub_type`: -Use `waba-template-author` when the WhatsApp sender needs templates written, categorized, submitted, or revised. +```json +{ + "event": "COMPLETED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" +} +``` -Use `template-builder-ui` when building the UI that imports or manages WhatsApp templates inside Sent. +Preserve unknown event strings. Verify authenticity using the mechanism Sent documents for the callback endpoint and make processing idempotent. -Use `messaging-performance-analyzer` when WhatsApp sends are connected but delivery/read/webhook outcomes are poor. +## Verify operational readiness -Use the `sent` skill for shared Sent terminology and routing. +- Profile WABA ID matches the intended business. +- Selected number is mapped to the intended profile. +- Template sharing/inheritance is intentional. +- A test template can be created with `sandbox: true`. +- The completion callback is reachable and idempotent. +- Returned message IDs are stored against the tenant/profile before webhook processing. +- Tokens and payment values are absent from logs. -## Suggested bundled references and scripts +For ordinary message and template webhooks, follow Sent's current events reference; those are separate from Meta browser events and profile-completion callbacks. -| File | Type | Purpose | -|---|---|---| -| `references/waba-embedded-signup-spec.md` | External platform reference | Keep Meta launch, token exchange, WABA lookup, phone registration, and subscription details out of the skill body. | -| `references/whatsapp-sender-profile-mapping.md` | Schema reference | Define how Sent profile IDs map to WABA IDs, phone-number IDs, display names, and status evidence. | -| `references/waba-onboarding-runbook.md` | Worked example | Show Sent-managed and customer-managed onboarding examples end-to-end. | +## Failure routing -## Unverified claims to confirm or remove +| Failure | Next action | +| --- | --- | +| `422` when credentials are omitted | Connect the organization WABA or provide dedicated credentials. | +| `403` with profile key and `x-profile-id` | Remove `x-profile-id` or use an authorized organization key. | +| Wrong WABA/number | Stop before completion and correct the profile mapping. | +| Expired/under-scoped token | Replace it securely; never print it while diagnosing. | +| Completion remains submitted | Inspect prerequisite and callback evidence; do not assume final failure from the `202`. | -- Sent does not expose a public Embedded Signup endpoint; WhatsApp connection is dashboard-only via Channels → WhatsApp (confirmed against Sent v3 docs snapshot, 2026-05-19; the Channels page is explicitly listed as "dashboard config; not directly in v3 API"). -- Required Meta app type, Tech Provider/Solution Partner status, granular scopes, and Graph endpoint sequence are external Meta claims, not Sent API facts. -- Mandatory direct phone-number registration or WABA subscription by the customer app depends on integration path and was not verified as a universal Sent requirement. -- Sent's webhook envelope is confirmed as `{field, sub_type, timestamp, payload}` with sub-types of the form `.` (e.g., `message.delivered`). WhatsApp-specific sub-types are not enumerated in the snapshot — discover them empirically against your account. +Use [references/waba-embedded-signup-spec.md](references/waba-embedded-signup-spec.md), [references/waba-onboarding-runbook.md](references/waba-onboarding-runbook.md), and [references/whatsapp-sender-profile-mapping.md](references/whatsapp-sender-profile-mapping.md). Use `sender-profile-architect` for tenant boundaries and `waba-template-author` for the first template. diff --git a/plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md b/plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md index 9d6f675..f032b9b 100644 --- a/plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md +++ b/plugins/sent/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md @@ -1,89 +1,48 @@ -# WABA Embedded Signup — Implementation Reference +# WABA integration specification -Supporting reference for `waba-embedded-signup`. The Sent v3 docs snapshot (2026-05-19) confirms that **Sent does not expose a public Embedded Signup API endpoint**. The customer-facing surface for connecting WhatsApp to Sent is the **dashboard's Channels → WhatsApp tab**, which is explicitly listed in Sent's "Dashboard pages → API endpoints map" as `(dashboard config; not directly in v3 API)`. The dashboard internally initiates Meta's Facebook Login for Business / Embedded Signup flow on the tenant's behalf. +## Capability boundaries -What this means for an integrator: +- Organization Embedded Signup is launched in the Sent dashboard. +- No public Sent endpoint starts that hosted signup. +- `POST /v3/profiles` can inherit the organization's WABA or accept a dedicated WABA. +- Direct profile credentials are not an Embedded Signup endpoint. -- **You do not call a Sent endpoint to start Embedded Signup.** You direct the tenant to their Sent dashboard. -- The Meta-side authentication, token exchange, WABA discovery, phone-number registration, app subscription, and app review state are owned by **Meta** and abstracted by the Sent dashboard. They are not surfaced as Sent API operations. -- After dashboard completion, the WhatsApp wiring is bound to the tenant's Sender Profile and routable via Sent's normal v3 API (`POST /v3/messages`, etc.). +## Dedicated credentials -Anything below this line is **external Meta documentation context** — included only so an operator debugging a stuck dashboard flow knows what is happening behind the scenes. Authoritative source: Meta — [Embedded Signup](https://developers.facebook.com/docs/whatsapp/embedded-signup), [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). Meta bumps the Graph API version regularly — always check the live Meta docs for the current version, scope names, and field names. +```text +whatsapp_business_account + waba_id required string + access_token required secret string + phone_number_id optional string +``` -## Sent-side surface (what the API does and doesn't expose) +Omitting `phone_number_id` invokes the current provisioning behavior documented by the profile contract. The token is write-only operational input and must never appear in API output, logs, fixtures, traces, analytics, or support text. -| Concern | Where it lives | -|---|---| -| Start Embedded Signup | Dashboard → Channels → WhatsApp → "Connect" (no public Sent API) | -| WABA / phone-number binding | Dashboard (not in v3 API) | -| Mark profile setup complete | `POST /v3/profiles/{id}/complete` (idempotent, sensitive endpoint — 10/min, burst 5) | -| Profile status after binding | `GET /v3/profiles/{id}` → `status` ∈ `incomplete` \| `pending_review` \| `approved` \| `rejected` | -| Webhook config | `POST /v3/webhooks`, `PUT /v3/webhooks/{id}`, `POST /v3/webhooks/{id}/test`, `POST /v3/webhooks/{id}/rotate-secret` (sensitive — 10/min, burst 5) | -| Auth header | `x-api-key: ` — single header, account-scoped. No `x-sender-id` in v3. | +## Inheritance -## Customer-facing dashboard flow (what the tenant sees) +Omit the entire `whatsapp_business_account` object to inherit. This succeeds only if the organization has completed Embedded Signup; otherwise expect `422`. -This mirrors the live flow on the dashboard's Channels page; it is what a tenant should be guided through, not an API sequence: +## Auth matrix -1. Dashboard → **Channels** → **WhatsApp** tab → click **Connect**. -2. Meta consent popup opens (Facebook Login for Business surface, initiated by Sent). -3. Tenant selects (or creates) a **WABA** under their Meta Business Portfolio. -4. Tenant grants Sent permission to **manage WhatsApp messages and templates** on that WABA. -5. Tenant adds a **Meta payment method** (separate from Sent billing — Meta charges per-conversation independently). -6. Dashboard reflects channel setup completion; the WhatsApp wiring is bound to the tenant's Sender Profile. -7. API credentials (the `x-api-key`) can be copied from the post-setup screen or retrieved later from the dashboard's API Keys page. +| Key | `x-profile-id` | Result | +| --- | --- | --- | +| Profile key | Omitted | Profile-scoped operation. | +| Profile key | Present | `403`. | +| Organization key | Valid child UUID | Child-scoped operation; organization rate-limit pool. | +| Organization key | Unowned UUID | `404`. | -The runbook (`references/waba-onboarding-runbook.md`) walks through this end-to-end with failure modes and recovery steps. +`x-sender-id` belongs to legacy v1/v2 guidance. -## Webhook envelope (Sent-confirmed) +## Profile completion -After WhatsApp is connected, Sent emits webhooks for that profile's messages using the universal envelope: +The request requires `webHookUrl`. A `202` only confirms processing began. A `200` can report an already-complete profile. Completion callback event values include `COMPLETED`, `SUBMITTED`, and `failed`; do not treat that vocabulary as the REST profile enum. ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "whatsapp", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "event": "SUBMITTED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" } ``` -Sub-types follow `.` (`message.queued`, `message.routed`, `message.sent`, `message.delivered`, `message.failed`, and on WhatsApp/RCS only, `message.read`). - -WhatsApp-specific sub-types beyond the universal `message.*` family (e.g., template approval/rejection notifications) are not enumerated in the v3 snapshot. To discover what your account currently subscribes to: - -1. List configured webhooks: `GET /v3/webhooks`. -2. Inspect a single webhook's `event_types` and `event_filters` fields. -3. Subscribe broadly to the `message` parent type and observe what arrives in production — fold the observed sub-types into your routing. - -## Webhook signature verification - -The webhook model (verified) exposes `signing_secret` as a per-webhook field; the exact HMAC algorithm and header name are not specified in the snapshot. Rotate via `POST /v3/webhooks/{id}/rotate-secret` — the old secret is invalidated immediately, so coordinate with the receiver before rotating. - -## Meta-side context (for operators only — link, do not reimplement) - -When a dashboard tenant is stuck and you need to know what the dashboard is doing on their behalf, the underlying Meta flow looks like this — read Meta's docs for current details: - -- Meta app type, Tech Provider / Solution Partner status, granular scopes, Graph version, redirect URI allowlisting → [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- OAuth code → System User token exchange → [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). -- WABA / phone-number lookup, phone-number registration with PIN, app subscription to WABA → [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). -- App review state, business verification, payment method, quality rating → Meta Business Suite UI. - -If a tenant is genuinely operating their own Meta app (not using the Sent-managed dashboard flow), they own all of the above and should be referred to Meta's docs directly. Sent's API does not replace that. - -## What is not in the v3 docs snapshot - -- The exact shape of the request body for `POST /v3/profiles/{id}/complete` for WhatsApp wiring (the snapshot confirms the endpoint exists and is sensitive; the per-channel payload is not published). -- The webhook signature algorithm / header used to verify Sent → receiver deliveries. -- The mapping shape between Sender Profile and the WABA / phone-number IDs the dashboard binds to it. -- WhatsApp-specific webhook sub-types (e.g., template lifecycle events). - -Treat each of these as "discover via your account" rather than "code to a spec". +Completion callbacks use `event`, not `sub_type`. Meta Embedded Signup browser messages also use an `event` field but have a different producer and payload. Keep the two handlers distinct. diff --git a/plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md b/plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md index bdb4098..d12cf88 100644 --- a/plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md +++ b/plugins/sent/skills/waba-embedded-signup/references/waba-onboarding-runbook.md @@ -1,120 +1,50 @@ -# WABA Onboarding Runbook — Dashboard Flow +# WABA onboarding runbook -Operator-facing companion to `waba-embedded-signup`. Walks the **Sent dashboard** WhatsApp connection flow in time order and tells you, for each step, what success looks like, the failure modes you should expect, and how to recover **without** restarting the whole signup. +## 1. Choose the path -The v3 snapshot confirms there is **no public Sent Embedded Signup API endpoint** — the Channels page in the dashboard is the surface, and it initiates Meta's Facebook Login for Business flow internally. This runbook reflects that reality; for the broader skill workflow and integration-path decision, see `waba-embedded-signup` SKILL.md. +- Organization needs its first WABA: use dashboard Embedded Signup. +- Child shares the organization WABA: create a profile and omit credentials. +- Child owns a dedicated WABA: create a profile with `waba_id` and `access_token`; optionally include `phone_number_id`. -## 0. Pre-flight (per-tenant gates) +Record why the choice matches brand, tenant, compliance, and blast-radius requirements. -Before the "Continue Channel Setup" button is meaningful for a tenant, two account-level gates must be true: +## 2. Prepare access -- [ ] **KYC approved.** Per the verified onboarding state machine, the account must have reached `KYC_COMPLETED` (state 5+). Before that, the v3 API returns `AUTH_006` and the dashboard blocks the Channels page. Compliance form fields (business identity, use cases, opt-in evidence) come from the dashboard's KYC + compliance pages. -- [ ] **Meta Business Portfolio ready.** The tenant must already have (or create during the flow) a Meta Business Portfolio under which a WABA will be selected or created. Sent does not provision this on the tenant's behalf. +Use a profile key alone or an organization key with `x-profile-id` for existing-child operations. Do not use `x-profile-id` with a profile key. Remove legacy `x-sender-id` examples. -If KYC is still in `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`, finish that first. The dashboard's onboarding checklist surfaces the next required step. +## 3. Handle secrets -## 1. Click "Continue Channel Setup" in the dashboard +- Receive tokens only through a protected server-side path. +- Store them in a secret manager if your system must retain them. +- Redact request bodies before logging. +- Never send tokens back to the frontend. +- Do not include tokens in screenshots, fixtures, errors, or support tickets. -After KYC, the dashboard surfaces a **Continue Channel Setup** entry that lands on the **Channels** page. +## 4. Create or update the profile -**Success looks like:** The Channels page loads and shows a **WhatsApp** tab with a **Connect** action. +Use `sandbox: true` first. On a dedicated WABA path, confirm that the returned non-secret WABA and number identifiers match intent. On inheritance, treat `422` as evidence the organization WABA prerequisite is absent. -**Common failure modes:** -- Button is missing / disabled → account state hasn't reached `KYC_COMPLETED`. Finish KYC first. -- API returns `AUTH_007` against `/v3/messages` for a tenant who thinks they're set up → they're at `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED` but haven't completed channel setup. They need to land on this page. +## 5. Complete -**Recovery without restarting:** Re-check `GET /v3/me` or the dashboard's onboarding indicator. Channel setup itself has no API; route the tenant back to the dashboard. +Send `webHookUrl` to `/v3/profiles/{profileId}/complete`. Persist the request ID and profile ID. A `202` is not final approval. -## 2. Select the phone number +The callback handler: -In the Channels → WhatsApp flow, the tenant selects the phone number that will be used for the WABA's first sender. Sent docs note that **this selection is not easy to change later** — once a phone number is bound to a Sender Profile, swapping it requires Meta-side migration plus a dashboard re-bind. +1. verifies the callback; +2. reads top-level `event`; +3. deduplicates by profile/event and delivery identity when available; +4. preserves unknown event strings; +5. records `COMPLETED`, `SUBMITTED`, or `failed` without coercing REST status. -**Success looks like:** The phone number is captured and the flow advances to Meta login. The number should be an E.164 line the tenant controls, not currently registered to another WABA they care about. +## 6. Smoke test -**Common failure modes:** -- Tenant picks a number that's already on a WABA they intend to keep separate → after Meta consent they'll discover the number is "in use elsewhere" and have to detach in WhatsApp Manager. -- Tenant picks a personal line they later want back for WhatsApp Consumer → that's a one-way door; warn upfront. +- Create a synthetic draft template with the Sent `definition` request shape. +- Validate with `sandbox: true`. +- Submit only after explicit review. +- Send to a controlled recipient. +- Persist the returned `message_id` with tenant/profile attribution. +- Verify the normal Sent message webhook separately from the completion callback. -**Recovery without restarting:** Within the same flow you can usually back out and pick a different number. After completion, switching numbers requires Meta-side migration and a fresh dashboard binding. +## 7. Rollback -## 3. Log in with Facebook/Meta and grant Sent permission - -The dashboard launches Meta's Embedded Signup popup (Facebook Login for Business). The tenant: - -- Logs in with their Meta account that admins the Business Portfolio. -- Selects (or creates) the **WABA** to bind. -- Grants Sent permission to **manage messages and templates** on that WABA. - -**Success looks like:** The popup closes with success; the dashboard reflects the connected WABA name. - -**Common failure modes:** -- Popup closes immediately → ad-blocker or popup-blocker. Disable for the Sent dashboard origin. -- Tenant chose "Create a new WhatsApp Business Account" inside the dialog and got stuck on business verification → not a Sent issue; tenant must finish verification in Meta Business Suite, then return. -- Tenant unchecked WhatsApp permissions in the consent screen → the binding will fail or be unusable. Re-launch Connect and accept all required permissions. - -**Recovery without restarting:** Re-launch Connect from the dashboard. The tenant only re-confirms permissions; previously-captured fields (like the chosen phone number) typically persist. - -## 4. Add Meta payment method - -Meta charges per-conversation for WhatsApp Business messaging, separately from Sent's billing. The tenant must add a payment method to the WABA in WhatsApp Manager / Meta Business Suite. - -**Success looks like:** Payment method status is "Active" in WhatsApp Manager. Sent's dashboard may surface a "Meta payment required" warning until this is true. - -**Common failure modes:** -- Card declines → tenant retries with a different card in Meta Business Suite. -- Tenant conflates this with Sent billing → clarify: Sent bills Sent fees; Meta bills WhatsApp conversation fees directly to the WABA. - -**Recovery without restarting:** Meta payment is set on the WABA, independent of the Sent flow — the tenant can complete this without re-doing steps 1–3. - -## 5. Confirm channel setup completion in dashboard - -After the WABA binding and Meta payment are in place, the dashboard reflects channel setup as complete. Internally, the account state should advance to `MESSAGE_COMPLIANCE_COMPLETED` and then to activated. The API surface that signals "I am done" is `POST /v3/profiles/{id}/complete` (idempotent, sensitive — 10/min, burst 5). Inspect `GET /v3/profiles/{id}` and look for `status` ∈ `pending_review` → `approved`. - -**Success looks like:** Profile `status` reaches `approved`; the dashboard shows the WhatsApp channel as connected. - -**Common failure modes:** -- Profile stays `pending_review` → Sent-side review is still running. Surface the status to the tenant; do not retry `complete` in a loop (rate-limited). -- Profile lands at `rejected` → KYC or compliance evidence was insufficient; the dashboard explains the reason. Fix in KYC + re-run. -- API returns `AUTH_005` against sends → the account state is at step 6 waiting for final Sent-side activation. No tenant action needed; wait. - -**Recovery without restarting:** `POST /v3/profiles/{id}/complete` is idempotent — calling again with the same input is safe. Do not delete and re-create the profile to "reset" status. - -## 6. Copy API credentials - -Once `status = approved`, API credentials are available: - -- On the post-setup screen, or -- Anytime from the dashboard's **API Keys** page (the snapshot lists this as `(dashboard-only; not in v3 API spec)` — there is no API to mint or list keys). - -Auth in v3 is a single header: `x-api-key: `. There is no `x-sender-id` in v3 — that's v2 legacy. The key is account-scoped. - -**Success looks like:** A test request to `GET /v3/me` with the key returns 200. - -**Common failure modes:** -- `AUTH_001` (401, missing header) → header name wrong; must be `x-api-key`. -- `AUTH_002` (401, invalid key) → key was rotated or copied with whitespace. -- `AUTH_007` (403, no channel configured) → key is valid but the account is at `KYC_COMPLETED` / `MESSAGE_COMPLIANCE_COMPLETED` without a finished channel. Re-check step 5. -- `AUTH_005` (403, pending final activation) → wait for Sent activation; not a credential problem. - -**Recovery without restarting:** Re-copy the key from the dashboard. Treat the key as a secret — never log it. Use the sandbox mode (`"sandbox": true` in mutation request bodies) for integration tests so you don't burn budget. - -## Stuck-state triage cheat-sheet - -| Symptom in production | Step | First thing to check | -|---|---|---| -| "Continue Channel Setup" missing | 0–1 | Account state — finish KYC first | -| Channels page rejects the chosen number | 2 | Number already on another WABA | -| Meta popup closes immediately | 3 | Popup/ad-blocker on dashboard origin | -| Popup completes but dashboard shows "not connected" | 3 | Tenant unchecked permissions; re-launch Connect | -| Dashboard shows "Meta payment required" | 4 | Add payment in WhatsApp Manager | -| Profile stuck `pending_review` | 5 | Sent-side review; do not re-POST `complete` in a loop | -| API send returns `AUTH_007` | 5 | Channel setup not actually complete | -| API send returns `AUTH_005` | 5 | Final Sent activation pending; no action | -| `x-api-key` returns `AUTH_002` | 6 | Re-copy from dashboard; check whitespace | - -## What this runbook deliberately does not cover - -- Customer apps that own their **own** Meta App and run Embedded Signup themselves (rather than using the Sent-managed dashboard flow). That path is owned by Meta — see `references/waba-embedded-signup-spec.md` and Meta's [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- Template authoring and submission — see `waba-template-author`. -- Multi-tenant Sender Profile design — see `sender-profile-architect`. -- Post-connection delivery debugging — see `messaging-performance-analyzer`. +If the WABA or number is wrong, stop new sends, revoke exposed credentials, correct profile mapping, and retain audit evidence. Avoid deleting a profile until number ownership and message retention are resolved. diff --git a/plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md b/plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md index bb74cb0..1102d4b 100644 --- a/plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md +++ b/plugins/sent/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md @@ -1,103 +1,40 @@ -# WhatsApp ↔ Sent Sender Profile Mapping - -How Meta-side entities created during Embedded Signup map onto Sent's Sender Profile model. Read this before deciding how many profiles to create per tenant, or when debugging why a webhook landed on the wrong profile. - -For the broader multi-channel architecture (one profile owns SMS + WhatsApp + RCS halves; how to split tenants across profiles), see `sender-profile-architect`. - -## The entities - -**Meta side:** -- **Business Manager / Business Portfolio** — the tenant's legal/operational umbrella in Meta Business Suite. -- **WABA (WhatsApp Business Account)** — owns templates and phone numbers; the unit Meta bills. -- **Phone Number** — a single E.164 number registered for Cloud API on a WABA. -- **System User** — long-lived identity holding the access token used to call Graph API on behalf of the tenant. - -**Sent side (v3 — schema verified against snapshot):** - -A Sender Profile is: - -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | The Sent-side primary key. | -| `name` | string | Display name. | -| `icon` | string \| null | URL. | -| `description` | string \| null | | -| `short_name` | string \| null | | -| `role` | `admin` \| `billing` \| `developer` \| null | Caller's role on this profile. | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status. | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | - -There is **no public `channels.whatsapp` sub-resource** on the Profile in the v3 docs snapshot. Per-channel WhatsApp wiring (WABA ID, phone-number ID) is performed via the dashboard Channels page, which is explicitly listed as "dashboard config; not directly in v3 API". Treat WABA and phone-number IDs as external provider identifiers that the dashboard binds to the profile; do not invent v3 endpoints to mutate that binding. - -Auth in v3 is a single header — `x-api-key: ` — at the account level. `x-sender-id` is **v2 legacy** and is exposed per profile in the dashboard for routing, not as a v3 API auth requirement. - -## ASCII map - -``` -Tenant - │ - ├── Business Manager (1) - │ │ - │ ├── WABA #A ────────────────────────► Sender Profile P1 (id, status=approved) - │ │ ├── Phone +1 555 0100 ◄────────┤ (dashboard-bound) - │ │ ├── Phone +1 555 0101 ◄──┐ │ - │ │ └── Templates │ │ - │ │ │ │ - │ │ └────► Sender Profile P2 - │ │ │ (different phone, same WABA) - │ │ - │ └── WABA #B ────────────────────────► Sender Profile P3 - │ └── Phone +44 20 7946 0000 ◄────┤ - │ - └── System User (1) ──► token held in vault, referenced by all of P1/P2/P3 -``` - -## Cardinality rules (operational, not enforced by v3 API) - -| Relationship | Cardinality | Notes | -|---|---|---| -| Business Manager → WABA | 1 : N | A tenant may operate multiple WABAs (per region or brand). | -| WABA → Phone Number | 1 : N | Up to 25 per WABA per Meta's current limits. | -| Phone Number → Sender Profile | 1 : 1 | **Hard rule.** Each phone number routes to exactly one profile; sharing breaks inbound routing. | -| WABA → Sender Profile | 1 : N | Multiple profiles may bind to the same WABA, each pinning a different phone number. | -| System User → WABA | 1 : N | One System User token can hold scopes for many WABAs. | -| Sender Profile → WhatsApp wiring | 0 : 1 | A profile has at most one WhatsApp binding. SMS / RCS bindings are independent. | - -## What `POST /v3/profiles/{id}/complete` actually does - -`POST /v3/profiles/{id}/complete` is confirmed in the v3 snapshot as the profile-completion endpoint. It supports `Idempotency-Key` and is classified as a sensitive endpoint (10 req/min, burst 5). It transitions the profile out of `incomplete` once prerequisites are met. - -The exact request/response shape for the completion call (which fields must be present, what gets persisted) is **not enumerated in the v3 snapshot**. Treat the completion call as a commit: prerequisites (KYC + channel config done via the dashboard) must already be true; the endpoint signals "I am ready". Check the live OpenAPI at [docs.sent.dm](https://docs.sent.dm) before wiring a tenant-facing integration. - -## Routing inbound events back to a profile - -Sent's webhook envelope (verified) is: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { "account_id": "...", "message_id": "...", "channel": "whatsapp", "inbound_number": "+1...", "outbound_number": "+1...", "template_id": "..." } -} +# WhatsApp Sender Profile mapping + +## Recommended records + +```text +tenant_id +sent_profile_id +waba_mode # organization_inherited | dedicated +waba_id +whatsapp_phone_number_id +whatsapp_phone_number +profile_key_secret_ref +status_raw +status_surface ``` -For WhatsApp inbound, the payload carries `account_id`, `message_id`, and the inbound/outbound E.164 numbers. WhatsApp-specific sub-types beyond the generic `message.*` family (e.g., template-status events) are not enumerated in the snapshot — discover them empirically against your account by subscribing broadly and observing what arrives. +Do not store `access_token` in this record. Use a secret-manager reference when retention is necessary. -## Detaching a WABA without losing message history +## Mapping invariants -There is no v3 API endpoint documented for detaching a WhatsApp binding from a profile. The Channels page in the dashboard is the surface. Operationally: +- A dedicated WABA profile has a recorded `waba_id` matching the intended tenant. +- An inherited profile explicitly records that it shares the organization WABA. +- A WhatsApp number maps to one current tenant/profile route unless the product has a documented coexistence model. +- `message_id` is persisted with tenant and profile before webhook events arrive. +- Unknown REST or completion callback statuses are stored verbatim with their surface. -1. Stop sending on the profile. -2. Wait for in-flight deliveries to settle (delivery webhooks drain within ~24h for normal traffic, longer for slower carriers). -3. Use the dashboard Channels page to remove the WhatsApp binding. -4. On the Meta side, unsubscribe your Tech Provider app from the WABA via Graph API if you held the subscription directly. +## Auth ownership -Historical MDRs remain queryable by `message_id` — message history is not deleted when the binding is removed. +Profile keys minimize tenant credential blast radius. Organization keys with `x-profile-id` centralize control but share the organization rate-limit pool and expand credential impact. Never expose the organization key to tenant code. -## Migrating a phone number between WABAs +## Event surfaces -Meta supports moving a phone number between WABAs and the phone-number ID is stable across the move. On Sent's side, the dashboard Channels page is the supported surface to re-bind. Since the v3 docs do not publish the channel-config mutation endpoint, do not encode a `PATCH /v3/profiles/{id}/channels/whatsapp` call in client integrations — operate via the dashboard until the API is published. +| Surface | Discriminator | +| --- | --- | +| Meta Embedded Signup browser message | `event` plus Meta session/data fields | +| Sent profile completion callback | top-level `event` | +| Sent message webhook | `field: "message"` plus `sub_type` | +| Sent template webhook | `field: "templates"`, no `sub_type` | -Templates are WABA-scoped and do **not** transfer with the phone number — re-author or re-import on the new WABA. +Do not copy envelopes between these integrations. They have different producers, authenticity checks, and retry behavior. diff --git a/plugins/sent/skills/waba-template-author/SKILL.md b/plugins/sent/skills/waba-template-author/SKILL.md index 40d25b1..c11baf2 100644 --- a/plugins/sent/skills/waba-template-author/SKILL.md +++ b/plugins/sent/skills/waba-template-author/SKILL.md @@ -1,197 +1,168 @@ --- name: waba-template-author -description: Writes, classifies, and revises WhatsApp templates for Sent, including utility, marketing, and authentication category decisions, variable samples, component structure, rejection-risk review, and Sent template submission. Use when a user says WhatsApp template, WABA template, template category, utility vs marketing, authentication template, Meta rejection, template samples, buttons, or wants approved WhatsApp copy in Sent. +description: Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads. --- - - -# WABA template author - -## Overview - -Use this skill to write WhatsApp template content that can be represented as a Sent template, submitted for WhatsApp review where required, and later sent through `POST /v3/messages` with `template.id`. The skill’s job is not just to produce polished copy; it must choose the right category, structure components correctly, provide realistic sample values, and flag review risks before submission. - -Sent stores templates as reusable message blueprints across SMS, WhatsApp, and RCS. WhatsApp review and category enforcement come from Meta, but the Sent-facing workflow uses Sent’s `/v3/templates` endpoints and Sent template statuses. - -## When to use - -Use this skill when the user asks for WhatsApp template copy, WABA template creation, utility/marketing/authentication classification, template rejection fixes, variable samples, buttons, headers, template categories, Meta approval risk, or a Sent template payload for WhatsApp. - -Do not use this skill to design the whole template-management UI; use `template-builder-ui`. Do not use it to connect a WABA or phone number; use `waba-embedded-signup`. Do not use it to register SMS compliance; use `sms-10dlc-registration`. - -## Category decision - -Pick the narrowest truthful WhatsApp category. Do not force promotional content into utility. The category should match the recipient’s expectation, the opt-in context, and the actual copy. - -| Category | Use when | Avoid when | -|---|---|---| -| Utility | The message is tied to an existing transaction, account, order, appointment, or service request. | The copy includes upsell, acquisition, abandoned cart, discount, or broad engagement language. | -| Marketing | The message promotes, re-engages, cross-sells, announces offers, or encourages optional action not tied to an existing transaction. | The message is purely required service/account information. | -| Authentication | The message delivers one-time passcodes or verification flows. | The message includes non-authentication content or marketing. | - -**Example.** “Your order 1234 shipped and arrives tomorrow” is utility. “Your order shipped — add accessories for 20% off” is marketing risk because it adds promotional content. - -## Process - -### 1. Capture the business intent - -Ask what event triggers the template, who receives it, what action the recipient should take, and whether the message contains any promotion. Write those answers before drafting copy. - -A strong intent statement is specific: “Send a delivery reschedule link after a courier misses the first attempt.” A weak one says: “Notify users about updates.” - -### 2. Choose the category before writing copy - -Drafting before category selection often creates copy that fails review. Choose utility, marketing, or authentication first, then write within that boundary. - -If the user wants utility but includes promotional language, explain the conflict and offer two options: remove promotion and keep utility, or keep promotion and classify as marketing. - -### 3. Draft the component structure - -Represent the template in Sent-compatible component language: header, body, footer, buttons, variables, and samples. Keep the component set as simple as the use case allows. - -| Component | Guidance | -|---|---| -| Header | Use only when it clarifies identity or context. Avoid promotional headers for utility templates. | -| Body | Put the required message and variables here. Keep the first sentence clear without needing the button. | -| Footer | Use for low-emphasis context such as opt-out or support where appropriate. | -| Buttons | Use quick replies or call-to-action buttons only when they directly support the message intent. | -| Variables | Use stable names and provide realistic samples for every variable. | - -### 4. Write with review risk in mind - -Use concise, literal copy. Avoid vague urgency, misleading scarcity, or mixed intents. Do not include sensitive data unless the use case requires it and the customer confirms it is acceptable. - -**Utility example.** - -```text -Name: order_shipped_update -Category: Utility -Language: en_US -Body: Hi {{first_name}}, your {{brand_name}} order {{order_id}} has shipped and is expected on {{delivery_date}}. Track it here: {{tracking_url}}. -Samples: - first_name: Alex - brand_name: Acme - order_id: A12345 - delivery_date: May 22 - tracking_url: https://acme.example/t/A12345 -Button: Track order -> {{tracking_url}} -``` - -**Marketing example.** - -```text -Name: spring_sale_announcement -Category: Marketing -Language: en_US -Body: Hi {{first_name}}, {{brand_name}} spring deals are live. Use code {{promo_code}} by {{end_date}} to save on selected items. -Samples: - first_name: Alex - brand_name: Acme - promo_code: SPRING20 - end_date: May 31 -Button: Shop now -> https://acme.example/sale +# WhatsApp Template Author + +Use this skill to turn a messaging intent into a valid body for `POST /v3/templates`, review it for WhatsApp policy risk, and explain the resulting lifecycle. Sent's template request is not Meta's Cloud API `components[]` shape. + +## Source precedence + +When official sources disagree: + +1. Use the live Sent v3 OpenAPI for paths, request fields, and response shapes. +2. Use the most specific current Sent guide for lifecycle and policy semantics. +3. Preserve unknown provider values instead of forcing them into a closed enum. + +The canonical references are the Sent template-definition guide, the v3 OpenAPI, and the webhook events reference. Do not use snapshot-era v2 examples. + +## Authoring workflow + +### 1. Establish intent and category + +Collect the business event, recipient expectation, requested action, language, channel overrides, and realistic sample values. Choose: + +- `UTILITY` for a specific non-promotional transaction, account, or service event. +- `MARKETING` for promotions, offers, re-engagement, product discovery, or mixed promotional content. +- `AUTHENTICATION` for one-time verification codes and supported authentication flows. + +If content mixes utility and promotion, classify it as marketing or split it. See [references/waba-template-categories.md](references/waba-template-categories.md). + +### 2. Build the Sent create request + +`POST /v3/templates` accepts these top-level fields: + +| Field | Requirement | +| --- | --- | +| `definition` | Required. Contains `header`, `body`, `footer`, `buttons`, optional `definitionVersion`, and optional `authenticationConfig`. | +| `category` | Optional: `UTILITY`, `MARKETING`, or `AUTHENTICATION`; omit for detection only when ambiguity is acceptable. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional source string; `from-api` is the documented default. | +| `submit_for_review` | Optional Boolean; default `false`. Draft and validate before review. | +| `sandbox` | Optional Boolean for validation without side effects. | + +Do not put `name`, `channels`, `body`, `header`, `buttons`, or `components` at the request root. `name` exists on update/response surfaces, not on the current create request. + +```json +{ + "category": "UTILITY", + "language": "en_US", + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} has shipped.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": null, + "whatsapp": null, + "rcs": null + }, + "footer": null, + "buttons": null, + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true +} ``` -### 5. Convert the draft into a Sent template operation +Use `definition.body.multiChannel` as the channel-neutral body. `sms`, `whatsapp`, and `rcs` are complete channel overrides, not fragments. Keep each body at or below 1,024 characters. -Use Sent’s template API for creation and lifecycle management. The verified operations are: +### 3. Define variables exactly -| Operation | Endpoint | Use | -|---|---|---| -| Create template | `POST /v3/templates` | Save a draft or submit a new template. | -| List templates | `GET /v3/templates` | Find templates by search, status, or category. | -| Retrieve template | `GET /v3/templates/{id}` | Inspect status and definition. | -| Update template | `PUT /v3/templates/{id}` | Revise name, category, language, definition, or submit for review. | -| Delete template | `DELETE /v3/templates/{id}` | Delete the Sent template, optionally deleting from Meta where supported. | +Use placeholders such as `{{0:variable}}`, `{{1:link}}`, or `{{2:media}}`. Each placeholder needs one matching definition with: -Use Sent’s documented template statuses in user-facing instructions: `PENDING`, `APPROVED`, `REJECTED` (per the Sent docs snapshot, Template Models section). Sent does **not** surface `PAUSED` — that is Meta-side only. If Meta returns additional statuses for a WhatsApp account, quote them as Meta-side evidence rather than Sent-surfaced statuses. +- a unique non-negative integer `id`; +- a readable `name`; +- a matching `type`; +- `props.sample` with realistic review and preview data. -### 6. Add variable samples before submission +Keep placeholder IDs and variable IDs aligned inside every body override. Never output naked `{{1}}` placeholders in a Sent request. -Every placeholder needs a realistic sample. Samples should look like production data and should not add claims that the body does not support. +### 4. Add supported buttons -**Bad sample pattern.** Body says “Your appointment is confirmed,” but sample data includes “50% off visit.” This can create category confusion. +Sent currently recognizes `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE`. Enforce: -**Good sample pattern.** Body and sample values all support the same transactional use case. +- 10 buttons total; +- at most 2 URL buttons; +- at most 1 voice-call button; +- at most 1 phone-number button; +- at most 1 copy-code button; +- quick replies may use the remaining slots, up to the total of 10. -### 7. Revise rejected templates from the reason, not from guesses +Buttons use `id`, `type`, and `props`. Labels are at most 25 characters. Require type-specific properties: `quickReplyType`; `urlType` and `url`; `countryCode` and `phoneNumber`; or `offerCode`. Quick replies and calls-to-action may coexist—do not invent an XOR rule. -If a template is rejected, retrieve the Sent template detail/status and any available rejection reason. Then change only what the reason justifies. Category mismatch, missing samples, unsupported components, and promotional language in utility templates require different fixes. +### 5. Handle authentication templates -| Rejection symptom | Likely correction | -|---|---| -| Category mismatch | Change category or remove conflicting copy. | -| Missing/weak samples | Add realistic variable samples. | -| Unsupported component | Simplify header/buttons or split channel variants. | -| Policy concern | Remove misleading, sensitive, or prohibited content. | -| Language mismatch | Correct language code and localized text. | +For `AUTHENTICATION`, use `definition.authenticationConfig`: -### 8. Confirm sendability after approval - -After approval, confirm the template can be used with the intended Sender Profile/channel and sent through `POST /v3/messages` with `template.id`. If delivery later fails, hand off to `messaging-performance-analyzer` rather than rewriting approved copy blindly. - -## Common rationalizations to avoid - -Do not call a template utility if it includes discounts, upsells, abandoned-cart messaging, or broad engagement language. - -Do not omit sample values because the placeholders are obvious. Review and test flows need rendered examples. - -Do not treat Meta Cloud API payload examples as the Sent API contract. Use Sent `/v3/templates` for Sent integrations. - -Do not introduce `PAUSED` as a Sent template status. Sent surfaces only `APPROVED`, `PENDING`, and `REJECTED` — PAUSED is Meta-side and is not reflected in the Sent template status. When Meta pauses, the Sent status stays as it was, and individual sends start failing instead — diagnose via `messaging-performance-analyzer`. +```json +{ + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 +} +``` -Do not rewrite a rejected template without reading the actual rejection reason when available. +Expiration is 1–90 minutes. Keep authentication content to the verification purpose, use one code variable and the supported copy-code action, and do not add marketing language, unrelated links, media, or promotional buttons. -## Verification checklist +### 6. Validate before submission -- [ ] The trigger event, audience, recipient action, and promotional content are documented. -- [ ] Category is chosen before copy is drafted. -- [ ] The body is clear without relying on a button. -- [ ] Every variable has a realistic sample value. -- [ ] Component choices match the selected channel and use case. -- [ ] Sent template API endpoints are used for create/list/get/update/delete. -- [ ] Status handling uses only Sent's surfaced set — `APPROVED`, `PENDING`, `REJECTED` (no `PAUSED`). -- [ ] Rejection fixes map to observed reasons, not generic rewrites. -- [ ] Approved templates are tested through Sent sending with `template.id` before broad rollout. +Run: -## Related skills +```bash +python scripts/lint_waba_template.py template.json +``` -Use `template-builder-ui` when the task is UI design, component validation, JSON editor behavior, or template-management product specs. +The linter validates the Sent request shape, variables, the 1,024-character limit, channel overrides, every current button type, per-type limits, and authentication configuration. A Meta Cloud API example with `components[]` must fail with an explicit conversion error. -Use `waba-embedded-signup` when the WhatsApp sender/WABA/phone number is not connected to Sent yet. +Use `sandbox: true` and `submit_for_review: false` while integrating. When the user is ready for provider review, show the final payload and explain that submission changes external state before proceeding. -Use `sender-profile-architect` when the template belongs to a specific tenant, brand, department, or profile boundary. +### 7. Track the right lifecycle surface -Use `sms-10dlc-registration` when WhatsApp copy will be mirrored to SMS and must align with US A2P use-case registration. +Sent template resources use the known states `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Do not claim this is every value the API may ever return. -Use `messaging-performance-analyzer` when approved WhatsApp templates have poor delivery, read, or webhook outcomes. +Template webhooks are WhatsApp approval events. They use `field: "templates"`, omit `sub_type` and `event`, and carry the provider status in `payload.status`: -Use the `sent` skill for shared Sent terminology and routing. +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": null + } +} +``` -## Bundled references and scripts +Common forwarded values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`. Meta can also send values such as `PAUSED` or `DISABLED`. Persist the raw string, handle known values, and safely surface unknown ones. See [references/template-rejection-playbook.md](references/template-rejection-playbook.md). -| File | Type | Purpose | -|---|---|---| -| `references/waba-template-categories.md` | Policy lookup table | Meta category boundaries, component rules, and Cloud API submission shape. | -| `references/waba-template-examples.md` | Worked examples | Copy-pasteable payloads for utility, marketing, and authentication templates. | -| `references/template-rejection-playbook.md` | Decision matrix | Map rejection reasons to precise edits and resubmission etiquette. | -| `scripts/lint_waba_template.py` | Validation script | Stdlib lint for a template JSON payload (placeholder numbering, samples, category-risk phrases, button structure, language code). Run from the skill root: `python scripts/lint_waba_template.py template.json`. | -| `scripts/fixtures/utility_good.json` | Fixture | Passing fixture for the linter. | -| `scripts/fixtures/utility_bad.json` | Fixture | Failing fixture (wrong placeholder order, promo phrasing in utility). | +## Boundaries -## Unverified claims to confirm or remove +Use `template-builder-ui` for editor architecture and client-side validation UX. Use `sent-templates` to list, inspect, or delete existing templates through the connected Sent tools. Use `waba-embedded-signup` for WABA connection. Use `rcs-agent-onboarding` for current RCS launch capabilities. -- Exact category-pricing behavior and Meta rejection-code semantics are external Meta policy context, not Sent API facts. -- Cross-check the Sent template schema against the live OpenAPI at `https://docs.sent.dm` before promoting code to production. +Meta Cloud API payloads may appear in [references/waba-template-examples.md](references/waba-template-examples.md), but every such example must be clearly labelled non-Sent and must never be passed to the Sent linter as a valid request. diff --git a/plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md b/plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md index 60d36c1..b8c8a0d 100644 --- a/plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md +++ b/plugins/sent/skills/waba-template-author/references/template-rejection-playbook.md @@ -1,182 +1,62 @@ -# Template Rejection Playbook — Reference +# Template rejection and lifecycle playbook -What to do when Meta rejects a WhatsApp template, silently re-categorizes it, -or pauses it after delivery starts. Companion to `references/waba-template-categories.md` -and `references/waba-template-examples.md`. Authoritative source for Meta-side codes is -the official [Cloud API template docs](https://developers.facebook.com/docs/whatsapp/message-templates). -Sent-surfaced statuses and codes come from the Sent docs snapshot referenced -above. +Use this reference when a Sent template is pending, rejected, paused, disabled, or recategorized by the WhatsApp provider. -Every entry: what triggers it, how to detect it from the rejection / status -payload, and what to change before resubmitting. +## Keep lifecycle surfaces separate -## Sent-surfaced template states (not Meta's) +Sent template resources have these known states: -Sent's template `status` set is exactly `APPROVED`, `PENDING`, `REJECTED` — -**no `PAUSED`**. When Meta pauses a template (quality rating drop, opt-out -spike), Sent's template status does **not** change; it stays whatever it was -(typically `APPROVED`). Sends against a Meta-paused template start failing -asynchronously — surface that via the `message.failed` webhook or the -`GET /v3/messages/{id}/activities` endpoint, not via a template-status poll. +- `DRAFT` +- `PENDING` +- `APPROVED` +- `REJECTED` +- `PAUSED` -When a send is attempted against a Sent template whose `status` is `PENDING` -or `REJECTED`, the batch is rejected synchronously with: +The template webhook is a provider-forwarding surface. Common `payload.status` values are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; provider values such as `PAUSED` and `DISABLED` can also arrive. These lists serve different purposes. Persist the original status string and surface unknown values safely. -| Code | HTTP | Meaning | -|---|---|---| -| `BUSINESS_005` | 422 | "WhatsApp template not approved (still PENDING / REJECTED)" | - -So the two failure modes are distinct: - -- Template never reached `APPROVED` in Sent → `BUSINESS_005` on send. -- Template is `APPROVED` in Sent but Meta-paused → per-message failure on the - webhook / activities feed; the template `status` you see in Sent is unchanged. - -For diagnosing post-approval send failures, hand off to -`messaging-performance-analyzer`. - -## Category mismatch (utility classified, marketing content) - -**What it looks like:** Template is `APPROVED` but the returned `category` is -`MARKETING` even though you submitted `UTILITY`. Or, after first send, the -status moves to `PENDING` → `APPROVED` again with a flipped category. - -**How to detect:** Compare submitted `category` to the post-approval `category` -on the webhook. Re-categorization is the most common silent failure. - -**Revise:** -- Strip any sentence that does not refer to the triggering event. -- Replace generic CTAs ("Shop now", "Browse more") with event-specific ones - ("View order", "Track package"). -- Remove second-person calls to action that are not the entity in the message. -- Resubmit under a new version suffix (`_v2`) — the old name is locked. - -## Promotional content in utility category - -**Banned in utility body, footer, header, button labels:** - -- "buy now", "shop now", "order today" -- "limited time", "exclusive", "hurry", "ends soon" -- "special offer", "best deal", "lowest price" -- "discount", "% off", "sale", "free shipping" -- Discount codes like `SAVE20`, even as a variable sample -- Promotional emojis in body or header text (🎉 🛍️ 💸 🔥) - -**Revise:** strip the phrase; if the use case genuinely includes a promo, -flip to `MARKETING` rather than masking the wording. - -## Missing variable samples - -**What it looks like:** Submission fails with `TAG_CONTENT_MISMATCH` or the -template is rejected with "variable example missing". - -**Sent's required shape:** +## Correct template event envelope ```json { - "type": "BODY", - "text": "Hi {{1}}, your order #{{2}} has shipped.", - "example": { "body_text": [["Jordan", "A1029"]] } + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "REJECTED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": "Promotional content is not utility content." + } } ``` -`body_text` is an array of arrays — the outer array is "one row per -variable group", and the inner array has one sample per `{{n}}`. Forgetting -the outer array is the single most common cause. - -For URL CTAs: - -```json -{ "type": "URL", "text": "Track", "url": "https://example.com/orders/{{1}}", - "example": ["https://example.com/orders/A1029"] } -``` - -`example` is a flat array here (not nested). - -## Authentication template with code formatting error - -**What it looks like:** Submission fails with "invalid component" on an -authentication template, or the OTP button does not appear in the rendered -template preview. - -**Revise:** -- Use `category: "AUTHENTICATION"` and the dedicated auth component shape, not - a UTILITY template with a `{{1}}` for the code. -- The body component must use `add_security_recommendation: true` or include - the platform-managed security recommendation; no freeform `{{1}}` for the code. -- The button component must be `OTP` with `otp_type` of `COPY_CODE` or `AUTOFILL`. -- `code_expiration_minutes` belongs on the FOOTER component, not the body. - -See `references/waba-template-examples.md` for two valid AUTH payloads. - -## Button URL doesn't match domain - -**What it looks like:** Rejection with "URL does not match business domain" -or the template is approved but later paused for the same reason. - -**Revise:** -- Confirm the WABA's verified business domain matches the CTA URL host. -- Subdomains often need to be added separately if Meta's domain check is - strict — `app.example.com` and `example.com` are not interchangeable. -- For URL CTAs with a variable, the example URL must resolve to the same - registered domain. -- If you're a multi-tenant platform on Sent, the WABA in question must own - the domain — you cannot deep-link to a tenant subdomain not registered - under that WABA. - -## Language code mismatch - -**What it looks like:** Rejection with `INVALID_LANGUAGE`, or two templates -under the same conceptual name behave inconsistently across recipients. - -**Revise:** -- Use BCP-47 codes with the underscore separator: `en_US`, `pt_BR`, `es_MX`. -- Not `en`, not `en-US`, not `en_us`. -- Each language is a *separate template* with its own approval. You cannot - submit one template and have it cover several locales. - -## Generic placeholders (template too generic) - -**What it looks like:** Rejection with a content reason ("does not meet -template quality standards") even though the wording is neutral. - -**Triggers:** -- Bodies like "{{1}}, here is an update for you" — Meta cannot tell what - category the template is for, so they default to reject. -- Sample values like `Test`, `Sample`, `XYZ` — these read as if the template - was never going to be used in production. +Template events use `field: "templates"` and omit both `sub_type` and `event`. Message events are different and do use `sub_type`. -**Revise:** add at least one variable that proves the use case (an order ID, -an appointment time, an account-event timestamp) and use realistic sample -values that match. +## Response procedure -## Resubmission etiquette and timing +1. Verify the webhook signature using the raw body and reject stale timestamps. +2. Deduplicate on template ID plus status transition. +3. Persist the raw payload and reason. +4. Retrieve the current Sent template before editing; webhooks can be delayed or reordered. +5. Map the reason to the smallest justified change. +6. Convert any Meta-shaped source into the Sent `definition` contract. +7. Run the local linter and use `sandbox: true`. +8. Show the final diff and obtain confirmation before review submission. -- **One change per resubmission.** If you change wording *and* category *and* - variables, you cannot tell which change unblocked the template. -- **Use a new version suffix.** `_v1` → `_v2`. Submitting under the same - `(name, language)` will fail with name-conflict if the prior template is - still in any state other than `DELETED`. -- **Wait for review before re-resubmitting.** Spamming submissions of the - same template body slows the queue for the WABA. -- **For Meta-paused templates** (Sent template status still `APPROVED` but - sends are failing on the webhook / activities feed), revise the content - before resubmitting under a new version — Meta paused for a reason and an - untouched resubmission lands in the same place. Confirm the failures via - `messaging-performance-analyzer` before rewriting. -- **For silent re-categorization,** resubmit the *strictest* version of the - wording even if you intend to send marketing content from it — once the - category is set, marketing-priced sends still work fine under a stricter - template. +## Common remediations -## Quick triage table +| Symptom | Appropriate response | +| --- | --- | +| Utility content recategorized | Remove promotion or deliberately use `MARKETING`; do not argue from transactional context alone. | +| Missing or unrealistic samples | Add `props.sample` for every placeholder without using customer data. | +| Invalid variable format | Replace naked placeholders with `{{0:variable}}` and align IDs. | +| Unsupported create shape | Move fields into `definition`; reject Meta `components[]` as a Sent request. | +| Button validation | Enforce 10 total and per-type limits; allow quick replies and CTA buttons to coexist. | +| `PAUSED` or `DISABLED` | Stop new WhatsApp sends with the template, preserve the provider value, and surface it for review. | +| Unknown status | Store and display it; do not silently coerce it to rejected or approved. | -| Symptom | Most likely cause | First fix | -|---|---|---| -| Approved but category flipped | Promotional language Meta detected | Strip wording, resubmit as `_v2` | -| `INVALID_FORMAT` | Component schema typo | Re-check component types and required fields | -| `TAG_CONTENT_MISMATCH` | Variables vs. samples count mismatch | Provide one sample per `{{n}}` in the right shape | -| `META_POLICY_VIOLATION` | Restricted content (alcohol, finance, etc.) | Check Meta's restricted-content policy for the WABA's vertical | -| `INVALID_LANGUAGE` | Bad locale code | Use BCP-47 with underscore | -| Sends fail with `BUSINESS_005` | Sent template still `PENDING` or `REJECTED` | Wait for Sent approval, or fix the rejection and resubmit as `_v2` | -| Sends fail post-approval (per-message failures, Sent status unchanged) | Meta-paused template (PAUSED is Meta-side, not reflected in Sent) | Diagnose via `messaging-performance-analyzer`; revise wording and resubmit `_v2` | +Do not claim provider approval timing as a guarantee, and do not repeatedly resubmit unchanged content. diff --git a/plugins/sent/skills/waba-template-author/references/waba-template-categories.md b/plugins/sent/skills/waba-template-author/references/waba-template-categories.md index 0c6085a..0338ba0 100644 --- a/plugins/sent/skills/waba-template-author/references/waba-template-categories.md +++ b/plugins/sent/skills/waba-template-author/references/waba-template-categories.md @@ -1,218 +1,39 @@ -# WABA Template Categories — Reference +# WhatsApp template categories -Supporting reference for `waba-template-author`. Excerpts from Meta's template -policy plus practical mappings to common use cases. Authoritative external -source: [WhatsApp Business Platform > Message Templates](https://developers.facebook.com/docs/whatsapp/message-templates). +Supporting policy reference for `waba-template-author`. The request contract comes from Sent; category review is ultimately performed by Meta for WhatsApp. -> The Meta policy evolves frequently — verify the current rules in the official -> docs before relying on edge cases. This reference captures the stable patterns -> and boundaries; assume any specific number or category boundary may have -> changed since this file was written. +## Decision order -## Sent's Template Categories and Statuses +1. Identify why the recipient expects the message. +2. Identify the single action the message asks them to take. +3. Remove optional promotional language and classify again. +4. If promotion remains, use `MARKETING`. +5. If the sole purpose is a verification code, use `AUTHENTICATION`. +6. Otherwise use `UTILITY` only when the message is tied to a specific transaction, account, or service event. -Sent surfaces exactly **three** categories — Meta's three, no others: +## Category guide -- `UTILITY` -- `MARKETING` -- `AUTHENTICATION` +| Category | Suitable intent | Common rejection or recategorization risk | +| --- | --- | --- | +| `UTILITY` | Order state, appointment reminder, account change, service interruption, requested support update | Discounts, upsells, product discovery, vague re-engagement, or calls to purchase | +| `MARKETING` | Offers, launches, recommendations, reminders to shop, abandoned-cart messages, mixed promotional content | Missing consent, misleading urgency, or attempting to disguise promotion as utility | +| `AUTHENTICATION` | OTP, login verification, account recovery code | Free-form content, promotional text, unrelated links/media, or multiple actions | -Sent's template `status` set is exactly: +Transactional context does not make promotional content utility. “Your receipt is ready” is utility; “Your receipt is ready—buy again for 20% off” is marketing. -- `APPROVED` -- `PENDING` -- `REJECTED` +## Authentication restrictions -**There is no `PAUSED` status in Sent.** Meta may internally pause a template -after delivery starts (quality rating drop, opt-out spike), but Sent does **not** -surface PAUSED — the Sent template status remains whatever it was (typically -`APPROVED`), while sends against the paused template start failing at send time. -Detect that via failed activities / message webhooks, not via the template -status. See `references/template-rejection-playbook.md` for the workflow. +- Set top-level `category` to `AUTHENTICATION`. +- Include `definition.authenticationConfig`. +- `codeExpirationMinutes`, when present, is an integer from 1 through 90. +- Keep the body to the verification purpose and one code variable. +- Use the supported `COPY_CODE` action for the code. +- Do not add promotion, unrelated URLs, media, or extra calls to action. -## The Three Categories +## Variables and samples -### Utility -Triggered by a user action or a recurring event the user already opted into. The -message is *about that event*. Lowest-priced category. +Provider reviewers see samples. Every placeholder such as `{{0:variable}}` must have the same numeric ID in the channel's variables array and a realistic `props.sample`. Do not use real customer data or secrets in samples. -**Eligible use cases (non-exhaustive):** -- Order placed / shipped / delivered -- Appointment confirmation, reminder, change -- Booking confirmation -- Account balance, statement, invoice -- Password change confirmation (note: *not* the OTP itself — that's authentication) -- Service status: outage, restoration, maintenance window -- Payment received / failed / overdue notice -- Recurring statement or subscription renewal notice -- Form / application status update +## Revision discipline -**Disqualifiers — if any of these are true, Meta will reclassify to marketing:** -- The message cross-sells, upsells, or invites the user back -- The CTA button leads anywhere other than the entity the message is about - (an order-confirmation button must link to that order, not the homepage) -- Promotional language ("hurry", "limited", "exclusive", "sale", "deal") -- Discount codes, percentages off, or "use code X" content -- Promotional imagery in a media header - -### Marketing -Business-initiated outreach with the intent to drive a new action — purchase, -visit, signup, re-engagement. Highest-priced category. - -**Eligible use cases:** -- Promo / sale announcement -- Abandoned cart recovery -- Welcome-back / win-back -- Newsletter / content drop -- Product launch -- Event invitation (when the event is promotional, not transactional) -- Survey or feedback request (when not tied to a specific transaction) - -There is no penalty for marketing templates; the penalty is for *labeling* -them as utility. When in doubt, ship as marketing. - -### Authentication -A separate template type, not just a category. Used for one-time codes, login -verification, account-recovery codes. - -**Distinct rules:** -- Body is constrained: "{{1}} is your verification code." plus optional security - disclaimer ("For your security, do not share this code.") -- Buttons collapse to a single button: "Copy code" or "Autofill" (one-tap, mobile only) -- Supports `code_expiration_minutes` field -- Cannot include marketing or utility content -- Lower per-message cost; some regions price authentication separately - -## Component Rules - -### Header (optional) -- Sent header `type`: `TEXT` | `IMAGE` | `VIDEO` | `DOCUMENT` (no `LOCATION` at the Sent layer) -- Text: max 60 chars, max 1 variable -- Media: provide a sample URL or media handle at submission - -### Body (required) -- Max 1024 chars -- Supports `{{1}}`, `{{2}}`, … placeholders -- Variables must be sequential (no gaps); every variable needs a sample -- No URLs in body (link via CTA buttons instead) for most categories; exceptions exist - -### Footer (optional) -- Max 60 chars -- No variables -- Common use: compliance text, opt-out instructions - -### Buttons (optional) -- *Either* quick replies *or* CTA buttons, never both -- Quick replies: up to 3, max 25 chars per label -- CTAs: up to 2, types: `URL` or `PHONE_NUMBER` - - URL CTAs may include one trailing variable: `https://example.com/orders/{{1}}` - - URL CTAs require an example URL for submission - -## Sent CreateTemplateRequest Shape - -Sent's `POST /v3/templates` accepts the following structured shape (from -the live Sent API documentation at https://docs.sent.dm): - -``` -{ - name, - category, // "UTILITY" | "MARKETING" | "AUTHENTICATION" - language, // BCP-47 with underscore, e.g. "en_US" - body: { - content, // body text with {{1}}, {{2}} placeholders - variables?: [ - { name, type: "text" | "number" | "date", example? } - ] - }, - header?: { - type: "TEXT" | "IMAGE" | "VIDEO" | "DOCUMENT", - content - }, - footer?: { content }, - buttons?: [ - { - type: "QUICK_REPLY" | "URL" | "PHONE_NUMBER", - text, - url?, // required when type = URL - phone_number? // required when type = PHONE_NUMBER - } - ], - channels?, // subset of ["sms", "whatsapp", "rcs"] - sandbox? // bool -} -``` - -Worked Sent request: - -```json -{ - "name": "order_confirmation_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} has been confirmed and will ship soon. Track it any time below.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] - }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to unsubscribe." }, - "buttons": [ - { - "type": "URL", - "text": "Track order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"], - "sandbox": false -} -``` - -Notes: - -- The Sent body is `body.content`, not `text` (Meta-side Cloud API uses `text` - on the BODY component). Variable samples live in `body.variables[].example` - rather than a separate Cloud API `example.body_text` block. -- `header.type` is restricted to **TEXT, IMAGE, VIDEO, DOCUMENT** at the Sent - layer. `LOCATION` is not a Sent header type. -- `buttons[].type` is restricted to **QUICK_REPLY, URL, PHONE_NUMBER** at the - Sent layer. Authentication-only Cloud API button types (`OTP`, `COPY_CODE`, - `AUTOFILL`) are Meta-side and are not Sent button types. -- `channels` accepts a subset of `["sms", "whatsapp", "rcs"]`. WhatsApp templates - always include `"whatsapp"`. -- `language` follows BCP-47 with the underscore separator (`en_US`, `pt_BR`, - `es_MX`). Not `en`, not `en-US`. - -## Common Rejection Reasons (from Meta's API) - -| Code / phrase | Meaning | Fix | -|---|---|---| -| `INVALID_FORMAT` | Component schema broken | Re-validate against the component rules above | -| `TAG_CONTENT_MISMATCH` | Variable count differs from samples | Provide a sample for every `{{n}}` | -| `META_POLICY_VIOLATION` | Content violates content policy | Remove promotional content, slurs, or restricted-category content | -| `INVALID_LANGUAGE` | Language code unsupported | Use BCP-47 (`en_US`, not `en`) and one from Meta's supported list | -| Silent reclassification | Approved but category changed | Body/buttons/header read promotional even if the use case is utility | - -## Worked Examples — Decision Tree in Action - -**"Your order #1029 has shipped. Track it here."** -- Triggered by purchase? Yes. About the purchase? Yes. CTA is order-specific? Yes. -- → **UTILITY**, single URL CTA. - -**"Your order #1029 has shipped. Check out our new arrivals!"** -- Triggered by purchase? Yes. About the purchase? No — second sentence is a promo. -- → **MARKETING** (or split into two templates). - -**"Your account password was changed."** -- Triggered by user action? Yes (they changed it). About that action? Yes. -- → **UTILITY**. (The OTP that authorized the change is **AUTHENTICATION**, separate template.) - -**"We miss you — here's 20% off your next order."** -- Business-initiated, re-engagement, discount. -- → **MARKETING**, unambiguously. - -**"Your code is 729451. For your security, do not share this code."** -- One-time code for login. -- → **AUTHENTICATION** template type. Not a utility template with a code in the body. +When Meta returns `REJECTED` or `CATEGORY_UPDATED`, retain the raw reason, change only what it supports, lint again, and resubmit deliberately. Do not repeatedly submit unchanged content. diff --git a/plugins/sent/skills/waba-template-author/references/waba-template-examples.md b/plugins/sent/skills/waba-template-author/references/waba-template-examples.md index d72c939..9adc811 100644 --- a/plugins/sent/skills/waba-template-author/references/waba-template-examples.md +++ b/plugins/sent/skills/waba-template-author/references/waba-template-examples.md @@ -1,244 +1,93 @@ -# WABA Template Examples — Reference +# Sent template examples -Worked, copy-pasteable WhatsApp template payloads grouped by Meta category. -Companion to `references/waba-template-categories.md`. Every example is a complete -**Sent `POST /v3/templates`** request body following the CreateTemplateRequest -shape. Variable placeholders use `{{1}}`, `{{2}}` in the body content, and -each variable has a named entry with `type` and `example` in `body.variables`. +All examples in the first section are bodies for `POST /v3/templates` and are expected to pass `scripts/lint_waba_template.py`. Synthetic values are used throughout. -> Synthetic values only — no real WABA IDs, brand names, or customer data. - -## Utility - -### 1. Order confirmation - -Triggered by checkout. About that order. Single URL CTA bound to the order. +## Utility with a WhatsApp override + ```json { - "name": "order_confirmation_v1", "category": "UTILITY", "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} is confirmed. We will let you know when it ships.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, your appointment is on {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + }, + "whatsapp": { + "type": "body", + "template": "Hello {{0:variable}}. Your appointment is confirmed for {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + } + }, + "footer": {"type": "text", "template": "Acme Scheduling", "variables": []}, + "buttons": [ + {"id": 1, "type": "QUICK_REPLY", "props": {"text": "Confirm", "quickReplyType": "custom"}}, + {"id": 2, "type": "URL", "props": {"text": "Manage booking", "urlType": "static", "url": "https://example.com/bookings"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": null }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "View order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why this is approved as utility: every component refers to the order, the CTA -deep-links to that order, no promotional language, no cross-sell. - -### 2. Shipping update - -Triggered by carrier scan. Variables numbered 1..3 sequentially. - -```json -{ - "name": "shipping_update_v2", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "package_id", "type": "text", "example": "A1029" }, - { "name": "delivery_eta", "type": "text", "example": "6 PM today" } - ] - }, - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: status-only language, sample values are neutral, CTA points to the -tracking page for that specific package. - -### 3. Appointment reminder - -Triggered by user-booked appointment. Quick-reply buttons stay within the same -appointment — confirm or reschedule, no upsell. - -```json -{ - "name": "appointment_reminder_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, this is a reminder of your appointment with {{2}} on {{3}} at {{4}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "provider_name", "type": "text", "example": "Dr. Patel" }, - { "name": "appointment_date", "type": "date", "example": "2026-05-18" }, - { "name": "appointment_time", "type": "text", "example": "10:30 AM" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Confirm" }, - { "type": "QUICK_REPLY", "text": "Reschedule" } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: every button action is tied to the appointment itself; no -"Book another visit" or other cross-sell. - -## Marketing - -### 1. Promo announcement - -Business-initiated, discount code in the body. Unambiguously marketing. - -```json -{ - "name": "spring_promo_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our spring sale is on — use code {{2}} for 20% off through Sunday.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "SPRING20" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale" - } - ], - "channels": ["whatsapp"] -} -``` - -### 2. Re-engagement (win-back) - -```json -{ - "name": "winback_30d_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, we miss you! Here is 15% off your next order with code {{2}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "COMEBACK15" } - ] - }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Shop deals" }, - { "type": "QUICK_REPLY", "text": "Browse new" } - ], - "channels": ["whatsapp"] -} -``` - -### 3. Seasonal announcement (image header) - -```json -{ - "name": "holiday_drop_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our holiday collection just dropped. Take a look before it sells out.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" } - ] - }, - "header": { - "type": "IMAGE", - "content": "https://example.com/assets/holiday-2026.jpg" - }, - "buttons": [ - { - "type": "URL", - "text": "See collection", - "url": "https://example.com/holiday" - } - ], - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` ## Authentication -Authentication templates are submitted under Sent's `AUTHENTICATION` category. -The Cloud API-specific `OTP` button types (`COPY_CODE`, `AUTOFILL`) are -Meta-side concepts and are not part of Sent's `buttons[].type` enum -(`QUICK_REPLY | URL | PHONE_NUMBER`). At the Sent layer, model the code as a -single body variable. - -### 1. One-time code - + ```json { - "name": "login_otp_v1", "category": "AUTHENTICATION", "language": "en_US", - "body": { - "content": "{{1}} is your verification code. For your security, do not share this code.", - "variables": [ - { "name": "code", "type": "text", "example": "729451" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Your verification code is {{0:variable}}.", + "variables": [ + {"id": 0, "name": "verificationCode", "type": "variable", "props": {"sample": "482193"}} + ] + } + }, + "footer": null, + "buttons": [ + {"id": 1, "type": "COPY_CODE", "props": {"text": "Copy code", "offerCode": "482193"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": { + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 + } }, - "footer": { "content": "This code expires in 10 minutes." }, - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` -### 2. Password reset confirmation (utility-shaped, plain confirmation) +## Meta Cloud API example — not a Sent request -If you only want a confirmation (no code), submit it as `UTILITY`: +The following abbreviated shape is deliberately separate. It must not pass the Sent linter or be posted to `POST /v3/templates`; convert its `components[]` into Sent's `definition` structure first. ```json { - "name": "password_changed_v1", - "category": "UTILITY", + "name": "order_update", "language": "en_US", - "body": { - "content": "Your account password was changed on {{1}}. If this was not you, contact support.", - "variables": [ - { "name": "changed_at", "type": "text", "example": "Mon May 18, 10:32 AM" } - ] - }, - "channels": ["whatsapp"] + "category": "UTILITY", + "components": [ + {"type": "BODY", "text": "Your order {{1}} has shipped."} + ] } ``` - -## Things to copy - -- Variable numbering in `body.content` is always `{{1}}, {{2}}, {{3}}` in order - of first appearance. -- `body.variables` is an array with one entry per `{{n}}`, in the same order. - Each entry has a `name`, a `type` (`text` | `number` | `date`), and an - `example` that matches the type. -- URL buttons take a `url`. Variables in URLs (`https://example.com/o/{{1}}`) - reuse the body's variable ordering. -- Phone-number buttons take a `phone_number` instead of `url`. -- Footers cannot contain variables — keep compliance-only language there. -- `channels` selects the channels the template should be available on. For a - WhatsApp-only template, use `["whatsapp"]`. diff --git a/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json b/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json index 3d3c1d3..16cb470 100644 --- a/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json +++ b/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_bad.json @@ -1,23 +1,11 @@ { - "name": "shipping_update_bad_v1", + "name": "meta_cloud_shape", "language": "en_US", "category": "UTILITY", "components": [ { "type": "BODY", - "text": "Hi {{2}}, your package #{{1}} is on the way. Buy now — limited time!", - "example": { "body_text": [["A1029", "Jordan"]] } - }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale", - "example": ["https://example.com/sale"] - } - ] + "text": "This is Meta's components[] format, not the Sent v3 request body." } ] } diff --git a/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json b/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json index 445c118..2b33ccc 100644 --- a/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json +++ b/plugins/sent/skills/waba-template-author/scripts/fixtures/utility_good.json @@ -1,27 +1,80 @@ { - "name": "shipping_update_v2", - "language": "en_US", "category": "UTILITY", - "components": [ - { - "type": "BODY", - "text": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "example": { "body_text": [["Jordan", "A1029", "6 PM today"]] } + "language": "en_US", + "definition": { + "header": { + "type": "text", + "template": "Order update", + "variables": [] + }, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} is ready.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": { + "type": "body", + "template": "Order {{1:variable}} is ready.", + "variables": [ + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + } }, - { - "type": "FOOTER", - "text": "Reply STOP to opt out." + "footer": { + "type": "text", + "template": "Acme Support", + "variables": [] }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}", - "example": ["https://example.com/track/A1029"] - } - ] - } - ] + "buttons": [ + { + "id": 1, + "type": "QUICK_REPLY", + "props": {"text": "Got it", "quickReplyType": "custom"} + }, + { + "id": 2, + "type": "URL", + "props": {"text": "Track order", "urlType": "static", "url": "https://example.com/track"} + }, + { + "id": 3, + "type": "VOICE_CALL", + "props": {"text": "Voice support", "countryCode": "US", "phoneNumber": "+12025550100"} + }, + { + "id": 4, + "type": "PHONE_NUMBER", + "props": {"text": "Call support", "countryCode": "US", "phoneNumber": "+12025550101"} + }, + { + "id": 5, + "type": "COPY_CODE", + "props": {"text": "Copy reference", "offerCode": "A-1042"} + } + ], + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } diff --git a/plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py b/plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py index 131dbd0..ccb5a1e 100644 --- a/plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py +++ b/plugins/sent/skills/waba-template-author/scripts/lint_waba_template.py @@ -1,32 +1,9 @@ #!/usr/bin/env python3 -"""Lint a Sent WhatsApp template payload against Meta's category + structural rules. - -Usage: - python lint_waba_template.py - -Exits 0 with "OK" if the template passes all checks. -Exits non-zero and prints each issue with the offending field on failure. - -Checks performed: - * Required top-level keys: name, language, category, components. - * category is one of UTILITY, MARKETING, AUTHENTICATION (Sent's three). - * language matches BCP-47 (lowercase locale, optional uppercase region: en, en_US). - * Exactly one BODY component is present with non-empty text - (AUTHENTICATION templates are exempt — they use managed body content). - * Placeholders {{1}}..{{N}} in body text are numbered 1..N sequentially with no gaps. - * Sample value count under example.body_text matches the placeholder count. - * For UTILITY templates: - - Promotional phrases ("buy now", "limited time", "special offer", - "discount", "sale", "free shipping") trigger warnings. - - "click here to purchase" triggers a hard failure (clear cross-sell). - * HEADER component (if present): `format` must be one of TEXT, IMAGE, VIDEO, - DOCUMENT (Sent's allowed CreateTemplateRequest header types). - * BUTTONS component (if present) is either <=3 QUICK_REPLY OR <=2 CTA - (URL/PHONE_NUMBER/OTP) — never mixed. Per-button checks: - - type must be one of QUICK_REPLY, URL, PHONE_NUMBER (Sent's button - enum) or OTP (Cloud API authentication-only, warned). - - URL buttons require a non-empty `url` field. - - PHONE_NUMBER buttons require a non-empty `phone_number` field. +"""Lint the JSON body sent to ``POST /v3/templates``. + +This validator intentionally accepts the Sent v3 request contract, not Meta's +Cloud API ``components[]`` format. Meta payloads are useful reference material, +but must be labelled and converted before they are sent to Sent. """ from __future__ import annotations @@ -35,13 +12,33 @@ import json import re import sys +from collections import Counter from pathlib import Path from typing import Any -VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} -LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") -PLACEHOLDER_RE = re.compile(r"\{\{(\d+)\}\}") +TOP_LEVEL_FIELDS = { + "category", + "language", + "definition", + "creation_source", + "submit_for_review", + "sandbox", +} +CREATE_UNSUPPORTED_FIELDS = {"name", "channels", "body", "header", "buttons", "components"} +VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} +VALID_BODY_CHANNELS = {"multiChannel", "sms", "whatsapp", "rcs"} +VALID_BUTTON_TYPES = {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"} +BUTTON_LIMITS = { + "QUICK_REPLY": 10, + "URL": 2, + "VOICE_CALL": 1, + "PHONE_NUMBER": 1, + "COPY_CODE": 1, +} +LANGUAGE_RE = re.compile(r"^[a-z]{2}(?:_[A-Z]{2})?$") +PLACEHOLDER_RE = re.compile(r"\{\{(\d+):(variable|link|media)\}\}") +ANY_PLACEHOLDER_RE = re.compile(r"\{\{[^{}]+\}\}") PROMO_WARN_PHRASES = ( "buy now", "limited time", @@ -52,13 +49,6 @@ ) PROMO_FAIL_PHRASES = ("click here to purchase",) -CTA_BUTTON_TYPES = {"URL", "PHONE_NUMBER", "OTP"} -QUICK_REPLY_TYPE = "QUICK_REPLY" - -# Sent CreateTemplateRequest enums (snapshot Template Models section). -SENT_HEADER_TYPES = {"TEXT", "IMAGE", "VIDEO", "DOCUMENT"} -SENT_BUTTON_TYPES = {"QUICK_REPLY", "URL", "PHONE_NUMBER"} - class LintResult: def __init__(self) -> None: @@ -76,243 +66,218 @@ def failed(self) -> bool: return bool(self.errors) -def _components_by_type(components: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: - grouped: dict[str, list[dict[str, Any]]] = {} - for c in components: - t = c.get("type") - if not isinstance(t, str): - continue - grouped.setdefault(t.upper(), []).append(c) - return grouped - - -def _check_top_level(payload: dict[str, Any], result: LintResult) -> None: - for key in ("name", "language", "category", "components"): - if key not in payload: - result.error(key, f"missing required top-level key '{key}'") - - category = payload.get("category") - if isinstance(category, str) and category not in VALID_CATEGORIES: - result.error( - "category", - f"category '{category}' must be one of {sorted(VALID_CATEGORIES)}", - ) - - language = payload.get("language") - if isinstance(language, str) and not LANGUAGE_RE.match(language): - result.error( - "language", - f"language '{language}' must match BCP-47 form (e.g. 'en' or 'en_US')", - ) - - components = payload.get("components") - if components is not None and not isinstance(components, list): - result.error("components", "components must be a list") - - -def _check_body(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - grouped = _components_by_type(components) - category = payload.get("category") - body_list = grouped.get("BODY", []) - - if category == "AUTHENTICATION": - # Authentication body uses managed content (e.g. add_security_recommendation); - # freeform text is not required and placeholder checks do not apply. - return - - if not body_list: - result.error("components", "exactly one BODY component is required") +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _reject_unknown_fields( + value: dict[str, Any], allowed: set[str], field: str, result: LintResult +) -> None: + for key in sorted(set(value) - allowed): + result.error(f"{field}.{key}" if field else key, "field is not part of the Sent v3 request contract") + + +def _check_variable( + variable: Any, + field: str, + expected_kind: str | None, + result: LintResult, +) -> int | None: + if not isinstance(variable, dict): + result.error(field, "variable must be an object") + return None + for key in ("id", "name", "type", "props"): + if key not in variable: + result.error(f"{field}.{key}", "missing required variable field") + variable_id = variable.get("id") + if not isinstance(variable_id, int) or variable_id < 0: + result.error(f"{field}.id", "must be a non-negative integer") + variable_id = None + if not _nonempty(variable.get("name")): + result.error(f"{field}.name", "must be a non-empty string") + kind = variable.get("type") + if kind not in {"variable", "link", "media"}: + result.error(f"{field}.type", "must be variable, link, or media") + elif expected_kind is not None and kind != expected_kind: + result.error(f"{field}.type", f"placeholder declares {expected_kind!r}, but variable declares {kind!r}") + props = variable.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + elif not _nonempty(props.get("sample")): + result.error(f"{field}.props.sample", "must be a non-empty review and preview sample") + return variable_id + + +def _check_content(content: Any, field: str, result: LintResult) -> None: + if not isinstance(content, dict): + result.error(field, "must be an object") return - if len(body_list) > 1: - result.error("components", "more than one BODY component is not allowed") - - body = body_list[0] - text = body.get("text") - if not isinstance(text, str) or not text.strip(): - result.error("components[BODY].text", "BODY text must be a non-empty string") + _reject_unknown_fields(content, {"type", "template", "variables"}, field, result) + template = content.get("template") + if not _nonempty(template): + result.error(f"{field}.template", "must be a non-empty string") return + if len(template) > 1024: + result.error(f"{field}.template", f"body exceeds the 1,024-character limit ({len(template)})") - placeholders = [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text)] - if placeholders: - unique_sorted = sorted(set(placeholders)) - expected = list(range(1, len(unique_sorted) + 1)) - if unique_sorted != expected: - result.error( - "components[BODY].text", - f"placeholders must be sequential starting at 1, got {unique_sorted}", - ) - first_occurrence = [] - seen: set[int] = set() - for n in placeholders: - if n not in seen: - first_occurrence.append(n) - seen.add(n) - if first_occurrence != sorted(first_occurrence): - result.error( - "components[BODY].text", - f"placeholders must first appear in numeric order, got {first_occurrence}", - ) - - example = body.get("example", {}) - body_text = example.get("body_text") if isinstance(example, dict) else None - placeholder_count = len(set(placeholders)) - - if placeholder_count == 0: - if body_text: - result.warn( - "components[BODY].example.body_text", - "body has no placeholders but example.body_text is present", - ) - return - - if not isinstance(body_text, list) or not body_text: - result.error( - "components[BODY].example.body_text", - "example.body_text must be a non-empty list of sample rows", - ) - return - first_row = body_text[0] - if not isinstance(first_row, list): - result.error( - "components[BODY].example.body_text", - "example.body_text must be array-of-arrays (one row per variable group)", - ) - return - if len(first_row) != placeholder_count: + placeholders = [(int(match.group(1)), match.group(2)) for match in PLACEHOLDER_RE.finditer(template)] + malformed = [match.group(0) for match in ANY_PLACEHOLDER_RE.finditer(template) if not PLACEHOLDER_RE.fullmatch(match.group(0))] + if malformed: result.error( - "components[BODY].example.body_text", - f"sample count {len(first_row)} does not match placeholder count {placeholder_count}", + f"{field}.template", + "use Sent placeholders such as '{{0:variable}}'; malformed: " + ", ".join(malformed), ) - -def _check_utility_promo(payload: dict[str, Any], result: LintResult) -> None: - if payload.get("category") != "UTILITY": - return - components = payload.get("components") - if not isinstance(components, list): - return - body_list = _components_by_type(components).get("BODY", []) - if not body_list: + variables = content.get("variables", []) + if variables is None: + variables = [] + if not isinstance(variables, list): + result.error(f"{field}.variables", "must be an array") return - text = body_list[0].get("text") - if not isinstance(text, str): - return - lowered = text.lower() - for phrase in PROMO_FAIL_PHRASES: - if phrase in lowered: - result.error( - "components[BODY].text", - f"UTILITY body contains banned promotional phrase '{phrase}'", - ) - for phrase in PROMO_WARN_PHRASES: - if phrase in lowered: - result.warn( - "components[BODY].text", - f"UTILITY body contains promotional phrase '{phrase}' — Meta is likely to re-categorize as MARKETING", - ) - -def _check_header(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - headers = _components_by_type(components).get("HEADER", []) - if not headers: - return - header = headers[0] - # In the Cloud API shape used by these fixtures, HEADER type discriminator - # is `format`; in Sent's shape it would be `type`. Accept either. - fmt = header.get("format") - if fmt is None: - fmt = header.get("type") - if not isinstance(fmt, str): - result.error( - "components[HEADER].format", - "HEADER component requires a 'format' (TEXT/IMAGE/VIDEO/DOCUMENT)", + expected = {variable_id: kind for variable_id, kind in placeholders} + if len(expected) != len({variable_id for variable_id, _ in placeholders}): + result.error(f"{field}.template", "one placeholder id cannot be reused with different types") + actual_ids: list[int] = [] + for index, variable in enumerate(variables): + variable_id = variable.get("id") if isinstance(variable, dict) else None + checked_id = _check_variable( + variable, + f"{field}.variables[{index}]", + expected.get(variable_id) if isinstance(variable_id, int) else None, + result, ) + if checked_id is not None: + actual_ids.append(checked_id) + duplicates = [str(key) for key, count in Counter(actual_ids).items() if count > 1] + if duplicates: + result.error(f"{field}.variables", "duplicate variable ids: " + ", ".join(duplicates)) + missing = sorted(set(expected) - set(actual_ids)) + extra = sorted(set(actual_ids) - set(expected)) + if missing: + result.error(f"{field}.variables", f"missing definitions for placeholder ids {missing}") + if extra: + result.error(f"{field}.variables", f"variables without matching placeholders: {extra}") + + +def _check_header_or_footer(value: Any, field: str, limit: int, result: LintResult) -> None: + if value is None: return - if fmt.upper() not in SENT_HEADER_TYPES: - result.error( - "components[HEADER].format", - f"header format '{fmt}' must be one of {sorted(SENT_HEADER_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - - -def _check_buttons(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): + if not isinstance(value, dict): + result.error(field, "must be an object or null") return - button_components = _components_by_type(components).get("BUTTONS", []) - if not button_components: + _reject_unknown_fields(value, {"type", "template", "variables"}, field, result) + template = value.get("template") + if not isinstance(template, str): + result.error(f"{field}.template", "must be a string") return - buttons = button_components[0].get("buttons") - if not isinstance(buttons, list) or not buttons: - result.error("components[BUTTONS].buttons", "buttons list must be non-empty") + if len(template) > limit: + result.error(f"{field}.template", f"exceeds the {limit}-character limit") + if field.endswith("footer") and (ANY_PLACEHOLDER_RE.search(template) or value.get("variables")): + result.error(field, "footer variables are not supported") + elif field.endswith("header"): + _check_content({"template": template, "variables": value.get("variables", [])}, field, result) + + +def _check_button(button: Any, index: int, result: LintResult) -> str | None: + field = f"definition.buttons[{index}]" + if not isinstance(button, dict): + result.error(field, "button must be an object") + return None + _reject_unknown_fields(button, {"id", "type", "props"}, field, result) + button_type = button.get("type") + if button_type not in VALID_BUTTON_TYPES: + result.error(f"{field}.type", f"must be one of {sorted(VALID_BUTTON_TYPES)}") + return None + props = button.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + return button_type + text = props.get("text") + if not _nonempty(text) or len(text) > 25: + result.error(f"{field}.props.text", "must be 1–25 characters") + if button_type == "QUICK_REPLY" and not _nonempty(props.get("quickReplyType")): + result.error(f"{field}.props.quickReplyType", "is required for QUICK_REPLY") + elif button_type == "URL": + if not _nonempty(props.get("urlType")): + result.error(f"{field}.props.urlType", "is required for URL") + if not _nonempty(props.get("url")): + result.error(f"{field}.props.url", "is required for URL") + elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}: + if not _nonempty(props.get("countryCode")): + result.error(f"{field}.props.countryCode", f"is required for {button_type}") + if not _nonempty(props.get("phoneNumber")): + result.error(f"{field}.props.phoneNumber", f"is required for {button_type}") + elif button_type == "COPY_CODE" and not _nonempty(props.get("offerCode")): + result.error(f"{field}.props.offerCode", "is required for COPY_CODE") + return button_type + + +def _check_definition(payload: dict[str, Any], result: LintResult) -> None: + definition = payload.get("definition") + if not isinstance(definition, dict): + result.error("definition", "required and must be an object") return - types = [] - for i, b in enumerate(buttons): - t = b.get("type") - if not isinstance(t, str): - result.error(f"components[BUTTONS].buttons[{i}].type", "button type missing") - continue - upper = t.upper() - types.append(upper) - - # Sent's button type enum is QUICK_REPLY|URL|PHONE_NUMBER. Cloud-API OTP - # buttons (auth-only) are surfaced as a warning rather than a hard fail - # because Cloud-API-shaped fixtures use them; they are not part of the - # Sent CreateTemplateRequest button enum. - if upper not in SENT_BUTTON_TYPES and upper != "OTP": - result.error( - f"components[BUTTONS].buttons[{i}].type", - f"button type '{t}' must be one of {sorted(SENT_BUTTON_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - continue - if upper == "OTP": - result.warn( - f"components[BUTTONS].buttons[{i}].type", - "'OTP' is a Cloud-API auth-only button type; not part of Sent's " - "button enum (QUICK_REPLY/URL/PHONE_NUMBER)", + _reject_unknown_fields( + definition, + {"header", "body", "footer", "buttons", "definitionVersion", "authenticationConfig"}, + "definition", + result, + ) + body = definition.get("body") + if not isinstance(body, dict): + result.error("definition.body", "required and must be an object") + else: + _reject_unknown_fields(body, VALID_BODY_CHANNELS, "definition.body", result) + if body.get("multiChannel") is None: + result.error("definition.body.multiChannel", "is required as the channel-neutral body") + for channel, content in body.items(): + if channel in VALID_BODY_CHANNELS and content is not None: + _check_content(content, f"definition.body.{channel}", result) + + _check_header_or_footer(definition.get("header"), "definition.header", 60, result) + _check_header_or_footer(definition.get("footer"), "definition.footer", 60, result) + + buttons = definition.get("buttons", []) + if buttons is None: + buttons = [] + if not isinstance(buttons, list): + result.error("definition.buttons", "must be an array or null") + buttons = [] + elif len(buttons) > 10: + result.error("definition.buttons", f"at most 10 buttons are allowed, got {len(buttons)}") + counts = Counter(filter(None, (_check_button(button, index, result) for index, button in enumerate(buttons)))) + for button_type, limit in BUTTON_LIMITS.items(): + if counts[button_type] > limit: + result.error("definition.buttons", f"{button_type} allows at most {limit}, got {counts[button_type]}") + + authentication = definition.get("authenticationConfig") + category = payload.get("category") + if authentication is not None: + if category != "AUTHENTICATION": + result.error("definition.authenticationConfig", "is only valid for AUTHENTICATION templates") + if not isinstance(authentication, dict): + result.error("definition.authenticationConfig", "must be an object or null") + else: + _reject_unknown_fields( + authentication, + {"addSecurityRecommendation", "codeExpirationMinutes"}, + "definition.authenticationConfig", + result, ) - - if upper == "URL": - url = b.get("url") - if not isinstance(url, str) or not url.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].url", - "URL buttons require a non-empty 'url' field", - ) - if upper == "PHONE_NUMBER": - phone = b.get("phone_number") - if not isinstance(phone, str) or not phone.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].phone_number", - "PHONE_NUMBER buttons require a non-empty 'phone_number' field", - ) - - quick = sum(1 for t in types if t == QUICK_REPLY_TYPE) - cta = sum(1 for t in types if t in CTA_BUTTON_TYPES) - if quick and cta: - result.error( - "components[BUTTONS].buttons", - "cannot mix QUICK_REPLY and CTA buttons in the same template", - ) - return - if quick > 3: - result.error( - "components[BUTTONS].buttons", - f"at most 3 QUICK_REPLY buttons allowed, got {quick}", - ) - if cta > 2: - result.error( - "components[BUTTONS].buttons", - f"at most 2 CTA buttons allowed, got {cta}", - ) + recommendation = authentication.get("addSecurityRecommendation") + if recommendation is not None and not isinstance(recommendation, bool): + result.error("definition.authenticationConfig.addSecurityRecommendation", "must be boolean") + expiration = authentication.get("codeExpirationMinutes") + if expiration is not None and (not isinstance(expiration, int) or not 1 <= expiration <= 90): + result.error("definition.authenticationConfig.codeExpirationMinutes", "must be an integer from 1 to 90") + if category == "AUTHENTICATION": + if authentication is None: + result.error("definition.authenticationConfig", "is required for AUTHENTICATION templates") + if any(button_type != "COPY_CODE" for button_type in counts): + result.error("definition.buttons", "AUTHENTICATION templates may only use COPY_CODE buttons") + if counts["COPY_CODE"] != 1: + result.error("definition.buttons", "AUTHENTICATION templates require exactly one COPY_CODE button") def lint_template(payload: Any) -> LintResult: @@ -320,52 +285,63 @@ def lint_template(payload: Any) -> LintResult: if not isinstance(payload, dict): result.error("", "template payload must be a JSON object") return result - _check_top_level(payload, result) - _check_body(payload, result) - _check_utility_promo(payload, result) - _check_header(payload, result) - _check_buttons(payload, result) + if "components" in payload: + result.error( + "components", + "Meta Cloud API components[] is not a Sent payload; convert it to definition before POST /v3/templates", + ) + for field in sorted(CREATE_UNSUPPORTED_FIELDS & set(payload)): + result.error(field, "unsupported top-level create field") + _reject_unknown_fields(payload, TOP_LEVEL_FIELDS, "", result) + category = payload.get("category") + if category is not None and category not in VALID_CATEGORIES: + result.error("category", f"must be one of {sorted(VALID_CATEGORIES)} or null") + language = payload.get("language") + if language is not None and (not isinstance(language, str) or not LANGUAGE_RE.fullmatch(language)): + result.error("language", "must look like en or en_US") + for field in ("submit_for_review", "sandbox"): + if field in payload and not isinstance(payload[field], bool): + result.error(field, "must be boolean") + _check_definition(payload, result) + + if category == "UTILITY": + body = payload.get("definition", {}).get("body", {}).get("multiChannel", {}) + text = body.get("template", "") if isinstance(body, dict) else "" + lowered = text.lower() + for phrase in PROMO_FAIL_PHRASES: + if phrase in lowered: + result.error("definition.body.multiChannel.template", f"UTILITY body contains promotional phrase {phrase!r}") + for phrase in PROMO_WARN_PHRASES: + if phrase in lowered: + result.warn("definition.body.multiChannel.template", f"Meta may reclassify promotional phrase {phrase!r} as MARKETING") return result def _format(prefix: str, entries: list[tuple[str, str]]) -> str: - return "\n".join(f"{prefix} {field}: {msg}" for field, msg in entries) + return "\n".join(f"{prefix} {field}: {message}" for field, message in entries) -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser( - prog="lint_waba_template.py", - description="Lint a Sent WhatsApp template payload against Meta's structural and category rules.", - ) - parser.add_argument( - "path", - type=Path, - help="Path to a JSON file containing a WhatsApp template payload.", - ) +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path, help="Sent POST /v3/templates request JSON") args = parser.parse_args(argv) - try: - raw = args.path.read_text(encoding="utf-8") + payload = json.loads(args.path.read_text(encoding="utf-8")) except OSError as exc: print(f"could not read {args.path}: {exc}", file=sys.stderr) return 2 - try: - payload = json.loads(raw) except json.JSONDecodeError as exc: print(f"invalid JSON in {args.path}: {exc}", file=sys.stderr) return 2 - result = lint_template(payload) - if result.warnings: print(_format("WARN", result.warnings)) if result.errors: print(_format("FAIL", result.errors)) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + raise SystemExit(main()) diff --git a/schemas/sent/v3-contract-manifest.json b/schemas/sent/v3-contract-manifest.json new file mode 100644 index 0000000..8bfbe23 --- /dev/null +++ b/schemas/sent/v3-contract-manifest.json @@ -0,0 +1,126 @@ +{ + "manifest_version": 1, + "source": "https://api.sent.dm/swagger/v3/swagger.json", + "verified_at": "2026-08-09", + "critical_paths": { + "/v3/templates": ["GET", "POST"], + "/v3/templates/{id}": ["DELETE", "GET", "PUT"], + "/v3/profiles": ["GET", "POST"], + "/v3/profiles/{profileId}": ["DELETE", "GET", "PATCH"], + "/v3/profiles/{profileId}/complete": ["POST"], + "/v3/profiles/{profileId}/campaigns": ["GET", "POST"], + "/v3/profiles/{profileId}/campaigns/{campaignId}": ["DELETE", "PUT"], + "/v3/messages": ["POST"], + "/v3/messages/{id}": ["GET"], + "/v3/messages/{id}/activities": ["GET"], + "/v3/webhooks": ["GET", "POST"], + "/v3/webhooks/event-types": ["GET"] + }, + "retired_guidance_paths": [ + "/v3/brands", + "/v3/brands/{brandId}/campaigns" + ], + "authentication": { + "required_header": "x-api-key", + "organization_scope_header": "x-profile-id", + "profile_key_scope_header_result": 403, + "legacy_headers": ["x-sender-id"] + }, + "template_create": { + "required_fields": ["definition"], + "allowed_fields": [ + "category", + "language", + "definition", + "creation_source", + "submit_for_review", + "sandbox" + ], + "unsupported_top_level_fields": ["name", "channels", "body", "header", "buttons", "components"], + "body_fields": ["multiChannel", "sms", "whatsapp", "rcs"], + "body_max_length": 1024, + "placeholder_example": "{{0:variable}}", + "variable_required_fields": ["id", "name", "type", "props"], + "variable_sample_field": "props.sample", + "button_types": ["QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"], + "button_limits": { + "total": 10, + "URL": 2, + "VOICE_CALL": 1, + "PHONE_NUMBER": 1, + "COPY_CODE": 1 + }, + "authentication_config_fields": ["addSecurityRecommendation", "codeExpirationMinutes"], + "resource_statuses": ["DRAFT", "PENDING", "APPROVED", "REJECTED", "PAUSED"] + }, + "template_webhook": { + "field": "templates", + "forbidden_envelope_fields": ["sub_type", "event"], + "known_provider_statuses": ["PENDING", "APPROVED", "REJECTED", "CATEGORY_UPDATED", "PAUSED", "DISABLED"] + }, + "profile": { + "create_required_fields": ["name"], + "sharing_fields": ["allow_contact_sharing", "allow_template_sharing"], + "inheritance_fields": ["inherit_contacts", "inherit_templates", "inherit_tcr_brand", "inherit_tcr_campaign"], + "billing_models": ["profile", "organization", "profile_and_organization"], + "number_fields": [ + "sending_phone_number_profile_id", + "sending_whatsapp_number_profile_id", + "sending_phone_number", + "whatsapp_phone_number", + "allow_number_change_during_onboarding" + ], + "waba_required_fields": ["waba_id", "access_token"], + "waba_optional_fields": ["phone_number_id"], + "complete_required_fields": ["webHookUrl"], + "create_status_example": "incomplete", + "complete_200_status_example": "completed", + "completion_callback_events": ["COMPLETED", "SUBMITTED", "failed"] + }, + "campaign": { + "request_wrapper": ["campaign", "sandbox"], + "required_fields": ["name", "description", "type", "useCases"], + "optional_fields": [ + "volume", + "messageFlow", + "privacyPolicyLink", + "termsAndConditionsLink", + "optinMessage", + "optoutMessage", + "helpMessage", + "optinKeywords", + "optoutKeywords", + "helpKeywords" + ], + "use_case_fields": ["messagingUseCaseUs", "sampleMessages"], + "use_case_values": [ + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME" + ], + "sample_min": 1, + "sample_max": 5, + "sample_max_length": 1024, + "volume_type": "numeric-string", + "volume_tier_boundary": 2000, + "statuses": ["SENT_CREATED", "ACTIVE", "EXPIRED"], + "submission_field": "submittedToTCR" + }, + "routing": { + "automatic": ["channel omitted", "sent"], + "pinned": ["sms", "whatsapp", "rcs"], + "multiple_explicit_channels": "broadcast", + "rcs_current_capabilities": ["text", "up to four suggestion chips"], + "rcs_roadmap_capabilities": ["rich cards", "carousels", "media attachments"] + } +} diff --git a/scripts/check_live_contract.py b/scripts/check_live_contract.py new file mode 100644 index 0000000..51df776 --- /dev/null +++ b/scripts/check_live_contract.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Compare the checked-in critical contract manifest with Sent's live v3 OpenAPI.""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.request +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = ROOT / "schemas" / "sent" / "v3-contract-manifest.json" + + +def resolve(document: dict[str, Any], value: dict[str, Any]) -> dict[str, Any]: + if "$ref" in value: + target: Any = document + for part in value["$ref"].removeprefix("#/").split("/"): + target = target[part] + return resolve(document, target) + merged: dict[str, Any] = {key: item for key, item in value.items() if key != "allOf"} + for member in value.get("allOf", []): + child = resolve(document, member) + merged.setdefault("properties", {}).update(child.get("properties", {})) + merged["required"] = sorted(set(merged.get("required", [])) | set(child.get("required", []))) + return merged + + +def schema(document: dict[str, Any], name: str) -> dict[str, Any]: + return resolve(document, document["components"]["schemas"][name]) + + +def compare(document: dict[str, Any], manifest: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for path, methods in manifest["critical_paths"].items(): + if path not in document.get("paths", {}): + errors.append(f"missing live path: {path}") + continue + live_methods = {method.upper() for method in document["paths"][path] if method.lower() in {"get", "post", "put", "patch", "delete"}} + if live_methods != set(methods): + errors.append(f"method drift at {path}: manifest={methods}, live={sorted(live_methods)}") + for retired in manifest["retired_guidance_paths"]: + if retired in document.get("paths", {}): + errors.append(f"retired guidance path is live again and needs review: {retired}") + + template = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsCreateTemplateRequest") + if set(template.get("properties", {})) != set(manifest["template_create"]["allowed_fields"]): + errors.append("template create field drift") + body_content = schema(document, "SentDmServicesCommonEntitiesTemplateBodyContent") + if body_content.get("properties", {}).get("template", {}).get("maxLength") != manifest["template_create"]["body_max_length"]: + errors.append("template body maximum drift") + button_description = schema(document, "SentDmServicesCommonEntitiesTemplateButton")["properties"]["type"].get("description", "") + for button_type in manifest["template_create"]["button_types"]: + if button_type not in button_description: + errors.append(f"template button type absent from live description: {button_type}") + + create_profile = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsCreateProfileRequest") + if set(manifest["profile"]["create_required_fields"]) - set(create_profile.get("required", [])): + errors.append("profile create required-field drift") + waba = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsWhatsappBusinessAccountCredentials") + if set(waba.get("required", [])) != set(manifest["profile"]["waba_required_fields"]): + errors.append("WABA credential required-field drift") + complete = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsProfilesCompleteProfileRequest") + if set(complete.get("required", [])) != set(manifest["profile"]["complete_required_fields"]): + errors.append("profile completion request drift") + + campaign = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsCampaignsCampaignData") + if set(campaign.get("required", [])) != set(manifest["campaign"]["required_fields"]): + errors.append("campaign required-field drift") + use_cases = schema(document, "SentDmServicesCommonEnumsMessagingUseCaseUS").get("enum", []) + if use_cases != manifest["campaign"]["use_case_values"]: + errors.append("campaign use-case enum drift") + samples = schema(document, "SentDmServicesEndpointsCustomerAPIv3RequestsCampaignsCampaignUseCaseData")["properties"]["sampleMessages"] + if (samples.get("minItems"), samples.get("maxItems")) != ( + manifest["campaign"]["sample_min"], + manifest["campaign"]["sample_max"], + ): + errors.append("campaign sample-count drift") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--openapi", type=Path, help="read a local OpenAPI document instead of the live URL") + args = parser.parse_args() + manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + if args.openapi: + document = json.loads(args.openapi.read_text(encoding="utf-8")) + else: + request = urllib.request.Request(manifest["source"], headers={"User-Agent": "sent-plugin-contract-check/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + document = json.load(response) + errors = compare(document, manifest) + if errors: + print("Live Sent contract drift detected:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("Live Sent v3 critical contract matches the checked-in manifest.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_model_routing_eval.py b/scripts/run_model_routing_eval.py new file mode 100644 index 0000000..54ae71c --- /dev/null +++ b/scripts/run_model_routing_eval.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Run every routing YAML case through a model and enforce release thresholds. + +Requires ``OPENAI_API_KEY``. The evaluator asks the model whether one candidate +skill should trigger, not which skill wins globally, so YAML expectations map +directly to trigger/no_trigger/ambiguous labels. +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Any + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +EVALS = ROOT / "evals" +SKILLS = ROOT / "packages" / "sent" / "skills" +LABELS = ("trigger", "no_trigger", "ambiguous") + + +def load_cases() -> list[dict[str, str]]: + cases: list[dict[str, str]] = [] + for path in sorted(EVALS.glob("*.yaml")): + suite = yaml.safe_load(path.read_text(encoding="utf-8")) + skill = suite["skill"] + description = yaml.safe_load( + (SKILLS / skill / "SKILL.md").read_text(encoding="utf-8").split("---", 2)[1] + )["description"] + for index, case in enumerate(suite["cases"], 1): + cases.append( + { + "id": f"{skill}:{index}", + "skill": skill, + "description": description, + "query": case["query"], + "expect": case["expect"], + } + ) + return cases + + +def response_text(response: dict[str, Any]) -> str: + for item in response.get("output", []): + for content in item.get("content", []): + if content.get("type") == "output_text": + return content["text"] + raise RuntimeError("model response did not contain output_text") + + +def predict(cases: list[dict[str, str]]) -> dict[str, str]: + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is required for model-backed routing evaluation") + model = os.environ.get("SENT_ROUTING_EVAL_MODEL", "gpt-5-mini") + inputs = [ + { + "id": case["id"], + "candidate_skill": case["skill"], + "candidate_description": case["description"], + "user_query": case["query"], + } + for case in cases + ] + schema = { + "type": "object", + "additionalProperties": False, + "required": ["predictions"], + "properties": { + "predictions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id", "label"], + "properties": { + "id": {"type": "string"}, + "label": {"type": "string", "enum": list(LABELS)}, + }, + }, + } + }, + } + body = { + "model": model, + "instructions": ( + "For each independent case, decide whether the candidate skill should trigger for the user query. " + "Use trigger when it clearly owns the request, no_trigger when it does not, and ambiguous when it " + "substantially overlaps another skill or needs routing clarification. Return every id exactly once." + ), + "input": json.dumps(inputs, ensure_ascii=False), + "text": { + "format": { + "type": "json_schema", + "name": "sent_routing_predictions", + "strict": True, + "schema": schema, + } + }, + } + request = urllib.request.Request( + "https://api.openai.com/v1/responses", + data=json.dumps(body).encode("utf-8"), + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=300) as response: + parsed = json.load(response) + result = json.loads(response_text(parsed))["predictions"] + return {item["id"]: item["label"] for item in result} + + +def evaluate(cases: list[dict[str, str]], predictions: dict[str, str]) -> int: + expected_ids = {case["id"] for case in cases} + if set(predictions) != expected_ids: + missing = sorted(expected_ids - set(predictions)) + extra = sorted(set(predictions) - expected_ids) + print(f"Prediction coverage mismatch: missing={missing}, extra={extra}", file=sys.stderr) + return 1 + confusion: Counter[tuple[str, str]] = Counter() + failures: list[dict[str, str]] = [] + for case in cases: + predicted = predictions[case["id"]] + expected = case["expect"] + confusion[(expected, predicted)] += 1 + if predicted != expected: + failures.append({**case, "predicted": predicted}) + total = len(cases) + correct = total - len(failures) + accuracy = correct / total if total else 0.0 + hard_negatives = [case for case in cases if case["expect"] == "no_trigger"] + hard_negative_success = sum(predictions[case["id"]] == "no_trigger" for case in hard_negatives) / len(hard_negatives) + + print("Confusion matrix (expected rows, predicted columns):") + print("expected\\predicted\t" + "\t".join(LABELS)) + for expected in LABELS: + print(expected + "\t" + "\t".join(str(confusion[(expected, predicted)]) for predicted in LABELS)) + print(f"Overall routing accuracy: {correct}/{total} ({accuracy:.1%})") + print(f"Hard-negative success: {hard_negative_success:.1%}") + if failures: + print("Newly ambiguous or misrouted prompts:") + for failure in failures: + print( + f"- {failure['id']}: expected={failure['expect']} predicted={failure['predicted']} " + f"query={failure['query']!r}" + ) + if hard_negative_success < 1.0 or accuracy < 0.95: + return 1 + return 0 + + +def main() -> int: + cases = load_cases() + predictions = predict(cases) + return evaluate(cases, predictions) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_contracts.py b/scripts/test_contracts.py new file mode 100644 index 0000000..97cd50a --- /dev/null +++ b/scripts/test_contracts.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Executable regression tests for Sent template, campaign, and routing contracts.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import re +import unittest +from pathlib import Path +from types import ModuleType + + +ROOT = Path(__file__).resolve().parents[1] +SKILLS = ROOT / "packages" / "sent" / "skills" +MANIFEST = json.loads((ROOT / "schemas" / "sent" / "v3-contract-manifest.json").read_text(encoding="utf-8")) + + +def load_module(name: str, path: Path) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +TEMPLATE = load_module( + "sent_template_linter", + SKILLS / "waba-template-author" / "scripts" / "lint_waba_template.py", +) +CAMPAIGN = load_module( + "sent_campaign_validator", + SKILLS / "sms-10dlc-registration" / "scripts" / "validate_campaign_payload.py", +) + + +def template_base() -> dict: + return { + "category": "UTILITY", + "language": "en_US", + "definition": { + "header": None, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}.", + "variables": [ + {"id": 0, "name": "name", "type": "variable", "props": {"sample": "Avery"}} + ], + } + }, + "footer": None, + "buttons": [], + "definitionVersion": "1.0", + "authenticationConfig": None, + }, + "creation_source": "from-api", + "submit_for_review": False, + "sandbox": True, + } + + +def button(button_type: str, index: int = 1) -> dict: + props: dict[str, object] = {"text": f"Action {index}"} + if button_type == "QUICK_REPLY": + props["quickReplyType"] = "custom" + elif button_type == "URL": + props.update(urlType="static", url=f"https://example.com/{index}") + elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}: + props.update(countryCode="US", phoneNumber="+12025550100") + elif button_type == "COPY_CODE": + props["offerCode"] = "482193" + return {"id": index, "type": button_type, "props": props} + + +def campaign_base(use_case: str = "ACCOUNT_NOTIFICATION", samples: int = 1) -> dict: + return { + "campaign": { + "name": "Synthetic campaign", + "description": "Synthetic account notifications for opted-in recipients.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": use_case, + "sampleMessages": [f"Acme Example: Sample notification {index}." for index in range(samples)], + } + ], + "volume": "2000", + }, + "sandbox": True, + } + + +class TemplateContractTests(unittest.TestCase): + def assert_valid(self, payload: dict) -> None: + result = TEMPLATE.lint_template(payload) + self.assertFalse(result.errors, result.errors) + + def assert_invalid(self, payload: dict, fragment: str) -> None: + result = TEMPLATE.lint_template(payload) + rendered = "\n".join(f"{field}: {message}" for field, message in result.errors) + self.assertIn(fragment, rendered) + + def test_all_button_types_and_mixed_kinds(self) -> None: + payload = template_base() + payload["definition"]["buttons"] = [button(kind, index) for index, kind in enumerate(sorted(TEMPLATE.VALID_BUTTON_TYPES), 1)] + self.assert_valid(payload) + + def test_button_boundaries(self) -> None: + payload = template_base() + payload["definition"]["buttons"] = [button("QUICK_REPLY", index) for index in range(1, 11)] + self.assert_valid(payload) + payload["definition"]["buttons"].append(button("QUICK_REPLY", 11)) + self.assert_invalid(payload, "at most 10 buttons") + payload = template_base() + payload["definition"]["buttons"] = [button("URL", index) for index in range(1, 4)] + self.assert_invalid(payload, "URL allows at most 2") + + def test_body_length_and_variable_boundaries(self) -> None: + payload = template_base() + content = payload["definition"]["body"]["multiChannel"] + content["template"] = "a" * 1024 + content["variables"] = [] + self.assert_valid(payload) + content["template"] += "a" + self.assert_invalid(payload, "1,024-character") + payload = template_base() + payload["definition"]["body"]["multiChannel"]["variables"][0]["props"] = {} + self.assert_invalid(payload, "props.sample") + + def test_channel_override(self) -> None: + payload = template_base() + payload["definition"]["body"]["whatsapp"] = { + "type": "body", + "template": "Hello {{1:variable}}.", + "variables": [{"id": 1, "name": "name", "type": "variable", "props": {"sample": "Morgan"}}], + } + self.assert_valid(payload) + + def test_authentication_restrictions(self) -> None: + payload = template_base() + payload["category"] = "AUTHENTICATION" + payload["definition"]["buttons"] = [button("COPY_CODE")] + payload["definition"]["authenticationConfig"] = { + "addSecurityRecommendation": True, + "codeExpirationMinutes": 10, + } + self.assert_valid(payload) + payload["definition"]["authenticationConfig"]["codeExpirationMinutes"] = 91 + self.assert_invalid(payload, "1 to 90") + + def test_cloud_api_shape_rejected(self) -> None: + result = TEMPLATE.lint_template({"category": "UTILITY", "components": []}) + rendered = "\n".join(message for _, message in result.errors) + self.assertIn("Meta Cloud API", rendered) + + def test_manifest_statuses_and_buttons(self) -> None: + contract = MANIFEST["template_create"] + self.assertEqual(set(contract["button_types"]), TEMPLATE.VALID_BUTTON_TYPES) + self.assertEqual(contract["resource_statuses"], ["DRAFT", "PENDING", "APPROVED", "REJECTED", "PAUSED"]) + + +class CampaignContractTests(unittest.TestCase): + def test_all_use_case_values(self) -> None: + for use_case in MANIFEST["campaign"]["use_case_values"]: + samples = 2 if use_case in CAMPAIGN.POLICY_TWO_SAMPLE_CASES else 1 + self.assertEqual(CAMPAIGN.validate(campaign_base(use_case, samples)), [], use_case) + + def test_sample_count_boundaries(self) -> None: + for count, valid in ((0, False), (1, True), (2, True), (5, True), (6, False)): + issues = CAMPAIGN.validate(campaign_base("ACCOUNT_NOTIFICATION", count)) + self.assertEqual(not issues, valid, (count, issues)) + + def test_marketing_and_mixed_policy(self) -> None: + for use_case in CAMPAIGN.POLICY_TWO_SAMPLE_CASES: + self.assertTrue(CAMPAIGN.validate(campaign_base(use_case, 1)), use_case) + self.assertFalse(CAMPAIGN.validate(campaign_base(use_case, 2)), use_case) + + def test_sample_length_and_volume_boundary(self) -> None: + payload = campaign_base() + payload["campaign"]["useCases"][0]["sampleMessages"] = ["a" * 1024] + self.assertFalse(CAMPAIGN.validate(payload)) + payload["campaign"]["useCases"][0]["sampleMessages"] = ["a" * 1025] + self.assertTrue(CAMPAIGN.validate(payload)) + for volume in ("1999", "2000"): + payload = campaign_base() + payload["campaign"]["volume"] = volume + self.assertFalse(CAMPAIGN.validate(payload), volume) + payload["campaign"]["volume"] = 2000 + self.assertTrue(CAMPAIGN.validate(payload)) + + def test_exact_camel_case(self) -> None: + payload = campaign_base() + payload["campaign"]["use_cases"] = payload["campaign"].pop("useCases") + rendered = "\n".join(CAMPAIGN.validate(payload)) + self.assertIn("exact camelCase", rendered) + + +class BundledExampleTests(unittest.TestCase): + def test_every_markdown_json_block_parses(self) -> None: + for path in SKILLS.rglob("*.md"): + for index, match in enumerate(re.finditer(r"```json\s*\n(.*?)```", path.read_text(encoding="utf-8"), re.DOTALL), 1): + with self.subTest(path=path, block=index): + json.loads(match.group(1)) + + def test_marked_sent_template_examples_lint(self) -> None: + pattern = re.compile(r"\s*```json\s*\n(.*?)```", re.DOTALL) + found = 0 + for path in SKILLS.rglob("*.md"): + for match in pattern.finditer(path.read_text(encoding="utf-8")): + found += 1 + result = TEMPLATE.lint_template(json.loads(match.group(1))) + self.assertFalse(result.errors, (path, result.errors)) + self.assertGreaterEqual(found, 2) + + def test_marked_campaign_examples_validate(self) -> None: + pattern = re.compile(r"\s*```json\s*\n(.*?)```", re.DOTALL) + found = 0 + for path in SKILLS.rglob("*.md"): + for match in pattern.finditer(path.read_text(encoding="utf-8")): + found += 1 + self.assertEqual(CAMPAIGN.validate(json.loads(match.group(1))), [], path) + self.assertGreaterEqual(found, 1) + + def test_no_retired_paths_or_false_fallback(self) -> None: + corpus = "\n".join(path.read_text(encoding="utf-8") for path in SKILLS.rglob("*.md")) + for retired in MANIFEST["retired_guidance_paths"]: + self.assertNotIn(retired, corpus) + self.assertNotRegex(corpus, r'\[\s*"rcs"\s*,\s*"sms"\s*\]') + self.assertNotRegex(corpus, r'\[\s*"sms"\s*,\s*"rcs"\s*\]') + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test_fixtures.py b/scripts/test_fixtures.py index 365a8fc..df94d6b 100644 --- a/scripts/test_fixtures.py +++ b/scripts/test_fixtures.py @@ -35,6 +35,18 @@ SKILLS / "sms-10dlc-registration" / "scripts" / "fixtures" / "bad.json", 1, ), + ( + "10DLC campaign good", + SKILLS / "sms-10dlc-registration" / "scripts" / "validate_campaign_payload.py", + SKILLS / "sms-10dlc-registration" / "scripts" / "fixtures" / "campaign_good.json", + 0, + ), + ( + "10DLC campaign bad", + SKILLS / "sms-10dlc-registration" / "scripts" / "validate_campaign_payload.py", + SKILLS / "sms-10dlc-registration" / "scripts" / "fixtures" / "campaign_bad.json", + 1, + ), ( "WABA template good", SKILLS / "waba-template-author" / "scripts" / "lint_waba_template.py", @@ -69,7 +81,7 @@ def main() -> None: print(f"PASS {label}: exit {expected}") if failures: raise SystemExit("\n\n".join(failures)) - print("Fixture contract preserved: MDR 0/3, 10DLC 0/1, WABA template 0/1.") + print("Fixture contract preserved: MDR 0/3, 10DLC evidence 0/1, campaign 0/1, WABA template 0/1.") if __name__ == "__main__": diff --git a/scripts/test_validation_gates.py b/scripts/test_validation_gates.py index 9b782b2..ce22475 100644 --- a/scripts/test_validation_gates.py +++ b/scripts/test_validation_gates.py @@ -106,6 +106,37 @@ def wrong_openai_test_count(root: Path) -> None: path.write_text(json.dumps(submission, indent=2) + "\n", encoding="utf-8") +def retired_brand_endpoint(root: Path) -> None: + path = root / "packages" / "sent" / "skills" / "sms-10dlc-registration" / "SKILL.md" + with path.open("a", encoding="utf-8") as handle: + handle.write("\nUse POST /v3/brands for registration.\n") + + +def template_sub_type(root: Path) -> None: + path = root / "packages" / "sent" / "skills" / "template-builder-ui" / "references" / "template-status-handling.md" + content = path.read_text(encoding="utf-8") + path.write_text(content.replace(' "field": "templates",\n', ' "field": "templates",\n "sub_type": "template.approved",\n', 1), encoding="utf-8") + + +def ordered_channel_fallback(root: Path) -> None: + path = root / "packages" / "sent" / "skills" / "rcs-agent-onboarding" / "SKILL.md" + with path.open("a", encoding="utf-8") as handle: + handle.write('\nUse ["rcs", "sms"] as ordered fallback.\n') + + +def thick_claude_command(root: Path) -> None: + path = root / "adapter-sources" / "claude" / "commands" / "rcs-onboard.md" + with path.open("a", encoding="utf-8") as handle: + handle.write("\nThen follow a provider-specific provisioning procedure.\n") + + +def contract_manifest_drift(root: Path) -> None: + path = root / "schemas" / "sent" / "v3-contract-manifest.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["template_create"]["body_max_length"] = 1028 + path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + CASES: tuple[tuple[str, Mutation, str], ...] = ( ("closed portable manifest", unknown_manifest_field, "plugin.json schema"), ("HTTPS MCP policy", insecure_mcp_url, "exact Sent Streamable HTTP endpoint"), @@ -120,6 +151,11 @@ def wrong_openai_test_count(root: Path) -> None: ("README skill catalog coverage", missing_readme_catalog_entry, "skill catalog does not link"), ("skill discovery descriptions", weak_skill_discovery_description, "discovery description must explain"), ("OpenAI test counts", wrong_openai_test_count, "exactly three negative test cases"), + ("retired brand endpoint", retired_brand_endpoint, "retired endpoint"), + ("template webhook envelope", template_sub_type, "template webhook example uses sub_type"), + ("ordered channel fallback", ordered_channel_fallback, "explicit RCS/SMS array"), + ("thin Claude command", thick_claude_command, "thin wrapper"), + ("checked-in contract manifest", contract_manifest_drift, "template body limit must be 1,024"), ) diff --git a/scripts/validate.py b/scripts/validate.py index 67f52fc..cf82577 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -24,6 +24,7 @@ ADAPTER_README = ROOT / "adapter-sources" / "shared" / "README.md" SCHEMAS = ROOT / "schemas" / "agent-plugins" / "1.0.0" OPENAI_SUBMISSION_SCHEMA = ROOT / "schemas" / "openai" / "chatgpt-app-submission.v1.json" +CONTRACT_MANIFEST = ROOT / "schemas" / "sent" / "v3-contract-manifest.json" VERSION = "0.1.0" MCP_URL = "https://mcp.sent.dm/mcp" PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" @@ -151,6 +152,25 @@ SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SVG_OPEN_TAG = re.compile(r"]*>", re.IGNORECASE) SVG_VIEWBOX = re.compile(r"\bviewBox\s*=\s*([\"'])([^\"']+)\1", re.IGNORECASE) +JSON_FENCE = re.compile(r"```json\s*\n(.*?)```", re.DOTALL) +TEMPLATE_REQUEST_FENCE = re.compile( + r"\s*```json\s*\n(.*?)```", + re.DOTALL, +) +CAMPAIGN_REQUEST_FENCE = re.compile( + r"\s*```json\s*\n(.*?)```", + re.DOTALL, +) +CLAUDE_COMMAND_SKILLS = { + "mdr-analyze": "messaging-performance-analyzer", + "rcs-onboard": "rcs-agent-onboarding", + "sender-plan": "sender-profile-architect", + "sent": "sent", + "sms-register": "sms-10dlc-registration", + "template-ui": "template-builder-ui", + "waba-auth": "waba-embedded-signup", + "waba-template": "waba-template-author", +} class Validation: @@ -459,6 +479,154 @@ def validate_evals(validation: Validation) -> None: ) +def validate_contract_manifest(validation: Validation) -> None: + validation.check(CONTRACT_MANIFEST.is_file(), "missing checked-in Sent v3 contract manifest") + if not CONTRACT_MANIFEST.is_file(): + return + manifest = load_json(CONTRACT_MANIFEST) + validation.check(manifest.get("manifest_version") == 1, "Sent contract manifest version must be 1") + validation.check( + manifest.get("source") == "https://api.sent.dm/swagger/v3/swagger.json", + "Sent contract manifest must identify the live v3 OpenAPI source", + ) + paths = manifest.get("critical_paths", {}) + expected_paths = { + "/v3/templates", + "/v3/templates/{id}", + "/v3/profiles", + "/v3/profiles/{profileId}", + "/v3/profiles/{profileId}/complete", + "/v3/profiles/{profileId}/campaigns", + "/v3/profiles/{profileId}/campaigns/{campaignId}", + "/v3/messages", + "/v3/messages/{id}", + "/v3/messages/{id}/activities", + "/v3/webhooks", + "/v3/webhooks/event-types", + } + validation.check(set(paths) == expected_paths, "Sent contract manifest critical path set drifted") + + template = manifest.get("template_create", {}) + validation.check(template.get("required_fields") == ["definition"], "template create must require definition") + validation.check(template.get("body_max_length") == 1024, "template body limit must be 1,024") + validation.check( + set(template.get("button_types", [])) + == {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"}, + "template button type manifest drifted", + ) + validation.check(template.get("button_limits", {}).get("total") == 10, "template button total must be 10") + validation.check( + template.get("resource_statuses") == ["DRAFT", "PENDING", "APPROVED", "REJECTED", "PAUSED"], + "template resource statuses drifted", + ) + webhook = manifest.get("template_webhook", {}) + validation.check(webhook.get("field") == "templates", "template webhook field must be templates") + validation.check( + set(webhook.get("forbidden_envelope_fields", [])) == {"sub_type", "event"}, + "template webhook must forbid sub_type and event", + ) + + auth = manifest.get("authentication", {}) + validation.check(auth.get("required_header") == "x-api-key", "v3 authentication header drifted") + validation.check(auth.get("organization_scope_header") == "x-profile-id", "profile scope header drifted") + validation.check(auth.get("profile_key_scope_header_result") == 403, "profile key x-profile-id result must be 403") + + campaign = manifest.get("campaign", {}) + validation.check(len(campaign.get("use_case_values", [])) == 13, "campaign manifest must contain 13 use cases") + validation.check( + (campaign.get("sample_min"), campaign.get("sample_max"), campaign.get("sample_max_length")) == (1, 5, 1024), + "campaign sample limits drifted", + ) + validation.check(campaign.get("volume_tier_boundary") == 2000, "campaign volume tier boundary must be 2,000") + validation.check( + campaign.get("statuses") == ["SENT_CREATED", "ACTIVE", "EXPIRED"] + and campaign.get("submission_field") == "submittedToTCR", + "campaign status/submission manifest drifted", + ) + routing = manifest.get("routing", {}) + validation.check(routing.get("multiple_explicit_channels") == "broadcast", "multiple channels must be broadcast") + + +def validate_contract_content(validation: Validation) -> None: + manifest = load_json(CONTRACT_MANIFEST) if CONTRACT_MANIFEST.is_file() else {} + markdown_files = list(SKILLS.rglob("*.md")) + corpus = "\n".join(path.read_text(encoding="utf-8") for path in markdown_files) + for retired in manifest.get("retired_guidance_paths", []): + validation.check(retired not in corpus, f"canonical skills contain retired endpoint {retired}") + for pattern in ( + r'\[\s*"rcs"\s*,\s*"sms"\s*\]', + r'\[\s*"sms"\s*,\s*"rcs"\s*\]', + ): + validation.check(not re.search(pattern, corpus), "canonical skills describe an explicit RCS/SMS array; use automatic routing") + + json_count = 0 + template_count = 0 + campaign_count = 0 + for path in markdown_files: + text = path.read_text(encoding="utf-8") + for index, match in enumerate(JSON_FENCE.finditer(text), 1): + json_count += 1 + try: + value = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + validation.errors.append(f"{path.relative_to(ROOT)}: JSON example {index} is invalid: {exc}") + continue + if isinstance(value, dict) and value.get("field") == "templates": + validation.check("sub_type" not in value, f"{path.relative_to(ROOT)}: template webhook example uses sub_type") + validation.check("event" not in value, f"{path.relative_to(ROOT)}: template webhook example uses event") + if path.parts[-3:-1] == ("waba-embedded-signup", "references") and isinstance(value, dict): + validation.check("sub_type" not in value, f"{path.relative_to(ROOT)}: WABA callback example uses sub_type") + template_count += len(TEMPLATE_REQUEST_FENCE.findall(text)) + campaign_count += len(CAMPAIGN_REQUEST_FENCE.findall(text)) + validation.check(json_count > 0, "no JSON examples found for contract parsing") + validation.check(template_count >= 2, "expected at least two marked Sent template request examples") + validation.check(campaign_count >= 1, "expected at least one marked Sent campaign request example") + + contract_tests = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "test_contracts.py")], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + validation.check( + contract_tests.returncode == 0, + contract_tests.stdout.strip() + "\n" + contract_tests.stderr.strip() + if contract_tests.returncode + else "contract tests failed", + ) + + +def validate_claude_command_sources(validation: Validation) -> None: + command_root = ROOT / "adapter-sources" / "claude" / "commands" + actual = {path.stem for path in command_root.glob("*.md")} + validation.check(actual == set(CLAUDE_COMMAND_SKILLS), f"Claude command source set mismatch: {sorted(actual)}") + for command, expected_skill in CLAUDE_COMMAND_SKILLS.items(): + path = command_root / f"{command}.md" + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + try: + _, raw_frontmatter, body = text.split("---", 2) + metadata = yaml.safe_load(raw_frontmatter) + except (ValueError, yaml.YAMLError) as exc: + validation.errors.append(f"{path.relative_to(ROOT)}: invalid command frontmatter: {exc}") + continue + validation.check( + isinstance(metadata, dict) and set(metadata) == {"description"}, + f"{path.relative_to(ROOT)}: command frontmatter must contain only description", + ) + expected_body = ( + f"Invoke the `{expected_skill}` skill with the user's request unchanged:\n\n" + "$ARGUMENTS" + ) + validation.check( + body.strip() == expected_body, + f"{path.relative_to(ROOT)}: command must be a thin wrapper around exactly one canonical skill", + ) + validation.check(expected_skill in EXPECTED_SKILLS, f"{path.relative_to(ROOT)}: referenced skill does not exist") + + def validate_adapters(validation: Validation) -> None: result = subprocess.run( [sys.executable, str(ROOT / "scripts" / "generate_adapters.py"), "--check"], @@ -628,6 +796,9 @@ def main() -> None: validate_tool_contract(validation) validate_documentation(validation) validate_evals(validation) + validate_contract_manifest(validation) + validate_contract_content(validation) + validate_claude_command_sources(validation) validate_adapters(validation) validate_openai_submission(validation) validation.finish() diff --git a/skills/messaging-performance-analyzer/SKILL.md b/skills/messaging-performance-analyzer/SKILL.md index 8421fef..c7b0d4d 100644 --- a/skills/messaging-performance-analyzer/SKILL.md +++ b/skills/messaging-performance-analyzer/SKILL.md @@ -44,7 +44,7 @@ Restate the user’s exact question as a measurable comparison. “WhatsApp is b Capture these dimensions before calculating anything: profile or sender identity, template ID/name, channel, country, send window, recipient segment, and whether fallback or multi-channel broadcast was requested. -**Example.** If a user says “RCS fallback stopped working,” define the cohort as messages sent with `channel: ["rcs", "sms"]` during the affected window, then compare RCS statuses, SMS fallback statuses, and duplicate recipient/channel pairs separately. +**Example.** If a user says “RCS fallback stopped working,” define the cohort as sends that omitted `channel` or used `channel: ["sent"]`, then compare the selected `payload.channel` and message activities. Analyze any explicit multi-channel arrays separately as broadcasts. ### 2. Build cohorts from Sent message IDs @@ -79,7 +79,7 @@ SMS, WhatsApp, and RCS fail differently. Do not average them together unless the |---|---|---| | SMS | Country, sender/profile, 10DLC campaign, opt-out, carrier family | Compliance status, brand/campaign readiness, opt-out logs, throughput patterns. | | WhatsApp | Template, language, category, recipient country, quality/tier symptoms | Template status, read receipts, conversation window, Meta-side errors if present. | -| RCS | Agent readiness, fallback behavior, capability gaps, rich content rendering | Sent RCS setup status, fallback SMS results, capability/error details if present. | +| RCS | Agent readiness, automatic routing, pinned-channel failures, text/suggestion-chip rendering | Sent RCS setup status, selected route, and exact activity/error details. | ### 6. Quantify impact before recommending fixes @@ -101,7 +101,7 @@ Do not label a campaign “carrier filtered” from a small sample without compa Do not treat `READ` as a universal stage. Sent documents read receipts for WhatsApp and RCS; SMS generally does not support read receipts. -Do not collapse RCS fallback into SMS delivery. For `channel: ["rcs", "sms"]`, count RCS attempts and SMS attempts separately, then report recipient-level success if the user asks for it. +Do not mistake broadcast for fallback. Omitted `channel` or `["sent"]` enables automatic routing; one explicit channel pins delivery; multiple explicit values create separate messages. Count every returned `message_id` once and report the selected channel. ## Verification checklist diff --git a/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md b/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md index 833d844..a24bd47 100644 --- a/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md +++ b/skills/messaging-performance-analyzer/references/performance-diagnosis-playbook.md @@ -85,9 +85,11 @@ If `DELIVERED` is healthy and `READ` is low across all cohorts, the cause is alm ### Symptom: RCS funnel "looks broken" -RCS is two funnels stitched together. Capability check happens before delivery; most "RCS broken" reports are actually "the audience isn't RCS-capable." +RCS routing and delivery are separate stages. Capability selection happens before delivery; many "RCS broken" reports are audiences that were not routed to RCS. -- If the Sender Profile uses fallback (`"channel": ["rcs", "sms"]`), the SMS fallback leg has its own `message_id` and its own lifecycle. Count separately. Never roll fallback SMS into RCS delivery. +- Omitted `channel` or `["sent"]` enables automatic routing. Inspect the returned message record and `payload.channel` to see what Sent selected. +- `["rcs"]` pins the send to RCS and is the cleanest cohort for isolating an RCS launch or payload problem. +- Multiple explicit channels are broadcast and create separate message IDs. Count them separately and never call one leg fallback. - Per-carrier RCS approval is real — an agent can be launched on one carrier and not on another. Symptoms scoped to one carrier point at agent state; hand off to `rcs-agent-onboarding`. ## Cross-skill handoff matrix diff --git a/skills/rcs-agent-onboarding/SKILL.md b/skills/rcs-agent-onboarding/SKILL.md index 4570ec1..1aa3df6 100644 --- a/skills/rcs-agent-onboarding/SKILL.md +++ b/skills/rcs-agent-onboarding/SKILL.md @@ -1,155 +1,88 @@ --- name: rcs-agent-onboarding -description: Guides RCS/RBM onboarding for Sent customers by preparing agent identity, launch evidence, carrier-approval handoff, SMS fallback, and post-launch verification. Use when a user says RCS agent, RBM, rich business messaging, carrier launch, RCS approval, RCS fallback, branded messages, agent verification, capability check, or wants to send RCS through Sent. +description: Guides current Sent RCS and RBM onboarding, launch evidence, carrier approval, text and suggestion-chip templates, Sender Profile readiness, and safe routing. Use for RCS launch, fallback, pinned-channel tests, or broadcast prevention. --- - +Sent RCS setup is not self-service. Sent and carrier approval are required. Prepare a complete launch packet, hand it to Sent, and verify the resulting Sender Profile with controlled messages. -# RCS agent onboarding +## Current capability boundary -## Overview +Current Sent RCS supports: -Use this skill to prepare a Sent customer for RCS launch without inventing a self-service provisioning flow. Sent’s public channel setup guidance says RCS setup is initiated through Sent, requires one-time carrier approval, and is not self-service. The agent’s job is to collect clean launch evidence, design fallback behavior, confirm profile/channel readiness, and create a verification plan for the first production sends. +- text content; and +- up to four suggestion chips. -RCS onboarding touches three separate layers. Sent owns the unified messaging API and fallback behavior. Google RBM and carriers own brand/agent review and launch approval. The customer owns brand assets, use-case clarity, consent, and support readiness. Keep those boundaries explicit. +Rich cards, carousels, and media attachments are roadmap features, not current Sent workflows. Do not request them as launch requirements, expose them as current template-builder controls, or declare them as active agent capabilities. -## When to use +## Routing semantics -Use this skill when the request mentions RCS, RBM, RCS agent, carrier launch, branded messaging, rich card, carousel, SMS fallback from RCS, or RCS approval. Use it for launch preparation, evidence gathering, fallback decisions, and post-launch smoke tests. +Channel selection on `POST /v3/messages` is not an ordered fallback list. -Do not use this skill for live delivery-rate analysis after launch; use `messaging-performance-analyzer`. Do not use it to register US SMS compliance; use `sms-10dlc-registration`. Do not promise direct Graph/RBM API provisioning unless the user confirms they operate the external RBM account outside Sent. +| Request | Behavior | +| --- | --- | +| Omit `channel` | Automatic Sent routing with fallback. | +| `channel: ["sent"]` | Explicit automatic Sent routing with fallback. | +| `channel: ["rcs"]` | Pinned RCS only; no cross-channel fallback. | +| Two or more explicit channel values | Broadcast: one separately created and billable message per recipient/channel pair. | -## Source-of-truth boundaries +Never put RCS and SMS together in an explicit array to describe fallback. Use omitted `channel` or `["sent"]` for automatic routing. Use explicit arrays only when broadcast is intended and confirmed. -| Topic | Treat as | Action | -|---|---|---| -| Sent API sending | Sent API fact | Use `POST /v3/messages` with templates and channel arrays. | -| RCS setup path | Sent documentation fact | Tell the user RCS setup is initiated by contacting Sent and requires approval. | -| SMS fallback | Sent documentation fact | Use Sent’s fallback behavior and explicit `channel: ["rcs", "sms"]` where appropriate. | -| Google RBM agent fields | External platform context | Collect assets and evidence, but do not claim Sent exposes those fields. | -| Per-carrier launch states | External platform context | Track approval evidence from Sent/Google/carriers; do not invent Sent status fields. | -| Rich-card rendering | Runtime evidence | Verify with test sends and message activities after setup is active. | +## Onboarding workflow -## Process +### 1. Define the launch use case -### 1. Classify the requested launch +Collect brand, audience, countries, consent source, message purpose, support path, estimated volume, and whether automatic fallback is required. Keep examples synthetic and within current text/chip capabilities. -Start by asking what the RCS agent will do, who receives the messages, and whether SMS fallback is required. The use case should be concrete enough for carrier review and template design. +### 2. Verify Sender Profile readiness -A good launch statement names the brand, audience, consent source, message types, support contact, and fallback behavior. A weak launch statement says only “we want RCS for marketing” or “we need branded SMS.” +Record the v3 profile UUID. Do not use legacy `x-sender-id` as v3 authentication. Choose a profile-specific API key or an organization API key with `x-profile-id`; only organization keys may use that header. -**Example.** “Acme Logistics wants RCS order updates for US consumers who opted in at checkout. Messages include shipment confirmation, delivery window changes, and support links. If RCS is unavailable, send the SMS version through the same Sent profile.” +If automatic routing may select US SMS, complete the appropriate 10DLC/compliance work first. An approved RCS agent does not make an SMS route compliant. -### 2. Build the RCS evidence packet +### 3. Prepare the evidence packet -Collect review-ready evidence before involving Sent. This reduces approval loops and prevents the agent from submitting vague brand claims. +Use [references/rcs-launch-evidence-packet.md](references/rcs-launch-evidence-packet.md). Include: -| Evidence | What to collect | Why it matters | -|---|---|---| -| Brand identity | Legal name, public brand name, website, logo, brand color, description | Reviewers compare the agent identity to the live business. | -| Contact and support | Support email, support phone, help URL, privacy policy | RCS users need visible ways to identify and contact the sender. | -| Use case | Transactional, OTP, marketing, customer care, or mixed use | Approval and fallback design depend on intent and consent. | -| Consent | Opt-in path, screenshot/URL, privacy policy, opt-out wording | Carriers need proof that recipients expect the messages. | -| Message examples | Representative plain-text and rich examples | Rich content must match the declared use case and brand. | -| SMS fallback | Equivalent SMS copy and approved SMS sender/compliance status | Fallback fails if SMS compliance is not ready. | +- consumer-facing brand name and website; +- logo and brand color; +- privacy policy and terms; +- support contacts; +- clear use case and consent flow; +- representative text messages; +- zero-to-four suggestion chips per message; +- target markets and requested timeline; +- automatic-routing or pinned-RCS test intent. -### 3. Check Sent profile and SMS fallback readiness +### 4. Hand off to Sent -Confirm that the customer has a Sender Profile in the Sent dashboard or through `/v3/profiles`. The dashboard walkthrough shows Sender Profiles with a display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The OpenAPI confirms profile creation, retrieval, update, and completion endpoints. +Because setup is not self-service, request Sent initiation and carrier approval. Do not fabricate RBM console clicks, public provisioning endpoints, capability declaration APIs, or carrier-approval status endpoints. -If the launch requires US SMS fallback, verify that the SMS side is compliant before RCS goes live. Sent’s channel setup guide recommends using the same phone number across SMS, WhatsApp, and RCS where possible, but fallback must still have a valid SMS route and compliance posture. +### 5. Build current templates -**Example fallback request.** After Sent confirms RCS is configured, a customer can request an RCS-first send with SMS fallback/broadcast semantics using a channel array such as: +Use Sent's template `definition` contract. RCS may have a complete `definition.body.rcs` override. Keep the RCS override text-based and limit suggestions to four. The `multiChannel` body remains required for template portability; routing fallback is still chosen at send time. -```json -{ - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } -} -``` +### 6. Test deliberately -Explain that Sent may create separate messages for each recipient/channel pair when multiple channels are specified. Analyze RCS and SMS attempts separately after sending. +- Validate templates and messages in sandbox where supported. +- Pin `["rcs"]` to prove the RCS path without cross-channel fallback. +- Omit `channel` or use `["sent"]` to verify automatic routing. +- If testing broadcast, state the expected recipient × channel message count and cost before sending. +- Persist every returned `message_id` with tenant, profile, channel, and logical test case. -### 4. Route the launch through Sent +Use `GET /v3/messages/{id}`, activities, and signed webhooks to verify actual routing and delivery. Do not infer fallback from the request alone. -Because Sent states that production RCS setup is not self-service, prepare a handoff note for Sent rather than pretending to click through an RBM console. Include the evidence packet, the Sender Profile identifier, the target countries/carriers if known, fallback requirements, and the requested go-live timeline. +## Launch acceptance -A clean handoff reads like this: +- [ ] Sent and carrier approval are confirmed. +- [ ] Profile UUID and credential pattern are recorded. +- [ ] Brand, consent, policy, and support evidence is complete. +- [ ] Templates use only text and up to four suggestion chips for RCS. +- [ ] Automatic fallback uses omitted `channel` or `["sent"]`. +- [ ] Pinned RCS uses `["rcs"]`. +- [ ] Broadcast is clearly labelled and costed. +- [ ] SMS compliance is ready wherever automatic routing can select SMS. +- [ ] Message IDs are mapped for webhook attribution. -> “Please initiate RCS setup for Sender Profile `support-us` / `x-sender-id` `...`. Brand is Acme Logistics, website `https://acme.example`, use case shipment notifications and customer-care replies. Opt-in occurs at checkout. SMS fallback is required through the existing US SMS route. Attached are logo, brand color, support contacts, privacy policy, and five message examples.” - -### 5. Define the test plan before launch - -Write the first-send test plan before approval arrives. Include a small set of internal numbers, target devices/carriers when available, template IDs, expected channel behavior, and rollback criteria. - -| Test | Expected result | Evidence to collect | -|---|---|---| -| RCS-capable internal device | RCS message reaches `DELIVERED`; `READ` may appear if opened. | Sent message status and activities. | -| Non-RCS-capable recipient | SMS fallback path succeeds where fallback is requested. | Separate RCS and SMS message IDs/statuses. | -| Rich content render | Cards/buttons render as designed on target devices. | Screenshots and message activities. | -| Webhook callback | Customer endpoint receives delivery/read events. | Sent webhook event history and customer logs. | - -### 6. Verify launch with Sent message evidence - -After Sent confirms the RCS setup is active, send a controlled batch using `POST /v3/messages`. For every Sent `message_id`, retrieve `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities`. Confirm that RCS messages progress through the documented lifecycle and that SMS fallback behaves as expected. - -If the first batch fails, do not guess. Separate setup failures from fallback failures, template/payload failures, and webhook ingestion failures. Use `messaging-performance-analyzer` for deeper funnel analysis once the launch is producing enough evidence. - -## Common rationalizations to avoid - -Do not tell the user RCS is self-service in Sent. Sent’s channel setup guide says to contact Sent and wait for carrier approval. - -Do not create a fake `fallback_policy` field in Sent requests. Use documented channel arrays and account-level fallback behavior unless a verified account-specific API field exists. - -Do not assume SMS fallback is safe because RCS is approved. SMS fallback needs a compliant sender, especially for US A2P traffic. - -Do not conflate brand approval with template quality. An approved RCS agent can still fail if the message payload, media, or fallback copy is wrong. - -## Verification checklist - -- [ ] The user’s RCS use case is specific enough for review and not just “send rich messages.” -- [ ] Brand identity, support contact, privacy policy, opt-in evidence, and sample messages are collected. -- [ ] The Sent Sender Profile or `x-sender-id` is identified. -- [ ] SMS fallback requirements are documented and routed to SMS compliance checks where needed. -- [ ] The handoff explicitly says Sent must initiate RCS setup and carrier approval. -- [ ] The first-send test plan includes RCS-capable, non-RCS-capable, rich-rendering, and webhook checks. -- [ ] Post-launch verification uses Sent `message_id`, status, and activities. -- [ ] External RBM facts are labeled as external context, not Sent API guarantees. - -## Related skills - -Use `sms-10dlc-registration` before launch when SMS fallback touches US A2P traffic, opt-in evidence, 10DLC campaigns, or brand vetting. - -Use `sender-profile-architect` when the customer has multiple brands, tenants, departments, or profiles and needs a durable sender architecture. - -Use `template-builder-ui` when the RCS launch needs reusable templates, rich component validation, or a template-creation workflow. - -Use `messaging-performance-analyzer` after launch when the user has message IDs, webhook events, failed sends, or delivery-rate symptoms. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/rbm-agent-spec.md` | Payload/schema reference | Keep Google RBM identity fields, asset requirements, and review vocabulary outside the skill body. | -| `references/rcs-launch-evidence-packet.md` | Worked example | Provide a complete filled-in launch packet for a realistic transactional RCS launch. | -| `references/rcs-fallback-patterns.md` | Decision matrix | Compare RCS-only, RCS-first with SMS fallback, and multi-channel broadcast patterns. | - -## Unverified claims to confirm or remove - -- Google RBM lifecycle states such as `pending_verification`, `launch_review`, or per-carrier launched states were not verified in Sent docs (these are Google-side, not exposed by Sent v3). -- Sent does not expose RCS rollout-status or capability-check endpoints in v3; use Activities + webhook events to observe behavior. -- Exact rich-card capability differences by carrier/device require external RBM evidence or live testing, not Sent docs alone. +Use [references/rbm-agent-spec.md](references/rbm-agent-spec.md) for the current launch specification and [references/rcs-fallback-patterns.md](references/rcs-fallback-patterns.md) for routing tests. Use `messaging-performance-analyzer` after enough message evidence exists. diff --git a/skills/rcs-agent-onboarding/references/rbm-agent-spec.md b/skills/rcs-agent-onboarding/references/rbm-agent-spec.md index 6099f15..070bd30 100644 --- a/skills/rcs-agent-onboarding/references/rbm-agent-spec.md +++ b/skills/rcs-agent-onboarding/references/rbm-agent-spec.md @@ -1,53 +1,29 @@ -# RBM Agent Spec — Reference +# Current Sent RCS launch specification -Supporting reference for `rcs-agent-onboarding`. This doc separates **what Sent exposes** about an RCS Agent from **what lives in Google RBM** (the underlying carrier platform). Keep that line bright: anything not in the Sent v3 docs snapshot is external context and should be linked, not mirrored. +## Approval boundary -Canonical Google source: [RCS Business Messaging docs](https://developers.google.com/business-communications/rcs-business-messaging). +RCS onboarding is coordinated through Sent and requires carrier approval. There is no public self-service provisioning flow in the current Sent v3 API. -## Sent's RCS setup model (verified) +## Required evidence -- **Not self-service.** Per Sent's channel-setup docs, RCS setup requires a one-time carrier approval that has to be initiated by contacting Sent (`support@sent.dm`). There is no dashboard button or v3 API endpoint that provisions an RCS Agent end-to-end on its own. -- **Post-approval visibility.** Once Sent (with Google + the carriers) has finished the approval cycle, the RCS Agent — Sent calls it a **Branded Sender** for RCS — appears in the dashboard alongside the customer's other channels. -- **No rollout-status or capability-check endpoint.** The v3 API does not surface a per-carrier launch state, an agent-ID field, or a recipient capability probe. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and webhook events (see `references/rcs-fallback-patterns.md`). +- Consumer-facing brand name and website +- Logo and primary brand color +- Privacy policy and terms +- Customer support details +- Consent/opt-in description +- Message purpose, audience, market, and volume +- Representative text messages +- Suggestion-chip labels/actions when used +- Sender Profile UUID and desired launch timeline -If a request implies "click here to create my RCS agent", correct it. The handoff is human-initiated by emailing `support@sent.dm` with the launch evidence packet. +## Supported message capability -## Sent-side terminology (verified) +Current Sent RCS guidance supports text and up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap features. Do not make them current approval prerequisites or capability declarations. -These are the terms Sent uses in its public docs and dashboard. Use them when talking to a Sent customer instead of Google's RBM API names. +## Identity and credentials -| Sent term | What it is | -|---|---| -| **RCS Agent** | The branded RCS sender identity that recipients see. Created on Sent's side after carrier approval. | -| **Branded Sender** | Sent's umbrella term for the per-channel sender identity (the RCS Agent for RCS, the WABA-attached phone number for WhatsApp, the 10DLC long code for SMS). | -| **Rich Card** | A single card with media, title, description, and suggestion chips. | -| **Carousel Card** | A horizontally-swiped collection of up to **10** Rich Cards. | -| **Suggestion Chip** | A tap-to-act chip below or inside a message. Three documented kinds: quick reply, open URL, dial number. | +Use the Sender Profile UUID. A profile key sends `x-api-key` alone; an organization key may scope with `x-profile-id`. `x-sender-id` is legacy v1/v2 terminology. -## Google RBM-side concepts (external — link, do not mirror) +## Approval states -The following live entirely in Google's RBM platform and are **not** exposed in the Sent v3 API. Don't pretend Sent surfaces them; treat them as platform context the customer hears about during review. - -- Agent identity fields (`displayName`, `logoUri`, `heroUri`, `color`, `verifiedDomains`, contact info) — set during Sent's onboarding handoff, not via Sent v3 API. See [Google's agent reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents). -- RBM capabilities the agent must declare (suggested replies, suggested actions, standalone rich card, rich card carousel, file/image/video/audio attachments). See [Google's capabilities guide](https://developers.google.com/business-communications/rcs-business-messaging/guides/build/capabilities). -- RBM use case taxonomy (`TRANSACTIONAL`, `OTP`, `PROMOTIONAL`, `CUSTOMER_CARE`, `MULTI_USE`). -- Verification and launch-review lifecycle (Google review → per-carrier review → carrier-specific `ENABLED` / `PENDING` rollout). Typical turnaround: 1-7 business days for verification; longer for launch. -- Per-carrier rejection codes and rejection reasons. See [Google's launch guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). -- Capability-check endpoint (`https://rcsbusinessmessaging.googleapis.com/v1/users/{phoneNumber}:capabilities`) — Google-side, requires direct RBM API access, not part of Sent v3. - -## What Sent does NOT expose (gap notes) - -These would be useful for an agent but are not in the v3 docs snapshot: - -- A field on the message resource indicating per-carrier launch state for the originating RCS Agent. -- A capability-check endpoint on Sent's side. -- A public `fallback_policy` field — channel selection is done via the `channel` array on the send request (see `references/rcs-fallback-patterns.md`). -- A structured RBM rejection code on failed messages. The `message.failed` webhook carries `payload.message_status = FAILED`; the human-readable reason lives in the `description` of the message detail fetched via `GET /v3/messages/{id}`. - -## Anti-patterns - -- Claiming Sent surfaces an `agentId` or per-carrier rollout state in v3 — it doesn't. -- Promising the customer they can self-serve an RCS Agent through the dashboard — the docs explicitly say to contact `support@sent.dm`. -- Restating Google's agent JSON schema in this file — link to Google's docs instead so this skill stays small and current. -- Using Google-side names (`displayName`, `verifiedDomains`) when talking to a Sent customer about their dashboard. Use Sent's terms (RCS Agent, Branded Sender, Rich Card, Carousel Card, Suggestion Chip). -- Treating a Carousel Card as unbounded — Sent's docs cap it at 10 Rich Cards. +Carrier approval state is an operational Sent/carrier process. Do not invent an API enum or per-carrier status endpoint. Store the evidence Sent provides, date it, and surface unknown values safely. diff --git a/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md b/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md index 4458c92..52aa8f7 100644 --- a/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md +++ b/skills/rcs-agent-onboarding/references/rcs-fallback-patterns.md @@ -1,136 +1,55 @@ -# RCS Fallback Patterns — Reference +# RCS routing and fallback patterns -Supporting reference for `rcs-agent-onboarding`. Covers how Sent expresses RCS-to-SMS fallback (the `channel` array on the send request), what the documented webhook events tell you, and where the boundary sits between Sent-verified behavior and Google RBM-side semantics. +## Automatic routing with fallback -External error semantics live at [Google's RBM error reference](https://developers.google.com/business-communications/rcs-business-messaging/reference/rest) — this doc only covers Sent's wrapping. - -## How fallback is expressed (verified) - -Sent does **not** expose a separate `fallback_policy` field in v3. Fallback intent is expressed entirely by the ordered `channel` array on `POST /v3/messages`: +Omit `channel` or send: ```json { - "to": ["+15551234567"], - "channel": ["rcs", "sms"], - "template": { "id": "template_uuid" } + "to": ["+12025550100"], + "channel": ["sent"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -| Array | Behavior | -|---|---| -| `["rcs"]` | RCS-only. If RCS can't deliver, the message fails. No SMS attempt. | -| `["rcs", "sms"]` | Documented fallback pattern. SMS is the explicit fallback target. | -| `["sms", "whatsapp", "rcs"]` | Per the v3 docs, an array with multiple channels creates **one message per channel** — all dispatch. This is a multi-channel broadcast, not a waterfall. | -| `["sms"]` | SMS-only. Used during agent provisioning before RCS is live. | -| (omitted) | Sent picks the optimal channel automatically based on the recipient's `available_channels`. | - -Two things worth surfacing to a customer: - -1. **Multi-channel arrays are broadcast, not waterfall.** The v3 docs describe `["sms", "whatsapp", "rcs"]` as producing one message per channel that all dispatch. If you want a strict RCS-first-with-SMS-fallback waterfall, the documented shape is `["rcs", "sms"]`. Anything longer needs explicit confirmation with Sent. -2. **No `fallback_policy` field exists in v3.** Documentation, dashboards, or examples that reference one are inferring a v2 concept. Use the channel array. - -## When fallback fires (inferred — confirm before promising) - -Sent's docs verify that RCS "falls back to SMS automatically for non-RCS-capable recipients" and that `["rcs", "sms"]` makes that explicit. The docs do **not** enumerate every trigger condition (capability mismatch vs. carrier-pending vs. RBM outage). Treat the following as inferred and confirm with Sent if a customer needs exact semantics: - -- Recipient device not RCS-capable -- Recipient on a carrier where the RCS Agent is not yet rolled out -- RBM transient unreachability - -For day-to-day customer guidance, "if RCS can't deliver, SMS is attempted" is the documented promise. The why-it-fell-back detail surfaces in the message's failure description (see below). - -## Content trimming on fallback (external) - -The v3 docs don't specify what happens to rich content (Rich Cards, Carousel Cards, Suggestion Chips) when a message authored for RCS falls back to SMS. SMS has no equivalent for any of those components. - -The safe default is to **author SMS-side content explicitly** rather than rely on automatic trimming. The `template-builder-ui` skill covers the dual-authoring workflow. - -If a customer needs an exact answer about Sent's trimming behavior, escalate to `support@sent.dm` — it's not in v3 docs. +Sent selects the available route. This is the cross-channel fallback mode. -## Verified webhook events for message lifecycle - -Sent's quickstart docs verify this lifecycle. Sub-types follow `message.`: - -| Event | Meaning | -|---|---| -| `message.queued` | Send accepted, waiting to dispatch | -| `message.routed` | Assigned to a carrier/provider | -| `message.sent` | Dispatched to the carrier/RCS/WhatsApp provider | -| `message.delivered` | Confirmed delivery to device | -| `message.read` | Recipient opened. RCS and WhatsApp only. | -| `message.failed` | Delivery failed at any stage. `payload.message_status = FAILED`. Fetch `GET /v3/messages/{id}` for the reason. | - -Webhook payload shape (top-level): +## Pinned RCS ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "to": ["+12025550100"], + "channel": ["rcs"], + "template": {"id": "00000000-0000-0000-0000-000000000000"}, + "sandbox": true } ``` -The `payload.channel` field is what tells you which channel actually delivered. To distinguish "RCS delivered" from "SMS fallback delivered" for the same logical send, inspect `payload.channel` on the `message.delivered` event. - -### Events that are NOT verified — do not assume they exist - -Earlier drafts of this skill referenced events like `message.channel_selected` and `message.fallback_triggered`. **Those names are not in the v3 docs.** The only verified `message.*` sub-types are `queued`, `routed`, `sent`, `delivered`, `read`, `failed`. - -If a customer's integration depends on a dedicated "fallback fired" event, reconstruct it from what's documented: +This requests RCS only and has no cross-channel fallback. Use it to isolate RCS launch or payload failures. -- For an `["rcs", "sms"]` send, observe `message.delivered` events and check `payload.channel` — `"sms"` on what was meant to be an RCS-first send is the fallback signal. -- For a failed RCS attempt that succeeded via SMS, the v3 docs don't promise an explicit pairing. Reconstruct by correlating `message.failed` (RCS) with a separate `message.delivered` (SMS) sharing the same logical send. -- If you genuinely need a dedicated fallback event, ask Sent — don't invent the name. +## Broadcast -### Send-time failure codes (verified) +Any array containing two or more explicit channel values is broadcast. Sent creates a separate message for each recipient/channel pair. This can duplicate content and charges. -On `message.failed`, fetch the message and read `description`. Verified codes that may appear: +Before a broadcast, calculate: -| Code | Meaning | -|---|---| -| `ERR_CONSENT_BLOCKED` | Recipient is opted out or on suppression list. No provider call. | -| `ERR_ROUTE_DENIED` | No active route could deliver to the requested channel/country. | -| `ERR_TEMPLATE_PARAMS_INVALID` | Required template variables missing or failed regex validation. | - -Per-carrier RBM rejection codes are external (Google) and not surfaced as a structured field in v3. - -## Testing fallback in lower environments - -Sent's v3 docs document **sandbox mode** as the testing affordance: add `"sandbox": true` to the request body and the API returns a realistic fake response without a provider call. Response includes `X-Sandbox: true` header. - -Sandbox mode is documented for `POST /v3/messages` and most other mutation endpoints. - -The v3 docs do **not** document a `force_fallback` flag, a test-recipient registry, or per-carrier launch-state overrides. Earlier drafts referenced these — treat as inferred / unverified. If a customer needs to exercise the SMS-fallback path specifically in a lower environment, the documented approach is: +```text +messages created = recipient count × explicit channel count +``` -1. Send with `sandbox: true` to validate request shape without side effects. -2. To exercise the real fallback path against the real provider chain, send with a small recipient list including known-non-RCS-capable numbers. -3. For pre-launch testing, send with `["sms"]` first to confirm SMS compliance and webhook plumbing, then introduce `["rcs", "sms"]` once RCS is approved. +Require explicit user intent and show the count/cost impact. -## What's NOT in v3 (gap notes) +## Observability -- A dedicated `message.fallback_triggered` webhook event. -- A `message.channel_selected` event. -- A `fallback_policy` field on the Sender Profile or on the send request. -- A `force_fallback` flag for non-prod testing. -- An MDR export schema documenting `attempted_channels` / `delivered_channel` fields. -- Per-carrier rollout-status fields. +The send response returns per-recipient message IDs and channels. Persist each record. Webhook message events use `field: "message"`, a message `sub_type`, and `payload.channel`. Determine what happened from the created message records and their activities—not from the ordering of the request array. -Anything above that appears in customer-facing guidance should be flagged as inferred and confirmed with Sent before relying on it. +## Test matrix -## Anti-patterns +| Test | Request | Expected evidence | +| --- | --- | --- | +| RCS path | `["rcs"]` | One RCS attempt per recipient, no SMS message ID | +| Automatic routing | omitted / `["sent"]` | One selected route per recipient according to availability | +| Broadcast | two explicit channels | Two message IDs per recipient | -- Inventing a `fallback_policy` field — it doesn't exist in v3; use the `channel` array. -- Inventing `message.fallback_triggered` or `message.channel_selected` webhook events — they're not in the documented lifecycle. -- Sending with `["rcs"]` and expecting SMS to back it up. RCS-only means RCS-or-fail. -- Treating a long channel array like `["rcs", "whatsapp", "sms"]` as a waterfall. Per v3 docs, multiple channels create one message per channel (broadcast). Use `["rcs", "sms"]` for the documented fallback shape. -- Assuming Sent auto-trims rich RCS content gracefully into SMS. Trimming behavior isn't in v3 docs — author SMS variants explicitly. -- Using `sandbox: true` in production traffic — sandbox is for tests, not real sends. +There is no documented `fallback_policy` or `force_fallback` field. Do not invent dedicated fallback webhook names. diff --git a/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md b/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md index 945576f..a4d9344 100644 --- a/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md +++ b/skills/rcs-agent-onboarding/references/rcs-launch-evidence-packet.md @@ -1,117 +1,42 @@ -# RCS Launch Evidence Packet — Reference +# RCS launch evidence packet -Supporting reference for `rcs-agent-onboarding`. Describes the evidence Sent needs from a customer before initiating the RCS handoff to Google + the carriers. Sent's docs say RCS setup is not self-service and must be requested through `support@sent.dm`; the packet is what makes that email actionable. +## Brand -The carrier-side launch review is external — authoritative source is [Google's RBM launch documentation](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). This doc only covers what Sent itself collects, and how that overlaps with the broader KYC/compliance form the customer already fills in. +- Legal and consumer-facing brand names +- Public website +- Square logo and brand color +- Privacy policy and terms URLs +- Support email, phone, or URL -## Why a packet +## Use case and consent -The dashboard handoff to `support@sent.dm` is the single biggest lever a customer has on launch latency. A complete packet means Sent's team can take it to the carriers without round-tripping the customer for missing material. A vague packet means the agent sits idle for weeks. +- Audience and target countries +- Transactional, authentication, marketing, support, or mixed intent +- Exact opt-in flow and proof +- Message frequency and estimated volume +- STOP/HELP handling where SMS can be selected by automatic routing -## Overlap with the existing compliance form (verified) +## Current message examples -The Sent dashboard already collects most of the brand-identity and use-case evidence as part of KYC and the compliance form. Reuse those answers — don't ask the customer to write everything from scratch. +Provide at least five representative text messages. For each, include zero-to-four suggestion chips and what each chip does. Do not include rich-card, carousel, or media-attachment requirements; those are not current Sent capabilities. -Fields the dashboard's compliance form already captures (per Sent's docs): +## Sender Profile -**Business identity** -- Legal business name -- Business registration number -- Business type / structure -- Industry category -- EIN / tax ID (US) -- Business address -- Business phone number -- Contact email +- v3 profile UUID +- Credential pattern: profile key or organization key plus `x-profile-id` +- Relevant numbers and markets +- SMS compliance state if automatic routing can select SMS -**Messaging / use-case** -- Use-case selection (Authentication, Notifications, Marketing, Customer Service, High Volume) -- Campaign description -- Sample messages per use case -- Opt-in mechanism (URL or description) -- Opt-out language +## Routing plan -For an RCS launch, these answers map directly into what the carriers want to see. If they're already complete and current, the RCS packet is mostly assembly, not authoring. +Choose one or more test modes: -## RCS-specific evidence (additive to compliance form) +- automatic routing: omitted `channel` or `["sent"]`; +- pinned RCS: `["rcs"]`; +- intentional broadcast: multiple explicit channels with expected message count and cost. -The bits below are required for RCS review but are **not** part of Sent's general compliance form — the customer has to supply them specifically for the RCS handoff. +Do not describe an explicit multi-channel array as fallback. -### 1. Brand authorization +## Handoff note -- Letter of authorization (LOA) signed by an officer of the brand confirming the customer is authorized to operate this RCS Agent -- For franchises / resellers: documentation of the licensing arrangement - -### 2. Sample message gallery for RCS - -For every Sent-side RCS component the agent will use, include at least one realistic sample showing it in use: - -- Plain text body -- Suggestion Chip — quick reply -- Suggestion Chip — open URL -- Suggestion Chip — dial number -- Rich Card (with media, title, description, chip) -- Carousel Card (up to 10 Rich Cards) -- Any attachment type the agent will send (image, video, file) - -Samples must use real brand assets (logo, color, copy voice). Placeholders are a common rejection reason. - -### 3. Brand assets - -- Square logo (RBM has minimum dimensions; check [Google's agent docs](https://developers.google.com/business-communications/rcs-business-messaging/reference/business-communications/rest/v1/brands.agents)) -- Brand color (hex) -- Optional hero/banner image -- Public website URL (must match the brand the agent represents) - -### 4. SMS fallback plan - -A one-paragraph statement of what happens when the recipient isn't RCS-capable. Sent's documented fallback mechanism is the `channel` array on the send request — see `references/rcs-fallback-patterns.md` for the option set. If the customer needs US SMS as fallback, confirm 10DLC compliance is already in place (see `sms-10dlc-registration`). - -### 5. End-user support contact - -A phone number, email, or in-product support URL recipients can reach with questions. Google and carriers both probe this contact during review, so it has to actually answer. - -## Pre-handoff checklist - -Before emailing `support@sent.dm`, confirm: - -- [ ] Compliance form is complete and current in the Sent dashboard -- [ ] Use-case selection on the form matches the RCS Agent's intended use -- [ ] Sample messages on the form cover the RCS use case (not just SMS) -- [ ] Opt-in mechanism (URL or description) is filled in and accurate -- [ ] Opt-out language is documented -- [ ] LOA signed and dated within last 12 months -- [ ] At least one sample per RCS component the agent will use, with real brand assets -- [ ] Brand logo, color, website URL ready to attach -- [ ] SMS fallback plan written down, with channel-array shape (e.g. `["rcs", "sms"]`) -- [ ] If US SMS is the fallback, 10DLC registration already complete -- [ ] Support contact is live and answers within stated SLA -- [ ] Every URL that will appear in a sample message resolves on the brand's public domain - -## Per-carrier nuance (external) - -Each carrier reviews independently after Google approves. The specifics shift over time and are not documented in Sent's v3 docs — treat carrier-specific copy requirements, opt-in language minimums, and throttling windows as external. Always check [Google's per-carrier guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch) for the current rules. - -Patterns that broadly hold: -- Marketing use cases get scrutinized harder than transactional / OTP. -- Carriers may require additional opt-in disclosure language beyond Google's. -- Even after `ENABLED`, carriers may stage rollout by recipient volume in the first weeks. - -## Common rejection reasons (Google-side, external) - -These come from Google and the carriers, not Sent. Listed here as a checklist, not a substitute for the [official guidance](https://developers.google.com/business-communications/rcs-business-messaging/guides/learn/launch). - -| Reason | Remediation | -|---|---| -| Brand assets in sample don't match the agent identity | Re-render samples with the actual logo, color, brand name | -| Use case description and declared use case disagree | Either change the declared use case or rewrite the description; resubmit | -| Opt-in disclosure missing channel name | Update the opt-in surface to name RCS (or "text messages including RCS") and re-screenshot | -| Sample exercises a capability not declared on the agent | Either declare the capability or remove the sample | -| Support contact unreachable | Wire up the contact and confirm before resubmitting | -| Same packet recycled across multiple agents with different brands | Each agent needs its own packet — Google catches this | - -## After submission - -- Sent does not expose a per-carrier rollout-status endpoint in v3. To observe RCS behavior after launch, use `GET /v3/messages/{id}`, `GET /v3/messages/{id}/activities`, and the webhook events listed in `references/rcs-fallback-patterns.md`. -- A carrier coming back with changes requested will produce a short reason — feed that back to the customer and update the relevant packet artifact before resubmitting. -- Some carriers stay pending for weeks even after Google approves; that's an external timeline, not a Sent issue. +Ask Sent to initiate RCS setup and carrier review for the named profile. Attach brand/consent evidence, message examples, target markets, support details, routing plan, and requested launch window. Avoid claims about approval timing that Sent or carriers have not confirmed. diff --git a/skills/sender-profile-architect/SKILL.md b/skills/sender-profile-architect/SKILL.md index b0b2fa3..1310177 100644 --- a/skills/sender-profile-architect/SKILL.md +++ b/skills/sender-profile-architect/SKILL.md @@ -1,182 +1,121 @@ --- name: sender-profile-architect -description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, or multi-channel messaging systems, including profile boundaries, account-level API key blast radius, webhooks, compliance inheritance, and channel readiness. Use when a user says sender profile, x-sender-id, profile setup, multi-tenant messaging, brand isolation, department sender, webhook routing, tenant offboarding, or asks how to model SMS, WhatsApp, and RCS senders in Sent. +description: Designs Sent Sender Profile architecture for multi-tenant, multi-brand, and multi-channel systems. Use for API-key scoping, x-profile-id, isolation, inheritance, sharing, billing, WABA, 10DLC campaigns, webhooks, or tenant offboarding. --- - +A Sender Profile is the operational boundary for tenant identity, channel configuration, inherited resources, billing, and credentials. Use this skill before provisioning when a poor boundary would mix brands, compliance posture, rate-limit impact, or webhook ownership. -# Sender profile architect +## Recommended tenancy model -## Overview +When tenants require isolation, recommend one Sent organization with one Sender Profile per tenant. A shared profile is appropriate only when the tenants genuinely share one brand, sender resources, compliance posture, billing/rate-limit expectations, and operational blast radius. -Use this skill to decide how a customer should map brands, tenants, departments, and channels onto Sent Sender Profiles. A Sender Profile is the durable boundary for sender identity and channel configuration. The Sent dashboard shows each profile with display name, brand description, `x-sender-id`, and SMS/WhatsApp configuration status. The v3 API exposes profile creation, listing, retrieval, update, deletion, and completion. +Do not recommend pooled-by-default architecture. Make the isolation decision explicit using [references/multi-tenancy-patterns.md](references/multi-tenancy-patterns.md). -Good profile architecture prevents three recurring failures: messages sent from the wrong brand, compliance resources shared across incompatible use cases, and webhook/event data that cannot be routed back to the correct tenant. +## Authentication patterns -## When to use +Sent v3 supports both: -Use this skill when the user asks how to create Sender Profiles, split one customer into multiple senders, model a marketplace or ISV, isolate brands, route webhooks, reason about account-level API key blast radius, complete profile setup, or safely offboard a tenant. Use it whenever the request mentions `x-sender-id`, Sender Profile, profile completion, multi-tenant messaging, brand hierarchy, SMS/WhatsApp/RCS sender setup, or webhook routing. +| Pattern | Headers | Blast radius | +| --- | --- | --- | +| Profile-specific API key | `x-api-key` | Profile-scoped credentials and rate-limit context. Do not add `x-profile-id`. | +| Organization API key acting for a child | `x-api-key` plus `x-profile-id: ` | Organization credential can reach permitted child profiles; rate limits remain in the organization pool. | -Do not use this skill to decide 10DLC use cases in detail, write WhatsApp template copy, onboard RCS approval, or analyze delivery failures. Hand those to the related skills once the profile boundary is clear. +Only organization keys may send `x-profile-id`. A profile key that sends it receives `403`. A profile outside the organization returns `404`. `X-Profile-Id` can be echoed in scoped responses. -## Profile boundary principle +`x-sender-id` is legacy v1/v2 terminology only. Do not use it for v3 authentication or routing. -Create a separate Sender Profile when the sender identity, compliance evidence, webhook routing, operational ownership, or channel readiness must be isolated. Reuse a profile when the same legal/brand identity sends the same class of traffic and should share compliance posture and operational controls. +Choose profile keys when tenant-level credential isolation and revocation are primary. Choose organization-key scoping for centrally controlled integrations that can protect a broader credential and deliberately accept a shared organization rate-limit pool. -| Split signal | Create separate profiles when | Reuse a profile when | -|---|---|---| -| Brand identity | The recipient sees different brand names or support contacts. | The recipient sees one brand across all messages. | -| Compliance | 10DLC brand/campaign, opt-in source, or use case differs materially. | Compliance evidence and use case are the same. | -| Channel configuration | SMS, WhatsApp, or RCS resources belong to different brands or regions. | Channels represent one sender identity. | -| Webhook routing | Events must land in different tenant queues or data stores. | One team owns all events and reconciliation. | -| Lifecycle | One sender may be paused, restricted, or offboarded independently. | Senders always launch, pause, and retire together. | +## Profile creation model -## Process +Create with `POST /v3/profiles`. `name` is required. Current optional areas include: -### 1. Draw the recipient-visible sender model +- identity: `icon`, `description`, `short_name`; +- sharing: `allow_contact_sharing`, `allow_template_sharing`; +- inheritance: `inherit_contacts`, `inherit_templates`, `inherit_tcr_brand`, `inherit_tcr_campaign`; +- billing: `billing_model`, `billing_contact`, and ephemeral `payment_details`; +- dedicated WABA credentials: `whatsapp_business_account` with `waba_id`, optional `phone_number_id`, and `access_token`; +- a dedicated brand: `brand.contact`, `brand.business`, and `brand.compliance`. -Start with what the recipient sees, not with internal account hierarchy. Ask: “Would the recipient reasonably think these messages came from the same sender?” If the answer is no, use separate profiles. +Do not add a separate brand endpoint. A dedicated brand is created with the profile; campaigns are managed under `/v3/profiles/{profileId}/campaigns`. -**Example.** A healthcare ISV serves three clinic chains. Each chain has its own patient-facing brand, privacy policy, and support phone. Create one profile per clinic chain. Do not put all clinics behind a single ISV profile just because the same platform sends the messages. +### Inheritance rules -### 2. Map each profile to channel readiness +- `inherit_tcr_brand: true` means the profile uses the organization's brand and cannot submit its own `brand` object. +- `inherit_tcr_campaign: true` makes inherited campaigns read-only for that profile. +- An inherited brand with `inherit_tcr_campaign: false` is a supported dedicated-campaign pattern. +- Sharing flags expose a profile's contacts/templates; inheritance flags consume organization resources. Treat those directions separately. -For each proposed profile, list SMS, WhatsApp, and RCS readiness separately. Sent’s channel setup guidance covers production setup for all three channels and recommends using the same phone number across SMS, WhatsApp, and RCS where possible. That recommendation does not override compliance or brand isolation. +### Billing and number references -| Channel | Profile-level questions | Follow-up skill | -|---|---|---| -| SMS | Is US A2P involved? Which brand/campaign and opt-in evidence apply? | `sms-10dlc-registration` | -| WhatsApp | Which WABA/phone number identity maps to this brand? Are templates approved? | `waba-embedded-signup`, `waba-template-author` | -| RCS | Has Sent initiated setup and carrier approval for this profile? Is SMS fallback ready? | `rcs-agent-onboarding` | +`billing_model` currently supports `profile`, `organization`, and `profile_and_organization`. A profile or fallback billing model requires `billing_contact` when none exists. Card fields are forwarded to the payment processor and must not be logged or persisted. -### 3. Create or update the Sent profile +Profile update can manage `sending_phone_number_profile_id`, `sending_whatsapp_number_profile_id`, `sending_phone_number`, `whatsapp_phone_number`, and `allow_number_change_during_onboarding`. Model reference IDs and direct numbers separately, and prevent cycles when one profile references another. -Use Sent’s profile API where API access is appropriate, or the dashboard when the user is operating manually. The verified v3 API includes: +## WABA choices -| Operation | Endpoint | Use | -|---|---|---| -| Create profile | `POST /v3/profiles` | Create a sender boundary for a brand, department, tenant, or use case. | -| List profiles | `GET /v3/profiles` | Audit existing profile boundaries before creating duplicates. | -| Retrieve profile | `GET /v3/profiles/{profileId}` | Inspect detailed profile configuration. | -| Update profile | `PATCH /v3/profiles/{profileId}` | Change profile configuration/settings. | -| Delete profile | `DELETE /v3/profiles/{profileId}` | Soft-delete a profile after traffic, webhooks, and credentials are drained. | -| Complete setup | `POST /v3/profiles/{profileId}/complete` | Validate prerequisites and start the profile completion workflow. | +There are three distinct paths: -Use idempotency keys on create/update/complete calls when the integration might retry. The OpenAPI exposes an optional `Idempotency-Key` header for those operations. +1. Organization Embedded Signup in the dashboard. +2. Child profile inheritance by omitting `whatsapp_business_account` after the organization has a WABA. +3. Dedicated profile WABA using `waba_id` and `access_token`; `phone_number_id` is optional. -### 4. Attach compliance and channel prerequisites before completion +There is no public endpoint that starts organization Embedded Signup. Direct credentials on `POST /v3/profiles` are not an “Embedded Signup endpoint.” Use `waba-embedded-signup` for the operational flow. -The profile completion endpoint validates prerequisites such as profile data, brand, campaigns, and channel connections. For US A2P SMS, create or attach Sent brand and campaign resources before completing the profile. The verified brand/campaign endpoints are `/v3/brands` and `/v3/brands/{brandId}/campaigns`. +## 10DLC and campaigns -Do not invent field names such as `tcr_brand_id` or `waba_phone_id` unless the actual API response includes them. Store Sent IDs returned by the API and any returned provider identifiers separately, with clear names. +Use a profile `brand` object for a dedicated brand. Manage campaigns at: -**Example data model.** +- `GET|POST /v3/profiles/{profileId}/campaigns` +- `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` -```text -sender_profiles -- sent_profile_id -- Sent Profile.id (UUID) -- x_sender_id -- per-profile, v2 legacy; useful for dashboard cross-ref -- name -- Sent Profile.name -- short_name -- Sent Profile.short_name -- description -- Sent Profile.description -- sent_status -- mirrors Sent Profile.status: incomplete|pending_review|approved|rejected -- status_app_level -- finer-grained internal lifecycle (do not conflate with sent_status) -- sms_ready_app_level -- whatsapp_ready_app_level -- rcs_ready_app_level - -sender_profile_resources -- sent_profile_id -- channel -- sent_resource_id -- e.g., /v3/brands/{brandId}, /v3/brands/{brandId}/campaigns/{id} -- provider_resource_type -- e.g., tcr_brand, tcr_campaign, waba, waba_phone, rbm_agent -- provider_resource_id -- status_last_seen_at -``` - -### 5. Design webhook routing around Sent event evidence - -Sent’s v3 webhook API supports creating/listing webhooks, retrieving event types, viewing webhook events, testing a webhook, toggling status, and rotating signing secrets. Use those endpoints to verify configuration and delivery before blaming channel infrastructure. - -Route inbound events by stable identifiers present in the Sent payload. If the exact event payload fields are not documented for the customer’s account, log full events in a secure staging environment and derive the routing map from observed Sent fields rather than assumed provider keys. - -**Example.** If a marketplace needs tenant-specific queues, route first by Sent profile or sender identifier if present in the event. Fall back to a mapping table from Sent message ID to tenant/profile created at send time. Avoid making provider IDs the only routing key. - -### 6. Model profile lifecycle as an application state machine - -Sent exposes profile APIs and completion behavior, but your application may need richer internal states. Label them as application states so future agents do not mistake them for Sent enums. - -| Application state | Meaning | Exit condition | -|---|---|---| -| `draft` | Profile data is being collected. | Required identity and owner fields are present. | -| `compliance_pending` | Brand/campaign/channel evidence is being prepared. | Required compliance resources exist or have been submitted. | -| `completion_started` | `/v3/profiles/{profileId}/complete` returned accepted/started behavior. | Webhook/callback or follow-up status indicates completion result. | -| `active` | Profile is approved for intended channels. | Traffic is allowed and test sends pass. | -| `restricted` | One or more channels is blocked, paused, or missing approval. | Root cause resolved and profile retested. | -| `retiring` | Sends are drained and webhooks/credentials are being removed. | No active sends, subscriptions, or credentials remain. | - -### 7. Plan tenant offboarding before the first send +Use `sms-10dlc-registration` for the payload and policy layer. -Offboarding is easiest when profile boundaries are clean. To retire a profile, stop new sends, drain in-flight messages, export relevant message/activity evidence, disable or reroute webhooks, revoke or rotate credentials, delete/soft-delete the profile when safe, and retain compliance records according to the customer’s policy. +## Completion and status handling -## Common rationalizations to avoid +Complete a profile with `POST /v3/profiles/{profileId}/complete` and a required `webHookUrl`: -Do not use one profile for every tenant just because it is easy. Over-splitting creates unnecessary compliance and operational work. - -Do not use one shared profile for distinct recipient-visible brands. Under-splitting creates wrong-sender and compliance-contamination failures. - -Do not treat internal tenant ID as a substitute for Sender Profile ID. The application can map tenant ID to profile ID, but outbound sends and webhook reconciliation need Sent identifiers. - -Do not hardcode provider identifiers as routing keys before verifying Sent webhook payloads. Sent’s event shape is the integration contract. - -Do not rotate webhook secrets casually. Secret rotation immediately invalidates the old secret, so coordinate with the receiving endpoint. - -## Verification checklist - -- [ ] Each proposed profile has a recipient-visible rationale. -- [ ] SMS, WhatsApp, and RCS readiness are tracked separately per profile. -- [ ] US A2P SMS profiles have brand/campaign work routed to compliance before completion. -- [ ] The implementation stores Sent profile IDs and any provider IDs as separate fields. -- [ ] Profile creation/update/complete calls use idempotency keys where retries are possible. -- [ ] Webhook routing is based on Sent event fields or a send-time Sent message ID mapping. -- [ ] Application lifecycle states are not presented as Sent API enum values. -- [ ] Offboarding drains sends, webhooks, credentials, and retained evidence. - -## Related skills +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -Use `sms-10dlc-registration` when a profile needs US A2P SMS brand/campaign registration, opt-in review, or 10DLC troubleshooting. +Status is surface-specific: -Use `waba-embedded-signup` when the architecture includes WhatsApp WABA/phone-number connection or Embedded Signup. +- Create response currently demonstrates lowercase `incomplete`. +- Completion `202` means processing started and does not contain a final status. +- Completion `200` currently demonstrates lowercase `completed` for an already-complete profile. +- Completion callbacks can report `COMPLETED`, `SUBMITTED`, or `failed`. +- REST guides and OpenAPI publish different profile status sets. -Use `rcs-agent-onboarding` when the profile needs RCS approval, launch evidence, or fallback design. +Do not assert a closed REST enum. Preserve unknown strings and record the endpoint/callback surface that produced them. -Use `template-builder-ui` when the architecture decision depends on reusable cross-channel template lifecycle. +## Webhook attribution -Use `messaging-performance-analyzer` after launch when webhook, delivery, or activity evidence shows a performance issue. +Sent events do not contain your application tenant ID. Before sending, persist the returned `message_id` with the tenant and profile. Route outbound status events through that mapping. For inbound messages, map the receiving number/profile resource to the tenant. -Use the `sent` skill for shared Sent terminology and routing. +```text +message_id -> tenant_id, profile_id, logical_send_id, channel +receiving_number -> tenant_id, profile_id +``` -## Suggested bundled references and scripts +Do not infer tenant ownership from `account_id` alone. Multiple tenant profiles can belong to one organization. -| File | Type | Purpose | -|---|---|---| -| `references/multi-tenancy-patterns.md` | Architecture reference | Keep detailed routing, rate-limit, idempotency, and offboarding patterns outside the skill body. | -| `references/sender-profile-data-model.md` | Schema reference | Provide recommended application tables and mapping fields for Sent profile integrations. | -| `references/profile-boundary-examples.md` | Worked examples | Show ISV, marketplace, multi-brand enterprise, and department-level profile splits. | +## Design checklist -## Unverified claims to confirm or remove +- [ ] Tenant/brand isolation decision is explicit. +- [ ] Credential pattern and rate-limit/blast radius are documented. +- [ ] Sharing and inheritance directions are intentional. +- [ ] Billing ownership is named. +- [ ] Number references cannot form cycles. +- [ ] WABA path is organization signup, inheritance, or dedicated credentials—not an invented hybrid. +- [ ] Dedicated brand/campaign paths are profile-based. +- [ ] `message_id` and inbound-number mappings support webhook attribution. +- [ ] Unknown profile statuses are tolerated. +- [ ] Tenant offboarding revokes credentials, disables sends, detaches resources safely, and retains audit evidence. -- API keys are issued per **customer account** (not per profile); `x-sender-id` is per-profile and visible in the dashboard but is v2 legacy for routing — v3 uses `x-api-key` alone. Design key blast-radius around the account, not the profile. -- Sent's profile `status` enum is `incomplete | pending_review | approved | rejected`. If your application tracks finer-grained internal lifecycle states (e.g. `partially_active`, `restricted`, `restoring`), they should not be conflated with the Sent profile `status` field — label them as application states. -- Provider-specific routing keys for WhatsApp/RCS/SMS (e.g., `phone_number_id`, `agentId`, TCR campaign ID) should not be required as primary routing keys unless observed in Sent event payloads or docs. The verified v3 webhook payload exposes `account_id`, `message_id`, `channel`, `inbound_number`, `outbound_number`, `template_id` — route on those plus a `message_id` → profile map persisted at send time. +See [references/sender-profile-data-model.md](references/sender-profile-data-model.md) and [references/profile-boundary-examples.md](references/profile-boundary-examples.md) for implementation patterns. diff --git a/skills/sender-profile-architect/references/multi-tenancy-patterns.md b/skills/sender-profile-architect/references/multi-tenancy-patterns.md index bc35e13..a00be20 100644 --- a/skills/sender-profile-architect/references/multi-tenancy-patterns.md +++ b/skills/sender-profile-architect/references/multi-tenancy-patterns.md @@ -1,127 +1,66 @@ -# Multi-Tenancy Patterns for Messaging Apps on Sent — Reference +# Multi-tenancy patterns -Supporting reference for `sender-profile-architect`. Patterns that are *specific to messaging workloads* on Sent — high write volume, webhook fan-in across SMS/WhatsApp/RCS, and the compliance constraints carriers, Meta, and Google impose. Generic multi-tenant SaaS theory is covered exhaustively elsewhere; this doc only captures what changes when SMS, WhatsApp, and RCS run through Sent. +## Preferred: one profile per isolated tenant -## What a Sender Profile owns +Use one organization and one Sender Profile per tenant when tenants have distinct brands, consent evidence, 10DLC campaigns, WABAs, phone numbers, billing, credentials, or incident blast radius. -A Sender Profile is *one tenant's sending identity* across the channels that profile uses. It carries `name`, `description`, `short_name`, `status` (`incomplete | pending_review | approved | rejected`), and a `settings` block of `{default_channel, webhook_url, timezone, language}`. Each channel attaches separately: +Benefits: -- **SMS** — TCR Brand (`/v3/brands`) + at least one Campaign (`/v3/brands/{brandId}/campaigns`), plus one or more phone numbers / short codes. -- **WhatsApp** — Meta WABA + WABA phone numbers (configured via the Sent dashboard / Channels page). -- **RCS** — Google RBM agent (not self-service; via Sent support). +- profile-specific key issuance and revocation; +- explicit organization-key scoping through `x-profile-id`; +- separate inheritance and sharing choices; +- clean tenant offboarding; +- reliable number/profile and message/profile attribution. -A tenant may have multiple profiles (one per brand, region, or use case). Auth is a single account-level `x-api-key`; that key can operate on any profile the account owns. +Organization-key scoping does not create a separate rate-limit pool: the organization's pool remains shared. -## Webhook Routing (the hot path) +## Shared profile -Sent fans channel events into a unified payload shape: +Use a shared profile only if all participants genuinely operate as one sender identity with one brand, resource set, compliance posture, billing/rate-limit expectations, and incident boundary. Lower object count is not sufficient justification. -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} -``` - -Top-level fields: `field`, `sub_type`, `timestamp`, `payload`. `sub_type` follows `.` (e.g., `message.delivered`, `message.failed`, `message.read`). - -Routing back to a Sender Profile uses what your application persisted at send time, joined on stable IDs in the payload: - -| Channel | Verified payload fields | Used to find | -|---|---|---| -| All | `payload.message_id` | the profile that owns this outbound message | -| All | `payload.account_id` | the customer account | -| All | `payload.channel` + `payload.outbound_number` | the configured sender | -| All | `payload.template_id` | the template / its owning profile | - -Narrow webhook subscriptions with `event_filters`: - -```json -"event_filters": { "message": ["delivered", "failed"] } -``` - -Shape: `{: [, ...]}`. Combine with `event_types: ["message"]` to subscribe to the `message` parent and only fire on the listed sub-types. - -ACK fast (≤ webhook `timeout_seconds`, default 30s, max 120s; Sent retries up to `retry_count`, default 3, max 5). Synchronous business logic in the webhook handler kills throughput because three platforms upstream all retry on slow / 5xx responses. - -Two failure modes to design out: - -- **Cold routing key.** A webhook arrives for an `outbound_number` or `template_id` you haven't mapped (the tenant added a number out-of-band, or a template was created in another environment). Log, return 200, alert ops — don't drop the event. -- **Slow routing-key lookup.** Cache the `message_id` → profile mapping aggressively, but back it with durable storage so cold pods resolve correctly. +A shared profile makes tenant credential isolation and webhook attribution application responsibilities. Document that tradeoff. -## Per-Channel Rate-Limit Accounting +## Hybrid inheritance -You account for limits at four layers. Track per-channel; bill at the profile. +Common supported patterns include: -| Source | Limit | Where it comes from | -|---|---|---| -| **Sent — standard endpoints** | 200 req/min, burst 50 | Sent API gateway | -| **Sent — sensitive endpoints** | 10 req/min, burst 5 (e.g., `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/users`, `POST /v3/profiles/{id}/complete`) | Sent API gateway | -| **Sent — message sending tier** | Starter 60/min · Growth 300/min · Enterprise custom | Sent plan tier | -| **Sent — webhook test** | 60/min | Sent API gateway | -| **SMS — TCR campaign TPS** | Per-campaign throughput, assigned after vetting | TCR + carrier reconciliation | -| **WhatsApp — phone-number tier** | 1K / 10K / 100K / unlimited business-initiated conversations per 24h, plus Cloud API CPS | Meta — readable from the phone-number record | -| **RCS — agent QPS** | Google RBM | Google | -| **Your per-profile quota** | Whatever you actually sell | Your billing layer | +| Brand | Campaign | Flags | +| --- | --- | --- | +| Organization brand | Organization campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Organization brand | Dedicated profile campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | +| Dedicated profile brand | Dedicated profile campaign | both false, with `brand` at profile creation | -Rate-limit responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (on 429) `Retry-After`. The error code on 429 is `BUSINESS_002`. +Do not set `brand` while `inherit_tcr_brand` is true. Inherited campaigns are read-only. -Bill against the Sender Profile, not the customer account — an account with three brands gets three meters. +## Credentials -## Idempotency +- A profile key uses only `x-api-key`. +- An organization key may add `x-profile-id` for a child. +- A profile key with `x-profile-id` receives `403`. +- `x-sender-id` is legacy v1/v2 terminology. -Header: `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Cached for **24 hours**, scoped **per customer account**. +Store credentials in a secret manager. Never expose organization keys to tenant-controlled runtimes. -Concurrent requests with the same key → second returns `409 CONFLICT_001`. Replays carry `Idempotent-Replayed: true` and `X-Original-Request-Id: ` headers. +## Webhook fan-out -**Same key + different payload → returns the cached response for the *first* payload, not a new one.** Use a unique key per distinct operation. A safe pattern: hash `(profile_id, operation, client_intent_id)` into the key so a re-send of the same business intent is idempotent but a different intent on the same profile is not. +Outbound events carry `payload.message_id`, not your tenant ID. Persist: -Supported on all profile mutations: -- `POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}` -- `POST /v3/profiles/{id}/complete` - -And on every other mutation endpoint listed in the snapshot (`/v3/messages`, `/v3/contacts`, `/v3/templates`, `/v3/brands`, `/v3/brands/{id}/campaigns`, `/v3/webhooks`, `/v3/users`). - -Sandbox mode (`"sandbox": true` in the body) stacks with idempotency — validates the request, returns a realistic fake response, and caches it for 24 hours. Useful for CI per-tenant smoke tests. - -## Outbound Message Idempotency - -Outbound message sends should also be idempotent on the tuple `(profile_id, channel, client_message_id)` at the application layer. Persist the intent to send *before* the upstream `POST /v3/messages` — if the call succeeds but your write fails, a retry would otherwise duplicate. Pair this with a stable `Idempotency-Key` on the Sent request itself so Sent collapses the duplicate even if your row write reaches Sent first. - -## State Reconciliation (per channel) - -The Profile resource exposes a coarse `status` (`incomplete | pending_review | approved | rejected`). Per-channel readiness (TCR vetting score, WhatsApp messaging tier, RBM launch state) is not in this snapshot — re-fetch from the dashboard or upstream APIs on a schedule: - -- **TCR / SMS** — campaign vetting score updates and carrier-level filtering changes don't always fire webhooks. Reconcile daily for healthy campaigns, hourly when state was recently changing. -- **WhatsApp** — phone-number quality rating and messaging tier change without webhooks. Re-fetch every few hours; alert on transitions. -- **RCS** — agent launch state and carrier rollout status update silently. Re-fetch daily for launched agents and more often during initial verification. - -Track when each channel was last reconciled so dashboards can show how stale each profile is. Don't conflate this internal staleness with Sent's `status` field. - -## Channel-Specific Anti-Patterns +```text +message_id -> tenant_id, profile_id, channel, logical_send_id +``` -- **WhatsApp** — Sharing one Meta System User token across multiple Sender Profiles. Token revocation now disables every profile. -- **SMS** — Reusing a TCR campaign across tenants. The campaign vetting score follows whoever the brand says it is — share at your peril. -- **RCS** — Hardcoding the RBM agent into the application instead of attaching it to a Sender Profile. Multi-region or multi-brand tenants will need multiple agents and the code path forks. -- **All** — Synchronous webhook processing. Throughput dies and the platforms retry aggressively. -- **All** — Claiming "data residency" by application-level filtering when the legal commitment is storage-level isolation. +Inbound events carry destination number/channel information. Persist: -## Tenant Offboarding +```text +channel + destination_number -> tenant_id, profile_id +``` -When a tenant churns, run the per-channel teardown — not just a state flag: +Verify signatures before lookup, deduplicate events, and route unknown mappings to a quarantine queue. Never guess the tenant from organization `account_id`. -- **SMS** — deactivate the TCR campaign(s), release the phone number(s) per Sent's release flow. -- **WhatsApp** — unsubscribe your app from the WABA, revoke the System User token. -- **RCS** — unlaunch / suspend the RBM agent (via Sent support). +## Offboarding -Then `DELETE /v3/profiles/{id}` to soft-delete the profile (use an `Idempotency-Key`). Disable or delete webhook subscriptions that fan into this profile. Schedule message-content deletion per your retention policy. +1. Block new application sends. +2. Revoke profile keys; rotate organization credentials if exposure is possible. +3. Disable or reroute webhooks and number references. +4. Preserve message/profile mappings for retention and disputes. +5. Delete the profile only after resource ownership and compliance retention are resolved. diff --git a/skills/sender-profile-architect/references/profile-boundary-examples.md b/skills/sender-profile-architect/references/profile-boundary-examples.md index c6d8ee0..74efd92 100644 --- a/skills/sender-profile-architect/references/profile-boundary-examples.md +++ b/skills/sender-profile-architect/references/profile-boundary-examples.md @@ -1,111 +1,50 @@ -# Profile Boundary Examples — Reference +# Profile boundary examples -Supporting reference for `sender-profile-architect`. Worked examples for "where should the Sender Profile boundary go?" — the question that determines blast radius, billing granularity, and onboarding pain. Each example covers when the boundary makes sense, when it doesn't, ops considerations, and how the choice ripples into 10DLC registration and WABA setup. +## Marketplace with fifty merchants -The default rule of thumb: **one Sender Profile per legal entity per distinct sending identity**. The examples below are when to break that rule. +Use one organization and one Sender Profile per merchant. Give independently operated merchant runtimes profile keys; keep an organization key only in the trusted control plane. Each merchant can inherit organization templates while owning a dedicated campaign under the inherited organization brand if policy permits. -## What's confirmed in v3 (read before using the examples) +## One enterprise brand with regional teams -- **Auth.** A single account-level `x-api-key` operates on every profile the account owns. There is no per-profile API key in v3. -- **`x-sender-id`.** Visible per-profile in the dashboard (Profiles page maps to `/v3/profiles` endpoints), but is v2 legacy for routing; v3 needs only `x-api-key`. Treat it as a per-profile identifier you can read, not as the auth header. -- **Profile `status`.** The Sent-side enum is `incomplete | pending_review | approved | rejected`. Anything finer-grained ("partially_active", "restricted", "restoring") is an application-level label. -- **Org-level user roles.** Owner / Admin / Billing / Developer. Owner is the only role with billing-ownership transfer rights; the Profile resource's per-call `role` field surfaces `admin | billing | developer` (not Owner) for the authenticated user's role in that profile. -- **10DLC.** Brand and Campaign are first-class Sent resources at `/v3/brands` and `/v3/brands/{brandId}/campaigns` — register them before completing the SMS portion of a profile. +A shared profile may be defensible when every team uses the same legal/consumer brand, consent posture, WABA/numbers, billing, and operations. If a region requires a distinct number, campaign, credential, or incident boundary, split it into its own profile. -## 1. One profile per legal entity (single-brand SaaS) +## Dedicated WABA tenant -A small SaaS with one corporate identity sending password resets, billing reminders, and product nudges from a single sender name. +Create the profile with: -**Picks this when:** all messages legitimately come from the same business, same brand voice, same vetting story. +```json +{ + "name": "Acme Support", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -**Doesn't pick this when:** the company runs multiple consumer brands under one corporate parent (those want separate profiles even if the same lawyer signs both TCR forms). +`phone_number_id` is optional. The access token must be injected from a secure runtime, never included in logs, fixtures, support tickets, or responses. -**Ops:** -- One webhook, one secret to rotate, one set of API keys. Lowest operational overhead. -- Single TCR Brand registration; multiple TCR Campaigns under it for distinct use cases (transactional vs marketing). -- Single WABA, one System User token. WABA quality rating reflects all sending behavior. -- Billing is a single line item — easy for finance, hard to attribute internally if multiple product teams share the profile. +## Organization WABA inheritance -**10DLC:** one Brand, multiple Campaigns. If transactional and marketing share one Campaign, carrier filtering will be harsher than necessary — split them. +After organization Embedded Signup is complete, omit `whatsapp_business_account` on the child profile. Omitting it without an organization WABA returns `422`. This is inheritance, not an API-started Embedded Signup flow. -**WABA:** one WABA, one phone number to start; tier upgrades benefit everything sent through this profile. +## Dedicated 10DLC brand -## 2. One profile per channel (WhatsApp-only vs SMS-fallback chains) +Set `inherit_tcr_brand: false` and include `brand` with `POST /v3/profiles`. Create campaigns through `/v3/profiles/{profileId}/campaigns`. Do not create a free-standing brand resource. -A tenant explicitly wants channel isolation — e.g. a WhatsApp-only consumer brand whose ops team should never accidentally send SMS, or a region where RCS is the primary channel with SMS strictly as fallback. +## Shared SMS number reference -**Picks this when:** legal, compliance, or product policy requires that channel-by-channel sending be controllable independently and visible independently in billing. +Use `sending_phone_number_profile_id` when a profile intentionally reuses another profile's SMS configuration. Record the source profile and prevent circular references. A direct `sending_phone_number` is a different mode and should not be conflated with the profile reference. -**Doesn't pick this when:** the channels are genuinely interchangeable for the same user journey. Sent's whole point is unifying sending — splitting profiles by channel often duplicates work without adding isolation. +## Webhook routing -**Ops:** -- Multiple webhook subscriptions, multiple secrets. Secret-rotation blast radius is smaller per channel, larger in aggregate. -- The "fallback" pattern (try WhatsApp, fall back to SMS) becomes an application-level orchestration across two profiles instead of one. Manageable, but inbound replies on the SMS profile won't carry the original WhatsApp `wamid` context — your app has to stitch threads. -- Cost tracking is cleanly per-channel. +When the send response returns message IDs, write all of them before treating the operation as accepted: -**10DLC:** the SMS-only profile carries the TCR registration; the WhatsApp-only profile has none. Don't register TCR for a profile that won't send SMS. +```text +message A -> tenant 42, profile P42, channel sms +message B -> tenant 42, profile P42, channel whatsapp +``` -**WABA:** the WhatsApp profile carries the WABA. Quality scoring is isolated — a bad SMS campaign won't pull WhatsApp tier down. - -## 3. Per-department profiles (sales, support, marketing) on the same brand - -A mid-sized company wants Sales, Support, and Marketing to send under the same overall brand but with different sender names, different vetting stories, and different billing meters. - -**Picks this when:** internal billing attribution matters (Marketing's budget is separate from Support's), or each department's send volume / patterns are different enough that mixing them would hurt vetting (Marketing's bulk sends would tank Support's quality rating). - -**Doesn't pick this when:** the departments truly send identical-looking traffic under one external brand. Splitting buys complexity without changing what carriers see. - -**Ops:** -- Three webhooks (or one webhook routing on `payload` fields), three sets of templates — but one shared account-level `x-api-key`. Reusable copy (e.g. WhatsApp templates) has to be authored per profile or copied between them. -- Quota / rate-limit accounting is per profile — Marketing can be throttled without affecting Support. -- Suspension blast radius is per profile — a Meta quality drop on Marketing doesn't pause Support. - -**10DLC:** typically one TCR Brand (same legal entity) but separate Campaigns per department's use case. Some tenants register multiple Brands if Marketing operates as a distinct legal entity. - -**WABA:** can be one WABA with multiple phone numbers split across profiles, or one WABA per profile. One-WABA-multiple-profiles complicates Sent-side modeling (each profile attaches to the same WABA); separate WABAs are cleaner if the departments genuinely want isolation. - -## 4. Per-tenant profile in a B2B2C platform (one Sent customer hosting many merchants) - -A platform — appointment-booking SaaS, e-commerce host, marketing platform — has one Sent contract but serves hundreds of downstream merchants who each need their own sender identity. - -**Picks this when:** each merchant is a distinct end-business that needs to appear as themselves to recipients, comply with TCR / Meta independently, and have their own billing meter. - -**Doesn't pick this when:** all merchants legitimately send "from" the platform brand. Then one profile is correct and merchant attribution is an internal concern. - -**Ops:** -- Profile provisioning is part of the merchant onboarding flow — this is where the WABA Embedded Signup (`waba-embedded-signup`) and 10DLC registration (`sms-10dlc-registration`) skills get invoked hundreds of times. -- Webhook fan-in: one Sent webhook per profile is unmanageable at hundreds of profiles. Either configure all profiles to one webhook URL and route on payload fields (`payload.account_id`, `payload.outbound_number`, `payload.template_id`, plus a `message_id`-to-profile map you keep at send time), or run per-profile webhooks behind a routing layer. The single-URL pattern has one secret to rotate and a larger blast radius. -- API-key blast radius: v3 issues a single account-level `x-api-key`, so a compromised key affects every merchant under that account. If per-merchant key isolation is a requirement, give each merchant its own Sent customer account (not just its own profile) — that's the only boundary that produces a distinct API key today. -- Billing: per-merchant meters fall out of per-profile accounting cleanly. - -**10DLC:** each merchant is its own TCR Brand and Campaign. The platform does not register *its own* Brand on behalf of merchants — the merchant signs. Plan for per-merchant TCR vetting time (days, not seconds). - -**WABA:** each merchant goes through Embedded Signup to attach their own WABA. The platform is the Tech Provider on Meta's side. Plan for the support burden — Meta's "phone number already in use" errors land on the platform. - -## 5. Per-region / per-geo profiles - -A business serving multiple regions wants to honor local regulations, language defaults, and regulator-specific sender identities (e.g. EU brand vs US brand vs LATAM brand). - -**Picks this when:** regions have genuinely different regulators (US 10DLC + Brazil's regs + EU's WhatsApp rules), different timezones for send-window enforcement, or different localized sender display names. - -**Doesn't pick this when:** the regional split is only a marketing convenience and all sending is from one legal entity with one global compliance posture. - -**Ops:** -- Per-region webhooks make per-region failover and on-call rotation straightforward. -- Per-region rate limits avoid one region's burst exhausting another's budget. -- Per-region secret rotation contains blast radius geographically. -- Cross-region analytics need a join layer — the per-profile billing/usage data has to be aggregated for the global view. - -**10DLC:** US-only concept. The US-region profile is the one with TCR; non-US profiles ignore TCR entirely. Don't try to register a non-US brand with TCR "for completeness". - -**WABA:** WABAs can serve global recipients, but pricing tiers and template approval workflows differ by recipient country, not by WABA region. Per-region WABAs typically map to per-region Meta Business Manager assets and per-region System User tokens — that's the operational reason to split, not regulatory. - -## Decision heuristic - -When you're not sure whether to split, ask in order: - -1. **Are these messages legally from the same entity?** No → split. -2. **Will one identity's bad behavior unfairly impact another's quality rating / TCR vetting?** Yes → split. -3. **Does anyone need to see per-X billing or rate limiting (X = brand, department, merchant, region)?** Yes → split. -4. **Do these channels actually share a user journey or are they being kept apart for ops reasons?** Kept apart → split. -5. **Otherwise:** one profile. Splits are cheap to add later; merges are not. +Multiple explicit channels create multiple messages. Route each webhook by `message_id`; do not expect an application tenant ID in the event. diff --git a/skills/sender-profile-architect/references/sender-profile-data-model.md b/skills/sender-profile-architect/references/sender-profile-data-model.md index d416557..91e3120 100644 --- a/skills/sender-profile-architect/references/sender-profile-data-model.md +++ b/skills/sender-profile-architect/references/sender-profile-data-model.md @@ -1,159 +1,98 @@ -# Sender Profile Data Model — Reference - -Supporting reference for `sender-profile-architect`. Captures the conceptual model Sent exposes for a Sender Profile and the resources that attach to it, as verified against the Sent v3 docs snapshot. - -This doc is **conceptual**, not a schema dump. Resource shapes evolve; the relationships below are what stabilize them. - -## Authentication context - -In v3, authentication is header-only with a single key per account: - -```http -x-api-key: +# Sender Profile data model + +## Core records + +```text +organization + id + organization_key_secret_ref + +tenant + id + organization_id + sent_profile_id + profile_key_secret_ref + credential_pattern + billing_model + +profile_policy + profile_id + allow_contact_sharing + allow_template_sharing + inherit_contacts + inherit_templates + inherit_tcr_brand + inherit_tcr_campaign + +channel_binding + profile_id + channel + direct_number + source_profile_id + waba_id + +message_attribution + message_id + logical_send_id + tenant_id + profile_id + channel + +inbound_route + channel + destination_number + tenant_id + profile_id ``` -The dashboard exposes a per-profile `x-sender-id` value for inspection and v2 legacy routing, but v3 API auth needs only `x-api-key`. API keys are issued at the **customer account** level, not per profile — a single API key can list, retrieve, create, update, complete, or delete any profile the account owns via `/v3/profiles*` endpoints. +`source_profile_id` models `sending_phone_number_profile_id` and `sending_whatsapp_number_profile_id`. Enforce referential integrity and prevent cycles. -## Sender Profile resource (verified) +## Profile request fields -The verified Profile model in Sent v3: +Create supports identity, sharing/inheritance, billing, dedicated WABA credentials, and a profile-owned `brand`. Update additionally supports number reference/direct-number fields and onboarding number-change policy. -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | Stable, Sent-issued | -| `name` | string | Display name | -| `icon` | string \| null | URL | -| `description` | string \| null | Human-readable description | -| `short_name` | string \| null | Compact label | -| `role` | `admin` \| `billing` \| `developer` \| null | The calling user's role within this profile | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status — these are the **only** Sent-side enum values | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | +Brand request fields are grouped into: -Note: `role` is the *calling user's* role in this profile, not a property of the profile itself. The org-level role enum (Owner / Admin / Billing / Developer) is the full set; the field omits `Owner` because that's billing-owner-only. +- `contact`: representative and business-facing contact data; +- `business`: legal identity, tax/entity type, address, country, URL; +- `compliance`: vertical, brand relationship, primary use case, TCR flag, number prefix, destination countries, notes. -## Sender Profile -> Channels -> Webhooks +Treat request camelCase inside `brand` separately from snake_case response fields. Do not round-trip by blindly serializing a response object as a create request. -``` -Customer account (one x-api-key) - │ - ├── SenderProfile (1..N) - │ ├── id, name, short_name, description, icon - │ ├── status: incomplete | pending_review | approved | rejected - │ ├── role (caller's role: admin | billing | developer) - │ ├── settings.default_channel ── routes if /v3/messages omits "channel" - │ ├── settings.webhook_url ── per-profile webhook destination - │ ├── settings.timezone, .language - │ │ - │ └── Channels (attached via dashboard / compliance flow) - │ ├── SMS → Brand (TCR) → Campaign(s) - │ ├── WhatsApp → WABA + phone number(s) - │ └── RCS → RBM agent (via Sent support) - │ - └── Webhooks (configured separately; can be per-profile or shared) - ├── id, display_name, endpoint_url, is_active - ├── event_types: ["message", "templates", ...] - ├── event_filters: {: [, ...]} - ├── signing_secret, retry_count (1-5, default 3), - │ timeout_seconds (5-120, default 30) - └── last_delivery_attempt_at, last_successful_delivery_at, - consecutive_failures -``` +## Authentication invariant -Webhook payloads have the shape: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "sms", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } -} +```text +profile key -> x-api-key only +organization key -> x-api-key + optional x-profile-id ``` -`payload.account_id` is the customer-account-level UUID. The payload does not currently include a top-level `profile_id`; routing back to a profile relies on the channel/numbers/template recorded at send time. - -## How channels attach to a profile - -Channels are configured per-profile through the Sent dashboard and compliance flow. Each channel attachment carries the regulator-facing identity for that channel: - -| Channel | Attached identity | Sent endpoints | -|---|---|---| -| SMS | A TCR-registered brand + at least one campaign, plus phone numbers / short codes | `/v3/brands`, `/v3/brands/{brandId}/campaigns` | -| WhatsApp | A Meta WABA + one or more WABA phone numbers (linked through Sent's Channels / WABA Embedded Signup flow) | Dashboard-only configuration in v3 | -| RCS | A Google RBM agent (not self-service; initiated via `support@sent.dm`) | Dashboard-only configuration in v3 | - -Two consequences fall out of this model: +Only the organization pattern may include `x-profile-id`. Rate limits for organization-scoped requests remain in the organization pool. -1. **Provisioning is per-channel and largely dashboard-driven.** A profile's `status` (`incomplete | pending_review | approved | rejected`) is the rollup; per-channel readiness is tracked alongside it but not in the verified Profile resource. If your application tracks finer-grained channel readiness it should label those as internal app states, not Sent enum values. -2. **Channel identifiers are the inbound routing keys.** The data model is what makes "look up the Sender Profile from an inbound phone number / WABA / RBM agent" possible — but in v3 those joins are made on data you persist at send time, not on a `profile_id` carried in the inbound webhook payload. +## Campaign ownership -## Brands and Campaigns (SMS / 10DLC) +Campaigns belong to the brand selected through a profile but are operated through profile paths: -The word *brand* shows up at two layers — keep them distinct: - -- **Profile metadata** — the `name`, `description`, and `short_name` on the Sender Profile, used for display and as default sender identity. -- **TCR Brand** — the legal-entity record registered with The Campaign Registry, mandatory for US 10DLC SMS. Sent exposes these as first-class resources: - - `GET /v3/brands`, `POST /v3/brands`, `PUT /v3/brands/{brandId}`, `DELETE /v3/brands/{brandId}` - - `POST /v3/brands/{brandId}/campaigns`, `PUT /v3/brands/{brandId}/campaigns/{id}`, `DELETE /v3/brands/{brandId}/campaigns/{id}` - -``` -SenderProfile (Sent) - └── SMS channel - └── TCR Brand (1) - └── TCR Campaign (1..N) ─── carrier filtering happens here +```text +/v3/profiles/{profileId}/campaigns +/v3/profiles/{profileId}/campaigns/{campaignId} ``` -For 10DLC details and use-case selection, see `sms-10dlc-registration`. - -WhatsApp has no direct analogue of TCR Campaigns — per-template approval (via `/v3/templates`) plays a similar role. RCS uses one RBM agent per profile per region. - -## Webhooks - -Webhooks are configured at the account level and can be scoped to a profile via `settings.webhook_url`, or you can configure a single webhook URL and route on the payload (`payload.account_id`, `payload.channel`, `payload.outbound_number`, `payload.template_id`). +An inherited brand can have profile-owned campaigns when campaign inheritance is disabled. -Key webhook config fields: +## Status storage -- `event_types` — list of parent event types subscribed (e.g., `["message"]`, `["message", "templates"]`). -- `event_filters` — narrow within a parent type: `{"message": ["delivered", "failed"]}` only fires for `message.delivered` and `message.failed`. -- `signing_secret` — rotate via `POST /v3/webhooks/{id}/rotate-secret` (sensitive endpoint: 10 req/min limit). Rotation invalidates the old secret immediately. -- `retry_count` 1-5 (default 3), `timeout_seconds` 5-120 (default 30) — Sent will retry up to `retry_count` times if your endpoint times out or 5xxs. -- `consecutive_failures` — surfaced so you can monitor a failing webhook before it's auto-disabled. - -## Idempotency - -`POST /v3/profiles`, `PATCH /v3/profiles/{id}`, `DELETE /v3/profiles/{id}`, and `POST /v3/profiles/{id}/complete` all support `Idempotency-Key: ` where the key matches `^[a-zA-Z0-9_-]{1,255}$`. Keys are cached **per customer account for 24 hours**. See `references/multi-tenancy-patterns.md` for the full idempotency/sandbox behavior across endpoints. - -## ER summary +Store at least: +```text +profile_id +status_raw +status_surface # create_response, rest_profile, completion_200, completion_callback +observed_at +payload_version ``` -Customer account ── x-api-key (one per account) - │ 1..N - ▼ -SenderProfile { id, name, status, role, settings } - │ - ├── Brand (TCR) ── Campaign(s) ── carrier-filtered SMS senders - ├── WABA ── PhoneNumber(s) (configured via dashboard) - └── RBM Agent (configured via Sent support) - -Webhooks (separate resource; can be many-to-one with profiles) - └── event_types[], event_filters{}, signing_secret, retry/timeout config -``` - -The single architectural rule that follows: every channel-specific record your application stores should reference the Sent **profile `id`**, not the customer `account_id` directly. Account is a foreign key on profile. -## What to confirm against the live OpenAPI +Known examples vary in case and vocabulary. Do not normalize unknown values into a closed enum. -Before writing application code, confirm the endpoint and schema details against the live Sent v3 OpenAPI at https://docs.sent.dm: +## Secret boundaries -- Exact channel-attachment fields on the Profile resource (the snapshot doesn't enumerate per-channel sub-objects). -- Whether `payload.profile_id` is added to webhooks in future API revisions. -- API-key creation + scope semantics (API Keys is dashboard-only in this snapshot). +WABA `access_token` and payment card fields are write-only operational secrets. Never log, echo, or persist raw values in profile records. Keep only secret-manager references and non-sensitive identifiers such as `waba_id`. diff --git a/skills/sms-10dlc-registration/SKILL.md b/skills/sms-10dlc-registration/SKILL.md index fd26358..365004d 100644 --- a/skills/sms-10dlc-registration/SKILL.md +++ b/skills/sms-10dlc-registration/SKILL.md @@ -1,165 +1,108 @@ --- name: sms-10dlc-registration -description: Prepares Sent US A2P SMS 10DLC compliance by collecting business, opt-in, brand, campaign, sample-message, and profile-completion evidence. Use when a user says 10DLC, A2P, TCR, campaign registry, brand vetting, SMS compliance, US texting, long code registration, opt-in proof, opt-out language, carrier filtering, or needs to register SMS through Sent. +description: Prepares and validates Sent US A2P 10DLC brand and campaign registration through Sender Profiles, including inheritance, all campaign use cases, opt-in evidence, sample-message policy, autoresponses, sandbox validation, TCR status, and rejection remediation. --- - +Use this skill for US A2P SMS over 10-digit long codes. Separate the compliance evidence packet from the exact Sent API request; they have different schemas and validators. -# SMS 10DLC registration +## Current Sent resource model -## Overview +There is no standalone brand CRUD path in the current v3 API. -Use this skill to prepare US A2P SMS compliance for Sent. Sent’s compliance documentation states that compliance is a prerequisite for sending messages and that 10DLC registration is mandatory for A2P messaging to US numbers. Sent handles TCR registration as part of the compliance process, while the customer must provide accurate business identity, consent, use-case, sample-message, and opt-out evidence. +- Create a dedicated brand inside `POST /v3/profiles` using `brand` and `inherit_tcr_brand: false`. +- List/create campaigns with `GET|POST /v3/profiles/{profileId}/campaigns`. +- Update/delete with `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. -The Sent v3 API exposes Sent-facing brand and campaign resources through `/v3/brands` and `/v3/brands/{brandId}/campaigns`. Profile completion through `/v3/profiles/{profileId}/complete` validates profile, brand, and campaign prerequisites before the profile is ready. +Reject guidance that reintroduces a free-standing brand path. -## When to use +## Choose inheritance deliberately -Use this skill when the request mentions 10DLC, A2P, TCR, brand registration, campaign registration, SMS compliance, US long code, EIN, opt-in proof, sample messages, opt-out, HELP/STOP language, vetting, rejected campaign, or carrier filtering caused by compliance. Use it before enabling US SMS sending or SMS fallback for RCS. +| Brand | Campaign | Settings | +| --- | --- | --- | +| Inherit both | Organization brand and campaign | `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | +| Inherit brand, own campaign | Shared legal brand with tenant-specific traffic | brand true, campaign false | +| Own both | Dedicated tenant/business | both false and supply `brand` during profile creation | -Do not use this skill for non-US country compliance unless the user supplies a Sent compliance source for that country. Do not use it to analyze live delivery failures except to identify whether compliance status is the likely next check. +Inherited campaigns are read-only. A profile cannot supply `brand` while brand inheritance is true. -## Required evidence +## Two validation layers -Collect evidence before creating or updating Sent brand/campaign resources. Bad evidence creates review loops and downstream filtering risk. +### Evidence readiness packet -| Evidence | What to capture | Sent-grounded reason | -|---|---|---| -| Legal business identity | Legal business name, address, EIN/tax ID, entity type | Sent’s compliance guide lists these as required inputs. | -| Public web presence | Live website URL and privacy policy URL | Sent requires a live website and privacy policy for compliance review. | -| Opt-in mechanism | URL, screenshot, form text, checkbox language, or checkout flow | Sent requires an opt-in mechanism URL. | -| Use-case description | Clear description of what messages are sent and why | Sent requires use-case description. | -| Sample messages | Realistic messages matching the declared use case | Sent requires sample messages. | -| Opt-out instructions | STOP/HELP or equivalent instructions where applicable | Sent requires opt-out instructions. | -| Sender Profile | Sent profile ID or dashboard profile being completed | Profile completion validates compliance prerequisites. | +The private packet uses the explicit internal version `sent-10dlc-evidence/v1` and snake_case evidence fields. It is not an API payload. -## Process +```bash +python scripts/validate_10dlc_packet.py evidence.json +``` -### 1. Decide whether this is US A2P SMS +Collect legal identity, public website/policy links, consent proof, message flow, opt-in/opt-out/help responses and keywords, use cases, and realistic samples. See [references/10dlc-evidence-checklist.md](references/10dlc-evidence-checklist.md). -Confirm destination country, traffic type, and sender type. This skill applies to US A2P SMS over 10DLC. If the user is sending only WhatsApp, RCS without SMS fallback, short code, toll-free, or non-US traffic, document the difference and route to the appropriate compliance workflow. +### Sent campaign request -**Example.** “We send appointment reminders from a SaaS platform to US patients using local long-code numbers” is US A2P SMS and needs 10DLC. “We send only WhatsApp utility templates” is not a 10DLC workflow, though WhatsApp has its own template and business requirements. +The API request uses exact camelCase and a `campaign` wrapper: -### 2. Normalize the business identity + +```json +{ + "campaign": { + "name": "Acme account notifications", + "description": "Account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in in account settings before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} +``` -Use the exact legal business name and tax ID records. Do not “clean up” the name to a marketing brand if the tax record uses another legal entity. Mismatches between legal identity, website, and opt-in flow are common rejection causes. +Validate it with: -If the customer is an ISV registering many customers, decide whether each customer needs its own profile/brand/campaign boundary with `sender-profile-architect`. Do not put unrelated customers under one brand because it is faster. +```bash +python scripts/validate_campaign_payload.py campaign.json +``` -### 3. Classify the campaign by use case +## API use cases -Pick the narrowest truthful campaign use case. Mixed-use campaigns can be valid, but they invite broader review and more filtering risk if the sample messages do not match the declared intent. +Support all 13 current values: -| Declared intent | Better sample | Bad sample | -|---|---|---| -| Account notification | “Acme: Your password was changed. If this was not you, visit https://acme.example/security. Reply STOP to opt out.” | “Huge sale today. Click now.” | -| Delivery notification | “Acme: Order 1234 is out for delivery today. Track: https://acme.example/t/1234. Reply STOP to opt out.” | “Your package is coming. Also buy these add-ons.” | -| Customer care | “Acme Support: We received your request and will respond shortly. Reply STOP to opt out.” | “Thanks for contacting us. Get 20% off now.” | -| Marketing | “Acme: Spring sale starts today. Use code SPRING. Reply STOP to opt out.” | Transactional description with promotional samples. | +`MARKETING`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `FRAUD_ALERT`, `TWO_FA`, `DELIVERY_NOTIFICATION`, `SECURITY_ALERT`, `M2M`, `MIXED`, `HIGHER_EDUCATION`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, and `LOW_VOLUME`. -Keep detailed TCR taxonomy and carrier-specific advice in a reference file. In the skill body, use only enough taxonomy to keep the submission honest. +Each use case structurally accepts 1–5 samples, each no longer than 1,024 characters. The compliance layer requires at least two samples for marketing and mixed traffic, including low-volume mixed. Keep that policy distinction visible instead of pretending OpenAPI requires two for all traffic. -### 4. Create or update Sent brand resources +## Volume and status -Use Sent’s brand endpoints when API work is in scope. The verified v3 API includes: +`volume` is optional and, when supplied, is a numeric string. Values below `"2000"` use the documented low-volume tier; `"2000"` is the boundary to the next tier. -| Operation | Endpoint | Notes | -|---|---|---| -| Create brand | `POST /v3/brands` | Creates a new brand and associated information. | -| List brands | `GET /v3/brands` | Retrieves brands for the authenticated customer, including inherited brands where applicable. | -| Update brand | `PUT /v3/brands/{brandId}` | Cannot update brands already submitted to TCR or inherited brands. | -| Delete brand | `DELETE /v3/brands/{brandId}` | Deletes a brand that belongs to the authenticated customer. | +Campaign responses currently expose statuses `SENT_CREATED`, `ACTIVE`, and `EXPIRED`, plus `submittedToTCR`. Preserve unknown future status strings. Do not confuse a successful Sent record creation with TCR submission or carrier activation. -Use optional `Idempotency-Key` headers on create/update calls when retrying. Store the Sent brand ID returned by the API. Store any returned TCR identifiers separately only if the API response exposes them. +## Safe workflow -### 5. Create or update Sent campaign resources +1. Confirm this is US A2P 10DLC traffic and the actual sending business is identified. +2. Select brand/campaign inheritance. +3. Validate the versioned evidence packet. +4. Create or confirm the profile brand. +5. Translate evidence into the exact camelCase campaign request. +6. Validate locally and use `sandbox: true`. +7. Show the payload and obtain confirmation before a real create/update/delete. +8. Store profile ID, campaign ID, `submittedToTCR`, raw status, and review evidence. +9. Complete the profile with required `webHookUrl` only after prerequisites are ready. -Create campaigns under the relevant Sent brand. The verified v3 API says each campaign must include at least one use case with sample messages. - -| Operation | Endpoint | Notes | -|---|---|---| -| Create campaign | `POST /v3/brands/{brandId}/campaigns` | Links the campaign to the brand and requires use-case/sample-message data. | -| List campaigns | `GET /v3/brands/{brandId}/campaigns` | Retrieves campaigns and their use cases/sample messages. | -| Update campaign | `PUT /v3/brands/{brandId}/campaigns/{campaignId}` | Cannot update campaigns already submitted to TCR. | -| Delete campaign | `DELETE /v3/brands/{brandId}/campaigns/{campaignId}` | Deletes a campaign within the brand. | - -Do not claim a public `tcr_campaign_id` field unless the actual response includes it. Refer to the Sent campaign ID for Sent API operations. - -### 6. Complete the Sender Profile setup - -After profile data, brand, and campaign prerequisites are ready, call or trigger profile completion through `POST /v3/profiles/{profileId}/complete`. The OpenAPI describes this as the final step in the profile compliance workflow, validating prerequisites and connecting profile configuration in the background. - -If completion fails, fix the missing prerequisite rather than creating duplicate brands or campaigns. Duplicate compliance objects increase confusion and can lead to sending from the wrong profile. - -### 7. Prepare the review-ready submission summary - -End the workflow with a compact summary the user can paste into Sent support, a dashboard form, or an internal ticket. Include legal identity, website, privacy policy, opt-in URL/evidence, use-case description, sample messages, opt-out instructions, Sent profile ID, Sent brand ID, Sent campaign ID, and any unresolved questions. - -**Example summary.** - -> “Acme Logistics LLC, EIN ending 1234, sends US SMS delivery notifications to customers who opt in at checkout. Website and privacy policy are live. Opt-in screenshot and URL are attached. Campaign use case is delivery notification. Sample messages match shipment status only and include opt-out instructions. Sent profile `...`, Sent brand `...`, Sent campaign `...` are ready for completion.” - -## Common rationalizations to avoid - -Do not register a marketing campaign as a utility or account-notification campaign because it may be cheaper or easier. The samples, opt-in flow, and actual traffic must match. - -Do not submit placeholder websites, private staging URLs, or missing privacy policies. Sent’s compliance guide calls for live URLs. - -Do not reuse one brand/campaign for unrelated customers. Compliance belongs to the sender and use case, not just the platform sending the API call. - -Do not edit a submitted brand or campaign in place if the API says submitted objects cannot be updated. Create the right correction path with Sent. - -Do not promise exact approval times beyond Sent’s guidance. Sent says TCR registration typically completes within 3 to 7 business days after the Sent compliance form is approved, with additional propagation time possible. - -## Verification checklist - -- [ ] The traffic is confirmed as US A2P SMS over a long-code route. -- [ ] Legal business identity matches tax and website evidence. -- [ ] Website and privacy policy URLs are live. -- [ ] Opt-in evidence is concrete and matches the declared use case. -- [ ] Sample messages are realistic and match the use case. -- [ ] Opt-out instructions are included where applicable and consistent with the user experience. -- [ ] Sent brand and campaign IDs are stored separately from any provider/TCR identifiers. -- [ ] Profile completion is run only after profile, brand, and campaign prerequisites are ready. -- [ ] Unverified throughput, carrier, or pricing claims are not presented as Sent facts. - -## Related skills - -Use `sender-profile-architect` when deciding whether brands, tenants, departments, or use cases need separate Sender Profiles. - -Use `rcs-agent-onboarding` when 10DLC work is needed for SMS fallback from RCS. - -Use `messaging-performance-analyzer` when registered traffic still shows delivery failures or filtering symptoms. - -Use `template-builder-ui` when the customer needs reusable SMS template copy that matches the registered use case. - -Use the `sent` skill for shared Sent terminology and routing. - -## Bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/tcr-use-cases.md` | Lookup table | TCR use-case taxonomy, sample-message patterns, and rejection reasons. | -| `references/10dlc-evidence-checklist.md` | Worked example | Field-by-field checklist for Sent's 10DLC compliance form. | -| `references/10dlc-rejection-remediation.md` | Decision matrix | Common TCR / carrier rejection codes mapped to fix steps and re-submission etiquette. | -| `scripts/validate_10dlc_packet.py` | Validation script | Pre-flight validator for a packet JSON. Run from the skill root: `python scripts/validate_10dlc_packet.py packet.json`. | -| `scripts/fixtures/good.json` | Fixture | Complete valid packet (passes validator). | -| `scripts/fixtures/bad.json` | Fixture | Packet with missing fields / invalid EIN / short sample (validator exits non-zero). | - -## Unverified claims to confirm or remove - -- Sent's `/v3/brands` and `/v3/brands/{id}/campaigns` endpoints exist; their internal mapping to TCR identifiers is opaque to the customer. Store the Sent brand and campaign IDs returned by the API — don't claim a public `tcr_brand_id` or `tcr_campaign_id` field unless an API response surfaces it. -- Exact throughput limits, per-carrier caps, and vetting-score-to-throughput mapping are not in Sent's docs. The snapshot only confirms account-wide tier limits (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom) — these are not TCR / carrier per-campaign throughput numbers. -- Country-specific compliance, routing, and pricing claims beyond Sent's listed country-specific document requirements (AU, BE, PL, ZA, SE, TH, UK) require a current Sent source. +Never use real consumer data in fixtures or samples. Use [references/tcr-use-cases.md](references/tcr-use-cases.md) for classification and [references/10dlc-rejection-remediation.md](references/10dlc-rejection-remediation.md) for failures. diff --git a/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md b/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md index e848027..ddaf6b9 100644 --- a/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md +++ b/skills/sms-10dlc-registration/references/10dlc-evidence-checklist.md @@ -1,140 +1,46 @@ -# 10DLC Evidence Checklist — Sent Compliance Form +# 10DLC evidence checklist -Supporting reference for `sms-10dlc-registration`. This is the definitive list of fields a tenant must supply on Sent's compliance form before Sent files the TCR brand + campaign on their behalf. Field names below match the verified compliance form in the Sent dashboard. The deeper TCR payload (`Brand.entityName`, `Campaign.privacyPolicyLink`, etc.) is filed by Sent internally — keep that taxonomy in `references/tcr-use-cases.md`. +The evidence packet is an internal readiness artifact with `schema_version: "sent-10dlc-evidence/v1"`. Its snake_case fields are not the Sent API contract. -Use this alongside `references/tcr-use-cases.md` when authoring the form, validating an incoming packet, or debugging why a customer's submission failed pre-flight checks. Run `scripts/validate_10dlc_packet.py` against a JSON dump of the packet for the mechanical checks. +## Business identity -## Required fields — business identity +- [ ] Legal business name and EIN match registration records. +- [ ] Public website represents the same recognizable brand. +- [ ] Physical address, business phone, and compliance contact are current. +- [ ] Privacy policy and terms links are public HTTPS pages. -These appear on the KYC + compliance pages and are required for every tenant regardless of country. +## Consent -### 1. Legal business name +- [ ] Opt-in method is named: web form, keyword, paper, or spoken consent. +- [ ] Proof URL or equivalent evidence is accessible to a reviewer. +- [ ] Consent text names the brand and message type. +- [ ] The checkbox is not preselected. +- [ ] Frequency, message/data-rate notice, HELP, and STOP disclosures are present where required. +- [ ] Marketing spoken consent includes the additional confirmation required by policy. -- **Format:** Exact name as registered with the state or national business registry. Include the suffix (`LLC`, `Inc.`, `Corp.`, `LLP`). -- **Common mistakes:** - - Submitting a DBA, marketing brand, or trade name instead of the legal entity. Sent will reject if the EIN/tax-ID-to-name match fails at the issuer lookup step. - - Trailing punctuation (`Acme, Inc.,`) — strip the trailing comma. - - Mismatched capitalization vs registry records. -- **Sent tip:** If the customer uses a different consumer-facing brand, that goes in campaign sample messages, not here. +## Message flow -### 2. Business registration number +Describe the sequence from consumer action through confirmation and recurring messages. A link alone is not a message flow. -- **Format:** The jurisdiction's company / corporate registration number (e.g. state filing number in the US, Companies House number in the UK). -- **Common mistakes:** - - Confusing this with the EIN / tax ID — they're separate fields. - - Submitting the partner / member number instead of the entity's filing number. +## Autoresponses -### 3. Business type / structure +- [ ] `optinMessage` names the brand and explains HELP/STOP, frequency, and rates where required. +- [ ] `optoutMessage` confirms unsubscribe and no further messages. +- [ ] `helpMessage` names the brand and provides a support method. +- [ ] `optinKeywords`, `optoutKeywords`, and `helpKeywords` are documented. +- [ ] STOP is in opt-out keywords and HELP is in help keywords. -- **Format:** One of the dashboard's entity-type options (Private Profit, Public Profit, Non-Profit, Government, Sole Proprietor, etc.). -- **Common mistakes:** - - Sole proprietors marking themselves as Private Profit. Sole-prop senders are subject to additional restrictions and Sent must know. - - Non-profits marking themselves as Private Profit to avoid extra documentation. +## Campaign translation -### 4. Industry category +Map evidence into these Sent camelCase fields only at API serialization time: -- **Format:** Pick the closest match from the dropdown. Drives review path and downstream carrier filtering posture. -- **Common mistakes:** - - Picking "Other" when a specific match exists. - - Misclassifying regulated industries (health, finance, gambling) as general retail. +```text +message_flow -> messageFlow +privacy_policy_url -> privacyPolicyLink +terms_and_conditions_url -> termsAndConditionsLink +autoresponses.optinMessage -> optinMessage +use_cases[].messaging_use_case_us -> useCases[].messagingUseCaseUs +use_cases[].sample_messages -> useCases[].sampleMessages +``` -### 5. EIN / tax ID - -- **Format:** Federal tax ID for the country. US EIN is nine digits, optionally hyphenated after the first two: `12-3456789` or `123456789`. Regex: `^\d{2}-?\d{7}$`. -- **Common mistakes:** - - Confusing EIN with SSN (sole proprietors). Sole props with no EIN should pick the sole-proprietor entity type — see `references/tcr-use-cases.md`. - - Submitting a state tax ID instead of the federal EIN. - - Typos in the first two digits (the IRS prefix). These fail the IRS match every time. -- **Non-US tenants:** Use the equivalent national tax ID (e.g. VAT number, ABN, GST registration). - -### 6. Business address - -- **Format:** Street, city, state / region, postal code, country (ISO 3166-1 alpha-2). PO boxes are not accepted for primary brand address. -- **Common mistakes:** - - Using a mail-forwarding or virtual-office address that doesn't match the address on file with the tax authority for the EIN. - - Country code mismatches (`USA` vs `US`). - -### 7. Business phone number - -- **Format:** Full E.164 phone number reachable for compliance contact. -- **Common mistakes:** - - Submitting a number that goes to a marketing IVR with no path to a human. - - Submitting a personal mobile when the tenant is a registered entity. - -### 8. Contact email - -- **Format:** Monitored inbox for compliance correspondence. -- **Common mistakes:** - - Submitting `noreply@…`. Sent's compliance team replies to this address and reviewers test it. - - Submitting the founder's personal email when the company has compliance ownership in a separate team. - -## Required fields — messaging / use case - -These appear in the messaging-compliance section of the form and are filed against the TCR campaign. - -### 9. Use-case selection - -- **Format:** One of: **Authentication**, **Notifications**, **Marketing**, **Customer Service**, **High Volume**. -- **Common mistakes:** - - Picking **Marketing** for a transactional flow because it sounds friendlier. Use-case affects review bar and carrier filtering posture — pick the narrowest accurate option. - - Picking **High Volume** for genuinely low-volume traffic to "future-proof" — Sent maps this to the TCR `MIXED` flow which has a higher review bar. -- **Sent tip:** The dashboard's **Suggest** button auto-fills `Campaign description` based on the use case. Edit it to match the tenant's actual flow before submitting. - -### 10. Campaign description - -- **Format:** 1-3 sentences explaining what messages the tenant sends, to whom, and when. The dashboard's **Suggest** button drafts this; edit before submitting. -- **Common mistakes:** - - Generic descriptions ("transactional messages"). Reviewers want specifics: "Shipping and delivery updates for orders placed on acme.example.com." - - Description doesn't match the use-case option (e.g. describing promotional content under **Notifications**). - -### 11. Sample messages (per use case) - -- **Format:** Plain-text examples of actual production messages, with `{Variable}` placeholders. Should include the brand name. -- **Common mistakes:** - - Under 20 characters. Reviewers flag these as too generic. - - Including a URL shortener that isn't on the brand's verified-domains list. - - Sample doesn't match the declared use case (promo content in an Authentication sample). -- **Note on opt-out language in samples:** Including `Reply STOP to opt out.` in samples is good carrier hygiene and most reviewers expect to see it, but Sent's compliance form treats opt-out **keywords** as a separate field (see #13). Putting STOP in samples does not satisfy field #13 and vice versa. - -### 12. Opt-in mechanism (URL or description) - -- **Format:** Public URL showing the exact form, checkbox, or flow where end users consent to receive SMS — or a written description of the opt-in flow if no public URL exists. -- **Common mistakes:** - - Linking to a checkout flow without showing the SMS-consent checkbox. - - Consent language that bundles SMS with marketing email — reviewers want SMS-specific consent. - - Pre-checked consent boxes (forbidden under most state laws). - -### 13. Opt-out instructions (Opt Keywords tab) - -- **Format:** Managed in the Sent dashboard under **Compliance → Opt Keywords**, not as free-text on the compliance form. At minimum: `STOP` to opt out, `START` to resume. Help keyword (`HELP`) is configured here too. -- **Common mistakes:** - - Assuming the keywords are inferred from sample messages — they're configured separately and must be set explicitly. - - Customizing the auto-reply text without a brand-name reference. - - Forgetting `START` — required for re-opt-in after a `STOP`. - -## US-specific extras (required for 10DLC) - -| Field | Format | Common mistakes | -|---|---|---| -| **Live website URL** | Full URL with scheme that resolves at submission time. | Staging or preview URLs; coming-soon pages; non-HTTPS. | -| **Privacy policy URL** | Direct URL to a public privacy policy that mentions SMS data handling. | Pointing at the homepage; policy doesn't mention SMS / frequency / data sharing; 404 or auth-walled URL. | -| **Opt-in mechanism URL or screenshot** | Same as field #12 but a publicly hosted URL is strongly preferred for US 10DLC review. | Auth-walled flows; staging-only flows. | -| **Opt-out instructions (STOP / START)** | Configured via the **Opt Keywords** dashboard tab. | Configured per profile, not per brand — make sure the right profile is selected. | - -## Country-specific extras - -Selected countries require additional uploaded documents in the KYC stage. These are gated by the country selected during KYC, not by use case: - -| Country | Extra docs | -|---|---| -| Australia (AU) | Utility Bill | -| Belgium (BE) | Proof of Local Address, Passport, Business Registration Certificate | -| Poland (PL) | Proof of Local Address | -| South Africa (ZA) | Proof of Local Address | -| Sweden (SE) | Proof of Local Address | -| Thailand (TH) | Proof of Worldwide Address, Business Registration Certificate | -| United Kingdom (UK) | Proof of Local Address | - -## After the form is submitted - -Sent's pre-flight runs `scripts/validate_10dlc_packet.py` against a JSON dump of these answers. Issues at this stage are cheap to fix — once filed with TCR a rejection costs days of round-trip. See `references/10dlc-rejection-remediation.md` for what to do when an account error code, TCR, or a carrier bounces a submission that passed pre-flight. +Run the evidence validator before translation and the campaign validator after translation. diff --git a/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md b/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md index 38a3432..b15101b 100644 --- a/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md +++ b/skills/sms-10dlc-registration/references/10dlc-rejection-remediation.md @@ -1,126 +1,35 @@ -# 10DLC Rejection Remediation — Reference +# 10DLC rejection remediation -Supporting reference for `sms-10dlc-registration`. When a tenant says "my SMS isn't going through", the failure can be in three layers: **Sent account state** (the customer isn't fully onboarded), **TCR-side** (the brand or campaign was rejected before reaching carriers), or **carrier-side** (T-Mobile / AT&T / Verizon / MVNO declined a TCR-approved campaign). Each layer has a different fix path — always identify the layer first. +## Diagnose the layer -Pair with `references/10dlc-evidence-checklist.md` (what should be in the packet) and `references/tcr-use-cases.md` (taxonomy). Run `scripts/validate_10dlc_packet.py` against the corrected packet before re-submitting. +| Layer | Evidence | +| --- | --- | +| Local evidence packet | Version, missing consent proof, invalid URLs, incomplete autoresponses | +| Sent request validation | Wrong camelCase, unsupported field, invalid use case, sample count/length, volume type | +| TCR submission | `submittedToTCR`, registry error/reason, brand or campaign status | +| Carrier operations | Campaign active but filtering, DCA election, content mismatch | -## Layer 0 — Sent account-state errors (verified) +Do not create a new campaign until the failing layer is known. -Before assuming TCR or carrier rejection, check whether the API call itself is returning a Sent account-state error. These are returned synchronously from the v3 API and indicate the tenant isn't fully activated yet. +## Common fixes -| Sent error code | HTTP | Account states that produce it | What to do | -|---|---|---|---| -| `AUTH_006` | 403 | `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, `KYC_RESUBMISSION_REQUESTED` | KYC isn't complete. Finish KYC in the dashboard. If `KYC_RESUBMISSION_REQUESTED`, the compliance team is waiting on revised docs. | -| `AUTH_007` | 403 | `KYC_COMPLETED`, `MESSAGE_COMPLIANCE_COMPLETED` | KYC is done but no messaging channel is configured. Complete the channel + brand/campaign step in the dashboard. | -| `AUTH_005` | 403 | (post-`MESSAGE_COMPLIANCE_COMPLETED`, pre-activation) | Everything is filed; Sent is finishing internal activation. Wait and re-poll. | -| `BUSINESS_003` | 422 | (any active account) | Insufficient account balance. Not a compliance issue — top up billing. Common source of post-registration sending failures once a tenant goes live. | -| `BUSINESS_005` | 422 | (any active account) | A referenced template is still `PENDING` or `REJECTED` (Sent template lifecycle, not TCR). Confirm the SMS template at `/v3/templates/{id}` is `APPROVED` before sending. | +- Wrong endpoint model: operate campaigns under the profile and create a dedicated brand within profile creation. +- Inheritance conflict: disable `inherit_tcr_campaign` before managing a dedicated campaign; do not supply `brand` while brand inheritance is true. +- Wrong field casing: serialize `useCases`, `messagingUseCaseUs`, and `sampleMessages` exactly. +- Too few samples: provide 1–5 structurally; provide at least two for marketing/mixed compliance. +- Weak opt-in: add observable proof and a complete message flow. +- Autoresponse failure: add brand, STOP/HELP, unsubscribe confirmation, and support details as applicable. +- Traffic mismatch: select a use case that matches actual content rather than rewriting only the example. -If you're seeing any of the above, it is **not** a TCR or carrier rejection. Resolve the account-state issue first, then verify whether downstream layers are clean. +## Status interpretation -## Layer 1 — TCR-side rejections (external) +`SENT_CREATED` means a Sent campaign record exists. `submittedToTCR: true` records registry submission. `ACTIVE` means operational activation, while `EXPIRED` is not send-ready. Preserve unknown status strings and any `tcrSyncError` rather than replacing them with a guessed category. -TCR rejected the brand or campaign before it reached carriers. Faster turnaround. Most often: missing data, EIN-name mismatch, content that violates TCR policy. Sent surfaces these on the Sender Profile / Compliance status surfaces; the specific rejection strings come from TCR and are not part of Sent's public error catalog. +## Resubmission -## Layer 2 — Carrier-side rejections (external) - -TCR approved the campaign, but one or more of T-Mobile, AT&T, Verizon, or an MVNO declined. The campaign's per-carrier state will show `DECLINED` or `SUSPENDED`. Carrier decisions are slower to reverse and require evidence updates before re-submission. Per-carrier rejection codes are owned by each carrier — track them externally. - -## 1. Brand verification failure (`UNVERIFIED`, `VETTING_FAILED`) - -- **Detect:** TCR brand status moves to `UNVERIFIED` or external vetting returns `FAILED`. Sent surfaces this on the Compliance status of the brand. -- **Root causes:** - - EIN doesn't match the IRS record for the supplied legal name. - - Brand address doesn't match the IRS or state filing. - - Submitted entity type (`PRIVATE_PROFIT` vs `NON_PROFIT`) contradicts the IRS record. -- **Fix steps:** - 1. Re-pull the tenant's IRS EIN confirmation letter (Form CP-575) and reconcile name, address, and entity type field-by-field. - 2. Correct the brand record in Sent. Resubmit for vetting (costs another vet fee). - 3. If the legal name has genuinely changed, the tenant needs an IRS Form 147C confirming the current name before re-vetting. -- **Re-submission etiquette:** Don't re-submit the same data hoping for a different reviewer. Re-vetting with unchanged data is logged as a duplicate and may slow the next legitimate re-vet. - -## 2. EIN-to-legal-name mismatch - -- **Detect:** Pre-flight (`scripts/validate_10dlc_packet.py`) catches format issues; TCR catches semantic mismatch. -- **Root causes:** - - Tenant submitted a DBA instead of legal entity name. - - Recent legal name change not yet reflected with the IRS. - - Typo in EIN. -- **Fix steps:** - 1. Confirm the EIN against the IRS confirmation letter — not the tenant's accounting system. - 2. Update legal name to match exactly (including suffix). - 3. If a legitimate name change has occurred, request Form 147C from IRS before re-filing. -- **Re-submission etiquette:** Note the changed fields in the resubmission cover letter. Reviewers approve corrections faster when the delta is explicit. - -## 3. Campaign content does not match declared use case - -- **Detect:** TCR returns a use-case-mismatch verdict or carriers return a content-violation verdict. Often surfaces as an Authentication / 2FA campaign getting downgraded after a sample promo message slips in. -- **Root causes:** - - Promotional language in samples for **Notifications**, **Authentication**, or **Customer Service**. - - Samples mention a discount, sale, or call-to-buy. - - Sample uses a transactional voice but the declared use case is **Marketing**. -- **Fix steps:** - 1. Re-classify: if any sample is genuinely promotional, split into two campaigns rather than reclassifying everything as **High Volume** (see `references/tcr-use-cases.md`). - 2. Rewrite samples to mirror only the traffic that belongs in the declared use case. - 3. For ambiguous samples, lead with the trigger event (`Your order #1029 has shipped`) — reviewers parse the first sentence hardest. -- **Re-submission etiquette:** Resubmit with the trimmed samples. If splitting into two campaigns, file them sequentially, not in parallel, so the first one's vetting score informs the second. - -## 4. Opt-in evidence insufficient - -- **Detect:** TCR flags the opt-in URL or it gets flagged in manual review. -- **Root causes:** - - URL points at a homepage rather than the specific consent form. - - Consent language bundles SMS with email or push. - - Pre-checked consent box. - - Consent is buried in terms of service rather than at the point of phone-number capture. -- **Fix steps:** - 1. Update the live opt-in surface so the SMS checkbox is unchecked by default and the consent text is SMS-specific. - 2. Take a fresh screenshot with timestamp and host it publicly. - 3. Re-file with the new URL or screenshot as the opt-in evidence. -- **Re-submission etiquette:** Don't paste a URL that requires login. If the consent flow is behind auth, host a public mock that mirrors the production UX. - -## 5. Sample messages too generic - -- **Detect:** TCR flags samples as too generic, or carriers downgrade vetting after launch. -- **Root causes:** - - Samples under 20 characters or under 3 sentences. - - Missing brand name, recipient context, or opt-out language. - - Samples are paraphrases instead of literal production sends. -- **Fix steps:** - 1. Pull 5 real sends (with PII redacted) from staging. - 2. Replace generic samples with the redacted real sends, keeping brand name intact. - 3. Make sure samples cover the variety the campaign will actually send. -- **Re-submission etiquette:** Number the samples in the cover note so a reviewer can confirm each one passes their content scan. - -## 6. Opt-out configuration missing or inconsistent - -- **Detect:** Sample messages don't reference opt-out, or the **Compliance → Opt Keywords** tab has STOP / START / HELP unconfigured for the active profile. -- **Root causes:** - - Opt Keywords tab not configured for the profile sending the traffic. - - Brand name missing from the opt-out confirmation auto-reply. - - Samples don't mention the opt-out instruction (even though the rule is in Opt Keywords, carriers still expect to see hints in samples). -- **Fix steps:** - 1. Configure **STOP** and **START** at minimum in **Compliance → Opt Keywords** for the active profile. - 2. Verify the auto-reply text references the brand and the word `STOP`. - 3. Add a hint like `Reply STOP to unsubscribe.` to samples — it's not the Sent-required field but most carriers expect it. -- **Re-submission etiquette:** Note in the cover that both the Opt Keywords config and the samples were updated together. - -## 7. Prohibited content category - -- **Detect:** TCR flags `SHAFT` (sex, hate, alcohol, firearms, tobacco) or a similar prohibited-content verdict. Some carriers extend this list (cannabis, payday loans, debt collection). -- **Root causes:** - - Campaign content falls into an outright prohibited category for US carriers. - - Age-gated category declared but no age-verification at opt-in. - - Affiliate marketing without disclosure. -- **Fix steps:** - 1. If outright prohibited (e.g. cannabis on Verizon), 10DLC is not the right channel. Surface this back to the tenant; the registration cannot succeed. - 2. If age-gated, add age verification at opt-in and update samples to reference the gate. - 3. If affiliate marketing, declare it honestly and add disclosure in the message body. -- **Re-submission etiquette:** Don't shop the same prohibited campaign to different reviewers. Address the category restriction or recommend an alternative channel (e.g. WhatsApp, RCS, email). - -## General re-submission etiquette - -- Fix one class of issue at a time. Bundling unrelated changes into a single re-submission makes it hard for reviewers to confirm each fix. -- Include a short cover note listing what changed since the last submission. -- Re-run `scripts/validate_10dlc_packet.py` before every re-submission. Mechanical failures are free to catch and expensive to re-file for. -- Track rejection codes on the Sender Profile so repeat rejections trigger a manual review before another re-file. +1. Save the original response and reason. +2. Fix the versioned evidence packet if necessary. +3. Rebuild and validate the API request. +4. Use `sandbox: true`. +5. Show the exact diff. +6. Obtain confirmation before the real mutation. diff --git a/skills/sms-10dlc-registration/references/tcr-use-cases.md b/skills/sms-10dlc-registration/references/tcr-use-cases.md index f1e564d..34085e5 100644 --- a/skills/sms-10dlc-registration/references/tcr-use-cases.md +++ b/skills/sms-10dlc-registration/references/tcr-use-cases.md @@ -1,96 +1,35 @@ -# TCR Use Cases & Vetting — Reference +# TCR use cases -Supporting reference for `sms-10dlc-registration`. The Campaign Registry's use-case taxonomy and the practical effect of each choice on throughput and carrier filtering. The canonical list is on the [TCR website](https://www.campaignregistry.com/) — use this doc for the *interpretation* in a multi-tenant Sent deployment. +## Current Sent API values -## Sent's compliance-form taxonomy (verified) +| Value | Typical traffic | +| --- | --- | +| `MARKETING` | Promotions, offers, product announcements | +| `ACCOUNT_NOTIFICATION` | Account changes, balances, non-security notices | +| `CUSTOMER_CARE` | Support conversations and case updates | +| `FRAUD_ALERT` | Suspected fraud notifications | +| `TWO_FA` | One-time passcodes and two-factor authentication | +| `DELIVERY_NOTIFICATION` | Shipment and delivery updates | +| `SECURITY_ALERT` | Security events distinct from general account notices | +| `M2M` | Machine-to-machine operational traffic | +| `MIXED` | Multiple standard use cases in one campaign | +| `HIGHER_EDUCATION` | College or university communications | +| `POLLING_VOTING` | Polling and voting interactions | +| `PUBLIC_SERVICE_ANNOUNCEMENT` | Public-interest or government notices | +| `LOW_VOLUME` | Low-volume mixed traffic | -Sent's compliance form exposes a **five-option** use-case selector to the tenant, not the full TCR taxonomy. Pick one of these on the form: +Select what the business will actually send. Do not use `MIXED` or `LOW_VOLUME` to hide a dominant high-risk use case. -| Sent compliance-form option | Typical underlying TCR use case(s) | -|---|---| -| **Authentication** | `2FA` | -| **Notifications** | `ACCOUNT_NOTIFICATION`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `SECURITY_ALERT`, `PUBLIC_SERVICE_ANNOUNCEMENT` | -| **Marketing** | `MARKETING` | -| **Customer Service** | `CUSTOMER_CARE` | -| **High Volume** | `MIXED` / high-throughput senders (Sent maps internally) | +## Sample rules -Sent files the TCR-side payload on the tenant's behalf — the deeper TCR taxonomy below is for interpreting that mapping and reasoning about why a use-case selection might trigger more or less filtering. The tenant only sees and chooses one of the five form values. +The API structure permits 1–5 samples per use case, up to 1,024 characters each. Compliance policy requires at least two samples for `MARKETING`, `MIXED`, and low-volume mixed traffic. Samples should: -## TCR use cases sorted by throughput & filtering risk (external interpretation) +- name the recognizable consumer brand; +- look like real production messages; +- match the selected use case and described message flow; +- include opt-out language when applicable; +- use synthetic names, codes, numbers, and URLs. -| Use case | Typical TPS (post-vetting) | Filtering risk | When to pick | -|---|---|---|---| -| `2FA` | Highest | Lowest | Codes only. No promotional content. | -| `ACCOUNT_NOTIFICATION` | High | Low | Order, shipping, appointment, payment, security notifications. Triggered by the recipient's action. | -| `CUSTOMER_CARE` | Medium-high | Low | Two-way support replies. Mostly inbound-driven. | -| `DELIVERY_NOTIFICATION` | High | Low | Specialized variant of ACCOUNT_NOTIFICATION for couriers. | -| `FRAUD_ALERT` | High | Low | Account-security alerts and fraud confirmations. | -| `HIGHER_EDUCATION` | Medium | Medium | School-affiliated communications. Required for many edu senders. | -| `LOW_VOLUME` | Low | Low | Pilots, internal tools, < 6,000 messages/day. | -| `MARKETING` | Medium | High | Promo, discount, win-back. Highest review bar; opt-in must be airtight. | -| `MIXED` | Medium | High | Last resort. Raises the bar without raising throughput. | -| `POLITICAL` | Medium | High | Political campaigns (US). Regulated category. | -| `POLLING_VOTING` | Medium | Medium | Surveys, polls, non-political voting. | -| `PUBLIC_SERVICE_ANNOUNCEMENT` | Medium | Low | Non-profit / government PSAs. | -| `SECURITY_ALERT` | High | Low | Specialized variant of FRAUD_ALERT for non-financial security. | -| `SOCIAL` | Medium | Medium | Person-to-person-feeling but business-sent (matchmaking, social apps). | +## Volume -Specific TPS numbers and vetting-score-to-throughput thresholds are **external** — Sent's docs only confirm the tier-level account-wide rate caps (Starter 60 msg/min, Growth 300 msg/min, Enterprise custom), not TCR / carrier per-campaign throughput. - -## Brand external vetting (external context) - -Brands can submit for **External Vetting** via TCR-approved vetting providers. This produces a `vettingScore` that: - -- Unlocks higher per-campaign TPS caps from each carrier. -- Reduces the filtering aggressiveness applied to the campaign's traffic. -- Cannot be inherited from another brand — every brand vets independently. - -The exact vetting-score-to-throughput mapping is set by each carrier and not exposed in Sent's docs. Treat as external (TCR + carrier docs). - -## Required campaign attributes (TCR-side, external) - -Every TCR campaign declares these. They're not directly user-visible on Sent's compliance form, but Sent files them from the form data plus dashboard configuration: - -- **Subscriber opt-in** — How recipients agreed to receive these messages. Captured on Sent's compliance form as the **opt-in mechanism URL or description**. -- **Opt-out keywords** — Minimum `STOP`. Managed on Sent in the **Compliance → Opt Keywords** tab; not embedded per sample message. -- **Help keywords** — Minimum `HELP`. Reply should describe what the campaign is and how to opt out. -- **Embedded link** — Whether your messages include URLs (`yes` / `no`). -- **Embedded phone** — Whether your messages include phone numbers (`yes` / `no`). -- **Affiliate marketing** — Whether the campaign promotes affiliate offers (`yes` / `no`). `yes` is heavily scrutinized. -- **Age-gated content** — Alcohol, gambling, firearms, tobacco. `yes` requires age verification at opt-in. -- **Direct lending** — Whether the campaign is for direct loans. Subject to additional review. - -## Sample messages - -TCR requires 2-5 sample messages per campaign. Carrier filters use these to validate live traffic. Mismatches between samples and production are the most common reason for downgrade. - -Good sample: -> {Brand Name}: Your order #1029 has shipped. Track: https://example.com/track/1029. - -Bad sample (don't do): -> Your order has shipped! - -Bad because: no brand name, no link/tracking specifics that mirror production. - -Note: Opt-out language (`Reply STOP to opt out.`) is conventional in samples and many carriers expect to see it, but the **rule** that every Sent-filed sample must literally embed `STOP` is not confirmed in Sent's docs — Sent treats opt-out as a separate field on the compliance form (Opt Keywords tab) rather than embedded in each sample. Sample-level opt-out is good carrier hygiene; the Sent-required field lives elsewhere. - -## Per-carrier filtering notes (external) - -TCR-approved doesn't mean delivered. Each major US carrier filters independently: - -- **T-Mobile** — Most aggressive. Strict on URL shorteners (use a branded short-link domain on your verified domains, not generic `bit.ly`). -- **AT&T** — Stricter on message-volume spikes than on content. Pace sends. -- **Verizon** — More content-sensitive on `MARKETING` and `MIXED`. Promotional content in an `ACCOUNT_NOTIFICATION` campaign gets caught here first. - -Track per-carrier delivery in the MDR funnel and reconcile per-carrier approval state on the campaign daily. Specific carrier rejection codes are external (Twilio / Bandwidth / Sinch / direct-aggregator docs). - -## Common rejection reasons (external taxonomy) - -| Reason | What it means | Fix | -|---|---|---| -| `Use case mismatch` | Sample messages don't fit the declared use case | Re-classify campaign or rewrite samples | -| `Missing opt-out language` | Samples don't include STOP / HELP language | Add it everywhere | -| `Brand name not in sender ID` | Recipient can't see who's texting | Add `{Brand Name}:` prefix to messages | -| `Affiliate disclosed = no, content suggests affiliate` | Lied on the attribute | Declare honestly | -| `Embedded link not declared` | Samples have links, attribute says no | Update the attribute | -| `Insufficient vetting score for requested TPS` | Vetting too low for the throughput tier | Re-vet at a higher tier, or accept lower TPS | +`volume` is a numeric string. Values below `2000` are in the documented low-volume tier. Exactly `2000` crosses the tier boundary, so validate `1999` and `2000` separately. diff --git a/skills/sms-10dlc-registration/scripts/fixtures/bad.json b/skills/sms-10dlc-registration/scripts/fixtures/bad.json index ee07bc8..f532cb6 100644 --- a/skills/sms-10dlc-registration/scripts/fixtures/bad.json +++ b/skills/sms-10dlc-registration/scripts/fixtures/bad.json @@ -1,26 +1,21 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "123-456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "2175550101", - "contact_email": "compliance-at-example.com", - "website": "https://example.com", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": [], - "start": ["START"], - "help": ["HELP"] + "schema_version": "unversioned-packet", + "legal_business_name": "", + "ein": "123", + "business_phone": "2025550100", + "contact_email": "invalid", + "website": "not-a-public-url", + "privacy_policy_url": "missing-scheme.example", + "terms_and_conditions_url": "", + "opt_in_evidence": {}, + "message_flow": "", + "autoresponses": { + "optinMessage": "", + "optoutMessage": "", + "helpMessage": "", + "optinKeywords": "YES", + "optoutKeywords": "UNSUBSCRIBE", + "helpKeywords": "INFO" }, - "use_cases": [ - { - "selection": "Transactional", - "description": "Shipping and delivery updates.", - "sample_messages": [ - "Order shipped!" - ] - } - ] + "use_cases": [] } diff --git a/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json b/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json new file mode 100644 index 0000000..0cea7bf --- /dev/null +++ b/skills/sms-10dlc-registration/scripts/fixtures/campaign_bad.json @@ -0,0 +1,16 @@ +{ + "campaign": { + "name": "Bad campaign", + "description": "Uses the wrong field names and too few marketing samples.", + "type": "App", + "use_cases": [], + "useCases": [ + { + "messagingUseCaseUs": "MARKETING", + "sampleMessages": ["One sample only"] + } + ], + "volume": 2000 + }, + "sandbox": true +} diff --git a/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json b/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json new file mode 100644 index 0000000..bc312c7 --- /dev/null +++ b/skills/sms-10dlc-registration/scripts/fixtures/campaign_good.json @@ -0,0 +1,26 @@ +{ + "campaign": { + "name": "Acme account notifications", + "description": "Synthetic account and delivery notifications for opted-in customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Acme Example: Your account preference was updated. Reply STOP to opt out." + ] + } + ], + "volume": "2000", + "messageFlow": "Customers opt in through the account settings form before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms", + "optinMessage": "Acme Example: You are subscribed. Reply STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" + }, + "sandbox": true +} diff --git a/skills/sms-10dlc-registration/scripts/fixtures/good.json b/skills/sms-10dlc-registration/scripts/fixtures/good.json index c40ddb4..1ca09fa 100644 --- a/skills/sms-10dlc-registration/scripts/fixtures/good.json +++ b/skills/sms-10dlc-registration/scripts/fixtures/good.json @@ -1,34 +1,33 @@ { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", + "schema_version": "sent-10dlc-evidence/v1", + "legal_business_name": "Acme Example LLC", "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", + "business_address": "123 Example Street, New York, NY 10001, US", + "business_phone": "+12025550100", "contact_email": "compliance@example.com", "website": "https://example.com", "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup?sms=consent", - "opt_keywords": { - "stop": ["STOP", "UNSUBSCRIBE", "CANCEL"], - "start": ["START", "UNSTOP"], - "help": ["HELP", "INFO"] + "terms_and_conditions_url": "https://example.com/terms", + "opt_in_evidence": { + "method": "web_form", + "description": "A synthetic checkout form uses an unchecked SMS consent box.", + "proof_url": "https://example.com/sms-consent" + }, + "message_flow": "Customers submit the web form and receive the opt-in confirmation before notifications begin.", + "autoresponses": { + "optinMessage": "Acme Example: You are subscribed. Msg frequency varies. Msg and data rates may apply. Reply HELP for help or STOP to opt out.", + "optoutMessage": "Acme Example: You are unsubscribed and will receive no more messages.", + "helpMessage": "Acme Example: Visit https://example.com/support for help.", + "optinKeywords": "START,YES", + "optoutKeywords": "STOP,UNSUBSCRIBE", + "helpKeywords": "HELP,INFO" }, "use_cases": [ { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders placed on example.com.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Track: https://example.com/track/1029. Reply STOP to opt out.", - "Example: Order #1042 is out for delivery today. Reply STOP to opt out." - ] - }, - { - "selection": "Authentication", - "description": "One-time login codes for example.com account holders.", + "messaging_use_case_us": "MARKETING", "sample_messages": [ - "Example: Your verification code is 482910. It expires in 10 minutes." + "Acme Example: Save 10% on your next order. Reply STOP to opt out.", + "Acme Example: New items are available. Reply STOP to opt out." ] } ] diff --git a/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py b/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py index d59ee0d..29547e4 100644 --- a/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py +++ b/skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py @@ -1,48 +1,9 @@ #!/usr/bin/env python3 -"""Validate a Sent 10DLC compliance packet before it is filed with TCR. +"""Validate the internal 10DLC evidence packet schema. -Usage: - python validate_10dlc_packet.py - -The packet is a JSON object representing the answers a tenant submitted on -Sent's compliance form. Field names match the verified Sent compliance form -in the dashboard (see references/10dlc-evidence-checklist.md). The validator -checks the mechanical things that are cheap to catch locally and expensive -to discover after TCR or a carrier rejects the submission. Semantic checks -(use-case match, content policy) are out of scope. - -Expected shape: - - { - "legal_business_name": "Example Corp LLC", - "business_registration_number": "IL-12345678", - "business_type": "PRIVATE_PROFIT", - "industry_category": "RETAIL", - "ein": "12-3456789", - "business_address": "123 Main St, Springfield, IL 62701, US", - "business_phone": "+12175550101", - "contact_email": "compliance@example.com", - "website": "https://example.com", - "privacy_policy_url": "https://example.com/privacy", - "opt_in_mechanism_url": "https://example.com/signup", - "opt_keywords": { - "stop": ["STOP"], - "start": ["START"], - "help": ["HELP"] - }, - "use_cases": [ - { - "selection": "Notifications", - "description": "Shipping and delivery updates for orders.", - "sample_messages": [ - "Example: Your order #1029 has shipped. Reply STOP to opt out." - ] - } - ] - } - -Exits 0 with `OK` on success. On failure, prints one issue per line in the -form `: : ` and exits 1. +This packet is readiness evidence, not the Sent campaign API request. Its +snake_case fields are namespaced by an explicit schema version so they cannot +be mistaken for Sent's camelCase contract. """ from __future__ import annotations @@ -51,217 +12,138 @@ import json import re import sys +from pathlib import Path from typing import Any -# Required top-level keys on the packet. Match the verified Sent compliance -# form field names — business identity, US-required URLs, opt keywords, and -# use cases. -REQUIRED_TOP_LEVEL = ( + +SCHEMA_VERSION = "sent-10dlc-evidence/v1" +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +EIN_RE = re.compile(r"^\d{2}-?\d{7}$") +PHONE_RE = re.compile(r"^\+\d{1,15}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +REQUIRED = { + "schema_version", "legal_business_name", - "business_registration_number", - "business_type", - "industry_category", "ein", "business_address", "business_phone", "contact_email", "website", "privacy_policy_url", - "opt_in_mechanism_url", - "opt_keywords", + "terms_and_conditions_url", + "opt_in_evidence", + "message_flow", + "autoresponses", "use_cases", -) - -# Required keys on each use case. -REQUIRED_USE_CASE = ( - "selection", - "description", - "sample_messages", -) - -# Verified use-case selection values from the Sent compliance form. -VALID_USE_CASE_SELECTIONS = ( - "Authentication", - "Notifications", - "Marketing", - "Customer Service", - "High Volume", -) - -URL_FIELDS = ("website", "privacy_policy_url", "opt_in_mechanism_url") - -# Permissive URL regex: scheme + host + optional path. Catches obvious junk -# (no scheme, internal whitespace, missing host) without trying to be a full -# RFC 3986 validator. -URL_RE = re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE) - -# EIN: 9 digits, optionally hyphenated after the first two. -EIN_RE = re.compile(r"^\d{2}-?\d{7}$") - -# E.164 phone number: leading +, then 1-15 digits. -PHONE_RE = re.compile(r"^\+\d{1,15}$") - -# Permissive email regex — local@domain.tld. -EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - -# Minimum length of a sample message before reviewers flag it as too generic. -MIN_SAMPLE_LEN = 20 +} -def _is_nonempty_string(value: Any) -> bool: - return isinstance(value, str) and value.strip() != "" +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) -def validate(packet: dict[str, Any], path: str) -> list[str]: - """Return a list of issue strings. Empty list means the packet is valid.""" +def validate(packet: Any, path: str = "") -> list[str]: issues: list[str] = [] def issue(field: str, reason: str) -> None: issues.append(f"{path}: {field}: {reason}") if not isinstance(packet, dict): - issue("", "packet must be a JSON object") + issue("", "must be a JSON object") return issues - - # Top-level required keys. - for key in REQUIRED_TOP_LEVEL: - if key not in packet: - issue(key, "missing required field") - elif key == "use_cases": - if not isinstance(packet[key], list) or len(packet[key]) == 0: - issue(key, "must be a non-empty list") - elif key == "opt_keywords": - if not isinstance(packet[key], dict): - issue(key, "must be a JSON object with stop/start/help keys") - elif not _is_nonempty_string(packet[key]): + for key in sorted(REQUIRED - set(packet)): + issue(key, "missing required field") + if packet.get("schema_version") != SCHEMA_VERSION: + issue("schema_version", f"must equal {SCHEMA_VERSION!r}") + for key in ("legal_business_name", "business_address", "message_flow"): + if key in packet and not _text(packet[key]): issue(key, "must be a non-empty string") + if _text(packet.get("ein")) and not EIN_RE.fullmatch(packet["ein"]): + issue("ein", "must contain nine digits, optionally formatted NN-NNNNNNN") + if _text(packet.get("business_phone")) and not PHONE_RE.fullmatch(packet["business_phone"]): + issue("business_phone", "must be E.164") + if _text(packet.get("contact_email")) and not EMAIL_RE.fullmatch(packet["contact_email"]): + issue("contact_email", "must be a valid email") + for key in ("website", "privacy_policy_url", "terms_and_conditions_url"): + if key in packet and (not _text(packet[key]) or not URL_RE.fullmatch(packet[key])): + issue(key, "must be a public HTTPS URL") + + opt_in = packet.get("opt_in_evidence") + if not isinstance(opt_in, dict): + issue("opt_in_evidence", "must be an object") + else: + for key in ("method", "description", "proof_url"): + if not _text(opt_in.get(key)): + issue(f"opt_in_evidence.{key}", "must be a non-empty string") + proof = opt_in.get("proof_url") + if _text(proof) and not URL_RE.fullmatch(proof): + issue("opt_in_evidence.proof_url", "must be a public HTTPS URL") + + autoresponses = packet.get("autoresponses") + if not isinstance(autoresponses, dict): + issue("autoresponses", "must be an object") + else: + for key in ("optinMessage", "optoutMessage", "helpMessage", "optinKeywords", "optoutKeywords", "helpKeywords"): + if not _text(autoresponses.get(key)): + issue(f"autoresponses.{key}", "must be a non-empty string") + if "STOP" not in str(autoresponses.get("optoutKeywords", "")).upper().split(","): + issue("autoresponses.optoutKeywords", "must include STOP") + if "HELP" not in str(autoresponses.get("helpKeywords", "")).upper().split(","): + issue("autoresponses.helpKeywords", "must include HELP") - # URL fields. - for key in URL_FIELDS: - value = packet.get(key) - if _is_nonempty_string(value) and not URL_RE.match(value): - issue(key, f"not a valid URL: {value!r}") - - # EIN format. - ein = packet.get("ein") - if _is_nonempty_string(ein) and not EIN_RE.match(ein): - issue("ein", f"must match ^\\d{{2}}-?\\d{{7}}$ (got {ein!r})") - - # Business phone format (E.164). - phone = packet.get("business_phone") - if _is_nonempty_string(phone) and not PHONE_RE.match(phone): - issue( - "business_phone", - f"must be E.164 (+CCNNNNNNNNNN, got {phone!r})", - ) - - # Contact email format. - email = packet.get("contact_email") - if _is_nonempty_string(email) and not EMAIL_RE.match(email): - issue("contact_email", f"not a valid email address: {email!r}") - - # Opt keywords — at minimum STOP must be configured. - opt_keywords = packet.get("opt_keywords") - if isinstance(opt_keywords, dict): - stop = opt_keywords.get("stop") - if not isinstance(stop, list) or not any( - _is_nonempty_string(kw) and kw.strip().upper() == "STOP" - for kw in stop - ): - issue( - "opt_keywords.stop", - "must include 'STOP' as an opt-out keyword (configured in " - "the Compliance → Opt Keywords dashboard tab)", - ) - - # Use cases. use_cases = packet.get("use_cases") - if isinstance(use_cases, list): - for i, uc in enumerate(use_cases): - prefix = f"use_cases[{i}]" - if not isinstance(uc, dict): - issue(prefix, "must be a JSON object") + if not isinstance(use_cases, list) or not use_cases: + issue("use_cases", "must be a non-empty array") + else: + for index, use_case in enumerate(use_cases): + field = f"use_cases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") continue - - for key in REQUIRED_USE_CASE: - if key not in uc: - issue(f"{prefix}.{key}", "missing required field") - - selection = uc.get("selection") - description = uc.get("description") - if "selection" in uc: - if not _is_nonempty_string(selection): - issue(f"{prefix}.selection", "must be a non-empty string") - elif selection not in VALID_USE_CASE_SELECTIONS: - issue( - f"{prefix}.selection", - f"must be one of {VALID_USE_CASE_SELECTIONS} " - f"(got {selection!r})", - ) - if "description" in uc and not _is_nonempty_string(description): - issue(f"{prefix}.description", "must be a non-empty string") - - samples = uc.get("sample_messages") - if "sample_messages" in uc: - if not isinstance(samples, list) or len(samples) == 0: - issue( - f"{prefix}.sample_messages", - "must be a non-empty list (>=1 sample per use case)", - ) - else: - for j, sample in enumerate(samples): - sprefix = f"{prefix}.sample_messages[{j}]" - if not _is_nonempty_string(sample): - issue(sprefix, "must be a non-empty string") - elif len(sample.strip()) < MIN_SAMPLE_LEN: - issue( - sprefix, - f"sample is {len(sample.strip())} chars; " - f"reviewers flag samples under {MIN_SAMPLE_LEN} as too generic", - ) - + if use_case.get("messaging_use_case_us") not in USE_CASES: + issue(f"{field}.messaging_use_case_us", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sample_messages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sample_messages", "must contain 1–5 samples") + elif any(not _text(sample) or len(sample) > 1024 for sample in samples): + issue(f"{field}.sample_messages", "samples must be non-empty strings of at most 1,024 characters") return issues def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - prog="validate_10dlc_packet.py", - description=( - "Validate a Sent 10DLC compliance packet (JSON) before filing " - "with The Campaign Registry. Checks required fields, URL / EIN " - "/ phone / email format, use-case selection, sample-message " - "length, and opt-out keyword configuration. See " - "references/10dlc-evidence-checklist.md for the field-by-field " - "rationale." - ), - ) - parser.add_argument("packet", help="Path to the packet JSON file") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("packet", type=Path) args = parser.parse_args(argv) - - path = args.packet - try: - with open(path, encoding="utf-8") as f: - packet = json.load(f) - except FileNotFoundError: - print(f"{path}: : not found", file=sys.stderr) - return 1 - except json.JSONDecodeError as e: - print(f"{path}: : invalid JSON ({e})", file=sys.stderr) + packet = json.loads(args.packet.read_text(encoding="utf-8")) + except OSError as exc: + print(f"{args.packet}: : {exc}", file=sys.stderr) return 1 - except OSError as e: - print(f"{path}: : could not read ({e})", file=sys.stderr) + except json.JSONDecodeError as exc: + print(f"{args.packet}: : invalid JSON ({exc})", file=sys.stderr) return 1 - - issues = validate(packet, path) + issues = validate(packet, str(args.packet)) if issues: - for line in issues: - print(line, file=sys.stderr) + print("\n".join(issues), file=sys.stderr) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py b/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py new file mode 100644 index 0000000..a3e54f5 --- /dev/null +++ b/skills/sms-10dlc-registration/scripts/validate_campaign_payload.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Validate the exact Sent campaign request used by profile campaign endpoints.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +USE_CASES = { + "MARKETING", + "ACCOUNT_NOTIFICATION", + "CUSTOMER_CARE", + "FRAUD_ALERT", + "TWO_FA", + "DELIVERY_NOTIFICATION", + "SECURITY_ALERT", + "M2M", + "MIXED", + "HIGHER_EDUCATION", + "POLLING_VOTING", + "PUBLIC_SERVICE_ANNOUNCEMENT", + "LOW_VOLUME", +} +POLICY_TWO_SAMPLE_CASES = {"MARKETING", "MIXED", "LOW_VOLUME"} +CAMPAIGN_REQUIRED = {"name", "description", "type", "useCases"} +CAMPAIGN_OPTIONAL = { + "volume", + "messageFlow", + "privacyPolicyLink", + "termsAndConditionsLink", + "optinMessage", + "optoutMessage", + "helpMessage", + "optinKeywords", + "optoutKeywords", + "helpKeywords", +} +URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE) +VOLUME_RE = re.compile(r"^\d+$") + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def validate(payload: Any, path: str = "") -> list[str]: + issues: list[str] = [] + + def issue(field: str, reason: str) -> None: + issues.append(f"{path}: {field}: {reason}") + + if not isinstance(payload, dict): + issue("", "must be a JSON object") + return issues + for key in sorted(set(payload) - {"campaign", "sandbox"}): + issue(key, "unsupported top-level field") + if "sandbox" in payload and not isinstance(payload["sandbox"], bool): + issue("sandbox", "must be boolean") + campaign = payload.get("campaign") + if not isinstance(campaign, dict): + issue("campaign", "required and must be an object") + return issues + allowed = CAMPAIGN_REQUIRED | CAMPAIGN_OPTIONAL + for key in sorted(set(campaign) - allowed): + issue(f"campaign.{key}", "unsupported field; use the exact camelCase Sent contract") + for key in sorted(CAMPAIGN_REQUIRED - set(campaign)): + issue(f"campaign.{key}", "missing required field") + for key in ("name", "description", "type"): + if key in campaign and not _text(campaign[key]): + issue(f"campaign.{key}", "must be a non-empty string") + volume = campaign.get("volume") + if volume is not None and (not isinstance(volume, str) or not VOLUME_RE.fullmatch(volume)): + issue("campaign.volume", "must be a numeric string such as '1999' or '2000'") + for key in ("privacyPolicyLink", "termsAndConditionsLink"): + value = campaign.get(key) + if value is not None and (not _text(value) or not URL_RE.fullmatch(value)): + issue(f"campaign.{key}", "must be a public HTTPS URL or null") + for key in ("optinKeywords", "optoutKeywords", "helpKeywords"): + value = campaign.get(key) + if value is not None and (not _text(value) or len(value) > 255): + issue(f"campaign.{key}", "must be a non-empty string of at most 255 characters or null") + + use_cases = campaign.get("useCases") + if not isinstance(use_cases, list) or not use_cases: + issue("campaign.useCases", "must be a non-empty array") + return issues + for index, use_case in enumerate(use_cases): + field = f"campaign.useCases[{index}]" + if not isinstance(use_case, dict): + issue(field, "must be an object") + continue + for key in sorted(set(use_case) - {"messagingUseCaseUs", "sampleMessages"}): + issue(f"{field}.{key}", "unsupported field") + selected = use_case.get("messagingUseCaseUs") + if selected not in USE_CASES: + issue(f"{field}.messagingUseCaseUs", f"must be one of {sorted(USE_CASES)}") + samples = use_case.get("sampleMessages") + if not isinstance(samples, list) or not 1 <= len(samples) <= 5: + issue(f"{field}.sampleMessages", "must contain 1–5 samples") + continue + for sample_index, sample in enumerate(samples): + if not _text(sample): + issue(f"{field}.sampleMessages[{sample_index}]", "must be a non-empty string") + elif len(sample) > 1024: + issue(f"{field}.sampleMessages[{sample_index}]", "must be at most 1,024 characters") + if selected in POLICY_TWO_SAMPLE_CASES and len(samples) < 2: + issue( + f"{field}.sampleMessages", + f"{selected} requires at least two samples under the compliance policy layer", + ) + return issues + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("payload", type=Path) + args = parser.parse_args() + try: + payload = json.loads(args.payload.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"{args.payload}: {exc}") + return 2 + issues = validate(payload, str(args.payload)) + if issues: + print("\n".join(issues)) + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/template-builder-ui/SKILL.md b/skills/template-builder-ui/SKILL.md index 327fb15..cfc2ccf 100644 --- a/skills/template-builder-ui/SKILL.md +++ b/skills/template-builder-ui/SKILL.md @@ -1,171 +1,109 @@ --- name: template-builder-ui -description: Designs and audits a Sent template builder UI for cross-channel SMS, WhatsApp, and RCS templates, including component editing, variable samples, status handling, channel-specific validation, and submission workflows. Use when a user says template builder, template editor, Sent templates, WhatsApp template UI, RCS template, SMS template, Meta import, JSON template, approval status, or wants a product spec for template creation. Do not use for generic templating engines (Jinja, Handlebars, Mustache, email/HTML templates) or non-Sent template systems. +description: Designs and audits tenant-facing Sent template builders, previews, validation, lifecycle UX, and API payload mapping. Use for template editor forms, variables, channel overrides, WhatsApp review, RCS suggestion chips, authentication templates, and safe submission flows. --- - +Design the interface around Sent's v3 `definition` contract. The UI may import Meta material, but its canonical saved and submitted model must never be Meta's `components[]` payload. -# Template builder UI +## Product model -## Overview +Use one draft object with: -Use this skill to design or improve a Sent template builder UI. Sent templates are reusable message blueprints across SMS, WhatsApp, and RCS. The UI must let users create valid templates, preview channel-specific rendering, supply variable samples, submit for review where required, and understand status without exposing irrelevant provider internals. +- optional `category` and `language`; +- required `definition.body.multiChannel`; +- optional complete body overrides for `sms`, `whatsapp`, and `rcs`; +- optional `definition.header`, `footer`, `buttons`, `definitionVersion`, and `authenticationConfig`; +- submission controls for `creation_source`, `submit_for_review`, and `sandbox`. -The Sent v3 template API supports create, list, retrieve, update, and delete operations. The first-message workflow sends templates through `POST /v3/messages` using a `template.id`. A good UI therefore optimizes both authoring and later sendability. +Do not expose top-level create fields named `name`, `channels`, `body`, `header`, or `buttons`. If the product needs an internal display label, keep it outside the Sent create payload. -## When to use +## Recommended editor sequence -Use this skill when the user asks for a template builder, template editor, template management UI, template validation, Meta import flow, JSON template builder, WhatsApp approval UI, RCS rich template editor, SMS template preview, template status page, or a product/engineering spec for Sent templates. +1. Capture intent and category. +2. Write the `multiChannel` body. +3. Insert variables as structured entities. +4. Add optional per-channel overrides. +5. Add header, footer, and buttons where supported. +6. Review live previews and accessibility. +7. Validate locally and with `sandbox: true`. +8. Save a draft, then explicitly submit for provider review. -Do not use this skill to write final WhatsApp template copy; use `waba-template-author`. Do not use it to decide Sender Profile boundaries; use `sender-profile-architect`. Do not use it to diagnose delivery failures after sends; use `messaging-performance-analyzer`. +Category should not block the first keystroke, but it must be visible before submission because it affects authentication rules and WhatsApp policy review. -## Product principles +## Variable UX -A Sent template UI should make the valid path obvious and the invalid path hard. Users should understand three things at all times: what channels the template targets, what variables need examples, and whether the template is editable, pending, approved, or rejected. +Inserting a variable creates both: -| Principle | UI behavior | Why it matters | -|---|---|---| -| Channel-first editing | User chooses SMS, WhatsApp, RCS, or combinations before components. | Component support differs by channel. | -| Variable-first validation | Every variable has a sample value before review/submission. | Reviewers and test sends need concrete rendered examples. | -| Status-aware actions | Drafts can be edited; pending/approved/rejected states guide next action. | Users should not unknowingly break reviewed content. | -| Provider-specific details are scoped | WhatsApp category and Meta import appear only where WhatsApp applies. | Keeps cross-channel UI from becoming WhatsApp-only. | -| JSON escape hatch | Advanced users can paste/edit JSON with schema validation. | Sent docs include JSON definition as a creation path. | +- a placeholder such as `{{0:variable}}`; and +- a matching entity with `id`, `name`, `type`, and `props.sample`. -## Process +Renumber atomically when variables move. Never let users edit placeholder syntax independently of the entity table. Show a clear error for naked `{{1}}` or IDs without definitions. -### 1. Start with the template intent and channels +## Validation matrix -Ask what the template is for before showing component controls. Intent drives category, variables, and review risk. Then ask which channels the user wants to support. +Apply the exact rules in [references/template-validation-matrix.md](references/template-validation-matrix.md), including: -**Example.** “Order shipped” targeting SMS, WhatsApp, and RCS should start from one intent but render differently: SMS may be plain text, WhatsApp may need a utility category and sample variables, and RCS may use richer actions if configured. +- a 1,024-character maximum for every body; +- 60 characters for header and footer; +- no footer variables; +- 10 buttons total; +- button types `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE` with their per-type limits; +- no invented quick-reply-versus-CTA exclusivity; +- `authenticationConfig` and authentication restrictions; +- complete, independently valid channel overrides. -### 2. Model the Sent template lifecycle +Run the bundled `waba-template-author` linter against serialized JSON. Server validation remains authoritative. -Use Sent’s documented statuses in the UI: Draft, Pending, Approved, and Rejected. Do not introduce provider-only states as global Sent states unless Sent event types or API responses expose them for the account. +## Channel previews -| Status | UI meaning | Allowed primary action | -|---|---|---| -| Draft | Saved but not submitted. | Edit, preview, validate, submit. | -| Pending | Submitted for review/approval where required. | View, cancel if supported, duplicate. | -| Approved | Available for production sends where channel setup allows. | Use in send flow, duplicate for revision. | -| Rejected | Review failed or validation blocked approval. | View reason, revise, resubmit or duplicate. | +### SMS -Although the OpenAPI says `PUT /v3/templates/{id}` can update name, category, language, definition, or submit for review, the UI should still protect approved templates with a “duplicate and revise” path when auditability matters. Present immutability as a product-safety choice, not a Sent API fact. +Preview plain text and estimated GSM/UCS-2 segments. Make clear that segment estimates affect billing and are not template body limits. -### 3. Back the UI with Sent template endpoints +### WhatsApp -Keep the UI contract aligned to the verified v3 template operations. +Preview header, body, footer, and buttons. Show sample values, category, language, and provider-review impact. -| UI action | Endpoint | Notes | -|---|---|---| -| Create template | `POST /v3/templates` | Create with header, body, footer, buttons, and review/draft intent. | -| List/search templates | `GET /v3/templates?page=&pageSize=&search=&status=&category=` | Support filtering by status, category, and search. | -| Open template detail | `GET /v3/templates/{id}` | Show name, category, language, status, and definition. | -| Save/update | `PUT /v3/templates/{id}` | Update editable fields or submit for review. | -| Delete | `DELETE /v3/templates/{id}` | Optionally delete from Meta where supported by the API request. | -| Send test after approval | `POST /v3/messages` | Use `template.id` and channel selection. | +### RCS -Use optional `Idempotency-Key` headers when create/update requests may be retried by the frontend or backend. +Current Sent RCS guidance supports text plus up to four suggestion chips. Rich cards, carousels, and media attachments are roadmap capabilities, not current Sent builder controls. Do not generate capability declarations for unavailable features. -### 4. Design the editor around components +Channel routing belongs to the send flow, not the template editor. If routing is shown in a simulator: -Represent the template as a structured definition rather than one text blob. Sent’s docs describe template components such as header, body, footer, and buttons, with practical support differences across SMS, RCS, and WhatsApp. +- omitted `channel` or `["sent"]` means automatic routing and fallback; +- `["rcs"]` pins RCS with no cross-channel fallback; +- multiple explicit values mean broadcast and separate billable messages. -| Component | UI guidance | Channel notes | -|---|---|---| -| Header | Optional title/media area with clear preview. | Most relevant to WhatsApp/RCS; validate per selected channel. | -| Body | Required main content with variable insertion. | Needed across channels; SMS preview should show plain-text length behavior. | -| Footer | Optional low-emphasis text. | Useful for compliance or context where supported. | -| Buttons/actions | Explicit button type and target. | Validate per channel; do not allow unsupported combinations. | -| Variables | Named or positional placeholders with sample values. | Samples are required for review and testing. | +Never describe an explicit RCS-plus-SMS array as ordered fallback. -### 5. Make validation staged and explainable +## Save and review behavior -Run validation in layers so users know whether a problem is a Sent schema issue, a channel support issue, or a policy/review issue. +Use `sandbox: true` for validation. Save with `submit_for_review: false`. Before switching it to `true`, show: -| Layer | Example error | Fix | -|---|---|---| -| Required fields | “Body is required.” | Add body content. | -| Variable samples | “`{{order_id}}` has no sample value.” | Add a realistic sample. | -| Channel support | “SMS cannot render this rich button.” | Remove button for SMS or split channel variants. | -| WhatsApp review risk | “Marketing language in a utility template may be rejected or reclassified.” | Change category or remove promotional content. | -| JSON schema | “Definition does not match Sent template shape.” | Correct JSON before save. | +- category and language; +- rendered previews with sample values; +- channel overrides; +- button actions; +- any warnings; +- the fact that provider review is an external state change. -**Example validation.** If a utility WhatsApp template says “Your order shipped. Add 20% off accessories today,” the UI should warn that promotional content conflicts with utility intent. For SMS, the same content may be syntactically valid but still must align with 10DLC use-case registration. +Do not autosubmit on save. -### 6. Support Sent’s creation paths +## Lifecycle UX -Sent's dashboard exposes four template creation flows at `app.sent.dm/dashboard/templates`. Mirror them by name and intent. +Resource status values currently include `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Keep an unknown-state renderer. -| Path | Best for | UI requirement | -|---|---|---| -| Create from Sample | New users and common templates | Curated examples with editable variables. | -| Create from Scratch | Product teams building custom flows | Guided component editor. | -| Import from Meta | Existing WhatsApp template libraries | Import review, mapping, and status reconciliation. | -| Create From Definition | Developers and migrations | Schema validation, diff view, and clear errors. | +WhatsApp template webhook events use `field: "templates"`, no `sub_type`, and no `event`; the status is `payload.status`. Provider values can include `CATEGORY_UPDATED`, `DISABLED`, and other future strings. See [references/template-status-handling.md](references/template-status-handling.md). -### 7. Preview the send path, not only the design +## Accessibility and failure recovery -An approved template is only useful if it can be sent. Add a test-send preview that asks for Sender Profile/channel context, recipient test number, variable values, and sandbox/production mode where applicable. Show that production sending uses `POST /v3/messages` with the selected `template.id`. +- Associate every error with a field and a summary. +- Do not rely on preview color alone. +- Preserve user edits after validation failures. +- Keep raw JSON inspection available for advanced users. +- Label imported Meta JSON as “Meta Cloud API source” until converted. +- Provide a diff for server normalization and provider-driven category/status changes. -## Common rationalizations to avoid - -Do not build a WhatsApp-only UI and call it a Sent template builder. Sent templates span SMS, WhatsApp, and RCS. - -Do not mark name/language/category immutable as a Sent API fact. The verified update endpoint can update those fields; immutability is a product governance decision. - -Do not show provider policy warnings globally. Only show WhatsApp-specific category/review warnings when WhatsApp is selected. - -Do not hide sample values in an advanced panel. Missing or unrealistic samples are a common review and testing failure. - -Do not rely on frontend validation alone. The backend should validate the Sent request shape and preserve API error messages for users. - -## Verification checklist - -- [ ] The UI starts with template intent and target channels. -- [ ] Statuses match Sent’s documented Draft, Pending, Approved, and Rejected states. -- [ ] Template CRUD maps to verified `/v3/templates` endpoints. -- [ ] Variables cannot be submitted without sample values. -- [ ] Component validation is channel-aware for SMS, WhatsApp, and RCS. -- [ ] WhatsApp-specific category/review warnings are scoped to WhatsApp templates. -- [ ] JSON definition mode validates schema before save. -- [ ] Test-send preview uses `POST /v3/messages` with `template.id` after approval/readiness checks. - -## Related skills - -Use `waba-template-author` when the task is to write or classify WhatsApp template content. - -Use `sms-10dlc-registration` when SMS template copy must match a US A2P campaign use case or opt-out evidence. - -Use `rcs-agent-onboarding` when RCS templates depend on agent approval, fallback behavior, or rich-rendering tests. - -Use `sender-profile-architect` when template ownership, profile scoping, or tenant boundaries are unclear. - -Use `messaging-performance-analyzer` when an approved template sends poorly or webhook evidence shows failures. - -Use the `sent` skill for shared Sent terminology and routing. - -## Suggested bundled references and scripts - -| File | Type | Purpose | -|---|---|---| -| `references/template-validation-matrix.md` | Lookup table | List component support, variable rules, and channel-specific restrictions without bloating the skill body. | -| `references/template-ui-wireflows.md` | Worked examples | Show sample, scratch, Meta import, and JSON creation flows. | -| `references/template-status-handling.md` | Decision matrix | Map Sent status and provider review outcomes to UI actions. | - -## Unverified claims to confirm or remove - -- Whether template name/language/category are immutable after first save is not documented in the snapshot; the `PUT /v3/templates/{id}` endpoint accepts these fields, so behavior should be verified against the live OpenAPI before assuming. Treat product-side locking as a governance choice, not an API fact. -- Mixed-button rules (quick-reply XOR CTA, ordering, per-category constraints) remain external Meta concerns — link to Meta's WhatsApp template docs, do not mirror. -- Template-status webhook event names follow the `.` pattern (the snapshot confirms the envelope) but the snapshot does not enumerate template-specific events. Discover the exact names via `GET /v3/webhooks/event-types` for the account. +Use [references/template-ui-wireflows.md](references/template-ui-wireflows.md) for state transitions. Use `waba-template-author` for copy and policy judgment, `sent-templates` for existing-resource operations, and `rcs-agent-onboarding` for RCS launch readiness. diff --git a/skills/template-builder-ui/references/template-status-handling.md b/skills/template-builder-ui/references/template-status-handling.md index 0f856c2..d9c1b87 100644 --- a/skills/template-builder-ui/references/template-status-handling.md +++ b/skills/template-builder-ui/references/template-status-handling.md @@ -1,75 +1,43 @@ -# Template Status Handling - -The lifecycle states a Sent template moves through, which fields are editable in each, how the UI gets notified of upstream changes, and how to handle resubmission. The builder UI tracks every template against the state machine below. - -## Status enum - -Sent's template status enum is exactly four states the UI renders, regardless of underlying channel: `Draft`, `Pending`, `Approved`, `Rejected`. There is **no `PAUSED` state in Sent.** Meta's upstream `PAUSED` flag exists Meta-side only and is not surfaced into the Sent template-status enum. - -| State | Meaning | Source | -|---|---|---| -| `Draft` | Authored locally, not yet submitted | Sent-internal | -| `Pending` | Submitted, awaiting review (WhatsApp = Meta review, typically 24–48 hours per Sent docs; RCS = Google review; SMS does not gate on review) | Set by Sent on submit, cleared by upstream callback | -| `Approved` | Live and sendable | Set by Sent on upstream approval | -| `Rejected` | Upstream rejected the submission | Set by Sent on upstream rejection, carries a `rejection_reason` payload | - -Other upstream states (Meta `PAUSED`, `DISABLED`, `IN_APPEAL`, etc.) do not appear as Sent statuses. If your UI needs to surface a Meta-only signal (e.g. a deliverability pause), treat it as a secondary annotation on an `Approved` row — never bucket those rows as `Rejected`. - -**During `Pending`, SMS sends still work.** Per Sent docs, a template can be sent over SMS while it is still awaiting WhatsApp approval — the `Pending` status gates only the channels that require upstream review. Reflect this in the UI: don't grey out the entire row, only the WhatsApp/RCS send actions. - -## Editable fields by state - -| Field | Draft | Pending | Approved | Rejected | -|---|:-:|:-:|:-:|:-:| -| Name | edit | locked | locked | locked | -| Language | edit | locked | locked | locked | -| Category | edit | locked | locked (Meta may silently change it — see below) | locked | -| Channel | edit | locked | locked | locked | -| Body | edit | locked | edit (creates v2) | edit | -| Header type | edit | locked | locked | edit | -| Header text/media | edit | locked | edit | edit | -| Footer | edit | locked | edit | edit | -| Button types (radio) | edit | locked | locked | edit | -| Button labels | edit | locked | edit | edit | -| Variable samples | edit | locked | edit | edit | - -Editing an **Approved** template's editable fields does not mutate the live template — it creates a new version on submit, while the prior version continues to send until the new one is approved. Make this obvious in the editor footer ("Submitting creates v2; v1 keeps sending until v2 is approved"). - -In **Pending**, surface a "Withdraw and edit" affordance — it calls the Sent withdraw endpoint and moves the template back to Draft. - -> The locks above are a **product-governance** choice. The Sent v3 `PUT /v3/templates/{id}` endpoint accepts name/language/category in its request body; whether those fields are truly immutable server-side after first save is not documented in the snapshot and should be verified against the live OpenAPI before relying on it. - -## Webhook vs polling - -Sent webhook events follow a top-level `field` + `sub_type` envelope, with `sub_type` formatted as `.` (e.g., `message.delivered`, `message.failed`). Template status changes are inferred to follow the same pattern (e.g., `template.approved`, `template.rejected`, or a single `template.status_changed`) — the snapshot confirms the envelope but does not enumerate template-specific events. **Discover the exact event names via `GET /v3/webhooks/event-types` for your account** and subscribe via `POST /v3/webhooks` with the relevant `event_types` / `event_filters` shape. - -Two reasonable UI approaches: - -- **Webhook + realtime fanout** (preferred). The backend receives the Sent webhook, fans out to the relevant tenant's realtime channel (Pusher / Ably / Supabase Realtime / WebSocket), and the list row updates in place. Lowest latency, no client polling load. Use when you already have a realtime layer for other reasons. -- **Short polling** (acceptable fallback). The list view polls `GET /v3/templates?status=pending` every 5–10s while any row is in Pending; stops polling when none remain. Simpler to ship; more network load. Use when you don't yet have realtime infrastructure. - -Do *not* poll per-row — always poll the list filter — and do not poll forever. Cap at e.g. 30 min after submit; beyond that, the tenant must refresh. - -## Rejection reason display: Meta vs Sent - -Two distinct rejection sources, and the UI should render them differently: - -- **Meta-surfaced rejection.** The `rejection_reason` Sent forwards from Meta's template-status update. Render this as a sticky banner with the human-readable reason on top, the raw Meta enum (e.g. `INVALID_FORMAT`, `ABUSIVE_CONTENT`, `INCORRECT_CATEGORY`) collapsed by default, and a Sent-maintained remediation hint mapped from the enum. -- **Sent-surfaced rejection.** When Sent's own pre-submission validation (the server-side mirror of the matrix) rejects the payload before forwarding to Meta. Render with a different icon and label ("Caught by Sent before submission") so tenants don't think Meta reviewed the template. - -Never show the raw Meta JSON — it's noisy and changes shape. Always go through the Sent-normalized rejection-reason API. - -## Resubmission flow - -After editing a Rejected template: - -1. The Submit button triggers a *new submission attempt* against the existing template record. The template ID stays stable; only the `attempt_id` increments. -2. The list row transitions Rejected → Pending in place; do not create a duplicate row. -3. The rejection-reason banner is dismissed automatically when the new attempt enters Pending. -4. On Approval, the row turns green and the banner stays gone. On a fresh Rejection, render the new reason — and add a "previous reasons" disclosure showing the prior failures so tenants can see they're not regressing. - -For Approved templates, "resubmit" is really "submit a new version" — covered above in Editable fields. - -## Silent re-categorization - -Meta may re-categorize an Approved template (most commonly `UTILITY` → `MARKETING`) without changing its status. If Sent surfaces this (event name not enumerated in the snapshot — verify against `GET /v3/webhooks/event-types`), the list-row UI should render a one-time dismissible banner ("Meta moved this template to Marketing — it will now bill at marketing rates"). Tenants who miss this end up surprised by billing. See the `template-builder-ui` SKILL.md for the editor-side treatment. +# Template status handling + +## Resource states + +Sent template resources currently surface `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Use an unknown state in the UI because contracts evolve. + +## Provider webhook states + +WhatsApp approval events use this envelope: + +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "CATEGORY_UPDATED", + "language": "en_US", + "category": "MARKETING", + "channel": "whatsapp", + "reason": "Provider category update" + } +} +``` + +Template events have `field: "templates"` and no `sub_type` or `event`. Known provider values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; values such as `PAUSED` and `DISABLED` may be forwarded verbatim. + +## UI behavior + +| Value | UI response | +| --- | --- | +| `DRAFT` | Editable; offer validate and submit actions. | +| `PENDING` | Lock provider-reviewed fields and show submission time. | +| `APPROVED` | Show usable status and immutable submitted content. | +| `REJECTED` | Show the reason and create a revision path. | +| `PAUSED` / `DISABLED` | Block new WhatsApp usage and surface remediation. | +| `CATEGORY_UPDATED` | Show old/new category when known and re-evaluate pricing/policy UX. | +| Unknown | Preserve raw value, use a neutral badge, and avoid destructive assumptions. | + +Verify webhook signatures, deduplicate transitions, retrieve the current resource before overwriting local state, and tolerate delayed or out-of-order deliveries. Polling may be used as recovery, not as evidence that invented webhook event names exist. diff --git a/skills/template-builder-ui/references/template-ui-wireflows.md b/skills/template-builder-ui/references/template-ui-wireflows.md index dcde0a4..62e3087 100644 --- a/skills/template-builder-ui/references/template-ui-wireflows.md +++ b/skills/template-builder-ui/references/template-ui-wireflows.md @@ -1,104 +1,49 @@ -# Template UI Wireflows +# Template builder wireflows -The user journeys a Sent template builder must support. Each flow is a UX spec — entry point, screen sequence, state at each step, validation gates, exit conditions — written so a frontend engineer can implement against it without re-deriving the requirements. +## New draft -The dashboard surface lives at `app.sent.dm/dashboard/templates`. The list view supports search/filter by **name**, **category** (Marketing / Utility), **description**, and **status** (Draft / Approved / Pending / Rejected). Each row exposes per-template actions: **edit**, **delete**, **view analytics**. +`intent → multiChannel body → variables → overrides → optional components → previews → validation → draft save` -There are four template creation flows, exposed in the dashboard exactly as: **Create from Scratch**, **Import from Meta**, **Create From Definition**, **Create from Sample**. +- The internal draft may have a product label, but serialization drops it because create does not accept top-level `name`. +- Variable insertion writes the placeholder and entity together. +- Switching a channel override off preserves it locally until the user confirms deletion. -## Flow 1: Create from Scratch +## Validate -**Entry:** Templates list (`app.sent.dm/dashboard/templates`) → "Create from Scratch" action. +`serialize → local linter → fix field errors → sandbox request → reconcile server response` -1. **Channel select** (if the tenant has more than one channel provisioned). One-step picker: SMS / WhatsApp / RCS. Locks the validator set (see `references/template-validation-matrix.md`). -2. **Category picker** (WhatsApp only). `UTILITY` / `MARKETING` / `AUTHENTICATION`, each with one-line definition + example. -3. **Name + language** form. Name validated against `^[a-z][a-z0-9_]{0,511}$`; show `_v1` suffix nudge. Language is a typed-search picker over BCP-47 codes. -4. **Editor + preview split**. Component editor left, live preview right, sticky sample editor bottom-right. -5. **Submit gate.** Submit button disabled until all blocking validations pass. On click: confirm modal that shows the final payload diff ("here's what we're sending to Meta"). -6. **Optimistic insert.** Template row appears in the list with `Pending` status before the network round-trips. -7. **Status polling / webhook subscription.** See `references/template-status-handling.md`. +- Mark Meta `components[]` imports as unconverted and block Sent submission. +- Preserve the last valid server response separately from the working draft. +- Server normalization should appear as a reviewable diff. -## Flow 2: Create from Sample (clone) +## Submit for review -**Entry:** Templates list → "Create from Sample" action, or existing template row → "Duplicate". +`draft → final preview → confirmation → submit_for_review=true → PENDING or returned state` -1. Open the editor pre-filled with the source template's full state (curated sample, or an existing template). -2. **Name field is empty and focused** — the user must pick a new name; the original `(name, language)` pair is treated as permanent by the builder. -3. The category and channel are pre-selected and locked to match the source (changing channel is "create new", not "duplicate"). -4. From here: identical to Flow 1 from step 4 onward. +Do not autosubmit. The confirmation displays category, language, samples, buttons, overrides, and warnings. -Clone is the most-used "create" path in practice — make it 1-click from any approved template, including across languages (duplicate + change language). +## Status changes -## Flow 3: Import from Meta +`DRAFT → PENDING → APPROVED | REJECTED | PAUSED | unknown` -**Entry:** Templates list → "Import from Meta" action (WhatsApp only). +This is a UI model, not a promise of an exhaustive provider state machine. Template webhooks can forward `CATEGORY_UPDATED`, `DISABLED`, or future values. Render unknown states and keep raw values. -1. Modal: paste the raw template JSON from the Meta dashboard's "View JSON" panel. -2. Parser maps Meta's shape into Sent's internal template model. -3. **Diff preview**: show fields that didn't round-trip cleanly (e.g. Meta has a property Sent doesn't model yet). Tenant can proceed or cancel. -4. Land in the editor (Flow 1 from step 4) with all parsed fields filled. Validation runs immediately so any Meta-side state that violates current rules surfaces as inline errors. -5. Sent treats the import as a *draft* — the imported template is not auto-submitted; the tenant must click Submit. +## Rejection recovery -## Flow 4: Create From Definition (advanced) +`webhook/poll → retrieve current resource → show reason → fork editable revision → lint → sandbox → confirm resubmission` -**Entry:** Templates list → "Create From Definition" action, or Editor → "Edit as JSON" toggle (available on Draft only). +Avoid destructive in-place edits when content is locked. Keep the submitted version and revision history visible. -1. Editor swaps to (or opens directly into) a monaco-style JSON pane showing Sent's internal template schema. -2. **Schema-guided autocomplete** (using the same JSON schema that drives the matrix). -3. Live validation + preview continue to run against the JSON. -4. "Back to form" toggle round-trips if the JSON is currently valid; greyed out if not (don't silently discard). -5. Submit gate is identical to Flow 1. +## RCS preview -This is the escape hatch for power users and for tenants whose IDE-driven workflows generate templates programmatically. +`text → zero-to-four suggestion chips → device preview` -## Flow 5: Edit +Do not add rich-card, carousel, or attachment branches to current Sent workflows. If a mockup illustrates future capability, label it roadmap-only and exclude it from serialized requests. -**Entry:** Existing template row → "edit" action. +## Routing simulator -State of the editor depends on the template's lifecycle state (see `references/template-status-handling.md`): +If the product includes a send simulator, keep routing semantics explicit: -- **Draft** → all fields editable. -- **Pending** → editor is read-only with a "Pending review — edit blocked" banner. Offer "Withdraw and edit" which moves the template back to Draft via the Sent API. -- **Approved** → name, language, and category are locked. Body, header (text only, not media swap), footer, button labels (not button types) are editable. Editing an Approved template creates a *new version* on submit — surface this prominently ("Submitting will create v2; v1 keeps sending until v2 is approved"). -- **Rejected** → all fields editable; the rejection-reason banner is sticky at the top of the editor (see Flow 8). - -## Flow 6: Status transitions visible in UI - -Every list row shows a status pill. Sent's status enum is exactly `Draft` / `Pending` / `Approved` / `Rejected` — there is no `PAUSED` state surfaced in Sent. Allowed transitions: - -``` -Draft ─submit→ Pending ─approved upstream→ Approved - └─rejected upstream→ Rejected ─edit→ Draft -``` - -Pills use Sent's design-system tokens — not raw Meta colors — so the same component renders for SMS templates and RCS templates. - -Transitions animate (fade pill color), and the row's last-updated timestamp updates on each transition so tenants can correlate with WhatsApp review SLA (per Sent docs, typically 24–48 hours). - -## Flow 7: Submit - -The submit click is the *only* destructive step in the builder. Treat it carefully: - -1. **Pre-submit checks** (client-side): - - Run the full validation matrix; any blocking failure aborts. - - Open a confirmation modal showing: final preview render, the JSON that will hit Meta, and a "What happens next?" explainer (review SLA, billing implication for marketing, etc.). -2. **POST to Sent's template-submit endpoint** (`POST /v3/templates`, optionally with `Idempotency-Key`). Show a button spinner. -3. **On 2xx**: dismiss the modal; insert an optimistic `Pending` row into the list; clear the draft state. -4. **On 4xx**: surface the Sent error message inline in the modal — do not dismiss; do not lose the user's work. -5. **Subscribe to status** — see `references/template-status-handling.md` for the webhook-vs-polling tradeoff. - -## Flow 8: Reject-recover - -When a template lands in `Rejected`: - -1. The list row's status pill is red and clickable. -2. Clicking opens the editor with a sticky banner at the top: human-readable rejection reason, the raw Meta `reason` collapsed by default, and a "Remediation" callout that maps the rejection code to a concrete fix ("Variable samples contained promotional language — rewrite samples as neutral data and resubmit"). -3. The editor pre-focuses the field most likely to be wrong (e.g. the offending sample input). -4. The tenant edits and clicks Submit — the resubmission creates a new attempt with a new `attempt_id` server-side; the list row updates in place rather than duplicating. - -## Cross-cutting notes - -- Every flow that lands in the editor reuses the same editor component — channel + category state determine which sub-forms render. There is *not* a separate editor per channel. -- Every flow respects the validation matrix at every keystroke; submit is the only network call. -- Optimistic UI is acceptable for insertions (Draft creation, submit) but not for status transitions — those must come from the server. -- All confirmation modals are dismissable with Escape; the Submit modal additionally requires explicit confirm (no Enter-key auto-submit). +- no `channel` / `["sent"]`: automatic routing and fallback; +- one explicit channel: pinned; +- two or more explicit channels: broadcast with one message per recipient/channel pair. diff --git a/skills/template-builder-ui/references/template-validation-matrix.md b/skills/template-builder-ui/references/template-validation-matrix.md index f25a368..41964f5 100644 --- a/skills/template-builder-ui/references/template-validation-matrix.md +++ b/skills/template-builder-ui/references/template-validation-matrix.md @@ -1,106 +1,60 @@ -# Template Validation Matrix - -Per-channel rules a Sent template builder UI must enforce client-side, with the surface treatment for each failure mode. Use this as the single source of truth when wiring validators into the editor — the same matrix should drive the submission handler, so a "soft warning" never silently becomes a hard reject downstream. - -**Surface treatments** referenced below: - -- **Inline error** — red text under the field, focused on save attempt, does not block typing. -- **Blocked save** — the Submit button is disabled until resolved; tooltip explains why. -- **Soft warning** — amber banner or icon, save still allowed, but the tenant must acknowledge. - -## Component support matrix (Sent-confirmed) - -| | SMS | RCS | WhatsApp | -|---|:-:|:-:|:-:| -| Header | ❌ | ✅ | ✅ | -| Body | ✅ | ✅ | ✅ | -| Footer | ❌ | ✅ | ✅ | -| Buttons | ❌ | ✅ | ✅ | - -Header types (where supported): `TEXT`, `IMAGE`, `VIDEO`, `DOCUMENT`. - -Button types (where supported): `QUICK_REPLY`, `URL`, `PHONE_NUMBER`. - -Template categories (apply to WhatsApp review; not a Sent enum extension): `UTILITY`, `MARKETING`, `AUTHENTICATION`. No others. - -## Character limits (Sent-confirmed) - -| Channel | Component | Limit | -|---|---|---| -| SMS | Body | 160 chars per segment (GSM-7); longer messages split | -| WhatsApp | Body | 1028 chars | -| WhatsApp | Header (text) | 60 chars (optional component) | -| WhatsApp | Footer | 60 chars (optional component) | -| RCS | Body | 1028 chars | -| RCS | Header | 60 chars (optional component) | - -## SMS - -SMS templates support **Body only** — no Header, Footer, or Buttons. All rules below apply to body content. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Body length (GSM-7) | 160 chars per segment | Inline char + segment counter; soft warning at 4+ segments | -| Body length (UCS-2) | 70 chars per segment (triggered by any non-GSM-7 char, e.g. emoji, curly quotes) | Detect encoding on every keystroke; soft warning at first UCS-2 char ("This template now bills as Unicode — N segments") | -| Total body length | Hard cap at 1600 chars (10 UCS-2 segments) | Blocked save above cap | -| Opt-out language | A2P templates must include `Reply STOP to opt out` (or equivalent) at least once across the campaign's template set | Soft warning per template; campaign-level check elsewhere | -| Link shortening | Public domain shorteners (bit.ly, tinyurl) are heavily filtered by carriers | Soft warning when a known shortener domain appears in the body; suggest the tenant's branded short domain | -| Variable placeholders | Use the placeholder format the SMS template model expects (named or ordinal — see https://docs.sent.dm). Stay consistent across all SMS templates in a project. | Inline error on mismatched placeholder style | -| Sender ID injection | Alphanumeric sender IDs not allowed in US 10DLC | Blocked save if the tenant tries to set a non-numeric sender on a US campaign | -| Public URL preview | Long URLs eat segments fast | Show effective char-with-URL count; soft warning if URL is >40 chars | - -For campaign-level filtering rules that gate which SMS templates are sendable, use `sms-10dlc-registration`. - -## WhatsApp - -Rules are *category-aware* — `UTILITY`, `MARKETING`, and `AUTHENTICATION` each have a different shape. The builder's category picker (per `SKILL.md`) reshapes which validators apply. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| Name format | `^[a-z][a-z0-9_]{0,511}$` | Inline error; auto-snake_case the input | -| Name+language permanence | Treat as immutable as a product-governance choice (the Sent v3 `PUT /v3/templates/{id}` accepts these fields, but the UI should still steer tenants to a versioned `_v2` flow for auditability) | Blocked save when editing; show `_v1` suffix nudge for new versions | -| Body length | 1028 chars across all categories | Inline char counter; blocked save above cap | -| Body required | Required for utility/marketing; auth body is fixed by Meta | Blocked save if empty | -| Header type | One of: none / `TEXT` / `IMAGE` / `VIDEO` / `DOCUMENT` | Radio control — invalid combos unreachable | -| Header (text) length | 60 chars, max 1 variable | Inline error | -| Header (media) sample | Sample upload required at submit | Blocked save without a sample asset | -| Footer length | 60 chars, no variables | Inline error; strip `{{` on paste | -| Buttons — mutually exclusive | `QUICK_REPLY` XOR CTAs (`URL` + `PHONE_NUMBER`). Mixing is a Meta reject. | Top-level button-type radio prevents construction; never allow per-button type picks | -| Quick replies | Max 3, 25 char labels | Add button hidden at 3; inline error on label length | -| CTA buttons | Max 2 total, mix of `URL` + `PHONE_NUMBER` allowed | Add button hidden at 2 | -| URL CTA variables | Max 1 trailing variable, must be the URL suffix (`https://example.com/orders/{{1}}`) | Inline error on inline variables | -| Variable placeholders | `{{n}}` ordinal, monotonically increasing from `{{1}}` | Autocomplete next index; inline error on gaps (`{{1}}` then `{{3}}`) | -| Variable samples | Required at submit, non-empty | Sticky sample editor + blocked save until all filled | -| Sample neutrality | Promotional words in samples (off / sale / free / now / discount / deal …) trigger Meta re-categorization | Soft warning on the offending sample field | -| Authentication body | Fixed copy with `{{1}}` for OTP | Body field becomes read-only when category = `AUTHENTICATION` | -| Authentication buttons | Single Copy code / One-tap button + `code_expiration_minutes` | Component editor swaps shape | - -For the category decision tree the picker presents, use `waba-template-author`. Mixed-button policy details remain an external Meta concern — link, do not mirror. - -## RCS - -RCS templates are richer (rich cards, suggested replies, suggested actions) but the channel mandates an SMS-text fallback for any device that can't render RCS. - -| Rule | Limit / Behavior | Surface | -|---|---|---| -| SMS fallback body | Required, follows the SMS rules above | Blocked save without fallback; auto-derive from card title+description as a starting point | -| Body length | 1028 chars | Inline counter | -| Header (text) length | 60 chars | Inline counter | -| Rich card title | 200 chars | Inline counter | -| Rich card description | 2000 chars | Inline counter | -| Card media | Image or video; image ≤ 100 KB for "short height", ≤ 2 MB max; video ≤ 100 MB | Blocked save on oversize; soft warning at >50 KB image for short-height layout | -| Card orientation | Vertical or horizontal | Radio; preview swaps layout | -| Suggested replies | Max 11 across the message; 25 chars each | Add button hidden at 11; inline error on label length | -| Suggested actions | Max 11 across the message (shared cap with replies); types include dial, openUrl, viewLocation, shareLocation, createCalendarEvent | Add button hidden when total = 11 | -| Action URL | Must be HTTPS, valid URL | Inline error | -| Carousel cards | 2-10 cards, all same orientation | Blocked save outside range; orientation locked after first card | -| Variable placeholders | Match Sent's RCS template placeholder format — see https://docs.sent.dm | Inline error on mismatched style | -| Fallback parity | Variables in the rich content must also resolve in the SMS fallback | Soft warning if a variable appears only in one | - -For agent-level capability gating that determines whether RCS is an option for a recipient, use `rcs-agent-onboarding`. - -## Cross-channel notes - -- The builder may target *one channel at a time* — do not let tenants compose a "WhatsApp + RCS combined template" in the same form. Channel selection is the first decision after category (where applicable) and locks the validator set. -- All three channels run validation on every keystroke (debounced) so the submit button reflects current state. -- The matrix is the contract between client validation and server validation — keep it codified (JSON schema or equivalent) and re-export to both sides. +# Template validation matrix + +This matrix maps UI controls to the body of `POST /v3/templates`. + +## Request root + +| Field | Client rule | +| --- | --- | +| `definition` | Required object. | +| `category` | Optional `UTILITY`, `MARKETING`, or `AUTHENTICATION`. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional string. | +| `submit_for_review` | Boolean; default to false in the editor. | +| `sandbox` | Boolean; use true for validation. | + +Reject top-level `name`, `channels`, `body`, `header`, `buttons`, and `components`. A `components[]` import is Meta Cloud API source and requires conversion. + +## Definition + +| Field | Rule | +| --- | --- | +| `body.multiChannel` | Required body content. | +| `body.sms`, `body.whatsapp`, `body.rcs` | Optional complete overrides using the same content schema. | +| `header.template` | Optional, maximum 60 characters. | +| `footer.template` | Optional, maximum 60 characters, no variables. | +| Body `template` | Required non-empty text, maximum 1,024 characters. | +| `definitionVersion` | Optional string; preserve when editing. | +| `authenticationConfig` | Only for `AUTHENTICATION`. | + +Every placeholder uses `{{id:type}}`, for example `{{0:variable}}`. Each must map one-to-one to a variable with a non-negative integer `id`, readable `name`, matching `type`, and non-empty `props.sample`. IDs are unique within a body. + +## Buttons + +| Type | Total allowed | Required properties | +| --- | ---: | --- | +| `QUICK_REPLY` | Up to the overall total of 10 | `text`, `quickReplyType` | +| `URL` | 2 | `text`, `urlType`, `url` | +| `VOICE_CALL` | 1 | `text`, `countryCode`, `phoneNumber` | +| `PHONE_NUMBER` | 1 | `text`, `countryCode`, `phoneNumber` | +| `COPY_CODE` | 1 | `text`, `offerCode` | + +There are at most 10 buttons in total, and button text is at most 25 characters. Quick replies and CTA buttons may coexist. + +## Authentication + +`authenticationConfig` accepts `addSecurityRecommendation` and optional `codeExpirationMinutes` from 1 through 90. Authentication templates should contain one code variable and one copy-code action, with no promotion, unrelated media, URL, or call action. + +## Channel-specific product rules + +| Channel | Current UI capability | +| --- | --- | +| SMS | Plain text preview and segment estimate. | +| WhatsApp | Header, body, footer, variables, and supported buttons. | +| RCS | Text and up to four suggestion chips. | + +Do not expose current Sent controls for RCS rich cards, carousels, or media attachments. They are roadmap features. Do not require an SMS fallback body; automatic routing is a send-time choice made by omitting `channel` or using `["sent"]`. + +## Server round trip + +Client validation is advisory. Serialize the exact Sent request, run the bundled linter, validate with `sandbox: true`, and reconcile server errors by field. Never silently rewrite submitted copy. diff --git a/skills/waba-embedded-signup/SKILL.md b/skills/waba-embedded-signup/SKILL.md index 3211f12..f1fa1c6 100644 --- a/skills/waba-embedded-signup/SKILL.md +++ b/skills/waba-embedded-signup/SKILL.md @@ -1,168 +1,124 @@ --- name: waba-embedded-signup -description: Guides WhatsApp Business Account connection for Sent Sender Profiles, including Embedded Signup planning, WABA and phone-number mapping, token/security handling, webhook readiness, and profile completion. Use when a user says Embedded Signup, WABA, connect WhatsApp, WhatsApp sender, phone number ID, Facebook Login for Business, Meta Business, sender profile WhatsApp setup, or webhooks not firing after WhatsApp signup. +description: Guides WhatsApp Business Account onboarding through Sent, separating dashboard Embedded Signup, organization WABA inheritance, and direct child-profile credentials. Use for WABA connection, Meta signup, profile creation, access-token handling, phone number mapping, completion callbacks, or WhatsApp onboarding failures. --- - +Keep three integration paths distinct. Calling all of them “Embedded Signup” creates wrong API designs and unsafe credential handling. -# WABA Embedded Signup +## The three paths -## Overview +| Path | Where it starts | Profile behavior | +| --- | --- | --- | +| Organization Embedded Signup | Sent dashboard | Connects the organization's WABA through the hosted Meta flow. There is no public Sent endpoint that starts this flow. | +| Organization WABA inheritance | `POST /v3/profiles` | Omit `whatsapp_business_account`; the child inherits the organization's connected WABA. | +| Dedicated child-profile WABA | `POST /v3/profiles` | Supply `whatsapp_business_account.waba_id` and `.access_token`; `phone_number_id` is optional. | -Use this skill to connect a WhatsApp Business Account (WABA) and phone number to a Sent Sender Profile without confusing Sent-side setup with Meta-side implementation details. Sent’s dashboard exposes Sender Profiles and WhatsApp configuration status. Sent’s profile API exposes profile CRUD and a profile-completion workflow. The public Sent sources reviewed for this rewrite did not expose a dedicated Embedded Signup endpoint, so direct Meta Graph flows should be treated as external integration context unless the user confirms that their application owns that flow. +If credentials are omitted and the organization has no connected WABA, profile creation returns `422`. Direct WABA credentials are a profile-creation feature, not a public “Embedded Signup endpoint.” -The safest workflow is to decide the integration path first: Sent-managed WhatsApp setup, customer-managed Meta Embedded Signup connected back to Sent, or a hybrid implementation coordinated with Sent. +## Authentication -## When to use +Use either: -Use this skill when the user mentions Embedded Signup, WABA, WhatsApp Business Account, WhatsApp phone number, phone number ID, Facebook Login for Business, Meta Business Manager, connecting WhatsApp to Sent, WhatsApp sender setup, Sender Profile WhatsApp status, or webhook delivery after WhatsApp onboarding. +- a profile-specific key in `x-api-key`; or +- an organization key in `x-api-key` plus `x-profile-id` when operating for an existing child profile. -Do not use this skill to author WhatsApp templates; use `waba-template-author`. Do not use it to build a generic Meta app unless the user explicitly asks for a Meta-side implementation. Do not claim Sent exposes Embedded Signup endpoints unless the account/docs confirm them. +Only organization keys may use `x-profile-id`; profile keys receive `403`. `x-sender-id` is legacy v1/v2 terminology. -## Process +## Path A: organization Embedded Signup -### 1. Decide the integration path first +1. An authorized organization administrator opens the Sent dashboard WhatsApp connection flow. +2. The hosted Meta Embedded Signup UI collects the Meta authorization and WABA/number choices. +3. Confirm the organization shows a connected WABA before creating inheriting children. +4. Record non-secret identifiers and audit who completed the action. -Start every session by asking which path applies. The answer changes what the agent should do next. +Do not invent a `POST /embedded-signup` or token-exchange endpoint in Sent's public API. If building your own Meta Tech Provider integration outside the Sent dashboard, follow Meta's current documentation and keep that system separate from the Sent API contract. -| Path | Use when | Agent role | -|---|---|---| -| Sent-managed setup | The customer wants Sent to guide or operate WhatsApp connection. | Prepare Sender Profile, business evidence, phone-number details, and handoff notes. | -| Customer-managed Embedded Signup | The customer’s app launches Meta Embedded Signup and passes results to Sent. | Review Meta-side security and mapping, then align results to Sent profile completion. | -| Migration/import | The customer already has a WABA/phone number and needs it represented in Sent. | Collect WABA/phone identifiers, ownership evidence, and profile mapping. | +Meta's browser `postMessage` events use an `event` field and nested data/session information. Do not rewrite them as Sent webhook `sub_type` envelopes. -If the user cannot answer, default to Sent-managed setup and avoid prescribing Graph API calls. +## Path B: inherit the organization WABA -### 2. Identify the Sender Profile +Omit `whatsapp_business_account`: -Locate or create the Sender Profile that will own the WhatsApp sender identity. Use the Sent dashboard or `/v3/profiles`. Record the Sent profile ID, `x-sender-id` if visible, display name, brand description, and intended WhatsApp phone number. - -A WhatsApp number should map to the same recipient-visible brand represented by the profile. If the number belongs to a different brand, department, or tenant, use `sender-profile-architect` before proceeding. - -### 3. Collect WhatsApp onboarding evidence - -Collect the minimum evidence needed for Sent or Meta review. - -| Evidence | Why it matters | -|---|---| -| Business legal name and Meta Business identity | Confirms the WABA belongs to the intended sender. | -| Public website and privacy policy | Supports business verification and template review. | -| Phone number and ownership/control evidence | Prevents connecting the wrong sender. | -| Display name | Must match the business identity recipients expect. | -| Use cases and example messages | Drives template authoring and policy review. | -| Webhook endpoint and owner | Needed to verify event delivery after connection. | - -### 4. Map external identifiers without making them the Sent contract - -If the user provides WABA ID, phone-number ID, Meta Business ID, or System User details, store them as external provider identifiers mapped to the Sent profile. Do not make those IDs the primary application sender key. Use Sent profile ID and Sent message IDs for Sent operations. - -**Example mapping.** - -```text -sent_profile_id: 2b1b... -x_sender_id: support_us -channel: whatsapp -provider: meta -provider_business_id: external value, if available -provider_waba_id: external value, if available -provider_phone_number_id: external value, if available -status_source: Sent dashboard/API or Meta integration logs +```json +{ + "name": "Tenant Support", + "description": "Synthetic child profile", + "short_name": "SUPPORT", + "inherit_templates": true, + "billing_model": "organization", + "sandbox": true +} ``` -### 5. Complete or re-check the Sent profile - -Use `/v3/profiles/{profileId}/complete` when prerequisites are ready and API completion is in scope. The OpenAPI describes profile completion as a background process that validates prerequisites and connects profile configuration. If completion returns missing prerequisites, fix those inputs rather than creating duplicate profiles. - -### 6. Verify webhook readiness - -Use Sent webhook endpoints to confirm event delivery. Verify the webhook exists, the relevant event types are available, and a test event reaches the customer endpoint via `POST /v3/webhooks/{id}/test`. +Use this only after the organization WABA is connected. Inheritance means the tenant shares that WABA boundary; confirm this matches the tenant/brand architecture. -Rotate webhook secrets only when needed and coordinate deployment, because secret rotation invalidates the old secret immediately. +## Path C: dedicated WABA credentials -## Meta-side implementation review - -Use this section only when the user confirms that their application owns Embedded Signup. Label the work as Meta-side. Validate security, mapping, and callback handling before connecting results to Sent. - -| Area | Check | -|---|---| -| Launch context | Embedded Signup is launched from the right app, business, and allowed origin. | -| Callback handling | The app captures the signup result, not just a UI success state. | -| Token exchange | Authorization codes/tokens are exchanged server-side, never in public frontend storage. | -| Scope verification | Returned permissions/granular scopes match the required WABA and phone-number access. | -| Identifier lookup | WABA ID and phone-number ID are read back and mapped to the Sent profile. | -| Phone registration | Registration is completed only if the integration path requires the customer app to do it. | -| App subscription | Webhook subscription is completed only if the integration path requires direct Meta callbacks. | -| Secret handling | Tokens and webhook secrets are encrypted, rotated, and not logged. | - -Do not assume the customer app must call every Meta endpoint. Sent may abstract parts of onboarding depending on the customer’s setup. - -## Troubleshooting patterns - -| Symptom | First check | Likely next step | -|---|---|---| -| Sender Profile still shows WhatsApp not configured | Sent profile prerequisites and completion status | Confirm whether Sent-managed setup or external Embedded Signup results were expected. | -| User completed Meta flow but Sent cannot send | Mapping between external phone/WABA and Sent profile | Provide identifiers/evidence to Sent or update integration mapping. | -| Templates remain unavailable | Template status and WhatsApp business review | Use `waba-template-author` and Sent template status. | -| Webhooks not firing | Sent webhook test and event history | Fix endpoint/subscription before blaming WhatsApp delivery. | -| Wrong tenant receives events | Profile/message ID mapping | Use `sender-profile-architect` to redesign routing. | - -## Common rationalizations to avoid - -Do not treat a Meta UI success screen as proof that Sent can send WhatsApp messages. Verify Sent profile/channel status and test sends. - -Do not store access tokens in browser storage or logs. Treat Meta tokens and Sent webhook secrets as production credentials. +```json +{ + "name": "Dedicated Tenant", + "whatsapp_business_account": { + "waba_id": "123456789012345", + "phone_number_id": "987654321098765", + "access_token": "" + }, + "sandbox": true +} +``` -Do not assume a WABA can be reused across unrelated brands or tenants. Recipient-visible identity and operational ownership matter. +`waba_id` and `access_token` are required. `phone_number_id` is optional: when omitted, the current contract describes provisioning and registration during onboarding. -Do not hardcode Graph API version, scope names, or endpoint sequences in this skill body. Keep those in a reference file and re-check Meta docs or Sent implementation guidance before use. +The token needs the applicable WhatsApp Business messaging and management permissions. Inject it from a secret manager. Never log it, echo it, write it to fixtures, return it to the browser, include it in support output, or retain it in general profile storage. Sent does not return it in API responses. -Do not rotate Sent webhook secrets without coordinating the receiving endpoint. +## Complete the profile -## Verification checklist +Call `POST /v3/profiles/{profileId}/complete` with the required `webHookUrl`: -- [ ] The integration path is identified as Sent-managed, customer-managed Embedded Signup, or migration/import. -- [ ] The correct Sent Sender Profile is identified before external identifiers are mapped. -- [ ] WhatsApp business identity, phone-number evidence, display name, and use cases are collected. -- [ ] WABA/phone-number IDs are stored as external identifiers, not as the primary Sent sender key. -- [ ] Profile completion is run or checked after prerequisites are ready. -- [ ] Sent webhook existence, event types, event history, and test delivery are verified. -- [ ] Meta Graph steps are only prescribed when the user confirms direct ownership of Embedded Signup. -- [ ] Tokens, webhook secrets, and callback data are handled server-side and securely. +```json +{ + "webHookUrl": "https://example.com/webhooks/profile-complete", + "sandbox": true +} +``` -## Related skills +- `202` means background processing started; there is no final status in that response. +- `200` can mean the profile was already complete and currently demonstrates lowercase `completed`. +- The completion callback can report `COMPLETED`, `SUBMITTED`, or `failed`. -Use `sender-profile-architect` when deciding whether a WABA or phone number belongs in a separate Sender Profile. +Treat the completion callback as its own integration surface. Its envelope uses `event`, not `sub_type`: -Use `waba-template-author` when the WhatsApp sender needs templates written, categorized, submitted, or revised. +```json +{ + "event": "COMPLETED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" +} +``` -Use `template-builder-ui` when building the UI that imports or manages WhatsApp templates inside Sent. +Preserve unknown event strings. Verify authenticity using the mechanism Sent documents for the callback endpoint and make processing idempotent. -Use `messaging-performance-analyzer` when WhatsApp sends are connected but delivery/read/webhook outcomes are poor. +## Verify operational readiness -Use the `sent` skill for shared Sent terminology and routing. +- Profile WABA ID matches the intended business. +- Selected number is mapped to the intended profile. +- Template sharing/inheritance is intentional. +- A test template can be created with `sandbox: true`. +- The completion callback is reachable and idempotent. +- Returned message IDs are stored against the tenant/profile before webhook processing. +- Tokens and payment values are absent from logs. -## Suggested bundled references and scripts +For ordinary message and template webhooks, follow Sent's current events reference; those are separate from Meta browser events and profile-completion callbacks. -| File | Type | Purpose | -|---|---|---| -| `references/waba-embedded-signup-spec.md` | External platform reference | Keep Meta launch, token exchange, WABA lookup, phone registration, and subscription details out of the skill body. | -| `references/whatsapp-sender-profile-mapping.md` | Schema reference | Define how Sent profile IDs map to WABA IDs, phone-number IDs, display names, and status evidence. | -| `references/waba-onboarding-runbook.md` | Worked example | Show Sent-managed and customer-managed onboarding examples end-to-end. | +## Failure routing -## Unverified claims to confirm or remove +| Failure | Next action | +| --- | --- | +| `422` when credentials are omitted | Connect the organization WABA or provide dedicated credentials. | +| `403` with profile key and `x-profile-id` | Remove `x-profile-id` or use an authorized organization key. | +| Wrong WABA/number | Stop before completion and correct the profile mapping. | +| Expired/under-scoped token | Replace it securely; never print it while diagnosing. | +| Completion remains submitted | Inspect prerequisite and callback evidence; do not assume final failure from the `202`. | -- Sent does not expose a public Embedded Signup endpoint; WhatsApp connection is dashboard-only via Channels → WhatsApp (confirmed against Sent v3 docs snapshot, 2026-05-19; the Channels page is explicitly listed as "dashboard config; not directly in v3 API"). -- Required Meta app type, Tech Provider/Solution Partner status, granular scopes, and Graph endpoint sequence are external Meta claims, not Sent API facts. -- Mandatory direct phone-number registration or WABA subscription by the customer app depends on integration path and was not verified as a universal Sent requirement. -- Sent's webhook envelope is confirmed as `{field, sub_type, timestamp, payload}` with sub-types of the form `.` (e.g., `message.delivered`). WhatsApp-specific sub-types are not enumerated in the snapshot — discover them empirically against your account. +Use [references/waba-embedded-signup-spec.md](references/waba-embedded-signup-spec.md), [references/waba-onboarding-runbook.md](references/waba-onboarding-runbook.md), and [references/whatsapp-sender-profile-mapping.md](references/whatsapp-sender-profile-mapping.md). Use `sender-profile-architect` for tenant boundaries and `waba-template-author` for the first template. diff --git a/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md b/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md index 9d6f675..f032b9b 100644 --- a/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md +++ b/skills/waba-embedded-signup/references/waba-embedded-signup-spec.md @@ -1,89 +1,48 @@ -# WABA Embedded Signup — Implementation Reference +# WABA integration specification -Supporting reference for `waba-embedded-signup`. The Sent v3 docs snapshot (2026-05-19) confirms that **Sent does not expose a public Embedded Signup API endpoint**. The customer-facing surface for connecting WhatsApp to Sent is the **dashboard's Channels → WhatsApp tab**, which is explicitly listed in Sent's "Dashboard pages → API endpoints map" as `(dashboard config; not directly in v3 API)`. The dashboard internally initiates Meta's Facebook Login for Business / Embedded Signup flow on the tenant's behalf. +## Capability boundaries -What this means for an integrator: +- Organization Embedded Signup is launched in the Sent dashboard. +- No public Sent endpoint starts that hosted signup. +- `POST /v3/profiles` can inherit the organization's WABA or accept a dedicated WABA. +- Direct profile credentials are not an Embedded Signup endpoint. -- **You do not call a Sent endpoint to start Embedded Signup.** You direct the tenant to their Sent dashboard. -- The Meta-side authentication, token exchange, WABA discovery, phone-number registration, app subscription, and app review state are owned by **Meta** and abstracted by the Sent dashboard. They are not surfaced as Sent API operations. -- After dashboard completion, the WhatsApp wiring is bound to the tenant's Sender Profile and routable via Sent's normal v3 API (`POST /v3/messages`, etc.). +## Dedicated credentials -Anything below this line is **external Meta documentation context** — included only so an operator debugging a stuck dashboard flow knows what is happening behind the scenes. Authoritative source: Meta — [Embedded Signup](https://developers.facebook.com/docs/whatsapp/embedded-signup), [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). Meta bumps the Graph API version regularly — always check the live Meta docs for the current version, scope names, and field names. +```text +whatsapp_business_account + waba_id required string + access_token required secret string + phone_number_id optional string +``` -## Sent-side surface (what the API does and doesn't expose) +Omitting `phone_number_id` invokes the current provisioning behavior documented by the profile contract. The token is write-only operational input and must never appear in API output, logs, fixtures, traces, analytics, or support text. -| Concern | Where it lives | -|---|---| -| Start Embedded Signup | Dashboard → Channels → WhatsApp → "Connect" (no public Sent API) | -| WABA / phone-number binding | Dashboard (not in v3 API) | -| Mark profile setup complete | `POST /v3/profiles/{id}/complete` (idempotent, sensitive endpoint — 10/min, burst 5) | -| Profile status after binding | `GET /v3/profiles/{id}` → `status` ∈ `incomplete` \| `pending_review` \| `approved` \| `rejected` | -| Webhook config | `POST /v3/webhooks`, `PUT /v3/webhooks/{id}`, `POST /v3/webhooks/{id}/test`, `POST /v3/webhooks/{id}/rotate-secret` (sensitive — 10/min, burst 5) | -| Auth header | `x-api-key: ` — single header, account-scoped. No `x-sender-id` in v3. | +## Inheritance -## Customer-facing dashboard flow (what the tenant sees) +Omit the entire `whatsapp_business_account` object to inherit. This succeeds only if the organization has completed Embedded Signup; otherwise expect `422`. -This mirrors the live flow on the dashboard's Channels page; it is what a tenant should be guided through, not an API sequence: +## Auth matrix -1. Dashboard → **Channels** → **WhatsApp** tab → click **Connect**. -2. Meta consent popup opens (Facebook Login for Business surface, initiated by Sent). -3. Tenant selects (or creates) a **WABA** under their Meta Business Portfolio. -4. Tenant grants Sent permission to **manage WhatsApp messages and templates** on that WABA. -5. Tenant adds a **Meta payment method** (separate from Sent billing — Meta charges per-conversation independently). -6. Dashboard reflects channel setup completion; the WhatsApp wiring is bound to the tenant's Sender Profile. -7. API credentials (the `x-api-key`) can be copied from the post-setup screen or retrieved later from the dashboard's API Keys page. +| Key | `x-profile-id` | Result | +| --- | --- | --- | +| Profile key | Omitted | Profile-scoped operation. | +| Profile key | Present | `403`. | +| Organization key | Valid child UUID | Child-scoped operation; organization rate-limit pool. | +| Organization key | Unowned UUID | `404`. | -The runbook (`references/waba-onboarding-runbook.md`) walks through this end-to-end with failure modes and recovery steps. +`x-sender-id` belongs to legacy v1/v2 guidance. -## Webhook envelope (Sent-confirmed) +## Profile completion -After WhatsApp is connected, Sent emits webhooks for that profile's messages using the universal envelope: +The request requires `webHookUrl`. A `202` only confirms processing began. A `200` can report an already-complete profile. Completion callback event values include `COMPLETED`, `SUBMITTED`, and `failed`; do not treat that vocabulary as the REST profile enum. ```json { - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { - "account_id": "", - "message_id": "", - "message_status": "DELIVERED", - "channel": "whatsapp", - "inbound_number": "+1...", - "outbound_number": "+1...", - "template_id": "" - } + "event": "SUBMITTED", + "profile_id": "00000000-0000-0000-0000-000000000000", + "timestamp": "2026-08-09T12:00:00Z" } ``` -Sub-types follow `.` (`message.queued`, `message.routed`, `message.sent`, `message.delivered`, `message.failed`, and on WhatsApp/RCS only, `message.read`). - -WhatsApp-specific sub-types beyond the universal `message.*` family (e.g., template approval/rejection notifications) are not enumerated in the v3 snapshot. To discover what your account currently subscribes to: - -1. List configured webhooks: `GET /v3/webhooks`. -2. Inspect a single webhook's `event_types` and `event_filters` fields. -3. Subscribe broadly to the `message` parent type and observe what arrives in production — fold the observed sub-types into your routing. - -## Webhook signature verification - -The webhook model (verified) exposes `signing_secret` as a per-webhook field; the exact HMAC algorithm and header name are not specified in the snapshot. Rotate via `POST /v3/webhooks/{id}/rotate-secret` — the old secret is invalidated immediately, so coordinate with the receiver before rotating. - -## Meta-side context (for operators only — link, do not reimplement) - -When a dashboard tenant is stuck and you need to know what the dashboard is doing on their behalf, the underlying Meta flow looks like this — read Meta's docs for current details: - -- Meta app type, Tech Provider / Solution Partner status, granular scopes, Graph version, redirect URI allowlisting → [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- OAuth code → System User token exchange → [Facebook Login for Business](https://developers.facebook.com/docs/facebook-login/facebook-login-for-business). -- WABA / phone-number lookup, phone-number registration with PIN, app subscription to WABA → [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). -- App review state, business verification, payment method, quality rating → Meta Business Suite UI. - -If a tenant is genuinely operating their own Meta app (not using the Sent-managed dashboard flow), they own all of the above and should be referred to Meta's docs directly. Sent's API does not replace that. - -## What is not in the v3 docs snapshot - -- The exact shape of the request body for `POST /v3/profiles/{id}/complete` for WhatsApp wiring (the snapshot confirms the endpoint exists and is sensitive; the per-channel payload is not published). -- The webhook signature algorithm / header used to verify Sent → receiver deliveries. -- The mapping shape between Sender Profile and the WABA / phone-number IDs the dashboard binds to it. -- WhatsApp-specific webhook sub-types (e.g., template lifecycle events). - -Treat each of these as "discover via your account" rather than "code to a spec". +Completion callbacks use `event`, not `sub_type`. Meta Embedded Signup browser messages also use an `event` field but have a different producer and payload. Keep the two handlers distinct. diff --git a/skills/waba-embedded-signup/references/waba-onboarding-runbook.md b/skills/waba-embedded-signup/references/waba-onboarding-runbook.md index bdb4098..d12cf88 100644 --- a/skills/waba-embedded-signup/references/waba-onboarding-runbook.md +++ b/skills/waba-embedded-signup/references/waba-onboarding-runbook.md @@ -1,120 +1,50 @@ -# WABA Onboarding Runbook — Dashboard Flow +# WABA onboarding runbook -Operator-facing companion to `waba-embedded-signup`. Walks the **Sent dashboard** WhatsApp connection flow in time order and tells you, for each step, what success looks like, the failure modes you should expect, and how to recover **without** restarting the whole signup. +## 1. Choose the path -The v3 snapshot confirms there is **no public Sent Embedded Signup API endpoint** — the Channels page in the dashboard is the surface, and it initiates Meta's Facebook Login for Business flow internally. This runbook reflects that reality; for the broader skill workflow and integration-path decision, see `waba-embedded-signup` SKILL.md. +- Organization needs its first WABA: use dashboard Embedded Signup. +- Child shares the organization WABA: create a profile and omit credentials. +- Child owns a dedicated WABA: create a profile with `waba_id` and `access_token`; optionally include `phone_number_id`. -## 0. Pre-flight (per-tenant gates) +Record why the choice matches brand, tenant, compliance, and blast-radius requirements. -Before the "Continue Channel Setup" button is meaningful for a tenant, two account-level gates must be true: +## 2. Prepare access -- [ ] **KYC approved.** Per the verified onboarding state machine, the account must have reached `KYC_COMPLETED` (state 5+). Before that, the v3 API returns `AUTH_006` and the dashboard blocks the Channels page. Compliance form fields (business identity, use cases, opt-in evidence) come from the dashboard's KYC + compliance pages. -- [ ] **Meta Business Portfolio ready.** The tenant must already have (or create during the flow) a Meta Business Portfolio under which a WABA will be selected or created. Sent does not provision this on the tenant's behalf. +Use a profile key alone or an organization key with `x-profile-id` for existing-child operations. Do not use `x-profile-id` with a profile key. Remove legacy `x-sender-id` examples. -If KYC is still in `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`, finish that first. The dashboard's onboarding checklist surfaces the next required step. +## 3. Handle secrets -## 1. Click "Continue Channel Setup" in the dashboard +- Receive tokens only through a protected server-side path. +- Store them in a secret manager if your system must retain them. +- Redact request bodies before logging. +- Never send tokens back to the frontend. +- Do not include tokens in screenshots, fixtures, errors, or support tickets. -After KYC, the dashboard surfaces a **Continue Channel Setup** entry that lands on the **Channels** page. +## 4. Create or update the profile -**Success looks like:** The Channels page loads and shows a **WhatsApp** tab with a **Connect** action. +Use `sandbox: true` first. On a dedicated WABA path, confirm that the returned non-secret WABA and number identifiers match intent. On inheritance, treat `422` as evidence the organization WABA prerequisite is absent. -**Common failure modes:** -- Button is missing / disabled → account state hasn't reached `KYC_COMPLETED`. Finish KYC first. -- API returns `AUTH_007` against `/v3/messages` for a tenant who thinks they're set up → they're at `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED` but haven't completed channel setup. They need to land on this page. +## 5. Complete -**Recovery without restarting:** Re-check `GET /v3/me` or the dashboard's onboarding indicator. Channel setup itself has no API; route the tenant back to the dashboard. +Send `webHookUrl` to `/v3/profiles/{profileId}/complete`. Persist the request ID and profile ID. A `202` is not final approval. -## 2. Select the phone number +The callback handler: -In the Channels → WhatsApp flow, the tenant selects the phone number that will be used for the WABA's first sender. Sent docs note that **this selection is not easy to change later** — once a phone number is bound to a Sender Profile, swapping it requires Meta-side migration plus a dashboard re-bind. +1. verifies the callback; +2. reads top-level `event`; +3. deduplicates by profile/event and delivery identity when available; +4. preserves unknown event strings; +5. records `COMPLETED`, `SUBMITTED`, or `failed` without coercing REST status. -**Success looks like:** The phone number is captured and the flow advances to Meta login. The number should be an E.164 line the tenant controls, not currently registered to another WABA they care about. +## 6. Smoke test -**Common failure modes:** -- Tenant picks a number that's already on a WABA they intend to keep separate → after Meta consent they'll discover the number is "in use elsewhere" and have to detach in WhatsApp Manager. -- Tenant picks a personal line they later want back for WhatsApp Consumer → that's a one-way door; warn upfront. +- Create a synthetic draft template with the Sent `definition` request shape. +- Validate with `sandbox: true`. +- Submit only after explicit review. +- Send to a controlled recipient. +- Persist the returned `message_id` with tenant/profile attribution. +- Verify the normal Sent message webhook separately from the completion callback. -**Recovery without restarting:** Within the same flow you can usually back out and pick a different number. After completion, switching numbers requires Meta-side migration and a fresh dashboard binding. +## 7. Rollback -## 3. Log in with Facebook/Meta and grant Sent permission - -The dashboard launches Meta's Embedded Signup popup (Facebook Login for Business). The tenant: - -- Logs in with their Meta account that admins the Business Portfolio. -- Selects (or creates) the **WABA** to bind. -- Grants Sent permission to **manage messages and templates** on that WABA. - -**Success looks like:** The popup closes with success; the dashboard reflects the connected WABA name. - -**Common failure modes:** -- Popup closes immediately → ad-blocker or popup-blocker. Disable for the Sent dashboard origin. -- Tenant chose "Create a new WhatsApp Business Account" inside the dialog and got stuck on business verification → not a Sent issue; tenant must finish verification in Meta Business Suite, then return. -- Tenant unchecked WhatsApp permissions in the consent screen → the binding will fail or be unusable. Re-launch Connect and accept all required permissions. - -**Recovery without restarting:** Re-launch Connect from the dashboard. The tenant only re-confirms permissions; previously-captured fields (like the chosen phone number) typically persist. - -## 4. Add Meta payment method - -Meta charges per-conversation for WhatsApp Business messaging, separately from Sent's billing. The tenant must add a payment method to the WABA in WhatsApp Manager / Meta Business Suite. - -**Success looks like:** Payment method status is "Active" in WhatsApp Manager. Sent's dashboard may surface a "Meta payment required" warning until this is true. - -**Common failure modes:** -- Card declines → tenant retries with a different card in Meta Business Suite. -- Tenant conflates this with Sent billing → clarify: Sent bills Sent fees; Meta bills WhatsApp conversation fees directly to the WABA. - -**Recovery without restarting:** Meta payment is set on the WABA, independent of the Sent flow — the tenant can complete this without re-doing steps 1–3. - -## 5. Confirm channel setup completion in dashboard - -After the WABA binding and Meta payment are in place, the dashboard reflects channel setup as complete. Internally, the account state should advance to `MESSAGE_COMPLIANCE_COMPLETED` and then to activated. The API surface that signals "I am done" is `POST /v3/profiles/{id}/complete` (idempotent, sensitive — 10/min, burst 5). Inspect `GET /v3/profiles/{id}` and look for `status` ∈ `pending_review` → `approved`. - -**Success looks like:** Profile `status` reaches `approved`; the dashboard shows the WhatsApp channel as connected. - -**Common failure modes:** -- Profile stays `pending_review` → Sent-side review is still running. Surface the status to the tenant; do not retry `complete` in a loop (rate-limited). -- Profile lands at `rejected` → KYC or compliance evidence was insufficient; the dashboard explains the reason. Fix in KYC + re-run. -- API returns `AUTH_005` against sends → the account state is at step 6 waiting for final Sent-side activation. No tenant action needed; wait. - -**Recovery without restarting:** `POST /v3/profiles/{id}/complete` is idempotent — calling again with the same input is safe. Do not delete and re-create the profile to "reset" status. - -## 6. Copy API credentials - -Once `status = approved`, API credentials are available: - -- On the post-setup screen, or -- Anytime from the dashboard's **API Keys** page (the snapshot lists this as `(dashboard-only; not in v3 API spec)` — there is no API to mint or list keys). - -Auth in v3 is a single header: `x-api-key: `. There is no `x-sender-id` in v3 — that's v2 legacy. The key is account-scoped. - -**Success looks like:** A test request to `GET /v3/me` with the key returns 200. - -**Common failure modes:** -- `AUTH_001` (401, missing header) → header name wrong; must be `x-api-key`. -- `AUTH_002` (401, invalid key) → key was rotated or copied with whitespace. -- `AUTH_007` (403, no channel configured) → key is valid but the account is at `KYC_COMPLETED` / `MESSAGE_COMPLIANCE_COMPLETED` without a finished channel. Re-check step 5. -- `AUTH_005` (403, pending final activation) → wait for Sent activation; not a credential problem. - -**Recovery without restarting:** Re-copy the key from the dashboard. Treat the key as a secret — never log it. Use the sandbox mode (`"sandbox": true` in mutation request bodies) for integration tests so you don't burn budget. - -## Stuck-state triage cheat-sheet - -| Symptom in production | Step | First thing to check | -|---|---|---| -| "Continue Channel Setup" missing | 0–1 | Account state — finish KYC first | -| Channels page rejects the chosen number | 2 | Number already on another WABA | -| Meta popup closes immediately | 3 | Popup/ad-blocker on dashboard origin | -| Popup completes but dashboard shows "not connected" | 3 | Tenant unchecked permissions; re-launch Connect | -| Dashboard shows "Meta payment required" | 4 | Add payment in WhatsApp Manager | -| Profile stuck `pending_review` | 5 | Sent-side review; do not re-POST `complete` in a loop | -| API send returns `AUTH_007` | 5 | Channel setup not actually complete | -| API send returns `AUTH_005` | 5 | Final Sent activation pending; no action | -| `x-api-key` returns `AUTH_002` | 6 | Re-copy from dashboard; check whitespace | - -## What this runbook deliberately does not cover - -- Customer apps that own their **own** Meta App and run Embedded Signup themselves (rather than using the Sent-managed dashboard flow). That path is owned by Meta — see `references/waba-embedded-signup-spec.md` and Meta's [Embedded Signup docs](https://developers.facebook.com/docs/whatsapp/embedded-signup). -- Template authoring and submission — see `waba-template-author`. -- Multi-tenant Sender Profile design — see `sender-profile-architect`. -- Post-connection delivery debugging — see `messaging-performance-analyzer`. +If the WABA or number is wrong, stop new sends, revoke exposed credentials, correct profile mapping, and retain audit evidence. Avoid deleting a profile until number ownership and message retention are resolved. diff --git a/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md b/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md index bb74cb0..1102d4b 100644 --- a/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md +++ b/skills/waba-embedded-signup/references/whatsapp-sender-profile-mapping.md @@ -1,103 +1,40 @@ -# WhatsApp ↔ Sent Sender Profile Mapping - -How Meta-side entities created during Embedded Signup map onto Sent's Sender Profile model. Read this before deciding how many profiles to create per tenant, or when debugging why a webhook landed on the wrong profile. - -For the broader multi-channel architecture (one profile owns SMS + WhatsApp + RCS halves; how to split tenants across profiles), see `sender-profile-architect`. - -## The entities - -**Meta side:** -- **Business Manager / Business Portfolio** — the tenant's legal/operational umbrella in Meta Business Suite. -- **WABA (WhatsApp Business Account)** — owns templates and phone numbers; the unit Meta bills. -- **Phone Number** — a single E.164 number registered for Cloud API on a WABA. -- **System User** — long-lived identity holding the access token used to call Graph API on behalf of the tenant. - -**Sent side (v3 — schema verified against snapshot):** - -A Sender Profile is: - -| Field | Type | Notes | -|---|---|---| -| `id` | UUID | The Sent-side primary key. | -| `name` | string | Display name. | -| `icon` | string \| null | URL. | -| `description` | string \| null | | -| `short_name` | string \| null | | -| `role` | `admin` \| `billing` \| `developer` \| null | Caller's role on this profile. | -| `status` | `incomplete` \| `pending_review` \| `approved` \| `rejected` \| null | Setup status. | -| `created_at` | ISO8601 | | -| `settings` | object | `{default_channel, webhook_url, timezone, language}` | - -There is **no public `channels.whatsapp` sub-resource** on the Profile in the v3 docs snapshot. Per-channel WhatsApp wiring (WABA ID, phone-number ID) is performed via the dashboard Channels page, which is explicitly listed as "dashboard config; not directly in v3 API". Treat WABA and phone-number IDs as external provider identifiers that the dashboard binds to the profile; do not invent v3 endpoints to mutate that binding. - -Auth in v3 is a single header — `x-api-key: ` — at the account level. `x-sender-id` is **v2 legacy** and is exposed per profile in the dashboard for routing, not as a v3 API auth requirement. - -## ASCII map - -``` -Tenant - │ - ├── Business Manager (1) - │ │ - │ ├── WABA #A ────────────────────────► Sender Profile P1 (id, status=approved) - │ │ ├── Phone +1 555 0100 ◄────────┤ (dashboard-bound) - │ │ ├── Phone +1 555 0101 ◄──┐ │ - │ │ └── Templates │ │ - │ │ │ │ - │ │ └────► Sender Profile P2 - │ │ │ (different phone, same WABA) - │ │ - │ └── WABA #B ────────────────────────► Sender Profile P3 - │ └── Phone +44 20 7946 0000 ◄────┤ - │ - └── System User (1) ──► token held in vault, referenced by all of P1/P2/P3 -``` - -## Cardinality rules (operational, not enforced by v3 API) - -| Relationship | Cardinality | Notes | -|---|---|---| -| Business Manager → WABA | 1 : N | A tenant may operate multiple WABAs (per region or brand). | -| WABA → Phone Number | 1 : N | Up to 25 per WABA per Meta's current limits. | -| Phone Number → Sender Profile | 1 : 1 | **Hard rule.** Each phone number routes to exactly one profile; sharing breaks inbound routing. | -| WABA → Sender Profile | 1 : N | Multiple profiles may bind to the same WABA, each pinning a different phone number. | -| System User → WABA | 1 : N | One System User token can hold scopes for many WABAs. | -| Sender Profile → WhatsApp wiring | 0 : 1 | A profile has at most one WhatsApp binding. SMS / RCS bindings are independent. | - -## What `POST /v3/profiles/{id}/complete` actually does - -`POST /v3/profiles/{id}/complete` is confirmed in the v3 snapshot as the profile-completion endpoint. It supports `Idempotency-Key` and is classified as a sensitive endpoint (10 req/min, burst 5). It transitions the profile out of `incomplete` once prerequisites are met. - -The exact request/response shape for the completion call (which fields must be present, what gets persisted) is **not enumerated in the v3 snapshot**. Treat the completion call as a commit: prerequisites (KYC + channel config done via the dashboard) must already be true; the endpoint signals "I am ready". Check the live OpenAPI at [docs.sent.dm](https://docs.sent.dm) before wiring a tenant-facing integration. - -## Routing inbound events back to a profile - -Sent's webhook envelope (verified) is: - -```json -{ - "field": "message", - "sub_type": "message.delivered", - "timestamp": "2026-01-15T10:35:00+00:00", - "payload": { "account_id": "...", "message_id": "...", "channel": "whatsapp", "inbound_number": "+1...", "outbound_number": "+1...", "template_id": "..." } -} +# WhatsApp Sender Profile mapping + +## Recommended records + +```text +tenant_id +sent_profile_id +waba_mode # organization_inherited | dedicated +waba_id +whatsapp_phone_number_id +whatsapp_phone_number +profile_key_secret_ref +status_raw +status_surface ``` -For WhatsApp inbound, the payload carries `account_id`, `message_id`, and the inbound/outbound E.164 numbers. WhatsApp-specific sub-types beyond the generic `message.*` family (e.g., template-status events) are not enumerated in the snapshot — discover them empirically against your account by subscribing broadly and observing what arrives. +Do not store `access_token` in this record. Use a secret-manager reference when retention is necessary. -## Detaching a WABA without losing message history +## Mapping invariants -There is no v3 API endpoint documented for detaching a WhatsApp binding from a profile. The Channels page in the dashboard is the surface. Operationally: +- A dedicated WABA profile has a recorded `waba_id` matching the intended tenant. +- An inherited profile explicitly records that it shares the organization WABA. +- A WhatsApp number maps to one current tenant/profile route unless the product has a documented coexistence model. +- `message_id` is persisted with tenant and profile before webhook events arrive. +- Unknown REST or completion callback statuses are stored verbatim with their surface. -1. Stop sending on the profile. -2. Wait for in-flight deliveries to settle (delivery webhooks drain within ~24h for normal traffic, longer for slower carriers). -3. Use the dashboard Channels page to remove the WhatsApp binding. -4. On the Meta side, unsubscribe your Tech Provider app from the WABA via Graph API if you held the subscription directly. +## Auth ownership -Historical MDRs remain queryable by `message_id` — message history is not deleted when the binding is removed. +Profile keys minimize tenant credential blast radius. Organization keys with `x-profile-id` centralize control but share the organization rate-limit pool and expand credential impact. Never expose the organization key to tenant code. -## Migrating a phone number between WABAs +## Event surfaces -Meta supports moving a phone number between WABAs and the phone-number ID is stable across the move. On Sent's side, the dashboard Channels page is the supported surface to re-bind. Since the v3 docs do not publish the channel-config mutation endpoint, do not encode a `PATCH /v3/profiles/{id}/channels/whatsapp` call in client integrations — operate via the dashboard until the API is published. +| Surface | Discriminator | +| --- | --- | +| Meta Embedded Signup browser message | `event` plus Meta session/data fields | +| Sent profile completion callback | top-level `event` | +| Sent message webhook | `field: "message"` plus `sub_type` | +| Sent template webhook | `field: "templates"`, no `sub_type` | -Templates are WABA-scoped and do **not** transfer with the phone number — re-author or re-import on the new WABA. +Do not copy envelopes between these integrations. They have different producers, authenticity checks, and retry behavior. diff --git a/skills/waba-template-author/SKILL.md b/skills/waba-template-author/SKILL.md index 40d25b1..c11baf2 100644 --- a/skills/waba-template-author/SKILL.md +++ b/skills/waba-template-author/SKILL.md @@ -1,197 +1,168 @@ --- name: waba-template-author -description: Writes, classifies, and revises WhatsApp templates for Sent, including utility, marketing, and authentication category decisions, variable samples, component structure, rejection-risk review, and Sent template submission. Use when a user says WhatsApp template, WABA template, template category, utility vs marketing, authentication template, Meta rejection, template samples, buttons, or wants approved WhatsApp copy in Sent. +description: Writes, classifies, validates, and repairs WhatsApp templates using the Sent v3 template definition contract. Use for utility, marketing, authentication, OTP, Meta review, rejected templates, variables, buttons, channel overrides, or submission-ready Sent payloads. --- - - -# WABA template author - -## Overview - -Use this skill to write WhatsApp template content that can be represented as a Sent template, submitted for WhatsApp review where required, and later sent through `POST /v3/messages` with `template.id`. The skill’s job is not just to produce polished copy; it must choose the right category, structure components correctly, provide realistic sample values, and flag review risks before submission. - -Sent stores templates as reusable message blueprints across SMS, WhatsApp, and RCS. WhatsApp review and category enforcement come from Meta, but the Sent-facing workflow uses Sent’s `/v3/templates` endpoints and Sent template statuses. - -## When to use - -Use this skill when the user asks for WhatsApp template copy, WABA template creation, utility/marketing/authentication classification, template rejection fixes, variable samples, buttons, headers, template categories, Meta approval risk, or a Sent template payload for WhatsApp. - -Do not use this skill to design the whole template-management UI; use `template-builder-ui`. Do not use it to connect a WABA or phone number; use `waba-embedded-signup`. Do not use it to register SMS compliance; use `sms-10dlc-registration`. - -## Category decision - -Pick the narrowest truthful WhatsApp category. Do not force promotional content into utility. The category should match the recipient’s expectation, the opt-in context, and the actual copy. - -| Category | Use when | Avoid when | -|---|---|---| -| Utility | The message is tied to an existing transaction, account, order, appointment, or service request. | The copy includes upsell, acquisition, abandoned cart, discount, or broad engagement language. | -| Marketing | The message promotes, re-engages, cross-sells, announces offers, or encourages optional action not tied to an existing transaction. | The message is purely required service/account information. | -| Authentication | The message delivers one-time passcodes or verification flows. | The message includes non-authentication content or marketing. | - -**Example.** “Your order 1234 shipped and arrives tomorrow” is utility. “Your order shipped — add accessories for 20% off” is marketing risk because it adds promotional content. - -## Process - -### 1. Capture the business intent - -Ask what event triggers the template, who receives it, what action the recipient should take, and whether the message contains any promotion. Write those answers before drafting copy. - -A strong intent statement is specific: “Send a delivery reschedule link after a courier misses the first attempt.” A weak one says: “Notify users about updates.” - -### 2. Choose the category before writing copy - -Drafting before category selection often creates copy that fails review. Choose utility, marketing, or authentication first, then write within that boundary. - -If the user wants utility but includes promotional language, explain the conflict and offer two options: remove promotion and keep utility, or keep promotion and classify as marketing. - -### 3. Draft the component structure - -Represent the template in Sent-compatible component language: header, body, footer, buttons, variables, and samples. Keep the component set as simple as the use case allows. - -| Component | Guidance | -|---|---| -| Header | Use only when it clarifies identity or context. Avoid promotional headers for utility templates. | -| Body | Put the required message and variables here. Keep the first sentence clear without needing the button. | -| Footer | Use for low-emphasis context such as opt-out or support where appropriate. | -| Buttons | Use quick replies or call-to-action buttons only when they directly support the message intent. | -| Variables | Use stable names and provide realistic samples for every variable. | - -### 4. Write with review risk in mind - -Use concise, literal copy. Avoid vague urgency, misleading scarcity, or mixed intents. Do not include sensitive data unless the use case requires it and the customer confirms it is acceptable. - -**Utility example.** - -```text -Name: order_shipped_update -Category: Utility -Language: en_US -Body: Hi {{first_name}}, your {{brand_name}} order {{order_id}} has shipped and is expected on {{delivery_date}}. Track it here: {{tracking_url}}. -Samples: - first_name: Alex - brand_name: Acme - order_id: A12345 - delivery_date: May 22 - tracking_url: https://acme.example/t/A12345 -Button: Track order -> {{tracking_url}} -``` - -**Marketing example.** - -```text -Name: spring_sale_announcement -Category: Marketing -Language: en_US -Body: Hi {{first_name}}, {{brand_name}} spring deals are live. Use code {{promo_code}} by {{end_date}} to save on selected items. -Samples: - first_name: Alex - brand_name: Acme - promo_code: SPRING20 - end_date: May 31 -Button: Shop now -> https://acme.example/sale +# WhatsApp Template Author + +Use this skill to turn a messaging intent into a valid body for `POST /v3/templates`, review it for WhatsApp policy risk, and explain the resulting lifecycle. Sent's template request is not Meta's Cloud API `components[]` shape. + +## Source precedence + +When official sources disagree: + +1. Use the live Sent v3 OpenAPI for paths, request fields, and response shapes. +2. Use the most specific current Sent guide for lifecycle and policy semantics. +3. Preserve unknown provider values instead of forcing them into a closed enum. + +The canonical references are the Sent template-definition guide, the v3 OpenAPI, and the webhook events reference. Do not use snapshot-era v2 examples. + +## Authoring workflow + +### 1. Establish intent and category + +Collect the business event, recipient expectation, requested action, language, channel overrides, and realistic sample values. Choose: + +- `UTILITY` for a specific non-promotional transaction, account, or service event. +- `MARKETING` for promotions, offers, re-engagement, product discovery, or mixed promotional content. +- `AUTHENTICATION` for one-time verification codes and supported authentication flows. + +If content mixes utility and promotion, classify it as marketing or split it. See [references/waba-template-categories.md](references/waba-template-categories.md). + +### 2. Build the Sent create request + +`POST /v3/templates` accepts these top-level fields: + +| Field | Requirement | +| --- | --- | +| `definition` | Required. Contains `header`, `body`, `footer`, `buttons`, optional `definitionVersion`, and optional `authenticationConfig`. | +| `category` | Optional: `UTILITY`, `MARKETING`, or `AUTHENTICATION`; omit for detection only when ambiguity is acceptable. | +| `language` | Optional locale such as `en_US`. | +| `creation_source` | Optional source string; `from-api` is the documented default. | +| `submit_for_review` | Optional Boolean; default `false`. Draft and validate before review. | +| `sandbox` | Optional Boolean for validation without side effects. | + +Do not put `name`, `channels`, `body`, `header`, `buttons`, or `components` at the request root. `name` exists on update/response surfaces, not on the current create request. + +```json +{ + "category": "UTILITY", + "language": "en_US", + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} has shipped.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": null, + "whatsapp": null, + "rcs": null + }, + "footer": null, + "buttons": null, + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true +} ``` -### 5. Convert the draft into a Sent template operation +Use `definition.body.multiChannel` as the channel-neutral body. `sms`, `whatsapp`, and `rcs` are complete channel overrides, not fragments. Keep each body at or below 1,024 characters. -Use Sent’s template API for creation and lifecycle management. The verified operations are: +### 3. Define variables exactly -| Operation | Endpoint | Use | -|---|---|---| -| Create template | `POST /v3/templates` | Save a draft or submit a new template. | -| List templates | `GET /v3/templates` | Find templates by search, status, or category. | -| Retrieve template | `GET /v3/templates/{id}` | Inspect status and definition. | -| Update template | `PUT /v3/templates/{id}` | Revise name, category, language, definition, or submit for review. | -| Delete template | `DELETE /v3/templates/{id}` | Delete the Sent template, optionally deleting from Meta where supported. | +Use placeholders such as `{{0:variable}}`, `{{1:link}}`, or `{{2:media}}`. Each placeholder needs one matching definition with: -Use Sent’s documented template statuses in user-facing instructions: `PENDING`, `APPROVED`, `REJECTED` (per the Sent docs snapshot, Template Models section). Sent does **not** surface `PAUSED` — that is Meta-side only. If Meta returns additional statuses for a WhatsApp account, quote them as Meta-side evidence rather than Sent-surfaced statuses. +- a unique non-negative integer `id`; +- a readable `name`; +- a matching `type`; +- `props.sample` with realistic review and preview data. -### 6. Add variable samples before submission +Keep placeholder IDs and variable IDs aligned inside every body override. Never output naked `{{1}}` placeholders in a Sent request. -Every placeholder needs a realistic sample. Samples should look like production data and should not add claims that the body does not support. +### 4. Add supported buttons -**Bad sample pattern.** Body says “Your appointment is confirmed,” but sample data includes “50% off visit.” This can create category confusion. +Sent currently recognizes `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, and `COPY_CODE`. Enforce: -**Good sample pattern.** Body and sample values all support the same transactional use case. +- 10 buttons total; +- at most 2 URL buttons; +- at most 1 voice-call button; +- at most 1 phone-number button; +- at most 1 copy-code button; +- quick replies may use the remaining slots, up to the total of 10. -### 7. Revise rejected templates from the reason, not from guesses +Buttons use `id`, `type`, and `props`. Labels are at most 25 characters. Require type-specific properties: `quickReplyType`; `urlType` and `url`; `countryCode` and `phoneNumber`; or `offerCode`. Quick replies and calls-to-action may coexist—do not invent an XOR rule. -If a template is rejected, retrieve the Sent template detail/status and any available rejection reason. Then change only what the reason justifies. Category mismatch, missing samples, unsupported components, and promotional language in utility templates require different fixes. +### 5. Handle authentication templates -| Rejection symptom | Likely correction | -|---|---| -| Category mismatch | Change category or remove conflicting copy. | -| Missing/weak samples | Add realistic variable samples. | -| Unsupported component | Simplify header/buttons or split channel variants. | -| Policy concern | Remove misleading, sensitive, or prohibited content. | -| Language mismatch | Correct language code and localized text. | +For `AUTHENTICATION`, use `definition.authenticationConfig`: -### 8. Confirm sendability after approval - -After approval, confirm the template can be used with the intended Sender Profile/channel and sent through `POST /v3/messages` with `template.id`. If delivery later fails, hand off to `messaging-performance-analyzer` rather than rewriting approved copy blindly. - -## Common rationalizations to avoid - -Do not call a template utility if it includes discounts, upsells, abandoned-cart messaging, or broad engagement language. - -Do not omit sample values because the placeholders are obvious. Review and test flows need rendered examples. - -Do not treat Meta Cloud API payload examples as the Sent API contract. Use Sent `/v3/templates` for Sent integrations. - -Do not introduce `PAUSED` as a Sent template status. Sent surfaces only `APPROVED`, `PENDING`, and `REJECTED` — PAUSED is Meta-side and is not reflected in the Sent template status. When Meta pauses, the Sent status stays as it was, and individual sends start failing instead — diagnose via `messaging-performance-analyzer`. +```json +{ + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 +} +``` -Do not rewrite a rejected template without reading the actual rejection reason when available. +Expiration is 1–90 minutes. Keep authentication content to the verification purpose, use one code variable and the supported copy-code action, and do not add marketing language, unrelated links, media, or promotional buttons. -## Verification checklist +### 6. Validate before submission -- [ ] The trigger event, audience, recipient action, and promotional content are documented. -- [ ] Category is chosen before copy is drafted. -- [ ] The body is clear without relying on a button. -- [ ] Every variable has a realistic sample value. -- [ ] Component choices match the selected channel and use case. -- [ ] Sent template API endpoints are used for create/list/get/update/delete. -- [ ] Status handling uses only Sent's surfaced set — `APPROVED`, `PENDING`, `REJECTED` (no `PAUSED`). -- [ ] Rejection fixes map to observed reasons, not generic rewrites. -- [ ] Approved templates are tested through Sent sending with `template.id` before broad rollout. +Run: -## Related skills +```bash +python scripts/lint_waba_template.py template.json +``` -Use `template-builder-ui` when the task is UI design, component validation, JSON editor behavior, or template-management product specs. +The linter validates the Sent request shape, variables, the 1,024-character limit, channel overrides, every current button type, per-type limits, and authentication configuration. A Meta Cloud API example with `components[]` must fail with an explicit conversion error. -Use `waba-embedded-signup` when the WhatsApp sender/WABA/phone number is not connected to Sent yet. +Use `sandbox: true` and `submit_for_review: false` while integrating. When the user is ready for provider review, show the final payload and explain that submission changes external state before proceeding. -Use `sender-profile-architect` when the template belongs to a specific tenant, brand, department, or profile boundary. +### 7. Track the right lifecycle surface -Use `sms-10dlc-registration` when WhatsApp copy will be mirrored to SMS and must align with US A2P use-case registration. +Sent template resources use the known states `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`, and `PAUSED`. Do not claim this is every value the API may ever return. -Use `messaging-performance-analyzer` when approved WhatsApp templates have poor delivery, read, or webhook outcomes. +Template webhooks are WhatsApp approval events. They use `field: "templates"`, omit `sub_type` and `event`, and carry the provider status in `payload.status`: -Use the `sent` skill for shared Sent terminology and routing. +```json +{ + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": null + } +} +``` -## Bundled references and scripts +Common forwarded values include `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`. Meta can also send values such as `PAUSED` or `DISABLED`. Persist the raw string, handle known values, and safely surface unknown ones. See [references/template-rejection-playbook.md](references/template-rejection-playbook.md). -| File | Type | Purpose | -|---|---|---| -| `references/waba-template-categories.md` | Policy lookup table | Meta category boundaries, component rules, and Cloud API submission shape. | -| `references/waba-template-examples.md` | Worked examples | Copy-pasteable payloads for utility, marketing, and authentication templates. | -| `references/template-rejection-playbook.md` | Decision matrix | Map rejection reasons to precise edits and resubmission etiquette. | -| `scripts/lint_waba_template.py` | Validation script | Stdlib lint for a template JSON payload (placeholder numbering, samples, category-risk phrases, button structure, language code). Run from the skill root: `python scripts/lint_waba_template.py template.json`. | -| `scripts/fixtures/utility_good.json` | Fixture | Passing fixture for the linter. | -| `scripts/fixtures/utility_bad.json` | Fixture | Failing fixture (wrong placeholder order, promo phrasing in utility). | +## Boundaries -## Unverified claims to confirm or remove +Use `template-builder-ui` for editor architecture and client-side validation UX. Use `sent-templates` to list, inspect, or delete existing templates through the connected Sent tools. Use `waba-embedded-signup` for WABA connection. Use `rcs-agent-onboarding` for current RCS launch capabilities. -- Exact category-pricing behavior and Meta rejection-code semantics are external Meta policy context, not Sent API facts. -- Cross-check the Sent template schema against the live OpenAPI at `https://docs.sent.dm` before promoting code to production. +Meta Cloud API payloads may appear in [references/waba-template-examples.md](references/waba-template-examples.md), but every such example must be clearly labelled non-Sent and must never be passed to the Sent linter as a valid request. diff --git a/skills/waba-template-author/references/template-rejection-playbook.md b/skills/waba-template-author/references/template-rejection-playbook.md index 60d36c1..b8c8a0d 100644 --- a/skills/waba-template-author/references/template-rejection-playbook.md +++ b/skills/waba-template-author/references/template-rejection-playbook.md @@ -1,182 +1,62 @@ -# Template Rejection Playbook — Reference +# Template rejection and lifecycle playbook -What to do when Meta rejects a WhatsApp template, silently re-categorizes it, -or pauses it after delivery starts. Companion to `references/waba-template-categories.md` -and `references/waba-template-examples.md`. Authoritative source for Meta-side codes is -the official [Cloud API template docs](https://developers.facebook.com/docs/whatsapp/message-templates). -Sent-surfaced statuses and codes come from the Sent docs snapshot referenced -above. +Use this reference when a Sent template is pending, rejected, paused, disabled, or recategorized by the WhatsApp provider. -Every entry: what triggers it, how to detect it from the rejection / status -payload, and what to change before resubmitting. +## Keep lifecycle surfaces separate -## Sent-surfaced template states (not Meta's) +Sent template resources have these known states: -Sent's template `status` set is exactly `APPROVED`, `PENDING`, `REJECTED` — -**no `PAUSED`**. When Meta pauses a template (quality rating drop, opt-out -spike), Sent's template status does **not** change; it stays whatever it was -(typically `APPROVED`). Sends against a Meta-paused template start failing -asynchronously — surface that via the `message.failed` webhook or the -`GET /v3/messages/{id}/activities` endpoint, not via a template-status poll. +- `DRAFT` +- `PENDING` +- `APPROVED` +- `REJECTED` +- `PAUSED` -When a send is attempted against a Sent template whose `status` is `PENDING` -or `REJECTED`, the batch is rejected synchronously with: +The template webhook is a provider-forwarding surface. Common `payload.status` values are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`; provider values such as `PAUSED` and `DISABLED` can also arrive. These lists serve different purposes. Persist the original status string and surface unknown values safely. -| Code | HTTP | Meaning | -|---|---|---| -| `BUSINESS_005` | 422 | "WhatsApp template not approved (still PENDING / REJECTED)" | - -So the two failure modes are distinct: - -- Template never reached `APPROVED` in Sent → `BUSINESS_005` on send. -- Template is `APPROVED` in Sent but Meta-paused → per-message failure on the - webhook / activities feed; the template `status` you see in Sent is unchanged. - -For diagnosing post-approval send failures, hand off to -`messaging-performance-analyzer`. - -## Category mismatch (utility classified, marketing content) - -**What it looks like:** Template is `APPROVED` but the returned `category` is -`MARKETING` even though you submitted `UTILITY`. Or, after first send, the -status moves to `PENDING` → `APPROVED` again with a flipped category. - -**How to detect:** Compare submitted `category` to the post-approval `category` -on the webhook. Re-categorization is the most common silent failure. - -**Revise:** -- Strip any sentence that does not refer to the triggering event. -- Replace generic CTAs ("Shop now", "Browse more") with event-specific ones - ("View order", "Track package"). -- Remove second-person calls to action that are not the entity in the message. -- Resubmit under a new version suffix (`_v2`) — the old name is locked. - -## Promotional content in utility category - -**Banned in utility body, footer, header, button labels:** - -- "buy now", "shop now", "order today" -- "limited time", "exclusive", "hurry", "ends soon" -- "special offer", "best deal", "lowest price" -- "discount", "% off", "sale", "free shipping" -- Discount codes like `SAVE20`, even as a variable sample -- Promotional emojis in body or header text (🎉 🛍️ 💸 🔥) - -**Revise:** strip the phrase; if the use case genuinely includes a promo, -flip to `MARKETING` rather than masking the wording. - -## Missing variable samples - -**What it looks like:** Submission fails with `TAG_CONTENT_MISMATCH` or the -template is rejected with "variable example missing". - -**Sent's required shape:** +## Correct template event envelope ```json { - "type": "BODY", - "text": "Hi {{1}}, your order #{{2}} has shipped.", - "example": { "body_text": [["Jordan", "A1029"]] } + "field": "templates", + "timestamp": "2026-08-09T12:00:00Z", + "payload": { + "account_id": "00000000-0000-0000-0000-000000000000", + "template_id": "11111111-1111-1111-1111-111111111111", + "template_name": "order_update", + "whatsapp_template_id": "2222222222222222", + "status": "REJECTED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp", + "reason": "Promotional content is not utility content." + } } ``` -`body_text` is an array of arrays — the outer array is "one row per -variable group", and the inner array has one sample per `{{n}}`. Forgetting -the outer array is the single most common cause. - -For URL CTAs: - -```json -{ "type": "URL", "text": "Track", "url": "https://example.com/orders/{{1}}", - "example": ["https://example.com/orders/A1029"] } -``` - -`example` is a flat array here (not nested). - -## Authentication template with code formatting error - -**What it looks like:** Submission fails with "invalid component" on an -authentication template, or the OTP button does not appear in the rendered -template preview. - -**Revise:** -- Use `category: "AUTHENTICATION"` and the dedicated auth component shape, not - a UTILITY template with a `{{1}}` for the code. -- The body component must use `add_security_recommendation: true` or include - the platform-managed security recommendation; no freeform `{{1}}` for the code. -- The button component must be `OTP` with `otp_type` of `COPY_CODE` or `AUTOFILL`. -- `code_expiration_minutes` belongs on the FOOTER component, not the body. - -See `references/waba-template-examples.md` for two valid AUTH payloads. - -## Button URL doesn't match domain - -**What it looks like:** Rejection with "URL does not match business domain" -or the template is approved but later paused for the same reason. - -**Revise:** -- Confirm the WABA's verified business domain matches the CTA URL host. -- Subdomains often need to be added separately if Meta's domain check is - strict — `app.example.com` and `example.com` are not interchangeable. -- For URL CTAs with a variable, the example URL must resolve to the same - registered domain. -- If you're a multi-tenant platform on Sent, the WABA in question must own - the domain — you cannot deep-link to a tenant subdomain not registered - under that WABA. - -## Language code mismatch - -**What it looks like:** Rejection with `INVALID_LANGUAGE`, or two templates -under the same conceptual name behave inconsistently across recipients. - -**Revise:** -- Use BCP-47 codes with the underscore separator: `en_US`, `pt_BR`, `es_MX`. -- Not `en`, not `en-US`, not `en_us`. -- Each language is a *separate template* with its own approval. You cannot - submit one template and have it cover several locales. - -## Generic placeholders (template too generic) - -**What it looks like:** Rejection with a content reason ("does not meet -template quality standards") even though the wording is neutral. - -**Triggers:** -- Bodies like "{{1}}, here is an update for you" — Meta cannot tell what - category the template is for, so they default to reject. -- Sample values like `Test`, `Sample`, `XYZ` — these read as if the template - was never going to be used in production. +Template events use `field: "templates"` and omit both `sub_type` and `event`. Message events are different and do use `sub_type`. -**Revise:** add at least one variable that proves the use case (an order ID, -an appointment time, an account-event timestamp) and use realistic sample -values that match. +## Response procedure -## Resubmission etiquette and timing +1. Verify the webhook signature using the raw body and reject stale timestamps. +2. Deduplicate on template ID plus status transition. +3. Persist the raw payload and reason. +4. Retrieve the current Sent template before editing; webhooks can be delayed or reordered. +5. Map the reason to the smallest justified change. +6. Convert any Meta-shaped source into the Sent `definition` contract. +7. Run the local linter and use `sandbox: true`. +8. Show the final diff and obtain confirmation before review submission. -- **One change per resubmission.** If you change wording *and* category *and* - variables, you cannot tell which change unblocked the template. -- **Use a new version suffix.** `_v1` → `_v2`. Submitting under the same - `(name, language)` will fail with name-conflict if the prior template is - still in any state other than `DELETED`. -- **Wait for review before re-resubmitting.** Spamming submissions of the - same template body slows the queue for the WABA. -- **For Meta-paused templates** (Sent template status still `APPROVED` but - sends are failing on the webhook / activities feed), revise the content - before resubmitting under a new version — Meta paused for a reason and an - untouched resubmission lands in the same place. Confirm the failures via - `messaging-performance-analyzer` before rewriting. -- **For silent re-categorization,** resubmit the *strictest* version of the - wording even if you intend to send marketing content from it — once the - category is set, marketing-priced sends still work fine under a stricter - template. +## Common remediations -## Quick triage table +| Symptom | Appropriate response | +| --- | --- | +| Utility content recategorized | Remove promotion or deliberately use `MARKETING`; do not argue from transactional context alone. | +| Missing or unrealistic samples | Add `props.sample` for every placeholder without using customer data. | +| Invalid variable format | Replace naked placeholders with `{{0:variable}}` and align IDs. | +| Unsupported create shape | Move fields into `definition`; reject Meta `components[]` as a Sent request. | +| Button validation | Enforce 10 total and per-type limits; allow quick replies and CTA buttons to coexist. | +| `PAUSED` or `DISABLED` | Stop new WhatsApp sends with the template, preserve the provider value, and surface it for review. | +| Unknown status | Store and display it; do not silently coerce it to rejected or approved. | -| Symptom | Most likely cause | First fix | -|---|---|---| -| Approved but category flipped | Promotional language Meta detected | Strip wording, resubmit as `_v2` | -| `INVALID_FORMAT` | Component schema typo | Re-check component types and required fields | -| `TAG_CONTENT_MISMATCH` | Variables vs. samples count mismatch | Provide one sample per `{{n}}` in the right shape | -| `META_POLICY_VIOLATION` | Restricted content (alcohol, finance, etc.) | Check Meta's restricted-content policy for the WABA's vertical | -| `INVALID_LANGUAGE` | Bad locale code | Use BCP-47 with underscore | -| Sends fail with `BUSINESS_005` | Sent template still `PENDING` or `REJECTED` | Wait for Sent approval, or fix the rejection and resubmit as `_v2` | -| Sends fail post-approval (per-message failures, Sent status unchanged) | Meta-paused template (PAUSED is Meta-side, not reflected in Sent) | Diagnose via `messaging-performance-analyzer`; revise wording and resubmit `_v2` | +Do not claim provider approval timing as a guarantee, and do not repeatedly resubmit unchanged content. diff --git a/skills/waba-template-author/references/waba-template-categories.md b/skills/waba-template-author/references/waba-template-categories.md index 0c6085a..0338ba0 100644 --- a/skills/waba-template-author/references/waba-template-categories.md +++ b/skills/waba-template-author/references/waba-template-categories.md @@ -1,218 +1,39 @@ -# WABA Template Categories — Reference +# WhatsApp template categories -Supporting reference for `waba-template-author`. Excerpts from Meta's template -policy plus practical mappings to common use cases. Authoritative external -source: [WhatsApp Business Platform > Message Templates](https://developers.facebook.com/docs/whatsapp/message-templates). +Supporting policy reference for `waba-template-author`. The request contract comes from Sent; category review is ultimately performed by Meta for WhatsApp. -> The Meta policy evolves frequently — verify the current rules in the official -> docs before relying on edge cases. This reference captures the stable patterns -> and boundaries; assume any specific number or category boundary may have -> changed since this file was written. +## Decision order -## Sent's Template Categories and Statuses +1. Identify why the recipient expects the message. +2. Identify the single action the message asks them to take. +3. Remove optional promotional language and classify again. +4. If promotion remains, use `MARKETING`. +5. If the sole purpose is a verification code, use `AUTHENTICATION`. +6. Otherwise use `UTILITY` only when the message is tied to a specific transaction, account, or service event. -Sent surfaces exactly **three** categories — Meta's three, no others: +## Category guide -- `UTILITY` -- `MARKETING` -- `AUTHENTICATION` +| Category | Suitable intent | Common rejection or recategorization risk | +| --- | --- | --- | +| `UTILITY` | Order state, appointment reminder, account change, service interruption, requested support update | Discounts, upsells, product discovery, vague re-engagement, or calls to purchase | +| `MARKETING` | Offers, launches, recommendations, reminders to shop, abandoned-cart messages, mixed promotional content | Missing consent, misleading urgency, or attempting to disguise promotion as utility | +| `AUTHENTICATION` | OTP, login verification, account recovery code | Free-form content, promotional text, unrelated links/media, or multiple actions | -Sent's template `status` set is exactly: +Transactional context does not make promotional content utility. “Your receipt is ready” is utility; “Your receipt is ready—buy again for 20% off” is marketing. -- `APPROVED` -- `PENDING` -- `REJECTED` +## Authentication restrictions -**There is no `PAUSED` status in Sent.** Meta may internally pause a template -after delivery starts (quality rating drop, opt-out spike), but Sent does **not** -surface PAUSED — the Sent template status remains whatever it was (typically -`APPROVED`), while sends against the paused template start failing at send time. -Detect that via failed activities / message webhooks, not via the template -status. See `references/template-rejection-playbook.md` for the workflow. +- Set top-level `category` to `AUTHENTICATION`. +- Include `definition.authenticationConfig`. +- `codeExpirationMinutes`, when present, is an integer from 1 through 90. +- Keep the body to the verification purpose and one code variable. +- Use the supported `COPY_CODE` action for the code. +- Do not add promotion, unrelated URLs, media, or extra calls to action. -## The Three Categories +## Variables and samples -### Utility -Triggered by a user action or a recurring event the user already opted into. The -message is *about that event*. Lowest-priced category. +Provider reviewers see samples. Every placeholder such as `{{0:variable}}` must have the same numeric ID in the channel's variables array and a realistic `props.sample`. Do not use real customer data or secrets in samples. -**Eligible use cases (non-exhaustive):** -- Order placed / shipped / delivered -- Appointment confirmation, reminder, change -- Booking confirmation -- Account balance, statement, invoice -- Password change confirmation (note: *not* the OTP itself — that's authentication) -- Service status: outage, restoration, maintenance window -- Payment received / failed / overdue notice -- Recurring statement or subscription renewal notice -- Form / application status update +## Revision discipline -**Disqualifiers — if any of these are true, Meta will reclassify to marketing:** -- The message cross-sells, upsells, or invites the user back -- The CTA button leads anywhere other than the entity the message is about - (an order-confirmation button must link to that order, not the homepage) -- Promotional language ("hurry", "limited", "exclusive", "sale", "deal") -- Discount codes, percentages off, or "use code X" content -- Promotional imagery in a media header - -### Marketing -Business-initiated outreach with the intent to drive a new action — purchase, -visit, signup, re-engagement. Highest-priced category. - -**Eligible use cases:** -- Promo / sale announcement -- Abandoned cart recovery -- Welcome-back / win-back -- Newsletter / content drop -- Product launch -- Event invitation (when the event is promotional, not transactional) -- Survey or feedback request (when not tied to a specific transaction) - -There is no penalty for marketing templates; the penalty is for *labeling* -them as utility. When in doubt, ship as marketing. - -### Authentication -A separate template type, not just a category. Used for one-time codes, login -verification, account-recovery codes. - -**Distinct rules:** -- Body is constrained: "{{1}} is your verification code." plus optional security - disclaimer ("For your security, do not share this code.") -- Buttons collapse to a single button: "Copy code" or "Autofill" (one-tap, mobile only) -- Supports `code_expiration_minutes` field -- Cannot include marketing or utility content -- Lower per-message cost; some regions price authentication separately - -## Component Rules - -### Header (optional) -- Sent header `type`: `TEXT` | `IMAGE` | `VIDEO` | `DOCUMENT` (no `LOCATION` at the Sent layer) -- Text: max 60 chars, max 1 variable -- Media: provide a sample URL or media handle at submission - -### Body (required) -- Max 1024 chars -- Supports `{{1}}`, `{{2}}`, … placeholders -- Variables must be sequential (no gaps); every variable needs a sample -- No URLs in body (link via CTA buttons instead) for most categories; exceptions exist - -### Footer (optional) -- Max 60 chars -- No variables -- Common use: compliance text, opt-out instructions - -### Buttons (optional) -- *Either* quick replies *or* CTA buttons, never both -- Quick replies: up to 3, max 25 chars per label -- CTAs: up to 2, types: `URL` or `PHONE_NUMBER` - - URL CTAs may include one trailing variable: `https://example.com/orders/{{1}}` - - URL CTAs require an example URL for submission - -## Sent CreateTemplateRequest Shape - -Sent's `POST /v3/templates` accepts the following structured shape (from -the live Sent API documentation at https://docs.sent.dm): - -``` -{ - name, - category, // "UTILITY" | "MARKETING" | "AUTHENTICATION" - language, // BCP-47 with underscore, e.g. "en_US" - body: { - content, // body text with {{1}}, {{2}} placeholders - variables?: [ - { name, type: "text" | "number" | "date", example? } - ] - }, - header?: { - type: "TEXT" | "IMAGE" | "VIDEO" | "DOCUMENT", - content - }, - footer?: { content }, - buttons?: [ - { - type: "QUICK_REPLY" | "URL" | "PHONE_NUMBER", - text, - url?, // required when type = URL - phone_number? // required when type = PHONE_NUMBER - } - ], - channels?, // subset of ["sms", "whatsapp", "rcs"] - sandbox? // bool -} -``` - -Worked Sent request: - -```json -{ - "name": "order_confirmation_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} has been confirmed and will ship soon. Track it any time below.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] - }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to unsubscribe." }, - "buttons": [ - { - "type": "URL", - "text": "Track order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"], - "sandbox": false -} -``` - -Notes: - -- The Sent body is `body.content`, not `text` (Meta-side Cloud API uses `text` - on the BODY component). Variable samples live in `body.variables[].example` - rather than a separate Cloud API `example.body_text` block. -- `header.type` is restricted to **TEXT, IMAGE, VIDEO, DOCUMENT** at the Sent - layer. `LOCATION` is not a Sent header type. -- `buttons[].type` is restricted to **QUICK_REPLY, URL, PHONE_NUMBER** at the - Sent layer. Authentication-only Cloud API button types (`OTP`, `COPY_CODE`, - `AUTOFILL`) are Meta-side and are not Sent button types. -- `channels` accepts a subset of `["sms", "whatsapp", "rcs"]`. WhatsApp templates - always include `"whatsapp"`. -- `language` follows BCP-47 with the underscore separator (`en_US`, `pt_BR`, - `es_MX`). Not `en`, not `en-US`. - -## Common Rejection Reasons (from Meta's API) - -| Code / phrase | Meaning | Fix | -|---|---|---| -| `INVALID_FORMAT` | Component schema broken | Re-validate against the component rules above | -| `TAG_CONTENT_MISMATCH` | Variable count differs from samples | Provide a sample for every `{{n}}` | -| `META_POLICY_VIOLATION` | Content violates content policy | Remove promotional content, slurs, or restricted-category content | -| `INVALID_LANGUAGE` | Language code unsupported | Use BCP-47 (`en_US`, not `en`) and one from Meta's supported list | -| Silent reclassification | Approved but category changed | Body/buttons/header read promotional even if the use case is utility | - -## Worked Examples — Decision Tree in Action - -**"Your order #1029 has shipped. Track it here."** -- Triggered by purchase? Yes. About the purchase? Yes. CTA is order-specific? Yes. -- → **UTILITY**, single URL CTA. - -**"Your order #1029 has shipped. Check out our new arrivals!"** -- Triggered by purchase? Yes. About the purchase? No — second sentence is a promo. -- → **MARKETING** (or split into two templates). - -**"Your account password was changed."** -- Triggered by user action? Yes (they changed it). About that action? Yes. -- → **UTILITY**. (The OTP that authorized the change is **AUTHENTICATION**, separate template.) - -**"We miss you — here's 20% off your next order."** -- Business-initiated, re-engagement, discount. -- → **MARKETING**, unambiguously. - -**"Your code is 729451. For your security, do not share this code."** -- One-time code for login. -- → **AUTHENTICATION** template type. Not a utility template with a code in the body. +When Meta returns `REJECTED` or `CATEGORY_UPDATED`, retain the raw reason, change only what it supports, lint again, and resubmit deliberately. Do not repeatedly submit unchanged content. diff --git a/skills/waba-template-author/references/waba-template-examples.md b/skills/waba-template-author/references/waba-template-examples.md index d72c939..9adc811 100644 --- a/skills/waba-template-author/references/waba-template-examples.md +++ b/skills/waba-template-author/references/waba-template-examples.md @@ -1,244 +1,93 @@ -# WABA Template Examples — Reference +# Sent template examples -Worked, copy-pasteable WhatsApp template payloads grouped by Meta category. -Companion to `references/waba-template-categories.md`. Every example is a complete -**Sent `POST /v3/templates`** request body following the CreateTemplateRequest -shape. Variable placeholders use `{{1}}`, `{{2}}` in the body content, and -each variable has a named entry with `type` and `example` in `body.variables`. +All examples in the first section are bodies for `POST /v3/templates` and are expected to pass `scripts/lint_waba_template.py`. Synthetic values are used throughout. -> Synthetic values only — no real WABA IDs, brand names, or customer data. - -## Utility - -### 1. Order confirmation - -Triggered by checkout. About that order. Single URL CTA bound to the order. +## Utility with a WhatsApp override + ```json { - "name": "order_confirmation_v1", "category": "UTILITY", "language": "en_US", - "body": { - "content": "Hi {{1}}, your order #{{2}} is confirmed. We will let you know when it ships.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "order_id", "type": "text", "example": "A1029" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, your appointment is on {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + }, + "whatsapp": { + "type": "body", + "template": "Hello {{0:variable}}. Your appointment is confirmed for {{1:variable}}.", + "variables": [ + {"id": 0, "name": "customerName", "type": "variable", "props": {"sample": "Avery"}}, + {"id": 1, "name": "appointmentTime", "type": "variable", "props": {"sample": "August 14 at 10:30 AM"}} + ] + } + }, + "footer": {"type": "text", "template": "Acme Scheduling", "variables": []}, + "buttons": [ + {"id": 1, "type": "QUICK_REPLY", "props": {"text": "Confirm", "quickReplyType": "custom"}}, + {"id": 2, "type": "URL", "props": {"text": "Manage booking", "urlType": "static", "url": "https://example.com/bookings"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": null }, - "header": { "type": "TEXT", "content": "Order #{{1}} confirmed" }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "View order", - "url": "https://example.com/orders/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why this is approved as utility: every component refers to the order, the CTA -deep-links to that order, no promotional language, no cross-sell. - -### 2. Shipping update - -Triggered by carrier scan. Variables numbered 1..3 sequentially. - -```json -{ - "name": "shipping_update_v2", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "package_id", "type": "text", "example": "A1029" }, - { "name": "delivery_eta", "type": "text", "example": "6 PM today" } - ] - }, - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}" - } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: status-only language, sample values are neutral, CTA points to the -tracking page for that specific package. - -### 3. Appointment reminder - -Triggered by user-booked appointment. Quick-reply buttons stay within the same -appointment — confirm or reschedule, no upsell. - -```json -{ - "name": "appointment_reminder_v1", - "category": "UTILITY", - "language": "en_US", - "body": { - "content": "Hi {{1}}, this is a reminder of your appointment with {{2}} on {{3}} at {{4}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "provider_name", "type": "text", "example": "Dr. Patel" }, - { "name": "appointment_date", "type": "date", "example": "2026-05-18" }, - { "name": "appointment_time", "type": "text", "example": "10:30 AM" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Confirm" }, - { "type": "QUICK_REPLY", "text": "Reschedule" } - ], - "channels": ["whatsapp"] -} -``` - -Why approved: every button action is tied to the appointment itself; no -"Book another visit" or other cross-sell. - -## Marketing - -### 1. Promo announcement - -Business-initiated, discount code in the body. Unambiguously marketing. - -```json -{ - "name": "spring_promo_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our spring sale is on — use code {{2}} for 20% off through Sunday.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "SPRING20" } - ] - }, - "footer": { "content": "Reply STOP to opt out." }, - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale" - } - ], - "channels": ["whatsapp"] -} -``` - -### 2. Re-engagement (win-back) - -```json -{ - "name": "winback_30d_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, we miss you! Here is 15% off your next order with code {{2}}.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" }, - { "name": "promo_code", "type": "text", "example": "COMEBACK15" } - ] - }, - "buttons": [ - { "type": "QUICK_REPLY", "text": "Shop deals" }, - { "type": "QUICK_REPLY", "text": "Browse new" } - ], - "channels": ["whatsapp"] -} -``` - -### 3. Seasonal announcement (image header) - -```json -{ - "name": "holiday_drop_v1", - "category": "MARKETING", - "language": "en_US", - "body": { - "content": "Hi {{1}}, our holiday collection just dropped. Take a look before it sells out.", - "variables": [ - { "name": "first_name", "type": "text", "example": "Jordan" } - ] - }, - "header": { - "type": "IMAGE", - "content": "https://example.com/assets/holiday-2026.jpg" - }, - "buttons": [ - { - "type": "URL", - "text": "See collection", - "url": "https://example.com/holiday" - } - ], - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` ## Authentication -Authentication templates are submitted under Sent's `AUTHENTICATION` category. -The Cloud API-specific `OTP` button types (`COPY_CODE`, `AUTOFILL`) are -Meta-side concepts and are not part of Sent's `buttons[].type` enum -(`QUICK_REPLY | URL | PHONE_NUMBER`). At the Sent layer, model the code as a -single body variable. - -### 1. One-time code - + ```json { - "name": "login_otp_v1", "category": "AUTHENTICATION", "language": "en_US", - "body": { - "content": "{{1}} is your verification code. For your security, do not share this code.", - "variables": [ - { "name": "code", "type": "text", "example": "729451" } - ] + "definition": { + "header": null, + "body": { + "multiChannel": { + "type": "body", + "template": "Your verification code is {{0:variable}}.", + "variables": [ + {"id": 0, "name": "verificationCode", "type": "variable", "props": {"sample": "482193"}} + ] + } + }, + "footer": null, + "buttons": [ + {"id": 1, "type": "COPY_CODE", "props": {"text": "Copy code", "offerCode": "482193"}} + ], + "definitionVersion": "1.0", + "authenticationConfig": { + "addSecurityRecommendation": true, + "codeExpirationMinutes": 10 + } }, - "footer": { "content": "This code expires in 10 minutes." }, - "channels": ["whatsapp"] + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } ``` -### 2. Password reset confirmation (utility-shaped, plain confirmation) +## Meta Cloud API example — not a Sent request -If you only want a confirmation (no code), submit it as `UTILITY`: +The following abbreviated shape is deliberately separate. It must not pass the Sent linter or be posted to `POST /v3/templates`; convert its `components[]` into Sent's `definition` structure first. ```json { - "name": "password_changed_v1", - "category": "UTILITY", + "name": "order_update", "language": "en_US", - "body": { - "content": "Your account password was changed on {{1}}. If this was not you, contact support.", - "variables": [ - { "name": "changed_at", "type": "text", "example": "Mon May 18, 10:32 AM" } - ] - }, - "channels": ["whatsapp"] + "category": "UTILITY", + "components": [ + {"type": "BODY", "text": "Your order {{1}} has shipped."} + ] } ``` - -## Things to copy - -- Variable numbering in `body.content` is always `{{1}}, {{2}}, {{3}}` in order - of first appearance. -- `body.variables` is an array with one entry per `{{n}}`, in the same order. - Each entry has a `name`, a `type` (`text` | `number` | `date`), and an - `example` that matches the type. -- URL buttons take a `url`. Variables in URLs (`https://example.com/o/{{1}}`) - reuse the body's variable ordering. -- Phone-number buttons take a `phone_number` instead of `url`. -- Footers cannot contain variables — keep compliance-only language there. -- `channels` selects the channels the template should be available on. For a - WhatsApp-only template, use `["whatsapp"]`. diff --git a/skills/waba-template-author/scripts/fixtures/utility_bad.json b/skills/waba-template-author/scripts/fixtures/utility_bad.json index 3d3c1d3..16cb470 100644 --- a/skills/waba-template-author/scripts/fixtures/utility_bad.json +++ b/skills/waba-template-author/scripts/fixtures/utility_bad.json @@ -1,23 +1,11 @@ { - "name": "shipping_update_bad_v1", + "name": "meta_cloud_shape", "language": "en_US", "category": "UTILITY", "components": [ { "type": "BODY", - "text": "Hi {{2}}, your package #{{1}} is on the way. Buy now — limited time!", - "example": { "body_text": [["A1029", "Jordan"]] } - }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Shop now", - "url": "https://example.com/sale", - "example": ["https://example.com/sale"] - } - ] + "text": "This is Meta's components[] format, not the Sent v3 request body." } ] } diff --git a/skills/waba-template-author/scripts/fixtures/utility_good.json b/skills/waba-template-author/scripts/fixtures/utility_good.json index 445c118..2b33ccc 100644 --- a/skills/waba-template-author/scripts/fixtures/utility_good.json +++ b/skills/waba-template-author/scripts/fixtures/utility_good.json @@ -1,27 +1,80 @@ { - "name": "shipping_update_v2", - "language": "en_US", "category": "UTILITY", - "components": [ - { - "type": "BODY", - "text": "Hi {{1}}, package #{{2}} is out for delivery and should arrive by {{3}}.", - "example": { "body_text": [["Jordan", "A1029", "6 PM today"]] } + "language": "en_US", + "definition": { + "header": { + "type": "text", + "template": "Order update", + "variables": [] + }, + "body": { + "multiChannel": { + "type": "body", + "template": "Hi {{0:variable}}, order {{1:variable}} is ready.", + "variables": [ + { + "id": 0, + "name": "customerName", + "type": "variable", + "props": {"sample": "Avery"} + }, + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + }, + "sms": { + "type": "body", + "template": "Order {{1:variable}} is ready.", + "variables": [ + { + "id": 1, + "name": "orderNumber", + "type": "variable", + "props": {"sample": "A-1042"} + } + ] + } }, - { - "type": "FOOTER", - "text": "Reply STOP to opt out." + "footer": { + "type": "text", + "template": "Acme Support", + "variables": [] }, - { - "type": "BUTTONS", - "buttons": [ - { - "type": "URL", - "text": "Track package", - "url": "https://example.com/track/{{1}}", - "example": ["https://example.com/track/A1029"] - } - ] - } - ] + "buttons": [ + { + "id": 1, + "type": "QUICK_REPLY", + "props": {"text": "Got it", "quickReplyType": "custom"} + }, + { + "id": 2, + "type": "URL", + "props": {"text": "Track order", "urlType": "static", "url": "https://example.com/track"} + }, + { + "id": 3, + "type": "VOICE_CALL", + "props": {"text": "Voice support", "countryCode": "US", "phoneNumber": "+12025550100"} + }, + { + "id": 4, + "type": "PHONE_NUMBER", + "props": {"text": "Call support", "countryCode": "US", "phoneNumber": "+12025550101"} + }, + { + "id": 5, + "type": "COPY_CODE", + "props": {"text": "Copy reference", "offerCode": "A-1042"} + } + ], + "definitionVersion": "1.0", + "authenticationConfig": null + }, + "creation_source": "from-api", + "submit_for_review": false, + "sandbox": true } diff --git a/skills/waba-template-author/scripts/lint_waba_template.py b/skills/waba-template-author/scripts/lint_waba_template.py index 131dbd0..ccb5a1e 100644 --- a/skills/waba-template-author/scripts/lint_waba_template.py +++ b/skills/waba-template-author/scripts/lint_waba_template.py @@ -1,32 +1,9 @@ #!/usr/bin/env python3 -"""Lint a Sent WhatsApp template payload against Meta's category + structural rules. - -Usage: - python lint_waba_template.py - -Exits 0 with "OK" if the template passes all checks. -Exits non-zero and prints each issue with the offending field on failure. - -Checks performed: - * Required top-level keys: name, language, category, components. - * category is one of UTILITY, MARKETING, AUTHENTICATION (Sent's three). - * language matches BCP-47 (lowercase locale, optional uppercase region: en, en_US). - * Exactly one BODY component is present with non-empty text - (AUTHENTICATION templates are exempt — they use managed body content). - * Placeholders {{1}}..{{N}} in body text are numbered 1..N sequentially with no gaps. - * Sample value count under example.body_text matches the placeholder count. - * For UTILITY templates: - - Promotional phrases ("buy now", "limited time", "special offer", - "discount", "sale", "free shipping") trigger warnings. - - "click here to purchase" triggers a hard failure (clear cross-sell). - * HEADER component (if present): `format` must be one of TEXT, IMAGE, VIDEO, - DOCUMENT (Sent's allowed CreateTemplateRequest header types). - * BUTTONS component (if present) is either <=3 QUICK_REPLY OR <=2 CTA - (URL/PHONE_NUMBER/OTP) — never mixed. Per-button checks: - - type must be one of QUICK_REPLY, URL, PHONE_NUMBER (Sent's button - enum) or OTP (Cloud API authentication-only, warned). - - URL buttons require a non-empty `url` field. - - PHONE_NUMBER buttons require a non-empty `phone_number` field. +"""Lint the JSON body sent to ``POST /v3/templates``. + +This validator intentionally accepts the Sent v3 request contract, not Meta's +Cloud API ``components[]`` format. Meta payloads are useful reference material, +but must be labelled and converted before they are sent to Sent. """ from __future__ import annotations @@ -35,13 +12,33 @@ import json import re import sys +from collections import Counter from pathlib import Path from typing import Any -VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} -LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") -PLACEHOLDER_RE = re.compile(r"\{\{(\d+)\}\}") +TOP_LEVEL_FIELDS = { + "category", + "language", + "definition", + "creation_source", + "submit_for_review", + "sandbox", +} +CREATE_UNSUPPORTED_FIELDS = {"name", "channels", "body", "header", "buttons", "components"} +VALID_CATEGORIES = {"UTILITY", "MARKETING", "AUTHENTICATION"} +VALID_BODY_CHANNELS = {"multiChannel", "sms", "whatsapp", "rcs"} +VALID_BUTTON_TYPES = {"QUICK_REPLY", "URL", "VOICE_CALL", "PHONE_NUMBER", "COPY_CODE"} +BUTTON_LIMITS = { + "QUICK_REPLY": 10, + "URL": 2, + "VOICE_CALL": 1, + "PHONE_NUMBER": 1, + "COPY_CODE": 1, +} +LANGUAGE_RE = re.compile(r"^[a-z]{2}(?:_[A-Z]{2})?$") +PLACEHOLDER_RE = re.compile(r"\{\{(\d+):(variable|link|media)\}\}") +ANY_PLACEHOLDER_RE = re.compile(r"\{\{[^{}]+\}\}") PROMO_WARN_PHRASES = ( "buy now", "limited time", @@ -52,13 +49,6 @@ ) PROMO_FAIL_PHRASES = ("click here to purchase",) -CTA_BUTTON_TYPES = {"URL", "PHONE_NUMBER", "OTP"} -QUICK_REPLY_TYPE = "QUICK_REPLY" - -# Sent CreateTemplateRequest enums (snapshot Template Models section). -SENT_HEADER_TYPES = {"TEXT", "IMAGE", "VIDEO", "DOCUMENT"} -SENT_BUTTON_TYPES = {"QUICK_REPLY", "URL", "PHONE_NUMBER"} - class LintResult: def __init__(self) -> None: @@ -76,243 +66,218 @@ def failed(self) -> bool: return bool(self.errors) -def _components_by_type(components: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: - grouped: dict[str, list[dict[str, Any]]] = {} - for c in components: - t = c.get("type") - if not isinstance(t, str): - continue - grouped.setdefault(t.upper(), []).append(c) - return grouped - - -def _check_top_level(payload: dict[str, Any], result: LintResult) -> None: - for key in ("name", "language", "category", "components"): - if key not in payload: - result.error(key, f"missing required top-level key '{key}'") - - category = payload.get("category") - if isinstance(category, str) and category not in VALID_CATEGORIES: - result.error( - "category", - f"category '{category}' must be one of {sorted(VALID_CATEGORIES)}", - ) - - language = payload.get("language") - if isinstance(language, str) and not LANGUAGE_RE.match(language): - result.error( - "language", - f"language '{language}' must match BCP-47 form (e.g. 'en' or 'en_US')", - ) - - components = payload.get("components") - if components is not None and not isinstance(components, list): - result.error("components", "components must be a list") - - -def _check_body(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - grouped = _components_by_type(components) - category = payload.get("category") - body_list = grouped.get("BODY", []) - - if category == "AUTHENTICATION": - # Authentication body uses managed content (e.g. add_security_recommendation); - # freeform text is not required and placeholder checks do not apply. - return - - if not body_list: - result.error("components", "exactly one BODY component is required") +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _reject_unknown_fields( + value: dict[str, Any], allowed: set[str], field: str, result: LintResult +) -> None: + for key in sorted(set(value) - allowed): + result.error(f"{field}.{key}" if field else key, "field is not part of the Sent v3 request contract") + + +def _check_variable( + variable: Any, + field: str, + expected_kind: str | None, + result: LintResult, +) -> int | None: + if not isinstance(variable, dict): + result.error(field, "variable must be an object") + return None + for key in ("id", "name", "type", "props"): + if key not in variable: + result.error(f"{field}.{key}", "missing required variable field") + variable_id = variable.get("id") + if not isinstance(variable_id, int) or variable_id < 0: + result.error(f"{field}.id", "must be a non-negative integer") + variable_id = None + if not _nonempty(variable.get("name")): + result.error(f"{field}.name", "must be a non-empty string") + kind = variable.get("type") + if kind not in {"variable", "link", "media"}: + result.error(f"{field}.type", "must be variable, link, or media") + elif expected_kind is not None and kind != expected_kind: + result.error(f"{field}.type", f"placeholder declares {expected_kind!r}, but variable declares {kind!r}") + props = variable.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + elif not _nonempty(props.get("sample")): + result.error(f"{field}.props.sample", "must be a non-empty review and preview sample") + return variable_id + + +def _check_content(content: Any, field: str, result: LintResult) -> None: + if not isinstance(content, dict): + result.error(field, "must be an object") return - if len(body_list) > 1: - result.error("components", "more than one BODY component is not allowed") - - body = body_list[0] - text = body.get("text") - if not isinstance(text, str) or not text.strip(): - result.error("components[BODY].text", "BODY text must be a non-empty string") + _reject_unknown_fields(content, {"type", "template", "variables"}, field, result) + template = content.get("template") + if not _nonempty(template): + result.error(f"{field}.template", "must be a non-empty string") return + if len(template) > 1024: + result.error(f"{field}.template", f"body exceeds the 1,024-character limit ({len(template)})") - placeholders = [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text)] - if placeholders: - unique_sorted = sorted(set(placeholders)) - expected = list(range(1, len(unique_sorted) + 1)) - if unique_sorted != expected: - result.error( - "components[BODY].text", - f"placeholders must be sequential starting at 1, got {unique_sorted}", - ) - first_occurrence = [] - seen: set[int] = set() - for n in placeholders: - if n not in seen: - first_occurrence.append(n) - seen.add(n) - if first_occurrence != sorted(first_occurrence): - result.error( - "components[BODY].text", - f"placeholders must first appear in numeric order, got {first_occurrence}", - ) - - example = body.get("example", {}) - body_text = example.get("body_text") if isinstance(example, dict) else None - placeholder_count = len(set(placeholders)) - - if placeholder_count == 0: - if body_text: - result.warn( - "components[BODY].example.body_text", - "body has no placeholders but example.body_text is present", - ) - return - - if not isinstance(body_text, list) or not body_text: - result.error( - "components[BODY].example.body_text", - "example.body_text must be a non-empty list of sample rows", - ) - return - first_row = body_text[0] - if not isinstance(first_row, list): - result.error( - "components[BODY].example.body_text", - "example.body_text must be array-of-arrays (one row per variable group)", - ) - return - if len(first_row) != placeholder_count: + placeholders = [(int(match.group(1)), match.group(2)) for match in PLACEHOLDER_RE.finditer(template)] + malformed = [match.group(0) for match in ANY_PLACEHOLDER_RE.finditer(template) if not PLACEHOLDER_RE.fullmatch(match.group(0))] + if malformed: result.error( - "components[BODY].example.body_text", - f"sample count {len(first_row)} does not match placeholder count {placeholder_count}", + f"{field}.template", + "use Sent placeholders such as '{{0:variable}}'; malformed: " + ", ".join(malformed), ) - -def _check_utility_promo(payload: dict[str, Any], result: LintResult) -> None: - if payload.get("category") != "UTILITY": - return - components = payload.get("components") - if not isinstance(components, list): - return - body_list = _components_by_type(components).get("BODY", []) - if not body_list: + variables = content.get("variables", []) + if variables is None: + variables = [] + if not isinstance(variables, list): + result.error(f"{field}.variables", "must be an array") return - text = body_list[0].get("text") - if not isinstance(text, str): - return - lowered = text.lower() - for phrase in PROMO_FAIL_PHRASES: - if phrase in lowered: - result.error( - "components[BODY].text", - f"UTILITY body contains banned promotional phrase '{phrase}'", - ) - for phrase in PROMO_WARN_PHRASES: - if phrase in lowered: - result.warn( - "components[BODY].text", - f"UTILITY body contains promotional phrase '{phrase}' — Meta is likely to re-categorize as MARKETING", - ) - -def _check_header(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): - return - headers = _components_by_type(components).get("HEADER", []) - if not headers: - return - header = headers[0] - # In the Cloud API shape used by these fixtures, HEADER type discriminator - # is `format`; in Sent's shape it would be `type`. Accept either. - fmt = header.get("format") - if fmt is None: - fmt = header.get("type") - if not isinstance(fmt, str): - result.error( - "components[HEADER].format", - "HEADER component requires a 'format' (TEXT/IMAGE/VIDEO/DOCUMENT)", + expected = {variable_id: kind for variable_id, kind in placeholders} + if len(expected) != len({variable_id for variable_id, _ in placeholders}): + result.error(f"{field}.template", "one placeholder id cannot be reused with different types") + actual_ids: list[int] = [] + for index, variable in enumerate(variables): + variable_id = variable.get("id") if isinstance(variable, dict) else None + checked_id = _check_variable( + variable, + f"{field}.variables[{index}]", + expected.get(variable_id) if isinstance(variable_id, int) else None, + result, ) + if checked_id is not None: + actual_ids.append(checked_id) + duplicates = [str(key) for key, count in Counter(actual_ids).items() if count > 1] + if duplicates: + result.error(f"{field}.variables", "duplicate variable ids: " + ", ".join(duplicates)) + missing = sorted(set(expected) - set(actual_ids)) + extra = sorted(set(actual_ids) - set(expected)) + if missing: + result.error(f"{field}.variables", f"missing definitions for placeholder ids {missing}") + if extra: + result.error(f"{field}.variables", f"variables without matching placeholders: {extra}") + + +def _check_header_or_footer(value: Any, field: str, limit: int, result: LintResult) -> None: + if value is None: return - if fmt.upper() not in SENT_HEADER_TYPES: - result.error( - "components[HEADER].format", - f"header format '{fmt}' must be one of {sorted(SENT_HEADER_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - - -def _check_buttons(payload: dict[str, Any], result: LintResult) -> None: - components = payload.get("components") - if not isinstance(components, list): + if not isinstance(value, dict): + result.error(field, "must be an object or null") return - button_components = _components_by_type(components).get("BUTTONS", []) - if not button_components: + _reject_unknown_fields(value, {"type", "template", "variables"}, field, result) + template = value.get("template") + if not isinstance(template, str): + result.error(f"{field}.template", "must be a string") return - buttons = button_components[0].get("buttons") - if not isinstance(buttons, list) or not buttons: - result.error("components[BUTTONS].buttons", "buttons list must be non-empty") + if len(template) > limit: + result.error(f"{field}.template", f"exceeds the {limit}-character limit") + if field.endswith("footer") and (ANY_PLACEHOLDER_RE.search(template) or value.get("variables")): + result.error(field, "footer variables are not supported") + elif field.endswith("header"): + _check_content({"template": template, "variables": value.get("variables", [])}, field, result) + + +def _check_button(button: Any, index: int, result: LintResult) -> str | None: + field = f"definition.buttons[{index}]" + if not isinstance(button, dict): + result.error(field, "button must be an object") + return None + _reject_unknown_fields(button, {"id", "type", "props"}, field, result) + button_type = button.get("type") + if button_type not in VALID_BUTTON_TYPES: + result.error(f"{field}.type", f"must be one of {sorted(VALID_BUTTON_TYPES)}") + return None + props = button.get("props") + if not isinstance(props, dict): + result.error(f"{field}.props", "must be an object") + return button_type + text = props.get("text") + if not _nonempty(text) or len(text) > 25: + result.error(f"{field}.props.text", "must be 1–25 characters") + if button_type == "QUICK_REPLY" and not _nonempty(props.get("quickReplyType")): + result.error(f"{field}.props.quickReplyType", "is required for QUICK_REPLY") + elif button_type == "URL": + if not _nonempty(props.get("urlType")): + result.error(f"{field}.props.urlType", "is required for URL") + if not _nonempty(props.get("url")): + result.error(f"{field}.props.url", "is required for URL") + elif button_type in {"VOICE_CALL", "PHONE_NUMBER"}: + if not _nonempty(props.get("countryCode")): + result.error(f"{field}.props.countryCode", f"is required for {button_type}") + if not _nonempty(props.get("phoneNumber")): + result.error(f"{field}.props.phoneNumber", f"is required for {button_type}") + elif button_type == "COPY_CODE" and not _nonempty(props.get("offerCode")): + result.error(f"{field}.props.offerCode", "is required for COPY_CODE") + return button_type + + +def _check_definition(payload: dict[str, Any], result: LintResult) -> None: + definition = payload.get("definition") + if not isinstance(definition, dict): + result.error("definition", "required and must be an object") return - types = [] - for i, b in enumerate(buttons): - t = b.get("type") - if not isinstance(t, str): - result.error(f"components[BUTTONS].buttons[{i}].type", "button type missing") - continue - upper = t.upper() - types.append(upper) - - # Sent's button type enum is QUICK_REPLY|URL|PHONE_NUMBER. Cloud-API OTP - # buttons (auth-only) are surfaced as a warning rather than a hard fail - # because Cloud-API-shaped fixtures use them; they are not part of the - # Sent CreateTemplateRequest button enum. - if upper not in SENT_BUTTON_TYPES and upper != "OTP": - result.error( - f"components[BUTTONS].buttons[{i}].type", - f"button type '{t}' must be one of {sorted(SENT_BUTTON_TYPES)} " - "(Sent CreateTemplateRequest enum)", - ) - continue - if upper == "OTP": - result.warn( - f"components[BUTTONS].buttons[{i}].type", - "'OTP' is a Cloud-API auth-only button type; not part of Sent's " - "button enum (QUICK_REPLY/URL/PHONE_NUMBER)", + _reject_unknown_fields( + definition, + {"header", "body", "footer", "buttons", "definitionVersion", "authenticationConfig"}, + "definition", + result, + ) + body = definition.get("body") + if not isinstance(body, dict): + result.error("definition.body", "required and must be an object") + else: + _reject_unknown_fields(body, VALID_BODY_CHANNELS, "definition.body", result) + if body.get("multiChannel") is None: + result.error("definition.body.multiChannel", "is required as the channel-neutral body") + for channel, content in body.items(): + if channel in VALID_BODY_CHANNELS and content is not None: + _check_content(content, f"definition.body.{channel}", result) + + _check_header_or_footer(definition.get("header"), "definition.header", 60, result) + _check_header_or_footer(definition.get("footer"), "definition.footer", 60, result) + + buttons = definition.get("buttons", []) + if buttons is None: + buttons = [] + if not isinstance(buttons, list): + result.error("definition.buttons", "must be an array or null") + buttons = [] + elif len(buttons) > 10: + result.error("definition.buttons", f"at most 10 buttons are allowed, got {len(buttons)}") + counts = Counter(filter(None, (_check_button(button, index, result) for index, button in enumerate(buttons)))) + for button_type, limit in BUTTON_LIMITS.items(): + if counts[button_type] > limit: + result.error("definition.buttons", f"{button_type} allows at most {limit}, got {counts[button_type]}") + + authentication = definition.get("authenticationConfig") + category = payload.get("category") + if authentication is not None: + if category != "AUTHENTICATION": + result.error("definition.authenticationConfig", "is only valid for AUTHENTICATION templates") + if not isinstance(authentication, dict): + result.error("definition.authenticationConfig", "must be an object or null") + else: + _reject_unknown_fields( + authentication, + {"addSecurityRecommendation", "codeExpirationMinutes"}, + "definition.authenticationConfig", + result, ) - - if upper == "URL": - url = b.get("url") - if not isinstance(url, str) or not url.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].url", - "URL buttons require a non-empty 'url' field", - ) - if upper == "PHONE_NUMBER": - phone = b.get("phone_number") - if not isinstance(phone, str) or not phone.strip(): - result.error( - f"components[BUTTONS].buttons[{i}].phone_number", - "PHONE_NUMBER buttons require a non-empty 'phone_number' field", - ) - - quick = sum(1 for t in types if t == QUICK_REPLY_TYPE) - cta = sum(1 for t in types if t in CTA_BUTTON_TYPES) - if quick and cta: - result.error( - "components[BUTTONS].buttons", - "cannot mix QUICK_REPLY and CTA buttons in the same template", - ) - return - if quick > 3: - result.error( - "components[BUTTONS].buttons", - f"at most 3 QUICK_REPLY buttons allowed, got {quick}", - ) - if cta > 2: - result.error( - "components[BUTTONS].buttons", - f"at most 2 CTA buttons allowed, got {cta}", - ) + recommendation = authentication.get("addSecurityRecommendation") + if recommendation is not None and not isinstance(recommendation, bool): + result.error("definition.authenticationConfig.addSecurityRecommendation", "must be boolean") + expiration = authentication.get("codeExpirationMinutes") + if expiration is not None and (not isinstance(expiration, int) or not 1 <= expiration <= 90): + result.error("definition.authenticationConfig.codeExpirationMinutes", "must be an integer from 1 to 90") + if category == "AUTHENTICATION": + if authentication is None: + result.error("definition.authenticationConfig", "is required for AUTHENTICATION templates") + if any(button_type != "COPY_CODE" for button_type in counts): + result.error("definition.buttons", "AUTHENTICATION templates may only use COPY_CODE buttons") + if counts["COPY_CODE"] != 1: + result.error("definition.buttons", "AUTHENTICATION templates require exactly one COPY_CODE button") def lint_template(payload: Any) -> LintResult: @@ -320,52 +285,63 @@ def lint_template(payload: Any) -> LintResult: if not isinstance(payload, dict): result.error("", "template payload must be a JSON object") return result - _check_top_level(payload, result) - _check_body(payload, result) - _check_utility_promo(payload, result) - _check_header(payload, result) - _check_buttons(payload, result) + if "components" in payload: + result.error( + "components", + "Meta Cloud API components[] is not a Sent payload; convert it to definition before POST /v3/templates", + ) + for field in sorted(CREATE_UNSUPPORTED_FIELDS & set(payload)): + result.error(field, "unsupported top-level create field") + _reject_unknown_fields(payload, TOP_LEVEL_FIELDS, "", result) + category = payload.get("category") + if category is not None and category not in VALID_CATEGORIES: + result.error("category", f"must be one of {sorted(VALID_CATEGORIES)} or null") + language = payload.get("language") + if language is not None and (not isinstance(language, str) or not LANGUAGE_RE.fullmatch(language)): + result.error("language", "must look like en or en_US") + for field in ("submit_for_review", "sandbox"): + if field in payload and not isinstance(payload[field], bool): + result.error(field, "must be boolean") + _check_definition(payload, result) + + if category == "UTILITY": + body = payload.get("definition", {}).get("body", {}).get("multiChannel", {}) + text = body.get("template", "") if isinstance(body, dict) else "" + lowered = text.lower() + for phrase in PROMO_FAIL_PHRASES: + if phrase in lowered: + result.error("definition.body.multiChannel.template", f"UTILITY body contains promotional phrase {phrase!r}") + for phrase in PROMO_WARN_PHRASES: + if phrase in lowered: + result.warn("definition.body.multiChannel.template", f"Meta may reclassify promotional phrase {phrase!r} as MARKETING") return result def _format(prefix: str, entries: list[tuple[str, str]]) -> str: - return "\n".join(f"{prefix} {field}: {msg}" for field, msg in entries) + return "\n".join(f"{prefix} {field}: {message}" for field, message in entries) -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser( - prog="lint_waba_template.py", - description="Lint a Sent WhatsApp template payload against Meta's structural and category rules.", - ) - parser.add_argument( - "path", - type=Path, - help="Path to a JSON file containing a WhatsApp template payload.", - ) +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path, help="Sent POST /v3/templates request JSON") args = parser.parse_args(argv) - try: - raw = args.path.read_text(encoding="utf-8") + payload = json.loads(args.path.read_text(encoding="utf-8")) except OSError as exc: print(f"could not read {args.path}: {exc}", file=sys.stderr) return 2 - try: - payload = json.loads(raw) except json.JSONDecodeError as exc: print(f"invalid JSON in {args.path}: {exc}", file=sys.stderr) return 2 - result = lint_template(payload) - if result.warnings: print(_format("WARN", result.warnings)) if result.errors: print(_format("FAIL", result.errors)) return 1 - print("OK") return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + raise SystemExit(main())