diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9cc4636..c66e6a0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ "url": "https://sent.dm" }, "metadata": { - "description": "Official Sent skills and remote MCP integration for messaging operations and specialist channel workflows.", + "description": "Official Sent skills and remote MCP integration for messaging engineering, operations, migration, and specialist channel workflows.", "version": "0.1.0" }, "plugins": [ { "name": "sent", "source": "./claude-plugins/sent", - "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for SMS, WhatsApp, RCS, API integration, webhooks, routing, two-way messaging, Sender Profiles, migration, compliance, analytics, and agent-safe operations.", "category": "Productivity" } ] diff --git a/README.md b/README.md index cef57cd..2ac5785 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,17 @@ The repository root is directly discoverable as an Agent Plugins 1.0.0 package. | `rcs-agent-onboarding` | Prepare an RCS Business Messaging agent, carrier-approval evidence, capabilities, SMS fallback, and launch checks | “onboard an RBM agent”, “RCS carrier approval”, “design SMS fallback” | [`skills/rcs-agent-onboarding/SKILL.md`](skills/rcs-agent-onboarding/SKILL.md) | | `sender-profile-architect` | Design multi-tenant and multi-brand Sender Profile boundaries, webhook routing, lifecycle, and channel ownership | “model 50 messaging tenants”, “profile per brand?”, “route webhooks by tenant” | [`skills/sender-profile-architect/SKILL.md`](skills/sender-profile-architect/SKILL.md) | | `template-builder-ui` | Design or audit a tenant-facing SMS, WhatsApp, and RCS template editor and submission workflow | “build a WhatsApp template editor”, “design template validation”, “handle approval states” | [`skills/template-builder-ui/SKILL.md`](skills/template-builder-ui/SKILL.md) | +| `sent-integration-starter` | Stand up and harden a Sent v3 integration: SDK choice, idempotent sends, retry policy, error families, launch checklist | “add Sent to our app”, “what retry policy?”, “are we production ready?” | [`skills/sent-integration-starter/SKILL.md`](skills/sent-integration-starter/SKILL.md) | +| `sent-webhook-engineer` | Build and debug webhook receivers: signature verification, replay window, dedupe, retries, auto-disable recovery | “401 on every webhook”, “verify the signature header”, “our endpoint went inactive” | [`skills/sent-webhook-engineer/SKILL.md`](skills/sent-webhook-engineer/SKILL.md) | +| `sent-routing-strategist` | Choose channels and diagnose routes: broadcast versus automatic routing, reroute behavior, and delivery outcomes | “RCS then SMS fallback?”, “why is channel auto?”, “recipients got two messages” | [`skills/sent-routing-strategist/SKILL.md`](skills/sent-routing-strategist/SKILL.md) | +| `sent-two-way-messaging` | Design inbound flows: keyword consent, opt-out state, the WhatsApp 24-hour window, RCS STOP chips, conversation history | “do I handle STOP myself?”, “auto-reply stopped working”, “page conversation history” | [`skills/sent-two-way-messaging/SKILL.md`](skills/sent-two-way-messaging/SKILL.md) | +| `sent-profile-provisioning` | Execute the Sender Profile lifecycle: create, inheritance, completion callback, campaigns, users and roles | “create a profile via the API”, “completion callback never arrived”, “invite a developer” | [`skills/sent-profile-provisioning/SKILL.md`](skills/sent-profile-provisioning/SKILL.md) | +| `migrate-to-sent` | Migrate from Twilio, Sinch, Infobip, Vonage, or Bird: concept mapping, dual-run, staged cutover, rollback | “moving off Sinch”, “Vonage failover equivalent”, “dual-run comparison metrics” | [`skills/migrate-to-sent/SKILL.md`](skills/migrate-to-sent/SKILL.md) | Use `sent-analytics` for aggregate dashboard totals and trends. Use `messaging-performance-analyzer` for message-level evidence, funnel drop-off, and root-cause analysis. Use `sent-templates` for existing records, `waba-template-author` for WhatsApp content and policy decisions, and `template-builder-ui` for product UX. +For engineering work, use `sent-integration-starter` to stand up and harden a new integration, `sent-webhook-engineer` for receiver construction and delivery debugging, `sent-routing-strategist` for channel selection and route diagnosis, `sent-two-way-messaging` for inbound and consent flows, `sent-profile-provisioning` to execute the profile lifecycle that `sender-profile-architect` designs, and `migrate-to-sent` when replacing an incumbent provider. + ## Live MCP capabilities The plugin declares the Streamable HTTP endpoint `https://mcp.sent.dm/mcp` and exposes these operations through the authorized connection: diff --git a/adapter-sources/shared/README.md b/adapter-sources/shared/README.md index dbe2e6a..46f69ad 100644 --- a/adapter-sources/shared/README.md +++ b/adapter-sources/shared/README.md @@ -19,6 +19,12 @@ This generated host adapter packages the official Sent Agent Skills and remote M | `rcs-agent-onboarding` | Prepare an RBM agent for approval and launch | [`skills/rcs-agent-onboarding/SKILL.md`](skills/rcs-agent-onboarding/SKILL.md) | | `sender-profile-architect` | Design multi-tenant Sender Profile architecture | [`skills/sender-profile-architect/SKILL.md`](skills/sender-profile-architect/SKILL.md) | | `template-builder-ui` | Design cross-channel template-builder UX | [`skills/template-builder-ui/SKILL.md`](skills/template-builder-ui/SKILL.md) | +| `sent-integration-starter` | Stand up and harden a Sent v3 integration | [`skills/sent-integration-starter/SKILL.md`](skills/sent-integration-starter/SKILL.md) | +| `sent-webhook-engineer` | Build and debug verified webhook receivers | [`skills/sent-webhook-engineer/SKILL.md`](skills/sent-webhook-engineer/SKILL.md) | +| `sent-routing-strategist` | Choose channels and diagnose route outcomes | [`skills/sent-routing-strategist/SKILL.md`](skills/sent-routing-strategist/SKILL.md) | +| `sent-two-way-messaging` | Design inbound, consent, and conversational flows | [`skills/sent-two-way-messaging/SKILL.md`](skills/sent-two-way-messaging/SKILL.md) | +| `sent-profile-provisioning` | Execute the Sender Profile and user lifecycle | [`skills/sent-profile-provisioning/SKILL.md`](skills/sent-profile-provisioning/SKILL.md) | +| `migrate-to-sent` | Migrate from another CPaaS provider onto Sent | [`skills/migrate-to-sent/SKILL.md`](skills/migrate-to-sent/SKILL.md) | To install the skills without the host adapter, list or select them with the Skills CLI: diff --git a/adapter-sources/shared/marketplace.json b/adapter-sources/shared/marketplace.json index 589193e..a6a3838 100644 --- a/adapter-sources/shared/marketplace.json +++ b/adapter-sources/shared/marketplace.json @@ -1,7 +1,7 @@ { "display_name": "Sent", "short_description": "Business messaging workflows", - "long_description": "Use Sent MCP operations for safe account, contact, template, analytics, and messaging workflows. Focused specialist skills add delivery diagnosis, Sender Profile architecture, US A2P 10DLC registration, WhatsApp Business onboarding and template authoring, RCS agent launch readiness, and cross-channel template-builder design.", + "long_description": "Use Sent MCP operations for safe account, contact, template, analytics, and messaging workflows. Focused specialist skills add API integration, webhook engineering, contact-aware routing, two-way messaging, Sender Profile architecture and provisioning, CPaaS migration, delivery diagnosis, US A2P 10DLC, WhatsApp Business, RCS launch readiness, and cross-channel template design.", "developer_name": "Sent", "category": "Productivity", "capabilities": ["Interactive", "Write"], @@ -9,9 +9,9 @@ "privacy_policy_url": "https://www.sent.dm/en/legal/privacy-policy", "terms_of_service_url": "https://www.sent.dm/en/legal/terms-of-service", "default_prompts": [ - "Use $sent-account-readiness to check whether my Sent account is messaging-ready.", - "Use $messaging-performance-analyzer to diagnose this delivery report by channel.", - "Use $sms-10dlc-registration to review my US A2P registration evidence." + "Use $sent-integration-starter to review how this application should integrate Sent safely.", + "Use $sent-routing-strategist to explain the safest channel and reroute policy for this workflow.", + "Use $migrate-to-sent to plan a staged migration from our current messaging provider." ], - "marketplace_description": "Official Sent skills and remote MCP integration for messaging operations and specialist channel workflows." + "marketplace_description": "Official Sent skills and remote MCP integration for messaging engineering, operations, migration, and specialist channel workflows." } diff --git a/claude-plugins/sent/.claude-plugin/plugin.json b/claude-plugins/sent/.claude-plugin/plugin.json index aaa17df..43007b4 100644 --- a/claude-plugins/sent/.claude-plugin/plugin.json +++ b/claude-plugins/sent/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for SMS, WhatsApp, RCS, API integration, webhooks, routing, two-way messaging, Sender Profiles, migration, compliance, analytics, and agent-safe operations.", "author": { "name": "Sent", "url": "https://sent.dm" @@ -27,6 +27,11 @@ "analytics", "deliverability", "sender-profiles", + "api-integration", + "webhooks", + "routing", + "two-way-messaging", + "migration", "mcp" ], "commands": "./.claude/commands", diff --git a/claude-plugins/sent/README.md b/claude-plugins/sent/README.md index dbe2e6a..46f69ad 100644 --- a/claude-plugins/sent/README.md +++ b/claude-plugins/sent/README.md @@ -19,6 +19,12 @@ This generated host adapter packages the official Sent Agent Skills and remote M | `rcs-agent-onboarding` | Prepare an RBM agent for approval and launch | [`skills/rcs-agent-onboarding/SKILL.md`](skills/rcs-agent-onboarding/SKILL.md) | | `sender-profile-architect` | Design multi-tenant Sender Profile architecture | [`skills/sender-profile-architect/SKILL.md`](skills/sender-profile-architect/SKILL.md) | | `template-builder-ui` | Design cross-channel template-builder UX | [`skills/template-builder-ui/SKILL.md`](skills/template-builder-ui/SKILL.md) | +| `sent-integration-starter` | Stand up and harden a Sent v3 integration | [`skills/sent-integration-starter/SKILL.md`](skills/sent-integration-starter/SKILL.md) | +| `sent-webhook-engineer` | Build and debug verified webhook receivers | [`skills/sent-webhook-engineer/SKILL.md`](skills/sent-webhook-engineer/SKILL.md) | +| `sent-routing-strategist` | Choose channels and diagnose route outcomes | [`skills/sent-routing-strategist/SKILL.md`](skills/sent-routing-strategist/SKILL.md) | +| `sent-two-way-messaging` | Design inbound, consent, and conversational flows | [`skills/sent-two-way-messaging/SKILL.md`](skills/sent-two-way-messaging/SKILL.md) | +| `sent-profile-provisioning` | Execute the Sender Profile and user lifecycle | [`skills/sent-profile-provisioning/SKILL.md`](skills/sent-profile-provisioning/SKILL.md) | +| `migrate-to-sent` | Migrate from another CPaaS provider onto Sent | [`skills/migrate-to-sent/SKILL.md`](skills/migrate-to-sent/SKILL.md) | To install the skills without the host adapter, list or select them with the Skills CLI: diff --git a/claude-plugins/sent/skills/migrate-to-sent/SKILL.md b/claude-plugins/sent/skills/migrate-to-sent/SKILL.md new file mode 100644 index 0000000..de79092 --- /dev/null +++ b/claude-plugins/sent/skills/migrate-to-sent/SKILL.md @@ -0,0 +1,95 @@ +--- +name: migrate-to-sent +description: Plans and executes a migration from Twilio, Sinch, Infobip, Vonage, or MessageBird/Bird to Sent v3 — mapping send calls, status vocabularies, webhook signature schemes, opt-out stores, templates, and tenancy models, then cutting over safely with dual-run and rollback. Use when replacing an incumbent CPaaS provider, translating provider code or webhook handlers to Sent, or planning a phased cutover and its verification gates. +--- + +# Migrate to Sent + +Every migration from a major CPaaS provider hits the same five translation problems. Work them in this order, because the first one silently doubles cost and is invisible in tests. + +## 1. Ordered fallback becomes automatic routing + +Incumbent platforms express cross-channel delivery through different caller-side arrays, failover objects, messaging-service features, or application-level priority configuration. Do not assume those shapes have a direct Sent request-field equivalent. + +**Sent's `channel` array is a broadcast list.** Porting an ordered array produces one message and one charge per recipient-channel pair, which passes tests and multiplies production spend. The correct translation is automatic routing — omit `channel` or send `["sent"]` — which lets the platform select a route and reroute across up to three channel-and-provider pairs on the same `message_id`. Details belong to `sent-routing-strategist`; the migration rule is simply: **never port an ordered channel list.** + +## 2. Status vocabularies do not line up + +Incumbent statuses map onto Sent's, but Sent adds two states that have no equivalent and that break naive retry logic. + +| Sent status | Closest incumbent analogue | Migration note | +| --- | --- | --- | +| `QUEUED` | Twilio `queued`, Sinch `QUEUED_ON_CHANNEL` | Accepted, not sent | +| `ROUTED` | no analogue | Route chosen; fires again on reroute | +| `SENT` | Twilio `sent`, Sinch `MESSAGE_SUBMIT` | Provider handoff only | +| `DELIVERED` | `delivered` everywhere | The first proof of handset receipt | +| `READ` | Twilio `read`, Sinch `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | May still reroute; not necessarily final | +| `FILTERED` | Twilio error 21610 (opt-out) | **Policy gate. Never retry** | +| `BLOCKED` | account-level errors | **Account precondition.** Fix the account, then resend | +| `SCHEDULED` | no analogue | Quiet-hours parking; resumes automatically | + +Two consequences for ported code. Handlers that treat every non-delivered terminal state as retryable will retry consent blocks, which is a compliance failure rather than a bug. And handlers keyed on numeric provider error codes — Twilio's `21610` is the classic — must be rewritten against Sent's string `error.code` families. + +## 3. Webhook verification is a rewrite, not a port + +No two providers sign the same way, and no Sent SDK ships a verifier. + +| Provider | Scheme | +| --- | --- | +| Twilio | `X-Twilio-Signature`, base64 HMAC-**SHA1** over the full URL plus sorted POST parameters | +| Sinch | HMAC-SHA256 over `body.nonce.timestamp`, four `x-sinch-webhook-signature*` headers, or OAuth 2.0 | +| Infobip | Basic, HMAC-SHA256 over the raw body, or OAuth on a notification profile; **the header name is account-configured** | +| Vonage | JWT in `Authorization: Bearer`, or a legacy `sig` parameter | +| MessageBird/Bird | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash | +| **Sent** | `x-webhook-signature: v1,{base64}`, HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}` | + +Sent's key is the signing secret with `whsec_` stripped and the remainder base64-decoded, compared in constant time, with timestamps outside 300 seconds rejected. Because Sent provides no per-event id, dedupe keys must be derived from payload semantics. Build the receiver with `sent-webhook-engineer` rather than adapting the incumbent's verifier. + +## 4. Opt-out stores must be reconciled, not migrated by copy + +Every provider keeps its own suppression list — Twilio Advanced Opt-Out, Infobip Blocklist, Sinch OPT_IN/OPT_OUT events. Sent enforces consent at the platform level before events reach the application, stores it as `opt_out` on the contact, and applies it **channel-agnostically**: a `STOP` on SMS suppresses WhatsApp and RCS too. + +Reconciliation rules: export the incumbent's suppression list before cutover, treat any opt-out on any incumbent channel as a global Sent opt-out, and never clear `opt_out` to "clean up" migrated data. Sent's ten default keywords are `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END`, `START`, `UNSTOP`, `SUBSCRIBE`, `HELP`, `INFO`, matched only when the entire trimmed body equals the keyword — so incumbent-specific keywords need custom keyword entries. Rewrite any incumbent keyword matcher as an exact local consent mirror and audit mechanism; the matcher must not write consent to Sent again. Consent semantics belong to `sent-two-way-messaging`. + +## 5. Templates and tenancy are re-registered, not transferred + +WhatsApp templates live with the WABA, so the migration question is whether the WABA moves. Positional placeholders (`{{1}}`, `{{2}}`) become **named** parameters in Sent, which means every call site that passed an ordered array must pass a named map. Approval is asynchronous and arrives as a `templates` webhook event, so build the template inventory before cutover rather than during it. + +Tenancy maps as follows, with the boundary decision owned by `sender-profile-architect` and the API work by `sent-profile-provisioning`: + +| Incumbent construct | Sent equivalent | +| --- | --- | +| Twilio subaccount | Sender Profile | +| Twilio Messaging Service | routing plus profile configuration, not a caller-side pool | +| Infobip Application or Entity | Sender Profile | +| Sinch Conversation API app | Sender Profile | +| Provider API credential per tenant | Profile-scoped API key, or organization key with `x-profile-id` | + +## Migration sequence + +1. **Inventory** every send call site, webhook handler, status branch, template, suppression list, and credential. Use `scripts/inventory_scan.py` to find them mechanically. +2. **Map** each item using [references/provider-mapping.md](references/provider-mapping.md), flagging ordered-fallback arrays and numeric error codes as required rewrites. +3. **Stand up Sent in parallel**: credentials, one webhook per environment, verified receiver, templates re-registered and approved. +4. **Prove equivalence in sandbox** with `"sandbox": true`, then with a small live cohort confirmed to `DELIVERED`. +5. **Dual-run** with a traffic split, comparing delivery rates, latency, and cost per message on the same message classes. +6. **Cut over** by message class — lowest-risk transactional first, marketing last — keeping the incumbent receiver live. +7. **Decommission** only after a full billing cycle of clean data, then revoke incumbent credentials. + +Sequencing detail, verification gates, and rollback triggers are in [references/cutover-playbook.md](references/cutover-playbook.md). + +## Mistakes that survive testing + +- Porting an ordered channel array. Doubles cost, never errors. +- Treating `FILTERED` as retryable. Compliance exposure. +- Reusing the incumbent's signature verifier. Every delivery returns 401. +- Assuming `202` means delivered. Sent acknowledges acceptance only. +- Keeping positional template placeholders. Parameters silently mismatch. +- Retrying on `401`. Ten consecutive auth failures lock the credential with escalating lockout. +- Omitting `Idempotency-Key` during dual-run. A timeout retry sends twice. +- Sending `x-profile-id` with a profile-scoped key. Returns `403`. +- Copying an incumbent's `Authorization: Bearer` pattern. Sent authenticates with `x-api-key`. + +## Boundaries + +This skill owns provider mapping and line-by-line migration planning. Hand the resulting Sent client and resilience work to `sent-integration-starter`, channel semantics to `sent-routing-strategist`, receiver construction to `sent-webhook-engineer`, WhatsApp onboarding to `waba-embedded-signup`, and US campaign registration to `sms-10dlc-registration`. diff --git a/claude-plugins/sent/skills/migrate-to-sent/agents/openai.yaml b/claude-plugins/sent/skills/migrate-to-sent/agents/openai.yaml new file mode 100644 index 0000000..d068d06 --- /dev/null +++ b/claude-plugins/sent/skills/migrate-to-sent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate to Sent" + short_description: "Move from another CPaaS provider to Sent" + default_prompt: "Use $migrate-to-sent to plan our move from our current messaging provider to Sent." diff --git a/claude-plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md b/claude-plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md new file mode 100644 index 0000000..7908759 --- /dev/null +++ b/claude-plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md @@ -0,0 +1,151 @@ +# Cutover playbook + +## Table of contents + +- [Phase overview](#phase-overview) +- [Phase 1: inventory](#phase-1-inventory) +- [Phase 2: mapping and rewrite list](#phase-2-mapping-and-rewrite-list) +- [Phase 3: parallel build](#phase-3-parallel-build) +- [Phase 4: sandbox and cohort verification](#phase-4-sandbox-and-cohort-verification) +- [Phase 5: dual-run](#phase-5-dual-run) +- [Phase 6: staged cutover](#phase-6-staged-cutover) +- [Phase 7: decommission](#phase-7-decommission) +- [Rollback triggers](#rollback-triggers) +- [Comparison metrics](#comparison-metrics) +- [Data migration rules](#data-migration-rules) + +## Phase overview + +| Phase | Exit gate | +| --- | --- | +| 1. Inventory | Every send call site, handler, template, and credential is listed | +| 2. Mapping | Each item has a target and a rewrite classification | +| 3. Parallel build | Sent sends and receives in a lower environment | +| 4. Verification | Sandbox parity plus a live cohort reaching `DELIVERED` | +| 5. Dual-run | Delivery, latency, and cost within agreed tolerances | +| 6. Cutover | All message classes on Sent, incumbent idle but reversible | +| 7. Decommission | One clean billing cycle, then credentials revoked | + +Do not compress phases 4 and 5. The failure modes that matter — broadcast duplication, mishandled `FILTERED`, and dropped webhook events — appear under real traffic and correct cost accounting, not in tests. + +## Phase 1: inventory + +Enumerate mechanically rather than from memory: + +- send call sites, including background jobs, cron tasks, and admin tools; +- webhook handlers and their signature verification code; +- every branch on a provider status string or numeric error code; +- template inventory with variable style and approval state; +- suppression and opt-out storage; +- credentials per environment and per tenant; +- observability: dashboards, alerts, and log fields that reference provider identifiers. + +`scripts/inventory_scan.py` scans a repository for provider SDK imports, endpoint URLs, signature header names, status strings, numeric error codes, and ordered channel arrays, and reports each with a migration classification. Treat its output as the starting checklist, not the whole picture — configuration-driven senders and no-code automations will not appear in source. + +## Phase 2: mapping and rewrite list + +Classify every inventory item: + +| Classification | Meaning | Examples | +| --- | --- | --- | +| Direct map | Same concept, different syntax | Recipient, message body, delivery status branch | +| Rewrite | Concept exists but works differently | Fallback, templates, tenancy, consent checks | +| New code | No equivalent exists | Webhook signature verification, idempotency keys | +| Delete | The incumbent workaround is unnecessary | Provider-specific retry ladders that conflict with Sent's error contract | +| Review | Logic remains useful but changes responsibility | Keyword matchers become exact local consent mirrors and must not write consent to Sent again | + +The rewrite list is the real project plan. Prioritize by blast radius: ordered fallback arrays first, then status and error branching, then the receiver, then templates. + +## Phase 3: parallel build + +Stand Sent up beside the incumbent without removing anything: + +1. Create credentials per environment, and profile-scoped keys for runtime send paths. +2. Provision Sender Profiles for tenancy, with inheritance and sharing flags set deliberately. +3. Register one webhook per environment; never share an endpoint across environments, because auto-disable is per endpoint. +4. Build the receiver as new code with its own signature tests. +5. Re-register templates and wait for approval events; approval is asynchronous and gates go-live. +6. Add `Idempotency-Key` to every mutating call, derived from your own domain objects. +7. Introduce a feature flag or router that chooses provider per message class and tenant. + +## Phase 4: sandbox and cohort verification + +Sandbox first: `"sandbox": true` authenticates and validates without executing, so payload shape and credential wiring can be proven in continuous integration. Remember it does not perform resource lookups, so it cannot confirm a template id exists. + +Then a live cohort — internal staff or a small opt-in group. Gates: + +- a send returns `202` and every `message_id` is persisted with tenant, profile, and logical send id; +- events arrive, verify, and deduplicate; +- a message reaches `DELIVERED` and the application state reflects it; +- a deliberately induced failure produces the expected terminal state without a retry storm; +- a suppressed contact produces `FILTERED` and no retry; +- a template send renders correctly with named parameters; +- an inbound `STOP` updates the exact-match local consent mirror without writing consent to Sent again. + +## Phase 5: dual-run + +Split traffic by tenant or message class and compare like with like. Both providers must carry comparable message mixes, since transactional and marketing traffic have different delivery profiles. + +Requirements during dual-run: one application operation ledger that assigns each logical send to exactly one provider, Sent idempotency keys on every Sent mutation, equivalent incumbent safeguards where available, a single source of truth for delivery state per provider message id, and cost accounting that counts messages rather than requests. Never send the same user-facing notification through both providers merely to compare them. + +Run long enough to cover a weekly seasonality cycle and at least one deliberate failure drill. + +## Phase 6: staged cutover + +Order by risk, lowest first: + +1. internal and staff notifications; +2. low-volume transactional messages such as receipts; +3. high-volume transactional messages such as OTP and delivery alerts; +4. conversational and support traffic; +5. marketing and campaign traffic. + +Keep the incumbent receiver live throughout, because in-flight messages sent before the switch still deliver status callbacks there. Cut per message class and per tenant, and hold each step long enough to see a full daily cycle. + +## Phase 7: decommission + +Decommission only after one full billing cycle of clean Sent data. Then, in order: disable incumbent send paths in code, remove the router flag, revoke incumbent credentials, archive incumbent delivery and consent records for the applicable retention period, and remove incumbent-specific dashboards and alerts after confirming Sent equivalents exist. + +Retain the exported incumbent suppression list permanently as consent evidence, independent of the platform that now enforces it. + +## Rollback triggers + +Define these before dual-run so the decision is not made under pressure: + +| Trigger | Action | +| --- | --- | +| Delivery rate falls below the agreed tolerance for a message class | Roll that class back | +| Duplicate messages observed in production | Halt immediately; check for ordered channel arrays and missing idempotency keys | +| Webhook `consecutive_failures` rising toward auto-disable | Roll back the receiver, fix, re-enable in the dashboard | +| `FILTERED` volume above baseline | Halt; the local suppression mirror is likely stale | +| Cost per delivered message above the agreed ceiling | Halt; verify channel arrays and message counts | +| Auth lockout on a credential | Stop all retries; ten consecutive failures escalate the lockout | + +Rollback is a router flag flip per message class, which is why the router must remain in place until decommissioning. + +## Comparison metrics + +| Metric | Definition | Why it matters | +| --- | --- | --- | +| Delivery rate | `DELIVERED` divided by accepted, per message class | The primary quality signal | +| Filtered rate | `FILTERED` divided by accepted | Consent-mirror drift | +| Blocked rate | `BLOCKED` divided by accepted | Balance and account preconditions | +| Reroute rate | Messages with more than one attempted route | Routing health | +| Time to delivered | `DELIVERED` timestamp minus accepted timestamp | Latency comparison | +| Messages per logical send | Messages created divided by intended sends | Catches broadcast duplication | +| Cost per delivered message | Spend divided by `DELIVERED` count | The number finance will ask for | + +Messages per logical send should be exactly one for single-channel intent. Anything above one means a multi-channel array is in play. + +## Data migration rules + +| Data | Rule | +| --- | --- | +| Contacts | Re-create in Sent with E.164 numbers; contacts are validated endpoints, not a copied CRM table | +| Suppression list | Export before cutover; treat any incumbent opt-out as a global Sent opt-out | +| Templates | Re-register and await approval; convert positional variables to named parameters | +| Message history | Leave in the incumbent, archived; do not attempt to import history | +| Credentials | Create fresh in Sent; never reuse incumbent secrets | +| Webhook secrets | New per environment; store keyed by webhook id | + +Never clear `opt_out` during data migration. A migrated opt-out is consent evidence, and clearing it to reconcile row counts is a compliance failure rather than a data cleanup. diff --git a/claude-plugins/sent/skills/migrate-to-sent/references/provider-mapping.md b/claude-plugins/sent/skills/migrate-to-sent/references/provider-mapping.md new file mode 100644 index 0000000..1d9a65a --- /dev/null +++ b/claude-plugins/sent/skills/migrate-to-sent/references/provider-mapping.md @@ -0,0 +1,158 @@ +# Provider-to-Sent mapping reference + +## Table of contents + +- [How to use this reference](#how-to-use-this-reference) +- [Sent target contract](#sent-target-contract) +- [Twilio](#twilio) +- [Sinch](#sinch) +- [Infobip](#infobip) +- [Vonage](#vonage) +- [MessageBird and Bird](#messagebird-and-bird) +- [Cross-provider concept table](#cross-provider-concept-table) +- [Status mapping](#status-mapping) +- [Webhook signature comparison](#webhook-signature-comparison) +- [Error handling translation](#error-handling-translation) + +## How to use this reference + +Read the Sent target contract first, then only the section for the incumbent. Each provider section lists the send call, the fallback construct, the webhook scheme, the suppression store, the tenancy construct, and the specific rewrites that testing will not catch. + +Verify any header name or field against the incumbent's current documentation before writing customer-facing text; several of these platforms have renamed products and headers. + +## Sent target contract + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345", "eta": "Friday" } + }, + "sandbox": false +} +``` + +`POST /v3/messages` returns `202` with `data.recipients[]`, each carrying a `message_id`. Only `to` is required; supply `template` or `text`; omit `channel` for automatic routing. Authentication is `x-api-key`, optionally with `x-profile-id` on an organization key. Template parameters are a **named** map, not positional. + +For automatic routing the echoed per-recipient channel is not a resolved route and is never updated later. Read the real route from `message.routed`, from `GET /v3/messages/{id}` after routing, or from `GET /v3/messages/{id}/activities`. + +## Twilio + +| Concept | Twilio | Sent | +| --- | --- | --- | +| Send | `POST /2010-04-01/Accounts/{sid}/Messages.json`, form-encoded | `POST /v3/messages`, JSON | +| Auth | Basic auth with Account SID and auth token | `x-api-key` header | +| Recipient | `To`, with a `whatsapp:` prefix for WhatsApp | `to` array plus the `channel` array | +| Sender | `From` or `MessagingServiceSid` | Profile configuration and routing | +| Content | `Body`, or `ContentSid` with positional variables | `text`, or `template` with named parameters | +| Fallback | Messaging Service features such as Fallback to Long Code | Automatic routing with reroute | +| Status callback | `StatusCallback` per message | Account-level webhook subscription | +| Suppression | Advanced Opt-Out | Platform consent with `opt_out` on the contact | +| Tenancy | Subaccount | Sender Profile | +| Idempotency | Not offered on message create | `Idempotency-Key` header | + +Rewrites that testing will not catch: positional `{{1}}` variables become named parameters; numeric error codes such as `21610` for an opted-out recipient become string `error.code` families, and that particular case does not even fail the request — the send is accepted and the message finalizes as `FILTERED`; a per-message `StatusCallback` URL has no equivalent, so status routing moves into the subscription's `event_filters`. + +Use this skill for line-by-line translation, then use `sent-integration-starter` to harden the resulting client lifecycle, retries, and observability. + +## Sinch + +| Concept | Sinch Conversation API | Sent | +| --- | --- | --- | +| Send | `POST /v1/projects/{id}/messages:send` with a channel-priority order | `POST /v3/messages` with automatic routing | +| App model | Conversation API app with configured channels | Sender Profile | +| Webhooks | Up to five per app, created with `triggers[]` | Account-level subscription with `event_types` and `event_filters` | +| Callback auth | OAuth 2.0, or HMAC-SHA256 over `body.nonce.timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Consent | `OPT_IN` and `OPT_OUT` trigger events | Platform-enforced consent, applied before the event | +| Delivery reports | Typically three callbacks per message | One event per transition, plus repeats on reroute | + +Sinch's trigger catalog is far broader than Sent's two event families, so a handler switching over twenty-plus trigger types collapses to branching on `field` and `event`. Sinch's channel-priority ordering is the ordered-fallback pattern that must become automatic routing. + +## Infobip + +| Concept | Infobip | Sent | +| --- | --- | --- | +| Send | Channel-specific endpoints, or Conversations | `POST /v3/messages` | +| Sender strategy | Resource Associations and Sending Strategies such as sticky sender | Platform routing rules | +| Webhooks | Subscription plus a notification profile | Webhook registration | +| Webhook auth | Basic, HMAC-SHA256 over the raw body, or OAuth, optionally mTLS; **header name is account-configured** | Fixed `x-webhook-signature` scheme | +| Suppression | Blocklist, also called Do Not Contact | `opt_out` on the contact | +| Tenancy | Applications and Entities | Sender Profiles | + +Because Infobip's signing header name comes from account settings, an existing verifier is not portable and its header constant must not be reused. Sending Strategies have no caller-side equivalent: sticky-sender behavior is a platform routing concern in Sent, not a request parameter. + +## Vonage + +| Concept | Vonage Messages API v1 | Sent | +| --- | --- | --- | +| Send | Per-channel body with `message_type`, `channel`, `to`, `from` | Uniform `POST /v3/messages` | +| Fallback | `failover` array of complete alternative messages | Automatic routing with reroute | +| Webhook auth | JWT in `Authorization: Bearer`, or legacy `sig` parameter | HMAC signature headers | +| Credentials | Application id with a private key | API key | + +Vonage's `failover` array is the most explicit ordered-fallback construct of the five, and it is the one most often ported directly into Sent's `channel` array. It must not be. Note also that Vonage's JWT bearer pattern tempts engineers to authenticate to Sent with `Authorization: Bearer`; Sent uses `x-api-key`. + +## MessageBird and Bird + +| Concept | MessageBird/Bird | Sent | +| --- | --- | --- | +| Send | Conversations API, or channel APIs | `POST /v3/messages` | +| Fallback | Explicit `fallback` object naming a secondary channel | Automatic routing with reroute | +| Webhook auth | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash, with `messagebird-request-timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Suppression | Platform suppression list | `opt_out` on the contact | + +MessageBird's signature covers a hash of the body rather than the body itself, so a verifier ported to Sent will fail every delivery even though both use HMAC-SHA256. Treat the legacy MessageBird header names as legacy and confirm current Bird names before asserting them. + +## Cross-provider concept table + +| Concept | Twilio | Sinch | Infobip | Vonage | Bird | Sent | +| --- | --- | --- | --- | --- | --- | --- | +| Ordered fallback | Messaging Service features | channel priority | sending strategies | `failover` array | `fallback` object | **automatic routing only** | +| Tenancy | subaccount | Conversation app | Application/Entity | application | workspace | Sender Profile | +| Template variables | positional | per-channel | per-channel | per-channel | per-channel | **named map** | +| Consent store | Advanced Opt-Out | OPT_IN/OPT_OUT events | Blocklist | application-side | suppression list | `opt_out`, channel-agnostic | +| Idempotency | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | `Idempotency-Key` | +| Webhook scope | per message or service | per app, up to five | per subscription | per application | per workspace | per account, filtered | + +## Status mapping + +| Sent | Twilio | Sinch | Note | +| --- | --- | --- | --- | +| `QUEUED` | `queued`, `accepted` | `QUEUED_ON_CHANNEL` | Accepted only | +| `ROUTED` | — | — | No incumbent analogue; repeats on reroute | +| `SENT` | `sent` | `MESSAGE_SUBMIT` | Provider handoff | +| `DELIVERED` | `delivered` | `DELIVERED` | Handset confirmation | +| `READ` | `read` | `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | `FAILURE` | May reroute; not necessarily final | +| `FILTERED` | error 21610 behavior | opt-out enforcement | Policy gate; never retry | +| `BLOCKED` | account errors | account errors | Account precondition | +| `SCHEDULED` | — | — | Quiet-hours parking | + +The two states with no analogue, `FILTERED` and `BLOCKED`, are exactly the two that ported retry logic mishandles. + +## Webhook signature comparison + +| Provider | Algorithm | Signed content | Header | +| --- | --- | --- | --- | +| Twilio | HMAC-SHA1 | full URL plus sorted POST parameters | `X-Twilio-Signature` | +| Sinch | HMAC-SHA256 | `body.nonce.timestamp` | `x-sinch-webhook-signature` plus nonce, timestamp, algorithm | +| Infobip | HMAC-SHA256 | raw body | account-configured | +| Vonage | JWT, or MD5/HMAC over sorted parameters | token claims, or parameters | `Authorization`, or `sig` | +| Bird | HMAC-SHA256 | timestamp, URL, SHA-256 of body | `messagebird-signature` | +| **Sent** | HMAC-SHA256 | `{webhook_id}.{timestamp}.{raw_body}` | `x-webhook-signature` as `v1,{base64}` | + +Among the compared schemes, Sent includes the endpoint id in the signed content and expects a `v1,` version prefix. No listed provider's verifier is reusable as-is, and no Sent SDK ships one, so plan the receiver as new code with its own tests. + +## Error handling translation + +| Incumbent pattern | Sent replacement | +| --- | --- | +| Numeric error codes in a switch statement | String `error.code` with prefix families | +| Retry on any non-delivered status | Retry only `429`, `5xx`, `SERVICE_001`, and `CONFLICT_001` once | +| Opt-out surfaced as a send error | Send accepted with `202`; message finalizes as `FILTERED` | +| Insufficient balance surfaced as a send error | Send accepted with `202`; message finalizes as `BLOCKED` | +| Per-request quota headers | Headers only on `429`; pace by design | +| Provider-side deduplication assumptions | Supply a deterministic `Idempotency-Key` | + +Consent and balance problems moving out of the error path and into delivery data is the structural change that most often surprises a migrating team: monitor filtered and blocked rates as first-class metrics. diff --git a/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py new file mode 100644 index 0000000..253ba90 --- /dev/null +++ b/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Scan a repository for incumbent CPaaS usage that a Sent migration must address. + +Reports each finding with a migration classification so the output can be used +directly as the phase-2 rewrite list. + +Usage +----- + python3 inventory_scan.py --self-test + python3 inventory_scan.py --path /path/to/repo + python3 inventory_scan.py --path /path/to/repo --format json + +Exit codes: 0 no findings, 1 findings reported, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, asdict + +EXIT_CLEAN = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + +SKIP_DIRS = { + ".git", "node_modules", "vendor", "dist", "build", "target", ".venv", "venv", + "__pycache__", ".next", ".gradle", ".idea", ".mypy_cache", ".pytest_cache", "coverage", +} +SCAN_EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".java", ".kt", ".cs", ".php", ".rb", + ".yml", ".yaml", ".json", ".tf", ".sh", ".md", +} +MAX_FILE_BYTES = 2_000_000 + + +@dataclass(frozen=True) +class Rule: + rule_id: str + provider: str + pattern: str + classification: str + guidance: str + + +RULES: tuple[Rule, ...] = ( + Rule( + "ordered-channel-array", "sent", + r"""['\"]channel['\"]\s*:\s*\[\s*['\"](?:sms|whatsapp|rcs)['\"]\s*,\s*['\"](?:sms|whatsapp|rcs)['\"]""", + "rewrite", + "Multiple explicit channels broadcast rather than fall back. Omit 'channel' or use ['sent'].", + ), + Rule( + "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", + "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + ), + Rule( + "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", + "rewrite", "Replace with POST /v3/messages using the flat JSON body.", + ), + Rule( + "twilio-signature", "twilio", r"X-Twilio-Signature|validateRequest|RequestValidator", + "new_code", "Twilio signs HMAC-SHA1 over URL plus sorted params. Sent needs a new verifier.", + ), + Rule( + "twilio-messaging-service", "twilio", r"MessagingServiceSid|messaging_service_sid", + "rewrite", "Sender pools and sticky sender are platform routing concerns in Sent, not request fields.", + ), + Rule( + "twilio-optout-code", "twilio", r"\b21610\b", + "rewrite", "Opted-out sends are accepted with 202 and finalize as FILTERED, not as a numeric error.", + ), + Rule( + "twilio-channel-prefix", "twilio", r"['\"]whatsapp:\+?", + "rewrite", "Channel is selected by the 'channel' array in Sent, not by a recipient prefix.", + ), + Rule( + "sinch-conversation", "sinch", r"conversation\.api\.sinch\.com|messages:send|sinch-webhook-signature", + "rewrite", "Replace channel-priority ordering with Sent automatic routing.", + ), + Rule( + "infobip-endpoint", "infobip", r"[a-z0-9-]+\.api\.infobip\.com|infobip-api", + "rewrite", "Replace with POST /v3/messages; Sending Strategies have no caller-side equivalent.", + ), + Rule( + "infobip-blocklist", "infobip", r"blocklist|do-not-contact|dnc", + "rewrite", "Reconcile into Sent consent as a channel-agnostic opt_out on the contact.", + ), + Rule( + "vonage-failover", "vonage", r"['\"]failover['\"]\s*[:=]\s*\[", + "rewrite", "An ordered failover array must become Sent automatic routing.", + ), + Rule( + "vonage-sdk", "vonage", r"\b(?:@vonage/|nexmo|vonage-)\b", + "rewrite", "Replace the Vonage client; Sent authenticates with x-api-key, not a JWT bearer.", + ), + Rule( + "bird-fallback", "bird", r"messagebird|bird-signature|messagebird-signature", + "rewrite", "MessageBird signs over a body hash; the verifier is not portable to Sent.", + ), + Rule( + "generic-fallback-object", "any", r"['\"](?:fallback|failover)['\"]\s*[:=]", + "rewrite", "Caller-supplied fallback has no Sent equivalent; automatic routing performs it.", + ), + Rule( + "positional-template-var", "any", r"\{\{\s*[1-9][0-9]?\s*\}\}", + "rewrite", "Sent template parameters are a named map, not positional placeholders.", + ), + Rule( + "bearer-auth-to-provider", "any", r"Authorization['\"]?\s*[:=]\s*['\"]?Bearer\s", + "review", "Direct Sent REST calls use x-api-key; keep Bearer where an app proxy, MCP OAuth flow, or incumbent still requires it.", + ), + Rule( + "provider-status-branch", "any", + r"['\"](?:undelivered|accepted|sending)['\"]", + "rewrite", "Map incumbent status strings onto Sent statuses, adding FILTERED, BLOCKED, and SCHEDULED.", + ), + Rule( + "application-keyword-matcher", "any", + r"(?:==|===|\.equals\(|\.includes\(|\bin\s)\s*['\"](?:STOP|UNSUBSCRIBE|CANCEL|UNSTOP)['\"]", + "review", "Keep exact matching only to mirror local consent evidence; do not write consent to Sent a second time.", + ), + Rule( + "idempotency-key-present", "sent", r"Idempotency-Key", + "informational", "Idempotency key usage found; confirm keys are deterministic rather than random.", + ), +) + +COMPILED = tuple((rule, re.compile(rule.pattern, re.IGNORECASE)) for rule in RULES) + + +@dataclass +class Finding: + path: str + line: int + rule_id: str + provider: str + classification: str + guidance: str + excerpt: str + + +def redact_excerpt(line: str) -> str: + """Mask credential-like literals before reporting a matched source line.""" + line = re.sub( + r"(?i)(authorization[^\n]{0,24}bearer\s+)([^\s'\",;}]+)", + r"\1", + line, + ) + line = re.sub( + r"(?i)\b(api[_-]?key|auth[_-]?token|access[_-]?token|secret|password)(\s*[:=]\s*)([^\s,;}]+)", + r"\1\2", + line, + ) + return line + + +def scan_text(text: str, path: str = "") -> list[Finding]: + """Scan a blob of text and return findings.""" + findings: list[Finding] = [] + for number, line in enumerate(text.splitlines(), start=1): + if len(line) > 2000: + line = line[:2000] + for rule, regex in COMPILED: + if regex.search(line): + findings.append( + Finding( + path=path, + line=number, + rule_id=rule.rule_id, + provider=rule.provider, + classification=rule.classification, + guidance=rule.guidance, + excerpt=redact_excerpt(line.strip())[:200], + ) + ) + return findings + + +def scan_path(root: str) -> list[Finding]: + """Walk a directory tree and scan eligible files.""" + findings: list[Finding] = [] + for directory, subdirs, files in os.walk(root): + subdirs[:] = [name for name in subdirs if name not in SKIP_DIRS and not name.startswith(".")] + for filename in files: + if filename.startswith(".env"): + continue + extension = os.path.splitext(filename)[1].lower() + if extension not in SCAN_EXTENSIONS: + continue + full = os.path.join(directory, filename) + try: + if os.path.getsize(full) > MAX_FILE_BYTES: + continue + with open(full, encoding="utf-8", errors="replace") as handle: + text = handle.read() + except OSError: + continue + findings.extend(scan_text(text, os.path.relpath(full, root))) + return findings + + +def summarize(findings: list[Finding]) -> dict[str, int]: + """Count findings per classification.""" + counts: dict[str, int] = {} + for finding in findings: + counts[finding.classification] = counts.get(finding.classification, 0) + 1 + return counts + + +def _self_test() -> int: + failures: list[str] = [] + + sample = """ + const client = require('twilio')(sid, token); + await client.messages.create({ to, from, body }); + if (status === 'undelivered') retry(); + if (error.code === 21610) suppress(); + payload = {"channel": ["whatsapp", "sms"], "to": ["+14155551234"]} + body = {"failover": [{"channel": "sms"}]} + template = "Hello {{1}}, your order {{2}} shipped" + headers = {"Authorization": "Bearer " + token} + if (text.trim().toUpperCase() === 'STOP') { optOut(); } + """ + found = {finding.rule_id for finding in scan_text(sample)} + expected = { + "twilio-sdk", + "provider-status-branch", + "twilio-optout-code", + "ordered-channel-array", + "vonage-failover", + "generic-fallback-object", + "positional-template-var", + "bearer-auth-to-provider", + "application-keyword-matcher", + } + missing = expected - found + if missing: + failures.append(f"rules failed to fire: {sorted(missing)}") + + clean = 'payload = {"to": ["+14155551234"], "template": {"name": "t", "parameters": {"a": "b"}}}\n' + if scan_text(clean): + failures.append("a correct Sent payload must produce no findings") + + single = 'payload = {"channel": ["sms"]}\n' + if any(f.rule_id == "ordered-channel-array" for f in scan_text(single)): + failures.append("a single-channel array must not be flagged as broadcast") + + counts = summarize(scan_text(sample)) + if counts.get("rewrite", 0) < 5 or counts.get("review", 0) != 2: + failures.append(f"classification summary drifted: {counts}") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("inventory_scan self-test passed: 4 checks") + return EXIT_CLEAN + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Scan a repository for incumbent CPaaS usage.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--path", help="repository root to scan") + parser.add_argument("--format", choices=("text", "json"), default="text", help="output format") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + if not args.path: + parser.error("provide --path or --self-test") + if not os.path.isdir(args.path): + print(f"error: {args.path} is not a directory", file=sys.stderr) + return EXIT_USAGE + + findings = scan_path(args.path) + + if args.format == "json": + print(json.dumps({"summary": summarize(findings), "findings": [asdict(f) for f in findings]}, indent=2)) + else: + if not findings: + print("no incumbent CPaaS usage detected") + for finding in findings: + print(f"{finding.path}:{finding.line} [{finding.classification}/{finding.provider}] {finding.rule_id}") + print(f" {finding.excerpt}") + print(f" -> {finding.guidance}") + if findings: + print("\nsummary: " + ", ".join(f"{k}={v}" for k, v in sorted(summarize(findings).items()))) + + return EXIT_FINDINGS if findings else EXIT_CLEAN + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-plugins/sent/skills/sent-integration-starter/SKILL.md b/claude-plugins/sent/skills/sent-integration-starter/SKILL.md new file mode 100644 index 0000000..1f16fb3 --- /dev/null +++ b/claude-plugins/sent/skills/sent-integration-starter/SKILL.md @@ -0,0 +1,108 @@ +--- +name: sent-integration-starter +description: Stands up a production-ready Sent v3 integration in an existing codebase — SDK selection and client construction, x-api-key configuration, idempotent sends, retry and rate-limit handling, the 46-code error catalog, sandbox verification, and a verified webhook receiver. Use when adding Sent to an app for the first time, choosing an SDK or framework wiring, handling 429 or 409 responses, deciding what to log, or hardening an integration before launch. +--- + +# Sent Integration Starter + +Bring up a Sent integration in four stages: authenticate, send idempotently, receive verified events, then harden. Do not conflate them — most broken integrations pass stage one and skip stage three. + +## Stage 1: client and credentials + +Direct Sent v3 REST requests authenticate with the `x-api-key` header. An application proxy may accept `Authorization: Bearer` from its own callers, and the Sent MCP server uses client-managed OAuth, but neither changes the REST header sent to `api.sent.dm`. Organization keys may add `x-profile-id` to act for a child profile; a profile-scoped key that sends that header receives `403`. + +| Language | Package | Client | +| --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `new SentDm()` | +| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | +| C# | `Sentdm` | `new SentClient()` | +| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | +| Ruby | `sentdm` | `Sentdm::Client.new` | + +Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. + +Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). + +Validate configuration at boot and fail fast when the key is missing, rather than surfacing an auth error on the first customer send. + +## Stage 2: idempotent sends + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345" } + }, + "sandbox": true +} +``` + +`to` is the only required field. Supply `template` or `text`, and omit `channel` to let automatic routing choose. Never write a `channel` array with several values expecting fallback — that broadcasts and multiplies charges. Channel decisions belong to `sent-routing-strategist`. + +Send `Idempotency-Key` on every POST, PUT, and PATCH, derived deterministically from your own domain object (for example the order id plus the notification type) so a retry after a timeout cannot double-send. Keys are 1–255 characters of `[A-Za-z0-9_-]`, cached 24 hours per key per customer. A replay returns the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`. A duplicate arriving while the original is still in flight waits up to five seconds and then fails `409 CONFLICT_001`; a `503 SERVICE_001` means the idempotency store was unavailable and the request was deliberately not executed. + +`202` means accepted, not delivered. Persist the returned `message_id` values immediately with your own tenant, profile, and logical send identifiers. Webhook events carry the Sent message id and account data, but never your application's tenant identifier. + +## Stage 3: verified webhook receiver + +An integration without a receiver has no delivery truth. Register an endpoint, then verify every delivery: HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, keyed on the base64-decoded secret after stripping `whsec_`, compared in constant time, rejecting timestamps outside 300 seconds. No SDK ships a verifier in any language. + +Acknowledge with `200` before doing work, and deduplicate on `{message_id}:{message_status}` for outbound events and `message_id` for inbound. Ten consecutive failed deliveries disable the endpoint. Full mechanics belong to `sent-webhook-engineer`; treat a verified, fast-acknowledging, deduplicating receiver as a launch requirement here. + +## Stage 4: harden + +### Retry policy by response class + +| Response | Retry | How | +| --- | --- | --- | +| `2xx` | No | Success | +| `400`, `422` `VALIDATION_*` | No | Fix the request | +| `401`, `403` `AUTH_*` | No | Stop immediately; ten consecutive auth failures lock the credential with escalating lockouts | +| `404` `RESOURCE_*` | No | The referenced object does not exist | +| `409 CONFLICT_001` | Yes, once, after a pause | A concurrent duplicate is in flight | +| `429` | Yes | Honor `Retry-After`; jittered backoff | +| `5xx`, `503 SERVICE_001` | Yes | Exponential backoff with jitter and a ceiling | +| Timeout with no response | Retry safely only with evidence | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient, so do not automate a resend | + +The standard limit is 200 requests per minute on a sliding window. `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` are limited to 10 per minute. Rate-limit headers appear **only** on `429` responses, so pacing must be designed rather than measured — batch up to 1,000 recipients per request and pace at roughly one request per second for bulk work. + +### Error handling + +Errors arrive as `{success, data, error: {code, message, details, doc_url}, meta: {request_id, timestamp, version}}`. Branch on the `error.code` prefix family (`AUTH_`, `VALIDATION_`, `RESOURCE_`, `BUSINESS_`, `CONFLICT_`, `SERVICE_`, `INTERNAL_`) rather than on message text or on individual codes. The full 46-code catalog with retry classification is in [references/errors-and-limits.md](references/errors-and-limits.md). + +Two codes are counterintuitive: `BUSINESS_003` and `BUSINESS_004` are documented as request-level errors, but on `POST /v3/messages` the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED`. Insufficient balance therefore does not fail the send call. + +### Observability + +Log `meta.request_id` on every response, success or failure — it is the correlation handle for support. Record the mapping from your logical send to the returned `message_id` values, and keep an append-only event history so a reroute's sequence remains auditable. Never log the API key, the webhook signing secret, `payment_details`, or raw recipient message content beyond your retention policy. + +### Launch checklist + +- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. +- [ ] `Idempotency-Key` on every mutating call, derived deterministically. +- [ ] Retry policy distinguishes retryable from terminal by error family. +- [ ] Bulk paths pace against 200 requests per minute and batch to at most 1,000 recipients. +- [ ] Webhook receiver verifies signature and timestamp, returns `200` fast, and dedupes. +- [ ] Receiver returns non-2xx on genuine failure so Sent retries. +- [ ] `message_id` to tenant mapping is persisted before sending. +- [ ] `request_id` is logged; secrets and card data are not. +- [ ] Sandbox smoke test passes, then a real send reaches `DELIVERED`. +- [ ] Alerting covers webhook `consecutive_failures`, `429` volume, and filtered or blocked rates. + +## Verification + +Run the local preflight, which needs no credentials and no network: + +```bash +python3 scripts/preflight.py --self-test +``` + +Then verify a real path with `"sandbox": true`, which authenticates and validates without executing, and finally with one live send confirmed to `DELIVERED` through the receiver. + +## Boundaries + +Use `sent-webhook-engineer` for receiver depth, `sent-routing-strategist` for channel choice, `sent-messaging` for a confirmed one-off send, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` for multi-tenant provisioning, and `migrate-to-sent` when replacing another CPaaS provider. diff --git a/claude-plugins/sent/skills/sent-integration-starter/agents/openai.yaml b/claude-plugins/sent/skills/sent-integration-starter/agents/openai.yaml new file mode 100644 index 0000000..4e65bfc --- /dev/null +++ b/claude-plugins/sent/skills/sent-integration-starter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Integration Starter" + short_description: "Stand up a production-ready Sent integration" + default_prompt: "Use $sent-integration-starter to review how my codebase should integrate Sent and what to harden before going live." diff --git a/claude-plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md b/claude-plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md new file mode 100644 index 0000000..55b0d4d --- /dev/null +++ b/claude-plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md @@ -0,0 +1,186 @@ +# Error catalog, retries, idempotency, and rate limits + +## Table of contents + +- [Response envelope](#response-envelope) +- [Retry classification by family](#retry-classification-by-family) +- [AUTH codes](#auth-codes) +- [VALIDATION codes](#validation-codes) +- [RESOURCE codes](#resource-codes) +- [BUSINESS codes](#business-codes) +- [CONFLICT, SERVICE, and INTERNAL codes](#conflict-service-and-internal-codes) +- [Codes that behave differently on send](#codes-that-behave-differently-on-send) +- [Idempotency semantics](#idempotency-semantics) +- [Rate limits and pacing](#rate-limits-and-pacing) +- [Sandbox semantics](#sandbox-semantics) +- [Ambiguous send recovery](#ambiguous-send-recovery) + +## Response envelope + +Every response uses one shape: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_004", + "message": "Request validation failed", + "details": { "to": ["'to' must contain at least one recipient"] }, + "doc_url": "https://docs.sent.dm/reference/api/error-catalog" + }, + "meta": { + "request_id": "req_7X9zKp2jDw", + "timestamp": "2026-03-14T09:21:44Z", + "version": "v3" + } +} +``` + +Branch on `error.code`, never on `error.message`. Read `error.details` for field-level validation feedback and log `meta.request_id` on every response so support can correlate. + +## Retry classification by family + +| Family | Count | Default handling | +| --- | --- | --- | +| `AUTH_` | 6 | Terminal. Stop immediately; do not loop | +| `VALIDATION_` | 8 | Terminal. Fix the request | +| `RESOURCE_` | 14 | Terminal; reconcile `RESOURCE_007` with the existing resource | +| `BUSINESS_` | 11 | Mostly terminal; `BUSINESS_002` backs off | +| `CONFLICT_` | 1 | Retry once after a short pause | +| `SERVICE_` | 1 | Retry with backoff | +| `INTERNAL_` | 5 | Retry with backoff | + +The catalog contains 46 codes in total. Authentication failures deserve special care: ten consecutive failures lock the presented credential with a `429` and escalating lockout windows from one to sixty minutes, so a retry loop against a bad key extends its own outage. Stop and alert instead. + +## AUTH codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `AUTH_001` | 401 | User is not authenticated | never | +| `AUTH_002` | 401 | Invalid or missing API key | never | +| `AUTH_004` | 403 | Insufficient permissions | never | +| `AUTH_005` | 403 | Account not yet activated | never | +| `AUTH_006` | 403 | KYC verification not complete | never | +| `AUTH_007` | 403 | Channel setup not complete | never | + +`AUTH_004` is also what a profile-scoped key receives when it sends `x-profile-id`. `AUTH_005`, `AUTH_006`, and `AUTH_007` are onboarding states rather than credential problems, so surface them to an operator instead of retrying. + +## VALIDATION codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `VALIDATION_001` | 400 | Request validation failed | never | +| `VALIDATION_002` | 400 | Invalid phone number format | never | +| `VALIDATION_003` | 400 | Invalid GUID format | never | +| `VALIDATION_004` | 400 | Required field is missing | never | +| `VALIDATION_005` | 400 | Field value out of valid range | never | +| `VALIDATION_006` | 400 | Invalid enum value | never | +| `VALIDATION_007` | 400 | Invalid Idempotency-Key format | never | +| `VALIDATION_008` | 400 | Invalid template variable value | never | + +`VALIDATION_002` is prevented by normalizing recipients to E.164 before the call. `VALIDATION_006` is what an unsupported `channel` value returns. `VALIDATION_008` covers several distinct template-variable problems, so read the message rather than assuming one cause. + +## RESOURCE codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `RESOURCE_001` | 404 | Contact not found | never | +| `RESOURCE_002` | 404 | Template not found | never | +| `RESOURCE_003` | 404 | Message not found | never | +| `RESOURCE_004` | 404 | Customer not found | never | +| `RESOURCE_005` | 404 | Organization not found | never | +| `RESOURCE_006` | 404 | User not found | never | +| `RESOURCE_007` | 409 | Resource already exists | do not retry blindly | +| `RESOURCE_008` | 404 | Webhook not found | never | +| `RESOURCE_009` | 404 | Brand not found | never | +| `RESOURCE_010` | 404 | Campaign not found | never | +| `RESOURCE_011` | 404 | Batch not found | never | +| `RESOURCE_012` | 404 | Phone number not found | never | +| `RESOURCE_013` | 404 | Resource not found | never | +| `RESOURCE_014` | 404 | Profile not found | never | + +`RESOURCE_014` also occurs when an organization passes its own identifier as a `profileId`, which must be a child profile. `RESOURCE_007` is the duplicate-creation signal, most visibly when inviting a user who already has access; read the existing resource and decide whether the requested state is already satisfied. + +## BUSINESS codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `BUSINESS_001` | 400 | Cannot modify inherited contact | never | +| `BUSINESS_002` | 429 | Rate limit exceeded | backoff | +| `BUSINESS_003` | 402 | Insufficient account balance | never | +| `BUSINESS_004` | 400 | Contact has opted out | never | +| `BUSINESS_005` | 400 | Template not approved | never | +| `BUSINESS_006` | 400 | Message cannot be modified in current state | never | +| `BUSINESS_007` | 400 | Channel not available | never | +| `BUSINESS_008` | 400 | Operation would exceed quota | never | +| `BUSINESS_010` | 400 | Webhook is inactive | never | +| `BUSINESS_012` | 400 | Template is not active on the requested channel | never | +| `BUSINESS_014` | 403 | Account is suspended | never | + +`BUSINESS_001` is the inheritance boundary: a profile that inherits contacts cannot modify them. `BUSINESS_010` explains why a test delivery to a disabled webhook fails; re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the dashboard after fixing the receiver. + +## CONFLICT, SERVICE, and INTERNAL codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `CONFLICT_001` | 409 | Concurrent idempotent request | after delay | +| `SERVICE_001` | 503 | Cache service temporarily unavailable | backoff | +| `INTERNAL_001` | 500 | Unexpected internal server error | backoff | +| `INTERNAL_002` | 500 | Database operation failed | backoff | +| `INTERNAL_003` | 500 | External service error | backoff | +| `INTERNAL_004` | 504 | Timeout waiting for operation | backoff | +| `INTERNAL_005` | 503 | Service temporarily unavailable | backoff | + +`SERVICE_001` is a deliberate safety response: the idempotency cache was unavailable, so the API refused to execute rather than risk a duplicate. Retrying the same request with the same key is correct. + +## Codes that behave differently on send + +Two documented request-level codes do not reject `POST /v3/messages`. Insufficient balance (`BUSINESS_003`, 402) and an opted-out contact (`BUSINESS_004`, 400) are catalogued as errors, but on send the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED` respectively. Client code that only inspects HTTP status will believe those sends succeeded. + +The operational consequence is that balance and consent problems appear in delivery data rather than in error handling. Monitor blocked and filtered rates as first-class metrics alongside `4xx` and `5xx` counts. + +Sent also records internal reason codes on a message for consent blocks, route denials, no-route-matched, and invalid template parameters. These are never returned in API responses or webhook payloads, so diagnosis uses the terminal status plus the channel value plus `GET /v3/messages/{id}/activities`. + +## Idempotency semantics + +`Idempotency-Key` applies to POST, PUT, and PATCH on `/v3/*` and is ignored on GET and DELETE. Values are 1 to 255 characters of `[A-Za-z0-9_-]`. + +| Situation | Behavior | +| --- | --- | +| First successful request | Response cached for 24 hours per key per customer | +| Replay of a cached key | Cached body returned with `Idempotent-Replayed: true` and `X-Original-Request-Id` | +| Response larger than 5 MB | Not cached; a duplicate re-executes | +| Duplicate arrives while the original is in flight | Waits up to five seconds, then fails `409 CONFLICT_001` | +| Idempotency cache unavailable | `503 SERVICE_001`; the request was not executed | + +Derive keys deterministically from your own domain objects — an order id plus a notification type, for example — rather than generating a random value per attempt, so that a retry after a network timeout collides with the original instead of creating a second send. Because caching is per customer, the same key used by two different customers is two independent operations. + +## Rate limits and pacing + +| Tier | Limit | Window | Applies to | +| --- | --- | --- | --- | +| Standard | 200 requests/minute | Sliding 60 seconds | Everything not listed below | +| Sensitive | 10 requests/minute | Fixed window | `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/webhooks/{id}/test` | + +`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` are present **only** on `429` responses. There is no way to read remaining quota preemptively, so pacing must be a design decision rather than an adaptive reaction. + +For bulk work, batch up to 1,000 recipients per `POST /v3/messages` and pace at roughly one request per second, which keeps a large campaign inside the standard budget while leaving headroom for transactional traffic. Note that batching multiplies with channels: 1,000 recipients on two channels is 2,000 messages and 2,000 charges from a single request. + +Rate-limit exposure follows the credential. A profile-scoped key has its own pool; an organization key acting through `x-profile-id` draws on the organization pool shared by every profile. + +## Sandbox semantics + +`"sandbox": true` runs authentication and validation and then stops. Nothing is persisted, queued, dispatched to a provider, or charged, and resource lookups do not occur — so a sandbox request will not tell you whether a template id exists. Malformed requests still return real `400` and `422` responses, which is what makes sandbox valuable in continuous integration. + +The exception worth memorizing: `DELETE /v3/webhooks/{id}` ignores the flag and always deletes. Never use sandbox as a general dry-run guard for destructive calls. + +## Ambiguous send recovery + +When a send times out or the connection drops before a response arrives, the request may or may not have been accepted. Never blind-retry. + +1. If the original carried an `Idempotency-Key`, retry with the **same** key. A cached success returns the original response with `Idempotent-Replayed: true`; a `409 CONFLICT_001` means the original is still in flight, so pause and retry once. +2. If no key was sent, search your own request and response records for a returned `message_id`. Sent exposes no reliable lookup by idempotency key or recipient that can prove an ambiguous request did not execute. +3. Escalate ambiguous no-key cases for an explicit duplicate-risk decision. Only send again when your application has sufficient evidence that nothing was accepted, and attach an idempotency key this time. + +The same discipline applies to profile provisioning: a deterministic key derived from your provisioning record prevents a timeout from creating a second profile. diff --git a/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md new file mode 100644 index 0000000..713b428 --- /dev/null +++ b/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -0,0 +1,158 @@ +# SDK selection and framework wiring + +## Table of contents + +- [Package matrix](#package-matrix) +- [Client construction per language](#client-construction-per-language) +- [Configuration and environment variables](#configuration-and-environment-variables) +- [Framework wiring](#framework-wiring) +- [Background processing per ecosystem](#background-processing-per-ecosystem) +- [Multi-tenant credential patterns](#multi-tenant-credential-patterns) +- [Testing and mocking](#testing-and-mocking) +- [Deployment notes](#deployment-notes) + +## Package matrix + +| Language | Package | Install | Minimum runtime | +| --- | --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `npm install @sentdm/sentdm` | Node with ESM or CJS | +| Python | `sentdm` (imports as `sent_dm`) | `pip install sentdm` | Python 3.9 | +| Go | `github.com/sentdm/sent-dm-go` | `go get github.com/sentdm/sent-dm-go` | Go 1.22 | +| Java | `dm.sent:sent-java` | Maven or Gradle dependency | Java 8 | +| C# | `Sentdm` | `dotnet add package Sentdm` | .NET Standard 2.0 | +| PHP | `sentdm/sent-dm-php` | `composer require sentdm/sent-dm-php` | PHP 8.1 | +| Ruby | `sentdm` | `gem install sentdm` or Bundler | Ruby 3.2 | + +The distribution name and the import name differ in Python (`sentdm` installs, `sent_dm` imports) and the Ruby send method is `messages.send_` with a trailing underscore because `send` is reserved. Both are common first-hour errors. + +No SDK ships a webhook signature verifier in any language. That code is always application-owned. + +## Client construction per language + +```typescript +import SentDm from '@sentdm/sentdm'; + +// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. +export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); + +const response = await sent.messages.send({ + to: ['+14155551234'], + template: { name: 'order_confirmation', parameters: { order_id: '12345' } }, +}); +``` + +```python +from sent_dm import Sent, AsyncSent + +client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY +async_client = AsyncSent() + +response = client.messages.send( + to=["+14155551234"], + template={"name": "order_confirmation", "parameters": {"order_id": "12345"}}, +) +``` + +```go +client := sentdm.NewClient() // or option.WithAPIKey(...) +response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ + To: []string{"+14155551234"}, +}) +``` + +```java +SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +MessageSendResponse response = client.messages().send(params); +``` + +```csharp +using Sentdm; +SentClient client = new(); // reads SENT_DM_API_KEY +var response = await client.Messages.Send(body); +``` + +```php +use SentDm\Client; +$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); +``` + +```ruby +require "sentdm" +client = Sentdm::Client.new # reads SENT_DM_API_KEY +client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) +``` + +Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. + +## Configuration and environment variables + +| Variable | Purpose | Read automatically | +| --- | --- | --- | +| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | +| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | +| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | + +Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. + +For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. + +## Framework wiring + +| Framework | Client placement | Webhook raw body | +| --- | --- | --- | +| Next.js | Shared module such as `lib/sent/client.ts` | `await request.text()`; keep the route on the Node runtime | +| Express | Module singleton | `express.raw({ type: 'application/json' })` scoped to the webhook path | +| NestJS | Provider in a `SentModule` | `req.rawBody` with `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | Client built in the lifespan, injected as a dependency | `await request.body()` | +| Django | `@lru_cache` factory in a `client.py` | `request.body` | +| Flask | Cached on the app or request context | `request.get_data()` | +| Gin / Echo | Constructed in `main`, passed to handlers | `io.ReadAll(c.Request.Body)` | +| Spring Boot | `@Bean` in a configuration class | `@RequestBody String payload` | +| Laravel | Singleton in the service container | `$request->getContent()` in middleware | +| Symfony | Autowired service | `$request->getContent()` | +| Rails | Memoized in an initializer | `request.body.read` then `request.body.rewind` | +| Sinatra | Memoized module method | `request.body.read` then `request.body.rewind` | +| ASP.NET Core | Singleton via dependency injection | `new StreamReader(request.Body).ReadToEndAsync()` | + +The recurring defect is a global JSON body parser that destroys the byte-exact body needed for signature verification. Scope the parser away from the webhook path, or read the raw bytes before any parsing occurs. + +A minimal integration is four files regardless of stack: a client module, an outbound send route, an inbound webhook route, and a signature-verification helper. + +## Background processing per ecosystem + +Webhook handlers must acknowledge with `200` and then work asynchronously, because ten consecutive failed deliveries disable the endpoint and a slow handler manufactures those failures. + +| Ecosystem | Mechanism | +| --- | --- | +| Node | BullMQ or an equivalent durable queue | +| Python | Celery or another durable queue; reserve FastAPI `BackgroundTasks` for non-critical local work | +| Go | A bounded worker pool or a job queue | +| Java | `@Async` with a `ThreadPoolTaskExecutor`, or a broker | +| PHP | Laravel queued jobs, Symfony Messenger | +| Ruby | ActiveJob or Sidekiq | +| .NET | A `BackgroundService` consuming a channel or queue | + +Route bulk campaign traffic to a queue separate from transactional sends so a large campaign cannot starve time-sensitive messages, and set worker concurrency or a task rate limit that respects the 200-requests-per-minute budget. + +## Multi-tenant credential patterns + +Two patterns exist, and mixing them causes confusing `403` responses. + +A **profile-scoped key** is confined to one profile, has its own rate-limit pool, and must not send `x-profile-id` — doing so returns `403`. Prefer it for runtime send paths so a leaked key affects one tenant. + +An **organization key with `x-profile-id`** reaches permitted child profiles but draws on the organization's shared rate-limit pool, so one noisy tenant consumes everyone's quota. Prefer it for control-plane work such as provisioning. + +When each tenant supplies its own key, resolve it for the request, construct the client with that credential, and discard both afterward. Do not retain tenant credentials in a client cache merely to preserve connection pooling; isolation and rotation correctness take priority. Queued work must resolve the authorized tenant credential just in time from a secret store rather than embedding it in the job payload. Never place a key in a browser, mobile app, or any client the organization does not control, and keep separate keys per environment. `x-sender-id` is legacy v1 and v2 terminology with no role in v3. + +## Testing and mocking + +Use `"sandbox": true` for integration tests: authentication and validation still run, so a malformed request still returns `400` or `422`, but nothing is written, queued, charged, or dispatched to a provider. It is the right default in continuous integration. + +For unit tests, mock at the SDK boundary — `jest.fn()` on `messages.send`, a NestJS testing module override, a substituted `ISentClient` in .NET — and assert on the request payload rather than on transport behavior. For the receiver, generate valid headers locally with the webhook skill's signing script so tests cover the signature path without contacting Sent. + +Two notes on live verification. `POST /v3/webhooks/{id}/test` delivers exactly once with no retry, so re-run it after each fix. And `DELETE /v3/webhooks/{id}` ignores `sandbox` and always deletes, so never treat the flag as a dry-run guard for deletion. + +## Deployment notes + +Keep webhook routes on runtimes that expose Node-style crypto and raw bodies rather than on edge runtimes. Close the HTTP server gracefully on `SIGTERM` so in-flight deliveries finish instead of failing and triggering retries. Ensure load balancer idle timeouts exceed the configured `timeout_seconds`, and keep container clocks NTP-synchronized so the 300-second replay window does not reject valid traffic. Keep the route outside user-auth middleware. If abuse controls are required, make them signature-aware and capacity-safe rather than placing a generic limiter in front of verification and manufacturing the failures that lead to auto-disable. diff --git a/claude-plugins/sent/skills/sent-integration-starter/scripts/preflight.py b/claude-plugins/sent/skills/sent-integration-starter/scripts/preflight.py new file mode 100644 index 0000000..7f74185 --- /dev/null +++ b/claude-plugins/sent/skills/sent-integration-starter/scripts/preflight.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Offline preflight checks for a Sent v3 integration. + +Validates the things that break integrations before any network call is made: +recipient formatting, send-payload shape, channel-array intent, idempotency-key +format, batch sizing against the documented pacing budget, and the retry +classification of an error code. + +Usage +----- +Run the built-in synthetic fixtures:: + + python3 preflight.py --self-test + +Check a send payload written to a file:: + + python3 preflight.py --payload-file send.json + +Classify an error code for retry behavior:: + + python3 preflight.py --classify-error 409:CONFLICT_001 + +Exit codes: 0 all checks passed, 1 one or more findings, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +E164 = re.compile(r"^\+[1-9]\d{1,14}$") +IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9_-]{1,255}$") +VALID_CHANNELS = {"sent", "sms", "whatsapp", "rcs"} +MAX_RECIPIENTS_PER_REQUEST = 1000 +STANDARD_RATE_LIMIT_PER_MINUTE = 200 +SENSITIVE_RATE_LIMIT_PER_MINUTE = 10 + +TERMINAL_FAMILIES = {"AUTH", "VALIDATION", "RESOURCE"} +RETRYABLE_FAMILIES = {"SERVICE", "INTERNAL"} + +EXIT_OK = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + + +def check_recipients(recipients: object) -> list[str]: + """Validate the `to` array.""" + findings: list[str] = [] + if not isinstance(recipients, list) or not recipients: + return ["'to' must be a non-empty array of E.164 phone numbers"] + for value in recipients: + if not isinstance(value, str) or not E164.match(value): + findings.append(f"recipient {value!r} is not E.164 (leading '+', country code, digits only)") + if len(recipients) > MAX_RECIPIENTS_PER_REQUEST: + findings.append( + f"{len(recipients)} recipients exceeds the {MAX_RECIPIENTS_PER_REQUEST}-recipient per-request limit" + ) + return findings + + +def check_channels(channels: object) -> list[str]: + """Validate the `channel` array and flag broadcast intent.""" + if channels is None: + return [] + findings: list[str] = [] + if not isinstance(channels, list): + return ["'channel' must be an array when present"] + for value in channels: + if value not in VALID_CHANNELS: + findings.append(f"channel {value!r} is invalid; allowed values are {sorted(VALID_CHANNELS)}") + explicit = [value for value in channels if value != "sent"] + if len(explicit) > 1: + findings.append( + "multiple explicit channels broadcast rather than fall back: one message and one charge is created " + "per (recipient, channel) pair. Omit 'channel' or use ['sent'] for automatic routing with reroute" + ) + if "sent" in channels and len(channels) > 1: + findings.append("'sent' combined with an explicit channel is ambiguous; use one or the other") + return findings + + +def check_content(payload: dict) -> list[str]: + """Validate that exactly one content source is present.""" + has_template = isinstance(payload.get("template"), dict) + has_text = isinstance(payload.get("text"), str) and payload["text"].strip() != "" + if has_template and has_text: + return ["provide either 'template' or 'text', not both"] + if not has_template and not has_text: + return ["provide 'template' or 'text' as the message content"] + if has_template: + template = payload["template"] + if not template.get("id") and not template.get("name"): + return ["'template' requires 'id' or 'name'"] + if template.get("id") and template.get("name"): + return ["'template.id' and 'template.name' are mutually exclusive"] + parameters = template.get("parameters") + if parameters is not None and not isinstance(parameters, dict): + return ["'template.parameters' must be an object of string values"] + if isinstance(parameters, dict) and any(not isinstance(value, str) for value in parameters.values()): + return ["every 'template.parameters' value must be a string"] + return [] + + +def check_idempotency_key(key: object) -> list[str]: + """Validate an Idempotency-Key header value.""" + if key is None: + return ["no Idempotency-Key supplied; a timeout retry can produce a duplicate send"] + if not isinstance(key, str) or not IDEMPOTENCY_KEY.match(key): + return ["Idempotency-Key must be 1-255 characters of letters, digits, hyphens, or underscores"] + return [] + + +def estimate_batches(recipient_count: int, channel_count: int = 1) -> dict[str, int]: + """Return message and request estimates for a bulk send.""" + channel_count = max(1, channel_count) + messages = recipient_count * channel_count + requests = -(-recipient_count // MAX_RECIPIENTS_PER_REQUEST) + minutes = -(-requests // STANDARD_RATE_LIMIT_PER_MINUTE) + return { + "messages_created": messages, + "requests_required": requests, + "minimum_minutes_at_rate_limit": minutes, + } + + +def classify_error(status: int, code: str) -> tuple[str, str]: + """Return (classification, guidance) for a Sent error response.""" + family = code.split("_", 1)[0].upper() if code else "" + if status == 429: + return "retry", "honor Retry-After, then use jittered exponential backoff; stop if the credential is locked" + if code.upper() == "CONFLICT_001": + return "retry-once", "a concurrent duplicate is in flight; pause, then retry the same Idempotency-Key once" + if code.upper() == "SERVICE_001": + return "retry", "the idempotency store was unavailable and the request was deliberately not executed" + if family in RETRYABLE_FAMILIES or 500 <= status < 600: + return "retry", "exponential backoff with jitter and a bounded ceiling" + if family == "AUTH": + return "terminal", "stop immediately; ten consecutive auth failures lock the credential with escalating lockout" + if family in TERMINAL_FAMILIES: + return "terminal", "fix the request or the referenced resource; retrying reproduces the same result" + if family == "BUSINESS": + return "conditional", ( + "an account or policy precondition; on POST /v3/messages the send is accepted with 202 and the " + "affected messages finalize as BLOCKED or FILTERED, so resolve the condition before resending" + ) + return "unknown", "treat as terminal until classified; log meta.request_id and inspect error.doc_url" + + +def check_payload(payload: dict, idempotency_key: str | None = None) -> list[str]: + """Run every payload check and return the accumulated findings.""" + findings: list[str] = [] + findings.extend(check_recipients(payload.get("to"))) + findings.extend(check_channels(payload.get("channel"))) + findings.extend(check_content(payload)) + findings.extend(check_idempotency_key(idempotency_key)) + return findings + + +def _self_test() -> int: + failures: list[str] = [] + + good = { + "to": ["+14155551234"], + "template": {"name": "order_confirmation", "parameters": {"order_id": "12345"}}, + } + if check_payload(good, "order-12345-confirmation"): + failures.append("a well-formed payload with an idempotency key must produce no findings") + + if not check_recipients(["4155551234"]): + failures.append("a non-E.164 recipient must be flagged") + if not check_recipients([]): + failures.append("an empty recipient list must be flagged") + if not check_recipients(["+1415555%s" % "1" * 15]): + failures.append("an over-long number must be flagged") + + broadcast = check_channels(["whatsapp", "sms"]) + if not any("broadcast" in finding for finding in broadcast): + failures.append("a multi-channel array must be flagged as broadcast, not fallback") + if check_channels(["sent"]) or check_channels(None): + failures.append("automatic routing must produce no channel findings") + if not check_channels(["telegram"]): + failures.append("an unsupported channel value must be flagged") + + if not check_content({"to": ["+14155551234"]}): + failures.append("missing content must be flagged") + if not check_content({"template": {"id": "x", "name": "y"}}): + failures.append("template id and name together must be flagged") + if not check_content({"template": {"name": "t"}, "text": "hello"}): + failures.append("template and text together must be flagged") + if not check_content({"template": {"name": "t", "parameters": {"count": 2}}}): + failures.append("non-string template parameter values must be flagged") + + if not check_idempotency_key(None): + failures.append("a missing idempotency key must be flagged") + if not check_idempotency_key("bad key!"): + failures.append("an invalid idempotency key must be flagged") + + estimate = estimate_batches(2500, 2) + if estimate != {"messages_created": 5000, "requests_required": 3, "minimum_minutes_at_rate_limit": 1}: + failures.append(f"batch estimation drifted: {estimate}") + + expectations = { + (429, "BUSINESS_009"): "retry", + (409, "CONFLICT_001"): "retry-once", + (503, "SERVICE_001"): "retry", + (401, "AUTH_002"): "terminal", + (400, "VALIDATION_004"): "terminal", + (404, "RESOURCE_001"): "terminal", + (500, "INTERNAL_001"): "retry", + (402, "BUSINESS_003"): "conditional", + } + for (status, code), expected in expectations.items(): + actual, _ = classify_error(status, code) + if actual != expected: + failures.append(f"{status} {code} classified as {actual}, expected {expected}") + + if SENSITIVE_RATE_LIMIT_PER_MINUTE >= STANDARD_RATE_LIMIT_PER_MINUTE: + failures.append("the sensitive tier must be lower than the standard tier") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("preflight self-test passed: 20 checks") + return EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Offline preflight checks for a Sent v3 integration.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--payload-file", help="path to a JSON send payload to check") + parser.add_argument("--idempotency-key", help="the Idempotency-Key that will be sent with the payload") + parser.add_argument("--estimate", type=int, metavar="RECIPIENTS", help="estimate messages, requests, and pacing") + parser.add_argument("--channels", type=int, default=1, help="number of channels used with --estimate") + parser.add_argument("--classify-error", metavar="STATUS:CODE", help="classify an error response for retry") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if args.classify_error: + try: + status_text, _, code = args.classify_error.partition(":") + classification, guidance = classify_error(int(status_text), code) + except ValueError: + print("error: --classify-error expects STATUS:CODE, for example 429:BUSINESS_009", file=sys.stderr) + return EXIT_USAGE + print(f"{args.classify_error} -> {classification}: {guidance}") + return EXIT_OK + + if args.estimate is not None: + for key, value in estimate_batches(args.estimate, args.channels).items(): + print(f"{key}: {value}") + return EXIT_OK + + if not args.payload_file: + parser.error("provide --payload-file, --estimate, --classify-error, or --self-test") + + try: + with open(args.payload_file, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + print(f"error: could not read payload: {exc}", file=sys.stderr) + return EXIT_USAGE + + if not isinstance(payload, dict): + print("error: payload must be a JSON object", file=sys.stderr) + return EXIT_USAGE + + findings = check_payload(payload, args.idempotency_key) + if payload.get("sandbox") is True: + print("note: sandbox is true, so this request validates and authenticates without executing") + if not findings: + print("payload passed all preflight checks") + return EXIT_OK + for finding in findings: + print(f"- {finding}") + return EXIT_FINDINGS + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-plugins/sent/skills/sent-profile-provisioning/SKILL.md b/claude-plugins/sent/skills/sent-profile-provisioning/SKILL.md new file mode 100644 index 0000000..a021269 --- /dev/null +++ b/claude-plugins/sent/skills/sent-profile-provisioning/SKILL.md @@ -0,0 +1,134 @@ +--- +name: sent-profile-provisioning +description: Executes the Sent Sender Profile lifecycle over the API — creating profiles with the right inheritance, sharing, billing, and WhatsApp options, driving profile completion and its callback, managing 10DLC campaigns per profile, and administering users and roles. Use when calling POST /v3/profiles, handling a completion callback or unclear profile status, choosing inherit or dedicated resources, wiring per-tenant onboarding, or inviting and role-managing users. +--- + +# Sent Profile Provisioning + +This skill is the execution counterpart to profile architecture: once the tenancy boundary is decided, it drives the API calls, the completion callback, the campaign registration, and the user administration that make a profile able to send. Design the boundary with `sender-profile-architect` first; provision it here. + +## Provisioning sequence + +1. **Confirm the credential.** `POST /v3/profiles` requires an organization key with `admin`. Profile-scoped keys cannot create profiles, and a profile key that sends `x-profile-id` receives `403`. +2. **Decide inheritance and sharing before the call.** These flags shape compliance posture and are awkward to unwind later. +3. **Create the profile**, validating the payload with `"sandbox": true` first when the shape is uncertain. Use a different idempotency key for the live create because a successful sandbox response is cached for 24 hours. +4. **Attach or inherit WhatsApp** via exactly one of the three supported paths. +5. **Register campaigns** for US SMS under the profile. +6. **Complete the profile** with `POST /v3/profiles/{profileId}/complete` and a reachable `webHookUrl`. +7. **Reconcile status** from the callback, or by polling if the callback is missed. +8. **Invite users** with least-privilege roles. + +## Create payload essentials + +`name` is the only required field. The consequential optional fields group into identity, sharing, inheritance, billing, WhatsApp, and brand. + +```json +{ + "name": "Northwind Retail", + "short_name": "Northwind", + "description": "Retail brand tenant", + "allow_contact_sharing": false, + "allow_template_sharing": false, + "inherit_contacts": false, + "inherit_templates": false, + "inherit_tcr_brand": true, + "inherit_tcr_campaign": true, + "billing_model": "profile", + "billing_contact": { + "name": "Ada Ops", + "email": "ops@example.com", + "phone": "+14155550100", + "address": "1 Example Way, Springfield" + }, + "sandbox": true +} +``` + +`short_name` must be 3 to 11 characters of letters, numbers, and spaces with at least one letter. Inheritance flags default to true, so a profile created with no flags consumes the organization's contacts, templates, brand, and campaigns. The example opts into contact and template isolation explicitly while inheriting the organization's compliance registrations. Sharing flags expose this profile's resources outward; inheritance flags consume the organization's resources inward. They are independent directions and are frequently confused. + +Create permits `name` alone, but completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. When `inherit_tcr_brand` is true, the API rejects a `brand` object in the create request even though the profile still needs its own KYC submission; complete that KYC through the dashboard before calling the completion endpoint. + +`billing_model` accepts `profile`, `organization`, or `profile_and_organization`. Any model that includes `profile` requires `billing_contact` when none exists, and `payment_details` is only accepted for those models. Card fields are forwarded to the payment processor and must never be logged, echoed, or persisted anywhere in the application. + +Field-by-field rules, error codes, and the update-only fields are in [references/profile-lifecycle.md](references/profile-lifecycle.md). + +## Inheritance decisions + +| Flag | `true` means | Consequence | +| --- | --- | --- | +| `inherit_tcr_brand` | Use the organization's registered brand | A `brand` object in the same request is rejected | +| `inherit_tcr_campaign` | Use the organization's campaigns | Those campaigns are read-only for this profile; creating one returns a validation error | +| `inherit_contacts` | Read the organization's contacts | No contact isolation between tenants | +| `inherit_templates` | Read the organization's templates | No template isolation between tenants | + +An inherited brand with `inherit_tcr_campaign: false` is a supported and common pattern: shared legal identity, dedicated messaging use cases per tenant. + +## WhatsApp: exactly three paths + +1. Organization Embedded Signup, performed in the Sent Dashboard. **No public endpoint starts this flow.** +2. Child-profile inheritance — omit `whatsapp_business_account` once the organization has a WABA. +3. Dedicated profile credentials — supply `whatsapp_business_account` with `waba_id` and `access_token`, optionally `phone_number_id`. + +Supplying credentials on `POST /v3/profiles` is not an Embedded Signup endpoint. Omitting `whatsapp_business_account` when the organization has no WABA configured returns `422`; complete organization Embedded Signup or supply valid direct credentials. Use `waba-embedded-signup` for the operational signup flow. + +## Completion and status + +`POST /v3/profiles/{profileId}/complete` requires `webHookUrl`. + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +A `202` means processing started and carries no final status. A `200` means the profile was already complete and its body carries a status. The callback body is `{profileId, success, status, timestamp}` and is **delivered once with no retry**, so the receiver must be live before the call and the flow must degrade to polling `GET /v3/profiles/{profileId}`. This callback is separate from subscribed Sent webhooks and is not documented as carrying the webhook HMAC headers; use a unique callback path tied to the provisioning record, reject unknown profile ids, and treat polling as the authoritative recovery path. + +Profile status vocabulary differs by surface: the create response demonstrates lowercase `incomplete`, the completion `200` demonstrates lowercase `completed`, the completion callback uses `COMPLETED`, `SUBMITTED`, and `failed`, and `GET /v3/profiles/{id}` documents `approved`, `submitted`, `processing`, and `failed`. Do not assert a closed enum, do not lowercase-normalize into a fixed set, and record which surface produced each value. Compare statuses case-insensitively and preserve unknown strings. + +## Campaigns per profile + +Campaign management lives under the profile: `GET|POST /v3/profiles/{profileId}/campaigns` and `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. There are no standalone brand endpoints; a dedicated brand is created with the profile. + + +```json +{ + "campaign": { + "name": "Northwind order notifications", + "description": "Order and delivery notifications for opted-in Northwind customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Northwind: Your order 12345 has shipped. Reply STOP to opt out." + ] + } + ], + "volume": "1500", + "messageFlow": "Customers opt in at checkout before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms" + } +} +``` + +`messagingUseCaseUs` accepts one of thirteen values, `sampleMessages` holds 1 to 5 entries of at most 1,024 characters each, and a numeric `volume` string below 2,000 selects the low-volume tier while 2,000 or above selects the standard tier. Campaign statuses are `SENT_CREATED`, `ACTIVE`, and `EXPIRED`. Use `sms-10dlc-registration` for use-case selection and sample-copy policy. + +## Users and roles + +Five operations administer access: `GET /v3/users`, `POST /v3/users` (invite), `GET /v3/users/{userId}`, `PATCH /v3/users/{userId}` (role), and `DELETE /v3/users/{userId}`. None is exposed through MCP. Assignable roles are `admin`, `billing`, and `developer`; `owner` is implicit for the creating account and never appears in the list. Mutations require `admin`. + +Role checks resolve against the email that owns the API key and pass only for the owner or an **active** user with an allowed role — `invited`, `suspended`, and `rejected` users fail. Organization-level access cascades to child profiles. Invitations expire after seven days, and inviting an existing user returns `409`. + +Before any user mutation, read the current state, then confirm explicitly with the operator. The API refuses to let you change your own role, demote the last admin, remove yourself, or remove the last admin, but checking first produces a clear explanation instead of a validation error. The full role matrix and key-hygiene rules are in [references/users-and-roles.md](references/users-and-roles.md). + +There is no endpoint to list, create, or revoke API keys; key management is a dashboard operation. Rotation is create-new, deploy, verify with `GET /v3/me`, then disable or delete the old key — deleting first only when the key is compromised. + +## Multi-tenant provisioning notes + +Webhook events never carry your application's tenant identifier. Before the first send, persist `message_id -> {tenant, profile, logical_send_id, channel}` and `receiving_number -> {tenant, profile}`. Do not infer tenant ownership from `account_id`, since many tenant profiles can share one organization. Provision one webhook registration per environment so a failing lower-environment receiver cannot auto-disable production. + +## Boundaries + +Use `sender-profile-architect` for the isolation, credential, and blast-radius design decision; `waba-embedded-signup` for the WhatsApp signup flow; `sms-10dlc-registration` for brand vetting and campaign policy; and `sent-webhook-engineer` for subscribed message-event receivers. Profile-completion callbacks use the separate verification and polling guidance in this skill. diff --git a/claude-plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml b/claude-plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml new file mode 100644 index 0000000..df60ac1 --- /dev/null +++ b/claude-plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Profile Provisioning" + short_description: "Run the Sender Profile lifecycle" + default_prompt: "Use $sent-profile-provisioning to plan the API calls that bring a new Sender Profile to a sending state." diff --git a/claude-plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md b/claude-plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md new file mode 100644 index 0000000..108f49c --- /dev/null +++ b/claude-plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md @@ -0,0 +1,167 @@ +# Sender Profile lifecycle reference + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Create field reference](#create-field-reference) +- [Update-only fields](#update-only-fields) +- [Inheritance and sharing matrix](#inheritance-and-sharing-matrix) +- [Billing configuration](#billing-configuration) +- [WhatsApp attachment paths](#whatsapp-attachment-paths) +- [Completion flow and callback](#completion-flow-and-callback) +- [Status vocabulary by surface](#status-vocabulary-by-surface) +- [Error catalog for provisioning](#error-catalog-for-provisioning) +- [Idempotency and sandbox](#idempotency-and-sandbox) +- [Offboarding](#offboarding) + +## Operation catalog + +Ten operations cover profiles and their campaigns. None is available through MCP, so provisioning is REST-only. + +| Operation | Purpose | +| --- | --- | +| `POST /v3/profiles` | Create a profile | +| `GET /v3/profiles` | List profiles | +| `GET /v3/profiles/{profileId}` | Read one profile, including current status | +| `PATCH /v3/profiles/{profileId}` | Update configuration and number references | +| `DELETE /v3/profiles/{profileId}` | Remove a profile | +| `POST /v3/profiles/{profileId}/complete` | Start completion; requires `webHookUrl` | +| `GET /v3/profiles/{profileId}/campaigns` | List campaigns | +| `POST /v3/profiles/{profileId}/campaigns` | Create a campaign | +| `PUT /v3/profiles/{profileId}/campaigns/{campaignId}` | Update a campaign | +| `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` | Delete a campaign | + +Creation requires an organization API key whose owning email holds `admin`. An organization key may target a child with `x-profile-id`; a profile-scoped key sending that header receives `403`, and a profile outside the organization returns `404`. + +## Create field reference + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `name` | string | — | The only required field | +| `icon` | string | — | Display asset | +| `description` | string | — | Free text | +| `short_name` | string | — | 3–11 chars, letters, numbers, spaces, at least one letter | +| `allow_contact_sharing` | boolean | `false` | Exposes this profile's contacts outward | +| `allow_template_sharing` | boolean | `false` | Exposes this profile's templates outward | +| `inherit_contacts` | boolean | `true` | Consumes the organization's contacts | +| `inherit_templates` | boolean | `true` | Consumes the organization's templates | +| `inherit_tcr_brand` | boolean | `true` | Uses the organization's brand; forbids a `brand` object | +| `inherit_tcr_campaign` | boolean | `true` | Inherited campaigns are read-only for this profile | +| `billing_model` | enum | `profile` | `profile`, `organization`, or `profile_and_organization` | +| `billing_contact` | object | — | `name`, `email`, `phone`, `address`; required when the model includes `profile` and none exists | +| `whatsapp_business_account` | object | — | `waba_id` and `access_token` required, `phone_number_id` optional | +| `brand` | object | — | `contact` and `compliance` required, `business` optional; forbidden when `inherit_tcr_brand` is true | +| `payment_details` | object | — | `card_number`, `expiry`, `cvc`, `zip_code`; only for models including `profile` | +| `sandbox` | boolean | `false` | Simulate without side effects | + +`payment_details` is ephemeral and forwarded to the payment processor. Never log it, never echo it back to a user, never store it, and never place it in a file that could be committed. + +Although creation requires only `name`, completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. A profile inheriting the organization's TCR brand cannot include a `brand` object in the API request but still needs profile-level KYC submitted through the dashboard before completion. + +## Update-only fields + +`PATCH /v3/profiles/{profileId}` accepts the create fields plus number references: + +- `sending_phone_number_profile_id` +- `sending_whatsapp_number_profile_id` +- `sending_phone_number` +- `whatsapp_phone_number` +- `allow_number_change_during_onboarding` + +Model reference identifiers separately from literal numbers, and guard against cycles when one profile's sending number points at another profile that points back. + +## Inheritance and sharing matrix + +Inheritance pulls resources in; sharing pushes them out. They are independent. + +| Configuration | Result | +| --- | --- | +| `inherit_contacts: true`, `allow_contact_sharing: false` | Reads organization contacts; does not expose its own | +| `inherit_contacts: false`, `allow_contact_sharing: true` | Isolated contact store that other profiles may read | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | Fully inherited compliance posture; campaigns read-only here | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | Shared legal identity with per-tenant use cases — the common multi-tenant pattern | +| `inherit_tcr_brand: false` | Dedicated brand supplied in the same create request | + +For tenant isolation, set `inherit_contacts` and `inherit_templates` to false explicitly, because both default to true and a silently inherited store means one tenant can read another's data model. + +## Billing configuration + +| Model | Meaning | Requires | +| --- | --- | --- | +| `profile` | The profile pays | `billing_contact`, optionally `payment_details` | +| `organization` | The organization pays | Nothing profile-side | +| `profile_and_organization` | Profile first with organization fallback | `billing_contact` | + +Effective balance follows this configuration, so a balance reading for a profile with `organization` billing reflects the organization's funds. Confirm which model a profile uses before interpreting a balance or diagnosing a `BLOCKED` message. + +## WhatsApp attachment paths + +| Path | How | When | +| --- | --- | --- | +| Organization Embedded Signup | Sent Dashboard only; no public endpoint exists | The organization owns one WABA used across profiles | +| Child inheritance | Omit `whatsapp_business_account` | Tenants share the organization's WABA | +| Dedicated credentials | `whatsapp_business_account` with `waba_id` and `access_token` | The tenant owns its own WABA | + +Requesting inheritance when the organization has no WABA configured returns `422 VALIDATION_001`. Do not invent a hybrid, and do not describe the create payload as an Embedded Signup endpoint. + +## Completion flow and callback + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +Responses: `202` means processing started and contains no final status; `200` means the profile was already complete and the body carries a status. + +The callback body is `{profileId, success, status, timestamp}`, documented with `COMPLETED`, `SUBMITTED`, and `failed`. It is delivered **once, with no retry**, which drives three requirements: the receiver must be reachable before the completion call, the receiver must be idempotent on `profileId`, and a reconciliation job must poll `GET /v3/profiles/{profileId}` for profiles that have been awaiting completion beyond a timeout. + +Note that this callback is not part of the `/v3/webhooks` subscription system and is not documented as carrying the `x-webhook-signature` scheme. Give each provisioning record a unique, hard-to-guess callback path, treat its payload as untrusted input, verify `profileId` against the record you created, and never take action on an unrecognized identifier. Polling the profile remains the recovery and reconciliation authority. + +## Status vocabulary by surface + +| Surface | Observed values | +| --- | --- | +| Create response | lowercase `incomplete` | +| Completion `200` | lowercase `completed` | +| Completion callback | `COMPLETED`, `SUBMITTED`, `failed` | +| `GET /v3/profiles/{profileId}` guide | `approved`, `submitted`, `processing`, `failed` | +| REST guides versus OpenAPI | Publish different status sets | + +Handle this by comparing case-insensitively, preserving unknown strings verbatim, recording which surface produced the value, and never switch-casing over an assumed closed enum. A provisioning state machine should treat any unrecognized status as "needs human review" rather than as an error. + +## Error catalog for provisioning + +| Status | Code | Meaning | +| --- | --- | --- | +| 400 | `VALIDATION_001` | Invalid payload, including a `brand` object alongside `inherit_tcr_brand: true` | +| 400 | `VALIDATION_001` | Cannot create campaigns when `inherit_tcr_campaign` is true, or the campaigns are read-only | +| 403 | `AUTH_004` | Profile key attempted `x-profile-id`, or insufficient role | +| 404 | `RESOURCE_005` | Organization not found | +| 404 | `RESOURCE_014` | Profile not found | +| 404 | `RESOURCE_009` | Brand not found for the profile | +| 404 | `RESOURCE_010` | Campaign not found | +| 422 | `VALIDATION_001` | Organization has no WABA configured | + +The response envelope carries `error.code`, `error.message`, `error.details`, and `error.doc_url`, plus `meta.request_id`. Log `request_id` for every provisioning call; it is the correlation handle for support. + +## Idempotency and sandbox + +`Idempotency-Key` is honored on POST, PUT, and PATCH with a value of 1 to 255 characters from `[A-Za-z0-9_-]`. Successful responses are cached 24 hours per key per customer, replays return the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`, a duplicate arriving while the original is in flight waits up to five seconds and then fails `409 CONFLICT_001`, and if the idempotency store is unavailable the API returns `503 SERVICE_001` rather than risk a double execution. Use a deterministic key derived from your own provisioning record so a retry after a network timeout cannot create a second profile. + +`"sandbox": true` authenticates and validates without persisting, queueing, calling providers, deducting balance, or looking up resources. Use it to prove a payload shape in CI. A successful sandbox mutation is itself cached by idempotency, so use a distinct key for the later live mutation or the live call will replay the sandbox response. Sandbox does not protect deletions in the webhook API, so never rely on it as a general dry-run guard. + +## Offboarding + +Deprovisioning a tenant is an ordered, evidence-preserving sequence rather than a single delete: + +1. Stop new sends at the application layer. +2. Disable or delete the tenant's API keys in the dashboard. +3. Remove or downgrade the tenant's users, keeping at least one admin on the organization. +4. Disable webhook registrations that pointed at tenant infrastructure. +5. Detach shared resources deliberately, checking whether other profiles inherit from them. +6. Retain delivery and consent records for the applicable retention period before deleting the profile. +7. Record the `request_id` and timestamp of each step as the audit trail. + +Contact deletion dissociates the contact from the calling customer while shared contact, capability, and delivery records persist, so deletion is not an erasure mechanism. diff --git a/claude-plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md b/claude-plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md new file mode 100644 index 0000000..fe1722c --- /dev/null +++ b/claude-plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md @@ -0,0 +1,108 @@ +# Users, roles, and credential hygiene + +## Table of contents + +- [User operations](#user-operations) +- [Role model](#role-model) +- [How a role check is evaluated](#how-a-role-check-is-evaluated) +- [Invitation lifecycle](#invitation-lifecycle) +- [Guardrails on user mutations](#guardrails-on-user-mutations) +- [API key model](#api-key-model) +- [Key rotation](#key-rotation) +- [Least-privilege assignments](#least-privilege-assignments) +- [Audit evidence](#audit-evidence) + +## User operations + +| Operation | Required role | Body | Notes | +| --- | --- | --- | --- | +| `GET /v3/users` | `developer` or higher | — | Lists active, invited, and suspended users | +| `POST /v3/users` | `admin` | `email`, `name`, `role`, optional `sandbox` | `201` with status `invited`; `409 RESOURCE_007` if the user exists | +| `GET /v3/users/{userId}` | `developer` or higher | — | Single user detail | +| `PATCH /v3/users/{userId}` | `admin` | `role`, optional `sandbox` | Role change only | +| `DELETE /v3/users/{userId}` | `admin` | optional `sandbox` | Removes access | + +All five accept `x-profile-id` on an organization key to act on a child profile. None is exposed through MCP. + +The user object returns `id`, `email`, `name`, `role`, `status`, `invited_at`, `last_login_at`, `created_at`, and `updated_at`. + +The roles reference documentation states that the two read operations require "any role," while the OpenAPI specification requires `developer` or higher. Follow the specification and design for `developer` as the read floor; a `billing`-only user should not be assumed able to list users. + +## Role model + +| Role | Assignable by API | Scope of access | +| --- | --- | --- | +| `owner` | No | The account that created the organization or profile. Implicit, absent from the user list, passes every role check | +| `admin` | Yes | Passes every role check: profile management, user management, and reads | +| `developer` | Yes | Passes any-role checks; dashboard access to development, messaging, number lookup, activities, API keys, webhooks, channels, and settings | +| `billing` | Yes | Passes any-role checks; dashboard access limited to billing | + +Note the practical implication of `developer` including API keys and webhooks: a developer can create credentials and change event destinations. Treat the role as privileged even though it cannot manage users. + +## How a role check is evaluated + +The check resolves against the email address that owns the API key. It passes when that email is the owner email, or when an **active** user with that email exists on the organization or profile holding an allowed role. Users in `invited`, `suspended`, or `rejected` status fail every check. + +For a Sender Profile, checks cascade upward: owner or role access at the organization level also grants access to the organization's profiles. A user can therefore hold different effective roles across profiles when invited separately, while an organization-level role applies everywhere beneath it. + +Two operational consequences. First, an invitation that has not been accepted grants nothing, so provisioning automation must not assume access after `POST /v3/users` returns `201`. Second, revoking access at the organization level is the only way to remove cascading access; deleting a profile-level user leaves an organization-level grant intact. + +## Invitation lifecycle + +```text +POST /v3/users → status "invited" → email with token → user accepts → status "active" + └── 7 days elapse → token expires → re-invite required +``` + +Inviting an email that already has access returns `409 RESOURCE_007`; read the user list first and decide between a role change and an invitation. A provisioning flow that invites tenant staff should record the invitation timestamp and re-invite after expiry rather than retrying blindly, and should verify `status == "active"` before assuming the user can act. + +## Guardrails on user mutations + +The API refuses to let a caller change their own role, demote the last admin, remove themselves, or remove the last admin. Rather than discovering these as validation errors, check first: + +1. `GET /v3/users` and identify the target plus the count of active admins. +2. Confirm the target is not the caller's own account. +3. Confirm the change leaves at least one active admin. +4. Present the intended change and require explicit confirmation from the operator immediately before the call. +5. Record the `meta.request_id` from the response. + +Role changes and removals are effectively irreversible from the target user's perspective — re-granting requires a fresh invitation and acceptance — so treat both as destructive operations that deserve a stated diff before execution. + +## API key model + +There are two key types. An organization key can act for a child profile by sending `x-profile-id` with the profile UUID. A profile-scoped key is confined to its own profile and receives `403 AUTH_004` if it sends `x-profile-id`. + +Rate-limit exposure follows the key type: a profile key draws on its own pool, while an organization key acting through `x-profile-id` draws on the organization pool, so one noisy integration can consume quota shared by every profile. + +There is **no** endpoint to list, create, or revoke API keys. Key management happens in the Sent Dashboard, where the value is masked in the table and copied with a control. Any runbook that claims to automate key creation is wrong. + +Failed authentication is tracked per presented credential rather than per IP address: ten consecutive failures lock that credential with a `429` and escalating lockout windows from one minute up to sixty. A retry loop against a bad key therefore extends its own outage, so authentication failures must stop retrying immediately and alert instead. + +`x-sender-id` is legacy v1 and v2 terminology. It has no role in v3 authentication or routing. + +## Key rotation + +1. Create a replacement key in the dashboard. +2. Update the secret store and redeploy so the new key is in use. +3. Verify with `GET /v3/me` that the new key resolves to the expected account. +4. Disable or delete the old key. + +When a key is known to be compromised, invert the first steps and delete the old key immediately, accepting the brief outage. Keep separate keys per environment so rotating production never touches development, and never place a key in a browser, mobile app, or any client the organization does not control. + +## Least-privilege assignments + +| Workload | Credential | Role | +| --- | --- | --- | +| Server-side sends for one tenant | Profile-scoped key | `developer` | +| Provisioning new profiles | Organization key | `admin` | +| Campaign and brand registration | Organization or profile key per ownership | `admin` | +| Analytics reads | Profile-scoped key where possible | `developer` | +| Webhook management | Key matching the webhook's scope | `developer` | +| Billing review | — | `billing` | +| User administration | Organization key | `admin` | + +Prefer profile-scoped keys for runtime send paths so a leak is contained to one tenant, and reserve organization keys for control-plane operations that genuinely require cross-profile reach. + +## Audit evidence + +Every response carries `meta.request_id` and `meta.timestamp`. Log both alongside the operation name, the acting credential identifier, and — when an organization key acted through `x-profile-id` — the target profile, since the credential alone does not reveal which tenant was affected. The Sent Dashboard's Activities section, visible to owner, admin, and developer roles, is the platform-side counterpart. Because there is no API for key inventory, maintain a written register of which key exists for which environment and workload, who owns it, and when it was last rotated. diff --git a/claude-plugins/sent/skills/sent-routing-strategist/SKILL.md b/claude-plugins/sent/skills/sent-routing-strategist/SKILL.md new file mode 100644 index 0000000..e63159d --- /dev/null +++ b/claude-plugins/sent/skills/sent-routing-strategist/SKILL.md @@ -0,0 +1,76 @@ +--- +name: sent-routing-strategist +description: Decides how a Sent message should reach the recipient — automatic routing versus a pinned channel, what the channel array actually does, how fallback and reroute work, and why a message ended as FAILED, FILTERED, BLOCKED, or channel "auto". Use when choosing the channel field, expecting WhatsApp-to-SMS fallback, debugging an unexpected route or duplicate charges from multiple channels, or interpreting message status and activity evidence. +--- + +# Sent Routing Strategist + +Routing is where the most expensive Sent misconceptions live. Two facts govern almost every decision: + +1. **The `channel` array is a broadcast list, not a preference order.** `["whatsapp", "sms"]` with two recipients creates four messages and four charges. There is no `fallback` field and no ordered-preference syntax. +2. **Automatic routing is the fallback mechanism.** Omit `channel`, or send `["sent"]`, and the platform selects a route, then reroutes across up to three distinct channel-and-provider pairs when a route-level failure occurs. + +## Decide the channel value + +| Intent | Correct value | Reason | +| --- | --- | --- | +| Reach the recipient however works best | omit `channel` or `["sent"]` | Enables route selection and reroute | +| Guarantee one specific channel | `["sms"]`, `["whatsapp"]`, or `["rcs"]` | Pinning restricts matching to that channel and never crosses channels | +| Deliberately deliver the same content on several channels | `["whatsapp", "sms"]` | Broadcast; expect one message and one charge per pair | +| "Try RCS, fall back to SMS" | omit `channel` or `["sent"]` | An ordered array would broadcast; automatic routing performs the fallback | + +Any value outside `sent`, `sms`, `whatsapp`, and `rcs` returns `400`. When a user asks for ordered fallback, name the misconception explicitly before writing code, because the failure mode is duplicate delivery and duplicate cost rather than an error. + +## What a pinned channel gives up + +Pinning restricts route matching to the named channel. Rules without a channel constraint still match and resolve to the pinned channel, so pinning does not require channel-specific rules to exist. A pinned send never crosses to a different channel, though same-channel provider hops remain possible when a rule permits them. If no route exists on the pinned channel, the message ends `FAILED` with no route matched — it does not silently fall back. + +Pin when a compliance, contractual, or content constraint requires a specific channel. Otherwise prefer automatic routing. + +## Reading the outcome + +`POST /v3/messages` returns `202` with per-recipient `message_id` values. For automatic routing, the echoed per-recipient channel is not a resolved route and is never updated afterward. Resolve the truth from evidence: + +| Question | Evidence | +| --- | --- | +| Which route was actually attempted | `message.routed` event, or `channel` on `GET /v3/messages/{id}` after routing | +| Did the recipient's device receive it | `message.delivered` | +| What sequence of routes was tried | `GET /v3/messages/{id}/activities` | +| Why did it stop | Terminal status plus channel value | + +## Terminal status interpretation + +| Status | Meaning | Correct response | +| --- | --- | --- | +| `FAILED` | A route attempt failed; automatic routing may still enqueue another attempt | Inspect the latest message state and activities before treating it as final | +| `FILTERED` | Policy gate — consent block or route denial | Never retry; a consent block is a compliance stop | +| `BLOCKED` | Account precondition — balance, onboarding quota, unapproved template | Fix the account condition, then send again | +| `SCHEDULED` | Parked by quiet-hours policy | Wait; it re-enters the pipeline automatically | + +An outcome whose `channel` is `auto` means the message ended before any route was attempted. The causes are no matching route, invalid template parameters, a consent block, or an account precondition. Account preconditions do not reject the send request: it is accepted with `202` and the affected messages surface as `BLOCKED`. + +Sent records internal send-time reason codes on the message for these cases, but does not return them in API responses or webhooks, so diagnosis relies on the status-and-channel combination plus the activity history. The mapping from observable evidence to root cause is tabulated in [references/routing-diagnosis.md](references/routing-diagnosis.md). + +## Reroute behavior + +A failed route is retried only when the terminal failure signals a route or carrier problem another route might overcome: undeliverable by this route, provider service unavailable, provider timeout, or transport error. Every other failure stays `FAILED`. + +Reroute reuses the **same `message_id`** and re-runs the pipeline, so `message.queued` and `message.routed` fire again, consent gates re-apply on every attempt, and already-attempted routes are excluded. The ceiling is three distinct channel-and-provider pairs across the initial send and all reroutes. + +The WhatsApp-to-SMS behavior customers ask about is a specific case of this: a WhatsApp message accepted and then failed for a recipient-side reason reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, so subsequent automatic sends skip WhatsApp for that recipient. It requires automatic routing; a pinned WhatsApp send cannot produce it. + +## How automatic routing selects a route + +Routes come from platform-maintained rules evaluated at send time against recipient attributes (country, number prefix, exact number, carrier, number type, ported state), sender, template attributes, channel, and whether the destination is international. Ordering is: exact-recipient rules first, then account-scoped before global, then match specificity, then rule priority, then longer number prefix, then the older rule. Inactive, deleted, expired, and below-threshold rules are excluded. Candidates whose template has an explicit non-approved review status on that channel are dropped, while a channel with no recorded review is not blocked. The first surviving candidate wins and the rest remain available as fallback routes. + +There is no fixed channel preference order, so never promise "RCS first, then WhatsApp, then SMS." Read [references/routing-model.md](references/routing-model.md) before making any claim about why a specific route was chosen. + +## Cost and volume consequences + +Because broadcast multiplies messages by recipients, review any multi-channel array against expected spend before sending. A 1,000-recipient send with two channels is 2,000 messages. The per-request recipient ceiling is 1,000, and documented pacing pairs full batches with roughly one request per second to stay inside the 200-requests-per-minute budget. + +RCS today carries text plus up to four suggestion chips, mapped from template buttons, and every outbound RCS message receives an appended STOP chip. Do not design an RCS-pinned flow that depends on rich cards, carousels, or media. + +## Boundaries + +Use `sent-messaging` to execute a single send with confirmation, `sent-two-way-messaging` for consent and inbound keyword semantics, `messaging-performance-analyzer` for aggregate delivery-rate regressions, and `sent-webhook-engineer` for receiving and deduplicating the events this skill teaches you to read. diff --git a/claude-plugins/sent/skills/sent-routing-strategist/agents/openai.yaml b/claude-plugins/sent/skills/sent-routing-strategist/agents/openai.yaml new file mode 100644 index 0000000..d92c655 --- /dev/null +++ b/claude-plugins/sent/skills/sent-routing-strategist/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Routing Strategist" + short_description: "Choose channels and diagnose Sent routes" + default_prompt: "Use $sent-routing-strategist to decide how my Sent messages should reach recipients across channels." diff --git a/claude-plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md b/claude-plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md new file mode 100644 index 0000000..c5d3d06 --- /dev/null +++ b/claude-plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md @@ -0,0 +1,92 @@ +# Routing diagnosis from observable evidence + +## Table of contents + +- [Evidence sources](#evidence-sources) +- [Symptom to cause table](#symptom-to-cause-table) +- [Outcome and channel matrix](#outcome-and-channel-matrix) +- [Diagnostic sequence](#diagnostic-sequence) +- [Retry decision rules](#retry-decision-rules) +- [Cost review before a multi-channel send](#cost-review-before-a-multi-channel-send) +- [Worked examples](#worked-examples) + +## Evidence sources + +| Source | What it proves | +| --- | --- | +| `202` response `data.recipients[]` | The message ids that were created; nothing about routing | +| `GET /v3/messages/{id}` | Current status and the attempted channel once routing occurred | +| `GET /v3/messages/{id}/activities` | The sequence of attempts, which is the only way to see multiple routes | +| `message.routed` event | The concrete route chosen for that attempt | +| Terminal event `channel` | The attempted route, or `auto` when the message ended before routing | + +Sent records internal reason codes on the message for consent blocks, route denials, no-route-matched, and invalid template parameters, but does not return them through the API or webhooks. Diagnosis therefore combines the latest outcome, the channel value, and the activity history rather than reading an error code. + +## Symptom to cause table + +| Symptom | Most likely cause | Confirmation | +| --- | --- | --- | +| Recipients received the same content twice | A multi-channel array was treated as a fallback list | Count messages in the `202` response: `len(to) × len(channel)` | +| Billing higher than expected on a campaign | Same as above | Compare charged messages against recipient count | +| Expected WhatsApp-to-SMS fallback, got only a WhatsApp failure | The send pinned `["whatsapp"]` | Pinned sends never cross channels; switch to automatic routing | +| `FAILED` with channel `auto` | No routing rule matched, or template parameters were invalid | Activities show no route attempt | +| `FILTERED` with a channel value | Route denial without permitted fallback, or every candidate denied | Activities show attempts ending in denial | +| `FILTERED` with channel `auto` | Consent block before routing | Check the contact's `opt_out` state | +| `BLOCKED` | Account precondition: balance, onboarding quota, or unapproved template | Check balance and template approval state | +| Message stuck in `SCHEDULED` | Quiet-hours policy parked it | It re-enters the pipeline automatically; do not resend | +| Duplicate `queued` and `routed` events for one id | A reroute re-ran the pipeline | Activities show more than one attempted route | +| Channel changed between two events for one message | Reroute moved to another route | Expected on automatic routing | +| `400` on send | A channel value outside `sent`, `sms`, `whatsapp`, `rcs` | Inspect the request `channel` array | +| Pinned RCS message failed immediately | No route exists on the pinned channel | A pinned send does not fall back | +| No `READ` event on SMS | `READ` exists only on WhatsApp and RCS | Expected, not a defect | + +## Outcome and channel matrix + +| Outcome | Channel `auto` | Channel is a concrete route | +| --- | --- | --- | +| `FAILED` | No route matched, or invalid template parameters | One route failed; inspect newer events and activities to determine whether reroute continued | +| `FILTERED` | Consent block before routing | Route denial that did not permit fallback, or all candidates denied | +| `BLOCKED` | Account precondition evaluated before routing | Rare; treat as an account precondition regardless | +| `DELIVERED` | Not possible | Normal success | + +## Diagnostic sequence + +1. Confirm what was requested. Re-read the send body: was `channel` omitted, `["sent"]`, pinned, or multi-valued? This alone resolves most reported "fallback did not work" and "duplicate message" cases. +2. Count expected messages as `len(to) × len(channel)` and compare with the `202` response. +3. Fetch `GET /v3/messages/{id}` for a representative message and record status and channel. +4. Fetch `GET /v3/messages/{id}/activities` and list the attempted routes in order. +5. Classify the terminal state using the matrix above. +6. Decide retry eligibility using the rules below, and state the reason rather than retrying reflexively. + +## Retry decision rules + +| Terminal state | Retry | Precondition | +| --- | --- | --- | +| `FAILED` after route exhaustion on automatic routing | Only with a changed input | Three distinct routes were already tried; a new send repeats the same rules unless the recipient, template, or channel choice changes | +| `FAILED` with channel `auto` from no route matched | No | The rule set has no path to that recipient; escalate rather than loop | +| `FAILED` from invalid template parameters | Yes | After fixing the parameters | +| `FILTERED` from a consent block | Never | Sending anyway is a compliance violation | +| `FILTERED` from a route denial | No | Policy decision; escalate | +| `BLOCKED` | Yes | After the account condition is resolved | +| `SCHEDULED` | No | It resumes automatically; a resend duplicates it | +| Ambiguous send where the client never saw a response | Retry only with the original key | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient that proves non-execution | + +## Cost review before a multi-channel send + +Before executing any send whose `channel` array has more than one value, state the arithmetic to the user: recipients times channels equals messages equals charges. Confirm the intent is genuinely simultaneous multi-channel delivery. If the intent is preference or fallback, change the request to automatic routing instead. + +For volume, the per-request recipient ceiling is 1,000. Documented pacing pairs full 1,000-recipient batches with roughly one request per second to stay inside the 200-requests-per-minute limit, and rate-limit headers appear only on `429` responses, so pacing must be designed rather than discovered. + +## Worked examples + +**"We wanted WhatsApp with SMS fallback and every customer got two messages."** +The request used `["whatsapp", "sms"]`, which broadcasts. The `202` response contained two message ids per recipient, both of which were charged. The correct request omits `channel` entirely. Automatic routing then attempts a route and, on a route-level or recipient-side WhatsApp failure, reroutes to another candidate on the same `message_id`. + +**"A message failed on WhatsApp and then delivered on SMS, but our dashboard shows it as failed."** +Automatic routing rerouted on the same `message_id`, so the receiver saw `message.failed` with `channel: whatsapp` followed by newer `queued`, `routed`, and `delivered` events with `channel: sms`. The dashboard treated `FAILED` as permanently terminal. Project current state by event timestamp, preserve the append-only route history, and let a newer reroute outcome replace the earlier attempt failure. + +**"All sends to one country come back FAILED with channel auto."** +No routing rule matched for that destination. Activities show no attempted route. This is not fixable from the request payload; escalate the destination coverage rather than retrying. + +**"A pinned RCS send failed instantly and never tried SMS."** +Correct behavior. Pinning restricts matching to RCS, and a pinned send never crosses channels. Use automatic routing to allow another channel. diff --git a/claude-plugins/sent/skills/sent-routing-strategist/references/routing-model.md b/claude-plugins/sent/skills/sent-routing-strategist/references/routing-model.md new file mode 100644 index 0000000..df8eabd --- /dev/null +++ b/claude-plugins/sent/skills/sent-routing-strategist/references/routing-model.md @@ -0,0 +1,136 @@ +# Sent channel routing model + +## Table of contents + +- [Channel vocabulary](#channel-vocabulary) +- [Broadcast semantics](#broadcast-semantics) +- [Route selection pipeline](#route-selection-pipeline) +- [Pinned-channel behavior](#pinned-channel-behavior) +- [Send-time fallback across candidates](#send-time-fallback-across-candidates) +- [Reroute after delivery failure](#reroute-after-delivery-failure) +- [Where each channel value surfaces](#where-each-channel-value-surfaces) +- [Status lifecycle](#status-lifecycle) +- [Channel capability limits](#channel-capability-limits) + +## Channel vocabulary + +| Value | Meaning | +| --- | --- | +| `sent` | Automatic routing. The default when `channel` is omitted or supplied as an empty array | +| `sms` | Pin to SMS | +| `whatsapp` | Pin to WhatsApp | +| `rcs` | Pin to RCS | +| `auto` | Internal placeholder for an unresolved automatic route. Appears in responses and events, never as an input | + +Any other value returns `400`. Treat `auto` as read-only diagnostic output and never display it to an end user as a channel name. + +## Broadcast semantics + +The `channel` array enumerates channels to send on, not an order of preference. The number of messages created is `len(to) × len(channel)`, each with its own `message_id`, its own lifecycle, and its own charge. + +```json +{ + "to": ["+14155551234", "+14155555678"], + "channel": ["whatsapp", "sms"], + "template": { "name": "order_confirmation", "parameters": { "order_id": "12345" } } +} +``` + +That request creates four messages. The `202` response enumerates all four in `data.recipients[]`. + +There is no `fallback` field, no priority weighting, and no way to express "try this, then that" through the array. Ordered arrays or explicit failover objects are common on incumbent platforms, which makes porting them into Sent's channel array a high-risk migration defect. + +## Route selection pipeline + +For automatic routing, the platform evaluates its maintained routing rules at send time. Rules can constrain on: + +- recipient attributes: country, number prefix, exact number, carrier, number type, and ported state; +- the sender; +- template attributes: id, name, and category; +- channel; +- whether the destination is international. + +Candidates are ordered by: + +1. exact-recipient rules ahead of everything else; +2. account-scoped rules ahead of global rules; +3. match specificity; +4. explicit rule priority; +5. longer number prefix; +6. older rule as the final tie-break. + +Exclusions applied before selection: inactive, deleted, or expired rules; rules whose own minimum match threshold is not met; and candidates on a channel where the template carries an explicit non-approved review status such as rejected, pending, or paused. A channel with **no** recorded per-channel review is not excluded — absence of review is not disapproval. + +The first surviving candidate becomes the attempted route, the message moves to `ROUTED`, and `message.routed` fires. Remaining candidates stay available as fallback routes for that message. + +Two consequences worth stating to users. There is no fixed cross-channel preference order, so any claim like "RCS is tried first" is unsupported. And because rules are platform-maintained rather than caller-supplied, an application cannot express routing preference through the API; it can only choose automatic routing or pin a channel. + +## Pinned-channel behavior + +Pinning narrows candidate matching to the named channel. Rules that carry no channel constraint still match and resolve to the pinned channel, so pinning works even without channel-specific rules. + +A pinned message never crosses to a different channel. Same-channel provider hops remain possible when the matched rule permits them, which means a pinned SMS message can still be retried through a different provider. If no route matches on the pinned channel, the message ends `FAILED` with no route matched rather than falling back. + +Pin for a hard requirement: a compliance rule that mandates a channel, a contract that prices a channel, or content that only renders on one channel. Prefer automatic routing everywhere else. + +## Send-time fallback across candidates + +Fallback at send time walks the candidate list rather than the caller's array. When a candidate route carries a DENY decision that permits fallback, evaluation moves to the next candidate. When a DENY does not permit fallback — including the case where every candidate is denied — the message finalizes as `FILTERED` and the record carries the denied route's channel. + +This is why `FILTERED` must never be retried blindly. The gate is a policy decision, most often consent, and repeating the send reproduces the same outcome while risking a compliance violation. + +## Reroute after delivery failure + +Reroute happens only when a terminal failure indicates a route or carrier problem that another route might overcome: + +| Failure signal | Reroutes | +| --- | --- | +| Undeliverable by this route | Yes | +| Provider service unavailable | Yes | +| Provider timeout | Yes | +| Transport error | Yes | +| Recipient-side rejection on WhatsApp after acceptance | Yes, and records a recipient-scoped rule that WhatsApp is not deliverable for that number | +| Invalid content or template parameters | No | +| Consent block | No | +| Account precondition | No | +| Any other failure | No | + +Mechanics that affect application code: the reroute reuses the same `message_id`, re-runs the pipeline so `QUEUED` and `ROUTED` transitions and their webhooks fire again, excludes already-attempted routes, re-applies consent gates on every attempt, and stops at a ceiling of three distinct channel-and-provider pairs across the initial send and all reroutes. + +Therefore a `message.failed` event is not proof of final failure. Treat a message as finally failed only when its stored state remains failed and no further events arrive, and make the status projection idempotent so repeated `queued` and `routed` events do not double-count. + +## Where each channel value surfaces + +| Surface | Automatic routing | Pinned channel | +| --- | --- | --- | +| `202` response `data.recipients[].channel` | Not a resolved route, and never updated later | The pinned channel | +| `message.queued`, `message.routed`, `message.scheduled` | `sent` | The pinned channel | +| Terminal events after a route was attempted | The attempted route's channel | The pinned channel | +| Terminal events for a message that ended before routing | `auto` | `auto` | +| `GET /v3/messages/{id}` before routing | `auto` | The pinned channel | +| `GET /v3/messages/{id}` after routing | The attempted route's channel | The pinned channel | + +The practical rule: never treat the send response as routing evidence. Resolve routes from `message.routed`, from `GET /v3/messages/{id}` after routing, or from the activity history. + +## Status lifecycle + +| Status | Final for the logical message | Meaning | +| --- | --- | --- | +| `QUEUED` | No | Accepted into the pipeline | +| `SCHEDULED` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `ROUTED` | No | A channel and provider were selected | +| `SENT` | No | Handed to the provider | +| `DELIVERED` | Yes | Confirmed at the handset | +| `READ` | Yes | Read by the recipient; WhatsApp and RCS only | +| `FAILED` | Not always | One attempt failed; a newer automatic reroute may follow on the same message id | +| `FILTERED` | Yes | Policy gate: consent block or route denial | +| `BLOCKED` | Yes | Account precondition: balance, onboarding quota, unapproved template | +| `RECEIVED` | — | Inbound message | + +`FILTERED` and `BLOCKED` are not carrier failures. Feeding them into retry logic produces either a compliance problem or a retry loop that cannot succeed until an account action is taken. + +## Channel capability limits + +RCS currently supports text plus up to four suggestion chips, mapped from template buttons, with rich cards, carousels, and media on the roadmap. Every outbound RCS message receives an appended STOP chip, so an RCS surface always exposes an opt-out affordance the application did not author. + +`READ` reaches only WhatsApp and RCS; its absence on SMS is expected. Inbound support differs by channel as well — SMS inbound depends on an MO-capable provider and a supported number type, so alphanumeric sender IDs never receive replies. Route consent and inbound questions to the two-way messaging skill. diff --git a/claude-plugins/sent/skills/sent-two-way-messaging/SKILL.md b/claude-plugins/sent/skills/sent-two-way-messaging/SKILL.md new file mode 100644 index 0000000..ce9f32c --- /dev/null +++ b/claude-plugins/sent/skills/sent-two-way-messaging/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sent-two-way-messaging +description: Designs inbound and conversational Sent flows — opt-out and opt-in keyword handling, consent state on contacts, auto-replies inside the WhatsApp 24-hour window, RCS STOP chips, conversation history retrieval, and per-channel inbound capability. Use when handling message.received events, implementing STOP or HELP behavior, restoring consent after an opt-out, building a support inbox or chatbot on Sent, or paginating conversation history. +--- + +# Sent Two-Way Messaging + +Inbound messaging on Sent has one governing rule: **consent is enforced by the platform before the application sees the event.** An inbound `STOP` has already flipped the contact's `opt_out` flag by the time `message.received` arrives. The application's job is to record it, reflect it in its own UI, and never attempt to send around it. + +## Keyword handling + +Ten keywords ship as defaults: + +| Action | Keywords | +| --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | +| Help auto-reply | `HELP`, `INFO` | + +Matching requires the **entire trimmed message body** to equal a keyword, case-insensitively. "Please stop messaging me" does not match; "stop" does. Custom keywords are configured in the Sent Dashboard under Compliance, Opt Keywords, with an action of Opt Out, Opt In, or Help, and each must be a single exact token. + +Do not claim keywords that are not in the documented set. In application code, mirror the same exact-match rule only to update local subscriber state and audit evidence; never use that matcher to apply consent to Sent a second time. Keep custom dashboard keywords synchronized with the local mirror, and reconcile against the contact's `opt_out` field when uncertain. + +## Consent state + +An opt-out sets `opt_out` on the contact record. Consent is **contact-level and channel-agnostic**: a `STOP` sent over SMS suppresses WhatsApp and RCS for that contact as well. Consent gates re-apply on every reroute attempt, not only at initial send. + +Restoring consent requires the recipient's own action. A user-initiated opt-in keyword clears suppression. `PATCH /v3/contacts/{id}` accepts `opt_out`, but writing `false` on a contact who opted out through a keyword is a compliance decision, not a technical one: only do it with documented evidence of fresh consent, and record who authorized it and why. + +Downstream, a suppressed send does not fail with an error. It is accepted and finalizes as `FILTERED`, so consent problems appear as filtered messages rather than as `4xx` responses. Details are in [references/consent-and-keywords.md](references/consent-and-keywords.md). + +## Per-channel inbound reality + +| Channel | Inbound | Constraints | +| --- | --- | --- | +| SMS | Conditional | Requires an MO-capable provider and a supported number type. Alphanumeric sender IDs and SMPP paths without an inbound route never deliver inbound messages | +| RCS | Full | Typed replies match keywords; the appended STOP chip is processed directly by the consent engine | +| WhatsApp | Full | Free-form replies only inside the 24-hour customer service window; outside it, an approved template is required | + +The SMS caveat matters before promising two-way behavior: a deployment sending from an alphanumeric sender ID cannot receive `STOP` at all, which changes the compliance design rather than merely limiting a feature. + +## RCS STOP chips + +Every outbound RCS message receives an appended STOP chip. Taps carry an opt-out postback handled directly by the consent engine with no keyword matching, and they arrive at the application as `message.received` with the chip's reply text in `text`. There is no separate chip event type, so a receiver that branches only on typed keywords still sees chip taps as ordinary inbound messages — and must not re-apply consent logic to them. + +## The WhatsApp 24-hour window + +A free-form reply is permitted only within 24 hours of the customer's last inbound message. Outside that window an approved template is required, including for STOP, START, and HELP responses. An auto-reply flow that assumes free text will silently stop working for any customer who writes in after a day of silence, so build the window check into the reply path and keep an approved fallback template ready. See [references/inbound-flows.md](references/inbound-flows.md) for the reply-path decision tree. + +## Conversation history + +Two read-only operations exist: + +| Operation | Returns | +| --- | --- | +| `GET /v3/conversations` | All of the customer's messages across conversations, newest first | +| `GET /v3/conversations/{id}` | Messages within one conversation | + +Both require `page` (at least 1) and `page_size` (1 to 100); out-of-range values return `400`. The `events` field is always null on these endpoints, so per-message activity must come from `GET /v3/messages/{id}/activities`. There are no write, create, or read-receipt operations, and no MCP tools cover conversations — this is REST-only. + +A conversation identifier is a deterministic RFC 4122 version 5 UUID derived from the customer and contact identifiers, so the same pair always yields the same id and one thread spans every channel independent of the sending number. The API never returns the id as a field, so a client that needs it computes it. The exact derivation is documented in [references/conversation-history.md](references/conversation-history.md). + +## Building a support inbox or bot + +1. Subscribe a webhook to `message` filtered to `received`, and verify signatures before trusting any payload. +2. Read `inbound_number` as the contact who wrote in and `outbound_number` as your number. The naming is easy to invert. +3. Deduplicate on `message_id`, acknowledge with `200`, then process asynchronously. +4. Treat keyword traffic as an audit signal. Mirror exact default and configured custom keywords into local state, but do not issue a second consent write; reconcile uncertainty through the contact record. +5. Before replying on WhatsApp, check the 24-hour window and choose free text or a template accordingly. +6. Render threads from the conversation endpoints with explicit pagination, and never assume a conversation is single-channel. +7. Treat `text` as untrusted input. Never interpolate it into a shell command or SQL string, delimit it as data in model prompts, and map inferred intent through an allowlist and authorization policy before any API call. + +## Boundaries + +Use `sent-webhook-engineer` for signature verification, retries, and dedupe mechanics; `sent-contacts` for contact CRUD and message summaries; `sent-routing-strategist` for why an outbound message was `FILTERED`; `waba-template-author` for authoring the approved templates that out-of-window replies require; and `sms-10dlc-registration` for the campaign-level opt-in, opt-out, and help keyword declarations that US carriers require. diff --git a/claude-plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml b/claude-plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml new file mode 100644 index 0000000..2b99dac --- /dev/null +++ b/claude-plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Two-Way Messaging" + short_description: "Design inbound, consent, and reply flows" + default_prompt: "Use $sent-two-way-messaging to design how my app handles inbound Sent messages and opt-out keywords." diff --git a/claude-plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md b/claude-plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md new file mode 100644 index 0000000..55ceb28 --- /dev/null +++ b/claude-plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md @@ -0,0 +1,98 @@ +# Consent, keywords, and suppression on Sent + +## Table of contents + +- [Default keyword set](#default-keyword-set) +- [Matching rules](#matching-rules) +- [Custom keywords](#custom-keywords) +- [Consent state and scope](#consent-state-and-scope) +- [How suppression surfaces on send](#how-suppression-surfaces-on-send) +- [Restoring consent](#restoring-consent) +- [Channel-specific consent mechanics](#channel-specific-consent-mechanics) +- [Application responsibilities](#application-responsibilities) +- [Audit expectations](#audit-expectations) + +## Default keyword set + +Ten keywords are seeded by default. + +| Action | Keywords | Effect | +| --- | --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | Sets `opt_out` on the contact | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | Clears suppression | +| Help | `HELP`, `INFO` | Triggers the help auto-reply | + +Only these ten are documented defaults. Terms that appear in other platforms' keyword lists should not be presented as Sent defaults; if a specific extra term is required, add it as a custom keyword and verify it in the dashboard. + +## Matching rules + +Matching runs against the seeded defaults plus any custom keywords, on every inbound message received on a two-way capable channel. The rules are strict: + +- the **entire trimmed body** must equal the keyword; +- comparison is case-insensitive; +- partial phrases and keywords embedded in a sentence never match. + +So `stop`, `STOP`, and ` Stop ` all opt the contact out, while `please stop texting me` does not. This is deliberate: loose matching would opt out customers who used the word incidentally. It also means a real-world opt-out intent expressed in a sentence will not be caught automatically, which is a reason to route inbound text to a human queue rather than assuming keyword coverage is complete. + +Mirror this exact matcher in the application when local subscriber state or audit evidence is required. The platform has already applied consent by the time the event arrives, so the local matcher must never issue a second consent write. Keep configured custom keywords synchronized and reconcile uncertain state from the contact's `opt_out` field. + +## Custom keywords + +Custom keywords are configured in the Sent Dashboard under Compliance, then Opt Keywords. Each entry names a single exact token and one action: Opt Out, Opt In, or Help. There is no REST or MCP surface for keyword management, so keyword changes are a dashboard operation that cannot be scripted; treat the configured set as an environment fact to be read, documented, and version-controlled in the application's own runbook. + +When designing custom keywords, prefer short single tokens in the languages the audience actually writes in, and avoid tokens that collide with normal replies such as `YES` or `NO` if those are used for other flows. + +## Consent state and scope + +An opt-out flips `opt_out` on the contact record. Two properties of that state are load-bearing: + +**Contact-level.** Consent attaches to the contact, not to a campaign, template, or sending number. There is no per-template or per-campaign suppression list. + +**Channel-agnostic.** A keyword received on any channel suppresses every channel. A customer who texts `STOP` over SMS will not receive WhatsApp or RCS messages either. Applications that model consent per channel will over-send relative to the platform and see the difference as filtered messages. + +Consent gates re-apply on **every** reroute attempt, not only at the initial send. A message that passed the gate at send time is still re-checked when automatic routing retries it on another route. + +## How suppression surfaces on send + +A send to a suppressed contact is not rejected with a `4xx`. The request is accepted, and the affected message finalizes as `FILTERED` with a terminal channel value of `auto` when the block occurred before routing. Consequences: + +- Consent problems appear in delivery data rather than in API error handling, so a client that only inspects HTTP status codes will not notice them. +- `FILTERED` must never be retried. Retrying a consent block is a compliance violation, and it cannot succeed. +- Consent-driven filtering should be monitored as its own metric. A rising filtered rate usually means a stale local suppression list rather than a delivery problem. + +## Restoring consent + +Consent restoration is the recipient's decision. The clean path is a user-initiated opt-in keyword, which clears suppression through the same engine that set it. + +`PATCH /v3/contacts/{id}` accepts `opt_out` as a writable field, so it is technically possible to clear the flag from the API. Treat that as a compliance action rather than a data fix: + +1. Require documented evidence of fresh consent — a form submission, a recorded confirmation, or a written request. +2. Record who authorized the change, when, and against which evidence. +3. Never bulk-clear `opt_out` across a contact list, and never clear it to "fix" a filtered-message metric. +4. Prefer asking the customer to text an opt-in keyword, which produces platform-side evidence. + +An agent asked to clear `opt_out` should surface the compliance implication and require explicit confirmation naming the consent evidence before proceeding. + +## Channel-specific consent mechanics + +| Channel | Inbound keyword path | Notes | +| --- | --- | --- | +| SMS | Requires an MO-capable provider and a supported number type | Alphanumeric sender IDs and SMPP paths without an inbound route never deliver `STOP`; plan compliance around that limitation | +| RCS | Typed replies match keywords; the appended STOP chip bypasses matching | Every outbound RCS message carries a STOP chip whose tap is handled directly by the consent engine | +| WhatsApp | Full inbound support | Replies to STOP, START, or HELP outside the 24-hour window require an approved template | + +The RCS STOP chip means an RCS deployment always exposes an opt-out affordance the application did not author, and its taps arrive as ordinary `message.received` events with the chip's reply text in `text`. There is no distinct event type for a chip tap. + +## Application responsibilities + +The platform owns enforcement; the application owns reflection and evidence. + +- Mirror `opt_out` into local state by exact-matching the documented and configured keyword set, and reconcile from `GET /v3/contacts/{id}` when uncertain. +- Never use the local text match to re-apply consent to Sent; it is a mirror and audit mechanism only. +- Show suppression state in any internal UI where staff could otherwise trigger a send. +- Keep the local mirror reconciled on a schedule, since a keyword can arrive at any time and a stale mirror produces filtered messages. +- For US SMS, keep the campaign-level opt-in, opt-out, and help keyword declarations consistent with what is actually configured; carrier registration expects them to match. + +## Audit expectations + +Retain, per consent change: the inbound `message_id`, the received timestamp, the channel, the exact matched text, and the resulting state. Inbound keyword events are the strongest evidence available that a customer opted out or back in, and they are the artifact a carrier or regulator will ask for. Because Sent applies consent before the event is delivered, the event is a record of a completed action, and treating it as a request to perform an action risks double-processing. diff --git a/claude-plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md b/claude-plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md new file mode 100644 index 0000000..f32649f --- /dev/null +++ b/claude-plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md @@ -0,0 +1,55 @@ +# Conversation history retrieval + +## Operations + +| Operation | Returns | Required query parameters | +| --- | --- | --- | +| `GET /v3/conversations` | All of the calling customer's messages across conversations, newest first | `page` (>= 1), `page_size` (1–100) | +| `GET /v3/conversations/{id}` | Messages within one conversation | `page` (>= 1), `page_size` (1–100) | + +Both are read-only. There is no create, update, delete, participant, or read-receipt operation, and no MCP tool covers conversations, so this is REST-only work even inside an MCP-authorized session. Out-of-range pagination values return `400`; missing credentials return `401`. + +The OpenAPI summaries for these two operations are swapped relative to their descriptions — the list endpoint is summarized as "list conversation messages" while its description says messages across all conversations, and the single-conversation variant reads the opposite way. Trust the descriptions and the behavior above. + +## Message record fields + +Each returned message carries: + +`id`, `customer_id`, `contact_id`, `phone`, `phone_international`, `region_code`, `template_id`, `template_name`, `template_category`, `channel`, `message_body` (with `header`, `content`, `footer`, and `buttons`), `status`, `direction` (`INBOUND` or `OUTBOUND`), `created_at`, `price`, `active_contact_price`, and `events`. + +`events` is always null on these endpoints. Per-message activity history must come from `GET /v3/messages/{id}/activities`, which is also the only place a reroute's sequence of attempted routes is visible. + +## Conversation identifiers + +A conversation id is a deterministic RFC 4122 version 5 UUID. It is derived from the namespace `9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99` and the name `{customer_id}:{contact_id}`, where both identifiers are lowercase canonical UUIDs and the customer id comes first. Version 5 uses SHA-1 name-based hashing, equivalent to PostgreSQL's `uuid_generate_v5`. + +```python +import uuid + +NAMESPACE = uuid.UUID("9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99") + + +def conversation_id(customer_id: str, contact_id: str) -> str: + name = f"{customer_id.lower()}:{contact_id.lower()}" + return str(uuid.uuid5(NAMESPACE, name)) +``` + +Three properties follow. The same customer-and-contact pair always yields the same id, so it can be computed offline and used as a stable local key. The API never returns the id as a field, so a client that needs it must derive it. And because the id depends only on customer and contact, **one thread spans every channel** and is independent of which sending number or channel was used — a customer who moves between SMS and WhatsApp stays in one conversation. + +## Pagination strategy + +Always pass `page` and `page_size` explicitly; there is no usable default. Results are newest-first, so page 1 is the most recent slice and a thread view should either reverse each page for display or fetch from the oldest page. + +Because the collection grows while it is being read, a long backfill can shift items across page boundaries. For an initial sync, page through quickly with `page_size: 100` and reconcile by message `id`, then switch to incremental updates driven by `message.received` and status webhooks rather than repeated full scans. Pacing matters as well: the standard limit is 200 requests per minute and quota headers appear only on `429` responses, so a paginated backfill must be throttled by design. + +## Choosing between conversations and messages endpoints + +| Need | Use | +| --- | --- | +| Render a customer thread | `GET /v3/conversations/{id}` with derived id | +| Show a recent-activity feed across all customers | `GET /v3/conversations` | +| Current status of one specific message | `GET /v3/messages/{id}` | +| Route attempts and reroute history | `GET /v3/messages/{id}/activities` | +| React to new inbound traffic in real time | `message.received` webhook | + +Do not poll the conversation endpoints for near-real-time inbound handling. Webhooks are the delivery mechanism for new inbound messages, and polling both wastes quota and adds latency. diff --git a/claude-plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md b/claude-plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md new file mode 100644 index 0000000..ad0b249 --- /dev/null +++ b/claude-plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md @@ -0,0 +1,112 @@ +# Inbound flow design + +## Table of contents + +- [Inbound event shape](#inbound-event-shape) +- [Reply-path decision tree](#reply-path-decision-tree) +- [The WhatsApp 24-hour window](#the-whatsapp-24-hour-window) +- [Support inbox architecture](#support-inbox-architecture) +- [Bot and auto-reply design](#bot-and-auto-reply-design) +- [Treating inbound content as untrusted](#treating-inbound-content-as-untrusted) +- [Testing an inbound flow](#testing-an-inbound-flow) + +## Inbound event shape + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "Where is my order?", + "channel": "whatsapp", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in; `outbound_number` is your number that received it. Inverting these is a common defect that sends replies to your own number. `text` may be null for non-text payloads. The application's tenant identifier is never present, so map ownership from `outbound_number` to the tenant and profile that own it. + +Dedupe on `message_id`. Inbound events, like all Sent webhook deliveries, may be retried. + +## Reply-path decision tree + +```text +inbound message.received +├── Is text an exact keyword match handled by the platform? +│ ├── Opt out / opt in → consent already applied. Record it. Do NOT reply with your own confirmation +│ │ unless product requirements demand one, and never re-set consent yourself. +│ └── Help → the platform sends the help auto-reply. Optionally log it. +└── Anything else + ├── channel == whatsapp + │ ├── within 24h of the contact's last inbound → free-form reply permitted + │ └── outside 24h → an approved template is required + ├── channel == rcs → reply permitted; a STOP chip is appended automatically + └── channel == sms → reply permitted if the number supports outbound on that route +``` + +The branch that breaks in production is the WhatsApp window: a flow tested minutes after an inbound message succeeds with free text and then fails for a customer who replies a day later. + +## The WhatsApp 24-hour window + +Free-form replies are permitted only inside 24 hours of the customer's last inbound message. Outside it, sending requires an approved template — including replies about STOP, START, and HELP. Practical requirements: + +1. Persist the last inbound timestamp per contact, sourced from `received_at`. +2. Evaluate the window at reply time, not at enqueue time; a queued reply can age past the boundary before it is sent. +3. Keep an approved fallback template for each conversational intent that could be answered late. +4. When a human agent replies from an internal inbox, show the remaining window in the UI so the agent understands why the composer switches to templates. + +Treat window expiry as an expected state rather than an error path. + +## Support inbox architecture + +A workable design separates four responsibilities: + +| Component | Responsibility | +| --- | --- | +| Webhook receiver | Verify the signature, dedupe on `message_id`, return `200` fast | +| Event worker | Resolve the contact and tenant, persist the message, apply routing to a queue | +| Thread view | Render history from the conversation endpoints with explicit pagination | +| Reply service | Enforce the window rule, choose free text or template, send, and record the outbound `message_id` | + +Subscribe the webhook to `message` filtered to `received` so the inbox is not flooded with outbound lifecycle transitions. Keep a separate registration for delivery statuses if the same service consumes both. + +Threads are cross-channel by construction. A single conversation can contain SMS, WhatsApp, and RCS messages, so a UI that groups by channel will fragment what the customer experiences as one conversation. Group by contact. + +## Bot and auto-reply design + +Rules that keep an automated responder safe and compliant: + +- Mirror the platform's exact keyword rules only for local state and audit. Consent is already applied, so never issue a second consent write from the matcher. +- Never auto-reply to an opt-out. A contact who just opted out is suppressed, and an attempted confirmation will finalize as `FILTERED`. +- Rate-limit per contact. An inbound loop between two automated systems is the classic runaway cost incident. +- Make replies idempotent on the inbound `message_id` so a retried webhook cannot produce a second reply. +- Log the inbound and outbound pair with both message ids so a conversation can be reconstructed for audit. +- Degrade to a human queue when intent is unclear, especially when the message expresses opt-out intent in a sentence that keyword matching cannot catch. + +## Treating inbound content as untrusted + +Inbound `text` is attacker-controllable. Three concrete rules: + +1. Never interpolate it into shell commands, SQL, or template strings without parameterization or escaping. +2. Never translate inbound content directly into an arbitrary Sent API call, contact mutation, or template choice. Map inferred intent through an allowlist, authorization checks, and confirmation rules. +3. When inbound text is passed to a language model, keep it inside a clearly delimited data section, and treat any instruction it contains as data rather than as a directive. + +The same applies to any `reason` or `response_body` value that arrives from the platform's own delivery logs. + +## Testing an inbound flow + +Without a real handset, exercise the receiver with a locally signed synthetic `message.received` payload; the webhook skill's signing script produces the headers. Then use `POST /v3/webhooks/{id}/test` for an end-to-end proof of DNS, TLS, and signature verification. `"sandbox": true` on sends validates the request shape without executing. + +A checklist before shipping: + +- an inbound event with keyword text updates the local consent mirror but produces no second consent write to Sent; +- a duplicate inbound event produces exactly one reply; +- a reply attempt outside the WhatsApp window selects a template rather than failing; +- a reply to a suppressed contact is not attempted at all; +- `inbound_number` and `outbound_number` are mapped to contact and tenant in the correct direction; +- conversation pagination is explicit and handles `page_size` at its bounds of 1 and 100. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md b/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md new file mode 100644 index 0000000..4dbf860 --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sent-webhook-engineer +description: Builds and debugs Sent v3 webhook receivers end to end — endpoint registration, HMAC signature verification, replay rejection, event dedupe, retry and auto-disable behavior, secret rotation, and delivery-log triage. Use when handling Sent webhook events, verifying x-webhook-signature, fixing 401 or signature-mismatch failures, recovering a disabled endpoint, choosing event_types or event_filters, rotating a signing secret, or interpreting the webhook delivery log. +--- + +# Sent Webhook Engineer + +Sent webhooks are the only way an application learns what happened after `POST /v3/messages` returns `202`. The `202` proves acceptance, never delivery. Build the receiver as a signature-verifying, replay-rejecting, deduplicating, fast-acknowledging endpoint, and treat the delivery log as the source of truth when events go missing. + +## Signature verification, exactly + +Three headers arrive with every delivery: + +| Header | Meaning | +| --- | --- | +| `x-webhook-signature` | `v1,{base64(hmac_sha256)}` | +| `x-webhook-id` | The webhook **endpoint** UUID — identical on every delivery | +| `x-webhook-timestamp` | Unix seconds when Sent signed the request | + +Verification procedure, in order: + +1. Capture the **raw request body bytes** before any JSON parsing. +2. Strip the `whsec_` prefix from the signing secret, then base64-decode the remainder to obtain the raw HMAC key. +3. Build the signed content as `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`. +4. Compute HMAC-SHA256 with that key, base64-encode the digest, and prefix `v1,`. +5. Compare with a constant-time comparison. +6. Reject when `abs(now - timestamp) > 300` seconds. + +The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. + +**`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. + +## Failure triage order + +When a receiver rejects or misses events, work this sequence rather than guessing: + +1. **Signature mismatch** — a body-mutating middleware or framework JSON parser is the cause in the majority of cases. Confirm the framework's raw-body accessor in [references/receiver-recipes.md](references/receiver-recipes.md). +2. **Replay rejection** — server clock skew beyond the 300-second tolerance. +3. **Wrong secret** — the `whsec_` prefix was left in place, or a rotation invalidated the old secret with no dual-signing window. +4. **Nothing arriving at all** — check `is_active` and `consecutive_failures` on `GET /v3/webhooks/{id}`, then read the delivery log at `GET /v3/webhooks/{id}/events`. +5. **Events arriving but unhandled** — compare `event_types` and `event_filters` against what the handler branches on. + +## Retry, auto-disable, and recovery + +A delivery attempt fails on any non-2xx status, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff with the first retry roughly one minute after the failure, doubling thereafter and capped at 60 minutes between attempts, stopping on the first 2xx or when `retry_count` is exhausted. Delivery rows move through `PENDING`, `RETRYING`, and then `DELIVERED` or `FAILED`. + +`consecutive_failures` tracks consecutive failed delivery attempts. Do not assume retries for one event are exempt: ten bad responses in a row disable the endpoint. After fixing the receiver, re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the Sent Dashboard. Any successful delivery resets the counter to zero. Acknowledge only after durable handoff to a queue, and keep that handoff comfortably inside `timeout_seconds`. + +## Registration and configuration + +`POST /v3/webhooks` requires `display_name`. Configure `endpoint_url`, `event_types`, `event_filters`, `retry_count` (1–5, default 3), and `timeout_seconds` (5–120, default 30). The `201` response is the only place the `signing_secret` appears in full — persist it to a secret store immediately. + + +```json +{ + "display_name": "Production delivery events", + "endpoint_url": "https://hooks.example.com/webhooks/sent", + "event_types": ["message", "templates"], + "event_filters": { + "message": ["delivered", "failed", "received"] + }, + "retry_count": 3, + "timeout_seconds": 30 +} +``` + +Set `event_filters` deliberately. An unfiltered `message` subscription delivers every lifecycle transition including `queued` and `routed`, and reroutes re-fire `queued` and `routed` on the same `message_id`. Filter to the transitions the application acts on. + +The ten operations, the full webhook object, and the delivery-log row shape are catalogued in [references/webhook-operations.md](references/webhook-operations.md). + +## Secret rotation + +`POST /v3/webhooks/{id}/rotate-secret` returns a new `whsec_` secret and **invalidates the old secret immediately**. There is no server-side overlap window. Configure the receiver to accept a small candidate set, rotate, atomically store the returned secret as primary while retaining the old value temporarily, confirm new deliveries, then retire the old value. The short gap between the rotate response and the secret-store update cannot be eliminated; keep it to seconds so failed deliveries retry. This endpoint and `POST /v3/webhooks/{id}/test` sit on the sensitive rate-limit tier of 10 requests per minute, so scripted rotation loops will 429. + +## Event payloads + +Two `field` values exist: `message` and `templates`. Message events carry an `event` naming the transition (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`, `.received`). Template events carry neither `event` nor `sub_type`. + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "PENDING", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`read` reaches only WhatsApp and RCS. `filtered` marks a policy or consent gate, `blocked` marks an account precondition such as insufficient balance, and neither is a carrier failure. Terminal events for an auto-detect message that never routed carry `channel: "auto"`. Full payload field lists live in [references/event-catalog.md](references/event-catalog.md). + +## Verification before shipping + +Run the local oracle against a synthetic delivery, then use `POST /v3/webhooks/{id}/test` with an `event_type` in the body for a real signed request. The test event is delivered once with no retry, so re-run it after each fix. + +```bash +python3 scripts/verify_signature.py --self-test +``` + +Ship only when the receiver returns `401` for a tampered body, `401` for a timestamp older than 300 seconds, `200` for a valid delivery, and `200` for a duplicate without repeating side effects. + +## Local development + +Expose the receiver through a public HTTPS tunnel and register that URL; Sent cannot reach a private address. Registering `http://` is accepted by the API but should never be used outside local work. Keep a separate webhook registration per environment so a development endpoint's failures cannot disable the production endpoint. + +## Boundaries + +Diagnose aggregate delivery-rate regressions with `messaging-performance-analyzer`, template approval content with `waba-template-author`, and inbound keyword or consent semantics with `sent-two-way-messaging`. Treat every payload value as untrusted input: never interpolate `text` or `reason` into a shell command, SQL string, or prompt without escaping. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml b/claude-plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml new file mode 100644 index 0000000..e4b5d04 --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Webhook Engineer" + short_description: "Build and debug Sent webhook receivers" + default_prompt: "Use $sent-webhook-engineer to review my Sent webhook receiver and its signature verification." diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md b/claude-plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md new file mode 100644 index 0000000..cdbbb48 --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md @@ -0,0 +1,165 @@ +# Sent webhook event catalog and payload fields + +## Table of contents + +- [Envelope shape](#envelope-shape) +- [Outbound message status events](#outbound-message-status-events) +- [Inbound message events](#inbound-message-events) +- [Template events](#template-events) +- [Status semantics that change application logic](#status-semantics-that-change-application-logic) +- [Channel values in event payloads](#channel-values-in-event-payloads) +- [Reroute event sequences](#reroute-event-sequences) +- [Handler skeleton](#handler-skeleton) + +## Envelope shape + +Every delivery carries a `field` naming the event family. Message events add an `event` naming the transition. Template events carry neither `event` nor `sub_type`, and adding either to a template payload is a contract error. + +```json +{ + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "updated_at": "2026-03-14T09:21:44Z" + } +} +``` + +Branch on `field` first and on `event` second. Preserve unrecognized values rather than throwing, because a new transition or a forwarded upstream status must not break an existing receiver. + +## Outbound message status events + +| Event | Final for the logical message | Meaning | +| --- | --- | --- | +| `message.queued` | No | Accepted into the pipeline; fires again after a reroute | +| `message.routed` | No | A concrete channel and provider were selected; fires again after a reroute | +| `message.sent` | No | Handed to the provider | +| `message.delivered` | Yes | Provider confirmed handset delivery | +| `message.read` | Yes | Recipient read the message; WhatsApp and RCS only | +| `message.failed` | Not always | One route attempt failed; automatic routing may queue another attempt on the same message id | +| `message.scheduled` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `message.filtered` | Yes | Blocked by a policy gate such as consent or a route denial | +| `message.blocked` | Yes | Blocked by an account precondition such as insufficient balance | + +Payload fields on status events include `message_id`, `message_status`, `channel`, `account_id`, `updated_at`, and a sender-profile identifier when the send was profile-scoped. The application's own tenant identifier is never present, so keep a `message_id` mapping written before the send. + +## Inbound message events + +`message.received` carries a distinct payload: + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "STOP", + "channel": "sms", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in and `outbound_number` is the number they wrote to; the naming trips up receivers that assume `inbound` means "our side." `text` may be null for non-text payloads. RCS suggestion-chip taps, including the appended STOP chip, arrive as `message.received` with the chip's reply text in `text` — there is no separate chip event type. Consent keywords are processed by Sent before the event reaches the application, so an inbound `STOP` is an audit record of an opt-out that already happened, not a request to perform one. + +## Template events + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`whatsapp_template_id` is an empty string until Meta approves the template. Documented statuses are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`, and upstream Meta values such as `PAUSED` and `DISABLED` are forwarded verbatim — treat the field as an open string set. `reason` is present only when a reason exists, most often on rejection. Route rejection content to template authoring rather than trying to auto-correct copy in the receiver. + +## Status semantics that change application logic + +`filtered` and `blocked` are not carrier failures and must not feed retry logic. A `filtered` message hit a policy gate, most often consent or a route denial, so retrying the same send reproduces the same result and, for consent, would be a compliance violation. A `blocked` message hit an account precondition such as insufficient balance, an onboarding quota, or an unapproved template; the fix is an account action, after which a new send is appropriate. + +`delivered` is the first event that proves the recipient's device received the message. The `202` from the send endpoint proves only acceptance, and `sent` proves only provider handoff. Any business rule with legal or financial weight should key on `delivered`. + +`read` exists only on WhatsApp and RCS, and its absence on SMS is expected rather than a failure. + +## Channel values in event payloads + +| Value | Where it appears | Interpretation | +| --- | --- | --- | +| `sms`, `whatsapp`, `rcs` | Any event after routing | The concrete attempted route | +| `sent` | `message.queued`, `message.routed`, `message.scheduled` for automatic routing | Automatic routing requested; not yet a resolved route | +| `auto` | Terminal events for a message that ended before routing | Internal placeholder; the message never reached a channel | +| `null` | Per-recipient echo in the send response for auto-detect | Not a resolved route | + +A terminal event carrying `auto` means the message failed or was gated before any route was attempted, which points at no matching route, invalid template parameters, a consent block, or an account precondition. Do not display `auto` to end users as a channel name. + +## Reroute event sequences + +Automatic routing may retry a failed message on another route, up to three distinct channel-and-provider pairs across the initial send and all reroutes. The retry re-runs the pipeline on the **same `message_id`**, so the event stream for one logical send can look like this: + +```text +message.queued channel=sent +message.routed channel=whatsapp +message.sent channel=whatsapp +message.failed channel=whatsapp (route-level failure) +message.queued channel=sent (reroute begins, same message_id) +message.routed channel=sms +message.sent channel=sms +message.delivered channel=sms +``` + +Three consequences for receiver design. A `message.failed` is not necessarily final, so reconcile the current message and activity state before triggering an irreversible failure action. Repeated `queued` and `routed` events for one id are normal and must be idempotent. The channel can change mid-stream, so store the channel per event rather than overwriting a single field and assuming it is stable. + +Only route-level or carrier-level failures trigger a reroute. Recipient-level and content-level failures stay failed. A WhatsApp message that was accepted and then failed for a recipient-side reason both reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, which is the mechanism behind the WhatsApp-to-SMS fallback that customers observe on automatic routing. + +## Handler skeleton + +```python +from hashlib import sha256 + + +def handle(event: dict, raw_body: bytes) -> None: + field = event.get("field") + value = event.get("value", {}) + + if not record_receipt_once(sha256(raw_body).hexdigest(), event): + return # exact transport retry; still answer 200 + + if field == "templates": + apply_template_state_once(value["template_id"], value.get("status")) + return + + if field != "message": + record_unknown_event(event) + return + + name = event.get("event") + if name == "message.received": + record_inbound(value) # consent already applied upstream + return + + # Compare payload.updated_at with the projected event timestamp. Do not use + # a global status rank: FAILED may be followed by a successful reroute. + apply_if_newer(value["message_id"], value, value.get("updated_at")) + + if value.get("message_status") == "DELIVERED": + perform_once(f"{value['message_id']}:DELIVERED", on_delivered, value) +``` + +Return `200` before doing slow work. Every branch, including the unknown-event branch, must acknowledge rather than raise, and genuine handler failures should return a non-2xx so Sent retries instead of silently discarding the event. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md new file mode 100644 index 0000000..0609e5a --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -0,0 +1,246 @@ +# Webhook receiver recipes by framework + +Every recipe follows the same four steps: read the raw body, verify the signature and timestamp, acknowledge with `200`, then process asynchronously. Only the raw-body accessor and the background mechanism change. + +## Table of contents + +- [Raw-body accessor matrix](#raw-body-accessor-matrix) +- [Node and TypeScript](#node-and-typescript) +- [Python](#python) +- [Go](#go) +- [Java and Spring Boot](#java-and-spring-boot) +- [PHP](#php) +- [Ruby](#ruby) +- [ASP.NET Core](#aspnet-core) +- [Deployment traps](#deployment-traps) + +## Raw-body accessor matrix + +| Framework | Accessor | Trap that breaks the signature | +| --- | --- | --- | +| Next.js route handler | `await request.text()` | Keep the route on the Node runtime; do not re-parse the body first | +| Express | `express.raw({ type: 'application/json' })` on the webhook path | A global `express.json()` replaces the exact bytes | +| NestJS | `req.rawBody` | Requires `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | `await request.body()` | Binding a Pydantic model first re-serializes | +| Django | `request.body` | Middleware that consumes the stream before the view | +| Flask | `request.get_data()` | `request.get_json()` first loses byte fidelity | +| Gin | `io.ReadAll(c.Request.Body)` | Body must be restored if later handlers read it | +| Echo | `io.ReadAll(c.Request().Body)` | Same | +| Spring Boot | `@RequestBody String payload` | Binding to a DTO re-serializes | +| Laravel | `$request->getContent()` | Middleware ordering; verify before any transform | +| Symfony | `$request->getContent()` | Same | +| Rails | `request.body.read` then `request.body.rewind` | `params` re-encodes the payload | +| Sinatra | `request.body.read` then `request.body.rewind` | Same | +| ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | + +Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. + +## Node and TypeScript + +```ts +import crypto from "node:crypto"; + +const TOLERANCE_SECONDS = 300; + +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { + const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; + if (!secret || !header?.startsWith("v1,")) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; + + const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); + const digest = crypto.createHmac("sha256", key).update(`${webhookId}.${timestamp}.${rawBody}`).digest("base64"); + const expected = Buffer.from(`v1,${digest}`); + const received = Buffer.from(header); + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} +``` + +Next.js route handler, kept on the Node runtime: + +```ts +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + const rawBody = await request.text(); + const ok = verify( + rawBody, + request.headers.get("x-webhook-id") ?? "", + request.headers.get("x-webhook-timestamp") ?? "", + request.headers.get("x-webhook-signature") ?? "", + ); + if (!ok) return new Response("invalid signature", { status: 401 }); + + await enqueue(JSON.parse(rawBody)); // hand off, do not process inline + return new Response(null, { status: 200 }); +} +``` + +Express, scoping the raw parser to the webhook path only: + +```js +app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { + const rawBody = req.body.toString("utf8"); + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + return res.status(401).send("invalid signature"); + } + res.status(200).end(); + queue.add("sent-event", JSON.parse(rawBody)); // after the response +}); +``` + +Mount `express.json()` on other routers rather than globally with `app.use`. In NestJS create the app with `{ rawBody: true }` and read `req.rawBody`. Use BullMQ or an equivalent queue for the background step. + +## Python + +```python +import base64, hashlib, hmac, os, time + +TOLERANCE_SECONDS = 300 + + +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: + secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") + if not secret or not header.startswith("v1,"): + return False + try: + if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: + return False + except ValueError: + return False + key = base64.b64decode(secret.removeprefix("whsec_")) + signed = f"{webhook_id}.{timestamp}.".encode() + raw_body + expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() + return hmac.compare_digest(expected, header) +``` + +FastAPI: + +```python +@app.post("/webhooks/sent", status_code=200) +async def receive(request: Request, background: BackgroundTasks): + raw = await request.body() + if not verify(raw, request.headers.get("x-webhook-id", ""), + request.headers.get("x-webhook-timestamp", ""), + request.headers.get("x-webhook-signature", "")): + raise HTTPException(status_code=401, detail="invalid signature") + background.add_task(process_event, json.loads(raw)) + return {"received": True} +``` + +Django reads `request.body` in the view and must exempt the route from CSRF. Flask reads `request.get_data()` in a decorator that wraps the view. For anything slower than a database insert, hand the parsed event to Celery with `process_event.delay(event)` and route message traffic to a dedicated queue so bulk campaigns cannot starve transactional work. + +## Go + +```go +func Verify(rawBody []byte, webhookID, timestamp, header string) bool { + secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") + if secret == "" || !strings.HasPrefix(header, "v1,") { + return false + } + sentAt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 { + return false + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(webhookID + "." + timestamp + ".")) + mac.Write(rawBody) + expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(expected), []byte(header)) == 1 +} +``` + +In Gin read with `io.ReadAll(c.Request.Body)`; in Echo use `c.Request().Body`. If any later middleware needs the body, restore it with `c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))`. Acknowledge, then dispatch to a goroutine with a bounded worker pool or a durable queue, and drain in-flight work on shutdown. + +## Java and Spring Boot + +```java +@PostMapping("/webhooks/sent") +public ResponseEntity receive( + @RequestBody String payload, + @RequestHeader("x-webhook-id") String webhookId, + @RequestHeader("x-webhook-timestamp") String timestamp, + @RequestHeader("x-webhook-signature") String signature) throws Exception { + + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + return ResponseEntity.status(401).build(); + } + events.submit(payload); // @Async executor + return ResponseEntity.ok().build(); +} +``` + +Bind the body as `String`, never as a DTO, because Jackson re-serialization changes the bytes. Verify with `Mac.getInstance("HmacSHA256")` and compare using `MessageDigest.isEqual`. Push processing onto a `ThreadPoolTaskExecutor` or a broker. + +## PHP + +Laravel middleware runs before the controller and reads `$request->getContent()`: + +```php +public function handle(Request $request, Closure $next) +{ + $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); + $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); + $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); + + if (abs(time() - (int) $request->header('x-webhook-timestamp')) > 300 + || !hash_equals($expected, (string) $request->header('x-webhook-signature'))) { + abort(401); + } + return $next($request); +} +``` + +Dispatch a `ShouldQueue` job from the controller. Symfony follows the same pattern with `$request->getContent()` and a Messenger message consumed by `messenger:consume`. + +## Ruby + +```ruby +def verified?(request) + raw = request.body.read + request.body.rewind + secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") + timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s + return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 + + key = Base64.decode64(secret.delete_prefix("whsec_")) + signed = "#{request.get_header('HTTP_X_WEBHOOK_ID')}.#{timestamp}.#{raw}" + expected = "v1,#{Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', key, signed))}" + ActiveSupport::SecurityUtils.secure_compare(expected, request.get_header("HTTP_X_WEBHOOK_SIGNATURE").to_s) +end +``` + +In Rails put this in a controller concern, skip `verify_authenticity_token` for the action, and enqueue with ActiveJob. In Sinatra read the body in the route and enqueue with Sidekiq; remember the client is memoized per Puma worker process. + +## ASP.NET Core + +```csharp +app.MapPost("/webhooks/sent", async (HttpRequest request) => +{ + using var reader = new StreamReader(request.Body); + var rawBody = await reader.ReadToEndAsync(); + + if (!WebhookSignature.Verify( + rawBody, + request.Headers["x-webhook-id"], + request.Headers["x-webhook-timestamp"], + request.Headers["x-webhook-signature"], + Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + { + return Results.Unauthorized(); + } + + await channel.Writer.WriteAsync(rawBody); // BackgroundService consumer + return Results.Ok(); +}); +``` + +Read the stream before model binding touches it, compare with `CryptographicOperations.FixedTimeEquals`, and consume from a `BackgroundService`. + +## Deployment traps + +Reverse proxies and API gateways that buffer, recompress, or normalize request bodies break the signature; configure pass-through for the webhook path. Serverless platforms that hand the body as base64 require decoding to the original bytes before verification, not after. Load balancer idle timeouts shorter than `timeout_seconds` produce phantom failures that appear in the delivery log as timeouts with no `http_status_code`. Container clocks must be NTP-synchronized or the 300-second window rejects valid traffic. Finally, keep the webhook path out of user-auth middleware. If abuse controls are needed, apply signature-aware, capacity-safe controls rather than a generic pre-verification limiter that manufactures the consecutive failures leading to auto-disable. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md b/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md new file mode 100644 index 0000000..ee5f9e1 --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md @@ -0,0 +1,141 @@ +# Sent v3 webhook operations and delivery lifecycle + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Webhook object](#webhook-object) +- [Creating a webhook](#creating-a-webhook) +- [Event types and filters](#event-types-and-filters) +- [Delivery attempts, retries, and backoff](#delivery-attempts-retries-and-backoff) +- [Auto-disable and recovery](#auto-disable-and-recovery) +- [Delivery log triage](#delivery-log-triage) +- [Test deliveries](#test-deliveries) +- [Rate limits and sandbox behavior](#rate-limits-and-sandbox-behavior) +- [Environment and tenancy layout](#environment-and-tenancy-layout) + +## Operation catalog + +Ten operations manage webhooks. None of them is exposed through the Sent MCP server, so webhook work is REST-only even in an agent session that already holds an MCP connection. + +| Operation | Purpose | Notes | +| --- | --- | --- | +| `POST /v3/webhooks` | Register an endpoint | `201` body carries the only full view of `signing_secret` | +| `GET /v3/webhooks` | List endpoints | Inspect `is_active` and `consecutive_failures` here first | +| `GET /v3/webhooks/event-types` | Discover subscribable event types | Use before hardcoding an `event_types` array | +| `GET /v3/webhooks/{id}` | Inspect one endpoint | Confirms configuration and health counters | +| `PUT /v3/webhooks/{id}` | Update configuration | Replaces the mutable configuration fields | +| `DELETE /v3/webhooks/{id}` | Remove an endpoint | Ignores `sandbox` and always deletes | +| `GET /v3/webhooks/{id}/events` | Delivery log | Requires `page` and `page_size`; optional `search` | +| `POST /v3/webhooks/{id}/rotate-secret` | Replace the signing secret | Old secret dies immediately; sensitive rate tier | +| `POST /v3/webhooks/{id}/test` | Send a synthetic signed delivery | Requires `event_type`; one attempt, no retry; sensitive rate tier | +| `PATCH /v3/webhooks/{id}/toggle-status` | Enable or disable | Operational pause without losing configuration | + +## Webhook object + +| Field | Meaning | +| --- | --- | +| `id` | Endpoint UUID; the value of `x-webhook-id` on every delivery | +| `display_name` | Required label; the only required field on create | +| `endpoint_url` | Destination; scheme must be `http://` or `https://` | +| `signing_secret` | `whsec_`-prefixed secret, fully visible only in the create and rotate responses | +| `is_active` | False after auto-disable or an explicit toggle | +| `event_types` | Subscribed event families, for example `["message", "templates"]` | +| `event_filters` | Per-family narrowing, for example `{"message": ["delivered", "failed"]}` | +| `retry_count` | 1–5, default 3 | +| `timeout_seconds` | 5–120, default 30 | +| `last_delivery_attempt_at` | Timestamp of the most recent attempt of any outcome | +| `last_successful_delivery_at` | Timestamp of the most recent 2xx | +| `consecutive_failures` | Counter of consecutive failed attempts; ten disables the endpoint | +| `created_at`, `updated_at` | Audit timestamps | + +The gap between `last_delivery_attempt_at` and `last_successful_delivery_at` is the fastest health signal: a recent attempt with a stale success means the endpoint is failing right now. + +## Creating a webhook + +Only `display_name` is required, but a useful registration sets the destination, the subscriptions, and the delivery envelope explicitly. + +```json +{ + "display_name": "Staging inbound and failures", + "endpoint_url": "https://staging-hooks.example.com/webhooks/sent", + "event_types": ["message"], + "event_filters": { + "message": ["received", "failed", "filtered", "blocked"] + }, + "retry_count": 5, + "timeout_seconds": 15 +} +``` + +Choose `timeout_seconds` to match how fast the endpoint acknowledges, not how long processing takes. A receiver that returns `200` in 50 milliseconds and queues the work is compatible with the 5-second minimum; a receiver that writes to three downstream systems before responding will eventually breach even a 120-second ceiling under load and start accumulating consecutive failures. + +Choose `retry_count` against the recovery profile of the receiver. Three attempts spread over roughly seven minutes suits a stateless service behind a load balancer. Five attempts, reaching further into the capped 60-minute backoff, suits a receiver whose dependency outages last longer than a few minutes. + +## Event types and filters + +Call `GET /v3/webhooks/event-types` rather than assuming the catalog. Two `field` families exist today: `message`, which carries an `event` naming the transition, and `templates`, which carries approval-state changes without an `event` field. + +Filters matter more than they appear. An unfiltered `message` subscription delivers every transition, and because a reroute re-runs the pipeline on the same message id, `queued` and `routed` can arrive several times for one logical send. Subscribing only to the transitions the application acts on reduces both traffic and the chance of a double-processing bug. + +A practical split by consumer: + +| Consumer | Subscription | +| --- | --- | +| Delivery ledger and retries | `message` filtered to `delivered`, `failed`, `filtered`, `blocked` | +| Support inbox and auto-replies | `message` filtered to `received` | +| Read-receipt analytics | `message` filtered to `read` | +| Template governance | `templates` | +| Route debugging in a lower environment | `message` unfiltered | + +## Delivery attempts, retries, and backoff + +An attempt fails on any non-2xx response, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff: the first retry lands roughly one minute after the failure, each subsequent delay doubles, and the interval is capped at 60 minutes between attempts. Retries stop at the first 2xx or when `retry_count` is exhausted. + +Delivery rows report `delivery_status` as `PENDING` while queued, `RETRYING` between attempts, `DELIVERED` on success, and `FAILED` once attempts are exhausted. A `DELIVERED` outcome resets the endpoint's `consecutive_failures` to zero. + +Because retries are signed fresh, a retried delivery has a new `x-webhook-timestamp` and a new signature but the same payload — which is precisely why dedupe must key on payload semantics rather than on headers. + +## Auto-disable and recovery + +Ten consecutive failed delivery attempts disable the endpoint. Treat every failed attempt as capable of advancing the counter, whether it is a retry of one event or the first attempt for another; do not rely on event boundaries for protection. Any success resets the counter. + +Once `is_active` is false, Sent stops delivering. Recovery sequence: + +1. Read `GET /v3/webhooks/{id}` and confirm `is_active` and `consecutive_failures`. +2. Read the delivery log and identify the recurring `http_status_code` or `error_message`. +3. Fix the receiver and prove it locally against a signed synthetic delivery. +4. Re-enable the webhook with `PATCH /v3/webhooks/{id}/toggle-status` or in the Sent Dashboard. +5. Confirm recovery with `POST /v3/webhooks/{id}/test`, then verify the log shows `DELIVERED`. +6. Backfill the outage window from `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities` for messages whose state is stale, because events that failed permanently during the outage are not redelivered on re-enable. + +Add monitoring on `consecutive_failures` so an alert fires at three or four rather than at ten. + +## Delivery log triage + +Each row of `GET /v3/webhooks/{id}/events` contains `id`, `event_type`, `event_data`, `delivery_status`, `http_status_code`, `response_body`, `delivery_attempts`, `error_message`, `created_at`, `processing_started_at`, and `processing_completed_at`. Both `page` and `page_size` are required; omitting them returns a validation error rather than a default page. + +| Log evidence | Diagnosis | +| --- | --- | +| `http_status_code` 401 or 403 | The receiver is rejecting the signature, or authentication middleware sits in front of the webhook route | +| `http_status_code` 404 | Route path or environment mismatch in `endpoint_url` | +| `http_status_code` 5xx with a stack trace in `response_body` | Handler exception; fix the handler, not the registration | +| `error_message` naming a timeout with empty `http_status_code` | The receiver did not answer inside `timeout_seconds`; move work off the request path | +| `error_message` naming a connection or TLS failure | DNS, certificate, or firewall problem; the request never reached the application | +| `delivery_status` `DELIVERED` while the application has no record | The event was accepted and then dropped internally; instrument between acknowledgement and the queue | +| `delivery_attempts` climbing with `RETRYING` | Backoff is in progress; confirm the receiver recovered before it exhausts `retry_count` | + +Keep the receiver route outside user-auth middleware. Sent authenticates by signature, and an intervening session or Bearer-auth layer produces a 401 that looks exactly like a signature bug. + +## Test deliveries + +`POST /v3/webhooks/{id}/test` takes an `event_type` in the body and sends a real signed request with a synthetic payload to the registered URL. It is delivered once with no retry, so each fix needs a fresh call. Treat it as the end-to-end proof that DNS, TLS, routing, signature verification, and acknowledgement all work together; use the local signing script for iteration because the test endpoint is limited to 10 requests per minute. + +## Rate limits and sandbox behavior + +Standard endpoints allow 200 requests per minute on a sliding window. `rotate-secret` and `test` allow 10 per minute on a fixed window. Rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`) appear only on `429` responses, so a client cannot read remaining quota preemptively and must pace by design. + +`"sandbox": true` on create or update validates and authenticates without persisting anything, which makes it useful for checking a payload shape in CI. `DELETE /v3/webhooks/{id}` ignores the flag and always deletes, so never use sandbox as a dry-run guard for deletion. + +## Environment and tenancy layout + +Register one webhook per environment and never share an endpoint across environments. Because auto-disable is per endpoint, a development receiver returning 500s cannot then disable production. For multi-tenant systems, remember that events do not carry the application's tenant identifier: persist `message_id -> {tenant, profile, logical_send_id, channel}` before sending and map inbound events by the receiving number. Organization-scoped credentials with `x-profile-id` can manage a child profile's webhooks; profile-scoped keys manage only their own and receive `403` if they send `x-profile-id`. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md b/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md new file mode 100644 index 0000000..eb4af1f --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md @@ -0,0 +1,88 @@ +# Webhook signature verification and event deduplication + +## Table of contents + +- [Signature scheme](#signature-scheme) +- [Why the raw body matters](#why-the-raw-body-matters) +- [Replay rejection](#replay-rejection) +- [Secret handling and rotation](#secret-handling-and-rotation) +- [Deduplication without an event id](#deduplication-without-an-event-id) +- [Ordering and out-of-sequence events](#ordering-and-out-of-sequence-events) +- [Acceptance tests for a receiver](#acceptance-tests-for-a-receiver) + +## Signature scheme + +Sent signs each delivery with HMAC-SHA256 and publishes three headers. + +| Header | Example | Notes | +| --- | --- | --- | +| `x-webhook-signature` | `v1,K7t9...==` | Version tag, comma, base64 digest | +| `x-webhook-id` | `0f8fad5b-d9cb-469f-a165-70867728950e` | Endpoint UUID, constant across deliveries | +| `x-webhook-timestamp` | `1767225600` | Unix seconds | + +The signed content is the concatenation `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, where the first two components are joined by literal `.` characters and the third is the untouched request body. The HMAC key is the signing secret with the leading `whsec_` removed and the remainder base64-decoded, which yields raw key bytes rather than an ASCII string. The digest is base64-encoded and prefixed with `v1,`. + +The construction is compatible with Svix-style verification, so an existing Svix helper can usually be adapted by pointing it at these header names. No Sent SDK provides a built-in verifier in any of the seven supported languages, so this logic is application code in every deployment. + +Compare signatures with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`, `MessageDigest.isEqual`, `hash_equals`, `subtle.ConstantTimeCompare`). A plain `==` on a signature invites a timing oracle. + +## Why the raw body matters + +The signature covers exact bytes. Any transformation between the socket and the verification step invalidates it: JSON parse and re-serialize, key reordering, whitespace normalization, Unicode escaping changes, trailing-newline insertion, gzip re-encoding, or a proxy that rewrites the body. This is the single most common cause of signature failures, and it usually appears as "verification works with curl but fails behind the framework." + +Diagnose it by logging the byte length and a SHA-256 of the body at the verification point and comparing against the `Content-Length` header. A mismatch means something consumed and rebuilt the body upstream. + +## Replay rejection + +Reject a delivery when `abs(now - x-webhook-timestamp) > 300` seconds. The five-minute tolerance is the documented value and appears as `TOLERANCE_SECONDS = 300` in the official samples for every language. Two operational consequences follow. First, hosts must run NTP; clock drift beyond five minutes rejects perfectly valid traffic and the symptom looks identical to a signature bug. Second, because retries can arrive up to 60 minutes after the original attempt, each retry is signed with its own fresh timestamp — the receiver must never cache the first timestamp and compare later deliveries against it. + +For forensic replay of an archived delivery, verify the HMAC while explicitly skipping the freshness check rather than widening the production tolerance. + +## Secret handling and rotation + +The full `signing_secret` appears exactly once, in the `201` body of `POST /v3/webhooks`. Store it in a secret manager keyed by webhook id and environment. `GET /v3/webhooks/{id}` is the way to confirm which endpoint a stored secret belongs to. + +`POST /v3/webhooks/{id}/rotate-secret` returns the replacement and invalidates the previous secret immediately. There is no dual-signing window on Sent's side, so the receiver must provide the overlap: + +1. Deploy a receiver that reads a primary secret and an optional secondary secret and accepts a delivery that verifies under either. +2. Put the current secret in both slots and deploy. +3. Rotate, and write the new secret into the primary slot. +4. Confirm from the delivery log that recent attempts are `DELIVERED`. +5. Clear the secondary slot and deploy again. + +Rotate on compromise, on operator offboarding, and on a fixed schedule. The rotate endpoint is on the sensitive tier of 10 requests per minute, so automation must not loop over many webhooks quickly. + +## Deduplication without an event id + +Sent does not publish a per-event unique identifier. `x-webhook-id` names the endpoint and is identical on every delivery, so using it as a dedupe key collapses all events into a single row. Derive keys from event semantics instead: + +| Event | Idempotency key | Rationale | +| --- | --- | --- | +| Outbound status (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`) | `sha256(raw_body)` for receipt dedupe; `{message_id}:{message_status}` for one-time business effects | Exact delivery retries carry the same payload, while a reroute may legitimately repeat a status with a different channel or timestamp | +| Inbound (`message.received`) | `{message_id}` | Each inbound message has its own id | +| Template (`field: "templates"`) | `{template_id}:{status}` | Approval transitions are the meaningful unit | +| Anything unrecognized | `sha256(raw_body)` | Absorbs an exact retry without depending on a fresh retry timestamp | + +Persist the key with a unique constraint and treat an insert conflict as "already processed, return 200." A duplicate must never repeat side effects such as charging a card, sending a follow-up message, or writing a second audit row. + +Reroutes make two layers necessary. Use a raw-body hash to suppress exact transport retries, but retain distinct reroute events in an append-only receipt ledger because their channel or `updated_at` differs. Gate one-time business effects separately—for example, send a receipt only once for `{message_id}:DELIVERED`—so preserving route evidence does not duplicate side effects. + +## Ordering and out-of-sequence events + +Delivery order is not guaranteed, and a global status rank is unsafe: automatic routing can emit `FAILED`, then a newer `QUEUED`, `ROUTED`, and `DELIVERED` on the same message id. Persist the append-only receipt first, then update the current projection only when the payload's `updated_at` is newer than the projected event timestamp. Use receipt order only as a tie-breaker, preserve the attempted channel per event, and reconcile uncertain final state with `GET /v3/messages/{id}` plus activities. Never make `FAILED` permanently outrank a later successful reroute. + +## Acceptance tests for a receiver + +A receiver is ready when all of the following hold: + +1. A validly signed delivery returns `200`. +2. A body with a single byte changed returns `401`. +3. A delivery signed with a different secret returns `401`. +4. A delivery whose timestamp is 400 seconds old returns `401`. +5. A delivery whose `x-webhook-id` is altered returns `401`. +6. The same valid delivery sent twice returns `200` twice and performs side effects once. +7. A `message.delivered` followed by a late `message.sent` leaves the stored status at `DELIVERED`. +8. A handler exception still returns a non-2xx status so Sent retries, rather than swallowing the error and returning `200`. +9. Processing that exceeds one second happens after the response, not before it. + +Use `scripts/verify_signature.py --sign` to produce headers for cases 1, 6, and 7, and mutate them for the negative cases. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py new file mode 100644 index 0000000..e79391f --- /dev/null +++ b/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Reference implementation and test oracle for Sent v3 webhook signatures. + +The signed content is exactly ``{webhook_id}.{timestamp}.{raw_body}``. The key is +the signing secret with its ``whsec_`` prefix removed and the remainder +base64-decoded. The signature header value is ``v1,{base64(hmac_sha256)}``. + +Usage +----- +Self-test with synthetic fixtures (no network, no credentials):: + + python3 verify_signature.py --self-test + +Verify a captured delivery:: + + python3 verify_signature.py --body-file delivery.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --timestamp 1767225600 \ + --signature 'v1,Base64Signature==' \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Sign a synthetic delivery so a local receiver can be exercised:: + + python3 verify_signature.py --sign --body-file event.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, +3 usage or configuration error. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import sys +import time + +SECRET_PREFIX = "whsec_" +SIGNATURE_PREFIX = "v1," +TOLERANCE_SECONDS = 300 + +EXIT_VALID = 0 +EXIT_INVALID = 1 +EXIT_REPLAY = 2 +EXIT_USAGE = 3 + + +def decode_secret(secret: str) -> bytes: + """Return the raw HMAC key for a Sent signing secret.""" + if not secret: + raise ValueError("signing secret is empty") + material = secret[len(SECRET_PREFIX):] if secret.startswith(SECRET_PREFIX) else secret + padding = "=" * (-len(material) % 4) + try: + return base64.b64decode(material + padding, validate=True) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise ValueError(f"signing secret is not valid base64 after the prefix: {exc}") from exc + + +def signed_content(webhook_id: str, timestamp: str, raw_body: bytes) -> bytes: + """Build the byte string Sent signs.""" + return f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body + + +def compute_signature(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str: + """Return the full ``v1,...`` header value for a delivery.""" + digest = hmac.new( + decode_secret(secret), + signed_content(webhook_id, timestamp, raw_body), + hashlib.sha256, + ).digest() + return SIGNATURE_PREFIX + base64.b64encode(digest).decode("ascii") + + +def signature_matches(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> bool: + """Constant-time comparison of a received signature header.""" + expected = compute_signature(secret, webhook_id, timestamp, raw_body) + return hmac.compare_digest(expected, header.strip()) + + +def timestamp_fresh(timestamp: str, now: int | None = None, tolerance: int = TOLERANCE_SECONDS) -> bool: + """Return True when the timestamp is inside the replay window.""" + try: + sent_at = int(timestamp) + except (TypeError, ValueError): + return False + reference = int(time.time()) if now is None else now + return abs(reference - sent_at) <= tolerance + + +def verify(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> int: + """Return the process exit code for one delivery.""" + if not timestamp_fresh(timestamp): + return EXIT_REPLAY + return EXIT_VALID if signature_matches(secret, webhook_id, timestamp, raw_body, header) else EXIT_INVALID + + +def _self_test() -> int: + secret = SECRET_PREFIX + base64.b64encode(b"synthetic-signing-key-0123456789").decode("ascii") + webhook_id = "0f8fad5b-d9cb-469f-a165-70867728950e" + now = int(time.time()) + timestamp = str(now) + body = json.dumps( + { + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + }, + }, + separators=(",", ":"), + ).encode("utf-8") + + failures: list[str] = [] + header = compute_signature(secret, webhook_id, timestamp, body) + + if not header.startswith(SIGNATURE_PREFIX): + failures.append("signature header must start with 'v1,'") + if verify(secret, webhook_id, timestamp, body, header) != EXIT_VALID: + failures.append("a freshly signed delivery must verify") + if verify(secret, webhook_id, timestamp, body + b" ", header) != EXIT_INVALID: + failures.append("a mutated body must fail verification") + if verify(secret, "11111111-2222-3333-4444-555555555555", timestamp, body, header) != EXIT_INVALID: + failures.append("a different webhook id must fail verification") + stale = str(now - (TOLERANCE_SECONDS + 60)) + if verify(secret, webhook_id, stale, body, compute_signature(secret, webhook_id, stale, body)) != EXIT_REPLAY: + failures.append("a stale timestamp must be rejected as a replay") + if decode_secret(secret) != decode_secret(secret[len(SECRET_PREFIX):]): + failures.append("prefixed and unprefixed secrets must decode identically") + + reserialized = json.dumps(json.loads(body)).encode("utf-8") + if reserialized != body and signature_matches(secret, webhook_id, timestamp, reserialized, header): + failures.append("re-serialized JSON must not verify; raw bytes are required") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_INVALID + print("verify_signature self-test passed: 7 checks") + return EXIT_VALID + + +def _resolve_secret(args: argparse.Namespace) -> str: + if args.secret_env: + secret = os.environ.get(args.secret_env, "") + if not secret: + raise ValueError(f"environment variable {args.secret_env} is unset or empty") + return secret + raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Verify or sign a Sent v3 webhook delivery.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--sign", action="store_true", help="emit headers for a synthetic signed delivery") + parser.add_argument("--body-file", help="path to the raw request body captured byte for byte") + parser.add_argument("--webhook-id", help="value of the x-webhook-id header") + parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") + parser.add_argument("--signature", help="value of the x-webhook-signature header") + parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument( + "--skip-replay-check", + action="store_true", + help="verify the HMAC only, for forensic replay of an archived delivery", + ) + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if not args.body_file or not args.webhook_id: + parser.error("--body-file and --webhook-id are required unless --self-test is used") + + try: + raw_body = open(args.body_file, "rb").read() + secret = _resolve_secret(args) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_USAGE + + if args.sign: + timestamp = args.timestamp or str(int(time.time())) + print(f"x-webhook-id: {args.webhook_id}") + print(f"x-webhook-timestamp: {timestamp}") + print(f"x-webhook-signature: {compute_signature(secret, args.webhook_id, timestamp, raw_body)}") + return EXIT_VALID + + if not args.timestamp or not args.signature: + parser.error("--timestamp and --signature are required when verifying") + + if args.skip_replay_check: + matched = signature_matches(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + result = EXIT_VALID if matched else EXIT_INVALID + else: + result = verify(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + + print({EXIT_VALID: "valid", EXIT_INVALID: "invalid signature", EXIT_REPLAY: "replay window exceeded"}[result]) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-plugins/sent/skills/sent/SKILL.md b/claude-plugins/sent/skills/sent/SKILL.md index f13a519..9e8b3f1 100644 --- a/claude-plugins/sent/skills/sent/SKILL.md +++ b/claude-plugins/sent/skills/sent/SKILL.md @@ -49,6 +49,19 @@ Do **not** use when: | Diagnosing delivery from MDR exports, funnels, cohorts, or cross-channel failure codes | `messaging-performance-analyzer` | | Designing or auditing a tenant-facing template-builder UI | `template-builder-ui` | +### Engineering and integration + +| User intent | Target skill | +|---|---| +| Adding Sent to a codebase, choosing an SDK, or hardening retries, idempotency, and error handling before launch | `sent-integration-starter` | +| Building or debugging a webhook receiver, signature verification, dedupe, or an auto-disabled endpoint | `sent-webhook-engineer` | +| Choosing the channel field, expecting cross-channel fallback, or interpreting a route, reroute, or delivery outcome | `sent-routing-strategist` | +| Handling inbound messages, opt-out keywords, consent state, the WhatsApp 24-hour window, or conversation history | `sent-two-way-messaging` | +| Executing the Sender Profile lifecycle over the API, including completion callbacks, campaigns, and user roles | `sent-profile-provisioning` | +| Replacing Twilio, Sinch, Infobip, Vonage, or Bird with Sent, including cutover and rollback planning | `migrate-to-sent` | + +Within this group, note two frequent hand-offs: `sender-profile-architect` decides the tenancy boundary and `sent-profile-provisioning` implements it, while `migrate-to-sent` plans a provider replacement and `sent-integration-starter` hardens the resulting integration. + If the request matches one row cleanly, invoke that skill and stop. If it spans several rows, state the proposed order and begin with the prerequisite. For example, check `sent-account-readiness` before a live send, use `sent-templates` to locate an existing template before `sent-messaging`, and use `messaging-performance-analyzer` when the user provides an export rather than asking for live dashboard metrics. ## Clarifying questions to ask before routing @@ -70,7 +83,7 @@ One question per turn is fine; never fire all seven at once. This skill is not a fallback for general questions. If the user asks about: - **Balance, onboarding state, or whether the selected account can send** — use `sent-account-readiness`. - **Contracts, plan pricing, invoices, or account access that the available operations cannot answer** — direct them to Sent support or `https://docs.sent.dm`. -- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally. +- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally; route to `sent-integration-starter` once the question involves Sent's own retry, idempotency, or rate-limit contract. - **Meta, Google, TCR, or carrier policy outside a specialist skill's scope** — use current upstream documentation. If after the clarifying questions the request still doesn't fit any target skill, say so plainly. Don't force a route. diff --git a/evals/migrate-to-sent.yaml b/evals/migrate-to-sent.yaml new file mode 100644 index 0000000..113ad3d --- /dev/null +++ b/evals/migrate-to-sent.yaml @@ -0,0 +1,29 @@ +skill: migrate-to-sent +cases: + - query: "We're moving off Sinch to Sent. What's the migration plan?" + expect: trigger + rationale: End-to-end provider migration covering mapping, dual-run, cutover, and rollback. + - query: "Our Vonage code uses a failover array — what's the Sent equivalent?" + expect: trigger + rationale: The central translation problem; an ordered failover array must become automatic routing rather than a channel array. + - query: "How do I map Infobip's Blocklist onto Sent's opt-out model?" + expect: trigger + rationale: Suppression-store reconciliation, including that Sent consent is channel-agnostic. + - query: "Can I reuse our MessageBird webhook signature verifier for Sent?" + expect: trigger + rationale: Hard negative to correct; MessageBird signs over a body hash, so the verifier is not portable. + - query: "Our handler branches on Twilio error 21610 — what replaces it?" + expect: trigger + rationale: Numeric provider codes become string error families, and opted-out sends surface as FILTERED rather than an error. + - query: "What should we compare during a dual-run before cutting over to Sent?" + expect: trigger + rationale: The comparison metrics and rollback triggers are core deliverables of the cutover playbook. + - query: "Translate this specific Twilio Node snippet into Sent line by line." + expect: trigger + rationale: Provider-specific code translation is part of the migration inventory and rewrite workflow. + - query: "Set up a brand new Sent integration in our greenfield Next.js app." + expect: no_trigger + rationale: No incumbent provider to migrate from; sent-integration-starter owns greenfield setup. + - query: "Should each merchant get its own Sender Profile after we migrate from Twilio subaccounts?" + expect: ambiguous + rationale: The subaccount-to-profile mapping is migration content, but the isolation decision itself belongs to sender-profile-architect. diff --git a/evals/sent-integration-starter.yaml b/evals/sent-integration-starter.yaml new file mode 100644 index 0000000..e12501b --- /dev/null +++ b/evals/sent-integration-starter.yaml @@ -0,0 +1,29 @@ +skill: sent-integration-starter +cases: + - query: "Add Sent messaging to our Next.js app — which SDK and how do I wire it up?" + expect: trigger + rationale: First-time integration covering SDK selection, client placement, and the four-file minimum shape. + - query: "What retry policy should my worker use for Sent API errors?" + expect: trigger + rationale: Retry classification by error family, including the auth-lockout stop rule, is owned here. + - query: "I got a 409 CONFLICT_001 from Sent. What does that mean?" + expect: trigger + rationale: "Idempotency semantics: a concurrent duplicate in flight, retried once after a pause." + - query: "How should I use Idempotency-Key so a timeout doesn't double-send?" + expect: trigger + rationale: Deterministic key derivation, the 24-hour cache, and replay headers are core content. + - query: "We keep hitting 429s during a bulk campaign — how do I pace requests?" + expect: trigger + rationale: The 200-per-minute budget, the 1,000-recipient batch ceiling, and headers only on 429 responses. + - query: "Is our Sent integration ready for production?" + expect: trigger + rationale: The launch checklist and hardening stage are precisely this skill's deliverable. + - query: "Send a message to +14155551234 right now confirming their order." + expect: no_trigger + rationale: A single confirmed send is sent-messaging; this skill builds and hardens the integration rather than executing sends. + - query: "Which channel should I use so WhatsApp falls back to SMS?" + expect: no_trigger + rationale: Channel selection and fallback semantics belong to sent-routing-strategist. + - query: "We're moving off Twilio to Sent — where do I start?" + expect: ambiguous + rationale: migrate-to-sent owns the provider mapping and cutover; this skill takes over for greenfield client, retry, and receiver hardening once the migration plan exists. diff --git a/evals/sent-profile-provisioning.yaml b/evals/sent-profile-provisioning.yaml new file mode 100644 index 0000000..1031b22 --- /dev/null +++ b/evals/sent-profile-provisioning.yaml @@ -0,0 +1,29 @@ +skill: sent-profile-provisioning +cases: + - query: "Walk me through the API calls to create a Sender Profile and get it to a sending state." + expect: trigger + rationale: The canonical provisioning sequence from create through completion, campaigns, and users. + - query: "My profile completion callback never arrived. What now?" + expect: trigger + rationale: The completion callback is delivered once with no retry, so the polling reconciliation path belongs here. + - query: "Should inherit_tcr_campaign be true or false for per-tenant 10DLC use cases?" + expect: trigger + rationale: Inheritance flag mechanics and the inherited-brand with dedicated-campaign pattern are core content. + - query: "How do I invite a teammate as a developer and make sure they can actually list users?" + expect: trigger + rationale: User invitation lifecycle plus the developer read floor and active-status requirement. + - query: "POST /v3/profiles returned 422 saying no WhatsApp Business Account is configured." + expect: trigger + rationale: Specific provisioning error tied to the three WhatsApp attachment paths. + - query: "Can I create an API key through the Sent API so each tenant gets its own?" + expect: trigger + rationale: Hard negative to correct; no key endpoints exist and key management is a dashboard operation. + - query: "Should each of my 50 merchants get its own profile or share one?" + expect: no_trigger + rationale: That isolation and blast-radius decision belongs to sender-profile-architect before any provisioning call. + - query: "Write the sample messages and pick the use case for our 10DLC campaign." + expect: no_trigger + rationale: Use-case selection and sample-copy policy belong to sms-10dlc-registration. + - query: "We need per-tenant WhatsApp numbers onboarded for 20 merchants." + expect: ambiguous + rationale: Profile creation with dedicated WABA credentials lives here, but the Embedded Signup onboarding flow itself belongs to waba-embedded-signup. diff --git a/evals/sent-routing-strategist.yaml b/evals/sent-routing-strategist.yaml new file mode 100644 index 0000000..7619937 --- /dev/null +++ b/evals/sent-routing-strategist.yaml @@ -0,0 +1,29 @@ +skill: sent-routing-strategist +cases: + - query: "I want Sent to try RCS first and fall back to SMS. Is channel ['rcs','sms'] right?" + expect: trigger + rationale: The canonical broadcast-versus-fallback misconception; the array duplicates delivery and automatic routing is the correct expression. + - query: "Every recipient got the message twice after we added a second channel." + expect: trigger + rationale: Duplicate delivery from a multi-channel array, diagnosed by recipients times channels arithmetic. + - query: "Why is the channel on this Sent message 'auto' and the status FAILED?" + expect: trigger + rationale: Terminal auto means the message ended before routing; this skill owns the status-and-channel diagnosis matrix. + - query: "Should I pin WhatsApp or let Sent choose the channel for transactional receipts?" + expect: trigger + rationale: Direct pinned-versus-automatic routing decision, including what pinning gives up. + - query: "Our WhatsApp message failed and then a second message arrived by SMS with the same ID — is that a bug?" + expect: trigger + rationale: Reroute reuses the same message id and re-fires queued and routed; expected automatic-routing behavior. + - query: "A message came back FILTERED. Should my worker retry it?" + expect: trigger + rationale: FILTERED is a policy gate rather than a carrier failure, so the retry decision rules belong here. + - query: "How do I verify the HMAC signature on the webhook that told me about this failure?" + expect: no_trigger + rationale: Signature verification and receiver construction belong to sent-webhook-engineer. + - query: "What is the cheapest SMS provider in Brazil?" + expect: no_trigger + rationale: Carrier commercial questions are outside the routing contract this skill describes. + - query: "Our delivery rate dropped this week across all channels — what changed?" + expect: ambiguous + rationale: Aggregate regression analysis belongs to messaging-performance-analyzer; route here only when the question narrows to route selection or reroute behavior for specific messages. diff --git a/evals/sent-two-way-messaging.yaml b/evals/sent-two-way-messaging.yaml new file mode 100644 index 0000000..d9c910a --- /dev/null +++ b/evals/sent-two-way-messaging.yaml @@ -0,0 +1,29 @@ +skill: sent-two-way-messaging +cases: + - query: "Do I need to handle STOP myself when I receive a message.received event from Sent?" + expect: trigger + rationale: The platform applies consent before delivery; the application mirrors the exact match locally for evidence without writing consent to Sent again. + - query: "Which opt-out and opt-in keywords does Sent handle by default?" + expect: trigger + rationale: The documented ten-keyword set and the exact-match rule are core content of this skill. + - query: "A customer replied a day later and my WhatsApp auto-reply stopped working." + expect: trigger + rationale: The 24-hour customer service window requires an approved template outside it; this is the reply-path decision tree. + - query: "How do I page through a customer's conversation history on Sent?" + expect: trigger + rationale: Read-only conversation endpoints with required page and page_size, plus the derived UUIDv5 conversation id. + - query: "Can I set opt_out back to false for a contact who texted STOP?" + expect: trigger + rationale: Technically writable but a compliance decision requiring documented fresh consent; the skill must surface that. + - query: "We send from an alphanumeric sender ID — will STOP still work?" + expect: trigger + rationale: SMS inbound is conditional on an MO-capable provider and number type; alphanumeric senders never receive inbound. + - query: "How do I verify the webhook signature before trusting the inbound payload?" + expect: no_trigger + rationale: Signature verification, replay windows, and dedupe mechanics belong to sent-webhook-engineer. + - query: "Write the copy for a WhatsApp utility template and pick its category." + expect: no_trigger + rationale: Template authoring and Meta category selection belong to waba-template-author. + - query: "Our outbound campaign shows a lot of FILTERED messages — what is going on?" + expect: ambiguous + rationale: Consent suppression is this skill's domain, but route denials and delivery-outcome interpretation belong to sent-routing-strategist; decide after checking whether contacts are opted out. diff --git a/evals/sent-webhook-engineer.yaml b/evals/sent-webhook-engineer.yaml new file mode 100644 index 0000000..207334b --- /dev/null +++ b/evals/sent-webhook-engineer.yaml @@ -0,0 +1,29 @@ +skill: sent-webhook-engineer +cases: + - query: "My Sent webhook returns 401 for every event even though I copied the signing secret correctly." + expect: trigger + rationale: Canonical signature-verification failure; the skill owns the raw-body, whsec_ prefix, and replay-window diagnosis order. + - query: "How do I verify x-webhook-signature in an Express app?" + expect: trigger + rationale: Direct request for the HMAC scheme plus the framework raw-body accessor, both owned by this skill. + - query: "Sent stopped delivering webhooks to my endpoint and the dashboard shows it as inactive." + expect: trigger + rationale: Auto-disable after ten consecutive failures, with the delivery-log triage and re-enable sequence. + - query: "Can I use x-webhook-id to deduplicate Sent events?" + expect: trigger + rationale: Hard negative that must be corrected; x-webhook-id is the endpoint UUID, so dedupe keys must be derived from payload semantics. + - query: "I need to rotate our webhook signing secret without dropping events." + expect: trigger + rationale: Rotation invalidates the old secret immediately, so the dual-secret receiver sequence is required knowledge here. + - query: "Why did I get message.queued and message.routed twice for the same message_id?" + expect: trigger + rationale: Reroute re-runs the pipeline on the same message id; the event-stream and idempotency guidance lives in this skill. + - query: "Write a generic HMAC webhook verifier for my Stripe integration." + expect: no_trigger + rationale: No Sent context and a different signature scheme; this belongs to general engineering rather than the Sent webhook contract. + - query: "Our WhatsApp template was rejected and I saw it in a webhook payload." + expect: no_trigger + rationale: Template rejection content and category remediation belong to waba-template-author; only the envelope shape is this skill's concern. + - query: "Deliveries are fine but our delivery rate dropped 12% this week — help me investigate." + expect: ambiguous + rationale: Route to messaging-performance-analyzer for aggregate delivery diagnosis, and here only if the cause turns out to be dropped or unprocessed webhook events. diff --git a/packages/sent/README.md b/packages/sent/README.md index c352adc..edb1cdb 100644 --- a/packages/sent/README.md +++ b/packages/sent/README.md @@ -43,6 +43,12 @@ Install only the focused skills needed by a project by repeating `--skill | `rcs-agent-onboarding` | Prepare RBM agent identity, carrier approval, capabilities, fallback, and launch evidence | [`skills/rcs-agent-onboarding/SKILL.md`](skills/rcs-agent-onboarding/SKILL.md) | | `sender-profile-architect` | Design multi-tenant Sender Profile boundaries, webhook routing, and lifecycle | [`skills/sender-profile-architect/SKILL.md`](skills/sender-profile-architect/SKILL.md) | | `template-builder-ui` | Design and audit tenant-facing SMS, WhatsApp, and RCS template-builder UX | [`skills/template-builder-ui/SKILL.md`](skills/template-builder-ui/SKILL.md) | +| `sent-integration-starter` | Stand up and harden a Sent v3 integration end to end | [`skills/sent-integration-starter/SKILL.md`](skills/sent-integration-starter/SKILL.md) | +| `sent-webhook-engineer` | Build and debug verified webhook receivers and delivery health | [`skills/sent-webhook-engineer/SKILL.md`](skills/sent-webhook-engineer/SKILL.md) | +| `sent-routing-strategist` | Choose channels and diagnose route attempts and delivery outcomes | [`skills/sent-routing-strategist/SKILL.md`](skills/sent-routing-strategist/SKILL.md) | +| `sent-two-way-messaging` | Design inbound, consent, and conversational flows | [`skills/sent-two-way-messaging/SKILL.md`](skills/sent-two-way-messaging/SKILL.md) | +| `sent-profile-provisioning` | Execute the Sender Profile, campaign, and user lifecycle | [`skills/sent-profile-provisioning/SKILL.md`](skills/sent-profile-provisioning/SKILL.md) | +| `migrate-to-sent` | Migrate from another CPaaS provider onto Sent | [`skills/migrate-to-sent/SKILL.md`](skills/migrate-to-sent/SKILL.md) | Each `SKILL.md` contains the discovery metadata and core workflow. Skill-local `references/` hold deeper specifications and examples, `scripts/` hold deterministic validators or analyzers, and `agents/openai.yaml` supplies optional host UI metadata. @@ -60,6 +66,8 @@ The package declares `https://mcp.sent.dm/mcp` as a Streamable HTTP server. Use `sent-analytics` for aggregate dashboard totals and trends. Use `messaging-performance-analyzer` for message-level evidence, funnel drop-off, and root-cause diagnosis. Use `sent-templates` for existing records, `waba-template-author` for WhatsApp content, and `template-builder-ui` for product UX. +For engineering work, use `sent-integration-starter` for new integrations, `sent-webhook-engineer` for receivers, `sent-routing-strategist` for channel and route decisions, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` to execute what `sender-profile-architect` designs, and `migrate-to-sent` when replacing an incumbent provider. + ## Authorization Authorization is client-managed. The MCP client performs OAuth 2.1 with PKCE and Dynamic Client Registration; the package contains no token, API key, authorization header, OAuth client ID, or credential placeholder. diff --git a/packages/sent/plugin.json b/packages/sent/plugin.json index d230e14..bde2f75 100644 --- a/packages/sent/plugin.json +++ b/packages/sent/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for SMS, WhatsApp, RCS, API integration, webhooks, routing, two-way messaging, Sender Profiles, migration, compliance, analytics, and agent-safe operations.", "author": { "name": "Sent", "url": "https://sent.dm" @@ -28,6 +28,11 @@ "analytics", "deliverability", "sender-profiles", + "api-integration", + "webhooks", + "routing", + "two-way-messaging", + "migration", "mcp" ] } diff --git a/packages/sent/skills/migrate-to-sent/SKILL.md b/packages/sent/skills/migrate-to-sent/SKILL.md new file mode 100644 index 0000000..de79092 --- /dev/null +++ b/packages/sent/skills/migrate-to-sent/SKILL.md @@ -0,0 +1,95 @@ +--- +name: migrate-to-sent +description: Plans and executes a migration from Twilio, Sinch, Infobip, Vonage, or MessageBird/Bird to Sent v3 — mapping send calls, status vocabularies, webhook signature schemes, opt-out stores, templates, and tenancy models, then cutting over safely with dual-run and rollback. Use when replacing an incumbent CPaaS provider, translating provider code or webhook handlers to Sent, or planning a phased cutover and its verification gates. +--- + +# Migrate to Sent + +Every migration from a major CPaaS provider hits the same five translation problems. Work them in this order, because the first one silently doubles cost and is invisible in tests. + +## 1. Ordered fallback becomes automatic routing + +Incumbent platforms express cross-channel delivery through different caller-side arrays, failover objects, messaging-service features, or application-level priority configuration. Do not assume those shapes have a direct Sent request-field equivalent. + +**Sent's `channel` array is a broadcast list.** Porting an ordered array produces one message and one charge per recipient-channel pair, which passes tests and multiplies production spend. The correct translation is automatic routing — omit `channel` or send `["sent"]` — which lets the platform select a route and reroute across up to three channel-and-provider pairs on the same `message_id`. Details belong to `sent-routing-strategist`; the migration rule is simply: **never port an ordered channel list.** + +## 2. Status vocabularies do not line up + +Incumbent statuses map onto Sent's, but Sent adds two states that have no equivalent and that break naive retry logic. + +| Sent status | Closest incumbent analogue | Migration note | +| --- | --- | --- | +| `QUEUED` | Twilio `queued`, Sinch `QUEUED_ON_CHANNEL` | Accepted, not sent | +| `ROUTED` | no analogue | Route chosen; fires again on reroute | +| `SENT` | Twilio `sent`, Sinch `MESSAGE_SUBMIT` | Provider handoff only | +| `DELIVERED` | `delivered` everywhere | The first proof of handset receipt | +| `READ` | Twilio `read`, Sinch `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | May still reroute; not necessarily final | +| `FILTERED` | Twilio error 21610 (opt-out) | **Policy gate. Never retry** | +| `BLOCKED` | account-level errors | **Account precondition.** Fix the account, then resend | +| `SCHEDULED` | no analogue | Quiet-hours parking; resumes automatically | + +Two consequences for ported code. Handlers that treat every non-delivered terminal state as retryable will retry consent blocks, which is a compliance failure rather than a bug. And handlers keyed on numeric provider error codes — Twilio's `21610` is the classic — must be rewritten against Sent's string `error.code` families. + +## 3. Webhook verification is a rewrite, not a port + +No two providers sign the same way, and no Sent SDK ships a verifier. + +| Provider | Scheme | +| --- | --- | +| Twilio | `X-Twilio-Signature`, base64 HMAC-**SHA1** over the full URL plus sorted POST parameters | +| Sinch | HMAC-SHA256 over `body.nonce.timestamp`, four `x-sinch-webhook-signature*` headers, or OAuth 2.0 | +| Infobip | Basic, HMAC-SHA256 over the raw body, or OAuth on a notification profile; **the header name is account-configured** | +| Vonage | JWT in `Authorization: Bearer`, or a legacy `sig` parameter | +| MessageBird/Bird | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash | +| **Sent** | `x-webhook-signature: v1,{base64}`, HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}` | + +Sent's key is the signing secret with `whsec_` stripped and the remainder base64-decoded, compared in constant time, with timestamps outside 300 seconds rejected. Because Sent provides no per-event id, dedupe keys must be derived from payload semantics. Build the receiver with `sent-webhook-engineer` rather than adapting the incumbent's verifier. + +## 4. Opt-out stores must be reconciled, not migrated by copy + +Every provider keeps its own suppression list — Twilio Advanced Opt-Out, Infobip Blocklist, Sinch OPT_IN/OPT_OUT events. Sent enforces consent at the platform level before events reach the application, stores it as `opt_out` on the contact, and applies it **channel-agnostically**: a `STOP` on SMS suppresses WhatsApp and RCS too. + +Reconciliation rules: export the incumbent's suppression list before cutover, treat any opt-out on any incumbent channel as a global Sent opt-out, and never clear `opt_out` to "clean up" migrated data. Sent's ten default keywords are `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END`, `START`, `UNSTOP`, `SUBSCRIBE`, `HELP`, `INFO`, matched only when the entire trimmed body equals the keyword — so incumbent-specific keywords need custom keyword entries. Rewrite any incumbent keyword matcher as an exact local consent mirror and audit mechanism; the matcher must not write consent to Sent again. Consent semantics belong to `sent-two-way-messaging`. + +## 5. Templates and tenancy are re-registered, not transferred + +WhatsApp templates live with the WABA, so the migration question is whether the WABA moves. Positional placeholders (`{{1}}`, `{{2}}`) become **named** parameters in Sent, which means every call site that passed an ordered array must pass a named map. Approval is asynchronous and arrives as a `templates` webhook event, so build the template inventory before cutover rather than during it. + +Tenancy maps as follows, with the boundary decision owned by `sender-profile-architect` and the API work by `sent-profile-provisioning`: + +| Incumbent construct | Sent equivalent | +| --- | --- | +| Twilio subaccount | Sender Profile | +| Twilio Messaging Service | routing plus profile configuration, not a caller-side pool | +| Infobip Application or Entity | Sender Profile | +| Sinch Conversation API app | Sender Profile | +| Provider API credential per tenant | Profile-scoped API key, or organization key with `x-profile-id` | + +## Migration sequence + +1. **Inventory** every send call site, webhook handler, status branch, template, suppression list, and credential. Use `scripts/inventory_scan.py` to find them mechanically. +2. **Map** each item using [references/provider-mapping.md](references/provider-mapping.md), flagging ordered-fallback arrays and numeric error codes as required rewrites. +3. **Stand up Sent in parallel**: credentials, one webhook per environment, verified receiver, templates re-registered and approved. +4. **Prove equivalence in sandbox** with `"sandbox": true`, then with a small live cohort confirmed to `DELIVERED`. +5. **Dual-run** with a traffic split, comparing delivery rates, latency, and cost per message on the same message classes. +6. **Cut over** by message class — lowest-risk transactional first, marketing last — keeping the incumbent receiver live. +7. **Decommission** only after a full billing cycle of clean data, then revoke incumbent credentials. + +Sequencing detail, verification gates, and rollback triggers are in [references/cutover-playbook.md](references/cutover-playbook.md). + +## Mistakes that survive testing + +- Porting an ordered channel array. Doubles cost, never errors. +- Treating `FILTERED` as retryable. Compliance exposure. +- Reusing the incumbent's signature verifier. Every delivery returns 401. +- Assuming `202` means delivered. Sent acknowledges acceptance only. +- Keeping positional template placeholders. Parameters silently mismatch. +- Retrying on `401`. Ten consecutive auth failures lock the credential with escalating lockout. +- Omitting `Idempotency-Key` during dual-run. A timeout retry sends twice. +- Sending `x-profile-id` with a profile-scoped key. Returns `403`. +- Copying an incumbent's `Authorization: Bearer` pattern. Sent authenticates with `x-api-key`. + +## Boundaries + +This skill owns provider mapping and line-by-line migration planning. Hand the resulting Sent client and resilience work to `sent-integration-starter`, channel semantics to `sent-routing-strategist`, receiver construction to `sent-webhook-engineer`, WhatsApp onboarding to `waba-embedded-signup`, and US campaign registration to `sms-10dlc-registration`. diff --git a/packages/sent/skills/migrate-to-sent/agents/openai.yaml b/packages/sent/skills/migrate-to-sent/agents/openai.yaml new file mode 100644 index 0000000..d068d06 --- /dev/null +++ b/packages/sent/skills/migrate-to-sent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate to Sent" + short_description: "Move from another CPaaS provider to Sent" + default_prompt: "Use $migrate-to-sent to plan our move from our current messaging provider to Sent." diff --git a/packages/sent/skills/migrate-to-sent/references/cutover-playbook.md b/packages/sent/skills/migrate-to-sent/references/cutover-playbook.md new file mode 100644 index 0000000..7908759 --- /dev/null +++ b/packages/sent/skills/migrate-to-sent/references/cutover-playbook.md @@ -0,0 +1,151 @@ +# Cutover playbook + +## Table of contents + +- [Phase overview](#phase-overview) +- [Phase 1: inventory](#phase-1-inventory) +- [Phase 2: mapping and rewrite list](#phase-2-mapping-and-rewrite-list) +- [Phase 3: parallel build](#phase-3-parallel-build) +- [Phase 4: sandbox and cohort verification](#phase-4-sandbox-and-cohort-verification) +- [Phase 5: dual-run](#phase-5-dual-run) +- [Phase 6: staged cutover](#phase-6-staged-cutover) +- [Phase 7: decommission](#phase-7-decommission) +- [Rollback triggers](#rollback-triggers) +- [Comparison metrics](#comparison-metrics) +- [Data migration rules](#data-migration-rules) + +## Phase overview + +| Phase | Exit gate | +| --- | --- | +| 1. Inventory | Every send call site, handler, template, and credential is listed | +| 2. Mapping | Each item has a target and a rewrite classification | +| 3. Parallel build | Sent sends and receives in a lower environment | +| 4. Verification | Sandbox parity plus a live cohort reaching `DELIVERED` | +| 5. Dual-run | Delivery, latency, and cost within agreed tolerances | +| 6. Cutover | All message classes on Sent, incumbent idle but reversible | +| 7. Decommission | One clean billing cycle, then credentials revoked | + +Do not compress phases 4 and 5. The failure modes that matter — broadcast duplication, mishandled `FILTERED`, and dropped webhook events — appear under real traffic and correct cost accounting, not in tests. + +## Phase 1: inventory + +Enumerate mechanically rather than from memory: + +- send call sites, including background jobs, cron tasks, and admin tools; +- webhook handlers and their signature verification code; +- every branch on a provider status string or numeric error code; +- template inventory with variable style and approval state; +- suppression and opt-out storage; +- credentials per environment and per tenant; +- observability: dashboards, alerts, and log fields that reference provider identifiers. + +`scripts/inventory_scan.py` scans a repository for provider SDK imports, endpoint URLs, signature header names, status strings, numeric error codes, and ordered channel arrays, and reports each with a migration classification. Treat its output as the starting checklist, not the whole picture — configuration-driven senders and no-code automations will not appear in source. + +## Phase 2: mapping and rewrite list + +Classify every inventory item: + +| Classification | Meaning | Examples | +| --- | --- | --- | +| Direct map | Same concept, different syntax | Recipient, message body, delivery status branch | +| Rewrite | Concept exists but works differently | Fallback, templates, tenancy, consent checks | +| New code | No equivalent exists | Webhook signature verification, idempotency keys | +| Delete | The incumbent workaround is unnecessary | Provider-specific retry ladders that conflict with Sent's error contract | +| Review | Logic remains useful but changes responsibility | Keyword matchers become exact local consent mirrors and must not write consent to Sent again | + +The rewrite list is the real project plan. Prioritize by blast radius: ordered fallback arrays first, then status and error branching, then the receiver, then templates. + +## Phase 3: parallel build + +Stand Sent up beside the incumbent without removing anything: + +1. Create credentials per environment, and profile-scoped keys for runtime send paths. +2. Provision Sender Profiles for tenancy, with inheritance and sharing flags set deliberately. +3. Register one webhook per environment; never share an endpoint across environments, because auto-disable is per endpoint. +4. Build the receiver as new code with its own signature tests. +5. Re-register templates and wait for approval events; approval is asynchronous and gates go-live. +6. Add `Idempotency-Key` to every mutating call, derived from your own domain objects. +7. Introduce a feature flag or router that chooses provider per message class and tenant. + +## Phase 4: sandbox and cohort verification + +Sandbox first: `"sandbox": true` authenticates and validates without executing, so payload shape and credential wiring can be proven in continuous integration. Remember it does not perform resource lookups, so it cannot confirm a template id exists. + +Then a live cohort — internal staff or a small opt-in group. Gates: + +- a send returns `202` and every `message_id` is persisted with tenant, profile, and logical send id; +- events arrive, verify, and deduplicate; +- a message reaches `DELIVERED` and the application state reflects it; +- a deliberately induced failure produces the expected terminal state without a retry storm; +- a suppressed contact produces `FILTERED` and no retry; +- a template send renders correctly with named parameters; +- an inbound `STOP` updates the exact-match local consent mirror without writing consent to Sent again. + +## Phase 5: dual-run + +Split traffic by tenant or message class and compare like with like. Both providers must carry comparable message mixes, since transactional and marketing traffic have different delivery profiles. + +Requirements during dual-run: one application operation ledger that assigns each logical send to exactly one provider, Sent idempotency keys on every Sent mutation, equivalent incumbent safeguards where available, a single source of truth for delivery state per provider message id, and cost accounting that counts messages rather than requests. Never send the same user-facing notification through both providers merely to compare them. + +Run long enough to cover a weekly seasonality cycle and at least one deliberate failure drill. + +## Phase 6: staged cutover + +Order by risk, lowest first: + +1. internal and staff notifications; +2. low-volume transactional messages such as receipts; +3. high-volume transactional messages such as OTP and delivery alerts; +4. conversational and support traffic; +5. marketing and campaign traffic. + +Keep the incumbent receiver live throughout, because in-flight messages sent before the switch still deliver status callbacks there. Cut per message class and per tenant, and hold each step long enough to see a full daily cycle. + +## Phase 7: decommission + +Decommission only after one full billing cycle of clean Sent data. Then, in order: disable incumbent send paths in code, remove the router flag, revoke incumbent credentials, archive incumbent delivery and consent records for the applicable retention period, and remove incumbent-specific dashboards and alerts after confirming Sent equivalents exist. + +Retain the exported incumbent suppression list permanently as consent evidence, independent of the platform that now enforces it. + +## Rollback triggers + +Define these before dual-run so the decision is not made under pressure: + +| Trigger | Action | +| --- | --- | +| Delivery rate falls below the agreed tolerance for a message class | Roll that class back | +| Duplicate messages observed in production | Halt immediately; check for ordered channel arrays and missing idempotency keys | +| Webhook `consecutive_failures` rising toward auto-disable | Roll back the receiver, fix, re-enable in the dashboard | +| `FILTERED` volume above baseline | Halt; the local suppression mirror is likely stale | +| Cost per delivered message above the agreed ceiling | Halt; verify channel arrays and message counts | +| Auth lockout on a credential | Stop all retries; ten consecutive failures escalate the lockout | + +Rollback is a router flag flip per message class, which is why the router must remain in place until decommissioning. + +## Comparison metrics + +| Metric | Definition | Why it matters | +| --- | --- | --- | +| Delivery rate | `DELIVERED` divided by accepted, per message class | The primary quality signal | +| Filtered rate | `FILTERED` divided by accepted | Consent-mirror drift | +| Blocked rate | `BLOCKED` divided by accepted | Balance and account preconditions | +| Reroute rate | Messages with more than one attempted route | Routing health | +| Time to delivered | `DELIVERED` timestamp minus accepted timestamp | Latency comparison | +| Messages per logical send | Messages created divided by intended sends | Catches broadcast duplication | +| Cost per delivered message | Spend divided by `DELIVERED` count | The number finance will ask for | + +Messages per logical send should be exactly one for single-channel intent. Anything above one means a multi-channel array is in play. + +## Data migration rules + +| Data | Rule | +| --- | --- | +| Contacts | Re-create in Sent with E.164 numbers; contacts are validated endpoints, not a copied CRM table | +| Suppression list | Export before cutover; treat any incumbent opt-out as a global Sent opt-out | +| Templates | Re-register and await approval; convert positional variables to named parameters | +| Message history | Leave in the incumbent, archived; do not attempt to import history | +| Credentials | Create fresh in Sent; never reuse incumbent secrets | +| Webhook secrets | New per environment; store keyed by webhook id | + +Never clear `opt_out` during data migration. A migrated opt-out is consent evidence, and clearing it to reconcile row counts is a compliance failure rather than a data cleanup. diff --git a/packages/sent/skills/migrate-to-sent/references/provider-mapping.md b/packages/sent/skills/migrate-to-sent/references/provider-mapping.md new file mode 100644 index 0000000..1d9a65a --- /dev/null +++ b/packages/sent/skills/migrate-to-sent/references/provider-mapping.md @@ -0,0 +1,158 @@ +# Provider-to-Sent mapping reference + +## Table of contents + +- [How to use this reference](#how-to-use-this-reference) +- [Sent target contract](#sent-target-contract) +- [Twilio](#twilio) +- [Sinch](#sinch) +- [Infobip](#infobip) +- [Vonage](#vonage) +- [MessageBird and Bird](#messagebird-and-bird) +- [Cross-provider concept table](#cross-provider-concept-table) +- [Status mapping](#status-mapping) +- [Webhook signature comparison](#webhook-signature-comparison) +- [Error handling translation](#error-handling-translation) + +## How to use this reference + +Read the Sent target contract first, then only the section for the incumbent. Each provider section lists the send call, the fallback construct, the webhook scheme, the suppression store, the tenancy construct, and the specific rewrites that testing will not catch. + +Verify any header name or field against the incumbent's current documentation before writing customer-facing text; several of these platforms have renamed products and headers. + +## Sent target contract + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345", "eta": "Friday" } + }, + "sandbox": false +} +``` + +`POST /v3/messages` returns `202` with `data.recipients[]`, each carrying a `message_id`. Only `to` is required; supply `template` or `text`; omit `channel` for automatic routing. Authentication is `x-api-key`, optionally with `x-profile-id` on an organization key. Template parameters are a **named** map, not positional. + +For automatic routing the echoed per-recipient channel is not a resolved route and is never updated later. Read the real route from `message.routed`, from `GET /v3/messages/{id}` after routing, or from `GET /v3/messages/{id}/activities`. + +## Twilio + +| Concept | Twilio | Sent | +| --- | --- | --- | +| Send | `POST /2010-04-01/Accounts/{sid}/Messages.json`, form-encoded | `POST /v3/messages`, JSON | +| Auth | Basic auth with Account SID and auth token | `x-api-key` header | +| Recipient | `To`, with a `whatsapp:` prefix for WhatsApp | `to` array plus the `channel` array | +| Sender | `From` or `MessagingServiceSid` | Profile configuration and routing | +| Content | `Body`, or `ContentSid` with positional variables | `text`, or `template` with named parameters | +| Fallback | Messaging Service features such as Fallback to Long Code | Automatic routing with reroute | +| Status callback | `StatusCallback` per message | Account-level webhook subscription | +| Suppression | Advanced Opt-Out | Platform consent with `opt_out` on the contact | +| Tenancy | Subaccount | Sender Profile | +| Idempotency | Not offered on message create | `Idempotency-Key` header | + +Rewrites that testing will not catch: positional `{{1}}` variables become named parameters; numeric error codes such as `21610` for an opted-out recipient become string `error.code` families, and that particular case does not even fail the request — the send is accepted and the message finalizes as `FILTERED`; a per-message `StatusCallback` URL has no equivalent, so status routing moves into the subscription's `event_filters`. + +Use this skill for line-by-line translation, then use `sent-integration-starter` to harden the resulting client lifecycle, retries, and observability. + +## Sinch + +| Concept | Sinch Conversation API | Sent | +| --- | --- | --- | +| Send | `POST /v1/projects/{id}/messages:send` with a channel-priority order | `POST /v3/messages` with automatic routing | +| App model | Conversation API app with configured channels | Sender Profile | +| Webhooks | Up to five per app, created with `triggers[]` | Account-level subscription with `event_types` and `event_filters` | +| Callback auth | OAuth 2.0, or HMAC-SHA256 over `body.nonce.timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Consent | `OPT_IN` and `OPT_OUT` trigger events | Platform-enforced consent, applied before the event | +| Delivery reports | Typically three callbacks per message | One event per transition, plus repeats on reroute | + +Sinch's trigger catalog is far broader than Sent's two event families, so a handler switching over twenty-plus trigger types collapses to branching on `field` and `event`. Sinch's channel-priority ordering is the ordered-fallback pattern that must become automatic routing. + +## Infobip + +| Concept | Infobip | Sent | +| --- | --- | --- | +| Send | Channel-specific endpoints, or Conversations | `POST /v3/messages` | +| Sender strategy | Resource Associations and Sending Strategies such as sticky sender | Platform routing rules | +| Webhooks | Subscription plus a notification profile | Webhook registration | +| Webhook auth | Basic, HMAC-SHA256 over the raw body, or OAuth, optionally mTLS; **header name is account-configured** | Fixed `x-webhook-signature` scheme | +| Suppression | Blocklist, also called Do Not Contact | `opt_out` on the contact | +| Tenancy | Applications and Entities | Sender Profiles | + +Because Infobip's signing header name comes from account settings, an existing verifier is not portable and its header constant must not be reused. Sending Strategies have no caller-side equivalent: sticky-sender behavior is a platform routing concern in Sent, not a request parameter. + +## Vonage + +| Concept | Vonage Messages API v1 | Sent | +| --- | --- | --- | +| Send | Per-channel body with `message_type`, `channel`, `to`, `from` | Uniform `POST /v3/messages` | +| Fallback | `failover` array of complete alternative messages | Automatic routing with reroute | +| Webhook auth | JWT in `Authorization: Bearer`, or legacy `sig` parameter | HMAC signature headers | +| Credentials | Application id with a private key | API key | + +Vonage's `failover` array is the most explicit ordered-fallback construct of the five, and it is the one most often ported directly into Sent's `channel` array. It must not be. Note also that Vonage's JWT bearer pattern tempts engineers to authenticate to Sent with `Authorization: Bearer`; Sent uses `x-api-key`. + +## MessageBird and Bird + +| Concept | MessageBird/Bird | Sent | +| --- | --- | --- | +| Send | Conversations API, or channel APIs | `POST /v3/messages` | +| Fallback | Explicit `fallback` object naming a secondary channel | Automatic routing with reroute | +| Webhook auth | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash, with `messagebird-request-timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Suppression | Platform suppression list | `opt_out` on the contact | + +MessageBird's signature covers a hash of the body rather than the body itself, so a verifier ported to Sent will fail every delivery even though both use HMAC-SHA256. Treat the legacy MessageBird header names as legacy and confirm current Bird names before asserting them. + +## Cross-provider concept table + +| Concept | Twilio | Sinch | Infobip | Vonage | Bird | Sent | +| --- | --- | --- | --- | --- | --- | --- | +| Ordered fallback | Messaging Service features | channel priority | sending strategies | `failover` array | `fallback` object | **automatic routing only** | +| Tenancy | subaccount | Conversation app | Application/Entity | application | workspace | Sender Profile | +| Template variables | positional | per-channel | per-channel | per-channel | per-channel | **named map** | +| Consent store | Advanced Opt-Out | OPT_IN/OPT_OUT events | Blocklist | application-side | suppression list | `opt_out`, channel-agnostic | +| Idempotency | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | `Idempotency-Key` | +| Webhook scope | per message or service | per app, up to five | per subscription | per application | per workspace | per account, filtered | + +## Status mapping + +| Sent | Twilio | Sinch | Note | +| --- | --- | --- | --- | +| `QUEUED` | `queued`, `accepted` | `QUEUED_ON_CHANNEL` | Accepted only | +| `ROUTED` | — | — | No incumbent analogue; repeats on reroute | +| `SENT` | `sent` | `MESSAGE_SUBMIT` | Provider handoff | +| `DELIVERED` | `delivered` | `DELIVERED` | Handset confirmation | +| `READ` | `read` | `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | `FAILURE` | May reroute; not necessarily final | +| `FILTERED` | error 21610 behavior | opt-out enforcement | Policy gate; never retry | +| `BLOCKED` | account errors | account errors | Account precondition | +| `SCHEDULED` | — | — | Quiet-hours parking | + +The two states with no analogue, `FILTERED` and `BLOCKED`, are exactly the two that ported retry logic mishandles. + +## Webhook signature comparison + +| Provider | Algorithm | Signed content | Header | +| --- | --- | --- | --- | +| Twilio | HMAC-SHA1 | full URL plus sorted POST parameters | `X-Twilio-Signature` | +| Sinch | HMAC-SHA256 | `body.nonce.timestamp` | `x-sinch-webhook-signature` plus nonce, timestamp, algorithm | +| Infobip | HMAC-SHA256 | raw body | account-configured | +| Vonage | JWT, or MD5/HMAC over sorted parameters | token claims, or parameters | `Authorization`, or `sig` | +| Bird | HMAC-SHA256 | timestamp, URL, SHA-256 of body | `messagebird-signature` | +| **Sent** | HMAC-SHA256 | `{webhook_id}.{timestamp}.{raw_body}` | `x-webhook-signature` as `v1,{base64}` | + +Among the compared schemes, Sent includes the endpoint id in the signed content and expects a `v1,` version prefix. No listed provider's verifier is reusable as-is, and no Sent SDK ships one, so plan the receiver as new code with its own tests. + +## Error handling translation + +| Incumbent pattern | Sent replacement | +| --- | --- | +| Numeric error codes in a switch statement | String `error.code` with prefix families | +| Retry on any non-delivered status | Retry only `429`, `5xx`, `SERVICE_001`, and `CONFLICT_001` once | +| Opt-out surfaced as a send error | Send accepted with `202`; message finalizes as `FILTERED` | +| Insufficient balance surfaced as a send error | Send accepted with `202`; message finalizes as `BLOCKED` | +| Per-request quota headers | Headers only on `429`; pace by design | +| Provider-side deduplication assumptions | Supply a deterministic `Idempotency-Key` | + +Consent and balance problems moving out of the error path and into delivery data is the structural change that most often surprises a migrating team: monitor filtered and blocked rates as first-class metrics. diff --git a/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py new file mode 100644 index 0000000..253ba90 --- /dev/null +++ b/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Scan a repository for incumbent CPaaS usage that a Sent migration must address. + +Reports each finding with a migration classification so the output can be used +directly as the phase-2 rewrite list. + +Usage +----- + python3 inventory_scan.py --self-test + python3 inventory_scan.py --path /path/to/repo + python3 inventory_scan.py --path /path/to/repo --format json + +Exit codes: 0 no findings, 1 findings reported, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, asdict + +EXIT_CLEAN = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + +SKIP_DIRS = { + ".git", "node_modules", "vendor", "dist", "build", "target", ".venv", "venv", + "__pycache__", ".next", ".gradle", ".idea", ".mypy_cache", ".pytest_cache", "coverage", +} +SCAN_EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".java", ".kt", ".cs", ".php", ".rb", + ".yml", ".yaml", ".json", ".tf", ".sh", ".md", +} +MAX_FILE_BYTES = 2_000_000 + + +@dataclass(frozen=True) +class Rule: + rule_id: str + provider: str + pattern: str + classification: str + guidance: str + + +RULES: tuple[Rule, ...] = ( + Rule( + "ordered-channel-array", "sent", + r"""['\"]channel['\"]\s*:\s*\[\s*['\"](?:sms|whatsapp|rcs)['\"]\s*,\s*['\"](?:sms|whatsapp|rcs)['\"]""", + "rewrite", + "Multiple explicit channels broadcast rather than fall back. Omit 'channel' or use ['sent'].", + ), + Rule( + "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", + "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + ), + Rule( + "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", + "rewrite", "Replace with POST /v3/messages using the flat JSON body.", + ), + Rule( + "twilio-signature", "twilio", r"X-Twilio-Signature|validateRequest|RequestValidator", + "new_code", "Twilio signs HMAC-SHA1 over URL plus sorted params. Sent needs a new verifier.", + ), + Rule( + "twilio-messaging-service", "twilio", r"MessagingServiceSid|messaging_service_sid", + "rewrite", "Sender pools and sticky sender are platform routing concerns in Sent, not request fields.", + ), + Rule( + "twilio-optout-code", "twilio", r"\b21610\b", + "rewrite", "Opted-out sends are accepted with 202 and finalize as FILTERED, not as a numeric error.", + ), + Rule( + "twilio-channel-prefix", "twilio", r"['\"]whatsapp:\+?", + "rewrite", "Channel is selected by the 'channel' array in Sent, not by a recipient prefix.", + ), + Rule( + "sinch-conversation", "sinch", r"conversation\.api\.sinch\.com|messages:send|sinch-webhook-signature", + "rewrite", "Replace channel-priority ordering with Sent automatic routing.", + ), + Rule( + "infobip-endpoint", "infobip", r"[a-z0-9-]+\.api\.infobip\.com|infobip-api", + "rewrite", "Replace with POST /v3/messages; Sending Strategies have no caller-side equivalent.", + ), + Rule( + "infobip-blocklist", "infobip", r"blocklist|do-not-contact|dnc", + "rewrite", "Reconcile into Sent consent as a channel-agnostic opt_out on the contact.", + ), + Rule( + "vonage-failover", "vonage", r"['\"]failover['\"]\s*[:=]\s*\[", + "rewrite", "An ordered failover array must become Sent automatic routing.", + ), + Rule( + "vonage-sdk", "vonage", r"\b(?:@vonage/|nexmo|vonage-)\b", + "rewrite", "Replace the Vonage client; Sent authenticates with x-api-key, not a JWT bearer.", + ), + Rule( + "bird-fallback", "bird", r"messagebird|bird-signature|messagebird-signature", + "rewrite", "MessageBird signs over a body hash; the verifier is not portable to Sent.", + ), + Rule( + "generic-fallback-object", "any", r"['\"](?:fallback|failover)['\"]\s*[:=]", + "rewrite", "Caller-supplied fallback has no Sent equivalent; automatic routing performs it.", + ), + Rule( + "positional-template-var", "any", r"\{\{\s*[1-9][0-9]?\s*\}\}", + "rewrite", "Sent template parameters are a named map, not positional placeholders.", + ), + Rule( + "bearer-auth-to-provider", "any", r"Authorization['\"]?\s*[:=]\s*['\"]?Bearer\s", + "review", "Direct Sent REST calls use x-api-key; keep Bearer where an app proxy, MCP OAuth flow, or incumbent still requires it.", + ), + Rule( + "provider-status-branch", "any", + r"['\"](?:undelivered|accepted|sending)['\"]", + "rewrite", "Map incumbent status strings onto Sent statuses, adding FILTERED, BLOCKED, and SCHEDULED.", + ), + Rule( + "application-keyword-matcher", "any", + r"(?:==|===|\.equals\(|\.includes\(|\bin\s)\s*['\"](?:STOP|UNSUBSCRIBE|CANCEL|UNSTOP)['\"]", + "review", "Keep exact matching only to mirror local consent evidence; do not write consent to Sent a second time.", + ), + Rule( + "idempotency-key-present", "sent", r"Idempotency-Key", + "informational", "Idempotency key usage found; confirm keys are deterministic rather than random.", + ), +) + +COMPILED = tuple((rule, re.compile(rule.pattern, re.IGNORECASE)) for rule in RULES) + + +@dataclass +class Finding: + path: str + line: int + rule_id: str + provider: str + classification: str + guidance: str + excerpt: str + + +def redact_excerpt(line: str) -> str: + """Mask credential-like literals before reporting a matched source line.""" + line = re.sub( + r"(?i)(authorization[^\n]{0,24}bearer\s+)([^\s'\",;}]+)", + r"\1", + line, + ) + line = re.sub( + r"(?i)\b(api[_-]?key|auth[_-]?token|access[_-]?token|secret|password)(\s*[:=]\s*)([^\s,;}]+)", + r"\1\2", + line, + ) + return line + + +def scan_text(text: str, path: str = "") -> list[Finding]: + """Scan a blob of text and return findings.""" + findings: list[Finding] = [] + for number, line in enumerate(text.splitlines(), start=1): + if len(line) > 2000: + line = line[:2000] + for rule, regex in COMPILED: + if regex.search(line): + findings.append( + Finding( + path=path, + line=number, + rule_id=rule.rule_id, + provider=rule.provider, + classification=rule.classification, + guidance=rule.guidance, + excerpt=redact_excerpt(line.strip())[:200], + ) + ) + return findings + + +def scan_path(root: str) -> list[Finding]: + """Walk a directory tree and scan eligible files.""" + findings: list[Finding] = [] + for directory, subdirs, files in os.walk(root): + subdirs[:] = [name for name in subdirs if name not in SKIP_DIRS and not name.startswith(".")] + for filename in files: + if filename.startswith(".env"): + continue + extension = os.path.splitext(filename)[1].lower() + if extension not in SCAN_EXTENSIONS: + continue + full = os.path.join(directory, filename) + try: + if os.path.getsize(full) > MAX_FILE_BYTES: + continue + with open(full, encoding="utf-8", errors="replace") as handle: + text = handle.read() + except OSError: + continue + findings.extend(scan_text(text, os.path.relpath(full, root))) + return findings + + +def summarize(findings: list[Finding]) -> dict[str, int]: + """Count findings per classification.""" + counts: dict[str, int] = {} + for finding in findings: + counts[finding.classification] = counts.get(finding.classification, 0) + 1 + return counts + + +def _self_test() -> int: + failures: list[str] = [] + + sample = """ + const client = require('twilio')(sid, token); + await client.messages.create({ to, from, body }); + if (status === 'undelivered') retry(); + if (error.code === 21610) suppress(); + payload = {"channel": ["whatsapp", "sms"], "to": ["+14155551234"]} + body = {"failover": [{"channel": "sms"}]} + template = "Hello {{1}}, your order {{2}} shipped" + headers = {"Authorization": "Bearer " + token} + if (text.trim().toUpperCase() === 'STOP') { optOut(); } + """ + found = {finding.rule_id for finding in scan_text(sample)} + expected = { + "twilio-sdk", + "provider-status-branch", + "twilio-optout-code", + "ordered-channel-array", + "vonage-failover", + "generic-fallback-object", + "positional-template-var", + "bearer-auth-to-provider", + "application-keyword-matcher", + } + missing = expected - found + if missing: + failures.append(f"rules failed to fire: {sorted(missing)}") + + clean = 'payload = {"to": ["+14155551234"], "template": {"name": "t", "parameters": {"a": "b"}}}\n' + if scan_text(clean): + failures.append("a correct Sent payload must produce no findings") + + single = 'payload = {"channel": ["sms"]}\n' + if any(f.rule_id == "ordered-channel-array" for f in scan_text(single)): + failures.append("a single-channel array must not be flagged as broadcast") + + counts = summarize(scan_text(sample)) + if counts.get("rewrite", 0) < 5 or counts.get("review", 0) != 2: + failures.append(f"classification summary drifted: {counts}") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("inventory_scan self-test passed: 4 checks") + return EXIT_CLEAN + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Scan a repository for incumbent CPaaS usage.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--path", help="repository root to scan") + parser.add_argument("--format", choices=("text", "json"), default="text", help="output format") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + if not args.path: + parser.error("provide --path or --self-test") + if not os.path.isdir(args.path): + print(f"error: {args.path} is not a directory", file=sys.stderr) + return EXIT_USAGE + + findings = scan_path(args.path) + + if args.format == "json": + print(json.dumps({"summary": summarize(findings), "findings": [asdict(f) for f in findings]}, indent=2)) + else: + if not findings: + print("no incumbent CPaaS usage detected") + for finding in findings: + print(f"{finding.path}:{finding.line} [{finding.classification}/{finding.provider}] {finding.rule_id}") + print(f" {finding.excerpt}") + print(f" -> {finding.guidance}") + if findings: + print("\nsummary: " + ", ".join(f"{k}={v}" for k, v in sorted(summarize(findings).items()))) + + return EXIT_FINDINGS if findings else EXIT_CLEAN + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sent/skills/sent-integration-starter/SKILL.md b/packages/sent/skills/sent-integration-starter/SKILL.md new file mode 100644 index 0000000..1f16fb3 --- /dev/null +++ b/packages/sent/skills/sent-integration-starter/SKILL.md @@ -0,0 +1,108 @@ +--- +name: sent-integration-starter +description: Stands up a production-ready Sent v3 integration in an existing codebase — SDK selection and client construction, x-api-key configuration, idempotent sends, retry and rate-limit handling, the 46-code error catalog, sandbox verification, and a verified webhook receiver. Use when adding Sent to an app for the first time, choosing an SDK or framework wiring, handling 429 or 409 responses, deciding what to log, or hardening an integration before launch. +--- + +# Sent Integration Starter + +Bring up a Sent integration in four stages: authenticate, send idempotently, receive verified events, then harden. Do not conflate them — most broken integrations pass stage one and skip stage three. + +## Stage 1: client and credentials + +Direct Sent v3 REST requests authenticate with the `x-api-key` header. An application proxy may accept `Authorization: Bearer` from its own callers, and the Sent MCP server uses client-managed OAuth, but neither changes the REST header sent to `api.sent.dm`. Organization keys may add `x-profile-id` to act for a child profile; a profile-scoped key that sends that header receives `403`. + +| Language | Package | Client | +| --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `new SentDm()` | +| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | +| C# | `Sentdm` | `new SentClient()` | +| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | +| Ruby | `sentdm` | `Sentdm::Client.new` | + +Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. + +Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). + +Validate configuration at boot and fail fast when the key is missing, rather than surfacing an auth error on the first customer send. + +## Stage 2: idempotent sends + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345" } + }, + "sandbox": true +} +``` + +`to` is the only required field. Supply `template` or `text`, and omit `channel` to let automatic routing choose. Never write a `channel` array with several values expecting fallback — that broadcasts and multiplies charges. Channel decisions belong to `sent-routing-strategist`. + +Send `Idempotency-Key` on every POST, PUT, and PATCH, derived deterministically from your own domain object (for example the order id plus the notification type) so a retry after a timeout cannot double-send. Keys are 1–255 characters of `[A-Za-z0-9_-]`, cached 24 hours per key per customer. A replay returns the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`. A duplicate arriving while the original is still in flight waits up to five seconds and then fails `409 CONFLICT_001`; a `503 SERVICE_001` means the idempotency store was unavailable and the request was deliberately not executed. + +`202` means accepted, not delivered. Persist the returned `message_id` values immediately with your own tenant, profile, and logical send identifiers. Webhook events carry the Sent message id and account data, but never your application's tenant identifier. + +## Stage 3: verified webhook receiver + +An integration without a receiver has no delivery truth. Register an endpoint, then verify every delivery: HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, keyed on the base64-decoded secret after stripping `whsec_`, compared in constant time, rejecting timestamps outside 300 seconds. No SDK ships a verifier in any language. + +Acknowledge with `200` before doing work, and deduplicate on `{message_id}:{message_status}` for outbound events and `message_id` for inbound. Ten consecutive failed deliveries disable the endpoint. Full mechanics belong to `sent-webhook-engineer`; treat a verified, fast-acknowledging, deduplicating receiver as a launch requirement here. + +## Stage 4: harden + +### Retry policy by response class + +| Response | Retry | How | +| --- | --- | --- | +| `2xx` | No | Success | +| `400`, `422` `VALIDATION_*` | No | Fix the request | +| `401`, `403` `AUTH_*` | No | Stop immediately; ten consecutive auth failures lock the credential with escalating lockouts | +| `404` `RESOURCE_*` | No | The referenced object does not exist | +| `409 CONFLICT_001` | Yes, once, after a pause | A concurrent duplicate is in flight | +| `429` | Yes | Honor `Retry-After`; jittered backoff | +| `5xx`, `503 SERVICE_001` | Yes | Exponential backoff with jitter and a ceiling | +| Timeout with no response | Retry safely only with evidence | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient, so do not automate a resend | + +The standard limit is 200 requests per minute on a sliding window. `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` are limited to 10 per minute. Rate-limit headers appear **only** on `429` responses, so pacing must be designed rather than measured — batch up to 1,000 recipients per request and pace at roughly one request per second for bulk work. + +### Error handling + +Errors arrive as `{success, data, error: {code, message, details, doc_url}, meta: {request_id, timestamp, version}}`. Branch on the `error.code` prefix family (`AUTH_`, `VALIDATION_`, `RESOURCE_`, `BUSINESS_`, `CONFLICT_`, `SERVICE_`, `INTERNAL_`) rather than on message text or on individual codes. The full 46-code catalog with retry classification is in [references/errors-and-limits.md](references/errors-and-limits.md). + +Two codes are counterintuitive: `BUSINESS_003` and `BUSINESS_004` are documented as request-level errors, but on `POST /v3/messages` the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED`. Insufficient balance therefore does not fail the send call. + +### Observability + +Log `meta.request_id` on every response, success or failure — it is the correlation handle for support. Record the mapping from your logical send to the returned `message_id` values, and keep an append-only event history so a reroute's sequence remains auditable. Never log the API key, the webhook signing secret, `payment_details`, or raw recipient message content beyond your retention policy. + +### Launch checklist + +- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. +- [ ] `Idempotency-Key` on every mutating call, derived deterministically. +- [ ] Retry policy distinguishes retryable from terminal by error family. +- [ ] Bulk paths pace against 200 requests per minute and batch to at most 1,000 recipients. +- [ ] Webhook receiver verifies signature and timestamp, returns `200` fast, and dedupes. +- [ ] Receiver returns non-2xx on genuine failure so Sent retries. +- [ ] `message_id` to tenant mapping is persisted before sending. +- [ ] `request_id` is logged; secrets and card data are not. +- [ ] Sandbox smoke test passes, then a real send reaches `DELIVERED`. +- [ ] Alerting covers webhook `consecutive_failures`, `429` volume, and filtered or blocked rates. + +## Verification + +Run the local preflight, which needs no credentials and no network: + +```bash +python3 scripts/preflight.py --self-test +``` + +Then verify a real path with `"sandbox": true`, which authenticates and validates without executing, and finally with one live send confirmed to `DELIVERED` through the receiver. + +## Boundaries + +Use `sent-webhook-engineer` for receiver depth, `sent-routing-strategist` for channel choice, `sent-messaging` for a confirmed one-off send, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` for multi-tenant provisioning, and `migrate-to-sent` when replacing another CPaaS provider. diff --git a/packages/sent/skills/sent-integration-starter/agents/openai.yaml b/packages/sent/skills/sent-integration-starter/agents/openai.yaml new file mode 100644 index 0000000..4e65bfc --- /dev/null +++ b/packages/sent/skills/sent-integration-starter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Integration Starter" + short_description: "Stand up a production-ready Sent integration" + default_prompt: "Use $sent-integration-starter to review how my codebase should integrate Sent and what to harden before going live." diff --git a/packages/sent/skills/sent-integration-starter/references/errors-and-limits.md b/packages/sent/skills/sent-integration-starter/references/errors-and-limits.md new file mode 100644 index 0000000..55b0d4d --- /dev/null +++ b/packages/sent/skills/sent-integration-starter/references/errors-and-limits.md @@ -0,0 +1,186 @@ +# Error catalog, retries, idempotency, and rate limits + +## Table of contents + +- [Response envelope](#response-envelope) +- [Retry classification by family](#retry-classification-by-family) +- [AUTH codes](#auth-codes) +- [VALIDATION codes](#validation-codes) +- [RESOURCE codes](#resource-codes) +- [BUSINESS codes](#business-codes) +- [CONFLICT, SERVICE, and INTERNAL codes](#conflict-service-and-internal-codes) +- [Codes that behave differently on send](#codes-that-behave-differently-on-send) +- [Idempotency semantics](#idempotency-semantics) +- [Rate limits and pacing](#rate-limits-and-pacing) +- [Sandbox semantics](#sandbox-semantics) +- [Ambiguous send recovery](#ambiguous-send-recovery) + +## Response envelope + +Every response uses one shape: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_004", + "message": "Request validation failed", + "details": { "to": ["'to' must contain at least one recipient"] }, + "doc_url": "https://docs.sent.dm/reference/api/error-catalog" + }, + "meta": { + "request_id": "req_7X9zKp2jDw", + "timestamp": "2026-03-14T09:21:44Z", + "version": "v3" + } +} +``` + +Branch on `error.code`, never on `error.message`. Read `error.details` for field-level validation feedback and log `meta.request_id` on every response so support can correlate. + +## Retry classification by family + +| Family | Count | Default handling | +| --- | --- | --- | +| `AUTH_` | 6 | Terminal. Stop immediately; do not loop | +| `VALIDATION_` | 8 | Terminal. Fix the request | +| `RESOURCE_` | 14 | Terminal; reconcile `RESOURCE_007` with the existing resource | +| `BUSINESS_` | 11 | Mostly terminal; `BUSINESS_002` backs off | +| `CONFLICT_` | 1 | Retry once after a short pause | +| `SERVICE_` | 1 | Retry with backoff | +| `INTERNAL_` | 5 | Retry with backoff | + +The catalog contains 46 codes in total. Authentication failures deserve special care: ten consecutive failures lock the presented credential with a `429` and escalating lockout windows from one to sixty minutes, so a retry loop against a bad key extends its own outage. Stop and alert instead. + +## AUTH codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `AUTH_001` | 401 | User is not authenticated | never | +| `AUTH_002` | 401 | Invalid or missing API key | never | +| `AUTH_004` | 403 | Insufficient permissions | never | +| `AUTH_005` | 403 | Account not yet activated | never | +| `AUTH_006` | 403 | KYC verification not complete | never | +| `AUTH_007` | 403 | Channel setup not complete | never | + +`AUTH_004` is also what a profile-scoped key receives when it sends `x-profile-id`. `AUTH_005`, `AUTH_006`, and `AUTH_007` are onboarding states rather than credential problems, so surface them to an operator instead of retrying. + +## VALIDATION codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `VALIDATION_001` | 400 | Request validation failed | never | +| `VALIDATION_002` | 400 | Invalid phone number format | never | +| `VALIDATION_003` | 400 | Invalid GUID format | never | +| `VALIDATION_004` | 400 | Required field is missing | never | +| `VALIDATION_005` | 400 | Field value out of valid range | never | +| `VALIDATION_006` | 400 | Invalid enum value | never | +| `VALIDATION_007` | 400 | Invalid Idempotency-Key format | never | +| `VALIDATION_008` | 400 | Invalid template variable value | never | + +`VALIDATION_002` is prevented by normalizing recipients to E.164 before the call. `VALIDATION_006` is what an unsupported `channel` value returns. `VALIDATION_008` covers several distinct template-variable problems, so read the message rather than assuming one cause. + +## RESOURCE codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `RESOURCE_001` | 404 | Contact not found | never | +| `RESOURCE_002` | 404 | Template not found | never | +| `RESOURCE_003` | 404 | Message not found | never | +| `RESOURCE_004` | 404 | Customer not found | never | +| `RESOURCE_005` | 404 | Organization not found | never | +| `RESOURCE_006` | 404 | User not found | never | +| `RESOURCE_007` | 409 | Resource already exists | do not retry blindly | +| `RESOURCE_008` | 404 | Webhook not found | never | +| `RESOURCE_009` | 404 | Brand not found | never | +| `RESOURCE_010` | 404 | Campaign not found | never | +| `RESOURCE_011` | 404 | Batch not found | never | +| `RESOURCE_012` | 404 | Phone number not found | never | +| `RESOURCE_013` | 404 | Resource not found | never | +| `RESOURCE_014` | 404 | Profile not found | never | + +`RESOURCE_014` also occurs when an organization passes its own identifier as a `profileId`, which must be a child profile. `RESOURCE_007` is the duplicate-creation signal, most visibly when inviting a user who already has access; read the existing resource and decide whether the requested state is already satisfied. + +## BUSINESS codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `BUSINESS_001` | 400 | Cannot modify inherited contact | never | +| `BUSINESS_002` | 429 | Rate limit exceeded | backoff | +| `BUSINESS_003` | 402 | Insufficient account balance | never | +| `BUSINESS_004` | 400 | Contact has opted out | never | +| `BUSINESS_005` | 400 | Template not approved | never | +| `BUSINESS_006` | 400 | Message cannot be modified in current state | never | +| `BUSINESS_007` | 400 | Channel not available | never | +| `BUSINESS_008` | 400 | Operation would exceed quota | never | +| `BUSINESS_010` | 400 | Webhook is inactive | never | +| `BUSINESS_012` | 400 | Template is not active on the requested channel | never | +| `BUSINESS_014` | 403 | Account is suspended | never | + +`BUSINESS_001` is the inheritance boundary: a profile that inherits contacts cannot modify them. `BUSINESS_010` explains why a test delivery to a disabled webhook fails; re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the dashboard after fixing the receiver. + +## CONFLICT, SERVICE, and INTERNAL codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `CONFLICT_001` | 409 | Concurrent idempotent request | after delay | +| `SERVICE_001` | 503 | Cache service temporarily unavailable | backoff | +| `INTERNAL_001` | 500 | Unexpected internal server error | backoff | +| `INTERNAL_002` | 500 | Database operation failed | backoff | +| `INTERNAL_003` | 500 | External service error | backoff | +| `INTERNAL_004` | 504 | Timeout waiting for operation | backoff | +| `INTERNAL_005` | 503 | Service temporarily unavailable | backoff | + +`SERVICE_001` is a deliberate safety response: the idempotency cache was unavailable, so the API refused to execute rather than risk a duplicate. Retrying the same request with the same key is correct. + +## Codes that behave differently on send + +Two documented request-level codes do not reject `POST /v3/messages`. Insufficient balance (`BUSINESS_003`, 402) and an opted-out contact (`BUSINESS_004`, 400) are catalogued as errors, but on send the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED` respectively. Client code that only inspects HTTP status will believe those sends succeeded. + +The operational consequence is that balance and consent problems appear in delivery data rather than in error handling. Monitor blocked and filtered rates as first-class metrics alongside `4xx` and `5xx` counts. + +Sent also records internal reason codes on a message for consent blocks, route denials, no-route-matched, and invalid template parameters. These are never returned in API responses or webhook payloads, so diagnosis uses the terminal status plus the channel value plus `GET /v3/messages/{id}/activities`. + +## Idempotency semantics + +`Idempotency-Key` applies to POST, PUT, and PATCH on `/v3/*` and is ignored on GET and DELETE. Values are 1 to 255 characters of `[A-Za-z0-9_-]`. + +| Situation | Behavior | +| --- | --- | +| First successful request | Response cached for 24 hours per key per customer | +| Replay of a cached key | Cached body returned with `Idempotent-Replayed: true` and `X-Original-Request-Id` | +| Response larger than 5 MB | Not cached; a duplicate re-executes | +| Duplicate arrives while the original is in flight | Waits up to five seconds, then fails `409 CONFLICT_001` | +| Idempotency cache unavailable | `503 SERVICE_001`; the request was not executed | + +Derive keys deterministically from your own domain objects — an order id plus a notification type, for example — rather than generating a random value per attempt, so that a retry after a network timeout collides with the original instead of creating a second send. Because caching is per customer, the same key used by two different customers is two independent operations. + +## Rate limits and pacing + +| Tier | Limit | Window | Applies to | +| --- | --- | --- | --- | +| Standard | 200 requests/minute | Sliding 60 seconds | Everything not listed below | +| Sensitive | 10 requests/minute | Fixed window | `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/webhooks/{id}/test` | + +`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` are present **only** on `429` responses. There is no way to read remaining quota preemptively, so pacing must be a design decision rather than an adaptive reaction. + +For bulk work, batch up to 1,000 recipients per `POST /v3/messages` and pace at roughly one request per second, which keeps a large campaign inside the standard budget while leaving headroom for transactional traffic. Note that batching multiplies with channels: 1,000 recipients on two channels is 2,000 messages and 2,000 charges from a single request. + +Rate-limit exposure follows the credential. A profile-scoped key has its own pool; an organization key acting through `x-profile-id` draws on the organization pool shared by every profile. + +## Sandbox semantics + +`"sandbox": true` runs authentication and validation and then stops. Nothing is persisted, queued, dispatched to a provider, or charged, and resource lookups do not occur — so a sandbox request will not tell you whether a template id exists. Malformed requests still return real `400` and `422` responses, which is what makes sandbox valuable in continuous integration. + +The exception worth memorizing: `DELETE /v3/webhooks/{id}` ignores the flag and always deletes. Never use sandbox as a general dry-run guard for destructive calls. + +## Ambiguous send recovery + +When a send times out or the connection drops before a response arrives, the request may or may not have been accepted. Never blind-retry. + +1. If the original carried an `Idempotency-Key`, retry with the **same** key. A cached success returns the original response with `Idempotent-Replayed: true`; a `409 CONFLICT_001` means the original is still in flight, so pause and retry once. +2. If no key was sent, search your own request and response records for a returned `message_id`. Sent exposes no reliable lookup by idempotency key or recipient that can prove an ambiguous request did not execute. +3. Escalate ambiguous no-key cases for an explicit duplicate-risk decision. Only send again when your application has sufficient evidence that nothing was accepted, and attach an idempotency key this time. + +The same discipline applies to profile provisioning: a deterministic key derived from your provisioning record prevents a timeout from creating a second profile. diff --git a/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md new file mode 100644 index 0000000..713b428 --- /dev/null +++ b/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -0,0 +1,158 @@ +# SDK selection and framework wiring + +## Table of contents + +- [Package matrix](#package-matrix) +- [Client construction per language](#client-construction-per-language) +- [Configuration and environment variables](#configuration-and-environment-variables) +- [Framework wiring](#framework-wiring) +- [Background processing per ecosystem](#background-processing-per-ecosystem) +- [Multi-tenant credential patterns](#multi-tenant-credential-patterns) +- [Testing and mocking](#testing-and-mocking) +- [Deployment notes](#deployment-notes) + +## Package matrix + +| Language | Package | Install | Minimum runtime | +| --- | --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `npm install @sentdm/sentdm` | Node with ESM or CJS | +| Python | `sentdm` (imports as `sent_dm`) | `pip install sentdm` | Python 3.9 | +| Go | `github.com/sentdm/sent-dm-go` | `go get github.com/sentdm/sent-dm-go` | Go 1.22 | +| Java | `dm.sent:sent-java` | Maven or Gradle dependency | Java 8 | +| C# | `Sentdm` | `dotnet add package Sentdm` | .NET Standard 2.0 | +| PHP | `sentdm/sent-dm-php` | `composer require sentdm/sent-dm-php` | PHP 8.1 | +| Ruby | `sentdm` | `gem install sentdm` or Bundler | Ruby 3.2 | + +The distribution name and the import name differ in Python (`sentdm` installs, `sent_dm` imports) and the Ruby send method is `messages.send_` with a trailing underscore because `send` is reserved. Both are common first-hour errors. + +No SDK ships a webhook signature verifier in any language. That code is always application-owned. + +## Client construction per language + +```typescript +import SentDm from '@sentdm/sentdm'; + +// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. +export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); + +const response = await sent.messages.send({ + to: ['+14155551234'], + template: { name: 'order_confirmation', parameters: { order_id: '12345' } }, +}); +``` + +```python +from sent_dm import Sent, AsyncSent + +client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY +async_client = AsyncSent() + +response = client.messages.send( + to=["+14155551234"], + template={"name": "order_confirmation", "parameters": {"order_id": "12345"}}, +) +``` + +```go +client := sentdm.NewClient() // or option.WithAPIKey(...) +response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ + To: []string{"+14155551234"}, +}) +``` + +```java +SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +MessageSendResponse response = client.messages().send(params); +``` + +```csharp +using Sentdm; +SentClient client = new(); // reads SENT_DM_API_KEY +var response = await client.Messages.Send(body); +``` + +```php +use SentDm\Client; +$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); +``` + +```ruby +require "sentdm" +client = Sentdm::Client.new # reads SENT_DM_API_KEY +client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) +``` + +Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. + +## Configuration and environment variables + +| Variable | Purpose | Read automatically | +| --- | --- | --- | +| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | +| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | +| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | + +Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. + +For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. + +## Framework wiring + +| Framework | Client placement | Webhook raw body | +| --- | --- | --- | +| Next.js | Shared module such as `lib/sent/client.ts` | `await request.text()`; keep the route on the Node runtime | +| Express | Module singleton | `express.raw({ type: 'application/json' })` scoped to the webhook path | +| NestJS | Provider in a `SentModule` | `req.rawBody` with `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | Client built in the lifespan, injected as a dependency | `await request.body()` | +| Django | `@lru_cache` factory in a `client.py` | `request.body` | +| Flask | Cached on the app or request context | `request.get_data()` | +| Gin / Echo | Constructed in `main`, passed to handlers | `io.ReadAll(c.Request.Body)` | +| Spring Boot | `@Bean` in a configuration class | `@RequestBody String payload` | +| Laravel | Singleton in the service container | `$request->getContent()` in middleware | +| Symfony | Autowired service | `$request->getContent()` | +| Rails | Memoized in an initializer | `request.body.read` then `request.body.rewind` | +| Sinatra | Memoized module method | `request.body.read` then `request.body.rewind` | +| ASP.NET Core | Singleton via dependency injection | `new StreamReader(request.Body).ReadToEndAsync()` | + +The recurring defect is a global JSON body parser that destroys the byte-exact body needed for signature verification. Scope the parser away from the webhook path, or read the raw bytes before any parsing occurs. + +A minimal integration is four files regardless of stack: a client module, an outbound send route, an inbound webhook route, and a signature-verification helper. + +## Background processing per ecosystem + +Webhook handlers must acknowledge with `200` and then work asynchronously, because ten consecutive failed deliveries disable the endpoint and a slow handler manufactures those failures. + +| Ecosystem | Mechanism | +| --- | --- | +| Node | BullMQ or an equivalent durable queue | +| Python | Celery or another durable queue; reserve FastAPI `BackgroundTasks` for non-critical local work | +| Go | A bounded worker pool or a job queue | +| Java | `@Async` with a `ThreadPoolTaskExecutor`, or a broker | +| PHP | Laravel queued jobs, Symfony Messenger | +| Ruby | ActiveJob or Sidekiq | +| .NET | A `BackgroundService` consuming a channel or queue | + +Route bulk campaign traffic to a queue separate from transactional sends so a large campaign cannot starve time-sensitive messages, and set worker concurrency or a task rate limit that respects the 200-requests-per-minute budget. + +## Multi-tenant credential patterns + +Two patterns exist, and mixing them causes confusing `403` responses. + +A **profile-scoped key** is confined to one profile, has its own rate-limit pool, and must not send `x-profile-id` — doing so returns `403`. Prefer it for runtime send paths so a leaked key affects one tenant. + +An **organization key with `x-profile-id`** reaches permitted child profiles but draws on the organization's shared rate-limit pool, so one noisy tenant consumes everyone's quota. Prefer it for control-plane work such as provisioning. + +When each tenant supplies its own key, resolve it for the request, construct the client with that credential, and discard both afterward. Do not retain tenant credentials in a client cache merely to preserve connection pooling; isolation and rotation correctness take priority. Queued work must resolve the authorized tenant credential just in time from a secret store rather than embedding it in the job payload. Never place a key in a browser, mobile app, or any client the organization does not control, and keep separate keys per environment. `x-sender-id` is legacy v1 and v2 terminology with no role in v3. + +## Testing and mocking + +Use `"sandbox": true` for integration tests: authentication and validation still run, so a malformed request still returns `400` or `422`, but nothing is written, queued, charged, or dispatched to a provider. It is the right default in continuous integration. + +For unit tests, mock at the SDK boundary — `jest.fn()` on `messages.send`, a NestJS testing module override, a substituted `ISentClient` in .NET — and assert on the request payload rather than on transport behavior. For the receiver, generate valid headers locally with the webhook skill's signing script so tests cover the signature path without contacting Sent. + +Two notes on live verification. `POST /v3/webhooks/{id}/test` delivers exactly once with no retry, so re-run it after each fix. And `DELETE /v3/webhooks/{id}` ignores `sandbox` and always deletes, so never treat the flag as a dry-run guard for deletion. + +## Deployment notes + +Keep webhook routes on runtimes that expose Node-style crypto and raw bodies rather than on edge runtimes. Close the HTTP server gracefully on `SIGTERM` so in-flight deliveries finish instead of failing and triggering retries. Ensure load balancer idle timeouts exceed the configured `timeout_seconds`, and keep container clocks NTP-synchronized so the 300-second replay window does not reject valid traffic. Keep the route outside user-auth middleware. If abuse controls are required, make them signature-aware and capacity-safe rather than placing a generic limiter in front of verification and manufacturing the failures that lead to auto-disable. diff --git a/packages/sent/skills/sent-integration-starter/scripts/preflight.py b/packages/sent/skills/sent-integration-starter/scripts/preflight.py new file mode 100644 index 0000000..7f74185 --- /dev/null +++ b/packages/sent/skills/sent-integration-starter/scripts/preflight.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Offline preflight checks for a Sent v3 integration. + +Validates the things that break integrations before any network call is made: +recipient formatting, send-payload shape, channel-array intent, idempotency-key +format, batch sizing against the documented pacing budget, and the retry +classification of an error code. + +Usage +----- +Run the built-in synthetic fixtures:: + + python3 preflight.py --self-test + +Check a send payload written to a file:: + + python3 preflight.py --payload-file send.json + +Classify an error code for retry behavior:: + + python3 preflight.py --classify-error 409:CONFLICT_001 + +Exit codes: 0 all checks passed, 1 one or more findings, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +E164 = re.compile(r"^\+[1-9]\d{1,14}$") +IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9_-]{1,255}$") +VALID_CHANNELS = {"sent", "sms", "whatsapp", "rcs"} +MAX_RECIPIENTS_PER_REQUEST = 1000 +STANDARD_RATE_LIMIT_PER_MINUTE = 200 +SENSITIVE_RATE_LIMIT_PER_MINUTE = 10 + +TERMINAL_FAMILIES = {"AUTH", "VALIDATION", "RESOURCE"} +RETRYABLE_FAMILIES = {"SERVICE", "INTERNAL"} + +EXIT_OK = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + + +def check_recipients(recipients: object) -> list[str]: + """Validate the `to` array.""" + findings: list[str] = [] + if not isinstance(recipients, list) or not recipients: + return ["'to' must be a non-empty array of E.164 phone numbers"] + for value in recipients: + if not isinstance(value, str) or not E164.match(value): + findings.append(f"recipient {value!r} is not E.164 (leading '+', country code, digits only)") + if len(recipients) > MAX_RECIPIENTS_PER_REQUEST: + findings.append( + f"{len(recipients)} recipients exceeds the {MAX_RECIPIENTS_PER_REQUEST}-recipient per-request limit" + ) + return findings + + +def check_channels(channels: object) -> list[str]: + """Validate the `channel` array and flag broadcast intent.""" + if channels is None: + return [] + findings: list[str] = [] + if not isinstance(channels, list): + return ["'channel' must be an array when present"] + for value in channels: + if value not in VALID_CHANNELS: + findings.append(f"channel {value!r} is invalid; allowed values are {sorted(VALID_CHANNELS)}") + explicit = [value for value in channels if value != "sent"] + if len(explicit) > 1: + findings.append( + "multiple explicit channels broadcast rather than fall back: one message and one charge is created " + "per (recipient, channel) pair. Omit 'channel' or use ['sent'] for automatic routing with reroute" + ) + if "sent" in channels and len(channels) > 1: + findings.append("'sent' combined with an explicit channel is ambiguous; use one or the other") + return findings + + +def check_content(payload: dict) -> list[str]: + """Validate that exactly one content source is present.""" + has_template = isinstance(payload.get("template"), dict) + has_text = isinstance(payload.get("text"), str) and payload["text"].strip() != "" + if has_template and has_text: + return ["provide either 'template' or 'text', not both"] + if not has_template and not has_text: + return ["provide 'template' or 'text' as the message content"] + if has_template: + template = payload["template"] + if not template.get("id") and not template.get("name"): + return ["'template' requires 'id' or 'name'"] + if template.get("id") and template.get("name"): + return ["'template.id' and 'template.name' are mutually exclusive"] + parameters = template.get("parameters") + if parameters is not None and not isinstance(parameters, dict): + return ["'template.parameters' must be an object of string values"] + if isinstance(parameters, dict) and any(not isinstance(value, str) for value in parameters.values()): + return ["every 'template.parameters' value must be a string"] + return [] + + +def check_idempotency_key(key: object) -> list[str]: + """Validate an Idempotency-Key header value.""" + if key is None: + return ["no Idempotency-Key supplied; a timeout retry can produce a duplicate send"] + if not isinstance(key, str) or not IDEMPOTENCY_KEY.match(key): + return ["Idempotency-Key must be 1-255 characters of letters, digits, hyphens, or underscores"] + return [] + + +def estimate_batches(recipient_count: int, channel_count: int = 1) -> dict[str, int]: + """Return message and request estimates for a bulk send.""" + channel_count = max(1, channel_count) + messages = recipient_count * channel_count + requests = -(-recipient_count // MAX_RECIPIENTS_PER_REQUEST) + minutes = -(-requests // STANDARD_RATE_LIMIT_PER_MINUTE) + return { + "messages_created": messages, + "requests_required": requests, + "minimum_minutes_at_rate_limit": minutes, + } + + +def classify_error(status: int, code: str) -> tuple[str, str]: + """Return (classification, guidance) for a Sent error response.""" + family = code.split("_", 1)[0].upper() if code else "" + if status == 429: + return "retry", "honor Retry-After, then use jittered exponential backoff; stop if the credential is locked" + if code.upper() == "CONFLICT_001": + return "retry-once", "a concurrent duplicate is in flight; pause, then retry the same Idempotency-Key once" + if code.upper() == "SERVICE_001": + return "retry", "the idempotency store was unavailable and the request was deliberately not executed" + if family in RETRYABLE_FAMILIES or 500 <= status < 600: + return "retry", "exponential backoff with jitter and a bounded ceiling" + if family == "AUTH": + return "terminal", "stop immediately; ten consecutive auth failures lock the credential with escalating lockout" + if family in TERMINAL_FAMILIES: + return "terminal", "fix the request or the referenced resource; retrying reproduces the same result" + if family == "BUSINESS": + return "conditional", ( + "an account or policy precondition; on POST /v3/messages the send is accepted with 202 and the " + "affected messages finalize as BLOCKED or FILTERED, so resolve the condition before resending" + ) + return "unknown", "treat as terminal until classified; log meta.request_id and inspect error.doc_url" + + +def check_payload(payload: dict, idempotency_key: str | None = None) -> list[str]: + """Run every payload check and return the accumulated findings.""" + findings: list[str] = [] + findings.extend(check_recipients(payload.get("to"))) + findings.extend(check_channels(payload.get("channel"))) + findings.extend(check_content(payload)) + findings.extend(check_idempotency_key(idempotency_key)) + return findings + + +def _self_test() -> int: + failures: list[str] = [] + + good = { + "to": ["+14155551234"], + "template": {"name": "order_confirmation", "parameters": {"order_id": "12345"}}, + } + if check_payload(good, "order-12345-confirmation"): + failures.append("a well-formed payload with an idempotency key must produce no findings") + + if not check_recipients(["4155551234"]): + failures.append("a non-E.164 recipient must be flagged") + if not check_recipients([]): + failures.append("an empty recipient list must be flagged") + if not check_recipients(["+1415555%s" % "1" * 15]): + failures.append("an over-long number must be flagged") + + broadcast = check_channels(["whatsapp", "sms"]) + if not any("broadcast" in finding for finding in broadcast): + failures.append("a multi-channel array must be flagged as broadcast, not fallback") + if check_channels(["sent"]) or check_channels(None): + failures.append("automatic routing must produce no channel findings") + if not check_channels(["telegram"]): + failures.append("an unsupported channel value must be flagged") + + if not check_content({"to": ["+14155551234"]}): + failures.append("missing content must be flagged") + if not check_content({"template": {"id": "x", "name": "y"}}): + failures.append("template id and name together must be flagged") + if not check_content({"template": {"name": "t"}, "text": "hello"}): + failures.append("template and text together must be flagged") + if not check_content({"template": {"name": "t", "parameters": {"count": 2}}}): + failures.append("non-string template parameter values must be flagged") + + if not check_idempotency_key(None): + failures.append("a missing idempotency key must be flagged") + if not check_idempotency_key("bad key!"): + failures.append("an invalid idempotency key must be flagged") + + estimate = estimate_batches(2500, 2) + if estimate != {"messages_created": 5000, "requests_required": 3, "minimum_minutes_at_rate_limit": 1}: + failures.append(f"batch estimation drifted: {estimate}") + + expectations = { + (429, "BUSINESS_009"): "retry", + (409, "CONFLICT_001"): "retry-once", + (503, "SERVICE_001"): "retry", + (401, "AUTH_002"): "terminal", + (400, "VALIDATION_004"): "terminal", + (404, "RESOURCE_001"): "terminal", + (500, "INTERNAL_001"): "retry", + (402, "BUSINESS_003"): "conditional", + } + for (status, code), expected in expectations.items(): + actual, _ = classify_error(status, code) + if actual != expected: + failures.append(f"{status} {code} classified as {actual}, expected {expected}") + + if SENSITIVE_RATE_LIMIT_PER_MINUTE >= STANDARD_RATE_LIMIT_PER_MINUTE: + failures.append("the sensitive tier must be lower than the standard tier") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("preflight self-test passed: 20 checks") + return EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Offline preflight checks for a Sent v3 integration.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--payload-file", help="path to a JSON send payload to check") + parser.add_argument("--idempotency-key", help="the Idempotency-Key that will be sent with the payload") + parser.add_argument("--estimate", type=int, metavar="RECIPIENTS", help="estimate messages, requests, and pacing") + parser.add_argument("--channels", type=int, default=1, help="number of channels used with --estimate") + parser.add_argument("--classify-error", metavar="STATUS:CODE", help="classify an error response for retry") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if args.classify_error: + try: + status_text, _, code = args.classify_error.partition(":") + classification, guidance = classify_error(int(status_text), code) + except ValueError: + print("error: --classify-error expects STATUS:CODE, for example 429:BUSINESS_009", file=sys.stderr) + return EXIT_USAGE + print(f"{args.classify_error} -> {classification}: {guidance}") + return EXIT_OK + + if args.estimate is not None: + for key, value in estimate_batches(args.estimate, args.channels).items(): + print(f"{key}: {value}") + return EXIT_OK + + if not args.payload_file: + parser.error("provide --payload-file, --estimate, --classify-error, or --self-test") + + try: + with open(args.payload_file, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + print(f"error: could not read payload: {exc}", file=sys.stderr) + return EXIT_USAGE + + if not isinstance(payload, dict): + print("error: payload must be a JSON object", file=sys.stderr) + return EXIT_USAGE + + findings = check_payload(payload, args.idempotency_key) + if payload.get("sandbox") is True: + print("note: sandbox is true, so this request validates and authenticates without executing") + if not findings: + print("payload passed all preflight checks") + return EXIT_OK + for finding in findings: + print(f"- {finding}") + return EXIT_FINDINGS + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sent/skills/sent-profile-provisioning/SKILL.md b/packages/sent/skills/sent-profile-provisioning/SKILL.md new file mode 100644 index 0000000..a021269 --- /dev/null +++ b/packages/sent/skills/sent-profile-provisioning/SKILL.md @@ -0,0 +1,134 @@ +--- +name: sent-profile-provisioning +description: Executes the Sent Sender Profile lifecycle over the API — creating profiles with the right inheritance, sharing, billing, and WhatsApp options, driving profile completion and its callback, managing 10DLC campaigns per profile, and administering users and roles. Use when calling POST /v3/profiles, handling a completion callback or unclear profile status, choosing inherit or dedicated resources, wiring per-tenant onboarding, or inviting and role-managing users. +--- + +# Sent Profile Provisioning + +This skill is the execution counterpart to profile architecture: once the tenancy boundary is decided, it drives the API calls, the completion callback, the campaign registration, and the user administration that make a profile able to send. Design the boundary with `sender-profile-architect` first; provision it here. + +## Provisioning sequence + +1. **Confirm the credential.** `POST /v3/profiles` requires an organization key with `admin`. Profile-scoped keys cannot create profiles, and a profile key that sends `x-profile-id` receives `403`. +2. **Decide inheritance and sharing before the call.** These flags shape compliance posture and are awkward to unwind later. +3. **Create the profile**, validating the payload with `"sandbox": true` first when the shape is uncertain. Use a different idempotency key for the live create because a successful sandbox response is cached for 24 hours. +4. **Attach or inherit WhatsApp** via exactly one of the three supported paths. +5. **Register campaigns** for US SMS under the profile. +6. **Complete the profile** with `POST /v3/profiles/{profileId}/complete` and a reachable `webHookUrl`. +7. **Reconcile status** from the callback, or by polling if the callback is missed. +8. **Invite users** with least-privilege roles. + +## Create payload essentials + +`name` is the only required field. The consequential optional fields group into identity, sharing, inheritance, billing, WhatsApp, and brand. + +```json +{ + "name": "Northwind Retail", + "short_name": "Northwind", + "description": "Retail brand tenant", + "allow_contact_sharing": false, + "allow_template_sharing": false, + "inherit_contacts": false, + "inherit_templates": false, + "inherit_tcr_brand": true, + "inherit_tcr_campaign": true, + "billing_model": "profile", + "billing_contact": { + "name": "Ada Ops", + "email": "ops@example.com", + "phone": "+14155550100", + "address": "1 Example Way, Springfield" + }, + "sandbox": true +} +``` + +`short_name` must be 3 to 11 characters of letters, numbers, and spaces with at least one letter. Inheritance flags default to true, so a profile created with no flags consumes the organization's contacts, templates, brand, and campaigns. The example opts into contact and template isolation explicitly while inheriting the organization's compliance registrations. Sharing flags expose this profile's resources outward; inheritance flags consume the organization's resources inward. They are independent directions and are frequently confused. + +Create permits `name` alone, but completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. When `inherit_tcr_brand` is true, the API rejects a `brand` object in the create request even though the profile still needs its own KYC submission; complete that KYC through the dashboard before calling the completion endpoint. + +`billing_model` accepts `profile`, `organization`, or `profile_and_organization`. Any model that includes `profile` requires `billing_contact` when none exists, and `payment_details` is only accepted for those models. Card fields are forwarded to the payment processor and must never be logged, echoed, or persisted anywhere in the application. + +Field-by-field rules, error codes, and the update-only fields are in [references/profile-lifecycle.md](references/profile-lifecycle.md). + +## Inheritance decisions + +| Flag | `true` means | Consequence | +| --- | --- | --- | +| `inherit_tcr_brand` | Use the organization's registered brand | A `brand` object in the same request is rejected | +| `inherit_tcr_campaign` | Use the organization's campaigns | Those campaigns are read-only for this profile; creating one returns a validation error | +| `inherit_contacts` | Read the organization's contacts | No contact isolation between tenants | +| `inherit_templates` | Read the organization's templates | No template isolation between tenants | + +An inherited brand with `inherit_tcr_campaign: false` is a supported and common pattern: shared legal identity, dedicated messaging use cases per tenant. + +## WhatsApp: exactly three paths + +1. Organization Embedded Signup, performed in the Sent Dashboard. **No public endpoint starts this flow.** +2. Child-profile inheritance — omit `whatsapp_business_account` once the organization has a WABA. +3. Dedicated profile credentials — supply `whatsapp_business_account` with `waba_id` and `access_token`, optionally `phone_number_id`. + +Supplying credentials on `POST /v3/profiles` is not an Embedded Signup endpoint. Omitting `whatsapp_business_account` when the organization has no WABA configured returns `422`; complete organization Embedded Signup or supply valid direct credentials. Use `waba-embedded-signup` for the operational signup flow. + +## Completion and status + +`POST /v3/profiles/{profileId}/complete` requires `webHookUrl`. + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +A `202` means processing started and carries no final status. A `200` means the profile was already complete and its body carries a status. The callback body is `{profileId, success, status, timestamp}` and is **delivered once with no retry**, so the receiver must be live before the call and the flow must degrade to polling `GET /v3/profiles/{profileId}`. This callback is separate from subscribed Sent webhooks and is not documented as carrying the webhook HMAC headers; use a unique callback path tied to the provisioning record, reject unknown profile ids, and treat polling as the authoritative recovery path. + +Profile status vocabulary differs by surface: the create response demonstrates lowercase `incomplete`, the completion `200` demonstrates lowercase `completed`, the completion callback uses `COMPLETED`, `SUBMITTED`, and `failed`, and `GET /v3/profiles/{id}` documents `approved`, `submitted`, `processing`, and `failed`. Do not assert a closed enum, do not lowercase-normalize into a fixed set, and record which surface produced each value. Compare statuses case-insensitively and preserve unknown strings. + +## Campaigns per profile + +Campaign management lives under the profile: `GET|POST /v3/profiles/{profileId}/campaigns` and `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. There are no standalone brand endpoints; a dedicated brand is created with the profile. + + +```json +{ + "campaign": { + "name": "Northwind order notifications", + "description": "Order and delivery notifications for opted-in Northwind customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Northwind: Your order 12345 has shipped. Reply STOP to opt out." + ] + } + ], + "volume": "1500", + "messageFlow": "Customers opt in at checkout before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms" + } +} +``` + +`messagingUseCaseUs` accepts one of thirteen values, `sampleMessages` holds 1 to 5 entries of at most 1,024 characters each, and a numeric `volume` string below 2,000 selects the low-volume tier while 2,000 or above selects the standard tier. Campaign statuses are `SENT_CREATED`, `ACTIVE`, and `EXPIRED`. Use `sms-10dlc-registration` for use-case selection and sample-copy policy. + +## Users and roles + +Five operations administer access: `GET /v3/users`, `POST /v3/users` (invite), `GET /v3/users/{userId}`, `PATCH /v3/users/{userId}` (role), and `DELETE /v3/users/{userId}`. None is exposed through MCP. Assignable roles are `admin`, `billing`, and `developer`; `owner` is implicit for the creating account and never appears in the list. Mutations require `admin`. + +Role checks resolve against the email that owns the API key and pass only for the owner or an **active** user with an allowed role — `invited`, `suspended`, and `rejected` users fail. Organization-level access cascades to child profiles. Invitations expire after seven days, and inviting an existing user returns `409`. + +Before any user mutation, read the current state, then confirm explicitly with the operator. The API refuses to let you change your own role, demote the last admin, remove yourself, or remove the last admin, but checking first produces a clear explanation instead of a validation error. The full role matrix and key-hygiene rules are in [references/users-and-roles.md](references/users-and-roles.md). + +There is no endpoint to list, create, or revoke API keys; key management is a dashboard operation. Rotation is create-new, deploy, verify with `GET /v3/me`, then disable or delete the old key — deleting first only when the key is compromised. + +## Multi-tenant provisioning notes + +Webhook events never carry your application's tenant identifier. Before the first send, persist `message_id -> {tenant, profile, logical_send_id, channel}` and `receiving_number -> {tenant, profile}`. Do not infer tenant ownership from `account_id`, since many tenant profiles can share one organization. Provision one webhook registration per environment so a failing lower-environment receiver cannot auto-disable production. + +## Boundaries + +Use `sender-profile-architect` for the isolation, credential, and blast-radius design decision; `waba-embedded-signup` for the WhatsApp signup flow; `sms-10dlc-registration` for brand vetting and campaign policy; and `sent-webhook-engineer` for subscribed message-event receivers. Profile-completion callbacks use the separate verification and polling guidance in this skill. diff --git a/packages/sent/skills/sent-profile-provisioning/agents/openai.yaml b/packages/sent/skills/sent-profile-provisioning/agents/openai.yaml new file mode 100644 index 0000000..df60ac1 --- /dev/null +++ b/packages/sent/skills/sent-profile-provisioning/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Profile Provisioning" + short_description: "Run the Sender Profile lifecycle" + default_prompt: "Use $sent-profile-provisioning to plan the API calls that bring a new Sender Profile to a sending state." diff --git a/packages/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md b/packages/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md new file mode 100644 index 0000000..108f49c --- /dev/null +++ b/packages/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md @@ -0,0 +1,167 @@ +# Sender Profile lifecycle reference + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Create field reference](#create-field-reference) +- [Update-only fields](#update-only-fields) +- [Inheritance and sharing matrix](#inheritance-and-sharing-matrix) +- [Billing configuration](#billing-configuration) +- [WhatsApp attachment paths](#whatsapp-attachment-paths) +- [Completion flow and callback](#completion-flow-and-callback) +- [Status vocabulary by surface](#status-vocabulary-by-surface) +- [Error catalog for provisioning](#error-catalog-for-provisioning) +- [Idempotency and sandbox](#idempotency-and-sandbox) +- [Offboarding](#offboarding) + +## Operation catalog + +Ten operations cover profiles and their campaigns. None is available through MCP, so provisioning is REST-only. + +| Operation | Purpose | +| --- | --- | +| `POST /v3/profiles` | Create a profile | +| `GET /v3/profiles` | List profiles | +| `GET /v3/profiles/{profileId}` | Read one profile, including current status | +| `PATCH /v3/profiles/{profileId}` | Update configuration and number references | +| `DELETE /v3/profiles/{profileId}` | Remove a profile | +| `POST /v3/profiles/{profileId}/complete` | Start completion; requires `webHookUrl` | +| `GET /v3/profiles/{profileId}/campaigns` | List campaigns | +| `POST /v3/profiles/{profileId}/campaigns` | Create a campaign | +| `PUT /v3/profiles/{profileId}/campaigns/{campaignId}` | Update a campaign | +| `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` | Delete a campaign | + +Creation requires an organization API key whose owning email holds `admin`. An organization key may target a child with `x-profile-id`; a profile-scoped key sending that header receives `403`, and a profile outside the organization returns `404`. + +## Create field reference + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `name` | string | — | The only required field | +| `icon` | string | — | Display asset | +| `description` | string | — | Free text | +| `short_name` | string | — | 3–11 chars, letters, numbers, spaces, at least one letter | +| `allow_contact_sharing` | boolean | `false` | Exposes this profile's contacts outward | +| `allow_template_sharing` | boolean | `false` | Exposes this profile's templates outward | +| `inherit_contacts` | boolean | `true` | Consumes the organization's contacts | +| `inherit_templates` | boolean | `true` | Consumes the organization's templates | +| `inherit_tcr_brand` | boolean | `true` | Uses the organization's brand; forbids a `brand` object | +| `inherit_tcr_campaign` | boolean | `true` | Inherited campaigns are read-only for this profile | +| `billing_model` | enum | `profile` | `profile`, `organization`, or `profile_and_organization` | +| `billing_contact` | object | — | `name`, `email`, `phone`, `address`; required when the model includes `profile` and none exists | +| `whatsapp_business_account` | object | — | `waba_id` and `access_token` required, `phone_number_id` optional | +| `brand` | object | — | `contact` and `compliance` required, `business` optional; forbidden when `inherit_tcr_brand` is true | +| `payment_details` | object | — | `card_number`, `expiry`, `cvc`, `zip_code`; only for models including `profile` | +| `sandbox` | boolean | `false` | Simulate without side effects | + +`payment_details` is ephemeral and forwarded to the payment processor. Never log it, never echo it back to a user, never store it, and never place it in a file that could be committed. + +Although creation requires only `name`, completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. A profile inheriting the organization's TCR brand cannot include a `brand` object in the API request but still needs profile-level KYC submitted through the dashboard before completion. + +## Update-only fields + +`PATCH /v3/profiles/{profileId}` accepts the create fields plus number references: + +- `sending_phone_number_profile_id` +- `sending_whatsapp_number_profile_id` +- `sending_phone_number` +- `whatsapp_phone_number` +- `allow_number_change_during_onboarding` + +Model reference identifiers separately from literal numbers, and guard against cycles when one profile's sending number points at another profile that points back. + +## Inheritance and sharing matrix + +Inheritance pulls resources in; sharing pushes them out. They are independent. + +| Configuration | Result | +| --- | --- | +| `inherit_contacts: true`, `allow_contact_sharing: false` | Reads organization contacts; does not expose its own | +| `inherit_contacts: false`, `allow_contact_sharing: true` | Isolated contact store that other profiles may read | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | Fully inherited compliance posture; campaigns read-only here | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | Shared legal identity with per-tenant use cases — the common multi-tenant pattern | +| `inherit_tcr_brand: false` | Dedicated brand supplied in the same create request | + +For tenant isolation, set `inherit_contacts` and `inherit_templates` to false explicitly, because both default to true and a silently inherited store means one tenant can read another's data model. + +## Billing configuration + +| Model | Meaning | Requires | +| --- | --- | --- | +| `profile` | The profile pays | `billing_contact`, optionally `payment_details` | +| `organization` | The organization pays | Nothing profile-side | +| `profile_and_organization` | Profile first with organization fallback | `billing_contact` | + +Effective balance follows this configuration, so a balance reading for a profile with `organization` billing reflects the organization's funds. Confirm which model a profile uses before interpreting a balance or diagnosing a `BLOCKED` message. + +## WhatsApp attachment paths + +| Path | How | When | +| --- | --- | --- | +| Organization Embedded Signup | Sent Dashboard only; no public endpoint exists | The organization owns one WABA used across profiles | +| Child inheritance | Omit `whatsapp_business_account` | Tenants share the organization's WABA | +| Dedicated credentials | `whatsapp_business_account` with `waba_id` and `access_token` | The tenant owns its own WABA | + +Requesting inheritance when the organization has no WABA configured returns `422 VALIDATION_001`. Do not invent a hybrid, and do not describe the create payload as an Embedded Signup endpoint. + +## Completion flow and callback + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +Responses: `202` means processing started and contains no final status; `200` means the profile was already complete and the body carries a status. + +The callback body is `{profileId, success, status, timestamp}`, documented with `COMPLETED`, `SUBMITTED`, and `failed`. It is delivered **once, with no retry**, which drives three requirements: the receiver must be reachable before the completion call, the receiver must be idempotent on `profileId`, and a reconciliation job must poll `GET /v3/profiles/{profileId}` for profiles that have been awaiting completion beyond a timeout. + +Note that this callback is not part of the `/v3/webhooks` subscription system and is not documented as carrying the `x-webhook-signature` scheme. Give each provisioning record a unique, hard-to-guess callback path, treat its payload as untrusted input, verify `profileId` against the record you created, and never take action on an unrecognized identifier. Polling the profile remains the recovery and reconciliation authority. + +## Status vocabulary by surface + +| Surface | Observed values | +| --- | --- | +| Create response | lowercase `incomplete` | +| Completion `200` | lowercase `completed` | +| Completion callback | `COMPLETED`, `SUBMITTED`, `failed` | +| `GET /v3/profiles/{profileId}` guide | `approved`, `submitted`, `processing`, `failed` | +| REST guides versus OpenAPI | Publish different status sets | + +Handle this by comparing case-insensitively, preserving unknown strings verbatim, recording which surface produced the value, and never switch-casing over an assumed closed enum. A provisioning state machine should treat any unrecognized status as "needs human review" rather than as an error. + +## Error catalog for provisioning + +| Status | Code | Meaning | +| --- | --- | --- | +| 400 | `VALIDATION_001` | Invalid payload, including a `brand` object alongside `inherit_tcr_brand: true` | +| 400 | `VALIDATION_001` | Cannot create campaigns when `inherit_tcr_campaign` is true, or the campaigns are read-only | +| 403 | `AUTH_004` | Profile key attempted `x-profile-id`, or insufficient role | +| 404 | `RESOURCE_005` | Organization not found | +| 404 | `RESOURCE_014` | Profile not found | +| 404 | `RESOURCE_009` | Brand not found for the profile | +| 404 | `RESOURCE_010` | Campaign not found | +| 422 | `VALIDATION_001` | Organization has no WABA configured | + +The response envelope carries `error.code`, `error.message`, `error.details`, and `error.doc_url`, plus `meta.request_id`. Log `request_id` for every provisioning call; it is the correlation handle for support. + +## Idempotency and sandbox + +`Idempotency-Key` is honored on POST, PUT, and PATCH with a value of 1 to 255 characters from `[A-Za-z0-9_-]`. Successful responses are cached 24 hours per key per customer, replays return the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`, a duplicate arriving while the original is in flight waits up to five seconds and then fails `409 CONFLICT_001`, and if the idempotency store is unavailable the API returns `503 SERVICE_001` rather than risk a double execution. Use a deterministic key derived from your own provisioning record so a retry after a network timeout cannot create a second profile. + +`"sandbox": true` authenticates and validates without persisting, queueing, calling providers, deducting balance, or looking up resources. Use it to prove a payload shape in CI. A successful sandbox mutation is itself cached by idempotency, so use a distinct key for the later live mutation or the live call will replay the sandbox response. Sandbox does not protect deletions in the webhook API, so never rely on it as a general dry-run guard. + +## Offboarding + +Deprovisioning a tenant is an ordered, evidence-preserving sequence rather than a single delete: + +1. Stop new sends at the application layer. +2. Disable or delete the tenant's API keys in the dashboard. +3. Remove or downgrade the tenant's users, keeping at least one admin on the organization. +4. Disable webhook registrations that pointed at tenant infrastructure. +5. Detach shared resources deliberately, checking whether other profiles inherit from them. +6. Retain delivery and consent records for the applicable retention period before deleting the profile. +7. Record the `request_id` and timestamp of each step as the audit trail. + +Contact deletion dissociates the contact from the calling customer while shared contact, capability, and delivery records persist, so deletion is not an erasure mechanism. diff --git a/packages/sent/skills/sent-profile-provisioning/references/users-and-roles.md b/packages/sent/skills/sent-profile-provisioning/references/users-and-roles.md new file mode 100644 index 0000000..fe1722c --- /dev/null +++ b/packages/sent/skills/sent-profile-provisioning/references/users-and-roles.md @@ -0,0 +1,108 @@ +# Users, roles, and credential hygiene + +## Table of contents + +- [User operations](#user-operations) +- [Role model](#role-model) +- [How a role check is evaluated](#how-a-role-check-is-evaluated) +- [Invitation lifecycle](#invitation-lifecycle) +- [Guardrails on user mutations](#guardrails-on-user-mutations) +- [API key model](#api-key-model) +- [Key rotation](#key-rotation) +- [Least-privilege assignments](#least-privilege-assignments) +- [Audit evidence](#audit-evidence) + +## User operations + +| Operation | Required role | Body | Notes | +| --- | --- | --- | --- | +| `GET /v3/users` | `developer` or higher | — | Lists active, invited, and suspended users | +| `POST /v3/users` | `admin` | `email`, `name`, `role`, optional `sandbox` | `201` with status `invited`; `409 RESOURCE_007` if the user exists | +| `GET /v3/users/{userId}` | `developer` or higher | — | Single user detail | +| `PATCH /v3/users/{userId}` | `admin` | `role`, optional `sandbox` | Role change only | +| `DELETE /v3/users/{userId}` | `admin` | optional `sandbox` | Removes access | + +All five accept `x-profile-id` on an organization key to act on a child profile. None is exposed through MCP. + +The user object returns `id`, `email`, `name`, `role`, `status`, `invited_at`, `last_login_at`, `created_at`, and `updated_at`. + +The roles reference documentation states that the two read operations require "any role," while the OpenAPI specification requires `developer` or higher. Follow the specification and design for `developer` as the read floor; a `billing`-only user should not be assumed able to list users. + +## Role model + +| Role | Assignable by API | Scope of access | +| --- | --- | --- | +| `owner` | No | The account that created the organization or profile. Implicit, absent from the user list, passes every role check | +| `admin` | Yes | Passes every role check: profile management, user management, and reads | +| `developer` | Yes | Passes any-role checks; dashboard access to development, messaging, number lookup, activities, API keys, webhooks, channels, and settings | +| `billing` | Yes | Passes any-role checks; dashboard access limited to billing | + +Note the practical implication of `developer` including API keys and webhooks: a developer can create credentials and change event destinations. Treat the role as privileged even though it cannot manage users. + +## How a role check is evaluated + +The check resolves against the email address that owns the API key. It passes when that email is the owner email, or when an **active** user with that email exists on the organization or profile holding an allowed role. Users in `invited`, `suspended`, or `rejected` status fail every check. + +For a Sender Profile, checks cascade upward: owner or role access at the organization level also grants access to the organization's profiles. A user can therefore hold different effective roles across profiles when invited separately, while an organization-level role applies everywhere beneath it. + +Two operational consequences. First, an invitation that has not been accepted grants nothing, so provisioning automation must not assume access after `POST /v3/users` returns `201`. Second, revoking access at the organization level is the only way to remove cascading access; deleting a profile-level user leaves an organization-level grant intact. + +## Invitation lifecycle + +```text +POST /v3/users → status "invited" → email with token → user accepts → status "active" + └── 7 days elapse → token expires → re-invite required +``` + +Inviting an email that already has access returns `409 RESOURCE_007`; read the user list first and decide between a role change and an invitation. A provisioning flow that invites tenant staff should record the invitation timestamp and re-invite after expiry rather than retrying blindly, and should verify `status == "active"` before assuming the user can act. + +## Guardrails on user mutations + +The API refuses to let a caller change their own role, demote the last admin, remove themselves, or remove the last admin. Rather than discovering these as validation errors, check first: + +1. `GET /v3/users` and identify the target plus the count of active admins. +2. Confirm the target is not the caller's own account. +3. Confirm the change leaves at least one active admin. +4. Present the intended change and require explicit confirmation from the operator immediately before the call. +5. Record the `meta.request_id` from the response. + +Role changes and removals are effectively irreversible from the target user's perspective — re-granting requires a fresh invitation and acceptance — so treat both as destructive operations that deserve a stated diff before execution. + +## API key model + +There are two key types. An organization key can act for a child profile by sending `x-profile-id` with the profile UUID. A profile-scoped key is confined to its own profile and receives `403 AUTH_004` if it sends `x-profile-id`. + +Rate-limit exposure follows the key type: a profile key draws on its own pool, while an organization key acting through `x-profile-id` draws on the organization pool, so one noisy integration can consume quota shared by every profile. + +There is **no** endpoint to list, create, or revoke API keys. Key management happens in the Sent Dashboard, where the value is masked in the table and copied with a control. Any runbook that claims to automate key creation is wrong. + +Failed authentication is tracked per presented credential rather than per IP address: ten consecutive failures lock that credential with a `429` and escalating lockout windows from one minute up to sixty. A retry loop against a bad key therefore extends its own outage, so authentication failures must stop retrying immediately and alert instead. + +`x-sender-id` is legacy v1 and v2 terminology. It has no role in v3 authentication or routing. + +## Key rotation + +1. Create a replacement key in the dashboard. +2. Update the secret store and redeploy so the new key is in use. +3. Verify with `GET /v3/me` that the new key resolves to the expected account. +4. Disable or delete the old key. + +When a key is known to be compromised, invert the first steps and delete the old key immediately, accepting the brief outage. Keep separate keys per environment so rotating production never touches development, and never place a key in a browser, mobile app, or any client the organization does not control. + +## Least-privilege assignments + +| Workload | Credential | Role | +| --- | --- | --- | +| Server-side sends for one tenant | Profile-scoped key | `developer` | +| Provisioning new profiles | Organization key | `admin` | +| Campaign and brand registration | Organization or profile key per ownership | `admin` | +| Analytics reads | Profile-scoped key where possible | `developer` | +| Webhook management | Key matching the webhook's scope | `developer` | +| Billing review | — | `billing` | +| User administration | Organization key | `admin` | + +Prefer profile-scoped keys for runtime send paths so a leak is contained to one tenant, and reserve organization keys for control-plane operations that genuinely require cross-profile reach. + +## Audit evidence + +Every response carries `meta.request_id` and `meta.timestamp`. Log both alongside the operation name, the acting credential identifier, and — when an organization key acted through `x-profile-id` — the target profile, since the credential alone does not reveal which tenant was affected. The Sent Dashboard's Activities section, visible to owner, admin, and developer roles, is the platform-side counterpart. Because there is no API for key inventory, maintain a written register of which key exists for which environment and workload, who owns it, and when it was last rotated. diff --git a/packages/sent/skills/sent-routing-strategist/SKILL.md b/packages/sent/skills/sent-routing-strategist/SKILL.md new file mode 100644 index 0000000..e63159d --- /dev/null +++ b/packages/sent/skills/sent-routing-strategist/SKILL.md @@ -0,0 +1,76 @@ +--- +name: sent-routing-strategist +description: Decides how a Sent message should reach the recipient — automatic routing versus a pinned channel, what the channel array actually does, how fallback and reroute work, and why a message ended as FAILED, FILTERED, BLOCKED, or channel "auto". Use when choosing the channel field, expecting WhatsApp-to-SMS fallback, debugging an unexpected route or duplicate charges from multiple channels, or interpreting message status and activity evidence. +--- + +# Sent Routing Strategist + +Routing is where the most expensive Sent misconceptions live. Two facts govern almost every decision: + +1. **The `channel` array is a broadcast list, not a preference order.** `["whatsapp", "sms"]` with two recipients creates four messages and four charges. There is no `fallback` field and no ordered-preference syntax. +2. **Automatic routing is the fallback mechanism.** Omit `channel`, or send `["sent"]`, and the platform selects a route, then reroutes across up to three distinct channel-and-provider pairs when a route-level failure occurs. + +## Decide the channel value + +| Intent | Correct value | Reason | +| --- | --- | --- | +| Reach the recipient however works best | omit `channel` or `["sent"]` | Enables route selection and reroute | +| Guarantee one specific channel | `["sms"]`, `["whatsapp"]`, or `["rcs"]` | Pinning restricts matching to that channel and never crosses channels | +| Deliberately deliver the same content on several channels | `["whatsapp", "sms"]` | Broadcast; expect one message and one charge per pair | +| "Try RCS, fall back to SMS" | omit `channel` or `["sent"]` | An ordered array would broadcast; automatic routing performs the fallback | + +Any value outside `sent`, `sms`, `whatsapp`, and `rcs` returns `400`. When a user asks for ordered fallback, name the misconception explicitly before writing code, because the failure mode is duplicate delivery and duplicate cost rather than an error. + +## What a pinned channel gives up + +Pinning restricts route matching to the named channel. Rules without a channel constraint still match and resolve to the pinned channel, so pinning does not require channel-specific rules to exist. A pinned send never crosses to a different channel, though same-channel provider hops remain possible when a rule permits them. If no route exists on the pinned channel, the message ends `FAILED` with no route matched — it does not silently fall back. + +Pin when a compliance, contractual, or content constraint requires a specific channel. Otherwise prefer automatic routing. + +## Reading the outcome + +`POST /v3/messages` returns `202` with per-recipient `message_id` values. For automatic routing, the echoed per-recipient channel is not a resolved route and is never updated afterward. Resolve the truth from evidence: + +| Question | Evidence | +| --- | --- | +| Which route was actually attempted | `message.routed` event, or `channel` on `GET /v3/messages/{id}` after routing | +| Did the recipient's device receive it | `message.delivered` | +| What sequence of routes was tried | `GET /v3/messages/{id}/activities` | +| Why did it stop | Terminal status plus channel value | + +## Terminal status interpretation + +| Status | Meaning | Correct response | +| --- | --- | --- | +| `FAILED` | A route attempt failed; automatic routing may still enqueue another attempt | Inspect the latest message state and activities before treating it as final | +| `FILTERED` | Policy gate — consent block or route denial | Never retry; a consent block is a compliance stop | +| `BLOCKED` | Account precondition — balance, onboarding quota, unapproved template | Fix the account condition, then send again | +| `SCHEDULED` | Parked by quiet-hours policy | Wait; it re-enters the pipeline automatically | + +An outcome whose `channel` is `auto` means the message ended before any route was attempted. The causes are no matching route, invalid template parameters, a consent block, or an account precondition. Account preconditions do not reject the send request: it is accepted with `202` and the affected messages surface as `BLOCKED`. + +Sent records internal send-time reason codes on the message for these cases, but does not return them in API responses or webhooks, so diagnosis relies on the status-and-channel combination plus the activity history. The mapping from observable evidence to root cause is tabulated in [references/routing-diagnosis.md](references/routing-diagnosis.md). + +## Reroute behavior + +A failed route is retried only when the terminal failure signals a route or carrier problem another route might overcome: undeliverable by this route, provider service unavailable, provider timeout, or transport error. Every other failure stays `FAILED`. + +Reroute reuses the **same `message_id`** and re-runs the pipeline, so `message.queued` and `message.routed` fire again, consent gates re-apply on every attempt, and already-attempted routes are excluded. The ceiling is three distinct channel-and-provider pairs across the initial send and all reroutes. + +The WhatsApp-to-SMS behavior customers ask about is a specific case of this: a WhatsApp message accepted and then failed for a recipient-side reason reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, so subsequent automatic sends skip WhatsApp for that recipient. It requires automatic routing; a pinned WhatsApp send cannot produce it. + +## How automatic routing selects a route + +Routes come from platform-maintained rules evaluated at send time against recipient attributes (country, number prefix, exact number, carrier, number type, ported state), sender, template attributes, channel, and whether the destination is international. Ordering is: exact-recipient rules first, then account-scoped before global, then match specificity, then rule priority, then longer number prefix, then the older rule. Inactive, deleted, expired, and below-threshold rules are excluded. Candidates whose template has an explicit non-approved review status on that channel are dropped, while a channel with no recorded review is not blocked. The first surviving candidate wins and the rest remain available as fallback routes. + +There is no fixed channel preference order, so never promise "RCS first, then WhatsApp, then SMS." Read [references/routing-model.md](references/routing-model.md) before making any claim about why a specific route was chosen. + +## Cost and volume consequences + +Because broadcast multiplies messages by recipients, review any multi-channel array against expected spend before sending. A 1,000-recipient send with two channels is 2,000 messages. The per-request recipient ceiling is 1,000, and documented pacing pairs full batches with roughly one request per second to stay inside the 200-requests-per-minute budget. + +RCS today carries text plus up to four suggestion chips, mapped from template buttons, and every outbound RCS message receives an appended STOP chip. Do not design an RCS-pinned flow that depends on rich cards, carousels, or media. + +## Boundaries + +Use `sent-messaging` to execute a single send with confirmation, `sent-two-way-messaging` for consent and inbound keyword semantics, `messaging-performance-analyzer` for aggregate delivery-rate regressions, and `sent-webhook-engineer` for receiving and deduplicating the events this skill teaches you to read. diff --git a/packages/sent/skills/sent-routing-strategist/agents/openai.yaml b/packages/sent/skills/sent-routing-strategist/agents/openai.yaml new file mode 100644 index 0000000..d92c655 --- /dev/null +++ b/packages/sent/skills/sent-routing-strategist/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Routing Strategist" + short_description: "Choose channels and diagnose Sent routes" + default_prompt: "Use $sent-routing-strategist to decide how my Sent messages should reach recipients across channels." diff --git a/packages/sent/skills/sent-routing-strategist/references/routing-diagnosis.md b/packages/sent/skills/sent-routing-strategist/references/routing-diagnosis.md new file mode 100644 index 0000000..c5d3d06 --- /dev/null +++ b/packages/sent/skills/sent-routing-strategist/references/routing-diagnosis.md @@ -0,0 +1,92 @@ +# Routing diagnosis from observable evidence + +## Table of contents + +- [Evidence sources](#evidence-sources) +- [Symptom to cause table](#symptom-to-cause-table) +- [Outcome and channel matrix](#outcome-and-channel-matrix) +- [Diagnostic sequence](#diagnostic-sequence) +- [Retry decision rules](#retry-decision-rules) +- [Cost review before a multi-channel send](#cost-review-before-a-multi-channel-send) +- [Worked examples](#worked-examples) + +## Evidence sources + +| Source | What it proves | +| --- | --- | +| `202` response `data.recipients[]` | The message ids that were created; nothing about routing | +| `GET /v3/messages/{id}` | Current status and the attempted channel once routing occurred | +| `GET /v3/messages/{id}/activities` | The sequence of attempts, which is the only way to see multiple routes | +| `message.routed` event | The concrete route chosen for that attempt | +| Terminal event `channel` | The attempted route, or `auto` when the message ended before routing | + +Sent records internal reason codes on the message for consent blocks, route denials, no-route-matched, and invalid template parameters, but does not return them through the API or webhooks. Diagnosis therefore combines the latest outcome, the channel value, and the activity history rather than reading an error code. + +## Symptom to cause table + +| Symptom | Most likely cause | Confirmation | +| --- | --- | --- | +| Recipients received the same content twice | A multi-channel array was treated as a fallback list | Count messages in the `202` response: `len(to) × len(channel)` | +| Billing higher than expected on a campaign | Same as above | Compare charged messages against recipient count | +| Expected WhatsApp-to-SMS fallback, got only a WhatsApp failure | The send pinned `["whatsapp"]` | Pinned sends never cross channels; switch to automatic routing | +| `FAILED` with channel `auto` | No routing rule matched, or template parameters were invalid | Activities show no route attempt | +| `FILTERED` with a channel value | Route denial without permitted fallback, or every candidate denied | Activities show attempts ending in denial | +| `FILTERED` with channel `auto` | Consent block before routing | Check the contact's `opt_out` state | +| `BLOCKED` | Account precondition: balance, onboarding quota, or unapproved template | Check balance and template approval state | +| Message stuck in `SCHEDULED` | Quiet-hours policy parked it | It re-enters the pipeline automatically; do not resend | +| Duplicate `queued` and `routed` events for one id | A reroute re-ran the pipeline | Activities show more than one attempted route | +| Channel changed between two events for one message | Reroute moved to another route | Expected on automatic routing | +| `400` on send | A channel value outside `sent`, `sms`, `whatsapp`, `rcs` | Inspect the request `channel` array | +| Pinned RCS message failed immediately | No route exists on the pinned channel | A pinned send does not fall back | +| No `READ` event on SMS | `READ` exists only on WhatsApp and RCS | Expected, not a defect | + +## Outcome and channel matrix + +| Outcome | Channel `auto` | Channel is a concrete route | +| --- | --- | --- | +| `FAILED` | No route matched, or invalid template parameters | One route failed; inspect newer events and activities to determine whether reroute continued | +| `FILTERED` | Consent block before routing | Route denial that did not permit fallback, or all candidates denied | +| `BLOCKED` | Account precondition evaluated before routing | Rare; treat as an account precondition regardless | +| `DELIVERED` | Not possible | Normal success | + +## Diagnostic sequence + +1. Confirm what was requested. Re-read the send body: was `channel` omitted, `["sent"]`, pinned, or multi-valued? This alone resolves most reported "fallback did not work" and "duplicate message" cases. +2. Count expected messages as `len(to) × len(channel)` and compare with the `202` response. +3. Fetch `GET /v3/messages/{id}` for a representative message and record status and channel. +4. Fetch `GET /v3/messages/{id}/activities` and list the attempted routes in order. +5. Classify the terminal state using the matrix above. +6. Decide retry eligibility using the rules below, and state the reason rather than retrying reflexively. + +## Retry decision rules + +| Terminal state | Retry | Precondition | +| --- | --- | --- | +| `FAILED` after route exhaustion on automatic routing | Only with a changed input | Three distinct routes were already tried; a new send repeats the same rules unless the recipient, template, or channel choice changes | +| `FAILED` with channel `auto` from no route matched | No | The rule set has no path to that recipient; escalate rather than loop | +| `FAILED` from invalid template parameters | Yes | After fixing the parameters | +| `FILTERED` from a consent block | Never | Sending anyway is a compliance violation | +| `FILTERED` from a route denial | No | Policy decision; escalate | +| `BLOCKED` | Yes | After the account condition is resolved | +| `SCHEDULED` | No | It resumes automatically; a resend duplicates it | +| Ambiguous send where the client never saw a response | Retry only with the original key | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient that proves non-execution | + +## Cost review before a multi-channel send + +Before executing any send whose `channel` array has more than one value, state the arithmetic to the user: recipients times channels equals messages equals charges. Confirm the intent is genuinely simultaneous multi-channel delivery. If the intent is preference or fallback, change the request to automatic routing instead. + +For volume, the per-request recipient ceiling is 1,000. Documented pacing pairs full 1,000-recipient batches with roughly one request per second to stay inside the 200-requests-per-minute limit, and rate-limit headers appear only on `429` responses, so pacing must be designed rather than discovered. + +## Worked examples + +**"We wanted WhatsApp with SMS fallback and every customer got two messages."** +The request used `["whatsapp", "sms"]`, which broadcasts. The `202` response contained two message ids per recipient, both of which were charged. The correct request omits `channel` entirely. Automatic routing then attempts a route and, on a route-level or recipient-side WhatsApp failure, reroutes to another candidate on the same `message_id`. + +**"A message failed on WhatsApp and then delivered on SMS, but our dashboard shows it as failed."** +Automatic routing rerouted on the same `message_id`, so the receiver saw `message.failed` with `channel: whatsapp` followed by newer `queued`, `routed`, and `delivered` events with `channel: sms`. The dashboard treated `FAILED` as permanently terminal. Project current state by event timestamp, preserve the append-only route history, and let a newer reroute outcome replace the earlier attempt failure. + +**"All sends to one country come back FAILED with channel auto."** +No routing rule matched for that destination. Activities show no attempted route. This is not fixable from the request payload; escalate the destination coverage rather than retrying. + +**"A pinned RCS send failed instantly and never tried SMS."** +Correct behavior. Pinning restricts matching to RCS, and a pinned send never crosses channels. Use automatic routing to allow another channel. diff --git a/packages/sent/skills/sent-routing-strategist/references/routing-model.md b/packages/sent/skills/sent-routing-strategist/references/routing-model.md new file mode 100644 index 0000000..df8eabd --- /dev/null +++ b/packages/sent/skills/sent-routing-strategist/references/routing-model.md @@ -0,0 +1,136 @@ +# Sent channel routing model + +## Table of contents + +- [Channel vocabulary](#channel-vocabulary) +- [Broadcast semantics](#broadcast-semantics) +- [Route selection pipeline](#route-selection-pipeline) +- [Pinned-channel behavior](#pinned-channel-behavior) +- [Send-time fallback across candidates](#send-time-fallback-across-candidates) +- [Reroute after delivery failure](#reroute-after-delivery-failure) +- [Where each channel value surfaces](#where-each-channel-value-surfaces) +- [Status lifecycle](#status-lifecycle) +- [Channel capability limits](#channel-capability-limits) + +## Channel vocabulary + +| Value | Meaning | +| --- | --- | +| `sent` | Automatic routing. The default when `channel` is omitted or supplied as an empty array | +| `sms` | Pin to SMS | +| `whatsapp` | Pin to WhatsApp | +| `rcs` | Pin to RCS | +| `auto` | Internal placeholder for an unresolved automatic route. Appears in responses and events, never as an input | + +Any other value returns `400`. Treat `auto` as read-only diagnostic output and never display it to an end user as a channel name. + +## Broadcast semantics + +The `channel` array enumerates channels to send on, not an order of preference. The number of messages created is `len(to) × len(channel)`, each with its own `message_id`, its own lifecycle, and its own charge. + +```json +{ + "to": ["+14155551234", "+14155555678"], + "channel": ["whatsapp", "sms"], + "template": { "name": "order_confirmation", "parameters": { "order_id": "12345" } } +} +``` + +That request creates four messages. The `202` response enumerates all four in `data.recipients[]`. + +There is no `fallback` field, no priority weighting, and no way to express "try this, then that" through the array. Ordered arrays or explicit failover objects are common on incumbent platforms, which makes porting them into Sent's channel array a high-risk migration defect. + +## Route selection pipeline + +For automatic routing, the platform evaluates its maintained routing rules at send time. Rules can constrain on: + +- recipient attributes: country, number prefix, exact number, carrier, number type, and ported state; +- the sender; +- template attributes: id, name, and category; +- channel; +- whether the destination is international. + +Candidates are ordered by: + +1. exact-recipient rules ahead of everything else; +2. account-scoped rules ahead of global rules; +3. match specificity; +4. explicit rule priority; +5. longer number prefix; +6. older rule as the final tie-break. + +Exclusions applied before selection: inactive, deleted, or expired rules; rules whose own minimum match threshold is not met; and candidates on a channel where the template carries an explicit non-approved review status such as rejected, pending, or paused. A channel with **no** recorded per-channel review is not excluded — absence of review is not disapproval. + +The first surviving candidate becomes the attempted route, the message moves to `ROUTED`, and `message.routed` fires. Remaining candidates stay available as fallback routes for that message. + +Two consequences worth stating to users. There is no fixed cross-channel preference order, so any claim like "RCS is tried first" is unsupported. And because rules are platform-maintained rather than caller-supplied, an application cannot express routing preference through the API; it can only choose automatic routing or pin a channel. + +## Pinned-channel behavior + +Pinning narrows candidate matching to the named channel. Rules that carry no channel constraint still match and resolve to the pinned channel, so pinning works even without channel-specific rules. + +A pinned message never crosses to a different channel. Same-channel provider hops remain possible when the matched rule permits them, which means a pinned SMS message can still be retried through a different provider. If no route matches on the pinned channel, the message ends `FAILED` with no route matched rather than falling back. + +Pin for a hard requirement: a compliance rule that mandates a channel, a contract that prices a channel, or content that only renders on one channel. Prefer automatic routing everywhere else. + +## Send-time fallback across candidates + +Fallback at send time walks the candidate list rather than the caller's array. When a candidate route carries a DENY decision that permits fallback, evaluation moves to the next candidate. When a DENY does not permit fallback — including the case where every candidate is denied — the message finalizes as `FILTERED` and the record carries the denied route's channel. + +This is why `FILTERED` must never be retried blindly. The gate is a policy decision, most often consent, and repeating the send reproduces the same outcome while risking a compliance violation. + +## Reroute after delivery failure + +Reroute happens only when a terminal failure indicates a route or carrier problem that another route might overcome: + +| Failure signal | Reroutes | +| --- | --- | +| Undeliverable by this route | Yes | +| Provider service unavailable | Yes | +| Provider timeout | Yes | +| Transport error | Yes | +| Recipient-side rejection on WhatsApp after acceptance | Yes, and records a recipient-scoped rule that WhatsApp is not deliverable for that number | +| Invalid content or template parameters | No | +| Consent block | No | +| Account precondition | No | +| Any other failure | No | + +Mechanics that affect application code: the reroute reuses the same `message_id`, re-runs the pipeline so `QUEUED` and `ROUTED` transitions and their webhooks fire again, excludes already-attempted routes, re-applies consent gates on every attempt, and stops at a ceiling of three distinct channel-and-provider pairs across the initial send and all reroutes. + +Therefore a `message.failed` event is not proof of final failure. Treat a message as finally failed only when its stored state remains failed and no further events arrive, and make the status projection idempotent so repeated `queued` and `routed` events do not double-count. + +## Where each channel value surfaces + +| Surface | Automatic routing | Pinned channel | +| --- | --- | --- | +| `202` response `data.recipients[].channel` | Not a resolved route, and never updated later | The pinned channel | +| `message.queued`, `message.routed`, `message.scheduled` | `sent` | The pinned channel | +| Terminal events after a route was attempted | The attempted route's channel | The pinned channel | +| Terminal events for a message that ended before routing | `auto` | `auto` | +| `GET /v3/messages/{id}` before routing | `auto` | The pinned channel | +| `GET /v3/messages/{id}` after routing | The attempted route's channel | The pinned channel | + +The practical rule: never treat the send response as routing evidence. Resolve routes from `message.routed`, from `GET /v3/messages/{id}` after routing, or from the activity history. + +## Status lifecycle + +| Status | Final for the logical message | Meaning | +| --- | --- | --- | +| `QUEUED` | No | Accepted into the pipeline | +| `SCHEDULED` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `ROUTED` | No | A channel and provider were selected | +| `SENT` | No | Handed to the provider | +| `DELIVERED` | Yes | Confirmed at the handset | +| `READ` | Yes | Read by the recipient; WhatsApp and RCS only | +| `FAILED` | Not always | One attempt failed; a newer automatic reroute may follow on the same message id | +| `FILTERED` | Yes | Policy gate: consent block or route denial | +| `BLOCKED` | Yes | Account precondition: balance, onboarding quota, unapproved template | +| `RECEIVED` | — | Inbound message | + +`FILTERED` and `BLOCKED` are not carrier failures. Feeding them into retry logic produces either a compliance problem or a retry loop that cannot succeed until an account action is taken. + +## Channel capability limits + +RCS currently supports text plus up to four suggestion chips, mapped from template buttons, with rich cards, carousels, and media on the roadmap. Every outbound RCS message receives an appended STOP chip, so an RCS surface always exposes an opt-out affordance the application did not author. + +`READ` reaches only WhatsApp and RCS; its absence on SMS is expected. Inbound support differs by channel as well — SMS inbound depends on an MO-capable provider and a supported number type, so alphanumeric sender IDs never receive replies. Route consent and inbound questions to the two-way messaging skill. diff --git a/packages/sent/skills/sent-two-way-messaging/SKILL.md b/packages/sent/skills/sent-two-way-messaging/SKILL.md new file mode 100644 index 0000000..ce9f32c --- /dev/null +++ b/packages/sent/skills/sent-two-way-messaging/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sent-two-way-messaging +description: Designs inbound and conversational Sent flows — opt-out and opt-in keyword handling, consent state on contacts, auto-replies inside the WhatsApp 24-hour window, RCS STOP chips, conversation history retrieval, and per-channel inbound capability. Use when handling message.received events, implementing STOP or HELP behavior, restoring consent after an opt-out, building a support inbox or chatbot on Sent, or paginating conversation history. +--- + +# Sent Two-Way Messaging + +Inbound messaging on Sent has one governing rule: **consent is enforced by the platform before the application sees the event.** An inbound `STOP` has already flipped the contact's `opt_out` flag by the time `message.received` arrives. The application's job is to record it, reflect it in its own UI, and never attempt to send around it. + +## Keyword handling + +Ten keywords ship as defaults: + +| Action | Keywords | +| --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | +| Help auto-reply | `HELP`, `INFO` | + +Matching requires the **entire trimmed message body** to equal a keyword, case-insensitively. "Please stop messaging me" does not match; "stop" does. Custom keywords are configured in the Sent Dashboard under Compliance, Opt Keywords, with an action of Opt Out, Opt In, or Help, and each must be a single exact token. + +Do not claim keywords that are not in the documented set. In application code, mirror the same exact-match rule only to update local subscriber state and audit evidence; never use that matcher to apply consent to Sent a second time. Keep custom dashboard keywords synchronized with the local mirror, and reconcile against the contact's `opt_out` field when uncertain. + +## Consent state + +An opt-out sets `opt_out` on the contact record. Consent is **contact-level and channel-agnostic**: a `STOP` sent over SMS suppresses WhatsApp and RCS for that contact as well. Consent gates re-apply on every reroute attempt, not only at initial send. + +Restoring consent requires the recipient's own action. A user-initiated opt-in keyword clears suppression. `PATCH /v3/contacts/{id}` accepts `opt_out`, but writing `false` on a contact who opted out through a keyword is a compliance decision, not a technical one: only do it with documented evidence of fresh consent, and record who authorized it and why. + +Downstream, a suppressed send does not fail with an error. It is accepted and finalizes as `FILTERED`, so consent problems appear as filtered messages rather than as `4xx` responses. Details are in [references/consent-and-keywords.md](references/consent-and-keywords.md). + +## Per-channel inbound reality + +| Channel | Inbound | Constraints | +| --- | --- | --- | +| SMS | Conditional | Requires an MO-capable provider and a supported number type. Alphanumeric sender IDs and SMPP paths without an inbound route never deliver inbound messages | +| RCS | Full | Typed replies match keywords; the appended STOP chip is processed directly by the consent engine | +| WhatsApp | Full | Free-form replies only inside the 24-hour customer service window; outside it, an approved template is required | + +The SMS caveat matters before promising two-way behavior: a deployment sending from an alphanumeric sender ID cannot receive `STOP` at all, which changes the compliance design rather than merely limiting a feature. + +## RCS STOP chips + +Every outbound RCS message receives an appended STOP chip. Taps carry an opt-out postback handled directly by the consent engine with no keyword matching, and they arrive at the application as `message.received` with the chip's reply text in `text`. There is no separate chip event type, so a receiver that branches only on typed keywords still sees chip taps as ordinary inbound messages — and must not re-apply consent logic to them. + +## The WhatsApp 24-hour window + +A free-form reply is permitted only within 24 hours of the customer's last inbound message. Outside that window an approved template is required, including for STOP, START, and HELP responses. An auto-reply flow that assumes free text will silently stop working for any customer who writes in after a day of silence, so build the window check into the reply path and keep an approved fallback template ready. See [references/inbound-flows.md](references/inbound-flows.md) for the reply-path decision tree. + +## Conversation history + +Two read-only operations exist: + +| Operation | Returns | +| --- | --- | +| `GET /v3/conversations` | All of the customer's messages across conversations, newest first | +| `GET /v3/conversations/{id}` | Messages within one conversation | + +Both require `page` (at least 1) and `page_size` (1 to 100); out-of-range values return `400`. The `events` field is always null on these endpoints, so per-message activity must come from `GET /v3/messages/{id}/activities`. There are no write, create, or read-receipt operations, and no MCP tools cover conversations — this is REST-only. + +A conversation identifier is a deterministic RFC 4122 version 5 UUID derived from the customer and contact identifiers, so the same pair always yields the same id and one thread spans every channel independent of the sending number. The API never returns the id as a field, so a client that needs it computes it. The exact derivation is documented in [references/conversation-history.md](references/conversation-history.md). + +## Building a support inbox or bot + +1. Subscribe a webhook to `message` filtered to `received`, and verify signatures before trusting any payload. +2. Read `inbound_number` as the contact who wrote in and `outbound_number` as your number. The naming is easy to invert. +3. Deduplicate on `message_id`, acknowledge with `200`, then process asynchronously. +4. Treat keyword traffic as an audit signal. Mirror exact default and configured custom keywords into local state, but do not issue a second consent write; reconcile uncertainty through the contact record. +5. Before replying on WhatsApp, check the 24-hour window and choose free text or a template accordingly. +6. Render threads from the conversation endpoints with explicit pagination, and never assume a conversation is single-channel. +7. Treat `text` as untrusted input. Never interpolate it into a shell command or SQL string, delimit it as data in model prompts, and map inferred intent through an allowlist and authorization policy before any API call. + +## Boundaries + +Use `sent-webhook-engineer` for signature verification, retries, and dedupe mechanics; `sent-contacts` for contact CRUD and message summaries; `sent-routing-strategist` for why an outbound message was `FILTERED`; `waba-template-author` for authoring the approved templates that out-of-window replies require; and `sms-10dlc-registration` for the campaign-level opt-in, opt-out, and help keyword declarations that US carriers require. diff --git a/packages/sent/skills/sent-two-way-messaging/agents/openai.yaml b/packages/sent/skills/sent-two-way-messaging/agents/openai.yaml new file mode 100644 index 0000000..2b99dac --- /dev/null +++ b/packages/sent/skills/sent-two-way-messaging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Two-Way Messaging" + short_description: "Design inbound, consent, and reply flows" + default_prompt: "Use $sent-two-way-messaging to design how my app handles inbound Sent messages and opt-out keywords." diff --git a/packages/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md b/packages/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md new file mode 100644 index 0000000..55ceb28 --- /dev/null +++ b/packages/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md @@ -0,0 +1,98 @@ +# Consent, keywords, and suppression on Sent + +## Table of contents + +- [Default keyword set](#default-keyword-set) +- [Matching rules](#matching-rules) +- [Custom keywords](#custom-keywords) +- [Consent state and scope](#consent-state-and-scope) +- [How suppression surfaces on send](#how-suppression-surfaces-on-send) +- [Restoring consent](#restoring-consent) +- [Channel-specific consent mechanics](#channel-specific-consent-mechanics) +- [Application responsibilities](#application-responsibilities) +- [Audit expectations](#audit-expectations) + +## Default keyword set + +Ten keywords are seeded by default. + +| Action | Keywords | Effect | +| --- | --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | Sets `opt_out` on the contact | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | Clears suppression | +| Help | `HELP`, `INFO` | Triggers the help auto-reply | + +Only these ten are documented defaults. Terms that appear in other platforms' keyword lists should not be presented as Sent defaults; if a specific extra term is required, add it as a custom keyword and verify it in the dashboard. + +## Matching rules + +Matching runs against the seeded defaults plus any custom keywords, on every inbound message received on a two-way capable channel. The rules are strict: + +- the **entire trimmed body** must equal the keyword; +- comparison is case-insensitive; +- partial phrases and keywords embedded in a sentence never match. + +So `stop`, `STOP`, and ` Stop ` all opt the contact out, while `please stop texting me` does not. This is deliberate: loose matching would opt out customers who used the word incidentally. It also means a real-world opt-out intent expressed in a sentence will not be caught automatically, which is a reason to route inbound text to a human queue rather than assuming keyword coverage is complete. + +Mirror this exact matcher in the application when local subscriber state or audit evidence is required. The platform has already applied consent by the time the event arrives, so the local matcher must never issue a second consent write. Keep configured custom keywords synchronized and reconcile uncertain state from the contact's `opt_out` field. + +## Custom keywords + +Custom keywords are configured in the Sent Dashboard under Compliance, then Opt Keywords. Each entry names a single exact token and one action: Opt Out, Opt In, or Help. There is no REST or MCP surface for keyword management, so keyword changes are a dashboard operation that cannot be scripted; treat the configured set as an environment fact to be read, documented, and version-controlled in the application's own runbook. + +When designing custom keywords, prefer short single tokens in the languages the audience actually writes in, and avoid tokens that collide with normal replies such as `YES` or `NO` if those are used for other flows. + +## Consent state and scope + +An opt-out flips `opt_out` on the contact record. Two properties of that state are load-bearing: + +**Contact-level.** Consent attaches to the contact, not to a campaign, template, or sending number. There is no per-template or per-campaign suppression list. + +**Channel-agnostic.** A keyword received on any channel suppresses every channel. A customer who texts `STOP` over SMS will not receive WhatsApp or RCS messages either. Applications that model consent per channel will over-send relative to the platform and see the difference as filtered messages. + +Consent gates re-apply on **every** reroute attempt, not only at the initial send. A message that passed the gate at send time is still re-checked when automatic routing retries it on another route. + +## How suppression surfaces on send + +A send to a suppressed contact is not rejected with a `4xx`. The request is accepted, and the affected message finalizes as `FILTERED` with a terminal channel value of `auto` when the block occurred before routing. Consequences: + +- Consent problems appear in delivery data rather than in API error handling, so a client that only inspects HTTP status codes will not notice them. +- `FILTERED` must never be retried. Retrying a consent block is a compliance violation, and it cannot succeed. +- Consent-driven filtering should be monitored as its own metric. A rising filtered rate usually means a stale local suppression list rather than a delivery problem. + +## Restoring consent + +Consent restoration is the recipient's decision. The clean path is a user-initiated opt-in keyword, which clears suppression through the same engine that set it. + +`PATCH /v3/contacts/{id}` accepts `opt_out` as a writable field, so it is technically possible to clear the flag from the API. Treat that as a compliance action rather than a data fix: + +1. Require documented evidence of fresh consent — a form submission, a recorded confirmation, or a written request. +2. Record who authorized the change, when, and against which evidence. +3. Never bulk-clear `opt_out` across a contact list, and never clear it to "fix" a filtered-message metric. +4. Prefer asking the customer to text an opt-in keyword, which produces platform-side evidence. + +An agent asked to clear `opt_out` should surface the compliance implication and require explicit confirmation naming the consent evidence before proceeding. + +## Channel-specific consent mechanics + +| Channel | Inbound keyword path | Notes | +| --- | --- | --- | +| SMS | Requires an MO-capable provider and a supported number type | Alphanumeric sender IDs and SMPP paths without an inbound route never deliver `STOP`; plan compliance around that limitation | +| RCS | Typed replies match keywords; the appended STOP chip bypasses matching | Every outbound RCS message carries a STOP chip whose tap is handled directly by the consent engine | +| WhatsApp | Full inbound support | Replies to STOP, START, or HELP outside the 24-hour window require an approved template | + +The RCS STOP chip means an RCS deployment always exposes an opt-out affordance the application did not author, and its taps arrive as ordinary `message.received` events with the chip's reply text in `text`. There is no distinct event type for a chip tap. + +## Application responsibilities + +The platform owns enforcement; the application owns reflection and evidence. + +- Mirror `opt_out` into local state by exact-matching the documented and configured keyword set, and reconcile from `GET /v3/contacts/{id}` when uncertain. +- Never use the local text match to re-apply consent to Sent; it is a mirror and audit mechanism only. +- Show suppression state in any internal UI where staff could otherwise trigger a send. +- Keep the local mirror reconciled on a schedule, since a keyword can arrive at any time and a stale mirror produces filtered messages. +- For US SMS, keep the campaign-level opt-in, opt-out, and help keyword declarations consistent with what is actually configured; carrier registration expects them to match. + +## Audit expectations + +Retain, per consent change: the inbound `message_id`, the received timestamp, the channel, the exact matched text, and the resulting state. Inbound keyword events are the strongest evidence available that a customer opted out or back in, and they are the artifact a carrier or regulator will ask for. Because Sent applies consent before the event is delivered, the event is a record of a completed action, and treating it as a request to perform an action risks double-processing. diff --git a/packages/sent/skills/sent-two-way-messaging/references/conversation-history.md b/packages/sent/skills/sent-two-way-messaging/references/conversation-history.md new file mode 100644 index 0000000..f32649f --- /dev/null +++ b/packages/sent/skills/sent-two-way-messaging/references/conversation-history.md @@ -0,0 +1,55 @@ +# Conversation history retrieval + +## Operations + +| Operation | Returns | Required query parameters | +| --- | --- | --- | +| `GET /v3/conversations` | All of the calling customer's messages across conversations, newest first | `page` (>= 1), `page_size` (1–100) | +| `GET /v3/conversations/{id}` | Messages within one conversation | `page` (>= 1), `page_size` (1–100) | + +Both are read-only. There is no create, update, delete, participant, or read-receipt operation, and no MCP tool covers conversations, so this is REST-only work even inside an MCP-authorized session. Out-of-range pagination values return `400`; missing credentials return `401`. + +The OpenAPI summaries for these two operations are swapped relative to their descriptions — the list endpoint is summarized as "list conversation messages" while its description says messages across all conversations, and the single-conversation variant reads the opposite way. Trust the descriptions and the behavior above. + +## Message record fields + +Each returned message carries: + +`id`, `customer_id`, `contact_id`, `phone`, `phone_international`, `region_code`, `template_id`, `template_name`, `template_category`, `channel`, `message_body` (with `header`, `content`, `footer`, and `buttons`), `status`, `direction` (`INBOUND` or `OUTBOUND`), `created_at`, `price`, `active_contact_price`, and `events`. + +`events` is always null on these endpoints. Per-message activity history must come from `GET /v3/messages/{id}/activities`, which is also the only place a reroute's sequence of attempted routes is visible. + +## Conversation identifiers + +A conversation id is a deterministic RFC 4122 version 5 UUID. It is derived from the namespace `9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99` and the name `{customer_id}:{contact_id}`, where both identifiers are lowercase canonical UUIDs and the customer id comes first. Version 5 uses SHA-1 name-based hashing, equivalent to PostgreSQL's `uuid_generate_v5`. + +```python +import uuid + +NAMESPACE = uuid.UUID("9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99") + + +def conversation_id(customer_id: str, contact_id: str) -> str: + name = f"{customer_id.lower()}:{contact_id.lower()}" + return str(uuid.uuid5(NAMESPACE, name)) +``` + +Three properties follow. The same customer-and-contact pair always yields the same id, so it can be computed offline and used as a stable local key. The API never returns the id as a field, so a client that needs it must derive it. And because the id depends only on customer and contact, **one thread spans every channel** and is independent of which sending number or channel was used — a customer who moves between SMS and WhatsApp stays in one conversation. + +## Pagination strategy + +Always pass `page` and `page_size` explicitly; there is no usable default. Results are newest-first, so page 1 is the most recent slice and a thread view should either reverse each page for display or fetch from the oldest page. + +Because the collection grows while it is being read, a long backfill can shift items across page boundaries. For an initial sync, page through quickly with `page_size: 100` and reconcile by message `id`, then switch to incremental updates driven by `message.received` and status webhooks rather than repeated full scans. Pacing matters as well: the standard limit is 200 requests per minute and quota headers appear only on `429` responses, so a paginated backfill must be throttled by design. + +## Choosing between conversations and messages endpoints + +| Need | Use | +| --- | --- | +| Render a customer thread | `GET /v3/conversations/{id}` with derived id | +| Show a recent-activity feed across all customers | `GET /v3/conversations` | +| Current status of one specific message | `GET /v3/messages/{id}` | +| Route attempts and reroute history | `GET /v3/messages/{id}/activities` | +| React to new inbound traffic in real time | `message.received` webhook | + +Do not poll the conversation endpoints for near-real-time inbound handling. Webhooks are the delivery mechanism for new inbound messages, and polling both wastes quota and adds latency. diff --git a/packages/sent/skills/sent-two-way-messaging/references/inbound-flows.md b/packages/sent/skills/sent-two-way-messaging/references/inbound-flows.md new file mode 100644 index 0000000..ad0b249 --- /dev/null +++ b/packages/sent/skills/sent-two-way-messaging/references/inbound-flows.md @@ -0,0 +1,112 @@ +# Inbound flow design + +## Table of contents + +- [Inbound event shape](#inbound-event-shape) +- [Reply-path decision tree](#reply-path-decision-tree) +- [The WhatsApp 24-hour window](#the-whatsapp-24-hour-window) +- [Support inbox architecture](#support-inbox-architecture) +- [Bot and auto-reply design](#bot-and-auto-reply-design) +- [Treating inbound content as untrusted](#treating-inbound-content-as-untrusted) +- [Testing an inbound flow](#testing-an-inbound-flow) + +## Inbound event shape + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "Where is my order?", + "channel": "whatsapp", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in; `outbound_number` is your number that received it. Inverting these is a common defect that sends replies to your own number. `text` may be null for non-text payloads. The application's tenant identifier is never present, so map ownership from `outbound_number` to the tenant and profile that own it. + +Dedupe on `message_id`. Inbound events, like all Sent webhook deliveries, may be retried. + +## Reply-path decision tree + +```text +inbound message.received +├── Is text an exact keyword match handled by the platform? +│ ├── Opt out / opt in → consent already applied. Record it. Do NOT reply with your own confirmation +│ │ unless product requirements demand one, and never re-set consent yourself. +│ └── Help → the platform sends the help auto-reply. Optionally log it. +└── Anything else + ├── channel == whatsapp + │ ├── within 24h of the contact's last inbound → free-form reply permitted + │ └── outside 24h → an approved template is required + ├── channel == rcs → reply permitted; a STOP chip is appended automatically + └── channel == sms → reply permitted if the number supports outbound on that route +``` + +The branch that breaks in production is the WhatsApp window: a flow tested minutes after an inbound message succeeds with free text and then fails for a customer who replies a day later. + +## The WhatsApp 24-hour window + +Free-form replies are permitted only inside 24 hours of the customer's last inbound message. Outside it, sending requires an approved template — including replies about STOP, START, and HELP. Practical requirements: + +1. Persist the last inbound timestamp per contact, sourced from `received_at`. +2. Evaluate the window at reply time, not at enqueue time; a queued reply can age past the boundary before it is sent. +3. Keep an approved fallback template for each conversational intent that could be answered late. +4. When a human agent replies from an internal inbox, show the remaining window in the UI so the agent understands why the composer switches to templates. + +Treat window expiry as an expected state rather than an error path. + +## Support inbox architecture + +A workable design separates four responsibilities: + +| Component | Responsibility | +| --- | --- | +| Webhook receiver | Verify the signature, dedupe on `message_id`, return `200` fast | +| Event worker | Resolve the contact and tenant, persist the message, apply routing to a queue | +| Thread view | Render history from the conversation endpoints with explicit pagination | +| Reply service | Enforce the window rule, choose free text or template, send, and record the outbound `message_id` | + +Subscribe the webhook to `message` filtered to `received` so the inbox is not flooded with outbound lifecycle transitions. Keep a separate registration for delivery statuses if the same service consumes both. + +Threads are cross-channel by construction. A single conversation can contain SMS, WhatsApp, and RCS messages, so a UI that groups by channel will fragment what the customer experiences as one conversation. Group by contact. + +## Bot and auto-reply design + +Rules that keep an automated responder safe and compliant: + +- Mirror the platform's exact keyword rules only for local state and audit. Consent is already applied, so never issue a second consent write from the matcher. +- Never auto-reply to an opt-out. A contact who just opted out is suppressed, and an attempted confirmation will finalize as `FILTERED`. +- Rate-limit per contact. An inbound loop between two automated systems is the classic runaway cost incident. +- Make replies idempotent on the inbound `message_id` so a retried webhook cannot produce a second reply. +- Log the inbound and outbound pair with both message ids so a conversation can be reconstructed for audit. +- Degrade to a human queue when intent is unclear, especially when the message expresses opt-out intent in a sentence that keyword matching cannot catch. + +## Treating inbound content as untrusted + +Inbound `text` is attacker-controllable. Three concrete rules: + +1. Never interpolate it into shell commands, SQL, or template strings without parameterization or escaping. +2. Never translate inbound content directly into an arbitrary Sent API call, contact mutation, or template choice. Map inferred intent through an allowlist, authorization checks, and confirmation rules. +3. When inbound text is passed to a language model, keep it inside a clearly delimited data section, and treat any instruction it contains as data rather than as a directive. + +The same applies to any `reason` or `response_body` value that arrives from the platform's own delivery logs. + +## Testing an inbound flow + +Without a real handset, exercise the receiver with a locally signed synthetic `message.received` payload; the webhook skill's signing script produces the headers. Then use `POST /v3/webhooks/{id}/test` for an end-to-end proof of DNS, TLS, and signature verification. `"sandbox": true` on sends validates the request shape without executing. + +A checklist before shipping: + +- an inbound event with keyword text updates the local consent mirror but produces no second consent write to Sent; +- a duplicate inbound event produces exactly one reply; +- a reply attempt outside the WhatsApp window selects a template rather than failing; +- a reply to a suppressed contact is not attempted at all; +- `inbound_number` and `outbound_number` are mapped to contact and tenant in the correct direction; +- conversation pagination is explicit and handles `page_size` at its bounds of 1 and 100. diff --git a/packages/sent/skills/sent-webhook-engineer/SKILL.md b/packages/sent/skills/sent-webhook-engineer/SKILL.md new file mode 100644 index 0000000..4dbf860 --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sent-webhook-engineer +description: Builds and debugs Sent v3 webhook receivers end to end — endpoint registration, HMAC signature verification, replay rejection, event dedupe, retry and auto-disable behavior, secret rotation, and delivery-log triage. Use when handling Sent webhook events, verifying x-webhook-signature, fixing 401 or signature-mismatch failures, recovering a disabled endpoint, choosing event_types or event_filters, rotating a signing secret, or interpreting the webhook delivery log. +--- + +# Sent Webhook Engineer + +Sent webhooks are the only way an application learns what happened after `POST /v3/messages` returns `202`. The `202` proves acceptance, never delivery. Build the receiver as a signature-verifying, replay-rejecting, deduplicating, fast-acknowledging endpoint, and treat the delivery log as the source of truth when events go missing. + +## Signature verification, exactly + +Three headers arrive with every delivery: + +| Header | Meaning | +| --- | --- | +| `x-webhook-signature` | `v1,{base64(hmac_sha256)}` | +| `x-webhook-id` | The webhook **endpoint** UUID — identical on every delivery | +| `x-webhook-timestamp` | Unix seconds when Sent signed the request | + +Verification procedure, in order: + +1. Capture the **raw request body bytes** before any JSON parsing. +2. Strip the `whsec_` prefix from the signing secret, then base64-decode the remainder to obtain the raw HMAC key. +3. Build the signed content as `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`. +4. Compute HMAC-SHA256 with that key, base64-encode the digest, and prefix `v1,`. +5. Compare with a constant-time comparison. +6. Reject when `abs(now - timestamp) > 300` seconds. + +The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. + +**`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. + +## Failure triage order + +When a receiver rejects or misses events, work this sequence rather than guessing: + +1. **Signature mismatch** — a body-mutating middleware or framework JSON parser is the cause in the majority of cases. Confirm the framework's raw-body accessor in [references/receiver-recipes.md](references/receiver-recipes.md). +2. **Replay rejection** — server clock skew beyond the 300-second tolerance. +3. **Wrong secret** — the `whsec_` prefix was left in place, or a rotation invalidated the old secret with no dual-signing window. +4. **Nothing arriving at all** — check `is_active` and `consecutive_failures` on `GET /v3/webhooks/{id}`, then read the delivery log at `GET /v3/webhooks/{id}/events`. +5. **Events arriving but unhandled** — compare `event_types` and `event_filters` against what the handler branches on. + +## Retry, auto-disable, and recovery + +A delivery attempt fails on any non-2xx status, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff with the first retry roughly one minute after the failure, doubling thereafter and capped at 60 minutes between attempts, stopping on the first 2xx or when `retry_count` is exhausted. Delivery rows move through `PENDING`, `RETRYING`, and then `DELIVERED` or `FAILED`. + +`consecutive_failures` tracks consecutive failed delivery attempts. Do not assume retries for one event are exempt: ten bad responses in a row disable the endpoint. After fixing the receiver, re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the Sent Dashboard. Any successful delivery resets the counter to zero. Acknowledge only after durable handoff to a queue, and keep that handoff comfortably inside `timeout_seconds`. + +## Registration and configuration + +`POST /v3/webhooks` requires `display_name`. Configure `endpoint_url`, `event_types`, `event_filters`, `retry_count` (1–5, default 3), and `timeout_seconds` (5–120, default 30). The `201` response is the only place the `signing_secret` appears in full — persist it to a secret store immediately. + + +```json +{ + "display_name": "Production delivery events", + "endpoint_url": "https://hooks.example.com/webhooks/sent", + "event_types": ["message", "templates"], + "event_filters": { + "message": ["delivered", "failed", "received"] + }, + "retry_count": 3, + "timeout_seconds": 30 +} +``` + +Set `event_filters` deliberately. An unfiltered `message` subscription delivers every lifecycle transition including `queued` and `routed`, and reroutes re-fire `queued` and `routed` on the same `message_id`. Filter to the transitions the application acts on. + +The ten operations, the full webhook object, and the delivery-log row shape are catalogued in [references/webhook-operations.md](references/webhook-operations.md). + +## Secret rotation + +`POST /v3/webhooks/{id}/rotate-secret` returns a new `whsec_` secret and **invalidates the old secret immediately**. There is no server-side overlap window. Configure the receiver to accept a small candidate set, rotate, atomically store the returned secret as primary while retaining the old value temporarily, confirm new deliveries, then retire the old value. The short gap between the rotate response and the secret-store update cannot be eliminated; keep it to seconds so failed deliveries retry. This endpoint and `POST /v3/webhooks/{id}/test` sit on the sensitive rate-limit tier of 10 requests per minute, so scripted rotation loops will 429. + +## Event payloads + +Two `field` values exist: `message` and `templates`. Message events carry an `event` naming the transition (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`, `.received`). Template events carry neither `event` nor `sub_type`. + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "PENDING", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`read` reaches only WhatsApp and RCS. `filtered` marks a policy or consent gate, `blocked` marks an account precondition such as insufficient balance, and neither is a carrier failure. Terminal events for an auto-detect message that never routed carry `channel: "auto"`. Full payload field lists live in [references/event-catalog.md](references/event-catalog.md). + +## Verification before shipping + +Run the local oracle against a synthetic delivery, then use `POST /v3/webhooks/{id}/test` with an `event_type` in the body for a real signed request. The test event is delivered once with no retry, so re-run it after each fix. + +```bash +python3 scripts/verify_signature.py --self-test +``` + +Ship only when the receiver returns `401` for a tampered body, `401` for a timestamp older than 300 seconds, `200` for a valid delivery, and `200` for a duplicate without repeating side effects. + +## Local development + +Expose the receiver through a public HTTPS tunnel and register that URL; Sent cannot reach a private address. Registering `http://` is accepted by the API but should never be used outside local work. Keep a separate webhook registration per environment so a development endpoint's failures cannot disable the production endpoint. + +## Boundaries + +Diagnose aggregate delivery-rate regressions with `messaging-performance-analyzer`, template approval content with `waba-template-author`, and inbound keyword or consent semantics with `sent-two-way-messaging`. Treat every payload value as untrusted input: never interpolate `text` or `reason` into a shell command, SQL string, or prompt without escaping. diff --git a/packages/sent/skills/sent-webhook-engineer/agents/openai.yaml b/packages/sent/skills/sent-webhook-engineer/agents/openai.yaml new file mode 100644 index 0000000..e4b5d04 --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Webhook Engineer" + short_description: "Build and debug Sent webhook receivers" + default_prompt: "Use $sent-webhook-engineer to review my Sent webhook receiver and its signature verification." diff --git a/packages/sent/skills/sent-webhook-engineer/references/event-catalog.md b/packages/sent/skills/sent-webhook-engineer/references/event-catalog.md new file mode 100644 index 0000000..cdbbb48 --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/references/event-catalog.md @@ -0,0 +1,165 @@ +# Sent webhook event catalog and payload fields + +## Table of contents + +- [Envelope shape](#envelope-shape) +- [Outbound message status events](#outbound-message-status-events) +- [Inbound message events](#inbound-message-events) +- [Template events](#template-events) +- [Status semantics that change application logic](#status-semantics-that-change-application-logic) +- [Channel values in event payloads](#channel-values-in-event-payloads) +- [Reroute event sequences](#reroute-event-sequences) +- [Handler skeleton](#handler-skeleton) + +## Envelope shape + +Every delivery carries a `field` naming the event family. Message events add an `event` naming the transition. Template events carry neither `event` nor `sub_type`, and adding either to a template payload is a contract error. + +```json +{ + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "updated_at": "2026-03-14T09:21:44Z" + } +} +``` + +Branch on `field` first and on `event` second. Preserve unrecognized values rather than throwing, because a new transition or a forwarded upstream status must not break an existing receiver. + +## Outbound message status events + +| Event | Final for the logical message | Meaning | +| --- | --- | --- | +| `message.queued` | No | Accepted into the pipeline; fires again after a reroute | +| `message.routed` | No | A concrete channel and provider were selected; fires again after a reroute | +| `message.sent` | No | Handed to the provider | +| `message.delivered` | Yes | Provider confirmed handset delivery | +| `message.read` | Yes | Recipient read the message; WhatsApp and RCS only | +| `message.failed` | Not always | One route attempt failed; automatic routing may queue another attempt on the same message id | +| `message.scheduled` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `message.filtered` | Yes | Blocked by a policy gate such as consent or a route denial | +| `message.blocked` | Yes | Blocked by an account precondition such as insufficient balance | + +Payload fields on status events include `message_id`, `message_status`, `channel`, `account_id`, `updated_at`, and a sender-profile identifier when the send was profile-scoped. The application's own tenant identifier is never present, so keep a `message_id` mapping written before the send. + +## Inbound message events + +`message.received` carries a distinct payload: + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "STOP", + "channel": "sms", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in and `outbound_number` is the number they wrote to; the naming trips up receivers that assume `inbound` means "our side." `text` may be null for non-text payloads. RCS suggestion-chip taps, including the appended STOP chip, arrive as `message.received` with the chip's reply text in `text` — there is no separate chip event type. Consent keywords are processed by Sent before the event reaches the application, so an inbound `STOP` is an audit record of an opt-out that already happened, not a request to perform one. + +## Template events + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`whatsapp_template_id` is an empty string until Meta approves the template. Documented statuses are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`, and upstream Meta values such as `PAUSED` and `DISABLED` are forwarded verbatim — treat the field as an open string set. `reason` is present only when a reason exists, most often on rejection. Route rejection content to template authoring rather than trying to auto-correct copy in the receiver. + +## Status semantics that change application logic + +`filtered` and `blocked` are not carrier failures and must not feed retry logic. A `filtered` message hit a policy gate, most often consent or a route denial, so retrying the same send reproduces the same result and, for consent, would be a compliance violation. A `blocked` message hit an account precondition such as insufficient balance, an onboarding quota, or an unapproved template; the fix is an account action, after which a new send is appropriate. + +`delivered` is the first event that proves the recipient's device received the message. The `202` from the send endpoint proves only acceptance, and `sent` proves only provider handoff. Any business rule with legal or financial weight should key on `delivered`. + +`read` exists only on WhatsApp and RCS, and its absence on SMS is expected rather than a failure. + +## Channel values in event payloads + +| Value | Where it appears | Interpretation | +| --- | --- | --- | +| `sms`, `whatsapp`, `rcs` | Any event after routing | The concrete attempted route | +| `sent` | `message.queued`, `message.routed`, `message.scheduled` for automatic routing | Automatic routing requested; not yet a resolved route | +| `auto` | Terminal events for a message that ended before routing | Internal placeholder; the message never reached a channel | +| `null` | Per-recipient echo in the send response for auto-detect | Not a resolved route | + +A terminal event carrying `auto` means the message failed or was gated before any route was attempted, which points at no matching route, invalid template parameters, a consent block, or an account precondition. Do not display `auto` to end users as a channel name. + +## Reroute event sequences + +Automatic routing may retry a failed message on another route, up to three distinct channel-and-provider pairs across the initial send and all reroutes. The retry re-runs the pipeline on the **same `message_id`**, so the event stream for one logical send can look like this: + +```text +message.queued channel=sent +message.routed channel=whatsapp +message.sent channel=whatsapp +message.failed channel=whatsapp (route-level failure) +message.queued channel=sent (reroute begins, same message_id) +message.routed channel=sms +message.sent channel=sms +message.delivered channel=sms +``` + +Three consequences for receiver design. A `message.failed` is not necessarily final, so reconcile the current message and activity state before triggering an irreversible failure action. Repeated `queued` and `routed` events for one id are normal and must be idempotent. The channel can change mid-stream, so store the channel per event rather than overwriting a single field and assuming it is stable. + +Only route-level or carrier-level failures trigger a reroute. Recipient-level and content-level failures stay failed. A WhatsApp message that was accepted and then failed for a recipient-side reason both reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, which is the mechanism behind the WhatsApp-to-SMS fallback that customers observe on automatic routing. + +## Handler skeleton + +```python +from hashlib import sha256 + + +def handle(event: dict, raw_body: bytes) -> None: + field = event.get("field") + value = event.get("value", {}) + + if not record_receipt_once(sha256(raw_body).hexdigest(), event): + return # exact transport retry; still answer 200 + + if field == "templates": + apply_template_state_once(value["template_id"], value.get("status")) + return + + if field != "message": + record_unknown_event(event) + return + + name = event.get("event") + if name == "message.received": + record_inbound(value) # consent already applied upstream + return + + # Compare payload.updated_at with the projected event timestamp. Do not use + # a global status rank: FAILED may be followed by a successful reroute. + apply_if_newer(value["message_id"], value, value.get("updated_at")) + + if value.get("message_status") == "DELIVERED": + perform_once(f"{value['message_id']}:DELIVERED", on_delivered, value) +``` + +Return `200` before doing slow work. Every branch, including the unknown-event branch, must acknowledge rather than raise, and genuine handler failures should return a non-2xx so Sent retries instead of silently discarding the event. diff --git a/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md new file mode 100644 index 0000000..0609e5a --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -0,0 +1,246 @@ +# Webhook receiver recipes by framework + +Every recipe follows the same four steps: read the raw body, verify the signature and timestamp, acknowledge with `200`, then process asynchronously. Only the raw-body accessor and the background mechanism change. + +## Table of contents + +- [Raw-body accessor matrix](#raw-body-accessor-matrix) +- [Node and TypeScript](#node-and-typescript) +- [Python](#python) +- [Go](#go) +- [Java and Spring Boot](#java-and-spring-boot) +- [PHP](#php) +- [Ruby](#ruby) +- [ASP.NET Core](#aspnet-core) +- [Deployment traps](#deployment-traps) + +## Raw-body accessor matrix + +| Framework | Accessor | Trap that breaks the signature | +| --- | --- | --- | +| Next.js route handler | `await request.text()` | Keep the route on the Node runtime; do not re-parse the body first | +| Express | `express.raw({ type: 'application/json' })` on the webhook path | A global `express.json()` replaces the exact bytes | +| NestJS | `req.rawBody` | Requires `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | `await request.body()` | Binding a Pydantic model first re-serializes | +| Django | `request.body` | Middleware that consumes the stream before the view | +| Flask | `request.get_data()` | `request.get_json()` first loses byte fidelity | +| Gin | `io.ReadAll(c.Request.Body)` | Body must be restored if later handlers read it | +| Echo | `io.ReadAll(c.Request().Body)` | Same | +| Spring Boot | `@RequestBody String payload` | Binding to a DTO re-serializes | +| Laravel | `$request->getContent()` | Middleware ordering; verify before any transform | +| Symfony | `$request->getContent()` | Same | +| Rails | `request.body.read` then `request.body.rewind` | `params` re-encodes the payload | +| Sinatra | `request.body.read` then `request.body.rewind` | Same | +| ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | + +Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. + +## Node and TypeScript + +```ts +import crypto from "node:crypto"; + +const TOLERANCE_SECONDS = 300; + +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { + const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; + if (!secret || !header?.startsWith("v1,")) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; + + const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); + const digest = crypto.createHmac("sha256", key).update(`${webhookId}.${timestamp}.${rawBody}`).digest("base64"); + const expected = Buffer.from(`v1,${digest}`); + const received = Buffer.from(header); + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} +``` + +Next.js route handler, kept on the Node runtime: + +```ts +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + const rawBody = await request.text(); + const ok = verify( + rawBody, + request.headers.get("x-webhook-id") ?? "", + request.headers.get("x-webhook-timestamp") ?? "", + request.headers.get("x-webhook-signature") ?? "", + ); + if (!ok) return new Response("invalid signature", { status: 401 }); + + await enqueue(JSON.parse(rawBody)); // hand off, do not process inline + return new Response(null, { status: 200 }); +} +``` + +Express, scoping the raw parser to the webhook path only: + +```js +app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { + const rawBody = req.body.toString("utf8"); + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + return res.status(401).send("invalid signature"); + } + res.status(200).end(); + queue.add("sent-event", JSON.parse(rawBody)); // after the response +}); +``` + +Mount `express.json()` on other routers rather than globally with `app.use`. In NestJS create the app with `{ rawBody: true }` and read `req.rawBody`. Use BullMQ or an equivalent queue for the background step. + +## Python + +```python +import base64, hashlib, hmac, os, time + +TOLERANCE_SECONDS = 300 + + +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: + secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") + if not secret or not header.startswith("v1,"): + return False + try: + if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: + return False + except ValueError: + return False + key = base64.b64decode(secret.removeprefix("whsec_")) + signed = f"{webhook_id}.{timestamp}.".encode() + raw_body + expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() + return hmac.compare_digest(expected, header) +``` + +FastAPI: + +```python +@app.post("/webhooks/sent", status_code=200) +async def receive(request: Request, background: BackgroundTasks): + raw = await request.body() + if not verify(raw, request.headers.get("x-webhook-id", ""), + request.headers.get("x-webhook-timestamp", ""), + request.headers.get("x-webhook-signature", "")): + raise HTTPException(status_code=401, detail="invalid signature") + background.add_task(process_event, json.loads(raw)) + return {"received": True} +``` + +Django reads `request.body` in the view and must exempt the route from CSRF. Flask reads `request.get_data()` in a decorator that wraps the view. For anything slower than a database insert, hand the parsed event to Celery with `process_event.delay(event)` and route message traffic to a dedicated queue so bulk campaigns cannot starve transactional work. + +## Go + +```go +func Verify(rawBody []byte, webhookID, timestamp, header string) bool { + secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") + if secret == "" || !strings.HasPrefix(header, "v1,") { + return false + } + sentAt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 { + return false + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(webhookID + "." + timestamp + ".")) + mac.Write(rawBody) + expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(expected), []byte(header)) == 1 +} +``` + +In Gin read with `io.ReadAll(c.Request.Body)`; in Echo use `c.Request().Body`. If any later middleware needs the body, restore it with `c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))`. Acknowledge, then dispatch to a goroutine with a bounded worker pool or a durable queue, and drain in-flight work on shutdown. + +## Java and Spring Boot + +```java +@PostMapping("/webhooks/sent") +public ResponseEntity receive( + @RequestBody String payload, + @RequestHeader("x-webhook-id") String webhookId, + @RequestHeader("x-webhook-timestamp") String timestamp, + @RequestHeader("x-webhook-signature") String signature) throws Exception { + + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + return ResponseEntity.status(401).build(); + } + events.submit(payload); // @Async executor + return ResponseEntity.ok().build(); +} +``` + +Bind the body as `String`, never as a DTO, because Jackson re-serialization changes the bytes. Verify with `Mac.getInstance("HmacSHA256")` and compare using `MessageDigest.isEqual`. Push processing onto a `ThreadPoolTaskExecutor` or a broker. + +## PHP + +Laravel middleware runs before the controller and reads `$request->getContent()`: + +```php +public function handle(Request $request, Closure $next) +{ + $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); + $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); + $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); + + if (abs(time() - (int) $request->header('x-webhook-timestamp')) > 300 + || !hash_equals($expected, (string) $request->header('x-webhook-signature'))) { + abort(401); + } + return $next($request); +} +``` + +Dispatch a `ShouldQueue` job from the controller. Symfony follows the same pattern with `$request->getContent()` and a Messenger message consumed by `messenger:consume`. + +## Ruby + +```ruby +def verified?(request) + raw = request.body.read + request.body.rewind + secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") + timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s + return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 + + key = Base64.decode64(secret.delete_prefix("whsec_")) + signed = "#{request.get_header('HTTP_X_WEBHOOK_ID')}.#{timestamp}.#{raw}" + expected = "v1,#{Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', key, signed))}" + ActiveSupport::SecurityUtils.secure_compare(expected, request.get_header("HTTP_X_WEBHOOK_SIGNATURE").to_s) +end +``` + +In Rails put this in a controller concern, skip `verify_authenticity_token` for the action, and enqueue with ActiveJob. In Sinatra read the body in the route and enqueue with Sidekiq; remember the client is memoized per Puma worker process. + +## ASP.NET Core + +```csharp +app.MapPost("/webhooks/sent", async (HttpRequest request) => +{ + using var reader = new StreamReader(request.Body); + var rawBody = await reader.ReadToEndAsync(); + + if (!WebhookSignature.Verify( + rawBody, + request.Headers["x-webhook-id"], + request.Headers["x-webhook-timestamp"], + request.Headers["x-webhook-signature"], + Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + { + return Results.Unauthorized(); + } + + await channel.Writer.WriteAsync(rawBody); // BackgroundService consumer + return Results.Ok(); +}); +``` + +Read the stream before model binding touches it, compare with `CryptographicOperations.FixedTimeEquals`, and consume from a `BackgroundService`. + +## Deployment traps + +Reverse proxies and API gateways that buffer, recompress, or normalize request bodies break the signature; configure pass-through for the webhook path. Serverless platforms that hand the body as base64 require decoding to the original bytes before verification, not after. Load balancer idle timeouts shorter than `timeout_seconds` produce phantom failures that appear in the delivery log as timeouts with no `http_status_code`. Container clocks must be NTP-synchronized or the 300-second window rejects valid traffic. Finally, keep the webhook path out of user-auth middleware. If abuse controls are needed, apply signature-aware, capacity-safe controls rather than a generic pre-verification limiter that manufactures the consecutive failures leading to auto-disable. diff --git a/packages/sent/skills/sent-webhook-engineer/references/webhook-operations.md b/packages/sent/skills/sent-webhook-engineer/references/webhook-operations.md new file mode 100644 index 0000000..ee5f9e1 --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/references/webhook-operations.md @@ -0,0 +1,141 @@ +# Sent v3 webhook operations and delivery lifecycle + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Webhook object](#webhook-object) +- [Creating a webhook](#creating-a-webhook) +- [Event types and filters](#event-types-and-filters) +- [Delivery attempts, retries, and backoff](#delivery-attempts-retries-and-backoff) +- [Auto-disable and recovery](#auto-disable-and-recovery) +- [Delivery log triage](#delivery-log-triage) +- [Test deliveries](#test-deliveries) +- [Rate limits and sandbox behavior](#rate-limits-and-sandbox-behavior) +- [Environment and tenancy layout](#environment-and-tenancy-layout) + +## Operation catalog + +Ten operations manage webhooks. None of them is exposed through the Sent MCP server, so webhook work is REST-only even in an agent session that already holds an MCP connection. + +| Operation | Purpose | Notes | +| --- | --- | --- | +| `POST /v3/webhooks` | Register an endpoint | `201` body carries the only full view of `signing_secret` | +| `GET /v3/webhooks` | List endpoints | Inspect `is_active` and `consecutive_failures` here first | +| `GET /v3/webhooks/event-types` | Discover subscribable event types | Use before hardcoding an `event_types` array | +| `GET /v3/webhooks/{id}` | Inspect one endpoint | Confirms configuration and health counters | +| `PUT /v3/webhooks/{id}` | Update configuration | Replaces the mutable configuration fields | +| `DELETE /v3/webhooks/{id}` | Remove an endpoint | Ignores `sandbox` and always deletes | +| `GET /v3/webhooks/{id}/events` | Delivery log | Requires `page` and `page_size`; optional `search` | +| `POST /v3/webhooks/{id}/rotate-secret` | Replace the signing secret | Old secret dies immediately; sensitive rate tier | +| `POST /v3/webhooks/{id}/test` | Send a synthetic signed delivery | Requires `event_type`; one attempt, no retry; sensitive rate tier | +| `PATCH /v3/webhooks/{id}/toggle-status` | Enable or disable | Operational pause without losing configuration | + +## Webhook object + +| Field | Meaning | +| --- | --- | +| `id` | Endpoint UUID; the value of `x-webhook-id` on every delivery | +| `display_name` | Required label; the only required field on create | +| `endpoint_url` | Destination; scheme must be `http://` or `https://` | +| `signing_secret` | `whsec_`-prefixed secret, fully visible only in the create and rotate responses | +| `is_active` | False after auto-disable or an explicit toggle | +| `event_types` | Subscribed event families, for example `["message", "templates"]` | +| `event_filters` | Per-family narrowing, for example `{"message": ["delivered", "failed"]}` | +| `retry_count` | 1–5, default 3 | +| `timeout_seconds` | 5–120, default 30 | +| `last_delivery_attempt_at` | Timestamp of the most recent attempt of any outcome | +| `last_successful_delivery_at` | Timestamp of the most recent 2xx | +| `consecutive_failures` | Counter of consecutive failed attempts; ten disables the endpoint | +| `created_at`, `updated_at` | Audit timestamps | + +The gap between `last_delivery_attempt_at` and `last_successful_delivery_at` is the fastest health signal: a recent attempt with a stale success means the endpoint is failing right now. + +## Creating a webhook + +Only `display_name` is required, but a useful registration sets the destination, the subscriptions, and the delivery envelope explicitly. + +```json +{ + "display_name": "Staging inbound and failures", + "endpoint_url": "https://staging-hooks.example.com/webhooks/sent", + "event_types": ["message"], + "event_filters": { + "message": ["received", "failed", "filtered", "blocked"] + }, + "retry_count": 5, + "timeout_seconds": 15 +} +``` + +Choose `timeout_seconds` to match how fast the endpoint acknowledges, not how long processing takes. A receiver that returns `200` in 50 milliseconds and queues the work is compatible with the 5-second minimum; a receiver that writes to three downstream systems before responding will eventually breach even a 120-second ceiling under load and start accumulating consecutive failures. + +Choose `retry_count` against the recovery profile of the receiver. Three attempts spread over roughly seven minutes suits a stateless service behind a load balancer. Five attempts, reaching further into the capped 60-minute backoff, suits a receiver whose dependency outages last longer than a few minutes. + +## Event types and filters + +Call `GET /v3/webhooks/event-types` rather than assuming the catalog. Two `field` families exist today: `message`, which carries an `event` naming the transition, and `templates`, which carries approval-state changes without an `event` field. + +Filters matter more than they appear. An unfiltered `message` subscription delivers every transition, and because a reroute re-runs the pipeline on the same message id, `queued` and `routed` can arrive several times for one logical send. Subscribing only to the transitions the application acts on reduces both traffic and the chance of a double-processing bug. + +A practical split by consumer: + +| Consumer | Subscription | +| --- | --- | +| Delivery ledger and retries | `message` filtered to `delivered`, `failed`, `filtered`, `blocked` | +| Support inbox and auto-replies | `message` filtered to `received` | +| Read-receipt analytics | `message` filtered to `read` | +| Template governance | `templates` | +| Route debugging in a lower environment | `message` unfiltered | + +## Delivery attempts, retries, and backoff + +An attempt fails on any non-2xx response, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff: the first retry lands roughly one minute after the failure, each subsequent delay doubles, and the interval is capped at 60 minutes between attempts. Retries stop at the first 2xx or when `retry_count` is exhausted. + +Delivery rows report `delivery_status` as `PENDING` while queued, `RETRYING` between attempts, `DELIVERED` on success, and `FAILED` once attempts are exhausted. A `DELIVERED` outcome resets the endpoint's `consecutive_failures` to zero. + +Because retries are signed fresh, a retried delivery has a new `x-webhook-timestamp` and a new signature but the same payload — which is precisely why dedupe must key on payload semantics rather than on headers. + +## Auto-disable and recovery + +Ten consecutive failed delivery attempts disable the endpoint. Treat every failed attempt as capable of advancing the counter, whether it is a retry of one event or the first attempt for another; do not rely on event boundaries for protection. Any success resets the counter. + +Once `is_active` is false, Sent stops delivering. Recovery sequence: + +1. Read `GET /v3/webhooks/{id}` and confirm `is_active` and `consecutive_failures`. +2. Read the delivery log and identify the recurring `http_status_code` or `error_message`. +3. Fix the receiver and prove it locally against a signed synthetic delivery. +4. Re-enable the webhook with `PATCH /v3/webhooks/{id}/toggle-status` or in the Sent Dashboard. +5. Confirm recovery with `POST /v3/webhooks/{id}/test`, then verify the log shows `DELIVERED`. +6. Backfill the outage window from `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities` for messages whose state is stale, because events that failed permanently during the outage are not redelivered on re-enable. + +Add monitoring on `consecutive_failures` so an alert fires at three or four rather than at ten. + +## Delivery log triage + +Each row of `GET /v3/webhooks/{id}/events` contains `id`, `event_type`, `event_data`, `delivery_status`, `http_status_code`, `response_body`, `delivery_attempts`, `error_message`, `created_at`, `processing_started_at`, and `processing_completed_at`. Both `page` and `page_size` are required; omitting them returns a validation error rather than a default page. + +| Log evidence | Diagnosis | +| --- | --- | +| `http_status_code` 401 or 403 | The receiver is rejecting the signature, or authentication middleware sits in front of the webhook route | +| `http_status_code` 404 | Route path or environment mismatch in `endpoint_url` | +| `http_status_code` 5xx with a stack trace in `response_body` | Handler exception; fix the handler, not the registration | +| `error_message` naming a timeout with empty `http_status_code` | The receiver did not answer inside `timeout_seconds`; move work off the request path | +| `error_message` naming a connection or TLS failure | DNS, certificate, or firewall problem; the request never reached the application | +| `delivery_status` `DELIVERED` while the application has no record | The event was accepted and then dropped internally; instrument between acknowledgement and the queue | +| `delivery_attempts` climbing with `RETRYING` | Backoff is in progress; confirm the receiver recovered before it exhausts `retry_count` | + +Keep the receiver route outside user-auth middleware. Sent authenticates by signature, and an intervening session or Bearer-auth layer produces a 401 that looks exactly like a signature bug. + +## Test deliveries + +`POST /v3/webhooks/{id}/test` takes an `event_type` in the body and sends a real signed request with a synthetic payload to the registered URL. It is delivered once with no retry, so each fix needs a fresh call. Treat it as the end-to-end proof that DNS, TLS, routing, signature verification, and acknowledgement all work together; use the local signing script for iteration because the test endpoint is limited to 10 requests per minute. + +## Rate limits and sandbox behavior + +Standard endpoints allow 200 requests per minute on a sliding window. `rotate-secret` and `test` allow 10 per minute on a fixed window. Rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`) appear only on `429` responses, so a client cannot read remaining quota preemptively and must pace by design. + +`"sandbox": true` on create or update validates and authenticates without persisting anything, which makes it useful for checking a payload shape in CI. `DELETE /v3/webhooks/{id}` ignores the flag and always deletes, so never use sandbox as a dry-run guard for deletion. + +## Environment and tenancy layout + +Register one webhook per environment and never share an endpoint across environments. Because auto-disable is per endpoint, a development receiver returning 500s cannot then disable production. For multi-tenant systems, remember that events do not carry the application's tenant identifier: persist `message_id -> {tenant, profile, logical_send_id, channel}` before sending and map inbound events by the receiving number. Organization-scoped credentials with `x-profile-id` can manage a child profile's webhooks; profile-scoped keys manage only their own and receive `403` if they send `x-profile-id`. diff --git a/packages/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md b/packages/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md new file mode 100644 index 0000000..eb4af1f --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md @@ -0,0 +1,88 @@ +# Webhook signature verification and event deduplication + +## Table of contents + +- [Signature scheme](#signature-scheme) +- [Why the raw body matters](#why-the-raw-body-matters) +- [Replay rejection](#replay-rejection) +- [Secret handling and rotation](#secret-handling-and-rotation) +- [Deduplication without an event id](#deduplication-without-an-event-id) +- [Ordering and out-of-sequence events](#ordering-and-out-of-sequence-events) +- [Acceptance tests for a receiver](#acceptance-tests-for-a-receiver) + +## Signature scheme + +Sent signs each delivery with HMAC-SHA256 and publishes three headers. + +| Header | Example | Notes | +| --- | --- | --- | +| `x-webhook-signature` | `v1,K7t9...==` | Version tag, comma, base64 digest | +| `x-webhook-id` | `0f8fad5b-d9cb-469f-a165-70867728950e` | Endpoint UUID, constant across deliveries | +| `x-webhook-timestamp` | `1767225600` | Unix seconds | + +The signed content is the concatenation `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, where the first two components are joined by literal `.` characters and the third is the untouched request body. The HMAC key is the signing secret with the leading `whsec_` removed and the remainder base64-decoded, which yields raw key bytes rather than an ASCII string. The digest is base64-encoded and prefixed with `v1,`. + +The construction is compatible with Svix-style verification, so an existing Svix helper can usually be adapted by pointing it at these header names. No Sent SDK provides a built-in verifier in any of the seven supported languages, so this logic is application code in every deployment. + +Compare signatures with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`, `MessageDigest.isEqual`, `hash_equals`, `subtle.ConstantTimeCompare`). A plain `==` on a signature invites a timing oracle. + +## Why the raw body matters + +The signature covers exact bytes. Any transformation between the socket and the verification step invalidates it: JSON parse and re-serialize, key reordering, whitespace normalization, Unicode escaping changes, trailing-newline insertion, gzip re-encoding, or a proxy that rewrites the body. This is the single most common cause of signature failures, and it usually appears as "verification works with curl but fails behind the framework." + +Diagnose it by logging the byte length and a SHA-256 of the body at the verification point and comparing against the `Content-Length` header. A mismatch means something consumed and rebuilt the body upstream. + +## Replay rejection + +Reject a delivery when `abs(now - x-webhook-timestamp) > 300` seconds. The five-minute tolerance is the documented value and appears as `TOLERANCE_SECONDS = 300` in the official samples for every language. Two operational consequences follow. First, hosts must run NTP; clock drift beyond five minutes rejects perfectly valid traffic and the symptom looks identical to a signature bug. Second, because retries can arrive up to 60 minutes after the original attempt, each retry is signed with its own fresh timestamp — the receiver must never cache the first timestamp and compare later deliveries against it. + +For forensic replay of an archived delivery, verify the HMAC while explicitly skipping the freshness check rather than widening the production tolerance. + +## Secret handling and rotation + +The full `signing_secret` appears exactly once, in the `201` body of `POST /v3/webhooks`. Store it in a secret manager keyed by webhook id and environment. `GET /v3/webhooks/{id}` is the way to confirm which endpoint a stored secret belongs to. + +`POST /v3/webhooks/{id}/rotate-secret` returns the replacement and invalidates the previous secret immediately. There is no dual-signing window on Sent's side, so the receiver must provide the overlap: + +1. Deploy a receiver that reads a primary secret and an optional secondary secret and accepts a delivery that verifies under either. +2. Put the current secret in both slots and deploy. +3. Rotate, and write the new secret into the primary slot. +4. Confirm from the delivery log that recent attempts are `DELIVERED`. +5. Clear the secondary slot and deploy again. + +Rotate on compromise, on operator offboarding, and on a fixed schedule. The rotate endpoint is on the sensitive tier of 10 requests per minute, so automation must not loop over many webhooks quickly. + +## Deduplication without an event id + +Sent does not publish a per-event unique identifier. `x-webhook-id` names the endpoint and is identical on every delivery, so using it as a dedupe key collapses all events into a single row. Derive keys from event semantics instead: + +| Event | Idempotency key | Rationale | +| --- | --- | --- | +| Outbound status (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`) | `sha256(raw_body)` for receipt dedupe; `{message_id}:{message_status}` for one-time business effects | Exact delivery retries carry the same payload, while a reroute may legitimately repeat a status with a different channel or timestamp | +| Inbound (`message.received`) | `{message_id}` | Each inbound message has its own id | +| Template (`field: "templates"`) | `{template_id}:{status}` | Approval transitions are the meaningful unit | +| Anything unrecognized | `sha256(raw_body)` | Absorbs an exact retry without depending on a fresh retry timestamp | + +Persist the key with a unique constraint and treat an insert conflict as "already processed, return 200." A duplicate must never repeat side effects such as charging a card, sending a follow-up message, or writing a second audit row. + +Reroutes make two layers necessary. Use a raw-body hash to suppress exact transport retries, but retain distinct reroute events in an append-only receipt ledger because their channel or `updated_at` differs. Gate one-time business effects separately—for example, send a receipt only once for `{message_id}:DELIVERED`—so preserving route evidence does not duplicate side effects. + +## Ordering and out-of-sequence events + +Delivery order is not guaranteed, and a global status rank is unsafe: automatic routing can emit `FAILED`, then a newer `QUEUED`, `ROUTED`, and `DELIVERED` on the same message id. Persist the append-only receipt first, then update the current projection only when the payload's `updated_at` is newer than the projected event timestamp. Use receipt order only as a tie-breaker, preserve the attempted channel per event, and reconcile uncertain final state with `GET /v3/messages/{id}` plus activities. Never make `FAILED` permanently outrank a later successful reroute. + +## Acceptance tests for a receiver + +A receiver is ready when all of the following hold: + +1. A validly signed delivery returns `200`. +2. A body with a single byte changed returns `401`. +3. A delivery signed with a different secret returns `401`. +4. A delivery whose timestamp is 400 seconds old returns `401`. +5. A delivery whose `x-webhook-id` is altered returns `401`. +6. The same valid delivery sent twice returns `200` twice and performs side effects once. +7. A `message.delivered` followed by a late `message.sent` leaves the stored status at `DELIVERED`. +8. A handler exception still returns a non-2xx status so Sent retries, rather than swallowing the error and returning `200`. +9. Processing that exceeds one second happens after the response, not before it. + +Use `scripts/verify_signature.py --sign` to produce headers for cases 1, 6, and 7, and mutate them for the negative cases. diff --git a/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py new file mode 100644 index 0000000..e79391f --- /dev/null +++ b/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Reference implementation and test oracle for Sent v3 webhook signatures. + +The signed content is exactly ``{webhook_id}.{timestamp}.{raw_body}``. The key is +the signing secret with its ``whsec_`` prefix removed and the remainder +base64-decoded. The signature header value is ``v1,{base64(hmac_sha256)}``. + +Usage +----- +Self-test with synthetic fixtures (no network, no credentials):: + + python3 verify_signature.py --self-test + +Verify a captured delivery:: + + python3 verify_signature.py --body-file delivery.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --timestamp 1767225600 \ + --signature 'v1,Base64Signature==' \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Sign a synthetic delivery so a local receiver can be exercised:: + + python3 verify_signature.py --sign --body-file event.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, +3 usage or configuration error. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import sys +import time + +SECRET_PREFIX = "whsec_" +SIGNATURE_PREFIX = "v1," +TOLERANCE_SECONDS = 300 + +EXIT_VALID = 0 +EXIT_INVALID = 1 +EXIT_REPLAY = 2 +EXIT_USAGE = 3 + + +def decode_secret(secret: str) -> bytes: + """Return the raw HMAC key for a Sent signing secret.""" + if not secret: + raise ValueError("signing secret is empty") + material = secret[len(SECRET_PREFIX):] if secret.startswith(SECRET_PREFIX) else secret + padding = "=" * (-len(material) % 4) + try: + return base64.b64decode(material + padding, validate=True) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise ValueError(f"signing secret is not valid base64 after the prefix: {exc}") from exc + + +def signed_content(webhook_id: str, timestamp: str, raw_body: bytes) -> bytes: + """Build the byte string Sent signs.""" + return f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body + + +def compute_signature(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str: + """Return the full ``v1,...`` header value for a delivery.""" + digest = hmac.new( + decode_secret(secret), + signed_content(webhook_id, timestamp, raw_body), + hashlib.sha256, + ).digest() + return SIGNATURE_PREFIX + base64.b64encode(digest).decode("ascii") + + +def signature_matches(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> bool: + """Constant-time comparison of a received signature header.""" + expected = compute_signature(secret, webhook_id, timestamp, raw_body) + return hmac.compare_digest(expected, header.strip()) + + +def timestamp_fresh(timestamp: str, now: int | None = None, tolerance: int = TOLERANCE_SECONDS) -> bool: + """Return True when the timestamp is inside the replay window.""" + try: + sent_at = int(timestamp) + except (TypeError, ValueError): + return False + reference = int(time.time()) if now is None else now + return abs(reference - sent_at) <= tolerance + + +def verify(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> int: + """Return the process exit code for one delivery.""" + if not timestamp_fresh(timestamp): + return EXIT_REPLAY + return EXIT_VALID if signature_matches(secret, webhook_id, timestamp, raw_body, header) else EXIT_INVALID + + +def _self_test() -> int: + secret = SECRET_PREFIX + base64.b64encode(b"synthetic-signing-key-0123456789").decode("ascii") + webhook_id = "0f8fad5b-d9cb-469f-a165-70867728950e" + now = int(time.time()) + timestamp = str(now) + body = json.dumps( + { + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + }, + }, + separators=(",", ":"), + ).encode("utf-8") + + failures: list[str] = [] + header = compute_signature(secret, webhook_id, timestamp, body) + + if not header.startswith(SIGNATURE_PREFIX): + failures.append("signature header must start with 'v1,'") + if verify(secret, webhook_id, timestamp, body, header) != EXIT_VALID: + failures.append("a freshly signed delivery must verify") + if verify(secret, webhook_id, timestamp, body + b" ", header) != EXIT_INVALID: + failures.append("a mutated body must fail verification") + if verify(secret, "11111111-2222-3333-4444-555555555555", timestamp, body, header) != EXIT_INVALID: + failures.append("a different webhook id must fail verification") + stale = str(now - (TOLERANCE_SECONDS + 60)) + if verify(secret, webhook_id, stale, body, compute_signature(secret, webhook_id, stale, body)) != EXIT_REPLAY: + failures.append("a stale timestamp must be rejected as a replay") + if decode_secret(secret) != decode_secret(secret[len(SECRET_PREFIX):]): + failures.append("prefixed and unprefixed secrets must decode identically") + + reserialized = json.dumps(json.loads(body)).encode("utf-8") + if reserialized != body and signature_matches(secret, webhook_id, timestamp, reserialized, header): + failures.append("re-serialized JSON must not verify; raw bytes are required") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_INVALID + print("verify_signature self-test passed: 7 checks") + return EXIT_VALID + + +def _resolve_secret(args: argparse.Namespace) -> str: + if args.secret_env: + secret = os.environ.get(args.secret_env, "") + if not secret: + raise ValueError(f"environment variable {args.secret_env} is unset or empty") + return secret + raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Verify or sign a Sent v3 webhook delivery.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--sign", action="store_true", help="emit headers for a synthetic signed delivery") + parser.add_argument("--body-file", help="path to the raw request body captured byte for byte") + parser.add_argument("--webhook-id", help="value of the x-webhook-id header") + parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") + parser.add_argument("--signature", help="value of the x-webhook-signature header") + parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument( + "--skip-replay-check", + action="store_true", + help="verify the HMAC only, for forensic replay of an archived delivery", + ) + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if not args.body_file or not args.webhook_id: + parser.error("--body-file and --webhook-id are required unless --self-test is used") + + try: + raw_body = open(args.body_file, "rb").read() + secret = _resolve_secret(args) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_USAGE + + if args.sign: + timestamp = args.timestamp or str(int(time.time())) + print(f"x-webhook-id: {args.webhook_id}") + print(f"x-webhook-timestamp: {timestamp}") + print(f"x-webhook-signature: {compute_signature(secret, args.webhook_id, timestamp, raw_body)}") + return EXIT_VALID + + if not args.timestamp or not args.signature: + parser.error("--timestamp and --signature are required when verifying") + + if args.skip_replay_check: + matched = signature_matches(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + result = EXIT_VALID if matched else EXIT_INVALID + else: + result = verify(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + + print({EXIT_VALID: "valid", EXIT_INVALID: "invalid signature", EXIT_REPLAY: "replay window exceeded"}[result]) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sent/skills/sent/SKILL.md b/packages/sent/skills/sent/SKILL.md index f13a519..9e8b3f1 100644 --- a/packages/sent/skills/sent/SKILL.md +++ b/packages/sent/skills/sent/SKILL.md @@ -49,6 +49,19 @@ Do **not** use when: | Diagnosing delivery from MDR exports, funnels, cohorts, or cross-channel failure codes | `messaging-performance-analyzer` | | Designing or auditing a tenant-facing template-builder UI | `template-builder-ui` | +### Engineering and integration + +| User intent | Target skill | +|---|---| +| Adding Sent to a codebase, choosing an SDK, or hardening retries, idempotency, and error handling before launch | `sent-integration-starter` | +| Building or debugging a webhook receiver, signature verification, dedupe, or an auto-disabled endpoint | `sent-webhook-engineer` | +| Choosing the channel field, expecting cross-channel fallback, or interpreting a route, reroute, or delivery outcome | `sent-routing-strategist` | +| Handling inbound messages, opt-out keywords, consent state, the WhatsApp 24-hour window, or conversation history | `sent-two-way-messaging` | +| Executing the Sender Profile lifecycle over the API, including completion callbacks, campaigns, and user roles | `sent-profile-provisioning` | +| Replacing Twilio, Sinch, Infobip, Vonage, or Bird with Sent, including cutover and rollback planning | `migrate-to-sent` | + +Within this group, note two frequent hand-offs: `sender-profile-architect` decides the tenancy boundary and `sent-profile-provisioning` implements it, while `migrate-to-sent` plans a provider replacement and `sent-integration-starter` hardens the resulting integration. + If the request matches one row cleanly, invoke that skill and stop. If it spans several rows, state the proposed order and begin with the prerequisite. For example, check `sent-account-readiness` before a live send, use `sent-templates` to locate an existing template before `sent-messaging`, and use `messaging-performance-analyzer` when the user provides an export rather than asking for live dashboard metrics. ## Clarifying questions to ask before routing @@ -70,7 +83,7 @@ One question per turn is fine; never fire all seven at once. This skill is not a fallback for general questions. If the user asks about: - **Balance, onboarding state, or whether the selected account can send** — use `sent-account-readiness`. - **Contracts, plan pricing, invoices, or account access that the available operations cannot answer** — direct them to Sent support or `https://docs.sent.dm`. -- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally. +- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally; route to `sent-integration-starter` once the question involves Sent's own retry, idempotency, or rate-limit contract. - **Meta, Google, TCR, or carrier policy outside a specialist skill's scope** — use current upstream documentation. If after the clarifying questions the request still doesn't fit any target skill, say so plainly. Don't force a route. diff --git a/plugin.json b/plugin.json index d230e14..bde2f75 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for SMS, WhatsApp, RCS, API integration, webhooks, routing, two-way messaging, Sender Profiles, migration, compliance, analytics, and agent-safe operations.", "author": { "name": "Sent", "url": "https://sent.dm" @@ -28,6 +28,11 @@ "analytics", "deliverability", "sender-profiles", + "api-integration", + "webhooks", + "routing", + "two-way-messaging", + "migration", "mcp" ] } diff --git a/plugins/sent/.codex-plugin/plugin.json b/plugins/sent/.codex-plugin/plugin.json index 94a4fb6..e7ef835 100644 --- a/plugins/sent/.codex-plugin/plugin.json +++ b/plugins/sent/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for SMS, WhatsApp, RCS, API integration, webhooks, routing, two-way messaging, Sender Profiles, migration, compliance, analytics, and agent-safe operations.", "author": { "name": "Sent", "url": "https://sent.dm" @@ -27,6 +27,11 @@ "analytics", "deliverability", "sender-profiles", + "api-integration", + "webhooks", + "routing", + "two-way-messaging", + "migration", "mcp" ], "skills": "./skills/", @@ -34,7 +39,7 @@ "interface": { "displayName": "Sent", "shortDescription": "Business messaging workflows", - "longDescription": "Use Sent MCP operations for safe account, contact, template, analytics, and messaging workflows. Focused specialist skills add delivery diagnosis, Sender Profile architecture, US A2P 10DLC registration, WhatsApp Business onboarding and template authoring, RCS agent launch readiness, and cross-channel template-builder design.", + "longDescription": "Use Sent MCP operations for safe account, contact, template, analytics, and messaging workflows. Focused specialist skills add API integration, webhook engineering, contact-aware routing, two-way messaging, Sender Profile architecture and provisioning, CPaaS migration, delivery diagnosis, US A2P 10DLC, WhatsApp Business, RCS launch readiness, and cross-channel template design.", "developerName": "Sent", "category": "Productivity", "capabilities": [ @@ -47,9 +52,9 @@ "logo": "./assets/logo.svg", "composerIcon": "./assets/logo.svg", "defaultPrompt": [ - "Use $sent-account-readiness to check whether my Sent account is messaging-ready.", - "Use $messaging-performance-analyzer to diagnose this delivery report by channel.", - "Use $sms-10dlc-registration to review my US A2P registration evidence." + "Use $sent-integration-starter to review how this application should integrate Sent safely.", + "Use $sent-routing-strategist to explain the safest channel and reroute policy for this workflow.", + "Use $migrate-to-sent to plan a staged migration from our current messaging provider." ] } } diff --git a/plugins/sent/README.md b/plugins/sent/README.md index dbe2e6a..46f69ad 100644 --- a/plugins/sent/README.md +++ b/plugins/sent/README.md @@ -19,6 +19,12 @@ This generated host adapter packages the official Sent Agent Skills and remote M | `rcs-agent-onboarding` | Prepare an RBM agent for approval and launch | [`skills/rcs-agent-onboarding/SKILL.md`](skills/rcs-agent-onboarding/SKILL.md) | | `sender-profile-architect` | Design multi-tenant Sender Profile architecture | [`skills/sender-profile-architect/SKILL.md`](skills/sender-profile-architect/SKILL.md) | | `template-builder-ui` | Design cross-channel template-builder UX | [`skills/template-builder-ui/SKILL.md`](skills/template-builder-ui/SKILL.md) | +| `sent-integration-starter` | Stand up and harden a Sent v3 integration | [`skills/sent-integration-starter/SKILL.md`](skills/sent-integration-starter/SKILL.md) | +| `sent-webhook-engineer` | Build and debug verified webhook receivers | [`skills/sent-webhook-engineer/SKILL.md`](skills/sent-webhook-engineer/SKILL.md) | +| `sent-routing-strategist` | Choose channels and diagnose route outcomes | [`skills/sent-routing-strategist/SKILL.md`](skills/sent-routing-strategist/SKILL.md) | +| `sent-two-way-messaging` | Design inbound, consent, and conversational flows | [`skills/sent-two-way-messaging/SKILL.md`](skills/sent-two-way-messaging/SKILL.md) | +| `sent-profile-provisioning` | Execute the Sender Profile and user lifecycle | [`skills/sent-profile-provisioning/SKILL.md`](skills/sent-profile-provisioning/SKILL.md) | +| `migrate-to-sent` | Migrate from another CPaaS provider onto Sent | [`skills/migrate-to-sent/SKILL.md`](skills/migrate-to-sent/SKILL.md) | To install the skills without the host adapter, list or select them with the Skills CLI: diff --git a/plugins/sent/skills/migrate-to-sent/SKILL.md b/plugins/sent/skills/migrate-to-sent/SKILL.md new file mode 100644 index 0000000..de79092 --- /dev/null +++ b/plugins/sent/skills/migrate-to-sent/SKILL.md @@ -0,0 +1,95 @@ +--- +name: migrate-to-sent +description: Plans and executes a migration from Twilio, Sinch, Infobip, Vonage, or MessageBird/Bird to Sent v3 — mapping send calls, status vocabularies, webhook signature schemes, opt-out stores, templates, and tenancy models, then cutting over safely with dual-run and rollback. Use when replacing an incumbent CPaaS provider, translating provider code or webhook handlers to Sent, or planning a phased cutover and its verification gates. +--- + +# Migrate to Sent + +Every migration from a major CPaaS provider hits the same five translation problems. Work them in this order, because the first one silently doubles cost and is invisible in tests. + +## 1. Ordered fallback becomes automatic routing + +Incumbent platforms express cross-channel delivery through different caller-side arrays, failover objects, messaging-service features, or application-level priority configuration. Do not assume those shapes have a direct Sent request-field equivalent. + +**Sent's `channel` array is a broadcast list.** Porting an ordered array produces one message and one charge per recipient-channel pair, which passes tests and multiplies production spend. The correct translation is automatic routing — omit `channel` or send `["sent"]` — which lets the platform select a route and reroute across up to three channel-and-provider pairs on the same `message_id`. Details belong to `sent-routing-strategist`; the migration rule is simply: **never port an ordered channel list.** + +## 2. Status vocabularies do not line up + +Incumbent statuses map onto Sent's, but Sent adds two states that have no equivalent and that break naive retry logic. + +| Sent status | Closest incumbent analogue | Migration note | +| --- | --- | --- | +| `QUEUED` | Twilio `queued`, Sinch `QUEUED_ON_CHANNEL` | Accepted, not sent | +| `ROUTED` | no analogue | Route chosen; fires again on reroute | +| `SENT` | Twilio `sent`, Sinch `MESSAGE_SUBMIT` | Provider handoff only | +| `DELIVERED` | `delivered` everywhere | The first proof of handset receipt | +| `READ` | Twilio `read`, Sinch `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | May still reroute; not necessarily final | +| `FILTERED` | Twilio error 21610 (opt-out) | **Policy gate. Never retry** | +| `BLOCKED` | account-level errors | **Account precondition.** Fix the account, then resend | +| `SCHEDULED` | no analogue | Quiet-hours parking; resumes automatically | + +Two consequences for ported code. Handlers that treat every non-delivered terminal state as retryable will retry consent blocks, which is a compliance failure rather than a bug. And handlers keyed on numeric provider error codes — Twilio's `21610` is the classic — must be rewritten against Sent's string `error.code` families. + +## 3. Webhook verification is a rewrite, not a port + +No two providers sign the same way, and no Sent SDK ships a verifier. + +| Provider | Scheme | +| --- | --- | +| Twilio | `X-Twilio-Signature`, base64 HMAC-**SHA1** over the full URL plus sorted POST parameters | +| Sinch | HMAC-SHA256 over `body.nonce.timestamp`, four `x-sinch-webhook-signature*` headers, or OAuth 2.0 | +| Infobip | Basic, HMAC-SHA256 over the raw body, or OAuth on a notification profile; **the header name is account-configured** | +| Vonage | JWT in `Authorization: Bearer`, or a legacy `sig` parameter | +| MessageBird/Bird | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash | +| **Sent** | `x-webhook-signature: v1,{base64}`, HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}` | + +Sent's key is the signing secret with `whsec_` stripped and the remainder base64-decoded, compared in constant time, with timestamps outside 300 seconds rejected. Because Sent provides no per-event id, dedupe keys must be derived from payload semantics. Build the receiver with `sent-webhook-engineer` rather than adapting the incumbent's verifier. + +## 4. Opt-out stores must be reconciled, not migrated by copy + +Every provider keeps its own suppression list — Twilio Advanced Opt-Out, Infobip Blocklist, Sinch OPT_IN/OPT_OUT events. Sent enforces consent at the platform level before events reach the application, stores it as `opt_out` on the contact, and applies it **channel-agnostically**: a `STOP` on SMS suppresses WhatsApp and RCS too. + +Reconciliation rules: export the incumbent's suppression list before cutover, treat any opt-out on any incumbent channel as a global Sent opt-out, and never clear `opt_out` to "clean up" migrated data. Sent's ten default keywords are `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END`, `START`, `UNSTOP`, `SUBSCRIBE`, `HELP`, `INFO`, matched only when the entire trimmed body equals the keyword — so incumbent-specific keywords need custom keyword entries. Rewrite any incumbent keyword matcher as an exact local consent mirror and audit mechanism; the matcher must not write consent to Sent again. Consent semantics belong to `sent-two-way-messaging`. + +## 5. Templates and tenancy are re-registered, not transferred + +WhatsApp templates live with the WABA, so the migration question is whether the WABA moves. Positional placeholders (`{{1}}`, `{{2}}`) become **named** parameters in Sent, which means every call site that passed an ordered array must pass a named map. Approval is asynchronous and arrives as a `templates` webhook event, so build the template inventory before cutover rather than during it. + +Tenancy maps as follows, with the boundary decision owned by `sender-profile-architect` and the API work by `sent-profile-provisioning`: + +| Incumbent construct | Sent equivalent | +| --- | --- | +| Twilio subaccount | Sender Profile | +| Twilio Messaging Service | routing plus profile configuration, not a caller-side pool | +| Infobip Application or Entity | Sender Profile | +| Sinch Conversation API app | Sender Profile | +| Provider API credential per tenant | Profile-scoped API key, or organization key with `x-profile-id` | + +## Migration sequence + +1. **Inventory** every send call site, webhook handler, status branch, template, suppression list, and credential. Use `scripts/inventory_scan.py` to find them mechanically. +2. **Map** each item using [references/provider-mapping.md](references/provider-mapping.md), flagging ordered-fallback arrays and numeric error codes as required rewrites. +3. **Stand up Sent in parallel**: credentials, one webhook per environment, verified receiver, templates re-registered and approved. +4. **Prove equivalence in sandbox** with `"sandbox": true`, then with a small live cohort confirmed to `DELIVERED`. +5. **Dual-run** with a traffic split, comparing delivery rates, latency, and cost per message on the same message classes. +6. **Cut over** by message class — lowest-risk transactional first, marketing last — keeping the incumbent receiver live. +7. **Decommission** only after a full billing cycle of clean data, then revoke incumbent credentials. + +Sequencing detail, verification gates, and rollback triggers are in [references/cutover-playbook.md](references/cutover-playbook.md). + +## Mistakes that survive testing + +- Porting an ordered channel array. Doubles cost, never errors. +- Treating `FILTERED` as retryable. Compliance exposure. +- Reusing the incumbent's signature verifier. Every delivery returns 401. +- Assuming `202` means delivered. Sent acknowledges acceptance only. +- Keeping positional template placeholders. Parameters silently mismatch. +- Retrying on `401`. Ten consecutive auth failures lock the credential with escalating lockout. +- Omitting `Idempotency-Key` during dual-run. A timeout retry sends twice. +- Sending `x-profile-id` with a profile-scoped key. Returns `403`. +- Copying an incumbent's `Authorization: Bearer` pattern. Sent authenticates with `x-api-key`. + +## Boundaries + +This skill owns provider mapping and line-by-line migration planning. Hand the resulting Sent client and resilience work to `sent-integration-starter`, channel semantics to `sent-routing-strategist`, receiver construction to `sent-webhook-engineer`, WhatsApp onboarding to `waba-embedded-signup`, and US campaign registration to `sms-10dlc-registration`. diff --git a/plugins/sent/skills/migrate-to-sent/agents/openai.yaml b/plugins/sent/skills/migrate-to-sent/agents/openai.yaml new file mode 100644 index 0000000..d068d06 --- /dev/null +++ b/plugins/sent/skills/migrate-to-sent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate to Sent" + short_description: "Move from another CPaaS provider to Sent" + default_prompt: "Use $migrate-to-sent to plan our move from our current messaging provider to Sent." diff --git a/plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md b/plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md new file mode 100644 index 0000000..7908759 --- /dev/null +++ b/plugins/sent/skills/migrate-to-sent/references/cutover-playbook.md @@ -0,0 +1,151 @@ +# Cutover playbook + +## Table of contents + +- [Phase overview](#phase-overview) +- [Phase 1: inventory](#phase-1-inventory) +- [Phase 2: mapping and rewrite list](#phase-2-mapping-and-rewrite-list) +- [Phase 3: parallel build](#phase-3-parallel-build) +- [Phase 4: sandbox and cohort verification](#phase-4-sandbox-and-cohort-verification) +- [Phase 5: dual-run](#phase-5-dual-run) +- [Phase 6: staged cutover](#phase-6-staged-cutover) +- [Phase 7: decommission](#phase-7-decommission) +- [Rollback triggers](#rollback-triggers) +- [Comparison metrics](#comparison-metrics) +- [Data migration rules](#data-migration-rules) + +## Phase overview + +| Phase | Exit gate | +| --- | --- | +| 1. Inventory | Every send call site, handler, template, and credential is listed | +| 2. Mapping | Each item has a target and a rewrite classification | +| 3. Parallel build | Sent sends and receives in a lower environment | +| 4. Verification | Sandbox parity plus a live cohort reaching `DELIVERED` | +| 5. Dual-run | Delivery, latency, and cost within agreed tolerances | +| 6. Cutover | All message classes on Sent, incumbent idle but reversible | +| 7. Decommission | One clean billing cycle, then credentials revoked | + +Do not compress phases 4 and 5. The failure modes that matter — broadcast duplication, mishandled `FILTERED`, and dropped webhook events — appear under real traffic and correct cost accounting, not in tests. + +## Phase 1: inventory + +Enumerate mechanically rather than from memory: + +- send call sites, including background jobs, cron tasks, and admin tools; +- webhook handlers and their signature verification code; +- every branch on a provider status string or numeric error code; +- template inventory with variable style and approval state; +- suppression and opt-out storage; +- credentials per environment and per tenant; +- observability: dashboards, alerts, and log fields that reference provider identifiers. + +`scripts/inventory_scan.py` scans a repository for provider SDK imports, endpoint URLs, signature header names, status strings, numeric error codes, and ordered channel arrays, and reports each with a migration classification. Treat its output as the starting checklist, not the whole picture — configuration-driven senders and no-code automations will not appear in source. + +## Phase 2: mapping and rewrite list + +Classify every inventory item: + +| Classification | Meaning | Examples | +| --- | --- | --- | +| Direct map | Same concept, different syntax | Recipient, message body, delivery status branch | +| Rewrite | Concept exists but works differently | Fallback, templates, tenancy, consent checks | +| New code | No equivalent exists | Webhook signature verification, idempotency keys | +| Delete | The incumbent workaround is unnecessary | Provider-specific retry ladders that conflict with Sent's error contract | +| Review | Logic remains useful but changes responsibility | Keyword matchers become exact local consent mirrors and must not write consent to Sent again | + +The rewrite list is the real project plan. Prioritize by blast radius: ordered fallback arrays first, then status and error branching, then the receiver, then templates. + +## Phase 3: parallel build + +Stand Sent up beside the incumbent without removing anything: + +1. Create credentials per environment, and profile-scoped keys for runtime send paths. +2. Provision Sender Profiles for tenancy, with inheritance and sharing flags set deliberately. +3. Register one webhook per environment; never share an endpoint across environments, because auto-disable is per endpoint. +4. Build the receiver as new code with its own signature tests. +5. Re-register templates and wait for approval events; approval is asynchronous and gates go-live. +6. Add `Idempotency-Key` to every mutating call, derived from your own domain objects. +7. Introduce a feature flag or router that chooses provider per message class and tenant. + +## Phase 4: sandbox and cohort verification + +Sandbox first: `"sandbox": true` authenticates and validates without executing, so payload shape and credential wiring can be proven in continuous integration. Remember it does not perform resource lookups, so it cannot confirm a template id exists. + +Then a live cohort — internal staff or a small opt-in group. Gates: + +- a send returns `202` and every `message_id` is persisted with tenant, profile, and logical send id; +- events arrive, verify, and deduplicate; +- a message reaches `DELIVERED` and the application state reflects it; +- a deliberately induced failure produces the expected terminal state without a retry storm; +- a suppressed contact produces `FILTERED` and no retry; +- a template send renders correctly with named parameters; +- an inbound `STOP` updates the exact-match local consent mirror without writing consent to Sent again. + +## Phase 5: dual-run + +Split traffic by tenant or message class and compare like with like. Both providers must carry comparable message mixes, since transactional and marketing traffic have different delivery profiles. + +Requirements during dual-run: one application operation ledger that assigns each logical send to exactly one provider, Sent idempotency keys on every Sent mutation, equivalent incumbent safeguards where available, a single source of truth for delivery state per provider message id, and cost accounting that counts messages rather than requests. Never send the same user-facing notification through both providers merely to compare them. + +Run long enough to cover a weekly seasonality cycle and at least one deliberate failure drill. + +## Phase 6: staged cutover + +Order by risk, lowest first: + +1. internal and staff notifications; +2. low-volume transactional messages such as receipts; +3. high-volume transactional messages such as OTP and delivery alerts; +4. conversational and support traffic; +5. marketing and campaign traffic. + +Keep the incumbent receiver live throughout, because in-flight messages sent before the switch still deliver status callbacks there. Cut per message class and per tenant, and hold each step long enough to see a full daily cycle. + +## Phase 7: decommission + +Decommission only after one full billing cycle of clean Sent data. Then, in order: disable incumbent send paths in code, remove the router flag, revoke incumbent credentials, archive incumbent delivery and consent records for the applicable retention period, and remove incumbent-specific dashboards and alerts after confirming Sent equivalents exist. + +Retain the exported incumbent suppression list permanently as consent evidence, independent of the platform that now enforces it. + +## Rollback triggers + +Define these before dual-run so the decision is not made under pressure: + +| Trigger | Action | +| --- | --- | +| Delivery rate falls below the agreed tolerance for a message class | Roll that class back | +| Duplicate messages observed in production | Halt immediately; check for ordered channel arrays and missing idempotency keys | +| Webhook `consecutive_failures` rising toward auto-disable | Roll back the receiver, fix, re-enable in the dashboard | +| `FILTERED` volume above baseline | Halt; the local suppression mirror is likely stale | +| Cost per delivered message above the agreed ceiling | Halt; verify channel arrays and message counts | +| Auth lockout on a credential | Stop all retries; ten consecutive failures escalate the lockout | + +Rollback is a router flag flip per message class, which is why the router must remain in place until decommissioning. + +## Comparison metrics + +| Metric | Definition | Why it matters | +| --- | --- | --- | +| Delivery rate | `DELIVERED` divided by accepted, per message class | The primary quality signal | +| Filtered rate | `FILTERED` divided by accepted | Consent-mirror drift | +| Blocked rate | `BLOCKED` divided by accepted | Balance and account preconditions | +| Reroute rate | Messages with more than one attempted route | Routing health | +| Time to delivered | `DELIVERED` timestamp minus accepted timestamp | Latency comparison | +| Messages per logical send | Messages created divided by intended sends | Catches broadcast duplication | +| Cost per delivered message | Spend divided by `DELIVERED` count | The number finance will ask for | + +Messages per logical send should be exactly one for single-channel intent. Anything above one means a multi-channel array is in play. + +## Data migration rules + +| Data | Rule | +| --- | --- | +| Contacts | Re-create in Sent with E.164 numbers; contacts are validated endpoints, not a copied CRM table | +| Suppression list | Export before cutover; treat any incumbent opt-out as a global Sent opt-out | +| Templates | Re-register and await approval; convert positional variables to named parameters | +| Message history | Leave in the incumbent, archived; do not attempt to import history | +| Credentials | Create fresh in Sent; never reuse incumbent secrets | +| Webhook secrets | New per environment; store keyed by webhook id | + +Never clear `opt_out` during data migration. A migrated opt-out is consent evidence, and clearing it to reconcile row counts is a compliance failure rather than a data cleanup. diff --git a/plugins/sent/skills/migrate-to-sent/references/provider-mapping.md b/plugins/sent/skills/migrate-to-sent/references/provider-mapping.md new file mode 100644 index 0000000..1d9a65a --- /dev/null +++ b/plugins/sent/skills/migrate-to-sent/references/provider-mapping.md @@ -0,0 +1,158 @@ +# Provider-to-Sent mapping reference + +## Table of contents + +- [How to use this reference](#how-to-use-this-reference) +- [Sent target contract](#sent-target-contract) +- [Twilio](#twilio) +- [Sinch](#sinch) +- [Infobip](#infobip) +- [Vonage](#vonage) +- [MessageBird and Bird](#messagebird-and-bird) +- [Cross-provider concept table](#cross-provider-concept-table) +- [Status mapping](#status-mapping) +- [Webhook signature comparison](#webhook-signature-comparison) +- [Error handling translation](#error-handling-translation) + +## How to use this reference + +Read the Sent target contract first, then only the section for the incumbent. Each provider section lists the send call, the fallback construct, the webhook scheme, the suppression store, the tenancy construct, and the specific rewrites that testing will not catch. + +Verify any header name or field against the incumbent's current documentation before writing customer-facing text; several of these platforms have renamed products and headers. + +## Sent target contract + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345", "eta": "Friday" } + }, + "sandbox": false +} +``` + +`POST /v3/messages` returns `202` with `data.recipients[]`, each carrying a `message_id`. Only `to` is required; supply `template` or `text`; omit `channel` for automatic routing. Authentication is `x-api-key`, optionally with `x-profile-id` on an organization key. Template parameters are a **named** map, not positional. + +For automatic routing the echoed per-recipient channel is not a resolved route and is never updated later. Read the real route from `message.routed`, from `GET /v3/messages/{id}` after routing, or from `GET /v3/messages/{id}/activities`. + +## Twilio + +| Concept | Twilio | Sent | +| --- | --- | --- | +| Send | `POST /2010-04-01/Accounts/{sid}/Messages.json`, form-encoded | `POST /v3/messages`, JSON | +| Auth | Basic auth with Account SID and auth token | `x-api-key` header | +| Recipient | `To`, with a `whatsapp:` prefix for WhatsApp | `to` array plus the `channel` array | +| Sender | `From` or `MessagingServiceSid` | Profile configuration and routing | +| Content | `Body`, or `ContentSid` with positional variables | `text`, or `template` with named parameters | +| Fallback | Messaging Service features such as Fallback to Long Code | Automatic routing with reroute | +| Status callback | `StatusCallback` per message | Account-level webhook subscription | +| Suppression | Advanced Opt-Out | Platform consent with `opt_out` on the contact | +| Tenancy | Subaccount | Sender Profile | +| Idempotency | Not offered on message create | `Idempotency-Key` header | + +Rewrites that testing will not catch: positional `{{1}}` variables become named parameters; numeric error codes such as `21610` for an opted-out recipient become string `error.code` families, and that particular case does not even fail the request — the send is accepted and the message finalizes as `FILTERED`; a per-message `StatusCallback` URL has no equivalent, so status routing moves into the subscription's `event_filters`. + +Use this skill for line-by-line translation, then use `sent-integration-starter` to harden the resulting client lifecycle, retries, and observability. + +## Sinch + +| Concept | Sinch Conversation API | Sent | +| --- | --- | --- | +| Send | `POST /v1/projects/{id}/messages:send` with a channel-priority order | `POST /v3/messages` with automatic routing | +| App model | Conversation API app with configured channels | Sender Profile | +| Webhooks | Up to five per app, created with `triggers[]` | Account-level subscription with `event_types` and `event_filters` | +| Callback auth | OAuth 2.0, or HMAC-SHA256 over `body.nonce.timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Consent | `OPT_IN` and `OPT_OUT` trigger events | Platform-enforced consent, applied before the event | +| Delivery reports | Typically three callbacks per message | One event per transition, plus repeats on reroute | + +Sinch's trigger catalog is far broader than Sent's two event families, so a handler switching over twenty-plus trigger types collapses to branching on `field` and `event`. Sinch's channel-priority ordering is the ordered-fallback pattern that must become automatic routing. + +## Infobip + +| Concept | Infobip | Sent | +| --- | --- | --- | +| Send | Channel-specific endpoints, or Conversations | `POST /v3/messages` | +| Sender strategy | Resource Associations and Sending Strategies such as sticky sender | Platform routing rules | +| Webhooks | Subscription plus a notification profile | Webhook registration | +| Webhook auth | Basic, HMAC-SHA256 over the raw body, or OAuth, optionally mTLS; **header name is account-configured** | Fixed `x-webhook-signature` scheme | +| Suppression | Blocklist, also called Do Not Contact | `opt_out` on the contact | +| Tenancy | Applications and Entities | Sender Profiles | + +Because Infobip's signing header name comes from account settings, an existing verifier is not portable and its header constant must not be reused. Sending Strategies have no caller-side equivalent: sticky-sender behavior is a platform routing concern in Sent, not a request parameter. + +## Vonage + +| Concept | Vonage Messages API v1 | Sent | +| --- | --- | --- | +| Send | Per-channel body with `message_type`, `channel`, `to`, `from` | Uniform `POST /v3/messages` | +| Fallback | `failover` array of complete alternative messages | Automatic routing with reroute | +| Webhook auth | JWT in `Authorization: Bearer`, or legacy `sig` parameter | HMAC signature headers | +| Credentials | Application id with a private key | API key | + +Vonage's `failover` array is the most explicit ordered-fallback construct of the five, and it is the one most often ported directly into Sent's `channel` array. It must not be. Note also that Vonage's JWT bearer pattern tempts engineers to authenticate to Sent with `Authorization: Bearer`; Sent uses `x-api-key`. + +## MessageBird and Bird + +| Concept | MessageBird/Bird | Sent | +| --- | --- | --- | +| Send | Conversations API, or channel APIs | `POST /v3/messages` | +| Fallback | Explicit `fallback` object naming a secondary channel | Automatic routing with reroute | +| Webhook auth | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash, with `messagebird-request-timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Suppression | Platform suppression list | `opt_out` on the contact | + +MessageBird's signature covers a hash of the body rather than the body itself, so a verifier ported to Sent will fail every delivery even though both use HMAC-SHA256. Treat the legacy MessageBird header names as legacy and confirm current Bird names before asserting them. + +## Cross-provider concept table + +| Concept | Twilio | Sinch | Infobip | Vonage | Bird | Sent | +| --- | --- | --- | --- | --- | --- | --- | +| Ordered fallback | Messaging Service features | channel priority | sending strategies | `failover` array | `fallback` object | **automatic routing only** | +| Tenancy | subaccount | Conversation app | Application/Entity | application | workspace | Sender Profile | +| Template variables | positional | per-channel | per-channel | per-channel | per-channel | **named map** | +| Consent store | Advanced Opt-Out | OPT_IN/OPT_OUT events | Blocklist | application-side | suppression list | `opt_out`, channel-agnostic | +| Idempotency | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | `Idempotency-Key` | +| Webhook scope | per message or service | per app, up to five | per subscription | per application | per workspace | per account, filtered | + +## Status mapping + +| Sent | Twilio | Sinch | Note | +| --- | --- | --- | --- | +| `QUEUED` | `queued`, `accepted` | `QUEUED_ON_CHANNEL` | Accepted only | +| `ROUTED` | — | — | No incumbent analogue; repeats on reroute | +| `SENT` | `sent` | `MESSAGE_SUBMIT` | Provider handoff | +| `DELIVERED` | `delivered` | `DELIVERED` | Handset confirmation | +| `READ` | `read` | `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | `FAILURE` | May reroute; not necessarily final | +| `FILTERED` | error 21610 behavior | opt-out enforcement | Policy gate; never retry | +| `BLOCKED` | account errors | account errors | Account precondition | +| `SCHEDULED` | — | — | Quiet-hours parking | + +The two states with no analogue, `FILTERED` and `BLOCKED`, are exactly the two that ported retry logic mishandles. + +## Webhook signature comparison + +| Provider | Algorithm | Signed content | Header | +| --- | --- | --- | --- | +| Twilio | HMAC-SHA1 | full URL plus sorted POST parameters | `X-Twilio-Signature` | +| Sinch | HMAC-SHA256 | `body.nonce.timestamp` | `x-sinch-webhook-signature` plus nonce, timestamp, algorithm | +| Infobip | HMAC-SHA256 | raw body | account-configured | +| Vonage | JWT, or MD5/HMAC over sorted parameters | token claims, or parameters | `Authorization`, or `sig` | +| Bird | HMAC-SHA256 | timestamp, URL, SHA-256 of body | `messagebird-signature` | +| **Sent** | HMAC-SHA256 | `{webhook_id}.{timestamp}.{raw_body}` | `x-webhook-signature` as `v1,{base64}` | + +Among the compared schemes, Sent includes the endpoint id in the signed content and expects a `v1,` version prefix. No listed provider's verifier is reusable as-is, and no Sent SDK ships one, so plan the receiver as new code with its own tests. + +## Error handling translation + +| Incumbent pattern | Sent replacement | +| --- | --- | +| Numeric error codes in a switch statement | String `error.code` with prefix families | +| Retry on any non-delivered status | Retry only `429`, `5xx`, `SERVICE_001`, and `CONFLICT_001` once | +| Opt-out surfaced as a send error | Send accepted with `202`; message finalizes as `FILTERED` | +| Insufficient balance surfaced as a send error | Send accepted with `202`; message finalizes as `BLOCKED` | +| Per-request quota headers | Headers only on `429`; pace by design | +| Provider-side deduplication assumptions | Supply a deterministic `Idempotency-Key` | + +Consent and balance problems moving out of the error path and into delivery data is the structural change that most often surprises a migrating team: monitor filtered and blocked rates as first-class metrics. diff --git a/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py new file mode 100644 index 0000000..253ba90 --- /dev/null +++ b/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Scan a repository for incumbent CPaaS usage that a Sent migration must address. + +Reports each finding with a migration classification so the output can be used +directly as the phase-2 rewrite list. + +Usage +----- + python3 inventory_scan.py --self-test + python3 inventory_scan.py --path /path/to/repo + python3 inventory_scan.py --path /path/to/repo --format json + +Exit codes: 0 no findings, 1 findings reported, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, asdict + +EXIT_CLEAN = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + +SKIP_DIRS = { + ".git", "node_modules", "vendor", "dist", "build", "target", ".venv", "venv", + "__pycache__", ".next", ".gradle", ".idea", ".mypy_cache", ".pytest_cache", "coverage", +} +SCAN_EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".java", ".kt", ".cs", ".php", ".rb", + ".yml", ".yaml", ".json", ".tf", ".sh", ".md", +} +MAX_FILE_BYTES = 2_000_000 + + +@dataclass(frozen=True) +class Rule: + rule_id: str + provider: str + pattern: str + classification: str + guidance: str + + +RULES: tuple[Rule, ...] = ( + Rule( + "ordered-channel-array", "sent", + r"""['\"]channel['\"]\s*:\s*\[\s*['\"](?:sms|whatsapp|rcs)['\"]\s*,\s*['\"](?:sms|whatsapp|rcs)['\"]""", + "rewrite", + "Multiple explicit channels broadcast rather than fall back. Omit 'channel' or use ['sent'].", + ), + Rule( + "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", + "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + ), + Rule( + "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", + "rewrite", "Replace with POST /v3/messages using the flat JSON body.", + ), + Rule( + "twilio-signature", "twilio", r"X-Twilio-Signature|validateRequest|RequestValidator", + "new_code", "Twilio signs HMAC-SHA1 over URL plus sorted params. Sent needs a new verifier.", + ), + Rule( + "twilio-messaging-service", "twilio", r"MessagingServiceSid|messaging_service_sid", + "rewrite", "Sender pools and sticky sender are platform routing concerns in Sent, not request fields.", + ), + Rule( + "twilio-optout-code", "twilio", r"\b21610\b", + "rewrite", "Opted-out sends are accepted with 202 and finalize as FILTERED, not as a numeric error.", + ), + Rule( + "twilio-channel-prefix", "twilio", r"['\"]whatsapp:\+?", + "rewrite", "Channel is selected by the 'channel' array in Sent, not by a recipient prefix.", + ), + Rule( + "sinch-conversation", "sinch", r"conversation\.api\.sinch\.com|messages:send|sinch-webhook-signature", + "rewrite", "Replace channel-priority ordering with Sent automatic routing.", + ), + Rule( + "infobip-endpoint", "infobip", r"[a-z0-9-]+\.api\.infobip\.com|infobip-api", + "rewrite", "Replace with POST /v3/messages; Sending Strategies have no caller-side equivalent.", + ), + Rule( + "infobip-blocklist", "infobip", r"blocklist|do-not-contact|dnc", + "rewrite", "Reconcile into Sent consent as a channel-agnostic opt_out on the contact.", + ), + Rule( + "vonage-failover", "vonage", r"['\"]failover['\"]\s*[:=]\s*\[", + "rewrite", "An ordered failover array must become Sent automatic routing.", + ), + Rule( + "vonage-sdk", "vonage", r"\b(?:@vonage/|nexmo|vonage-)\b", + "rewrite", "Replace the Vonage client; Sent authenticates with x-api-key, not a JWT bearer.", + ), + Rule( + "bird-fallback", "bird", r"messagebird|bird-signature|messagebird-signature", + "rewrite", "MessageBird signs over a body hash; the verifier is not portable to Sent.", + ), + Rule( + "generic-fallback-object", "any", r"['\"](?:fallback|failover)['\"]\s*[:=]", + "rewrite", "Caller-supplied fallback has no Sent equivalent; automatic routing performs it.", + ), + Rule( + "positional-template-var", "any", r"\{\{\s*[1-9][0-9]?\s*\}\}", + "rewrite", "Sent template parameters are a named map, not positional placeholders.", + ), + Rule( + "bearer-auth-to-provider", "any", r"Authorization['\"]?\s*[:=]\s*['\"]?Bearer\s", + "review", "Direct Sent REST calls use x-api-key; keep Bearer where an app proxy, MCP OAuth flow, or incumbent still requires it.", + ), + Rule( + "provider-status-branch", "any", + r"['\"](?:undelivered|accepted|sending)['\"]", + "rewrite", "Map incumbent status strings onto Sent statuses, adding FILTERED, BLOCKED, and SCHEDULED.", + ), + Rule( + "application-keyword-matcher", "any", + r"(?:==|===|\.equals\(|\.includes\(|\bin\s)\s*['\"](?:STOP|UNSUBSCRIBE|CANCEL|UNSTOP)['\"]", + "review", "Keep exact matching only to mirror local consent evidence; do not write consent to Sent a second time.", + ), + Rule( + "idempotency-key-present", "sent", r"Idempotency-Key", + "informational", "Idempotency key usage found; confirm keys are deterministic rather than random.", + ), +) + +COMPILED = tuple((rule, re.compile(rule.pattern, re.IGNORECASE)) for rule in RULES) + + +@dataclass +class Finding: + path: str + line: int + rule_id: str + provider: str + classification: str + guidance: str + excerpt: str + + +def redact_excerpt(line: str) -> str: + """Mask credential-like literals before reporting a matched source line.""" + line = re.sub( + r"(?i)(authorization[^\n]{0,24}bearer\s+)([^\s'\",;}]+)", + r"\1", + line, + ) + line = re.sub( + r"(?i)\b(api[_-]?key|auth[_-]?token|access[_-]?token|secret|password)(\s*[:=]\s*)([^\s,;}]+)", + r"\1\2", + line, + ) + return line + + +def scan_text(text: str, path: str = "") -> list[Finding]: + """Scan a blob of text and return findings.""" + findings: list[Finding] = [] + for number, line in enumerate(text.splitlines(), start=1): + if len(line) > 2000: + line = line[:2000] + for rule, regex in COMPILED: + if regex.search(line): + findings.append( + Finding( + path=path, + line=number, + rule_id=rule.rule_id, + provider=rule.provider, + classification=rule.classification, + guidance=rule.guidance, + excerpt=redact_excerpt(line.strip())[:200], + ) + ) + return findings + + +def scan_path(root: str) -> list[Finding]: + """Walk a directory tree and scan eligible files.""" + findings: list[Finding] = [] + for directory, subdirs, files in os.walk(root): + subdirs[:] = [name for name in subdirs if name not in SKIP_DIRS and not name.startswith(".")] + for filename in files: + if filename.startswith(".env"): + continue + extension = os.path.splitext(filename)[1].lower() + if extension not in SCAN_EXTENSIONS: + continue + full = os.path.join(directory, filename) + try: + if os.path.getsize(full) > MAX_FILE_BYTES: + continue + with open(full, encoding="utf-8", errors="replace") as handle: + text = handle.read() + except OSError: + continue + findings.extend(scan_text(text, os.path.relpath(full, root))) + return findings + + +def summarize(findings: list[Finding]) -> dict[str, int]: + """Count findings per classification.""" + counts: dict[str, int] = {} + for finding in findings: + counts[finding.classification] = counts.get(finding.classification, 0) + 1 + return counts + + +def _self_test() -> int: + failures: list[str] = [] + + sample = """ + const client = require('twilio')(sid, token); + await client.messages.create({ to, from, body }); + if (status === 'undelivered') retry(); + if (error.code === 21610) suppress(); + payload = {"channel": ["whatsapp", "sms"], "to": ["+14155551234"]} + body = {"failover": [{"channel": "sms"}]} + template = "Hello {{1}}, your order {{2}} shipped" + headers = {"Authorization": "Bearer " + token} + if (text.trim().toUpperCase() === 'STOP') { optOut(); } + """ + found = {finding.rule_id for finding in scan_text(sample)} + expected = { + "twilio-sdk", + "provider-status-branch", + "twilio-optout-code", + "ordered-channel-array", + "vonage-failover", + "generic-fallback-object", + "positional-template-var", + "bearer-auth-to-provider", + "application-keyword-matcher", + } + missing = expected - found + if missing: + failures.append(f"rules failed to fire: {sorted(missing)}") + + clean = 'payload = {"to": ["+14155551234"], "template": {"name": "t", "parameters": {"a": "b"}}}\n' + if scan_text(clean): + failures.append("a correct Sent payload must produce no findings") + + single = 'payload = {"channel": ["sms"]}\n' + if any(f.rule_id == "ordered-channel-array" for f in scan_text(single)): + failures.append("a single-channel array must not be flagged as broadcast") + + counts = summarize(scan_text(sample)) + if counts.get("rewrite", 0) < 5 or counts.get("review", 0) != 2: + failures.append(f"classification summary drifted: {counts}") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("inventory_scan self-test passed: 4 checks") + return EXIT_CLEAN + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Scan a repository for incumbent CPaaS usage.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--path", help="repository root to scan") + parser.add_argument("--format", choices=("text", "json"), default="text", help="output format") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + if not args.path: + parser.error("provide --path or --self-test") + if not os.path.isdir(args.path): + print(f"error: {args.path} is not a directory", file=sys.stderr) + return EXIT_USAGE + + findings = scan_path(args.path) + + if args.format == "json": + print(json.dumps({"summary": summarize(findings), "findings": [asdict(f) for f in findings]}, indent=2)) + else: + if not findings: + print("no incumbent CPaaS usage detected") + for finding in findings: + print(f"{finding.path}:{finding.line} [{finding.classification}/{finding.provider}] {finding.rule_id}") + print(f" {finding.excerpt}") + print(f" -> {finding.guidance}") + if findings: + print("\nsummary: " + ", ".join(f"{k}={v}" for k, v in sorted(summarize(findings).items()))) + + return EXIT_FINDINGS if findings else EXIT_CLEAN + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/sent/skills/sent-integration-starter/SKILL.md b/plugins/sent/skills/sent-integration-starter/SKILL.md new file mode 100644 index 0000000..1f16fb3 --- /dev/null +++ b/plugins/sent/skills/sent-integration-starter/SKILL.md @@ -0,0 +1,108 @@ +--- +name: sent-integration-starter +description: Stands up a production-ready Sent v3 integration in an existing codebase — SDK selection and client construction, x-api-key configuration, idempotent sends, retry and rate-limit handling, the 46-code error catalog, sandbox verification, and a verified webhook receiver. Use when adding Sent to an app for the first time, choosing an SDK or framework wiring, handling 429 or 409 responses, deciding what to log, or hardening an integration before launch. +--- + +# Sent Integration Starter + +Bring up a Sent integration in four stages: authenticate, send idempotently, receive verified events, then harden. Do not conflate them — most broken integrations pass stage one and skip stage three. + +## Stage 1: client and credentials + +Direct Sent v3 REST requests authenticate with the `x-api-key` header. An application proxy may accept `Authorization: Bearer` from its own callers, and the Sent MCP server uses client-managed OAuth, but neither changes the REST header sent to `api.sent.dm`. Organization keys may add `x-profile-id` to act for a child profile; a profile-scoped key that sends that header receives `403`. + +| Language | Package | Client | +| --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `new SentDm()` | +| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | +| C# | `Sentdm` | `new SentClient()` | +| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | +| Ruby | `sentdm` | `Sentdm::Client.new` | + +Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. + +Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). + +Validate configuration at boot and fail fast when the key is missing, rather than surfacing an auth error on the first customer send. + +## Stage 2: idempotent sends + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345" } + }, + "sandbox": true +} +``` + +`to` is the only required field. Supply `template` or `text`, and omit `channel` to let automatic routing choose. Never write a `channel` array with several values expecting fallback — that broadcasts and multiplies charges. Channel decisions belong to `sent-routing-strategist`. + +Send `Idempotency-Key` on every POST, PUT, and PATCH, derived deterministically from your own domain object (for example the order id plus the notification type) so a retry after a timeout cannot double-send. Keys are 1–255 characters of `[A-Za-z0-9_-]`, cached 24 hours per key per customer. A replay returns the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`. A duplicate arriving while the original is still in flight waits up to five seconds and then fails `409 CONFLICT_001`; a `503 SERVICE_001` means the idempotency store was unavailable and the request was deliberately not executed. + +`202` means accepted, not delivered. Persist the returned `message_id` values immediately with your own tenant, profile, and logical send identifiers. Webhook events carry the Sent message id and account data, but never your application's tenant identifier. + +## Stage 3: verified webhook receiver + +An integration without a receiver has no delivery truth. Register an endpoint, then verify every delivery: HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, keyed on the base64-decoded secret after stripping `whsec_`, compared in constant time, rejecting timestamps outside 300 seconds. No SDK ships a verifier in any language. + +Acknowledge with `200` before doing work, and deduplicate on `{message_id}:{message_status}` for outbound events and `message_id` for inbound. Ten consecutive failed deliveries disable the endpoint. Full mechanics belong to `sent-webhook-engineer`; treat a verified, fast-acknowledging, deduplicating receiver as a launch requirement here. + +## Stage 4: harden + +### Retry policy by response class + +| Response | Retry | How | +| --- | --- | --- | +| `2xx` | No | Success | +| `400`, `422` `VALIDATION_*` | No | Fix the request | +| `401`, `403` `AUTH_*` | No | Stop immediately; ten consecutive auth failures lock the credential with escalating lockouts | +| `404` `RESOURCE_*` | No | The referenced object does not exist | +| `409 CONFLICT_001` | Yes, once, after a pause | A concurrent duplicate is in flight | +| `429` | Yes | Honor `Retry-After`; jittered backoff | +| `5xx`, `503 SERVICE_001` | Yes | Exponential backoff with jitter and a ceiling | +| Timeout with no response | Retry safely only with evidence | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient, so do not automate a resend | + +The standard limit is 200 requests per minute on a sliding window. `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` are limited to 10 per minute. Rate-limit headers appear **only** on `429` responses, so pacing must be designed rather than measured — batch up to 1,000 recipients per request and pace at roughly one request per second for bulk work. + +### Error handling + +Errors arrive as `{success, data, error: {code, message, details, doc_url}, meta: {request_id, timestamp, version}}`. Branch on the `error.code` prefix family (`AUTH_`, `VALIDATION_`, `RESOURCE_`, `BUSINESS_`, `CONFLICT_`, `SERVICE_`, `INTERNAL_`) rather than on message text or on individual codes. The full 46-code catalog with retry classification is in [references/errors-and-limits.md](references/errors-and-limits.md). + +Two codes are counterintuitive: `BUSINESS_003` and `BUSINESS_004` are documented as request-level errors, but on `POST /v3/messages` the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED`. Insufficient balance therefore does not fail the send call. + +### Observability + +Log `meta.request_id` on every response, success or failure — it is the correlation handle for support. Record the mapping from your logical send to the returned `message_id` values, and keep an append-only event history so a reroute's sequence remains auditable. Never log the API key, the webhook signing secret, `payment_details`, or raw recipient message content beyond your retention policy. + +### Launch checklist + +- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. +- [ ] `Idempotency-Key` on every mutating call, derived deterministically. +- [ ] Retry policy distinguishes retryable from terminal by error family. +- [ ] Bulk paths pace against 200 requests per minute and batch to at most 1,000 recipients. +- [ ] Webhook receiver verifies signature and timestamp, returns `200` fast, and dedupes. +- [ ] Receiver returns non-2xx on genuine failure so Sent retries. +- [ ] `message_id` to tenant mapping is persisted before sending. +- [ ] `request_id` is logged; secrets and card data are not. +- [ ] Sandbox smoke test passes, then a real send reaches `DELIVERED`. +- [ ] Alerting covers webhook `consecutive_failures`, `429` volume, and filtered or blocked rates. + +## Verification + +Run the local preflight, which needs no credentials and no network: + +```bash +python3 scripts/preflight.py --self-test +``` + +Then verify a real path with `"sandbox": true`, which authenticates and validates without executing, and finally with one live send confirmed to `DELIVERED` through the receiver. + +## Boundaries + +Use `sent-webhook-engineer` for receiver depth, `sent-routing-strategist` for channel choice, `sent-messaging` for a confirmed one-off send, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` for multi-tenant provisioning, and `migrate-to-sent` when replacing another CPaaS provider. diff --git a/plugins/sent/skills/sent-integration-starter/agents/openai.yaml b/plugins/sent/skills/sent-integration-starter/agents/openai.yaml new file mode 100644 index 0000000..4e65bfc --- /dev/null +++ b/plugins/sent/skills/sent-integration-starter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Integration Starter" + short_description: "Stand up a production-ready Sent integration" + default_prompt: "Use $sent-integration-starter to review how my codebase should integrate Sent and what to harden before going live." diff --git a/plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md b/plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md new file mode 100644 index 0000000..55b0d4d --- /dev/null +++ b/plugins/sent/skills/sent-integration-starter/references/errors-and-limits.md @@ -0,0 +1,186 @@ +# Error catalog, retries, idempotency, and rate limits + +## Table of contents + +- [Response envelope](#response-envelope) +- [Retry classification by family](#retry-classification-by-family) +- [AUTH codes](#auth-codes) +- [VALIDATION codes](#validation-codes) +- [RESOURCE codes](#resource-codes) +- [BUSINESS codes](#business-codes) +- [CONFLICT, SERVICE, and INTERNAL codes](#conflict-service-and-internal-codes) +- [Codes that behave differently on send](#codes-that-behave-differently-on-send) +- [Idempotency semantics](#idempotency-semantics) +- [Rate limits and pacing](#rate-limits-and-pacing) +- [Sandbox semantics](#sandbox-semantics) +- [Ambiguous send recovery](#ambiguous-send-recovery) + +## Response envelope + +Every response uses one shape: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_004", + "message": "Request validation failed", + "details": { "to": ["'to' must contain at least one recipient"] }, + "doc_url": "https://docs.sent.dm/reference/api/error-catalog" + }, + "meta": { + "request_id": "req_7X9zKp2jDw", + "timestamp": "2026-03-14T09:21:44Z", + "version": "v3" + } +} +``` + +Branch on `error.code`, never on `error.message`. Read `error.details` for field-level validation feedback and log `meta.request_id` on every response so support can correlate. + +## Retry classification by family + +| Family | Count | Default handling | +| --- | --- | --- | +| `AUTH_` | 6 | Terminal. Stop immediately; do not loop | +| `VALIDATION_` | 8 | Terminal. Fix the request | +| `RESOURCE_` | 14 | Terminal; reconcile `RESOURCE_007` with the existing resource | +| `BUSINESS_` | 11 | Mostly terminal; `BUSINESS_002` backs off | +| `CONFLICT_` | 1 | Retry once after a short pause | +| `SERVICE_` | 1 | Retry with backoff | +| `INTERNAL_` | 5 | Retry with backoff | + +The catalog contains 46 codes in total. Authentication failures deserve special care: ten consecutive failures lock the presented credential with a `429` and escalating lockout windows from one to sixty minutes, so a retry loop against a bad key extends its own outage. Stop and alert instead. + +## AUTH codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `AUTH_001` | 401 | User is not authenticated | never | +| `AUTH_002` | 401 | Invalid or missing API key | never | +| `AUTH_004` | 403 | Insufficient permissions | never | +| `AUTH_005` | 403 | Account not yet activated | never | +| `AUTH_006` | 403 | KYC verification not complete | never | +| `AUTH_007` | 403 | Channel setup not complete | never | + +`AUTH_004` is also what a profile-scoped key receives when it sends `x-profile-id`. `AUTH_005`, `AUTH_006`, and `AUTH_007` are onboarding states rather than credential problems, so surface them to an operator instead of retrying. + +## VALIDATION codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `VALIDATION_001` | 400 | Request validation failed | never | +| `VALIDATION_002` | 400 | Invalid phone number format | never | +| `VALIDATION_003` | 400 | Invalid GUID format | never | +| `VALIDATION_004` | 400 | Required field is missing | never | +| `VALIDATION_005` | 400 | Field value out of valid range | never | +| `VALIDATION_006` | 400 | Invalid enum value | never | +| `VALIDATION_007` | 400 | Invalid Idempotency-Key format | never | +| `VALIDATION_008` | 400 | Invalid template variable value | never | + +`VALIDATION_002` is prevented by normalizing recipients to E.164 before the call. `VALIDATION_006` is what an unsupported `channel` value returns. `VALIDATION_008` covers several distinct template-variable problems, so read the message rather than assuming one cause. + +## RESOURCE codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `RESOURCE_001` | 404 | Contact not found | never | +| `RESOURCE_002` | 404 | Template not found | never | +| `RESOURCE_003` | 404 | Message not found | never | +| `RESOURCE_004` | 404 | Customer not found | never | +| `RESOURCE_005` | 404 | Organization not found | never | +| `RESOURCE_006` | 404 | User not found | never | +| `RESOURCE_007` | 409 | Resource already exists | do not retry blindly | +| `RESOURCE_008` | 404 | Webhook not found | never | +| `RESOURCE_009` | 404 | Brand not found | never | +| `RESOURCE_010` | 404 | Campaign not found | never | +| `RESOURCE_011` | 404 | Batch not found | never | +| `RESOURCE_012` | 404 | Phone number not found | never | +| `RESOURCE_013` | 404 | Resource not found | never | +| `RESOURCE_014` | 404 | Profile not found | never | + +`RESOURCE_014` also occurs when an organization passes its own identifier as a `profileId`, which must be a child profile. `RESOURCE_007` is the duplicate-creation signal, most visibly when inviting a user who already has access; read the existing resource and decide whether the requested state is already satisfied. + +## BUSINESS codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `BUSINESS_001` | 400 | Cannot modify inherited contact | never | +| `BUSINESS_002` | 429 | Rate limit exceeded | backoff | +| `BUSINESS_003` | 402 | Insufficient account balance | never | +| `BUSINESS_004` | 400 | Contact has opted out | never | +| `BUSINESS_005` | 400 | Template not approved | never | +| `BUSINESS_006` | 400 | Message cannot be modified in current state | never | +| `BUSINESS_007` | 400 | Channel not available | never | +| `BUSINESS_008` | 400 | Operation would exceed quota | never | +| `BUSINESS_010` | 400 | Webhook is inactive | never | +| `BUSINESS_012` | 400 | Template is not active on the requested channel | never | +| `BUSINESS_014` | 403 | Account is suspended | never | + +`BUSINESS_001` is the inheritance boundary: a profile that inherits contacts cannot modify them. `BUSINESS_010` explains why a test delivery to a disabled webhook fails; re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the dashboard after fixing the receiver. + +## CONFLICT, SERVICE, and INTERNAL codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `CONFLICT_001` | 409 | Concurrent idempotent request | after delay | +| `SERVICE_001` | 503 | Cache service temporarily unavailable | backoff | +| `INTERNAL_001` | 500 | Unexpected internal server error | backoff | +| `INTERNAL_002` | 500 | Database operation failed | backoff | +| `INTERNAL_003` | 500 | External service error | backoff | +| `INTERNAL_004` | 504 | Timeout waiting for operation | backoff | +| `INTERNAL_005` | 503 | Service temporarily unavailable | backoff | + +`SERVICE_001` is a deliberate safety response: the idempotency cache was unavailable, so the API refused to execute rather than risk a duplicate. Retrying the same request with the same key is correct. + +## Codes that behave differently on send + +Two documented request-level codes do not reject `POST /v3/messages`. Insufficient balance (`BUSINESS_003`, 402) and an opted-out contact (`BUSINESS_004`, 400) are catalogued as errors, but on send the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED` respectively. Client code that only inspects HTTP status will believe those sends succeeded. + +The operational consequence is that balance and consent problems appear in delivery data rather than in error handling. Monitor blocked and filtered rates as first-class metrics alongside `4xx` and `5xx` counts. + +Sent also records internal reason codes on a message for consent blocks, route denials, no-route-matched, and invalid template parameters. These are never returned in API responses or webhook payloads, so diagnosis uses the terminal status plus the channel value plus `GET /v3/messages/{id}/activities`. + +## Idempotency semantics + +`Idempotency-Key` applies to POST, PUT, and PATCH on `/v3/*` and is ignored on GET and DELETE. Values are 1 to 255 characters of `[A-Za-z0-9_-]`. + +| Situation | Behavior | +| --- | --- | +| First successful request | Response cached for 24 hours per key per customer | +| Replay of a cached key | Cached body returned with `Idempotent-Replayed: true` and `X-Original-Request-Id` | +| Response larger than 5 MB | Not cached; a duplicate re-executes | +| Duplicate arrives while the original is in flight | Waits up to five seconds, then fails `409 CONFLICT_001` | +| Idempotency cache unavailable | `503 SERVICE_001`; the request was not executed | + +Derive keys deterministically from your own domain objects — an order id plus a notification type, for example — rather than generating a random value per attempt, so that a retry after a network timeout collides with the original instead of creating a second send. Because caching is per customer, the same key used by two different customers is two independent operations. + +## Rate limits and pacing + +| Tier | Limit | Window | Applies to | +| --- | --- | --- | --- | +| Standard | 200 requests/minute | Sliding 60 seconds | Everything not listed below | +| Sensitive | 10 requests/minute | Fixed window | `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/webhooks/{id}/test` | + +`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` are present **only** on `429` responses. There is no way to read remaining quota preemptively, so pacing must be a design decision rather than an adaptive reaction. + +For bulk work, batch up to 1,000 recipients per `POST /v3/messages` and pace at roughly one request per second, which keeps a large campaign inside the standard budget while leaving headroom for transactional traffic. Note that batching multiplies with channels: 1,000 recipients on two channels is 2,000 messages and 2,000 charges from a single request. + +Rate-limit exposure follows the credential. A profile-scoped key has its own pool; an organization key acting through `x-profile-id` draws on the organization pool shared by every profile. + +## Sandbox semantics + +`"sandbox": true` runs authentication and validation and then stops. Nothing is persisted, queued, dispatched to a provider, or charged, and resource lookups do not occur — so a sandbox request will not tell you whether a template id exists. Malformed requests still return real `400` and `422` responses, which is what makes sandbox valuable in continuous integration. + +The exception worth memorizing: `DELETE /v3/webhooks/{id}` ignores the flag and always deletes. Never use sandbox as a general dry-run guard for destructive calls. + +## Ambiguous send recovery + +When a send times out or the connection drops before a response arrives, the request may or may not have been accepted. Never blind-retry. + +1. If the original carried an `Idempotency-Key`, retry with the **same** key. A cached success returns the original response with `Idempotent-Replayed: true`; a `409 CONFLICT_001` means the original is still in flight, so pause and retry once. +2. If no key was sent, search your own request and response records for a returned `message_id`. Sent exposes no reliable lookup by idempotency key or recipient that can prove an ambiguous request did not execute. +3. Escalate ambiguous no-key cases for an explicit duplicate-risk decision. Only send again when your application has sufficient evidence that nothing was accepted, and attach an idempotency key this time. + +The same discipline applies to profile provisioning: a deterministic key derived from your provisioning record prevents a timeout from creating a second profile. diff --git a/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md new file mode 100644 index 0000000..713b428 --- /dev/null +++ b/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -0,0 +1,158 @@ +# SDK selection and framework wiring + +## Table of contents + +- [Package matrix](#package-matrix) +- [Client construction per language](#client-construction-per-language) +- [Configuration and environment variables](#configuration-and-environment-variables) +- [Framework wiring](#framework-wiring) +- [Background processing per ecosystem](#background-processing-per-ecosystem) +- [Multi-tenant credential patterns](#multi-tenant-credential-patterns) +- [Testing and mocking](#testing-and-mocking) +- [Deployment notes](#deployment-notes) + +## Package matrix + +| Language | Package | Install | Minimum runtime | +| --- | --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `npm install @sentdm/sentdm` | Node with ESM or CJS | +| Python | `sentdm` (imports as `sent_dm`) | `pip install sentdm` | Python 3.9 | +| Go | `github.com/sentdm/sent-dm-go` | `go get github.com/sentdm/sent-dm-go` | Go 1.22 | +| Java | `dm.sent:sent-java` | Maven or Gradle dependency | Java 8 | +| C# | `Sentdm` | `dotnet add package Sentdm` | .NET Standard 2.0 | +| PHP | `sentdm/sent-dm-php` | `composer require sentdm/sent-dm-php` | PHP 8.1 | +| Ruby | `sentdm` | `gem install sentdm` or Bundler | Ruby 3.2 | + +The distribution name and the import name differ in Python (`sentdm` installs, `sent_dm` imports) and the Ruby send method is `messages.send_` with a trailing underscore because `send` is reserved. Both are common first-hour errors. + +No SDK ships a webhook signature verifier in any language. That code is always application-owned. + +## Client construction per language + +```typescript +import SentDm from '@sentdm/sentdm'; + +// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. +export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); + +const response = await sent.messages.send({ + to: ['+14155551234'], + template: { name: 'order_confirmation', parameters: { order_id: '12345' } }, +}); +``` + +```python +from sent_dm import Sent, AsyncSent + +client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY +async_client = AsyncSent() + +response = client.messages.send( + to=["+14155551234"], + template={"name": "order_confirmation", "parameters": {"order_id": "12345"}}, +) +``` + +```go +client := sentdm.NewClient() // or option.WithAPIKey(...) +response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ + To: []string{"+14155551234"}, +}) +``` + +```java +SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +MessageSendResponse response = client.messages().send(params); +``` + +```csharp +using Sentdm; +SentClient client = new(); // reads SENT_DM_API_KEY +var response = await client.Messages.Send(body); +``` + +```php +use SentDm\Client; +$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); +``` + +```ruby +require "sentdm" +client = Sentdm::Client.new # reads SENT_DM_API_KEY +client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) +``` + +Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. + +## Configuration and environment variables + +| Variable | Purpose | Read automatically | +| --- | --- | --- | +| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | +| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | +| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | + +Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. + +For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. + +## Framework wiring + +| Framework | Client placement | Webhook raw body | +| --- | --- | --- | +| Next.js | Shared module such as `lib/sent/client.ts` | `await request.text()`; keep the route on the Node runtime | +| Express | Module singleton | `express.raw({ type: 'application/json' })` scoped to the webhook path | +| NestJS | Provider in a `SentModule` | `req.rawBody` with `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | Client built in the lifespan, injected as a dependency | `await request.body()` | +| Django | `@lru_cache` factory in a `client.py` | `request.body` | +| Flask | Cached on the app or request context | `request.get_data()` | +| Gin / Echo | Constructed in `main`, passed to handlers | `io.ReadAll(c.Request.Body)` | +| Spring Boot | `@Bean` in a configuration class | `@RequestBody String payload` | +| Laravel | Singleton in the service container | `$request->getContent()` in middleware | +| Symfony | Autowired service | `$request->getContent()` | +| Rails | Memoized in an initializer | `request.body.read` then `request.body.rewind` | +| Sinatra | Memoized module method | `request.body.read` then `request.body.rewind` | +| ASP.NET Core | Singleton via dependency injection | `new StreamReader(request.Body).ReadToEndAsync()` | + +The recurring defect is a global JSON body parser that destroys the byte-exact body needed for signature verification. Scope the parser away from the webhook path, or read the raw bytes before any parsing occurs. + +A minimal integration is four files regardless of stack: a client module, an outbound send route, an inbound webhook route, and a signature-verification helper. + +## Background processing per ecosystem + +Webhook handlers must acknowledge with `200` and then work asynchronously, because ten consecutive failed deliveries disable the endpoint and a slow handler manufactures those failures. + +| Ecosystem | Mechanism | +| --- | --- | +| Node | BullMQ or an equivalent durable queue | +| Python | Celery or another durable queue; reserve FastAPI `BackgroundTasks` for non-critical local work | +| Go | A bounded worker pool or a job queue | +| Java | `@Async` with a `ThreadPoolTaskExecutor`, or a broker | +| PHP | Laravel queued jobs, Symfony Messenger | +| Ruby | ActiveJob or Sidekiq | +| .NET | A `BackgroundService` consuming a channel or queue | + +Route bulk campaign traffic to a queue separate from transactional sends so a large campaign cannot starve time-sensitive messages, and set worker concurrency or a task rate limit that respects the 200-requests-per-minute budget. + +## Multi-tenant credential patterns + +Two patterns exist, and mixing them causes confusing `403` responses. + +A **profile-scoped key** is confined to one profile, has its own rate-limit pool, and must not send `x-profile-id` — doing so returns `403`. Prefer it for runtime send paths so a leaked key affects one tenant. + +An **organization key with `x-profile-id`** reaches permitted child profiles but draws on the organization's shared rate-limit pool, so one noisy tenant consumes everyone's quota. Prefer it for control-plane work such as provisioning. + +When each tenant supplies its own key, resolve it for the request, construct the client with that credential, and discard both afterward. Do not retain tenant credentials in a client cache merely to preserve connection pooling; isolation and rotation correctness take priority. Queued work must resolve the authorized tenant credential just in time from a secret store rather than embedding it in the job payload. Never place a key in a browser, mobile app, or any client the organization does not control, and keep separate keys per environment. `x-sender-id` is legacy v1 and v2 terminology with no role in v3. + +## Testing and mocking + +Use `"sandbox": true` for integration tests: authentication and validation still run, so a malformed request still returns `400` or `422`, but nothing is written, queued, charged, or dispatched to a provider. It is the right default in continuous integration. + +For unit tests, mock at the SDK boundary — `jest.fn()` on `messages.send`, a NestJS testing module override, a substituted `ISentClient` in .NET — and assert on the request payload rather than on transport behavior. For the receiver, generate valid headers locally with the webhook skill's signing script so tests cover the signature path without contacting Sent. + +Two notes on live verification. `POST /v3/webhooks/{id}/test` delivers exactly once with no retry, so re-run it after each fix. And `DELETE /v3/webhooks/{id}` ignores `sandbox` and always deletes, so never treat the flag as a dry-run guard for deletion. + +## Deployment notes + +Keep webhook routes on runtimes that expose Node-style crypto and raw bodies rather than on edge runtimes. Close the HTTP server gracefully on `SIGTERM` so in-flight deliveries finish instead of failing and triggering retries. Ensure load balancer idle timeouts exceed the configured `timeout_seconds`, and keep container clocks NTP-synchronized so the 300-second replay window does not reject valid traffic. Keep the route outside user-auth middleware. If abuse controls are required, make them signature-aware and capacity-safe rather than placing a generic limiter in front of verification and manufacturing the failures that lead to auto-disable. diff --git a/plugins/sent/skills/sent-integration-starter/scripts/preflight.py b/plugins/sent/skills/sent-integration-starter/scripts/preflight.py new file mode 100644 index 0000000..7f74185 --- /dev/null +++ b/plugins/sent/skills/sent-integration-starter/scripts/preflight.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Offline preflight checks for a Sent v3 integration. + +Validates the things that break integrations before any network call is made: +recipient formatting, send-payload shape, channel-array intent, idempotency-key +format, batch sizing against the documented pacing budget, and the retry +classification of an error code. + +Usage +----- +Run the built-in synthetic fixtures:: + + python3 preflight.py --self-test + +Check a send payload written to a file:: + + python3 preflight.py --payload-file send.json + +Classify an error code for retry behavior:: + + python3 preflight.py --classify-error 409:CONFLICT_001 + +Exit codes: 0 all checks passed, 1 one or more findings, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +E164 = re.compile(r"^\+[1-9]\d{1,14}$") +IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9_-]{1,255}$") +VALID_CHANNELS = {"sent", "sms", "whatsapp", "rcs"} +MAX_RECIPIENTS_PER_REQUEST = 1000 +STANDARD_RATE_LIMIT_PER_MINUTE = 200 +SENSITIVE_RATE_LIMIT_PER_MINUTE = 10 + +TERMINAL_FAMILIES = {"AUTH", "VALIDATION", "RESOURCE"} +RETRYABLE_FAMILIES = {"SERVICE", "INTERNAL"} + +EXIT_OK = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + + +def check_recipients(recipients: object) -> list[str]: + """Validate the `to` array.""" + findings: list[str] = [] + if not isinstance(recipients, list) or not recipients: + return ["'to' must be a non-empty array of E.164 phone numbers"] + for value in recipients: + if not isinstance(value, str) or not E164.match(value): + findings.append(f"recipient {value!r} is not E.164 (leading '+', country code, digits only)") + if len(recipients) > MAX_RECIPIENTS_PER_REQUEST: + findings.append( + f"{len(recipients)} recipients exceeds the {MAX_RECIPIENTS_PER_REQUEST}-recipient per-request limit" + ) + return findings + + +def check_channels(channels: object) -> list[str]: + """Validate the `channel` array and flag broadcast intent.""" + if channels is None: + return [] + findings: list[str] = [] + if not isinstance(channels, list): + return ["'channel' must be an array when present"] + for value in channels: + if value not in VALID_CHANNELS: + findings.append(f"channel {value!r} is invalid; allowed values are {sorted(VALID_CHANNELS)}") + explicit = [value for value in channels if value != "sent"] + if len(explicit) > 1: + findings.append( + "multiple explicit channels broadcast rather than fall back: one message and one charge is created " + "per (recipient, channel) pair. Omit 'channel' or use ['sent'] for automatic routing with reroute" + ) + if "sent" in channels and len(channels) > 1: + findings.append("'sent' combined with an explicit channel is ambiguous; use one or the other") + return findings + + +def check_content(payload: dict) -> list[str]: + """Validate that exactly one content source is present.""" + has_template = isinstance(payload.get("template"), dict) + has_text = isinstance(payload.get("text"), str) and payload["text"].strip() != "" + if has_template and has_text: + return ["provide either 'template' or 'text', not both"] + if not has_template and not has_text: + return ["provide 'template' or 'text' as the message content"] + if has_template: + template = payload["template"] + if not template.get("id") and not template.get("name"): + return ["'template' requires 'id' or 'name'"] + if template.get("id") and template.get("name"): + return ["'template.id' and 'template.name' are mutually exclusive"] + parameters = template.get("parameters") + if parameters is not None and not isinstance(parameters, dict): + return ["'template.parameters' must be an object of string values"] + if isinstance(parameters, dict) and any(not isinstance(value, str) for value in parameters.values()): + return ["every 'template.parameters' value must be a string"] + return [] + + +def check_idempotency_key(key: object) -> list[str]: + """Validate an Idempotency-Key header value.""" + if key is None: + return ["no Idempotency-Key supplied; a timeout retry can produce a duplicate send"] + if not isinstance(key, str) or not IDEMPOTENCY_KEY.match(key): + return ["Idempotency-Key must be 1-255 characters of letters, digits, hyphens, or underscores"] + return [] + + +def estimate_batches(recipient_count: int, channel_count: int = 1) -> dict[str, int]: + """Return message and request estimates for a bulk send.""" + channel_count = max(1, channel_count) + messages = recipient_count * channel_count + requests = -(-recipient_count // MAX_RECIPIENTS_PER_REQUEST) + minutes = -(-requests // STANDARD_RATE_LIMIT_PER_MINUTE) + return { + "messages_created": messages, + "requests_required": requests, + "minimum_minutes_at_rate_limit": minutes, + } + + +def classify_error(status: int, code: str) -> tuple[str, str]: + """Return (classification, guidance) for a Sent error response.""" + family = code.split("_", 1)[0].upper() if code else "" + if status == 429: + return "retry", "honor Retry-After, then use jittered exponential backoff; stop if the credential is locked" + if code.upper() == "CONFLICT_001": + return "retry-once", "a concurrent duplicate is in flight; pause, then retry the same Idempotency-Key once" + if code.upper() == "SERVICE_001": + return "retry", "the idempotency store was unavailable and the request was deliberately not executed" + if family in RETRYABLE_FAMILIES or 500 <= status < 600: + return "retry", "exponential backoff with jitter and a bounded ceiling" + if family == "AUTH": + return "terminal", "stop immediately; ten consecutive auth failures lock the credential with escalating lockout" + if family in TERMINAL_FAMILIES: + return "terminal", "fix the request or the referenced resource; retrying reproduces the same result" + if family == "BUSINESS": + return "conditional", ( + "an account or policy precondition; on POST /v3/messages the send is accepted with 202 and the " + "affected messages finalize as BLOCKED or FILTERED, so resolve the condition before resending" + ) + return "unknown", "treat as terminal until classified; log meta.request_id and inspect error.doc_url" + + +def check_payload(payload: dict, idempotency_key: str | None = None) -> list[str]: + """Run every payload check and return the accumulated findings.""" + findings: list[str] = [] + findings.extend(check_recipients(payload.get("to"))) + findings.extend(check_channels(payload.get("channel"))) + findings.extend(check_content(payload)) + findings.extend(check_idempotency_key(idempotency_key)) + return findings + + +def _self_test() -> int: + failures: list[str] = [] + + good = { + "to": ["+14155551234"], + "template": {"name": "order_confirmation", "parameters": {"order_id": "12345"}}, + } + if check_payload(good, "order-12345-confirmation"): + failures.append("a well-formed payload with an idempotency key must produce no findings") + + if not check_recipients(["4155551234"]): + failures.append("a non-E.164 recipient must be flagged") + if not check_recipients([]): + failures.append("an empty recipient list must be flagged") + if not check_recipients(["+1415555%s" % "1" * 15]): + failures.append("an over-long number must be flagged") + + broadcast = check_channels(["whatsapp", "sms"]) + if not any("broadcast" in finding for finding in broadcast): + failures.append("a multi-channel array must be flagged as broadcast, not fallback") + if check_channels(["sent"]) or check_channels(None): + failures.append("automatic routing must produce no channel findings") + if not check_channels(["telegram"]): + failures.append("an unsupported channel value must be flagged") + + if not check_content({"to": ["+14155551234"]}): + failures.append("missing content must be flagged") + if not check_content({"template": {"id": "x", "name": "y"}}): + failures.append("template id and name together must be flagged") + if not check_content({"template": {"name": "t"}, "text": "hello"}): + failures.append("template and text together must be flagged") + if not check_content({"template": {"name": "t", "parameters": {"count": 2}}}): + failures.append("non-string template parameter values must be flagged") + + if not check_idempotency_key(None): + failures.append("a missing idempotency key must be flagged") + if not check_idempotency_key("bad key!"): + failures.append("an invalid idempotency key must be flagged") + + estimate = estimate_batches(2500, 2) + if estimate != {"messages_created": 5000, "requests_required": 3, "minimum_minutes_at_rate_limit": 1}: + failures.append(f"batch estimation drifted: {estimate}") + + expectations = { + (429, "BUSINESS_009"): "retry", + (409, "CONFLICT_001"): "retry-once", + (503, "SERVICE_001"): "retry", + (401, "AUTH_002"): "terminal", + (400, "VALIDATION_004"): "terminal", + (404, "RESOURCE_001"): "terminal", + (500, "INTERNAL_001"): "retry", + (402, "BUSINESS_003"): "conditional", + } + for (status, code), expected in expectations.items(): + actual, _ = classify_error(status, code) + if actual != expected: + failures.append(f"{status} {code} classified as {actual}, expected {expected}") + + if SENSITIVE_RATE_LIMIT_PER_MINUTE >= STANDARD_RATE_LIMIT_PER_MINUTE: + failures.append("the sensitive tier must be lower than the standard tier") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("preflight self-test passed: 20 checks") + return EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Offline preflight checks for a Sent v3 integration.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--payload-file", help="path to a JSON send payload to check") + parser.add_argument("--idempotency-key", help="the Idempotency-Key that will be sent with the payload") + parser.add_argument("--estimate", type=int, metavar="RECIPIENTS", help="estimate messages, requests, and pacing") + parser.add_argument("--channels", type=int, default=1, help="number of channels used with --estimate") + parser.add_argument("--classify-error", metavar="STATUS:CODE", help="classify an error response for retry") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if args.classify_error: + try: + status_text, _, code = args.classify_error.partition(":") + classification, guidance = classify_error(int(status_text), code) + except ValueError: + print("error: --classify-error expects STATUS:CODE, for example 429:BUSINESS_009", file=sys.stderr) + return EXIT_USAGE + print(f"{args.classify_error} -> {classification}: {guidance}") + return EXIT_OK + + if args.estimate is not None: + for key, value in estimate_batches(args.estimate, args.channels).items(): + print(f"{key}: {value}") + return EXIT_OK + + if not args.payload_file: + parser.error("provide --payload-file, --estimate, --classify-error, or --self-test") + + try: + with open(args.payload_file, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + print(f"error: could not read payload: {exc}", file=sys.stderr) + return EXIT_USAGE + + if not isinstance(payload, dict): + print("error: payload must be a JSON object", file=sys.stderr) + return EXIT_USAGE + + findings = check_payload(payload, args.idempotency_key) + if payload.get("sandbox") is True: + print("note: sandbox is true, so this request validates and authenticates without executing") + if not findings: + print("payload passed all preflight checks") + return EXIT_OK + for finding in findings: + print(f"- {finding}") + return EXIT_FINDINGS + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/sent/skills/sent-profile-provisioning/SKILL.md b/plugins/sent/skills/sent-profile-provisioning/SKILL.md new file mode 100644 index 0000000..a021269 --- /dev/null +++ b/plugins/sent/skills/sent-profile-provisioning/SKILL.md @@ -0,0 +1,134 @@ +--- +name: sent-profile-provisioning +description: Executes the Sent Sender Profile lifecycle over the API — creating profiles with the right inheritance, sharing, billing, and WhatsApp options, driving profile completion and its callback, managing 10DLC campaigns per profile, and administering users and roles. Use when calling POST /v3/profiles, handling a completion callback or unclear profile status, choosing inherit or dedicated resources, wiring per-tenant onboarding, or inviting and role-managing users. +--- + +# Sent Profile Provisioning + +This skill is the execution counterpart to profile architecture: once the tenancy boundary is decided, it drives the API calls, the completion callback, the campaign registration, and the user administration that make a profile able to send. Design the boundary with `sender-profile-architect` first; provision it here. + +## Provisioning sequence + +1. **Confirm the credential.** `POST /v3/profiles` requires an organization key with `admin`. Profile-scoped keys cannot create profiles, and a profile key that sends `x-profile-id` receives `403`. +2. **Decide inheritance and sharing before the call.** These flags shape compliance posture and are awkward to unwind later. +3. **Create the profile**, validating the payload with `"sandbox": true` first when the shape is uncertain. Use a different idempotency key for the live create because a successful sandbox response is cached for 24 hours. +4. **Attach or inherit WhatsApp** via exactly one of the three supported paths. +5. **Register campaigns** for US SMS under the profile. +6. **Complete the profile** with `POST /v3/profiles/{profileId}/complete` and a reachable `webHookUrl`. +7. **Reconcile status** from the callback, or by polling if the callback is missed. +8. **Invite users** with least-privilege roles. + +## Create payload essentials + +`name` is the only required field. The consequential optional fields group into identity, sharing, inheritance, billing, WhatsApp, and brand. + +```json +{ + "name": "Northwind Retail", + "short_name": "Northwind", + "description": "Retail brand tenant", + "allow_contact_sharing": false, + "allow_template_sharing": false, + "inherit_contacts": false, + "inherit_templates": false, + "inherit_tcr_brand": true, + "inherit_tcr_campaign": true, + "billing_model": "profile", + "billing_contact": { + "name": "Ada Ops", + "email": "ops@example.com", + "phone": "+14155550100", + "address": "1 Example Way, Springfield" + }, + "sandbox": true +} +``` + +`short_name` must be 3 to 11 characters of letters, numbers, and spaces with at least one letter. Inheritance flags default to true, so a profile created with no flags consumes the organization's contacts, templates, brand, and campaigns. The example opts into contact and template isolation explicitly while inheriting the organization's compliance registrations. Sharing flags expose this profile's resources outward; inheritance flags consume the organization's resources inward. They are independent directions and are frequently confused. + +Create permits `name` alone, but completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. When `inherit_tcr_brand` is true, the API rejects a `brand` object in the create request even though the profile still needs its own KYC submission; complete that KYC through the dashboard before calling the completion endpoint. + +`billing_model` accepts `profile`, `organization`, or `profile_and_organization`. Any model that includes `profile` requires `billing_contact` when none exists, and `payment_details` is only accepted for those models. Card fields are forwarded to the payment processor and must never be logged, echoed, or persisted anywhere in the application. + +Field-by-field rules, error codes, and the update-only fields are in [references/profile-lifecycle.md](references/profile-lifecycle.md). + +## Inheritance decisions + +| Flag | `true` means | Consequence | +| --- | --- | --- | +| `inherit_tcr_brand` | Use the organization's registered brand | A `brand` object in the same request is rejected | +| `inherit_tcr_campaign` | Use the organization's campaigns | Those campaigns are read-only for this profile; creating one returns a validation error | +| `inherit_contacts` | Read the organization's contacts | No contact isolation between tenants | +| `inherit_templates` | Read the organization's templates | No template isolation between tenants | + +An inherited brand with `inherit_tcr_campaign: false` is a supported and common pattern: shared legal identity, dedicated messaging use cases per tenant. + +## WhatsApp: exactly three paths + +1. Organization Embedded Signup, performed in the Sent Dashboard. **No public endpoint starts this flow.** +2. Child-profile inheritance — omit `whatsapp_business_account` once the organization has a WABA. +3. Dedicated profile credentials — supply `whatsapp_business_account` with `waba_id` and `access_token`, optionally `phone_number_id`. + +Supplying credentials on `POST /v3/profiles` is not an Embedded Signup endpoint. Omitting `whatsapp_business_account` when the organization has no WABA configured returns `422`; complete organization Embedded Signup or supply valid direct credentials. Use `waba-embedded-signup` for the operational signup flow. + +## Completion and status + +`POST /v3/profiles/{profileId}/complete` requires `webHookUrl`. + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +A `202` means processing started and carries no final status. A `200` means the profile was already complete and its body carries a status. The callback body is `{profileId, success, status, timestamp}` and is **delivered once with no retry**, so the receiver must be live before the call and the flow must degrade to polling `GET /v3/profiles/{profileId}`. This callback is separate from subscribed Sent webhooks and is not documented as carrying the webhook HMAC headers; use a unique callback path tied to the provisioning record, reject unknown profile ids, and treat polling as the authoritative recovery path. + +Profile status vocabulary differs by surface: the create response demonstrates lowercase `incomplete`, the completion `200` demonstrates lowercase `completed`, the completion callback uses `COMPLETED`, `SUBMITTED`, and `failed`, and `GET /v3/profiles/{id}` documents `approved`, `submitted`, `processing`, and `failed`. Do not assert a closed enum, do not lowercase-normalize into a fixed set, and record which surface produced each value. Compare statuses case-insensitively and preserve unknown strings. + +## Campaigns per profile + +Campaign management lives under the profile: `GET|POST /v3/profiles/{profileId}/campaigns` and `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. There are no standalone brand endpoints; a dedicated brand is created with the profile. + + +```json +{ + "campaign": { + "name": "Northwind order notifications", + "description": "Order and delivery notifications for opted-in Northwind customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Northwind: Your order 12345 has shipped. Reply STOP to opt out." + ] + } + ], + "volume": "1500", + "messageFlow": "Customers opt in at checkout before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms" + } +} +``` + +`messagingUseCaseUs` accepts one of thirteen values, `sampleMessages` holds 1 to 5 entries of at most 1,024 characters each, and a numeric `volume` string below 2,000 selects the low-volume tier while 2,000 or above selects the standard tier. Campaign statuses are `SENT_CREATED`, `ACTIVE`, and `EXPIRED`. Use `sms-10dlc-registration` for use-case selection and sample-copy policy. + +## Users and roles + +Five operations administer access: `GET /v3/users`, `POST /v3/users` (invite), `GET /v3/users/{userId}`, `PATCH /v3/users/{userId}` (role), and `DELETE /v3/users/{userId}`. None is exposed through MCP. Assignable roles are `admin`, `billing`, and `developer`; `owner` is implicit for the creating account and never appears in the list. Mutations require `admin`. + +Role checks resolve against the email that owns the API key and pass only for the owner or an **active** user with an allowed role — `invited`, `suspended`, and `rejected` users fail. Organization-level access cascades to child profiles. Invitations expire after seven days, and inviting an existing user returns `409`. + +Before any user mutation, read the current state, then confirm explicitly with the operator. The API refuses to let you change your own role, demote the last admin, remove yourself, or remove the last admin, but checking first produces a clear explanation instead of a validation error. The full role matrix and key-hygiene rules are in [references/users-and-roles.md](references/users-and-roles.md). + +There is no endpoint to list, create, or revoke API keys; key management is a dashboard operation. Rotation is create-new, deploy, verify with `GET /v3/me`, then disable or delete the old key — deleting first only when the key is compromised. + +## Multi-tenant provisioning notes + +Webhook events never carry your application's tenant identifier. Before the first send, persist `message_id -> {tenant, profile, logical_send_id, channel}` and `receiving_number -> {tenant, profile}`. Do not infer tenant ownership from `account_id`, since many tenant profiles can share one organization. Provision one webhook registration per environment so a failing lower-environment receiver cannot auto-disable production. + +## Boundaries + +Use `sender-profile-architect` for the isolation, credential, and blast-radius design decision; `waba-embedded-signup` for the WhatsApp signup flow; `sms-10dlc-registration` for brand vetting and campaign policy; and `sent-webhook-engineer` for subscribed message-event receivers. Profile-completion callbacks use the separate verification and polling guidance in this skill. diff --git a/plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml b/plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml new file mode 100644 index 0000000..df60ac1 --- /dev/null +++ b/plugins/sent/skills/sent-profile-provisioning/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Profile Provisioning" + short_description: "Run the Sender Profile lifecycle" + default_prompt: "Use $sent-profile-provisioning to plan the API calls that bring a new Sender Profile to a sending state." diff --git a/plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md b/plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md new file mode 100644 index 0000000..108f49c --- /dev/null +++ b/plugins/sent/skills/sent-profile-provisioning/references/profile-lifecycle.md @@ -0,0 +1,167 @@ +# Sender Profile lifecycle reference + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Create field reference](#create-field-reference) +- [Update-only fields](#update-only-fields) +- [Inheritance and sharing matrix](#inheritance-and-sharing-matrix) +- [Billing configuration](#billing-configuration) +- [WhatsApp attachment paths](#whatsapp-attachment-paths) +- [Completion flow and callback](#completion-flow-and-callback) +- [Status vocabulary by surface](#status-vocabulary-by-surface) +- [Error catalog for provisioning](#error-catalog-for-provisioning) +- [Idempotency and sandbox](#idempotency-and-sandbox) +- [Offboarding](#offboarding) + +## Operation catalog + +Ten operations cover profiles and their campaigns. None is available through MCP, so provisioning is REST-only. + +| Operation | Purpose | +| --- | --- | +| `POST /v3/profiles` | Create a profile | +| `GET /v3/profiles` | List profiles | +| `GET /v3/profiles/{profileId}` | Read one profile, including current status | +| `PATCH /v3/profiles/{profileId}` | Update configuration and number references | +| `DELETE /v3/profiles/{profileId}` | Remove a profile | +| `POST /v3/profiles/{profileId}/complete` | Start completion; requires `webHookUrl` | +| `GET /v3/profiles/{profileId}/campaigns` | List campaigns | +| `POST /v3/profiles/{profileId}/campaigns` | Create a campaign | +| `PUT /v3/profiles/{profileId}/campaigns/{campaignId}` | Update a campaign | +| `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` | Delete a campaign | + +Creation requires an organization API key whose owning email holds `admin`. An organization key may target a child with `x-profile-id`; a profile-scoped key sending that header receives `403`, and a profile outside the organization returns `404`. + +## Create field reference + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `name` | string | — | The only required field | +| `icon` | string | — | Display asset | +| `description` | string | — | Free text | +| `short_name` | string | — | 3–11 chars, letters, numbers, spaces, at least one letter | +| `allow_contact_sharing` | boolean | `false` | Exposes this profile's contacts outward | +| `allow_template_sharing` | boolean | `false` | Exposes this profile's templates outward | +| `inherit_contacts` | boolean | `true` | Consumes the organization's contacts | +| `inherit_templates` | boolean | `true` | Consumes the organization's templates | +| `inherit_tcr_brand` | boolean | `true` | Uses the organization's brand; forbids a `brand` object | +| `inherit_tcr_campaign` | boolean | `true` | Inherited campaigns are read-only for this profile | +| `billing_model` | enum | `profile` | `profile`, `organization`, or `profile_and_organization` | +| `billing_contact` | object | — | `name`, `email`, `phone`, `address`; required when the model includes `profile` and none exists | +| `whatsapp_business_account` | object | — | `waba_id` and `access_token` required, `phone_number_id` optional | +| `brand` | object | — | `contact` and `compliance` required, `business` optional; forbidden when `inherit_tcr_brand` is true | +| `payment_details` | object | — | `card_number`, `expiry`, `cvc`, `zip_code`; only for models including `profile` | +| `sandbox` | boolean | `false` | Simulate without side effects | + +`payment_details` is ephemeral and forwarded to the payment processor. Never log it, never echo it back to a user, never store it, and never place it in a file that could be committed. + +Although creation requires only `name`, completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. A profile inheriting the organization's TCR brand cannot include a `brand` object in the API request but still needs profile-level KYC submitted through the dashboard before completion. + +## Update-only fields + +`PATCH /v3/profiles/{profileId}` accepts the create fields plus number references: + +- `sending_phone_number_profile_id` +- `sending_whatsapp_number_profile_id` +- `sending_phone_number` +- `whatsapp_phone_number` +- `allow_number_change_during_onboarding` + +Model reference identifiers separately from literal numbers, and guard against cycles when one profile's sending number points at another profile that points back. + +## Inheritance and sharing matrix + +Inheritance pulls resources in; sharing pushes them out. They are independent. + +| Configuration | Result | +| --- | --- | +| `inherit_contacts: true`, `allow_contact_sharing: false` | Reads organization contacts; does not expose its own | +| `inherit_contacts: false`, `allow_contact_sharing: true` | Isolated contact store that other profiles may read | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | Fully inherited compliance posture; campaigns read-only here | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | Shared legal identity with per-tenant use cases — the common multi-tenant pattern | +| `inherit_tcr_brand: false` | Dedicated brand supplied in the same create request | + +For tenant isolation, set `inherit_contacts` and `inherit_templates` to false explicitly, because both default to true and a silently inherited store means one tenant can read another's data model. + +## Billing configuration + +| Model | Meaning | Requires | +| --- | --- | --- | +| `profile` | The profile pays | `billing_contact`, optionally `payment_details` | +| `organization` | The organization pays | Nothing profile-side | +| `profile_and_organization` | Profile first with organization fallback | `billing_contact` | + +Effective balance follows this configuration, so a balance reading for a profile with `organization` billing reflects the organization's funds. Confirm which model a profile uses before interpreting a balance or diagnosing a `BLOCKED` message. + +## WhatsApp attachment paths + +| Path | How | When | +| --- | --- | --- | +| Organization Embedded Signup | Sent Dashboard only; no public endpoint exists | The organization owns one WABA used across profiles | +| Child inheritance | Omit `whatsapp_business_account` | Tenants share the organization's WABA | +| Dedicated credentials | `whatsapp_business_account` with `waba_id` and `access_token` | The tenant owns its own WABA | + +Requesting inheritance when the organization has no WABA configured returns `422 VALIDATION_001`. Do not invent a hybrid, and do not describe the create payload as an Embedded Signup endpoint. + +## Completion flow and callback + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +Responses: `202` means processing started and contains no final status; `200` means the profile was already complete and the body carries a status. + +The callback body is `{profileId, success, status, timestamp}`, documented with `COMPLETED`, `SUBMITTED`, and `failed`. It is delivered **once, with no retry**, which drives three requirements: the receiver must be reachable before the completion call, the receiver must be idempotent on `profileId`, and a reconciliation job must poll `GET /v3/profiles/{profileId}` for profiles that have been awaiting completion beyond a timeout. + +Note that this callback is not part of the `/v3/webhooks` subscription system and is not documented as carrying the `x-webhook-signature` scheme. Give each provisioning record a unique, hard-to-guess callback path, treat its payload as untrusted input, verify `profileId` against the record you created, and never take action on an unrecognized identifier. Polling the profile remains the recovery and reconciliation authority. + +## Status vocabulary by surface + +| Surface | Observed values | +| --- | --- | +| Create response | lowercase `incomplete` | +| Completion `200` | lowercase `completed` | +| Completion callback | `COMPLETED`, `SUBMITTED`, `failed` | +| `GET /v3/profiles/{profileId}` guide | `approved`, `submitted`, `processing`, `failed` | +| REST guides versus OpenAPI | Publish different status sets | + +Handle this by comparing case-insensitively, preserving unknown strings verbatim, recording which surface produced the value, and never switch-casing over an assumed closed enum. A provisioning state machine should treat any unrecognized status as "needs human review" rather than as an error. + +## Error catalog for provisioning + +| Status | Code | Meaning | +| --- | --- | --- | +| 400 | `VALIDATION_001` | Invalid payload, including a `brand` object alongside `inherit_tcr_brand: true` | +| 400 | `VALIDATION_001` | Cannot create campaigns when `inherit_tcr_campaign` is true, or the campaigns are read-only | +| 403 | `AUTH_004` | Profile key attempted `x-profile-id`, or insufficient role | +| 404 | `RESOURCE_005` | Organization not found | +| 404 | `RESOURCE_014` | Profile not found | +| 404 | `RESOURCE_009` | Brand not found for the profile | +| 404 | `RESOURCE_010` | Campaign not found | +| 422 | `VALIDATION_001` | Organization has no WABA configured | + +The response envelope carries `error.code`, `error.message`, `error.details`, and `error.doc_url`, plus `meta.request_id`. Log `request_id` for every provisioning call; it is the correlation handle for support. + +## Idempotency and sandbox + +`Idempotency-Key` is honored on POST, PUT, and PATCH with a value of 1 to 255 characters from `[A-Za-z0-9_-]`. Successful responses are cached 24 hours per key per customer, replays return the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`, a duplicate arriving while the original is in flight waits up to five seconds and then fails `409 CONFLICT_001`, and if the idempotency store is unavailable the API returns `503 SERVICE_001` rather than risk a double execution. Use a deterministic key derived from your own provisioning record so a retry after a network timeout cannot create a second profile. + +`"sandbox": true` authenticates and validates without persisting, queueing, calling providers, deducting balance, or looking up resources. Use it to prove a payload shape in CI. A successful sandbox mutation is itself cached by idempotency, so use a distinct key for the later live mutation or the live call will replay the sandbox response. Sandbox does not protect deletions in the webhook API, so never rely on it as a general dry-run guard. + +## Offboarding + +Deprovisioning a tenant is an ordered, evidence-preserving sequence rather than a single delete: + +1. Stop new sends at the application layer. +2. Disable or delete the tenant's API keys in the dashboard. +3. Remove or downgrade the tenant's users, keeping at least one admin on the organization. +4. Disable webhook registrations that pointed at tenant infrastructure. +5. Detach shared resources deliberately, checking whether other profiles inherit from them. +6. Retain delivery and consent records for the applicable retention period before deleting the profile. +7. Record the `request_id` and timestamp of each step as the audit trail. + +Contact deletion dissociates the contact from the calling customer while shared contact, capability, and delivery records persist, so deletion is not an erasure mechanism. diff --git a/plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md b/plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md new file mode 100644 index 0000000..fe1722c --- /dev/null +++ b/plugins/sent/skills/sent-profile-provisioning/references/users-and-roles.md @@ -0,0 +1,108 @@ +# Users, roles, and credential hygiene + +## Table of contents + +- [User operations](#user-operations) +- [Role model](#role-model) +- [How a role check is evaluated](#how-a-role-check-is-evaluated) +- [Invitation lifecycle](#invitation-lifecycle) +- [Guardrails on user mutations](#guardrails-on-user-mutations) +- [API key model](#api-key-model) +- [Key rotation](#key-rotation) +- [Least-privilege assignments](#least-privilege-assignments) +- [Audit evidence](#audit-evidence) + +## User operations + +| Operation | Required role | Body | Notes | +| --- | --- | --- | --- | +| `GET /v3/users` | `developer` or higher | — | Lists active, invited, and suspended users | +| `POST /v3/users` | `admin` | `email`, `name`, `role`, optional `sandbox` | `201` with status `invited`; `409 RESOURCE_007` if the user exists | +| `GET /v3/users/{userId}` | `developer` or higher | — | Single user detail | +| `PATCH /v3/users/{userId}` | `admin` | `role`, optional `sandbox` | Role change only | +| `DELETE /v3/users/{userId}` | `admin` | optional `sandbox` | Removes access | + +All five accept `x-profile-id` on an organization key to act on a child profile. None is exposed through MCP. + +The user object returns `id`, `email`, `name`, `role`, `status`, `invited_at`, `last_login_at`, `created_at`, and `updated_at`. + +The roles reference documentation states that the two read operations require "any role," while the OpenAPI specification requires `developer` or higher. Follow the specification and design for `developer` as the read floor; a `billing`-only user should not be assumed able to list users. + +## Role model + +| Role | Assignable by API | Scope of access | +| --- | --- | --- | +| `owner` | No | The account that created the organization or profile. Implicit, absent from the user list, passes every role check | +| `admin` | Yes | Passes every role check: profile management, user management, and reads | +| `developer` | Yes | Passes any-role checks; dashboard access to development, messaging, number lookup, activities, API keys, webhooks, channels, and settings | +| `billing` | Yes | Passes any-role checks; dashboard access limited to billing | + +Note the practical implication of `developer` including API keys and webhooks: a developer can create credentials and change event destinations. Treat the role as privileged even though it cannot manage users. + +## How a role check is evaluated + +The check resolves against the email address that owns the API key. It passes when that email is the owner email, or when an **active** user with that email exists on the organization or profile holding an allowed role. Users in `invited`, `suspended`, or `rejected` status fail every check. + +For a Sender Profile, checks cascade upward: owner or role access at the organization level also grants access to the organization's profiles. A user can therefore hold different effective roles across profiles when invited separately, while an organization-level role applies everywhere beneath it. + +Two operational consequences. First, an invitation that has not been accepted grants nothing, so provisioning automation must not assume access after `POST /v3/users` returns `201`. Second, revoking access at the organization level is the only way to remove cascading access; deleting a profile-level user leaves an organization-level grant intact. + +## Invitation lifecycle + +```text +POST /v3/users → status "invited" → email with token → user accepts → status "active" + └── 7 days elapse → token expires → re-invite required +``` + +Inviting an email that already has access returns `409 RESOURCE_007`; read the user list first and decide between a role change and an invitation. A provisioning flow that invites tenant staff should record the invitation timestamp and re-invite after expiry rather than retrying blindly, and should verify `status == "active"` before assuming the user can act. + +## Guardrails on user mutations + +The API refuses to let a caller change their own role, demote the last admin, remove themselves, or remove the last admin. Rather than discovering these as validation errors, check first: + +1. `GET /v3/users` and identify the target plus the count of active admins. +2. Confirm the target is not the caller's own account. +3. Confirm the change leaves at least one active admin. +4. Present the intended change and require explicit confirmation from the operator immediately before the call. +5. Record the `meta.request_id` from the response. + +Role changes and removals are effectively irreversible from the target user's perspective — re-granting requires a fresh invitation and acceptance — so treat both as destructive operations that deserve a stated diff before execution. + +## API key model + +There are two key types. An organization key can act for a child profile by sending `x-profile-id` with the profile UUID. A profile-scoped key is confined to its own profile and receives `403 AUTH_004` if it sends `x-profile-id`. + +Rate-limit exposure follows the key type: a profile key draws on its own pool, while an organization key acting through `x-profile-id` draws on the organization pool, so one noisy integration can consume quota shared by every profile. + +There is **no** endpoint to list, create, or revoke API keys. Key management happens in the Sent Dashboard, where the value is masked in the table and copied with a control. Any runbook that claims to automate key creation is wrong. + +Failed authentication is tracked per presented credential rather than per IP address: ten consecutive failures lock that credential with a `429` and escalating lockout windows from one minute up to sixty. A retry loop against a bad key therefore extends its own outage, so authentication failures must stop retrying immediately and alert instead. + +`x-sender-id` is legacy v1 and v2 terminology. It has no role in v3 authentication or routing. + +## Key rotation + +1. Create a replacement key in the dashboard. +2. Update the secret store and redeploy so the new key is in use. +3. Verify with `GET /v3/me` that the new key resolves to the expected account. +4. Disable or delete the old key. + +When a key is known to be compromised, invert the first steps and delete the old key immediately, accepting the brief outage. Keep separate keys per environment so rotating production never touches development, and never place a key in a browser, mobile app, or any client the organization does not control. + +## Least-privilege assignments + +| Workload | Credential | Role | +| --- | --- | --- | +| Server-side sends for one tenant | Profile-scoped key | `developer` | +| Provisioning new profiles | Organization key | `admin` | +| Campaign and brand registration | Organization or profile key per ownership | `admin` | +| Analytics reads | Profile-scoped key where possible | `developer` | +| Webhook management | Key matching the webhook's scope | `developer` | +| Billing review | — | `billing` | +| User administration | Organization key | `admin` | + +Prefer profile-scoped keys for runtime send paths so a leak is contained to one tenant, and reserve organization keys for control-plane operations that genuinely require cross-profile reach. + +## Audit evidence + +Every response carries `meta.request_id` and `meta.timestamp`. Log both alongside the operation name, the acting credential identifier, and — when an organization key acted through `x-profile-id` — the target profile, since the credential alone does not reveal which tenant was affected. The Sent Dashboard's Activities section, visible to owner, admin, and developer roles, is the platform-side counterpart. Because there is no API for key inventory, maintain a written register of which key exists for which environment and workload, who owns it, and when it was last rotated. diff --git a/plugins/sent/skills/sent-routing-strategist/SKILL.md b/plugins/sent/skills/sent-routing-strategist/SKILL.md new file mode 100644 index 0000000..e63159d --- /dev/null +++ b/plugins/sent/skills/sent-routing-strategist/SKILL.md @@ -0,0 +1,76 @@ +--- +name: sent-routing-strategist +description: Decides how a Sent message should reach the recipient — automatic routing versus a pinned channel, what the channel array actually does, how fallback and reroute work, and why a message ended as FAILED, FILTERED, BLOCKED, or channel "auto". Use when choosing the channel field, expecting WhatsApp-to-SMS fallback, debugging an unexpected route or duplicate charges from multiple channels, or interpreting message status and activity evidence. +--- + +# Sent Routing Strategist + +Routing is where the most expensive Sent misconceptions live. Two facts govern almost every decision: + +1. **The `channel` array is a broadcast list, not a preference order.** `["whatsapp", "sms"]` with two recipients creates four messages and four charges. There is no `fallback` field and no ordered-preference syntax. +2. **Automatic routing is the fallback mechanism.** Omit `channel`, or send `["sent"]`, and the platform selects a route, then reroutes across up to three distinct channel-and-provider pairs when a route-level failure occurs. + +## Decide the channel value + +| Intent | Correct value | Reason | +| --- | --- | --- | +| Reach the recipient however works best | omit `channel` or `["sent"]` | Enables route selection and reroute | +| Guarantee one specific channel | `["sms"]`, `["whatsapp"]`, or `["rcs"]` | Pinning restricts matching to that channel and never crosses channels | +| Deliberately deliver the same content on several channels | `["whatsapp", "sms"]` | Broadcast; expect one message and one charge per pair | +| "Try RCS, fall back to SMS" | omit `channel` or `["sent"]` | An ordered array would broadcast; automatic routing performs the fallback | + +Any value outside `sent`, `sms`, `whatsapp`, and `rcs` returns `400`. When a user asks for ordered fallback, name the misconception explicitly before writing code, because the failure mode is duplicate delivery and duplicate cost rather than an error. + +## What a pinned channel gives up + +Pinning restricts route matching to the named channel. Rules without a channel constraint still match and resolve to the pinned channel, so pinning does not require channel-specific rules to exist. A pinned send never crosses to a different channel, though same-channel provider hops remain possible when a rule permits them. If no route exists on the pinned channel, the message ends `FAILED` with no route matched — it does not silently fall back. + +Pin when a compliance, contractual, or content constraint requires a specific channel. Otherwise prefer automatic routing. + +## Reading the outcome + +`POST /v3/messages` returns `202` with per-recipient `message_id` values. For automatic routing, the echoed per-recipient channel is not a resolved route and is never updated afterward. Resolve the truth from evidence: + +| Question | Evidence | +| --- | --- | +| Which route was actually attempted | `message.routed` event, or `channel` on `GET /v3/messages/{id}` after routing | +| Did the recipient's device receive it | `message.delivered` | +| What sequence of routes was tried | `GET /v3/messages/{id}/activities` | +| Why did it stop | Terminal status plus channel value | + +## Terminal status interpretation + +| Status | Meaning | Correct response | +| --- | --- | --- | +| `FAILED` | A route attempt failed; automatic routing may still enqueue another attempt | Inspect the latest message state and activities before treating it as final | +| `FILTERED` | Policy gate — consent block or route denial | Never retry; a consent block is a compliance stop | +| `BLOCKED` | Account precondition — balance, onboarding quota, unapproved template | Fix the account condition, then send again | +| `SCHEDULED` | Parked by quiet-hours policy | Wait; it re-enters the pipeline automatically | + +An outcome whose `channel` is `auto` means the message ended before any route was attempted. The causes are no matching route, invalid template parameters, a consent block, or an account precondition. Account preconditions do not reject the send request: it is accepted with `202` and the affected messages surface as `BLOCKED`. + +Sent records internal send-time reason codes on the message for these cases, but does not return them in API responses or webhooks, so diagnosis relies on the status-and-channel combination plus the activity history. The mapping from observable evidence to root cause is tabulated in [references/routing-diagnosis.md](references/routing-diagnosis.md). + +## Reroute behavior + +A failed route is retried only when the terminal failure signals a route or carrier problem another route might overcome: undeliverable by this route, provider service unavailable, provider timeout, or transport error. Every other failure stays `FAILED`. + +Reroute reuses the **same `message_id`** and re-runs the pipeline, so `message.queued` and `message.routed` fire again, consent gates re-apply on every attempt, and already-attempted routes are excluded. The ceiling is three distinct channel-and-provider pairs across the initial send and all reroutes. + +The WhatsApp-to-SMS behavior customers ask about is a specific case of this: a WhatsApp message accepted and then failed for a recipient-side reason reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, so subsequent automatic sends skip WhatsApp for that recipient. It requires automatic routing; a pinned WhatsApp send cannot produce it. + +## How automatic routing selects a route + +Routes come from platform-maintained rules evaluated at send time against recipient attributes (country, number prefix, exact number, carrier, number type, ported state), sender, template attributes, channel, and whether the destination is international. Ordering is: exact-recipient rules first, then account-scoped before global, then match specificity, then rule priority, then longer number prefix, then the older rule. Inactive, deleted, expired, and below-threshold rules are excluded. Candidates whose template has an explicit non-approved review status on that channel are dropped, while a channel with no recorded review is not blocked. The first surviving candidate wins and the rest remain available as fallback routes. + +There is no fixed channel preference order, so never promise "RCS first, then WhatsApp, then SMS." Read [references/routing-model.md](references/routing-model.md) before making any claim about why a specific route was chosen. + +## Cost and volume consequences + +Because broadcast multiplies messages by recipients, review any multi-channel array against expected spend before sending. A 1,000-recipient send with two channels is 2,000 messages. The per-request recipient ceiling is 1,000, and documented pacing pairs full batches with roughly one request per second to stay inside the 200-requests-per-minute budget. + +RCS today carries text plus up to four suggestion chips, mapped from template buttons, and every outbound RCS message receives an appended STOP chip. Do not design an RCS-pinned flow that depends on rich cards, carousels, or media. + +## Boundaries + +Use `sent-messaging` to execute a single send with confirmation, `sent-two-way-messaging` for consent and inbound keyword semantics, `messaging-performance-analyzer` for aggregate delivery-rate regressions, and `sent-webhook-engineer` for receiving and deduplicating the events this skill teaches you to read. diff --git a/plugins/sent/skills/sent-routing-strategist/agents/openai.yaml b/plugins/sent/skills/sent-routing-strategist/agents/openai.yaml new file mode 100644 index 0000000..d92c655 --- /dev/null +++ b/plugins/sent/skills/sent-routing-strategist/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Routing Strategist" + short_description: "Choose channels and diagnose Sent routes" + default_prompt: "Use $sent-routing-strategist to decide how my Sent messages should reach recipients across channels." diff --git a/plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md b/plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md new file mode 100644 index 0000000..c5d3d06 --- /dev/null +++ b/plugins/sent/skills/sent-routing-strategist/references/routing-diagnosis.md @@ -0,0 +1,92 @@ +# Routing diagnosis from observable evidence + +## Table of contents + +- [Evidence sources](#evidence-sources) +- [Symptom to cause table](#symptom-to-cause-table) +- [Outcome and channel matrix](#outcome-and-channel-matrix) +- [Diagnostic sequence](#diagnostic-sequence) +- [Retry decision rules](#retry-decision-rules) +- [Cost review before a multi-channel send](#cost-review-before-a-multi-channel-send) +- [Worked examples](#worked-examples) + +## Evidence sources + +| Source | What it proves | +| --- | --- | +| `202` response `data.recipients[]` | The message ids that were created; nothing about routing | +| `GET /v3/messages/{id}` | Current status and the attempted channel once routing occurred | +| `GET /v3/messages/{id}/activities` | The sequence of attempts, which is the only way to see multiple routes | +| `message.routed` event | The concrete route chosen for that attempt | +| Terminal event `channel` | The attempted route, or `auto` when the message ended before routing | + +Sent records internal reason codes on the message for consent blocks, route denials, no-route-matched, and invalid template parameters, but does not return them through the API or webhooks. Diagnosis therefore combines the latest outcome, the channel value, and the activity history rather than reading an error code. + +## Symptom to cause table + +| Symptom | Most likely cause | Confirmation | +| --- | --- | --- | +| Recipients received the same content twice | A multi-channel array was treated as a fallback list | Count messages in the `202` response: `len(to) × len(channel)` | +| Billing higher than expected on a campaign | Same as above | Compare charged messages against recipient count | +| Expected WhatsApp-to-SMS fallback, got only a WhatsApp failure | The send pinned `["whatsapp"]` | Pinned sends never cross channels; switch to automatic routing | +| `FAILED` with channel `auto` | No routing rule matched, or template parameters were invalid | Activities show no route attempt | +| `FILTERED` with a channel value | Route denial without permitted fallback, or every candidate denied | Activities show attempts ending in denial | +| `FILTERED` with channel `auto` | Consent block before routing | Check the contact's `opt_out` state | +| `BLOCKED` | Account precondition: balance, onboarding quota, or unapproved template | Check balance and template approval state | +| Message stuck in `SCHEDULED` | Quiet-hours policy parked it | It re-enters the pipeline automatically; do not resend | +| Duplicate `queued` and `routed` events for one id | A reroute re-ran the pipeline | Activities show more than one attempted route | +| Channel changed between two events for one message | Reroute moved to another route | Expected on automatic routing | +| `400` on send | A channel value outside `sent`, `sms`, `whatsapp`, `rcs` | Inspect the request `channel` array | +| Pinned RCS message failed immediately | No route exists on the pinned channel | A pinned send does not fall back | +| No `READ` event on SMS | `READ` exists only on WhatsApp and RCS | Expected, not a defect | + +## Outcome and channel matrix + +| Outcome | Channel `auto` | Channel is a concrete route | +| --- | --- | --- | +| `FAILED` | No route matched, or invalid template parameters | One route failed; inspect newer events and activities to determine whether reroute continued | +| `FILTERED` | Consent block before routing | Route denial that did not permit fallback, or all candidates denied | +| `BLOCKED` | Account precondition evaluated before routing | Rare; treat as an account precondition regardless | +| `DELIVERED` | Not possible | Normal success | + +## Diagnostic sequence + +1. Confirm what was requested. Re-read the send body: was `channel` omitted, `["sent"]`, pinned, or multi-valued? This alone resolves most reported "fallback did not work" and "duplicate message" cases. +2. Count expected messages as `len(to) × len(channel)` and compare with the `202` response. +3. Fetch `GET /v3/messages/{id}` for a representative message and record status and channel. +4. Fetch `GET /v3/messages/{id}/activities` and list the attempted routes in order. +5. Classify the terminal state using the matrix above. +6. Decide retry eligibility using the rules below, and state the reason rather than retrying reflexively. + +## Retry decision rules + +| Terminal state | Retry | Precondition | +| --- | --- | --- | +| `FAILED` after route exhaustion on automatic routing | Only with a changed input | Three distinct routes were already tried; a new send repeats the same rules unless the recipient, template, or channel choice changes | +| `FAILED` with channel `auto` from no route matched | No | The rule set has no path to that recipient; escalate rather than loop | +| `FAILED` from invalid template parameters | Yes | After fixing the parameters | +| `FILTERED` from a consent block | Never | Sending anyway is a compliance violation | +| `FILTERED` from a route denial | No | Policy decision; escalate | +| `BLOCKED` | Yes | After the account condition is resolved | +| `SCHEDULED` | No | It resumes automatically; a resend duplicates it | +| Ambiguous send where the client never saw a response | Retry only with the original key | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient that proves non-execution | + +## Cost review before a multi-channel send + +Before executing any send whose `channel` array has more than one value, state the arithmetic to the user: recipients times channels equals messages equals charges. Confirm the intent is genuinely simultaneous multi-channel delivery. If the intent is preference or fallback, change the request to automatic routing instead. + +For volume, the per-request recipient ceiling is 1,000. Documented pacing pairs full 1,000-recipient batches with roughly one request per second to stay inside the 200-requests-per-minute limit, and rate-limit headers appear only on `429` responses, so pacing must be designed rather than discovered. + +## Worked examples + +**"We wanted WhatsApp with SMS fallback and every customer got two messages."** +The request used `["whatsapp", "sms"]`, which broadcasts. The `202` response contained two message ids per recipient, both of which were charged. The correct request omits `channel` entirely. Automatic routing then attempts a route and, on a route-level or recipient-side WhatsApp failure, reroutes to another candidate on the same `message_id`. + +**"A message failed on WhatsApp and then delivered on SMS, but our dashboard shows it as failed."** +Automatic routing rerouted on the same `message_id`, so the receiver saw `message.failed` with `channel: whatsapp` followed by newer `queued`, `routed`, and `delivered` events with `channel: sms`. The dashboard treated `FAILED` as permanently terminal. Project current state by event timestamp, preserve the append-only route history, and let a newer reroute outcome replace the earlier attempt failure. + +**"All sends to one country come back FAILED with channel auto."** +No routing rule matched for that destination. Activities show no attempted route. This is not fixable from the request payload; escalate the destination coverage rather than retrying. + +**"A pinned RCS send failed instantly and never tried SMS."** +Correct behavior. Pinning restricts matching to RCS, and a pinned send never crosses channels. Use automatic routing to allow another channel. diff --git a/plugins/sent/skills/sent-routing-strategist/references/routing-model.md b/plugins/sent/skills/sent-routing-strategist/references/routing-model.md new file mode 100644 index 0000000..df8eabd --- /dev/null +++ b/plugins/sent/skills/sent-routing-strategist/references/routing-model.md @@ -0,0 +1,136 @@ +# Sent channel routing model + +## Table of contents + +- [Channel vocabulary](#channel-vocabulary) +- [Broadcast semantics](#broadcast-semantics) +- [Route selection pipeline](#route-selection-pipeline) +- [Pinned-channel behavior](#pinned-channel-behavior) +- [Send-time fallback across candidates](#send-time-fallback-across-candidates) +- [Reroute after delivery failure](#reroute-after-delivery-failure) +- [Where each channel value surfaces](#where-each-channel-value-surfaces) +- [Status lifecycle](#status-lifecycle) +- [Channel capability limits](#channel-capability-limits) + +## Channel vocabulary + +| Value | Meaning | +| --- | --- | +| `sent` | Automatic routing. The default when `channel` is omitted or supplied as an empty array | +| `sms` | Pin to SMS | +| `whatsapp` | Pin to WhatsApp | +| `rcs` | Pin to RCS | +| `auto` | Internal placeholder for an unresolved automatic route. Appears in responses and events, never as an input | + +Any other value returns `400`. Treat `auto` as read-only diagnostic output and never display it to an end user as a channel name. + +## Broadcast semantics + +The `channel` array enumerates channels to send on, not an order of preference. The number of messages created is `len(to) × len(channel)`, each with its own `message_id`, its own lifecycle, and its own charge. + +```json +{ + "to": ["+14155551234", "+14155555678"], + "channel": ["whatsapp", "sms"], + "template": { "name": "order_confirmation", "parameters": { "order_id": "12345" } } +} +``` + +That request creates four messages. The `202` response enumerates all four in `data.recipients[]`. + +There is no `fallback` field, no priority weighting, and no way to express "try this, then that" through the array. Ordered arrays or explicit failover objects are common on incumbent platforms, which makes porting them into Sent's channel array a high-risk migration defect. + +## Route selection pipeline + +For automatic routing, the platform evaluates its maintained routing rules at send time. Rules can constrain on: + +- recipient attributes: country, number prefix, exact number, carrier, number type, and ported state; +- the sender; +- template attributes: id, name, and category; +- channel; +- whether the destination is international. + +Candidates are ordered by: + +1. exact-recipient rules ahead of everything else; +2. account-scoped rules ahead of global rules; +3. match specificity; +4. explicit rule priority; +5. longer number prefix; +6. older rule as the final tie-break. + +Exclusions applied before selection: inactive, deleted, or expired rules; rules whose own minimum match threshold is not met; and candidates on a channel where the template carries an explicit non-approved review status such as rejected, pending, or paused. A channel with **no** recorded per-channel review is not excluded — absence of review is not disapproval. + +The first surviving candidate becomes the attempted route, the message moves to `ROUTED`, and `message.routed` fires. Remaining candidates stay available as fallback routes for that message. + +Two consequences worth stating to users. There is no fixed cross-channel preference order, so any claim like "RCS is tried first" is unsupported. And because rules are platform-maintained rather than caller-supplied, an application cannot express routing preference through the API; it can only choose automatic routing or pin a channel. + +## Pinned-channel behavior + +Pinning narrows candidate matching to the named channel. Rules that carry no channel constraint still match and resolve to the pinned channel, so pinning works even without channel-specific rules. + +A pinned message never crosses to a different channel. Same-channel provider hops remain possible when the matched rule permits them, which means a pinned SMS message can still be retried through a different provider. If no route matches on the pinned channel, the message ends `FAILED` with no route matched rather than falling back. + +Pin for a hard requirement: a compliance rule that mandates a channel, a contract that prices a channel, or content that only renders on one channel. Prefer automatic routing everywhere else. + +## Send-time fallback across candidates + +Fallback at send time walks the candidate list rather than the caller's array. When a candidate route carries a DENY decision that permits fallback, evaluation moves to the next candidate. When a DENY does not permit fallback — including the case where every candidate is denied — the message finalizes as `FILTERED` and the record carries the denied route's channel. + +This is why `FILTERED` must never be retried blindly. The gate is a policy decision, most often consent, and repeating the send reproduces the same outcome while risking a compliance violation. + +## Reroute after delivery failure + +Reroute happens only when a terminal failure indicates a route or carrier problem that another route might overcome: + +| Failure signal | Reroutes | +| --- | --- | +| Undeliverable by this route | Yes | +| Provider service unavailable | Yes | +| Provider timeout | Yes | +| Transport error | Yes | +| Recipient-side rejection on WhatsApp after acceptance | Yes, and records a recipient-scoped rule that WhatsApp is not deliverable for that number | +| Invalid content or template parameters | No | +| Consent block | No | +| Account precondition | No | +| Any other failure | No | + +Mechanics that affect application code: the reroute reuses the same `message_id`, re-runs the pipeline so `QUEUED` and `ROUTED` transitions and their webhooks fire again, excludes already-attempted routes, re-applies consent gates on every attempt, and stops at a ceiling of three distinct channel-and-provider pairs across the initial send and all reroutes. + +Therefore a `message.failed` event is not proof of final failure. Treat a message as finally failed only when its stored state remains failed and no further events arrive, and make the status projection idempotent so repeated `queued` and `routed` events do not double-count. + +## Where each channel value surfaces + +| Surface | Automatic routing | Pinned channel | +| --- | --- | --- | +| `202` response `data.recipients[].channel` | Not a resolved route, and never updated later | The pinned channel | +| `message.queued`, `message.routed`, `message.scheduled` | `sent` | The pinned channel | +| Terminal events after a route was attempted | The attempted route's channel | The pinned channel | +| Terminal events for a message that ended before routing | `auto` | `auto` | +| `GET /v3/messages/{id}` before routing | `auto` | The pinned channel | +| `GET /v3/messages/{id}` after routing | The attempted route's channel | The pinned channel | + +The practical rule: never treat the send response as routing evidence. Resolve routes from `message.routed`, from `GET /v3/messages/{id}` after routing, or from the activity history. + +## Status lifecycle + +| Status | Final for the logical message | Meaning | +| --- | --- | --- | +| `QUEUED` | No | Accepted into the pipeline | +| `SCHEDULED` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `ROUTED` | No | A channel and provider were selected | +| `SENT` | No | Handed to the provider | +| `DELIVERED` | Yes | Confirmed at the handset | +| `READ` | Yes | Read by the recipient; WhatsApp and RCS only | +| `FAILED` | Not always | One attempt failed; a newer automatic reroute may follow on the same message id | +| `FILTERED` | Yes | Policy gate: consent block or route denial | +| `BLOCKED` | Yes | Account precondition: balance, onboarding quota, unapproved template | +| `RECEIVED` | — | Inbound message | + +`FILTERED` and `BLOCKED` are not carrier failures. Feeding them into retry logic produces either a compliance problem or a retry loop that cannot succeed until an account action is taken. + +## Channel capability limits + +RCS currently supports text plus up to four suggestion chips, mapped from template buttons, with rich cards, carousels, and media on the roadmap. Every outbound RCS message receives an appended STOP chip, so an RCS surface always exposes an opt-out affordance the application did not author. + +`READ` reaches only WhatsApp and RCS; its absence on SMS is expected. Inbound support differs by channel as well — SMS inbound depends on an MO-capable provider and a supported number type, so alphanumeric sender IDs never receive replies. Route consent and inbound questions to the two-way messaging skill. diff --git a/plugins/sent/skills/sent-two-way-messaging/SKILL.md b/plugins/sent/skills/sent-two-way-messaging/SKILL.md new file mode 100644 index 0000000..ce9f32c --- /dev/null +++ b/plugins/sent/skills/sent-two-way-messaging/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sent-two-way-messaging +description: Designs inbound and conversational Sent flows — opt-out and opt-in keyword handling, consent state on contacts, auto-replies inside the WhatsApp 24-hour window, RCS STOP chips, conversation history retrieval, and per-channel inbound capability. Use when handling message.received events, implementing STOP or HELP behavior, restoring consent after an opt-out, building a support inbox or chatbot on Sent, or paginating conversation history. +--- + +# Sent Two-Way Messaging + +Inbound messaging on Sent has one governing rule: **consent is enforced by the platform before the application sees the event.** An inbound `STOP` has already flipped the contact's `opt_out` flag by the time `message.received` arrives. The application's job is to record it, reflect it in its own UI, and never attempt to send around it. + +## Keyword handling + +Ten keywords ship as defaults: + +| Action | Keywords | +| --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | +| Help auto-reply | `HELP`, `INFO` | + +Matching requires the **entire trimmed message body** to equal a keyword, case-insensitively. "Please stop messaging me" does not match; "stop" does. Custom keywords are configured in the Sent Dashboard under Compliance, Opt Keywords, with an action of Opt Out, Opt In, or Help, and each must be a single exact token. + +Do not claim keywords that are not in the documented set. In application code, mirror the same exact-match rule only to update local subscriber state and audit evidence; never use that matcher to apply consent to Sent a second time. Keep custom dashboard keywords synchronized with the local mirror, and reconcile against the contact's `opt_out` field when uncertain. + +## Consent state + +An opt-out sets `opt_out` on the contact record. Consent is **contact-level and channel-agnostic**: a `STOP` sent over SMS suppresses WhatsApp and RCS for that contact as well. Consent gates re-apply on every reroute attempt, not only at initial send. + +Restoring consent requires the recipient's own action. A user-initiated opt-in keyword clears suppression. `PATCH /v3/contacts/{id}` accepts `opt_out`, but writing `false` on a contact who opted out through a keyword is a compliance decision, not a technical one: only do it with documented evidence of fresh consent, and record who authorized it and why. + +Downstream, a suppressed send does not fail with an error. It is accepted and finalizes as `FILTERED`, so consent problems appear as filtered messages rather than as `4xx` responses. Details are in [references/consent-and-keywords.md](references/consent-and-keywords.md). + +## Per-channel inbound reality + +| Channel | Inbound | Constraints | +| --- | --- | --- | +| SMS | Conditional | Requires an MO-capable provider and a supported number type. Alphanumeric sender IDs and SMPP paths without an inbound route never deliver inbound messages | +| RCS | Full | Typed replies match keywords; the appended STOP chip is processed directly by the consent engine | +| WhatsApp | Full | Free-form replies only inside the 24-hour customer service window; outside it, an approved template is required | + +The SMS caveat matters before promising two-way behavior: a deployment sending from an alphanumeric sender ID cannot receive `STOP` at all, which changes the compliance design rather than merely limiting a feature. + +## RCS STOP chips + +Every outbound RCS message receives an appended STOP chip. Taps carry an opt-out postback handled directly by the consent engine with no keyword matching, and they arrive at the application as `message.received` with the chip's reply text in `text`. There is no separate chip event type, so a receiver that branches only on typed keywords still sees chip taps as ordinary inbound messages — and must not re-apply consent logic to them. + +## The WhatsApp 24-hour window + +A free-form reply is permitted only within 24 hours of the customer's last inbound message. Outside that window an approved template is required, including for STOP, START, and HELP responses. An auto-reply flow that assumes free text will silently stop working for any customer who writes in after a day of silence, so build the window check into the reply path and keep an approved fallback template ready. See [references/inbound-flows.md](references/inbound-flows.md) for the reply-path decision tree. + +## Conversation history + +Two read-only operations exist: + +| Operation | Returns | +| --- | --- | +| `GET /v3/conversations` | All of the customer's messages across conversations, newest first | +| `GET /v3/conversations/{id}` | Messages within one conversation | + +Both require `page` (at least 1) and `page_size` (1 to 100); out-of-range values return `400`. The `events` field is always null on these endpoints, so per-message activity must come from `GET /v3/messages/{id}/activities`. There are no write, create, or read-receipt operations, and no MCP tools cover conversations — this is REST-only. + +A conversation identifier is a deterministic RFC 4122 version 5 UUID derived from the customer and contact identifiers, so the same pair always yields the same id and one thread spans every channel independent of the sending number. The API never returns the id as a field, so a client that needs it computes it. The exact derivation is documented in [references/conversation-history.md](references/conversation-history.md). + +## Building a support inbox or bot + +1. Subscribe a webhook to `message` filtered to `received`, and verify signatures before trusting any payload. +2. Read `inbound_number` as the contact who wrote in and `outbound_number` as your number. The naming is easy to invert. +3. Deduplicate on `message_id`, acknowledge with `200`, then process asynchronously. +4. Treat keyword traffic as an audit signal. Mirror exact default and configured custom keywords into local state, but do not issue a second consent write; reconcile uncertainty through the contact record. +5. Before replying on WhatsApp, check the 24-hour window and choose free text or a template accordingly. +6. Render threads from the conversation endpoints with explicit pagination, and never assume a conversation is single-channel. +7. Treat `text` as untrusted input. Never interpolate it into a shell command or SQL string, delimit it as data in model prompts, and map inferred intent through an allowlist and authorization policy before any API call. + +## Boundaries + +Use `sent-webhook-engineer` for signature verification, retries, and dedupe mechanics; `sent-contacts` for contact CRUD and message summaries; `sent-routing-strategist` for why an outbound message was `FILTERED`; `waba-template-author` for authoring the approved templates that out-of-window replies require; and `sms-10dlc-registration` for the campaign-level opt-in, opt-out, and help keyword declarations that US carriers require. diff --git a/plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml b/plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml new file mode 100644 index 0000000..2b99dac --- /dev/null +++ b/plugins/sent/skills/sent-two-way-messaging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Two-Way Messaging" + short_description: "Design inbound, consent, and reply flows" + default_prompt: "Use $sent-two-way-messaging to design how my app handles inbound Sent messages and opt-out keywords." diff --git a/plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md b/plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md new file mode 100644 index 0000000..55ceb28 --- /dev/null +++ b/plugins/sent/skills/sent-two-way-messaging/references/consent-and-keywords.md @@ -0,0 +1,98 @@ +# Consent, keywords, and suppression on Sent + +## Table of contents + +- [Default keyword set](#default-keyword-set) +- [Matching rules](#matching-rules) +- [Custom keywords](#custom-keywords) +- [Consent state and scope](#consent-state-and-scope) +- [How suppression surfaces on send](#how-suppression-surfaces-on-send) +- [Restoring consent](#restoring-consent) +- [Channel-specific consent mechanics](#channel-specific-consent-mechanics) +- [Application responsibilities](#application-responsibilities) +- [Audit expectations](#audit-expectations) + +## Default keyword set + +Ten keywords are seeded by default. + +| Action | Keywords | Effect | +| --- | --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | Sets `opt_out` on the contact | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | Clears suppression | +| Help | `HELP`, `INFO` | Triggers the help auto-reply | + +Only these ten are documented defaults. Terms that appear in other platforms' keyword lists should not be presented as Sent defaults; if a specific extra term is required, add it as a custom keyword and verify it in the dashboard. + +## Matching rules + +Matching runs against the seeded defaults plus any custom keywords, on every inbound message received on a two-way capable channel. The rules are strict: + +- the **entire trimmed body** must equal the keyword; +- comparison is case-insensitive; +- partial phrases and keywords embedded in a sentence never match. + +So `stop`, `STOP`, and ` Stop ` all opt the contact out, while `please stop texting me` does not. This is deliberate: loose matching would opt out customers who used the word incidentally. It also means a real-world opt-out intent expressed in a sentence will not be caught automatically, which is a reason to route inbound text to a human queue rather than assuming keyword coverage is complete. + +Mirror this exact matcher in the application when local subscriber state or audit evidence is required. The platform has already applied consent by the time the event arrives, so the local matcher must never issue a second consent write. Keep configured custom keywords synchronized and reconcile uncertain state from the contact's `opt_out` field. + +## Custom keywords + +Custom keywords are configured in the Sent Dashboard under Compliance, then Opt Keywords. Each entry names a single exact token and one action: Opt Out, Opt In, or Help. There is no REST or MCP surface for keyword management, so keyword changes are a dashboard operation that cannot be scripted; treat the configured set as an environment fact to be read, documented, and version-controlled in the application's own runbook. + +When designing custom keywords, prefer short single tokens in the languages the audience actually writes in, and avoid tokens that collide with normal replies such as `YES` or `NO` if those are used for other flows. + +## Consent state and scope + +An opt-out flips `opt_out` on the contact record. Two properties of that state are load-bearing: + +**Contact-level.** Consent attaches to the contact, not to a campaign, template, or sending number. There is no per-template or per-campaign suppression list. + +**Channel-agnostic.** A keyword received on any channel suppresses every channel. A customer who texts `STOP` over SMS will not receive WhatsApp or RCS messages either. Applications that model consent per channel will over-send relative to the platform and see the difference as filtered messages. + +Consent gates re-apply on **every** reroute attempt, not only at the initial send. A message that passed the gate at send time is still re-checked when automatic routing retries it on another route. + +## How suppression surfaces on send + +A send to a suppressed contact is not rejected with a `4xx`. The request is accepted, and the affected message finalizes as `FILTERED` with a terminal channel value of `auto` when the block occurred before routing. Consequences: + +- Consent problems appear in delivery data rather than in API error handling, so a client that only inspects HTTP status codes will not notice them. +- `FILTERED` must never be retried. Retrying a consent block is a compliance violation, and it cannot succeed. +- Consent-driven filtering should be monitored as its own metric. A rising filtered rate usually means a stale local suppression list rather than a delivery problem. + +## Restoring consent + +Consent restoration is the recipient's decision. The clean path is a user-initiated opt-in keyword, which clears suppression through the same engine that set it. + +`PATCH /v3/contacts/{id}` accepts `opt_out` as a writable field, so it is technically possible to clear the flag from the API. Treat that as a compliance action rather than a data fix: + +1. Require documented evidence of fresh consent — a form submission, a recorded confirmation, or a written request. +2. Record who authorized the change, when, and against which evidence. +3. Never bulk-clear `opt_out` across a contact list, and never clear it to "fix" a filtered-message metric. +4. Prefer asking the customer to text an opt-in keyword, which produces platform-side evidence. + +An agent asked to clear `opt_out` should surface the compliance implication and require explicit confirmation naming the consent evidence before proceeding. + +## Channel-specific consent mechanics + +| Channel | Inbound keyword path | Notes | +| --- | --- | --- | +| SMS | Requires an MO-capable provider and a supported number type | Alphanumeric sender IDs and SMPP paths without an inbound route never deliver `STOP`; plan compliance around that limitation | +| RCS | Typed replies match keywords; the appended STOP chip bypasses matching | Every outbound RCS message carries a STOP chip whose tap is handled directly by the consent engine | +| WhatsApp | Full inbound support | Replies to STOP, START, or HELP outside the 24-hour window require an approved template | + +The RCS STOP chip means an RCS deployment always exposes an opt-out affordance the application did not author, and its taps arrive as ordinary `message.received` events with the chip's reply text in `text`. There is no distinct event type for a chip tap. + +## Application responsibilities + +The platform owns enforcement; the application owns reflection and evidence. + +- Mirror `opt_out` into local state by exact-matching the documented and configured keyword set, and reconcile from `GET /v3/contacts/{id}` when uncertain. +- Never use the local text match to re-apply consent to Sent; it is a mirror and audit mechanism only. +- Show suppression state in any internal UI where staff could otherwise trigger a send. +- Keep the local mirror reconciled on a schedule, since a keyword can arrive at any time and a stale mirror produces filtered messages. +- For US SMS, keep the campaign-level opt-in, opt-out, and help keyword declarations consistent with what is actually configured; carrier registration expects them to match. + +## Audit expectations + +Retain, per consent change: the inbound `message_id`, the received timestamp, the channel, the exact matched text, and the resulting state. Inbound keyword events are the strongest evidence available that a customer opted out or back in, and they are the artifact a carrier or regulator will ask for. Because Sent applies consent before the event is delivered, the event is a record of a completed action, and treating it as a request to perform an action risks double-processing. diff --git a/plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md b/plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md new file mode 100644 index 0000000..f32649f --- /dev/null +++ b/plugins/sent/skills/sent-two-way-messaging/references/conversation-history.md @@ -0,0 +1,55 @@ +# Conversation history retrieval + +## Operations + +| Operation | Returns | Required query parameters | +| --- | --- | --- | +| `GET /v3/conversations` | All of the calling customer's messages across conversations, newest first | `page` (>= 1), `page_size` (1–100) | +| `GET /v3/conversations/{id}` | Messages within one conversation | `page` (>= 1), `page_size` (1–100) | + +Both are read-only. There is no create, update, delete, participant, or read-receipt operation, and no MCP tool covers conversations, so this is REST-only work even inside an MCP-authorized session. Out-of-range pagination values return `400`; missing credentials return `401`. + +The OpenAPI summaries for these two operations are swapped relative to their descriptions — the list endpoint is summarized as "list conversation messages" while its description says messages across all conversations, and the single-conversation variant reads the opposite way. Trust the descriptions and the behavior above. + +## Message record fields + +Each returned message carries: + +`id`, `customer_id`, `contact_id`, `phone`, `phone_international`, `region_code`, `template_id`, `template_name`, `template_category`, `channel`, `message_body` (with `header`, `content`, `footer`, and `buttons`), `status`, `direction` (`INBOUND` or `OUTBOUND`), `created_at`, `price`, `active_contact_price`, and `events`. + +`events` is always null on these endpoints. Per-message activity history must come from `GET /v3/messages/{id}/activities`, which is also the only place a reroute's sequence of attempted routes is visible. + +## Conversation identifiers + +A conversation id is a deterministic RFC 4122 version 5 UUID. It is derived from the namespace `9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99` and the name `{customer_id}:{contact_id}`, where both identifiers are lowercase canonical UUIDs and the customer id comes first. Version 5 uses SHA-1 name-based hashing, equivalent to PostgreSQL's `uuid_generate_v5`. + +```python +import uuid + +NAMESPACE = uuid.UUID("9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99") + + +def conversation_id(customer_id: str, contact_id: str) -> str: + name = f"{customer_id.lower()}:{contact_id.lower()}" + return str(uuid.uuid5(NAMESPACE, name)) +``` + +Three properties follow. The same customer-and-contact pair always yields the same id, so it can be computed offline and used as a stable local key. The API never returns the id as a field, so a client that needs it must derive it. And because the id depends only on customer and contact, **one thread spans every channel** and is independent of which sending number or channel was used — a customer who moves between SMS and WhatsApp stays in one conversation. + +## Pagination strategy + +Always pass `page` and `page_size` explicitly; there is no usable default. Results are newest-first, so page 1 is the most recent slice and a thread view should either reverse each page for display or fetch from the oldest page. + +Because the collection grows while it is being read, a long backfill can shift items across page boundaries. For an initial sync, page through quickly with `page_size: 100` and reconcile by message `id`, then switch to incremental updates driven by `message.received` and status webhooks rather than repeated full scans. Pacing matters as well: the standard limit is 200 requests per minute and quota headers appear only on `429` responses, so a paginated backfill must be throttled by design. + +## Choosing between conversations and messages endpoints + +| Need | Use | +| --- | --- | +| Render a customer thread | `GET /v3/conversations/{id}` with derived id | +| Show a recent-activity feed across all customers | `GET /v3/conversations` | +| Current status of one specific message | `GET /v3/messages/{id}` | +| Route attempts and reroute history | `GET /v3/messages/{id}/activities` | +| React to new inbound traffic in real time | `message.received` webhook | + +Do not poll the conversation endpoints for near-real-time inbound handling. Webhooks are the delivery mechanism for new inbound messages, and polling both wastes quota and adds latency. diff --git a/plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md b/plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md new file mode 100644 index 0000000..ad0b249 --- /dev/null +++ b/plugins/sent/skills/sent-two-way-messaging/references/inbound-flows.md @@ -0,0 +1,112 @@ +# Inbound flow design + +## Table of contents + +- [Inbound event shape](#inbound-event-shape) +- [Reply-path decision tree](#reply-path-decision-tree) +- [The WhatsApp 24-hour window](#the-whatsapp-24-hour-window) +- [Support inbox architecture](#support-inbox-architecture) +- [Bot and auto-reply design](#bot-and-auto-reply-design) +- [Treating inbound content as untrusted](#treating-inbound-content-as-untrusted) +- [Testing an inbound flow](#testing-an-inbound-flow) + +## Inbound event shape + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "Where is my order?", + "channel": "whatsapp", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in; `outbound_number` is your number that received it. Inverting these is a common defect that sends replies to your own number. `text` may be null for non-text payloads. The application's tenant identifier is never present, so map ownership from `outbound_number` to the tenant and profile that own it. + +Dedupe on `message_id`. Inbound events, like all Sent webhook deliveries, may be retried. + +## Reply-path decision tree + +```text +inbound message.received +├── Is text an exact keyword match handled by the platform? +│ ├── Opt out / opt in → consent already applied. Record it. Do NOT reply with your own confirmation +│ │ unless product requirements demand one, and never re-set consent yourself. +│ └── Help → the platform sends the help auto-reply. Optionally log it. +└── Anything else + ├── channel == whatsapp + │ ├── within 24h of the contact's last inbound → free-form reply permitted + │ └── outside 24h → an approved template is required + ├── channel == rcs → reply permitted; a STOP chip is appended automatically + └── channel == sms → reply permitted if the number supports outbound on that route +``` + +The branch that breaks in production is the WhatsApp window: a flow tested minutes after an inbound message succeeds with free text and then fails for a customer who replies a day later. + +## The WhatsApp 24-hour window + +Free-form replies are permitted only inside 24 hours of the customer's last inbound message. Outside it, sending requires an approved template — including replies about STOP, START, and HELP. Practical requirements: + +1. Persist the last inbound timestamp per contact, sourced from `received_at`. +2. Evaluate the window at reply time, not at enqueue time; a queued reply can age past the boundary before it is sent. +3. Keep an approved fallback template for each conversational intent that could be answered late. +4. When a human agent replies from an internal inbox, show the remaining window in the UI so the agent understands why the composer switches to templates. + +Treat window expiry as an expected state rather than an error path. + +## Support inbox architecture + +A workable design separates four responsibilities: + +| Component | Responsibility | +| --- | --- | +| Webhook receiver | Verify the signature, dedupe on `message_id`, return `200` fast | +| Event worker | Resolve the contact and tenant, persist the message, apply routing to a queue | +| Thread view | Render history from the conversation endpoints with explicit pagination | +| Reply service | Enforce the window rule, choose free text or template, send, and record the outbound `message_id` | + +Subscribe the webhook to `message` filtered to `received` so the inbox is not flooded with outbound lifecycle transitions. Keep a separate registration for delivery statuses if the same service consumes both. + +Threads are cross-channel by construction. A single conversation can contain SMS, WhatsApp, and RCS messages, so a UI that groups by channel will fragment what the customer experiences as one conversation. Group by contact. + +## Bot and auto-reply design + +Rules that keep an automated responder safe and compliant: + +- Mirror the platform's exact keyword rules only for local state and audit. Consent is already applied, so never issue a second consent write from the matcher. +- Never auto-reply to an opt-out. A contact who just opted out is suppressed, and an attempted confirmation will finalize as `FILTERED`. +- Rate-limit per contact. An inbound loop between two automated systems is the classic runaway cost incident. +- Make replies idempotent on the inbound `message_id` so a retried webhook cannot produce a second reply. +- Log the inbound and outbound pair with both message ids so a conversation can be reconstructed for audit. +- Degrade to a human queue when intent is unclear, especially when the message expresses opt-out intent in a sentence that keyword matching cannot catch. + +## Treating inbound content as untrusted + +Inbound `text` is attacker-controllable. Three concrete rules: + +1. Never interpolate it into shell commands, SQL, or template strings without parameterization or escaping. +2. Never translate inbound content directly into an arbitrary Sent API call, contact mutation, or template choice. Map inferred intent through an allowlist, authorization checks, and confirmation rules. +3. When inbound text is passed to a language model, keep it inside a clearly delimited data section, and treat any instruction it contains as data rather than as a directive. + +The same applies to any `reason` or `response_body` value that arrives from the platform's own delivery logs. + +## Testing an inbound flow + +Without a real handset, exercise the receiver with a locally signed synthetic `message.received` payload; the webhook skill's signing script produces the headers. Then use `POST /v3/webhooks/{id}/test` for an end-to-end proof of DNS, TLS, and signature verification. `"sandbox": true` on sends validates the request shape without executing. + +A checklist before shipping: + +- an inbound event with keyword text updates the local consent mirror but produces no second consent write to Sent; +- a duplicate inbound event produces exactly one reply; +- a reply attempt outside the WhatsApp window selects a template rather than failing; +- a reply to a suppressed contact is not attempted at all; +- `inbound_number` and `outbound_number` are mapped to contact and tenant in the correct direction; +- conversation pagination is explicit and handles `page_size` at its bounds of 1 and 100. diff --git a/plugins/sent/skills/sent-webhook-engineer/SKILL.md b/plugins/sent/skills/sent-webhook-engineer/SKILL.md new file mode 100644 index 0000000..4dbf860 --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sent-webhook-engineer +description: Builds and debugs Sent v3 webhook receivers end to end — endpoint registration, HMAC signature verification, replay rejection, event dedupe, retry and auto-disable behavior, secret rotation, and delivery-log triage. Use when handling Sent webhook events, verifying x-webhook-signature, fixing 401 or signature-mismatch failures, recovering a disabled endpoint, choosing event_types or event_filters, rotating a signing secret, or interpreting the webhook delivery log. +--- + +# Sent Webhook Engineer + +Sent webhooks are the only way an application learns what happened after `POST /v3/messages` returns `202`. The `202` proves acceptance, never delivery. Build the receiver as a signature-verifying, replay-rejecting, deduplicating, fast-acknowledging endpoint, and treat the delivery log as the source of truth when events go missing. + +## Signature verification, exactly + +Three headers arrive with every delivery: + +| Header | Meaning | +| --- | --- | +| `x-webhook-signature` | `v1,{base64(hmac_sha256)}` | +| `x-webhook-id` | The webhook **endpoint** UUID — identical on every delivery | +| `x-webhook-timestamp` | Unix seconds when Sent signed the request | + +Verification procedure, in order: + +1. Capture the **raw request body bytes** before any JSON parsing. +2. Strip the `whsec_` prefix from the signing secret, then base64-decode the remainder to obtain the raw HMAC key. +3. Build the signed content as `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`. +4. Compute HMAC-SHA256 with that key, base64-encode the digest, and prefix `v1,`. +5. Compare with a constant-time comparison. +6. Reject when `abs(now - timestamp) > 300` seconds. + +The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. + +**`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. + +## Failure triage order + +When a receiver rejects or misses events, work this sequence rather than guessing: + +1. **Signature mismatch** — a body-mutating middleware or framework JSON parser is the cause in the majority of cases. Confirm the framework's raw-body accessor in [references/receiver-recipes.md](references/receiver-recipes.md). +2. **Replay rejection** — server clock skew beyond the 300-second tolerance. +3. **Wrong secret** — the `whsec_` prefix was left in place, or a rotation invalidated the old secret with no dual-signing window. +4. **Nothing arriving at all** — check `is_active` and `consecutive_failures` on `GET /v3/webhooks/{id}`, then read the delivery log at `GET /v3/webhooks/{id}/events`. +5. **Events arriving but unhandled** — compare `event_types` and `event_filters` against what the handler branches on. + +## Retry, auto-disable, and recovery + +A delivery attempt fails on any non-2xx status, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff with the first retry roughly one minute after the failure, doubling thereafter and capped at 60 minutes between attempts, stopping on the first 2xx or when `retry_count` is exhausted. Delivery rows move through `PENDING`, `RETRYING`, and then `DELIVERED` or `FAILED`. + +`consecutive_failures` tracks consecutive failed delivery attempts. Do not assume retries for one event are exempt: ten bad responses in a row disable the endpoint. After fixing the receiver, re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the Sent Dashboard. Any successful delivery resets the counter to zero. Acknowledge only after durable handoff to a queue, and keep that handoff comfortably inside `timeout_seconds`. + +## Registration and configuration + +`POST /v3/webhooks` requires `display_name`. Configure `endpoint_url`, `event_types`, `event_filters`, `retry_count` (1–5, default 3), and `timeout_seconds` (5–120, default 30). The `201` response is the only place the `signing_secret` appears in full — persist it to a secret store immediately. + + +```json +{ + "display_name": "Production delivery events", + "endpoint_url": "https://hooks.example.com/webhooks/sent", + "event_types": ["message", "templates"], + "event_filters": { + "message": ["delivered", "failed", "received"] + }, + "retry_count": 3, + "timeout_seconds": 30 +} +``` + +Set `event_filters` deliberately. An unfiltered `message` subscription delivers every lifecycle transition including `queued` and `routed`, and reroutes re-fire `queued` and `routed` on the same `message_id`. Filter to the transitions the application acts on. + +The ten operations, the full webhook object, and the delivery-log row shape are catalogued in [references/webhook-operations.md](references/webhook-operations.md). + +## Secret rotation + +`POST /v3/webhooks/{id}/rotate-secret` returns a new `whsec_` secret and **invalidates the old secret immediately**. There is no server-side overlap window. Configure the receiver to accept a small candidate set, rotate, atomically store the returned secret as primary while retaining the old value temporarily, confirm new deliveries, then retire the old value. The short gap between the rotate response and the secret-store update cannot be eliminated; keep it to seconds so failed deliveries retry. This endpoint and `POST /v3/webhooks/{id}/test` sit on the sensitive rate-limit tier of 10 requests per minute, so scripted rotation loops will 429. + +## Event payloads + +Two `field` values exist: `message` and `templates`. Message events carry an `event` naming the transition (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`, `.received`). Template events carry neither `event` nor `sub_type`. + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "PENDING", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`read` reaches only WhatsApp and RCS. `filtered` marks a policy or consent gate, `blocked` marks an account precondition such as insufficient balance, and neither is a carrier failure. Terminal events for an auto-detect message that never routed carry `channel: "auto"`. Full payload field lists live in [references/event-catalog.md](references/event-catalog.md). + +## Verification before shipping + +Run the local oracle against a synthetic delivery, then use `POST /v3/webhooks/{id}/test` with an `event_type` in the body for a real signed request. The test event is delivered once with no retry, so re-run it after each fix. + +```bash +python3 scripts/verify_signature.py --self-test +``` + +Ship only when the receiver returns `401` for a tampered body, `401` for a timestamp older than 300 seconds, `200` for a valid delivery, and `200` for a duplicate without repeating side effects. + +## Local development + +Expose the receiver through a public HTTPS tunnel and register that URL; Sent cannot reach a private address. Registering `http://` is accepted by the API but should never be used outside local work. Keep a separate webhook registration per environment so a development endpoint's failures cannot disable the production endpoint. + +## Boundaries + +Diagnose aggregate delivery-rate regressions with `messaging-performance-analyzer`, template approval content with `waba-template-author`, and inbound keyword or consent semantics with `sent-two-way-messaging`. Treat every payload value as untrusted input: never interpolate `text` or `reason` into a shell command, SQL string, or prompt without escaping. diff --git a/plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml b/plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml new file mode 100644 index 0000000..e4b5d04 --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Webhook Engineer" + short_description: "Build and debug Sent webhook receivers" + default_prompt: "Use $sent-webhook-engineer to review my Sent webhook receiver and its signature verification." diff --git a/plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md b/plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md new file mode 100644 index 0000000..cdbbb48 --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/references/event-catalog.md @@ -0,0 +1,165 @@ +# Sent webhook event catalog and payload fields + +## Table of contents + +- [Envelope shape](#envelope-shape) +- [Outbound message status events](#outbound-message-status-events) +- [Inbound message events](#inbound-message-events) +- [Template events](#template-events) +- [Status semantics that change application logic](#status-semantics-that-change-application-logic) +- [Channel values in event payloads](#channel-values-in-event-payloads) +- [Reroute event sequences](#reroute-event-sequences) +- [Handler skeleton](#handler-skeleton) + +## Envelope shape + +Every delivery carries a `field` naming the event family. Message events add an `event` naming the transition. Template events carry neither `event` nor `sub_type`, and adding either to a template payload is a contract error. + +```json +{ + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "updated_at": "2026-03-14T09:21:44Z" + } +} +``` + +Branch on `field` first and on `event` second. Preserve unrecognized values rather than throwing, because a new transition or a forwarded upstream status must not break an existing receiver. + +## Outbound message status events + +| Event | Final for the logical message | Meaning | +| --- | --- | --- | +| `message.queued` | No | Accepted into the pipeline; fires again after a reroute | +| `message.routed` | No | A concrete channel and provider were selected; fires again after a reroute | +| `message.sent` | No | Handed to the provider | +| `message.delivered` | Yes | Provider confirmed handset delivery | +| `message.read` | Yes | Recipient read the message; WhatsApp and RCS only | +| `message.failed` | Not always | One route attempt failed; automatic routing may queue another attempt on the same message id | +| `message.scheduled` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `message.filtered` | Yes | Blocked by a policy gate such as consent or a route denial | +| `message.blocked` | Yes | Blocked by an account precondition such as insufficient balance | + +Payload fields on status events include `message_id`, `message_status`, `channel`, `account_id`, `updated_at`, and a sender-profile identifier when the send was profile-scoped. The application's own tenant identifier is never present, so keep a `message_id` mapping written before the send. + +## Inbound message events + +`message.received` carries a distinct payload: + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "STOP", + "channel": "sms", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in and `outbound_number` is the number they wrote to; the naming trips up receivers that assume `inbound` means "our side." `text` may be null for non-text payloads. RCS suggestion-chip taps, including the appended STOP chip, arrive as `message.received` with the chip's reply text in `text` — there is no separate chip event type. Consent keywords are processed by Sent before the event reaches the application, so an inbound `STOP` is an audit record of an opt-out that already happened, not a request to perform one. + +## Template events + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`whatsapp_template_id` is an empty string until Meta approves the template. Documented statuses are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`, and upstream Meta values such as `PAUSED` and `DISABLED` are forwarded verbatim — treat the field as an open string set. `reason` is present only when a reason exists, most often on rejection. Route rejection content to template authoring rather than trying to auto-correct copy in the receiver. + +## Status semantics that change application logic + +`filtered` and `blocked` are not carrier failures and must not feed retry logic. A `filtered` message hit a policy gate, most often consent or a route denial, so retrying the same send reproduces the same result and, for consent, would be a compliance violation. A `blocked` message hit an account precondition such as insufficient balance, an onboarding quota, or an unapproved template; the fix is an account action, after which a new send is appropriate. + +`delivered` is the first event that proves the recipient's device received the message. The `202` from the send endpoint proves only acceptance, and `sent` proves only provider handoff. Any business rule with legal or financial weight should key on `delivered`. + +`read` exists only on WhatsApp and RCS, and its absence on SMS is expected rather than a failure. + +## Channel values in event payloads + +| Value | Where it appears | Interpretation | +| --- | --- | --- | +| `sms`, `whatsapp`, `rcs` | Any event after routing | The concrete attempted route | +| `sent` | `message.queued`, `message.routed`, `message.scheduled` for automatic routing | Automatic routing requested; not yet a resolved route | +| `auto` | Terminal events for a message that ended before routing | Internal placeholder; the message never reached a channel | +| `null` | Per-recipient echo in the send response for auto-detect | Not a resolved route | + +A terminal event carrying `auto` means the message failed or was gated before any route was attempted, which points at no matching route, invalid template parameters, a consent block, or an account precondition. Do not display `auto` to end users as a channel name. + +## Reroute event sequences + +Automatic routing may retry a failed message on another route, up to three distinct channel-and-provider pairs across the initial send and all reroutes. The retry re-runs the pipeline on the **same `message_id`**, so the event stream for one logical send can look like this: + +```text +message.queued channel=sent +message.routed channel=whatsapp +message.sent channel=whatsapp +message.failed channel=whatsapp (route-level failure) +message.queued channel=sent (reroute begins, same message_id) +message.routed channel=sms +message.sent channel=sms +message.delivered channel=sms +``` + +Three consequences for receiver design. A `message.failed` is not necessarily final, so reconcile the current message and activity state before triggering an irreversible failure action. Repeated `queued` and `routed` events for one id are normal and must be idempotent. The channel can change mid-stream, so store the channel per event rather than overwriting a single field and assuming it is stable. + +Only route-level or carrier-level failures trigger a reroute. Recipient-level and content-level failures stay failed. A WhatsApp message that was accepted and then failed for a recipient-side reason both reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, which is the mechanism behind the WhatsApp-to-SMS fallback that customers observe on automatic routing. + +## Handler skeleton + +```python +from hashlib import sha256 + + +def handle(event: dict, raw_body: bytes) -> None: + field = event.get("field") + value = event.get("value", {}) + + if not record_receipt_once(sha256(raw_body).hexdigest(), event): + return # exact transport retry; still answer 200 + + if field == "templates": + apply_template_state_once(value["template_id"], value.get("status")) + return + + if field != "message": + record_unknown_event(event) + return + + name = event.get("event") + if name == "message.received": + record_inbound(value) # consent already applied upstream + return + + # Compare payload.updated_at with the projected event timestamp. Do not use + # a global status rank: FAILED may be followed by a successful reroute. + apply_if_newer(value["message_id"], value, value.get("updated_at")) + + if value.get("message_status") == "DELIVERED": + perform_once(f"{value['message_id']}:DELIVERED", on_delivered, value) +``` + +Return `200` before doing slow work. Every branch, including the unknown-event branch, must acknowledge rather than raise, and genuine handler failures should return a non-2xx so Sent retries instead of silently discarding the event. diff --git a/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md new file mode 100644 index 0000000..0609e5a --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -0,0 +1,246 @@ +# Webhook receiver recipes by framework + +Every recipe follows the same four steps: read the raw body, verify the signature and timestamp, acknowledge with `200`, then process asynchronously. Only the raw-body accessor and the background mechanism change. + +## Table of contents + +- [Raw-body accessor matrix](#raw-body-accessor-matrix) +- [Node and TypeScript](#node-and-typescript) +- [Python](#python) +- [Go](#go) +- [Java and Spring Boot](#java-and-spring-boot) +- [PHP](#php) +- [Ruby](#ruby) +- [ASP.NET Core](#aspnet-core) +- [Deployment traps](#deployment-traps) + +## Raw-body accessor matrix + +| Framework | Accessor | Trap that breaks the signature | +| --- | --- | --- | +| Next.js route handler | `await request.text()` | Keep the route on the Node runtime; do not re-parse the body first | +| Express | `express.raw({ type: 'application/json' })` on the webhook path | A global `express.json()` replaces the exact bytes | +| NestJS | `req.rawBody` | Requires `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | `await request.body()` | Binding a Pydantic model first re-serializes | +| Django | `request.body` | Middleware that consumes the stream before the view | +| Flask | `request.get_data()` | `request.get_json()` first loses byte fidelity | +| Gin | `io.ReadAll(c.Request.Body)` | Body must be restored if later handlers read it | +| Echo | `io.ReadAll(c.Request().Body)` | Same | +| Spring Boot | `@RequestBody String payload` | Binding to a DTO re-serializes | +| Laravel | `$request->getContent()` | Middleware ordering; verify before any transform | +| Symfony | `$request->getContent()` | Same | +| Rails | `request.body.read` then `request.body.rewind` | `params` re-encodes the payload | +| Sinatra | `request.body.read` then `request.body.rewind` | Same | +| ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | + +Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. + +## Node and TypeScript + +```ts +import crypto from "node:crypto"; + +const TOLERANCE_SECONDS = 300; + +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { + const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; + if (!secret || !header?.startsWith("v1,")) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; + + const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); + const digest = crypto.createHmac("sha256", key).update(`${webhookId}.${timestamp}.${rawBody}`).digest("base64"); + const expected = Buffer.from(`v1,${digest}`); + const received = Buffer.from(header); + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} +``` + +Next.js route handler, kept on the Node runtime: + +```ts +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + const rawBody = await request.text(); + const ok = verify( + rawBody, + request.headers.get("x-webhook-id") ?? "", + request.headers.get("x-webhook-timestamp") ?? "", + request.headers.get("x-webhook-signature") ?? "", + ); + if (!ok) return new Response("invalid signature", { status: 401 }); + + await enqueue(JSON.parse(rawBody)); // hand off, do not process inline + return new Response(null, { status: 200 }); +} +``` + +Express, scoping the raw parser to the webhook path only: + +```js +app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { + const rawBody = req.body.toString("utf8"); + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + return res.status(401).send("invalid signature"); + } + res.status(200).end(); + queue.add("sent-event", JSON.parse(rawBody)); // after the response +}); +``` + +Mount `express.json()` on other routers rather than globally with `app.use`. In NestJS create the app with `{ rawBody: true }` and read `req.rawBody`. Use BullMQ or an equivalent queue for the background step. + +## Python + +```python +import base64, hashlib, hmac, os, time + +TOLERANCE_SECONDS = 300 + + +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: + secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") + if not secret or not header.startswith("v1,"): + return False + try: + if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: + return False + except ValueError: + return False + key = base64.b64decode(secret.removeprefix("whsec_")) + signed = f"{webhook_id}.{timestamp}.".encode() + raw_body + expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() + return hmac.compare_digest(expected, header) +``` + +FastAPI: + +```python +@app.post("/webhooks/sent", status_code=200) +async def receive(request: Request, background: BackgroundTasks): + raw = await request.body() + if not verify(raw, request.headers.get("x-webhook-id", ""), + request.headers.get("x-webhook-timestamp", ""), + request.headers.get("x-webhook-signature", "")): + raise HTTPException(status_code=401, detail="invalid signature") + background.add_task(process_event, json.loads(raw)) + return {"received": True} +``` + +Django reads `request.body` in the view and must exempt the route from CSRF. Flask reads `request.get_data()` in a decorator that wraps the view. For anything slower than a database insert, hand the parsed event to Celery with `process_event.delay(event)` and route message traffic to a dedicated queue so bulk campaigns cannot starve transactional work. + +## Go + +```go +func Verify(rawBody []byte, webhookID, timestamp, header string) bool { + secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") + if secret == "" || !strings.HasPrefix(header, "v1,") { + return false + } + sentAt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 { + return false + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(webhookID + "." + timestamp + ".")) + mac.Write(rawBody) + expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(expected), []byte(header)) == 1 +} +``` + +In Gin read with `io.ReadAll(c.Request.Body)`; in Echo use `c.Request().Body`. If any later middleware needs the body, restore it with `c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))`. Acknowledge, then dispatch to a goroutine with a bounded worker pool or a durable queue, and drain in-flight work on shutdown. + +## Java and Spring Boot + +```java +@PostMapping("/webhooks/sent") +public ResponseEntity receive( + @RequestBody String payload, + @RequestHeader("x-webhook-id") String webhookId, + @RequestHeader("x-webhook-timestamp") String timestamp, + @RequestHeader("x-webhook-signature") String signature) throws Exception { + + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + return ResponseEntity.status(401).build(); + } + events.submit(payload); // @Async executor + return ResponseEntity.ok().build(); +} +``` + +Bind the body as `String`, never as a DTO, because Jackson re-serialization changes the bytes. Verify with `Mac.getInstance("HmacSHA256")` and compare using `MessageDigest.isEqual`. Push processing onto a `ThreadPoolTaskExecutor` or a broker. + +## PHP + +Laravel middleware runs before the controller and reads `$request->getContent()`: + +```php +public function handle(Request $request, Closure $next) +{ + $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); + $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); + $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); + + if (abs(time() - (int) $request->header('x-webhook-timestamp')) > 300 + || !hash_equals($expected, (string) $request->header('x-webhook-signature'))) { + abort(401); + } + return $next($request); +} +``` + +Dispatch a `ShouldQueue` job from the controller. Symfony follows the same pattern with `$request->getContent()` and a Messenger message consumed by `messenger:consume`. + +## Ruby + +```ruby +def verified?(request) + raw = request.body.read + request.body.rewind + secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") + timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s + return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 + + key = Base64.decode64(secret.delete_prefix("whsec_")) + signed = "#{request.get_header('HTTP_X_WEBHOOK_ID')}.#{timestamp}.#{raw}" + expected = "v1,#{Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', key, signed))}" + ActiveSupport::SecurityUtils.secure_compare(expected, request.get_header("HTTP_X_WEBHOOK_SIGNATURE").to_s) +end +``` + +In Rails put this in a controller concern, skip `verify_authenticity_token` for the action, and enqueue with ActiveJob. In Sinatra read the body in the route and enqueue with Sidekiq; remember the client is memoized per Puma worker process. + +## ASP.NET Core + +```csharp +app.MapPost("/webhooks/sent", async (HttpRequest request) => +{ + using var reader = new StreamReader(request.Body); + var rawBody = await reader.ReadToEndAsync(); + + if (!WebhookSignature.Verify( + rawBody, + request.Headers["x-webhook-id"], + request.Headers["x-webhook-timestamp"], + request.Headers["x-webhook-signature"], + Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + { + return Results.Unauthorized(); + } + + await channel.Writer.WriteAsync(rawBody); // BackgroundService consumer + return Results.Ok(); +}); +``` + +Read the stream before model binding touches it, compare with `CryptographicOperations.FixedTimeEquals`, and consume from a `BackgroundService`. + +## Deployment traps + +Reverse proxies and API gateways that buffer, recompress, or normalize request bodies break the signature; configure pass-through for the webhook path. Serverless platforms that hand the body as base64 require decoding to the original bytes before verification, not after. Load balancer idle timeouts shorter than `timeout_seconds` produce phantom failures that appear in the delivery log as timeouts with no `http_status_code`. Container clocks must be NTP-synchronized or the 300-second window rejects valid traffic. Finally, keep the webhook path out of user-auth middleware. If abuse controls are needed, apply signature-aware, capacity-safe controls rather than a generic pre-verification limiter that manufactures the consecutive failures leading to auto-disable. diff --git a/plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md b/plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md new file mode 100644 index 0000000..ee5f9e1 --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/references/webhook-operations.md @@ -0,0 +1,141 @@ +# Sent v3 webhook operations and delivery lifecycle + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Webhook object](#webhook-object) +- [Creating a webhook](#creating-a-webhook) +- [Event types and filters](#event-types-and-filters) +- [Delivery attempts, retries, and backoff](#delivery-attempts-retries-and-backoff) +- [Auto-disable and recovery](#auto-disable-and-recovery) +- [Delivery log triage](#delivery-log-triage) +- [Test deliveries](#test-deliveries) +- [Rate limits and sandbox behavior](#rate-limits-and-sandbox-behavior) +- [Environment and tenancy layout](#environment-and-tenancy-layout) + +## Operation catalog + +Ten operations manage webhooks. None of them is exposed through the Sent MCP server, so webhook work is REST-only even in an agent session that already holds an MCP connection. + +| Operation | Purpose | Notes | +| --- | --- | --- | +| `POST /v3/webhooks` | Register an endpoint | `201` body carries the only full view of `signing_secret` | +| `GET /v3/webhooks` | List endpoints | Inspect `is_active` and `consecutive_failures` here first | +| `GET /v3/webhooks/event-types` | Discover subscribable event types | Use before hardcoding an `event_types` array | +| `GET /v3/webhooks/{id}` | Inspect one endpoint | Confirms configuration and health counters | +| `PUT /v3/webhooks/{id}` | Update configuration | Replaces the mutable configuration fields | +| `DELETE /v3/webhooks/{id}` | Remove an endpoint | Ignores `sandbox` and always deletes | +| `GET /v3/webhooks/{id}/events` | Delivery log | Requires `page` and `page_size`; optional `search` | +| `POST /v3/webhooks/{id}/rotate-secret` | Replace the signing secret | Old secret dies immediately; sensitive rate tier | +| `POST /v3/webhooks/{id}/test` | Send a synthetic signed delivery | Requires `event_type`; one attempt, no retry; sensitive rate tier | +| `PATCH /v3/webhooks/{id}/toggle-status` | Enable or disable | Operational pause without losing configuration | + +## Webhook object + +| Field | Meaning | +| --- | --- | +| `id` | Endpoint UUID; the value of `x-webhook-id` on every delivery | +| `display_name` | Required label; the only required field on create | +| `endpoint_url` | Destination; scheme must be `http://` or `https://` | +| `signing_secret` | `whsec_`-prefixed secret, fully visible only in the create and rotate responses | +| `is_active` | False after auto-disable or an explicit toggle | +| `event_types` | Subscribed event families, for example `["message", "templates"]` | +| `event_filters` | Per-family narrowing, for example `{"message": ["delivered", "failed"]}` | +| `retry_count` | 1–5, default 3 | +| `timeout_seconds` | 5–120, default 30 | +| `last_delivery_attempt_at` | Timestamp of the most recent attempt of any outcome | +| `last_successful_delivery_at` | Timestamp of the most recent 2xx | +| `consecutive_failures` | Counter of consecutive failed attempts; ten disables the endpoint | +| `created_at`, `updated_at` | Audit timestamps | + +The gap between `last_delivery_attempt_at` and `last_successful_delivery_at` is the fastest health signal: a recent attempt with a stale success means the endpoint is failing right now. + +## Creating a webhook + +Only `display_name` is required, but a useful registration sets the destination, the subscriptions, and the delivery envelope explicitly. + +```json +{ + "display_name": "Staging inbound and failures", + "endpoint_url": "https://staging-hooks.example.com/webhooks/sent", + "event_types": ["message"], + "event_filters": { + "message": ["received", "failed", "filtered", "blocked"] + }, + "retry_count": 5, + "timeout_seconds": 15 +} +``` + +Choose `timeout_seconds` to match how fast the endpoint acknowledges, not how long processing takes. A receiver that returns `200` in 50 milliseconds and queues the work is compatible with the 5-second minimum; a receiver that writes to three downstream systems before responding will eventually breach even a 120-second ceiling under load and start accumulating consecutive failures. + +Choose `retry_count` against the recovery profile of the receiver. Three attempts spread over roughly seven minutes suits a stateless service behind a load balancer. Five attempts, reaching further into the capped 60-minute backoff, suits a receiver whose dependency outages last longer than a few minutes. + +## Event types and filters + +Call `GET /v3/webhooks/event-types` rather than assuming the catalog. Two `field` families exist today: `message`, which carries an `event` naming the transition, and `templates`, which carries approval-state changes without an `event` field. + +Filters matter more than they appear. An unfiltered `message` subscription delivers every transition, and because a reroute re-runs the pipeline on the same message id, `queued` and `routed` can arrive several times for one logical send. Subscribing only to the transitions the application acts on reduces both traffic and the chance of a double-processing bug. + +A practical split by consumer: + +| Consumer | Subscription | +| --- | --- | +| Delivery ledger and retries | `message` filtered to `delivered`, `failed`, `filtered`, `blocked` | +| Support inbox and auto-replies | `message` filtered to `received` | +| Read-receipt analytics | `message` filtered to `read` | +| Template governance | `templates` | +| Route debugging in a lower environment | `message` unfiltered | + +## Delivery attempts, retries, and backoff + +An attempt fails on any non-2xx response, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff: the first retry lands roughly one minute after the failure, each subsequent delay doubles, and the interval is capped at 60 minutes between attempts. Retries stop at the first 2xx or when `retry_count` is exhausted. + +Delivery rows report `delivery_status` as `PENDING` while queued, `RETRYING` between attempts, `DELIVERED` on success, and `FAILED` once attempts are exhausted. A `DELIVERED` outcome resets the endpoint's `consecutive_failures` to zero. + +Because retries are signed fresh, a retried delivery has a new `x-webhook-timestamp` and a new signature but the same payload — which is precisely why dedupe must key on payload semantics rather than on headers. + +## Auto-disable and recovery + +Ten consecutive failed delivery attempts disable the endpoint. Treat every failed attempt as capable of advancing the counter, whether it is a retry of one event or the first attempt for another; do not rely on event boundaries for protection. Any success resets the counter. + +Once `is_active` is false, Sent stops delivering. Recovery sequence: + +1. Read `GET /v3/webhooks/{id}` and confirm `is_active` and `consecutive_failures`. +2. Read the delivery log and identify the recurring `http_status_code` or `error_message`. +3. Fix the receiver and prove it locally against a signed synthetic delivery. +4. Re-enable the webhook with `PATCH /v3/webhooks/{id}/toggle-status` or in the Sent Dashboard. +5. Confirm recovery with `POST /v3/webhooks/{id}/test`, then verify the log shows `DELIVERED`. +6. Backfill the outage window from `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities` for messages whose state is stale, because events that failed permanently during the outage are not redelivered on re-enable. + +Add monitoring on `consecutive_failures` so an alert fires at three or four rather than at ten. + +## Delivery log triage + +Each row of `GET /v3/webhooks/{id}/events` contains `id`, `event_type`, `event_data`, `delivery_status`, `http_status_code`, `response_body`, `delivery_attempts`, `error_message`, `created_at`, `processing_started_at`, and `processing_completed_at`. Both `page` and `page_size` are required; omitting them returns a validation error rather than a default page. + +| Log evidence | Diagnosis | +| --- | --- | +| `http_status_code` 401 or 403 | The receiver is rejecting the signature, or authentication middleware sits in front of the webhook route | +| `http_status_code` 404 | Route path or environment mismatch in `endpoint_url` | +| `http_status_code` 5xx with a stack trace in `response_body` | Handler exception; fix the handler, not the registration | +| `error_message` naming a timeout with empty `http_status_code` | The receiver did not answer inside `timeout_seconds`; move work off the request path | +| `error_message` naming a connection or TLS failure | DNS, certificate, or firewall problem; the request never reached the application | +| `delivery_status` `DELIVERED` while the application has no record | The event was accepted and then dropped internally; instrument between acknowledgement and the queue | +| `delivery_attempts` climbing with `RETRYING` | Backoff is in progress; confirm the receiver recovered before it exhausts `retry_count` | + +Keep the receiver route outside user-auth middleware. Sent authenticates by signature, and an intervening session or Bearer-auth layer produces a 401 that looks exactly like a signature bug. + +## Test deliveries + +`POST /v3/webhooks/{id}/test` takes an `event_type` in the body and sends a real signed request with a synthetic payload to the registered URL. It is delivered once with no retry, so each fix needs a fresh call. Treat it as the end-to-end proof that DNS, TLS, routing, signature verification, and acknowledgement all work together; use the local signing script for iteration because the test endpoint is limited to 10 requests per minute. + +## Rate limits and sandbox behavior + +Standard endpoints allow 200 requests per minute on a sliding window. `rotate-secret` and `test` allow 10 per minute on a fixed window. Rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`) appear only on `429` responses, so a client cannot read remaining quota preemptively and must pace by design. + +`"sandbox": true` on create or update validates and authenticates without persisting anything, which makes it useful for checking a payload shape in CI. `DELETE /v3/webhooks/{id}` ignores the flag and always deletes, so never use sandbox as a dry-run guard for deletion. + +## Environment and tenancy layout + +Register one webhook per environment and never share an endpoint across environments. Because auto-disable is per endpoint, a development receiver returning 500s cannot then disable production. For multi-tenant systems, remember that events do not carry the application's tenant identifier: persist `message_id -> {tenant, profile, logical_send_id, channel}` before sending and map inbound events by the receiving number. Organization-scoped credentials with `x-profile-id` can manage a child profile's webhooks; profile-scoped keys manage only their own and receive `403` if they send `x-profile-id`. diff --git a/plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md b/plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md new file mode 100644 index 0000000..eb4af1f --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md @@ -0,0 +1,88 @@ +# Webhook signature verification and event deduplication + +## Table of contents + +- [Signature scheme](#signature-scheme) +- [Why the raw body matters](#why-the-raw-body-matters) +- [Replay rejection](#replay-rejection) +- [Secret handling and rotation](#secret-handling-and-rotation) +- [Deduplication without an event id](#deduplication-without-an-event-id) +- [Ordering and out-of-sequence events](#ordering-and-out-of-sequence-events) +- [Acceptance tests for a receiver](#acceptance-tests-for-a-receiver) + +## Signature scheme + +Sent signs each delivery with HMAC-SHA256 and publishes three headers. + +| Header | Example | Notes | +| --- | --- | --- | +| `x-webhook-signature` | `v1,K7t9...==` | Version tag, comma, base64 digest | +| `x-webhook-id` | `0f8fad5b-d9cb-469f-a165-70867728950e` | Endpoint UUID, constant across deliveries | +| `x-webhook-timestamp` | `1767225600` | Unix seconds | + +The signed content is the concatenation `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, where the first two components are joined by literal `.` characters and the third is the untouched request body. The HMAC key is the signing secret with the leading `whsec_` removed and the remainder base64-decoded, which yields raw key bytes rather than an ASCII string. The digest is base64-encoded and prefixed with `v1,`. + +The construction is compatible with Svix-style verification, so an existing Svix helper can usually be adapted by pointing it at these header names. No Sent SDK provides a built-in verifier in any of the seven supported languages, so this logic is application code in every deployment. + +Compare signatures with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`, `MessageDigest.isEqual`, `hash_equals`, `subtle.ConstantTimeCompare`). A plain `==` on a signature invites a timing oracle. + +## Why the raw body matters + +The signature covers exact bytes. Any transformation between the socket and the verification step invalidates it: JSON parse and re-serialize, key reordering, whitespace normalization, Unicode escaping changes, trailing-newline insertion, gzip re-encoding, or a proxy that rewrites the body. This is the single most common cause of signature failures, and it usually appears as "verification works with curl but fails behind the framework." + +Diagnose it by logging the byte length and a SHA-256 of the body at the verification point and comparing against the `Content-Length` header. A mismatch means something consumed and rebuilt the body upstream. + +## Replay rejection + +Reject a delivery when `abs(now - x-webhook-timestamp) > 300` seconds. The five-minute tolerance is the documented value and appears as `TOLERANCE_SECONDS = 300` in the official samples for every language. Two operational consequences follow. First, hosts must run NTP; clock drift beyond five minutes rejects perfectly valid traffic and the symptom looks identical to a signature bug. Second, because retries can arrive up to 60 minutes after the original attempt, each retry is signed with its own fresh timestamp — the receiver must never cache the first timestamp and compare later deliveries against it. + +For forensic replay of an archived delivery, verify the HMAC while explicitly skipping the freshness check rather than widening the production tolerance. + +## Secret handling and rotation + +The full `signing_secret` appears exactly once, in the `201` body of `POST /v3/webhooks`. Store it in a secret manager keyed by webhook id and environment. `GET /v3/webhooks/{id}` is the way to confirm which endpoint a stored secret belongs to. + +`POST /v3/webhooks/{id}/rotate-secret` returns the replacement and invalidates the previous secret immediately. There is no dual-signing window on Sent's side, so the receiver must provide the overlap: + +1. Deploy a receiver that reads a primary secret and an optional secondary secret and accepts a delivery that verifies under either. +2. Put the current secret in both slots and deploy. +3. Rotate, and write the new secret into the primary slot. +4. Confirm from the delivery log that recent attempts are `DELIVERED`. +5. Clear the secondary slot and deploy again. + +Rotate on compromise, on operator offboarding, and on a fixed schedule. The rotate endpoint is on the sensitive tier of 10 requests per minute, so automation must not loop over many webhooks quickly. + +## Deduplication without an event id + +Sent does not publish a per-event unique identifier. `x-webhook-id` names the endpoint and is identical on every delivery, so using it as a dedupe key collapses all events into a single row. Derive keys from event semantics instead: + +| Event | Idempotency key | Rationale | +| --- | --- | --- | +| Outbound status (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`) | `sha256(raw_body)` for receipt dedupe; `{message_id}:{message_status}` for one-time business effects | Exact delivery retries carry the same payload, while a reroute may legitimately repeat a status with a different channel or timestamp | +| Inbound (`message.received`) | `{message_id}` | Each inbound message has its own id | +| Template (`field: "templates"`) | `{template_id}:{status}` | Approval transitions are the meaningful unit | +| Anything unrecognized | `sha256(raw_body)` | Absorbs an exact retry without depending on a fresh retry timestamp | + +Persist the key with a unique constraint and treat an insert conflict as "already processed, return 200." A duplicate must never repeat side effects such as charging a card, sending a follow-up message, or writing a second audit row. + +Reroutes make two layers necessary. Use a raw-body hash to suppress exact transport retries, but retain distinct reroute events in an append-only receipt ledger because their channel or `updated_at` differs. Gate one-time business effects separately—for example, send a receipt only once for `{message_id}:DELIVERED`—so preserving route evidence does not duplicate side effects. + +## Ordering and out-of-sequence events + +Delivery order is not guaranteed, and a global status rank is unsafe: automatic routing can emit `FAILED`, then a newer `QUEUED`, `ROUTED`, and `DELIVERED` on the same message id. Persist the append-only receipt first, then update the current projection only when the payload's `updated_at` is newer than the projected event timestamp. Use receipt order only as a tie-breaker, preserve the attempted channel per event, and reconcile uncertain final state with `GET /v3/messages/{id}` plus activities. Never make `FAILED` permanently outrank a later successful reroute. + +## Acceptance tests for a receiver + +A receiver is ready when all of the following hold: + +1. A validly signed delivery returns `200`. +2. A body with a single byte changed returns `401`. +3. A delivery signed with a different secret returns `401`. +4. A delivery whose timestamp is 400 seconds old returns `401`. +5. A delivery whose `x-webhook-id` is altered returns `401`. +6. The same valid delivery sent twice returns `200` twice and performs side effects once. +7. A `message.delivered` followed by a late `message.sent` leaves the stored status at `DELIVERED`. +8. A handler exception still returns a non-2xx status so Sent retries, rather than swallowing the error and returning `200`. +9. Processing that exceeds one second happens after the response, not before it. + +Use `scripts/verify_signature.py --sign` to produce headers for cases 1, 6, and 7, and mutate them for the negative cases. diff --git a/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py new file mode 100644 index 0000000..e79391f --- /dev/null +++ b/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Reference implementation and test oracle for Sent v3 webhook signatures. + +The signed content is exactly ``{webhook_id}.{timestamp}.{raw_body}``. The key is +the signing secret with its ``whsec_`` prefix removed and the remainder +base64-decoded. The signature header value is ``v1,{base64(hmac_sha256)}``. + +Usage +----- +Self-test with synthetic fixtures (no network, no credentials):: + + python3 verify_signature.py --self-test + +Verify a captured delivery:: + + python3 verify_signature.py --body-file delivery.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --timestamp 1767225600 \ + --signature 'v1,Base64Signature==' \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Sign a synthetic delivery so a local receiver can be exercised:: + + python3 verify_signature.py --sign --body-file event.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, +3 usage or configuration error. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import sys +import time + +SECRET_PREFIX = "whsec_" +SIGNATURE_PREFIX = "v1," +TOLERANCE_SECONDS = 300 + +EXIT_VALID = 0 +EXIT_INVALID = 1 +EXIT_REPLAY = 2 +EXIT_USAGE = 3 + + +def decode_secret(secret: str) -> bytes: + """Return the raw HMAC key for a Sent signing secret.""" + if not secret: + raise ValueError("signing secret is empty") + material = secret[len(SECRET_PREFIX):] if secret.startswith(SECRET_PREFIX) else secret + padding = "=" * (-len(material) % 4) + try: + return base64.b64decode(material + padding, validate=True) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise ValueError(f"signing secret is not valid base64 after the prefix: {exc}") from exc + + +def signed_content(webhook_id: str, timestamp: str, raw_body: bytes) -> bytes: + """Build the byte string Sent signs.""" + return f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body + + +def compute_signature(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str: + """Return the full ``v1,...`` header value for a delivery.""" + digest = hmac.new( + decode_secret(secret), + signed_content(webhook_id, timestamp, raw_body), + hashlib.sha256, + ).digest() + return SIGNATURE_PREFIX + base64.b64encode(digest).decode("ascii") + + +def signature_matches(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> bool: + """Constant-time comparison of a received signature header.""" + expected = compute_signature(secret, webhook_id, timestamp, raw_body) + return hmac.compare_digest(expected, header.strip()) + + +def timestamp_fresh(timestamp: str, now: int | None = None, tolerance: int = TOLERANCE_SECONDS) -> bool: + """Return True when the timestamp is inside the replay window.""" + try: + sent_at = int(timestamp) + except (TypeError, ValueError): + return False + reference = int(time.time()) if now is None else now + return abs(reference - sent_at) <= tolerance + + +def verify(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> int: + """Return the process exit code for one delivery.""" + if not timestamp_fresh(timestamp): + return EXIT_REPLAY + return EXIT_VALID if signature_matches(secret, webhook_id, timestamp, raw_body, header) else EXIT_INVALID + + +def _self_test() -> int: + secret = SECRET_PREFIX + base64.b64encode(b"synthetic-signing-key-0123456789").decode("ascii") + webhook_id = "0f8fad5b-d9cb-469f-a165-70867728950e" + now = int(time.time()) + timestamp = str(now) + body = json.dumps( + { + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + }, + }, + separators=(",", ":"), + ).encode("utf-8") + + failures: list[str] = [] + header = compute_signature(secret, webhook_id, timestamp, body) + + if not header.startswith(SIGNATURE_PREFIX): + failures.append("signature header must start with 'v1,'") + if verify(secret, webhook_id, timestamp, body, header) != EXIT_VALID: + failures.append("a freshly signed delivery must verify") + if verify(secret, webhook_id, timestamp, body + b" ", header) != EXIT_INVALID: + failures.append("a mutated body must fail verification") + if verify(secret, "11111111-2222-3333-4444-555555555555", timestamp, body, header) != EXIT_INVALID: + failures.append("a different webhook id must fail verification") + stale = str(now - (TOLERANCE_SECONDS + 60)) + if verify(secret, webhook_id, stale, body, compute_signature(secret, webhook_id, stale, body)) != EXIT_REPLAY: + failures.append("a stale timestamp must be rejected as a replay") + if decode_secret(secret) != decode_secret(secret[len(SECRET_PREFIX):]): + failures.append("prefixed and unprefixed secrets must decode identically") + + reserialized = json.dumps(json.loads(body)).encode("utf-8") + if reserialized != body and signature_matches(secret, webhook_id, timestamp, reserialized, header): + failures.append("re-serialized JSON must not verify; raw bytes are required") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_INVALID + print("verify_signature self-test passed: 7 checks") + return EXIT_VALID + + +def _resolve_secret(args: argparse.Namespace) -> str: + if args.secret_env: + secret = os.environ.get(args.secret_env, "") + if not secret: + raise ValueError(f"environment variable {args.secret_env} is unset or empty") + return secret + raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Verify or sign a Sent v3 webhook delivery.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--sign", action="store_true", help="emit headers for a synthetic signed delivery") + parser.add_argument("--body-file", help="path to the raw request body captured byte for byte") + parser.add_argument("--webhook-id", help="value of the x-webhook-id header") + parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") + parser.add_argument("--signature", help="value of the x-webhook-signature header") + parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument( + "--skip-replay-check", + action="store_true", + help="verify the HMAC only, for forensic replay of an archived delivery", + ) + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if not args.body_file or not args.webhook_id: + parser.error("--body-file and --webhook-id are required unless --self-test is used") + + try: + raw_body = open(args.body_file, "rb").read() + secret = _resolve_secret(args) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_USAGE + + if args.sign: + timestamp = args.timestamp or str(int(time.time())) + print(f"x-webhook-id: {args.webhook_id}") + print(f"x-webhook-timestamp: {timestamp}") + print(f"x-webhook-signature: {compute_signature(secret, args.webhook_id, timestamp, raw_body)}") + return EXIT_VALID + + if not args.timestamp or not args.signature: + parser.error("--timestamp and --signature are required when verifying") + + if args.skip_replay_check: + matched = signature_matches(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + result = EXIT_VALID if matched else EXIT_INVALID + else: + result = verify(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + + print({EXIT_VALID: "valid", EXIT_INVALID: "invalid signature", EXIT_REPLAY: "replay window exceeded"}[result]) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/sent/skills/sent/SKILL.md b/plugins/sent/skills/sent/SKILL.md index f13a519..9e8b3f1 100644 --- a/plugins/sent/skills/sent/SKILL.md +++ b/plugins/sent/skills/sent/SKILL.md @@ -49,6 +49,19 @@ Do **not** use when: | Diagnosing delivery from MDR exports, funnels, cohorts, or cross-channel failure codes | `messaging-performance-analyzer` | | Designing or auditing a tenant-facing template-builder UI | `template-builder-ui` | +### Engineering and integration + +| User intent | Target skill | +|---|---| +| Adding Sent to a codebase, choosing an SDK, or hardening retries, idempotency, and error handling before launch | `sent-integration-starter` | +| Building or debugging a webhook receiver, signature verification, dedupe, or an auto-disabled endpoint | `sent-webhook-engineer` | +| Choosing the channel field, expecting cross-channel fallback, or interpreting a route, reroute, or delivery outcome | `sent-routing-strategist` | +| Handling inbound messages, opt-out keywords, consent state, the WhatsApp 24-hour window, or conversation history | `sent-two-way-messaging` | +| Executing the Sender Profile lifecycle over the API, including completion callbacks, campaigns, and user roles | `sent-profile-provisioning` | +| Replacing Twilio, Sinch, Infobip, Vonage, or Bird with Sent, including cutover and rollback planning | `migrate-to-sent` | + +Within this group, note two frequent hand-offs: `sender-profile-architect` decides the tenancy boundary and `sent-profile-provisioning` implements it, while `migrate-to-sent` plans a provider replacement and `sent-integration-starter` hardens the resulting integration. + If the request matches one row cleanly, invoke that skill and stop. If it spans several rows, state the proposed order and begin with the prerequisite. For example, check `sent-account-readiness` before a live send, use `sent-templates` to locate an existing template before `sent-messaging`, and use `messaging-performance-analyzer` when the user provides an export rather than asking for live dashboard metrics. ## Clarifying questions to ask before routing @@ -70,7 +83,7 @@ One question per turn is fine; never fire all seven at once. This skill is not a fallback for general questions. If the user asks about: - **Balance, onboarding state, or whether the selected account can send** — use `sent-account-readiness`. - **Contracts, plan pricing, invoices, or account access that the available operations cannot answer** — direct them to Sent support or `https://docs.sent.dm`. -- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally. +- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally; route to `sent-integration-starter` once the question involves Sent's own retry, idempotency, or rate-limit contract. - **Meta, Google, TCR, or carrier policy outside a specialist skill's scope** — use current upstream documentation. If after the clarifying questions the request still doesn't fit any target skill, say so plainly. Don't force a route. diff --git a/schemas/sent/documentation-sources.json b/schemas/sent/documentation-sources.json index 02a3d29..fc21a26 100644 --- a/schemas/sent/documentation-sources.json +++ b/schemas/sent/documentation-sources.json @@ -5,7 +5,15 @@ "id": "llms-index", "url": "https://docs.sent.dm/llms.txt", "kind": "documentation-index", - "affected_skills": ["sent"], + "affected_skills": [ + "migrate-to-sent", + "sent", + "sent-integration-starter", + "sent-profile-provisioning", + "sent-routing-strategist", + "sent-two-way-messaging", + "sent-webhook-engineer" + ], "last_verified": "2026-08-09", "checks": [ { @@ -30,8 +38,14 @@ "sent-account-readiness", "sent-analytics", "sent-contacts", + "sent-integration-starter", "sent-messaging", + "sent-profile-provisioning", + "sent-routing-strategist", "sent-templates", + "sent-two-way-messaging", + "sent-webhook-engineer", + "migrate-to-sent", "sms-10dlc-registration", "waba-embedded-signup", "waba-template-author" @@ -66,6 +80,10 @@ "kind": "documentation", "affected_skills": [ "messaging-performance-analyzer", + "migrate-to-sent", + "sent-integration-starter", + "sent-two-way-messaging", + "sent-webhook-engineer", "sender-profile-architect", "waba-embedded-signup" ], @@ -89,7 +107,13 @@ "id": "sender-profiles", "url": "https://docs.sent.dm/llms/start/concepts/sender-profiles.txt", "kind": "documentation", - "affected_skills": ["sender-profile-architect", "sms-10dlc-registration", "waba-embedded-signup"], + "affected_skills": [ + "migrate-to-sent", + "sender-profile-architect", + "sent-profile-provisioning", + "sms-10dlc-registration", + "waba-embedded-signup" + ], "last_verified": "2026-08-09", "checks": [ { @@ -131,7 +155,14 @@ "id": "routing-rcs", "url": "https://docs.sent.dm/llms/reference/channel-routing.txt", "kind": "documentation", - "affected_skills": ["messaging-performance-analyzer", "rcs-agent-onboarding", "sent-messaging"], + "affected_skills": [ + "messaging-performance-analyzer", + "migrate-to-sent", + "rcs-agent-onboarding", + "sent-integration-starter", + "sent-messaging", + "sent-routing-strategist" + ], "last_verified": "2026-08-09", "checks": [ { @@ -148,6 +179,183 @@ } ] }, + { + "id": "sdks", + "url": "https://docs.sent.dm/llms/sdks.txt", + "kind": "documentation", + "affected_skills": ["migrate-to-sent", "sent-integration-starter"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "sdk.typescript_package", + "extractor": "contains", + "pattern": "| **TypeScript** | `@sentdm/sentdm` |", + "expected": true + }, + { + "fact": "sdk.python_package", + "extractor": "contains", + "pattern": "| **Python** | `sentdm` |", + "expected": true + }, + { + "fact": "sdk.go_package", + "extractor": "contains", + "pattern": "| **Go** | `github.com/sentdm/sent-dm-go` |", + "expected": true + }, + { + "fact": "sdk.java_package", + "extractor": "contains", + "pattern": "| **Java** | `dm.sent:sent-java` |", + "expected": true + }, + { + "fact": "sdk.csharp_package", + "extractor": "contains", + "pattern": "| **C#** | `Sentdm` |", + "expected": true + }, + { + "fact": "sdk.php_package", + "extractor": "contains", + "pattern": "| **PHP** | `sentdm/sent-dm-php` |", + "expected": true + }, + { + "fact": "sdk.ruby_package", + "extractor": "contains", + "pattern": "| **Ruby** | `sentdm` |", + "expected": true + } + ] + }, + { + "id": "per-request-credentials", + "url": "https://docs.sent.dm/llms/build/per-request-credentials.txt", + "kind": "documentation", + "affected_skills": ["sent-integration-starter", "sent-profile-provisioning"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "credentials.per_request_client", + "extractor": "contains", + "pattern": "there is no shared client", + "expected": true + }, + { + "fact": "credentials.tenant_isolation", + "extractor": "contains", + "pattern": "one tenant's calls", + "expected": true + } + ] + }, + { + "id": "idempotency", + "url": "https://docs.sent.dm/llms/reference/api/idempotency.txt", + "kind": "documentation", + "affected_skills": ["migrate-to-sent", "sent-integration-starter", "sent-profile-provisioning"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "idempotency.cache_duration", + "extractor": "contains", + "pattern": "Cache lifetime | 24 hours", + "expected": true + }, + { + "fact": "idempotency.concurrent_conflict", + "extractor": "contains", + "pattern": "CONFLICT_001", + "expected": true + } + ] + }, + { + "id": "webhook-signatures", + "url": "https://docs.sent.dm/llms/build/signature-verification.txt", + "kind": "documentation", + "affected_skills": ["migrate-to-sent", "sent-integration-starter", "sent-webhook-engineer"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "webhook.signature_tolerance_seconds", + "extractor": "contains", + "pattern": "300 seconds (5 minutes)", + "expected": true + }, + { + "fact": "webhook.rotation_no_overlap", + "extractor": "contains", + "pattern": "old secret is invalid", + "expected": true + } + ] + }, + { + "id": "webhook-receiver", + "url": "https://docs.sent.dm/llms/build/webhook-receiver.txt", + "kind": "documentation", + "affected_skills": ["sent-integration-starter", "sent-two-way-messaging", "sent-webhook-engineer"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "webhook.auto_disable_threshold", + "extractor": "regex_int", + "pattern": "After (\\d+) consecutive delivery failures", + "expected": 10 + }, + { + "fact": "webhook.dedupe_message_status", + "extractor": "contains", + "pattern": "message_id` +", + "expected": true + } + ] + }, + { + "id": "two-way-messaging", + "url": "https://docs.sent.dm/llms/reference/two-way-messaging.txt", + "kind": "documentation", + "affected_skills": ["migrate-to-sent", "sent-two-way-messaging"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "consent.contact_channel_agnostic", + "extractor": "contains", + "pattern": "contact-level and channel-agnostic", + "expected": true + }, + { + "fact": "conversation.uuid_v5", + "extractor": "contains", + "pattern": "RFC 4122 v5 UUID", + "expected": true + } + ] + }, + { + "id": "profile-provisioning", + "url": "https://docs.sent.dm/llms/start/advanced/sub-account-profiles-api.txt", + "kind": "documentation", + "affected_skills": ["migrate-to-sent", "sent-profile-provisioning", "sender-profile-architect"], + "last_verified": "2026-08-09", + "checks": [ + { + "fact": "profile.callback_single_attempt", + "extractor": "contains", + "pattern": "calls the webhook once and does not retry", + "expected": true + }, + { + "fact": "profile.organization_scope_header", + "extractor": "contains", + "pattern": "x-profile-id", + "expected": true + } + ] + }, { "id": "10dlc", "url": "https://docs.sent.dm/llms/start/advanced/10dlc-registration.txt", diff --git a/schemas/sent/v3-contract-manifest.json b/schemas/sent/v3-contract-manifest.json index 8bfbe23..074b234 100644 --- a/schemas/sent/v3-contract-manifest.json +++ b/schemas/sent/v3-contract-manifest.json @@ -13,8 +13,18 @@ "/v3/messages": ["POST"], "/v3/messages/{id}": ["GET"], "/v3/messages/{id}/activities": ["GET"], + "/v3/contacts/{id}": ["DELETE", "GET", "PATCH"], + "/v3/conversations": ["GET"], + "/v3/conversations/{id}": ["GET"], + "/v3/users": ["GET", "POST"], + "/v3/users/{userId}": ["DELETE", "GET", "PATCH"], "/v3/webhooks": ["GET", "POST"], - "/v3/webhooks/event-types": ["GET"] + "/v3/webhooks/event-types": ["GET"], + "/v3/webhooks/{id}": ["DELETE", "GET", "PUT"], + "/v3/webhooks/{id}/events": ["GET"], + "/v3/webhooks/{id}/rotate-secret": ["POST"], + "/v3/webhooks/{id}/test": ["POST"], + "/v3/webhooks/{id}/toggle-status": ["PATCH"] }, "retired_guidance_paths": [ "/v3/brands", diff --git a/scripts/test_contracts.py b/scripts/test_contracts.py index 926cf3e..7d0fff8 100644 --- a/scripts/test_contracts.py +++ b/scripts/test_contracts.py @@ -136,9 +136,16 @@ def test_source_catalog_covers_freshness_domains(self) -> None: "openapi", "templates", "webhooks", + "webhook-signatures", + "webhook-receiver", "sender-profiles", + "profile-provisioning", "waba", "routing-rcs", + "two-way-messaging", + "sdks", + "per-request-credentials", + "idempotency", "10dlc", }, ) diff --git a/scripts/validate.py b/scripts/validate.py index 14c6d6f..d93d4ba 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -734,8 +734,18 @@ def validate_contract_manifest(validation: Validation) -> None: "/v3/messages", "/v3/messages/{id}", "/v3/messages/{id}/activities", + "/v3/contacts/{id}", + "/v3/conversations", + "/v3/conversations/{id}", + "/v3/users", + "/v3/users/{userId}", "/v3/webhooks", "/v3/webhooks/event-types", + "/v3/webhooks/{id}", + "/v3/webhooks/{id}/events", + "/v3/webhooks/{id}/rotate-secret", + "/v3/webhooks/{id}/test", + "/v3/webhooks/{id}/toggle-status", } validation.check(set(paths) == expected_paths, "Sent contract manifest critical path set drifted") diff --git a/skills/migrate-to-sent/SKILL.md b/skills/migrate-to-sent/SKILL.md new file mode 100644 index 0000000..de79092 --- /dev/null +++ b/skills/migrate-to-sent/SKILL.md @@ -0,0 +1,95 @@ +--- +name: migrate-to-sent +description: Plans and executes a migration from Twilio, Sinch, Infobip, Vonage, or MessageBird/Bird to Sent v3 — mapping send calls, status vocabularies, webhook signature schemes, opt-out stores, templates, and tenancy models, then cutting over safely with dual-run and rollback. Use when replacing an incumbent CPaaS provider, translating provider code or webhook handlers to Sent, or planning a phased cutover and its verification gates. +--- + +# Migrate to Sent + +Every migration from a major CPaaS provider hits the same five translation problems. Work them in this order, because the first one silently doubles cost and is invisible in tests. + +## 1. Ordered fallback becomes automatic routing + +Incumbent platforms express cross-channel delivery through different caller-side arrays, failover objects, messaging-service features, or application-level priority configuration. Do not assume those shapes have a direct Sent request-field equivalent. + +**Sent's `channel` array is a broadcast list.** Porting an ordered array produces one message and one charge per recipient-channel pair, which passes tests and multiplies production spend. The correct translation is automatic routing — omit `channel` or send `["sent"]` — which lets the platform select a route and reroute across up to three channel-and-provider pairs on the same `message_id`. Details belong to `sent-routing-strategist`; the migration rule is simply: **never port an ordered channel list.** + +## 2. Status vocabularies do not line up + +Incumbent statuses map onto Sent's, but Sent adds two states that have no equivalent and that break naive retry logic. + +| Sent status | Closest incumbent analogue | Migration note | +| --- | --- | --- | +| `QUEUED` | Twilio `queued`, Sinch `QUEUED_ON_CHANNEL` | Accepted, not sent | +| `ROUTED` | no analogue | Route chosen; fires again on reroute | +| `SENT` | Twilio `sent`, Sinch `MESSAGE_SUBMIT` | Provider handoff only | +| `DELIVERED` | `delivered` everywhere | The first proof of handset receipt | +| `READ` | Twilio `read`, Sinch `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | May still reroute; not necessarily final | +| `FILTERED` | Twilio error 21610 (opt-out) | **Policy gate. Never retry** | +| `BLOCKED` | account-level errors | **Account precondition.** Fix the account, then resend | +| `SCHEDULED` | no analogue | Quiet-hours parking; resumes automatically | + +Two consequences for ported code. Handlers that treat every non-delivered terminal state as retryable will retry consent blocks, which is a compliance failure rather than a bug. And handlers keyed on numeric provider error codes — Twilio's `21610` is the classic — must be rewritten against Sent's string `error.code` families. + +## 3. Webhook verification is a rewrite, not a port + +No two providers sign the same way, and no Sent SDK ships a verifier. + +| Provider | Scheme | +| --- | --- | +| Twilio | `X-Twilio-Signature`, base64 HMAC-**SHA1** over the full URL plus sorted POST parameters | +| Sinch | HMAC-SHA256 over `body.nonce.timestamp`, four `x-sinch-webhook-signature*` headers, or OAuth 2.0 | +| Infobip | Basic, HMAC-SHA256 over the raw body, or OAuth on a notification profile; **the header name is account-configured** | +| Vonage | JWT in `Authorization: Bearer`, or a legacy `sig` parameter | +| MessageBird/Bird | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash | +| **Sent** | `x-webhook-signature: v1,{base64}`, HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}` | + +Sent's key is the signing secret with `whsec_` stripped and the remainder base64-decoded, compared in constant time, with timestamps outside 300 seconds rejected. Because Sent provides no per-event id, dedupe keys must be derived from payload semantics. Build the receiver with `sent-webhook-engineer` rather than adapting the incumbent's verifier. + +## 4. Opt-out stores must be reconciled, not migrated by copy + +Every provider keeps its own suppression list — Twilio Advanced Opt-Out, Infobip Blocklist, Sinch OPT_IN/OPT_OUT events. Sent enforces consent at the platform level before events reach the application, stores it as `opt_out` on the contact, and applies it **channel-agnostically**: a `STOP` on SMS suppresses WhatsApp and RCS too. + +Reconciliation rules: export the incumbent's suppression list before cutover, treat any opt-out on any incumbent channel as a global Sent opt-out, and never clear `opt_out` to "clean up" migrated data. Sent's ten default keywords are `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END`, `START`, `UNSTOP`, `SUBSCRIBE`, `HELP`, `INFO`, matched only when the entire trimmed body equals the keyword — so incumbent-specific keywords need custom keyword entries. Rewrite any incumbent keyword matcher as an exact local consent mirror and audit mechanism; the matcher must not write consent to Sent again. Consent semantics belong to `sent-two-way-messaging`. + +## 5. Templates and tenancy are re-registered, not transferred + +WhatsApp templates live with the WABA, so the migration question is whether the WABA moves. Positional placeholders (`{{1}}`, `{{2}}`) become **named** parameters in Sent, which means every call site that passed an ordered array must pass a named map. Approval is asynchronous and arrives as a `templates` webhook event, so build the template inventory before cutover rather than during it. + +Tenancy maps as follows, with the boundary decision owned by `sender-profile-architect` and the API work by `sent-profile-provisioning`: + +| Incumbent construct | Sent equivalent | +| --- | --- | +| Twilio subaccount | Sender Profile | +| Twilio Messaging Service | routing plus profile configuration, not a caller-side pool | +| Infobip Application or Entity | Sender Profile | +| Sinch Conversation API app | Sender Profile | +| Provider API credential per tenant | Profile-scoped API key, or organization key with `x-profile-id` | + +## Migration sequence + +1. **Inventory** every send call site, webhook handler, status branch, template, suppression list, and credential. Use `scripts/inventory_scan.py` to find them mechanically. +2. **Map** each item using [references/provider-mapping.md](references/provider-mapping.md), flagging ordered-fallback arrays and numeric error codes as required rewrites. +3. **Stand up Sent in parallel**: credentials, one webhook per environment, verified receiver, templates re-registered and approved. +4. **Prove equivalence in sandbox** with `"sandbox": true`, then with a small live cohort confirmed to `DELIVERED`. +5. **Dual-run** with a traffic split, comparing delivery rates, latency, and cost per message on the same message classes. +6. **Cut over** by message class — lowest-risk transactional first, marketing last — keeping the incumbent receiver live. +7. **Decommission** only after a full billing cycle of clean data, then revoke incumbent credentials. + +Sequencing detail, verification gates, and rollback triggers are in [references/cutover-playbook.md](references/cutover-playbook.md). + +## Mistakes that survive testing + +- Porting an ordered channel array. Doubles cost, never errors. +- Treating `FILTERED` as retryable. Compliance exposure. +- Reusing the incumbent's signature verifier. Every delivery returns 401. +- Assuming `202` means delivered. Sent acknowledges acceptance only. +- Keeping positional template placeholders. Parameters silently mismatch. +- Retrying on `401`. Ten consecutive auth failures lock the credential with escalating lockout. +- Omitting `Idempotency-Key` during dual-run. A timeout retry sends twice. +- Sending `x-profile-id` with a profile-scoped key. Returns `403`. +- Copying an incumbent's `Authorization: Bearer` pattern. Sent authenticates with `x-api-key`. + +## Boundaries + +This skill owns provider mapping and line-by-line migration planning. Hand the resulting Sent client and resilience work to `sent-integration-starter`, channel semantics to `sent-routing-strategist`, receiver construction to `sent-webhook-engineer`, WhatsApp onboarding to `waba-embedded-signup`, and US campaign registration to `sms-10dlc-registration`. diff --git a/skills/migrate-to-sent/agents/openai.yaml b/skills/migrate-to-sent/agents/openai.yaml new file mode 100644 index 0000000..d068d06 --- /dev/null +++ b/skills/migrate-to-sent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate to Sent" + short_description: "Move from another CPaaS provider to Sent" + default_prompt: "Use $migrate-to-sent to plan our move from our current messaging provider to Sent." diff --git a/skills/migrate-to-sent/references/cutover-playbook.md b/skills/migrate-to-sent/references/cutover-playbook.md new file mode 100644 index 0000000..7908759 --- /dev/null +++ b/skills/migrate-to-sent/references/cutover-playbook.md @@ -0,0 +1,151 @@ +# Cutover playbook + +## Table of contents + +- [Phase overview](#phase-overview) +- [Phase 1: inventory](#phase-1-inventory) +- [Phase 2: mapping and rewrite list](#phase-2-mapping-and-rewrite-list) +- [Phase 3: parallel build](#phase-3-parallel-build) +- [Phase 4: sandbox and cohort verification](#phase-4-sandbox-and-cohort-verification) +- [Phase 5: dual-run](#phase-5-dual-run) +- [Phase 6: staged cutover](#phase-6-staged-cutover) +- [Phase 7: decommission](#phase-7-decommission) +- [Rollback triggers](#rollback-triggers) +- [Comparison metrics](#comparison-metrics) +- [Data migration rules](#data-migration-rules) + +## Phase overview + +| Phase | Exit gate | +| --- | --- | +| 1. Inventory | Every send call site, handler, template, and credential is listed | +| 2. Mapping | Each item has a target and a rewrite classification | +| 3. Parallel build | Sent sends and receives in a lower environment | +| 4. Verification | Sandbox parity plus a live cohort reaching `DELIVERED` | +| 5. Dual-run | Delivery, latency, and cost within agreed tolerances | +| 6. Cutover | All message classes on Sent, incumbent idle but reversible | +| 7. Decommission | One clean billing cycle, then credentials revoked | + +Do not compress phases 4 and 5. The failure modes that matter — broadcast duplication, mishandled `FILTERED`, and dropped webhook events — appear under real traffic and correct cost accounting, not in tests. + +## Phase 1: inventory + +Enumerate mechanically rather than from memory: + +- send call sites, including background jobs, cron tasks, and admin tools; +- webhook handlers and their signature verification code; +- every branch on a provider status string or numeric error code; +- template inventory with variable style and approval state; +- suppression and opt-out storage; +- credentials per environment and per tenant; +- observability: dashboards, alerts, and log fields that reference provider identifiers. + +`scripts/inventory_scan.py` scans a repository for provider SDK imports, endpoint URLs, signature header names, status strings, numeric error codes, and ordered channel arrays, and reports each with a migration classification. Treat its output as the starting checklist, not the whole picture — configuration-driven senders and no-code automations will not appear in source. + +## Phase 2: mapping and rewrite list + +Classify every inventory item: + +| Classification | Meaning | Examples | +| --- | --- | --- | +| Direct map | Same concept, different syntax | Recipient, message body, delivery status branch | +| Rewrite | Concept exists but works differently | Fallback, templates, tenancy, consent checks | +| New code | No equivalent exists | Webhook signature verification, idempotency keys | +| Delete | The incumbent workaround is unnecessary | Provider-specific retry ladders that conflict with Sent's error contract | +| Review | Logic remains useful but changes responsibility | Keyword matchers become exact local consent mirrors and must not write consent to Sent again | + +The rewrite list is the real project plan. Prioritize by blast radius: ordered fallback arrays first, then status and error branching, then the receiver, then templates. + +## Phase 3: parallel build + +Stand Sent up beside the incumbent without removing anything: + +1. Create credentials per environment, and profile-scoped keys for runtime send paths. +2. Provision Sender Profiles for tenancy, with inheritance and sharing flags set deliberately. +3. Register one webhook per environment; never share an endpoint across environments, because auto-disable is per endpoint. +4. Build the receiver as new code with its own signature tests. +5. Re-register templates and wait for approval events; approval is asynchronous and gates go-live. +6. Add `Idempotency-Key` to every mutating call, derived from your own domain objects. +7. Introduce a feature flag or router that chooses provider per message class and tenant. + +## Phase 4: sandbox and cohort verification + +Sandbox first: `"sandbox": true` authenticates and validates without executing, so payload shape and credential wiring can be proven in continuous integration. Remember it does not perform resource lookups, so it cannot confirm a template id exists. + +Then a live cohort — internal staff or a small opt-in group. Gates: + +- a send returns `202` and every `message_id` is persisted with tenant, profile, and logical send id; +- events arrive, verify, and deduplicate; +- a message reaches `DELIVERED` and the application state reflects it; +- a deliberately induced failure produces the expected terminal state without a retry storm; +- a suppressed contact produces `FILTERED` and no retry; +- a template send renders correctly with named parameters; +- an inbound `STOP` updates the exact-match local consent mirror without writing consent to Sent again. + +## Phase 5: dual-run + +Split traffic by tenant or message class and compare like with like. Both providers must carry comparable message mixes, since transactional and marketing traffic have different delivery profiles. + +Requirements during dual-run: one application operation ledger that assigns each logical send to exactly one provider, Sent idempotency keys on every Sent mutation, equivalent incumbent safeguards where available, a single source of truth for delivery state per provider message id, and cost accounting that counts messages rather than requests. Never send the same user-facing notification through both providers merely to compare them. + +Run long enough to cover a weekly seasonality cycle and at least one deliberate failure drill. + +## Phase 6: staged cutover + +Order by risk, lowest first: + +1. internal and staff notifications; +2. low-volume transactional messages such as receipts; +3. high-volume transactional messages such as OTP and delivery alerts; +4. conversational and support traffic; +5. marketing and campaign traffic. + +Keep the incumbent receiver live throughout, because in-flight messages sent before the switch still deliver status callbacks there. Cut per message class and per tenant, and hold each step long enough to see a full daily cycle. + +## Phase 7: decommission + +Decommission only after one full billing cycle of clean Sent data. Then, in order: disable incumbent send paths in code, remove the router flag, revoke incumbent credentials, archive incumbent delivery and consent records for the applicable retention period, and remove incumbent-specific dashboards and alerts after confirming Sent equivalents exist. + +Retain the exported incumbent suppression list permanently as consent evidence, independent of the platform that now enforces it. + +## Rollback triggers + +Define these before dual-run so the decision is not made under pressure: + +| Trigger | Action | +| --- | --- | +| Delivery rate falls below the agreed tolerance for a message class | Roll that class back | +| Duplicate messages observed in production | Halt immediately; check for ordered channel arrays and missing idempotency keys | +| Webhook `consecutive_failures` rising toward auto-disable | Roll back the receiver, fix, re-enable in the dashboard | +| `FILTERED` volume above baseline | Halt; the local suppression mirror is likely stale | +| Cost per delivered message above the agreed ceiling | Halt; verify channel arrays and message counts | +| Auth lockout on a credential | Stop all retries; ten consecutive failures escalate the lockout | + +Rollback is a router flag flip per message class, which is why the router must remain in place until decommissioning. + +## Comparison metrics + +| Metric | Definition | Why it matters | +| --- | --- | --- | +| Delivery rate | `DELIVERED` divided by accepted, per message class | The primary quality signal | +| Filtered rate | `FILTERED` divided by accepted | Consent-mirror drift | +| Blocked rate | `BLOCKED` divided by accepted | Balance and account preconditions | +| Reroute rate | Messages with more than one attempted route | Routing health | +| Time to delivered | `DELIVERED` timestamp minus accepted timestamp | Latency comparison | +| Messages per logical send | Messages created divided by intended sends | Catches broadcast duplication | +| Cost per delivered message | Spend divided by `DELIVERED` count | The number finance will ask for | + +Messages per logical send should be exactly one for single-channel intent. Anything above one means a multi-channel array is in play. + +## Data migration rules + +| Data | Rule | +| --- | --- | +| Contacts | Re-create in Sent with E.164 numbers; contacts are validated endpoints, not a copied CRM table | +| Suppression list | Export before cutover; treat any incumbent opt-out as a global Sent opt-out | +| Templates | Re-register and await approval; convert positional variables to named parameters | +| Message history | Leave in the incumbent, archived; do not attempt to import history | +| Credentials | Create fresh in Sent; never reuse incumbent secrets | +| Webhook secrets | New per environment; store keyed by webhook id | + +Never clear `opt_out` during data migration. A migrated opt-out is consent evidence, and clearing it to reconcile row counts is a compliance failure rather than a data cleanup. diff --git a/skills/migrate-to-sent/references/provider-mapping.md b/skills/migrate-to-sent/references/provider-mapping.md new file mode 100644 index 0000000..1d9a65a --- /dev/null +++ b/skills/migrate-to-sent/references/provider-mapping.md @@ -0,0 +1,158 @@ +# Provider-to-Sent mapping reference + +## Table of contents + +- [How to use this reference](#how-to-use-this-reference) +- [Sent target contract](#sent-target-contract) +- [Twilio](#twilio) +- [Sinch](#sinch) +- [Infobip](#infobip) +- [Vonage](#vonage) +- [MessageBird and Bird](#messagebird-and-bird) +- [Cross-provider concept table](#cross-provider-concept-table) +- [Status mapping](#status-mapping) +- [Webhook signature comparison](#webhook-signature-comparison) +- [Error handling translation](#error-handling-translation) + +## How to use this reference + +Read the Sent target contract first, then only the section for the incumbent. Each provider section lists the send call, the fallback construct, the webhook scheme, the suppression store, the tenancy construct, and the specific rewrites that testing will not catch. + +Verify any header name or field against the incumbent's current documentation before writing customer-facing text; several of these platforms have renamed products and headers. + +## Sent target contract + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345", "eta": "Friday" } + }, + "sandbox": false +} +``` + +`POST /v3/messages` returns `202` with `data.recipients[]`, each carrying a `message_id`. Only `to` is required; supply `template` or `text`; omit `channel` for automatic routing. Authentication is `x-api-key`, optionally with `x-profile-id` on an organization key. Template parameters are a **named** map, not positional. + +For automatic routing the echoed per-recipient channel is not a resolved route and is never updated later. Read the real route from `message.routed`, from `GET /v3/messages/{id}` after routing, or from `GET /v3/messages/{id}/activities`. + +## Twilio + +| Concept | Twilio | Sent | +| --- | --- | --- | +| Send | `POST /2010-04-01/Accounts/{sid}/Messages.json`, form-encoded | `POST /v3/messages`, JSON | +| Auth | Basic auth with Account SID and auth token | `x-api-key` header | +| Recipient | `To`, with a `whatsapp:` prefix for WhatsApp | `to` array plus the `channel` array | +| Sender | `From` or `MessagingServiceSid` | Profile configuration and routing | +| Content | `Body`, or `ContentSid` with positional variables | `text`, or `template` with named parameters | +| Fallback | Messaging Service features such as Fallback to Long Code | Automatic routing with reroute | +| Status callback | `StatusCallback` per message | Account-level webhook subscription | +| Suppression | Advanced Opt-Out | Platform consent with `opt_out` on the contact | +| Tenancy | Subaccount | Sender Profile | +| Idempotency | Not offered on message create | `Idempotency-Key` header | + +Rewrites that testing will not catch: positional `{{1}}` variables become named parameters; numeric error codes such as `21610` for an opted-out recipient become string `error.code` families, and that particular case does not even fail the request — the send is accepted and the message finalizes as `FILTERED`; a per-message `StatusCallback` URL has no equivalent, so status routing moves into the subscription's `event_filters`. + +Use this skill for line-by-line translation, then use `sent-integration-starter` to harden the resulting client lifecycle, retries, and observability. + +## Sinch + +| Concept | Sinch Conversation API | Sent | +| --- | --- | --- | +| Send | `POST /v1/projects/{id}/messages:send` with a channel-priority order | `POST /v3/messages` with automatic routing | +| App model | Conversation API app with configured channels | Sender Profile | +| Webhooks | Up to five per app, created with `triggers[]` | Account-level subscription with `event_types` and `event_filters` | +| Callback auth | OAuth 2.0, or HMAC-SHA256 over `body.nonce.timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Consent | `OPT_IN` and `OPT_OUT` trigger events | Platform-enforced consent, applied before the event | +| Delivery reports | Typically three callbacks per message | One event per transition, plus repeats on reroute | + +Sinch's trigger catalog is far broader than Sent's two event families, so a handler switching over twenty-plus trigger types collapses to branching on `field` and `event`. Sinch's channel-priority ordering is the ordered-fallback pattern that must become automatic routing. + +## Infobip + +| Concept | Infobip | Sent | +| --- | --- | --- | +| Send | Channel-specific endpoints, or Conversations | `POST /v3/messages` | +| Sender strategy | Resource Associations and Sending Strategies such as sticky sender | Platform routing rules | +| Webhooks | Subscription plus a notification profile | Webhook registration | +| Webhook auth | Basic, HMAC-SHA256 over the raw body, or OAuth, optionally mTLS; **header name is account-configured** | Fixed `x-webhook-signature` scheme | +| Suppression | Blocklist, also called Do Not Contact | `opt_out` on the contact | +| Tenancy | Applications and Entities | Sender Profiles | + +Because Infobip's signing header name comes from account settings, an existing verifier is not portable and its header constant must not be reused. Sending Strategies have no caller-side equivalent: sticky-sender behavior is a platform routing concern in Sent, not a request parameter. + +## Vonage + +| Concept | Vonage Messages API v1 | Sent | +| --- | --- | --- | +| Send | Per-channel body with `message_type`, `channel`, `to`, `from` | Uniform `POST /v3/messages` | +| Fallback | `failover` array of complete alternative messages | Automatic routing with reroute | +| Webhook auth | JWT in `Authorization: Bearer`, or legacy `sig` parameter | HMAC signature headers | +| Credentials | Application id with a private key | API key | + +Vonage's `failover` array is the most explicit ordered-fallback construct of the five, and it is the one most often ported directly into Sent's `channel` array. It must not be. Note also that Vonage's JWT bearer pattern tempts engineers to authenticate to Sent with `Authorization: Bearer`; Sent uses `x-api-key`. + +## MessageBird and Bird + +| Concept | MessageBird/Bird | Sent | +| --- | --- | --- | +| Send | Conversations API, or channel APIs | `POST /v3/messages` | +| Fallback | Explicit `fallback` object naming a secondary channel | Automatic routing with reroute | +| Webhook auth | `messagebird-signature`, base64 HMAC-SHA256 over timestamp, URL, and a SHA-256 body hash, with `messagebird-request-timestamp` | HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` | +| Suppression | Platform suppression list | `opt_out` on the contact | + +MessageBird's signature covers a hash of the body rather than the body itself, so a verifier ported to Sent will fail every delivery even though both use HMAC-SHA256. Treat the legacy MessageBird header names as legacy and confirm current Bird names before asserting them. + +## Cross-provider concept table + +| Concept | Twilio | Sinch | Infobip | Vonage | Bird | Sent | +| --- | --- | --- | --- | --- | --- | --- | +| Ordered fallback | Messaging Service features | channel priority | sending strategies | `failover` array | `fallback` object | **automatic routing only** | +| Tenancy | subaccount | Conversation app | Application/Entity | application | workspace | Sender Profile | +| Template variables | positional | per-channel | per-channel | per-channel | per-channel | **named map** | +| Consent store | Advanced Opt-Out | OPT_IN/OPT_OUT events | Blocklist | application-side | suppression list | `opt_out`, channel-agnostic | +| Idempotency | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | Verify current send contract | `Idempotency-Key` | +| Webhook scope | per message or service | per app, up to five | per subscription | per application | per workspace | per account, filtered | + +## Status mapping + +| Sent | Twilio | Sinch | Note | +| --- | --- | --- | --- | +| `QUEUED` | `queued`, `accepted` | `QUEUED_ON_CHANNEL` | Accepted only | +| `ROUTED` | — | — | No incumbent analogue; repeats on reroute | +| `SENT` | `sent` | `MESSAGE_SUBMIT` | Provider handoff | +| `DELIVERED` | `delivered` | `DELIVERED` | Handset confirmation | +| `READ` | `read` | `READ` | WhatsApp and RCS only | +| `FAILED` | `failed`, `undelivered` | `FAILURE` | May reroute; not necessarily final | +| `FILTERED` | error 21610 behavior | opt-out enforcement | Policy gate; never retry | +| `BLOCKED` | account errors | account errors | Account precondition | +| `SCHEDULED` | — | — | Quiet-hours parking | + +The two states with no analogue, `FILTERED` and `BLOCKED`, are exactly the two that ported retry logic mishandles. + +## Webhook signature comparison + +| Provider | Algorithm | Signed content | Header | +| --- | --- | --- | --- | +| Twilio | HMAC-SHA1 | full URL plus sorted POST parameters | `X-Twilio-Signature` | +| Sinch | HMAC-SHA256 | `body.nonce.timestamp` | `x-sinch-webhook-signature` plus nonce, timestamp, algorithm | +| Infobip | HMAC-SHA256 | raw body | account-configured | +| Vonage | JWT, or MD5/HMAC over sorted parameters | token claims, or parameters | `Authorization`, or `sig` | +| Bird | HMAC-SHA256 | timestamp, URL, SHA-256 of body | `messagebird-signature` | +| **Sent** | HMAC-SHA256 | `{webhook_id}.{timestamp}.{raw_body}` | `x-webhook-signature` as `v1,{base64}` | + +Among the compared schemes, Sent includes the endpoint id in the signed content and expects a `v1,` version prefix. No listed provider's verifier is reusable as-is, and no Sent SDK ships one, so plan the receiver as new code with its own tests. + +## Error handling translation + +| Incumbent pattern | Sent replacement | +| --- | --- | +| Numeric error codes in a switch statement | String `error.code` with prefix families | +| Retry on any non-delivered status | Retry only `429`, `5xx`, `SERVICE_001`, and `CONFLICT_001` once | +| Opt-out surfaced as a send error | Send accepted with `202`; message finalizes as `FILTERED` | +| Insufficient balance surfaced as a send error | Send accepted with `202`; message finalizes as `BLOCKED` | +| Per-request quota headers | Headers only on `429`; pace by design | +| Provider-side deduplication assumptions | Supply a deterministic `Idempotency-Key` | + +Consent and balance problems moving out of the error path and into delivery data is the structural change that most often surprises a migrating team: monitor filtered and blocked rates as first-class metrics. diff --git a/skills/migrate-to-sent/scripts/inventory_scan.py b/skills/migrate-to-sent/scripts/inventory_scan.py new file mode 100644 index 0000000..253ba90 --- /dev/null +++ b/skills/migrate-to-sent/scripts/inventory_scan.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Scan a repository for incumbent CPaaS usage that a Sent migration must address. + +Reports each finding with a migration classification so the output can be used +directly as the phase-2 rewrite list. + +Usage +----- + python3 inventory_scan.py --self-test + python3 inventory_scan.py --path /path/to/repo + python3 inventory_scan.py --path /path/to/repo --format json + +Exit codes: 0 no findings, 1 findings reported, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, asdict + +EXIT_CLEAN = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + +SKIP_DIRS = { + ".git", "node_modules", "vendor", "dist", "build", "target", ".venv", "venv", + "__pycache__", ".next", ".gradle", ".idea", ".mypy_cache", ".pytest_cache", "coverage", +} +SCAN_EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".java", ".kt", ".cs", ".php", ".rb", + ".yml", ".yaml", ".json", ".tf", ".sh", ".md", +} +MAX_FILE_BYTES = 2_000_000 + + +@dataclass(frozen=True) +class Rule: + rule_id: str + provider: str + pattern: str + classification: str + guidance: str + + +RULES: tuple[Rule, ...] = ( + Rule( + "ordered-channel-array", "sent", + r"""['\"]channel['\"]\s*:\s*\[\s*['\"](?:sms|whatsapp|rcs)['\"]\s*,\s*['\"](?:sms|whatsapp|rcs)['\"]""", + "rewrite", + "Multiple explicit channels broadcast rather than fall back. Omit 'channel' or use ['sent'].", + ), + Rule( + "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", + "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + ), + Rule( + "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", + "rewrite", "Replace with POST /v3/messages using the flat JSON body.", + ), + Rule( + "twilio-signature", "twilio", r"X-Twilio-Signature|validateRequest|RequestValidator", + "new_code", "Twilio signs HMAC-SHA1 over URL plus sorted params. Sent needs a new verifier.", + ), + Rule( + "twilio-messaging-service", "twilio", r"MessagingServiceSid|messaging_service_sid", + "rewrite", "Sender pools and sticky sender are platform routing concerns in Sent, not request fields.", + ), + Rule( + "twilio-optout-code", "twilio", r"\b21610\b", + "rewrite", "Opted-out sends are accepted with 202 and finalize as FILTERED, not as a numeric error.", + ), + Rule( + "twilio-channel-prefix", "twilio", r"['\"]whatsapp:\+?", + "rewrite", "Channel is selected by the 'channel' array in Sent, not by a recipient prefix.", + ), + Rule( + "sinch-conversation", "sinch", r"conversation\.api\.sinch\.com|messages:send|sinch-webhook-signature", + "rewrite", "Replace channel-priority ordering with Sent automatic routing.", + ), + Rule( + "infobip-endpoint", "infobip", r"[a-z0-9-]+\.api\.infobip\.com|infobip-api", + "rewrite", "Replace with POST /v3/messages; Sending Strategies have no caller-side equivalent.", + ), + Rule( + "infobip-blocklist", "infobip", r"blocklist|do-not-contact|dnc", + "rewrite", "Reconcile into Sent consent as a channel-agnostic opt_out on the contact.", + ), + Rule( + "vonage-failover", "vonage", r"['\"]failover['\"]\s*[:=]\s*\[", + "rewrite", "An ordered failover array must become Sent automatic routing.", + ), + Rule( + "vonage-sdk", "vonage", r"\b(?:@vonage/|nexmo|vonage-)\b", + "rewrite", "Replace the Vonage client; Sent authenticates with x-api-key, not a JWT bearer.", + ), + Rule( + "bird-fallback", "bird", r"messagebird|bird-signature|messagebird-signature", + "rewrite", "MessageBird signs over a body hash; the verifier is not portable to Sent.", + ), + Rule( + "generic-fallback-object", "any", r"['\"](?:fallback|failover)['\"]\s*[:=]", + "rewrite", "Caller-supplied fallback has no Sent equivalent; automatic routing performs it.", + ), + Rule( + "positional-template-var", "any", r"\{\{\s*[1-9][0-9]?\s*\}\}", + "rewrite", "Sent template parameters are a named map, not positional placeholders.", + ), + Rule( + "bearer-auth-to-provider", "any", r"Authorization['\"]?\s*[:=]\s*['\"]?Bearer\s", + "review", "Direct Sent REST calls use x-api-key; keep Bearer where an app proxy, MCP OAuth flow, or incumbent still requires it.", + ), + Rule( + "provider-status-branch", "any", + r"['\"](?:undelivered|accepted|sending)['\"]", + "rewrite", "Map incumbent status strings onto Sent statuses, adding FILTERED, BLOCKED, and SCHEDULED.", + ), + Rule( + "application-keyword-matcher", "any", + r"(?:==|===|\.equals\(|\.includes\(|\bin\s)\s*['\"](?:STOP|UNSUBSCRIBE|CANCEL|UNSTOP)['\"]", + "review", "Keep exact matching only to mirror local consent evidence; do not write consent to Sent a second time.", + ), + Rule( + "idempotency-key-present", "sent", r"Idempotency-Key", + "informational", "Idempotency key usage found; confirm keys are deterministic rather than random.", + ), +) + +COMPILED = tuple((rule, re.compile(rule.pattern, re.IGNORECASE)) for rule in RULES) + + +@dataclass +class Finding: + path: str + line: int + rule_id: str + provider: str + classification: str + guidance: str + excerpt: str + + +def redact_excerpt(line: str) -> str: + """Mask credential-like literals before reporting a matched source line.""" + line = re.sub( + r"(?i)(authorization[^\n]{0,24}bearer\s+)([^\s'\",;}]+)", + r"\1", + line, + ) + line = re.sub( + r"(?i)\b(api[_-]?key|auth[_-]?token|access[_-]?token|secret|password)(\s*[:=]\s*)([^\s,;}]+)", + r"\1\2", + line, + ) + return line + + +def scan_text(text: str, path: str = "") -> list[Finding]: + """Scan a blob of text and return findings.""" + findings: list[Finding] = [] + for number, line in enumerate(text.splitlines(), start=1): + if len(line) > 2000: + line = line[:2000] + for rule, regex in COMPILED: + if regex.search(line): + findings.append( + Finding( + path=path, + line=number, + rule_id=rule.rule_id, + provider=rule.provider, + classification=rule.classification, + guidance=rule.guidance, + excerpt=redact_excerpt(line.strip())[:200], + ) + ) + return findings + + +def scan_path(root: str) -> list[Finding]: + """Walk a directory tree and scan eligible files.""" + findings: list[Finding] = [] + for directory, subdirs, files in os.walk(root): + subdirs[:] = [name for name in subdirs if name not in SKIP_DIRS and not name.startswith(".")] + for filename in files: + if filename.startswith(".env"): + continue + extension = os.path.splitext(filename)[1].lower() + if extension not in SCAN_EXTENSIONS: + continue + full = os.path.join(directory, filename) + try: + if os.path.getsize(full) > MAX_FILE_BYTES: + continue + with open(full, encoding="utf-8", errors="replace") as handle: + text = handle.read() + except OSError: + continue + findings.extend(scan_text(text, os.path.relpath(full, root))) + return findings + + +def summarize(findings: list[Finding]) -> dict[str, int]: + """Count findings per classification.""" + counts: dict[str, int] = {} + for finding in findings: + counts[finding.classification] = counts.get(finding.classification, 0) + 1 + return counts + + +def _self_test() -> int: + failures: list[str] = [] + + sample = """ + const client = require('twilio')(sid, token); + await client.messages.create({ to, from, body }); + if (status === 'undelivered') retry(); + if (error.code === 21610) suppress(); + payload = {"channel": ["whatsapp", "sms"], "to": ["+14155551234"]} + body = {"failover": [{"channel": "sms"}]} + template = "Hello {{1}}, your order {{2}} shipped" + headers = {"Authorization": "Bearer " + token} + if (text.trim().toUpperCase() === 'STOP') { optOut(); } + """ + found = {finding.rule_id for finding in scan_text(sample)} + expected = { + "twilio-sdk", + "provider-status-branch", + "twilio-optout-code", + "ordered-channel-array", + "vonage-failover", + "generic-fallback-object", + "positional-template-var", + "bearer-auth-to-provider", + "application-keyword-matcher", + } + missing = expected - found + if missing: + failures.append(f"rules failed to fire: {sorted(missing)}") + + clean = 'payload = {"to": ["+14155551234"], "template": {"name": "t", "parameters": {"a": "b"}}}\n' + if scan_text(clean): + failures.append("a correct Sent payload must produce no findings") + + single = 'payload = {"channel": ["sms"]}\n' + if any(f.rule_id == "ordered-channel-array" for f in scan_text(single)): + failures.append("a single-channel array must not be flagged as broadcast") + + counts = summarize(scan_text(sample)) + if counts.get("rewrite", 0) < 5 or counts.get("review", 0) != 2: + failures.append(f"classification summary drifted: {counts}") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("inventory_scan self-test passed: 4 checks") + return EXIT_CLEAN + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Scan a repository for incumbent CPaaS usage.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--path", help="repository root to scan") + parser.add_argument("--format", choices=("text", "json"), default="text", help="output format") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + if not args.path: + parser.error("provide --path or --self-test") + if not os.path.isdir(args.path): + print(f"error: {args.path} is not a directory", file=sys.stderr) + return EXIT_USAGE + + findings = scan_path(args.path) + + if args.format == "json": + print(json.dumps({"summary": summarize(findings), "findings": [asdict(f) for f in findings]}, indent=2)) + else: + if not findings: + print("no incumbent CPaaS usage detected") + for finding in findings: + print(f"{finding.path}:{finding.line} [{finding.classification}/{finding.provider}] {finding.rule_id}") + print(f" {finding.excerpt}") + print(f" -> {finding.guidance}") + if findings: + print("\nsummary: " + ", ".join(f"{k}={v}" for k, v in sorted(summarize(findings).items()))) + + return EXIT_FINDINGS if findings else EXIT_CLEAN + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/sent-integration-starter/SKILL.md b/skills/sent-integration-starter/SKILL.md new file mode 100644 index 0000000..1f16fb3 --- /dev/null +++ b/skills/sent-integration-starter/SKILL.md @@ -0,0 +1,108 @@ +--- +name: sent-integration-starter +description: Stands up a production-ready Sent v3 integration in an existing codebase — SDK selection and client construction, x-api-key configuration, idempotent sends, retry and rate-limit handling, the 46-code error catalog, sandbox verification, and a verified webhook receiver. Use when adding Sent to an app for the first time, choosing an SDK or framework wiring, handling 429 or 409 responses, deciding what to log, or hardening an integration before launch. +--- + +# Sent Integration Starter + +Bring up a Sent integration in four stages: authenticate, send idempotently, receive verified events, then harden. Do not conflate them — most broken integrations pass stage one and skip stage three. + +## Stage 1: client and credentials + +Direct Sent v3 REST requests authenticate with the `x-api-key` header. An application proxy may accept `Authorization: Bearer` from its own callers, and the Sent MCP server uses client-managed OAuth, but neither changes the REST header sent to `api.sent.dm`. Organization keys may add `x-profile-id` to act for a child profile; a profile-scoped key that sends that header receives `403`. + +| Language | Package | Client | +| --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `new SentDm()` | +| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | +| C# | `Sentdm` | `new SentClient()` | +| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | +| Ruby | `sentdm` | `Sentdm::Client.new` | + +Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. + +Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). + +Validate configuration at boot and fail fast when the key is missing, rather than surfacing an auth error on the first customer send. + +## Stage 2: idempotent sends + +```json +{ + "to": ["+14155551234"], + "template": { + "name": "order_confirmation", + "parameters": { "order_id": "12345" } + }, + "sandbox": true +} +``` + +`to` is the only required field. Supply `template` or `text`, and omit `channel` to let automatic routing choose. Never write a `channel` array with several values expecting fallback — that broadcasts and multiplies charges. Channel decisions belong to `sent-routing-strategist`. + +Send `Idempotency-Key` on every POST, PUT, and PATCH, derived deterministically from your own domain object (for example the order id plus the notification type) so a retry after a timeout cannot double-send. Keys are 1–255 characters of `[A-Za-z0-9_-]`, cached 24 hours per key per customer. A replay returns the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`. A duplicate arriving while the original is still in flight waits up to five seconds and then fails `409 CONFLICT_001`; a `503 SERVICE_001` means the idempotency store was unavailable and the request was deliberately not executed. + +`202` means accepted, not delivered. Persist the returned `message_id` values immediately with your own tenant, profile, and logical send identifiers. Webhook events carry the Sent message id and account data, but never your application's tenant identifier. + +## Stage 3: verified webhook receiver + +An integration without a receiver has no delivery truth. Register an endpoint, then verify every delivery: HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, keyed on the base64-decoded secret after stripping `whsec_`, compared in constant time, rejecting timestamps outside 300 seconds. No SDK ships a verifier in any language. + +Acknowledge with `200` before doing work, and deduplicate on `{message_id}:{message_status}` for outbound events and `message_id` for inbound. Ten consecutive failed deliveries disable the endpoint. Full mechanics belong to `sent-webhook-engineer`; treat a verified, fast-acknowledging, deduplicating receiver as a launch requirement here. + +## Stage 4: harden + +### Retry policy by response class + +| Response | Retry | How | +| --- | --- | --- | +| `2xx` | No | Success | +| `400`, `422` `VALIDATION_*` | No | Fix the request | +| `401`, `403` `AUTH_*` | No | Stop immediately; ten consecutive auth failures lock the credential with escalating lockouts | +| `404` `RESOURCE_*` | No | The referenced object does not exist | +| `409 CONFLICT_001` | Yes, once, after a pause | A concurrent duplicate is in flight | +| `429` | Yes | Honor `Retry-After`; jittered backoff | +| `5xx`, `503 SERVICE_001` | Yes | Exponential backoff with jitter and a ceiling | +| Timeout with no response | Retry safely only with evidence | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient, so do not automate a resend | + +The standard limit is 200 requests per minute on a sliding window. `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` are limited to 10 per minute. Rate-limit headers appear **only** on `429` responses, so pacing must be designed rather than measured — batch up to 1,000 recipients per request and pace at roughly one request per second for bulk work. + +### Error handling + +Errors arrive as `{success, data, error: {code, message, details, doc_url}, meta: {request_id, timestamp, version}}`. Branch on the `error.code` prefix family (`AUTH_`, `VALIDATION_`, `RESOURCE_`, `BUSINESS_`, `CONFLICT_`, `SERVICE_`, `INTERNAL_`) rather than on message text or on individual codes. The full 46-code catalog with retry classification is in [references/errors-and-limits.md](references/errors-and-limits.md). + +Two codes are counterintuitive: `BUSINESS_003` and `BUSINESS_004` are documented as request-level errors, but on `POST /v3/messages` the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED`. Insufficient balance therefore does not fail the send call. + +### Observability + +Log `meta.request_id` on every response, success or failure — it is the correlation handle for support. Record the mapping from your logical send to the returned `message_id` values, and keep an append-only event history so a reroute's sequence remains auditable. Never log the API key, the webhook signing secret, `payment_details`, or raw recipient message content beyond your retention policy. + +### Launch checklist + +- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. +- [ ] `Idempotency-Key` on every mutating call, derived deterministically. +- [ ] Retry policy distinguishes retryable from terminal by error family. +- [ ] Bulk paths pace against 200 requests per minute and batch to at most 1,000 recipients. +- [ ] Webhook receiver verifies signature and timestamp, returns `200` fast, and dedupes. +- [ ] Receiver returns non-2xx on genuine failure so Sent retries. +- [ ] `message_id` to tenant mapping is persisted before sending. +- [ ] `request_id` is logged; secrets and card data are not. +- [ ] Sandbox smoke test passes, then a real send reaches `DELIVERED`. +- [ ] Alerting covers webhook `consecutive_failures`, `429` volume, and filtered or blocked rates. + +## Verification + +Run the local preflight, which needs no credentials and no network: + +```bash +python3 scripts/preflight.py --self-test +``` + +Then verify a real path with `"sandbox": true`, which authenticates and validates without executing, and finally with one live send confirmed to `DELIVERED` through the receiver. + +## Boundaries + +Use `sent-webhook-engineer` for receiver depth, `sent-routing-strategist` for channel choice, `sent-messaging` for a confirmed one-off send, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` for multi-tenant provisioning, and `migrate-to-sent` when replacing another CPaaS provider. diff --git a/skills/sent-integration-starter/agents/openai.yaml b/skills/sent-integration-starter/agents/openai.yaml new file mode 100644 index 0000000..4e65bfc --- /dev/null +++ b/skills/sent-integration-starter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Integration Starter" + short_description: "Stand up a production-ready Sent integration" + default_prompt: "Use $sent-integration-starter to review how my codebase should integrate Sent and what to harden before going live." diff --git a/skills/sent-integration-starter/references/errors-and-limits.md b/skills/sent-integration-starter/references/errors-and-limits.md new file mode 100644 index 0000000..55b0d4d --- /dev/null +++ b/skills/sent-integration-starter/references/errors-and-limits.md @@ -0,0 +1,186 @@ +# Error catalog, retries, idempotency, and rate limits + +## Table of contents + +- [Response envelope](#response-envelope) +- [Retry classification by family](#retry-classification-by-family) +- [AUTH codes](#auth-codes) +- [VALIDATION codes](#validation-codes) +- [RESOURCE codes](#resource-codes) +- [BUSINESS codes](#business-codes) +- [CONFLICT, SERVICE, and INTERNAL codes](#conflict-service-and-internal-codes) +- [Codes that behave differently on send](#codes-that-behave-differently-on-send) +- [Idempotency semantics](#idempotency-semantics) +- [Rate limits and pacing](#rate-limits-and-pacing) +- [Sandbox semantics](#sandbox-semantics) +- [Ambiguous send recovery](#ambiguous-send-recovery) + +## Response envelope + +Every response uses one shape: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_004", + "message": "Request validation failed", + "details": { "to": ["'to' must contain at least one recipient"] }, + "doc_url": "https://docs.sent.dm/reference/api/error-catalog" + }, + "meta": { + "request_id": "req_7X9zKp2jDw", + "timestamp": "2026-03-14T09:21:44Z", + "version": "v3" + } +} +``` + +Branch on `error.code`, never on `error.message`. Read `error.details` for field-level validation feedback and log `meta.request_id` on every response so support can correlate. + +## Retry classification by family + +| Family | Count | Default handling | +| --- | --- | --- | +| `AUTH_` | 6 | Terminal. Stop immediately; do not loop | +| `VALIDATION_` | 8 | Terminal. Fix the request | +| `RESOURCE_` | 14 | Terminal; reconcile `RESOURCE_007` with the existing resource | +| `BUSINESS_` | 11 | Mostly terminal; `BUSINESS_002` backs off | +| `CONFLICT_` | 1 | Retry once after a short pause | +| `SERVICE_` | 1 | Retry with backoff | +| `INTERNAL_` | 5 | Retry with backoff | + +The catalog contains 46 codes in total. Authentication failures deserve special care: ten consecutive failures lock the presented credential with a `429` and escalating lockout windows from one to sixty minutes, so a retry loop against a bad key extends its own outage. Stop and alert instead. + +## AUTH codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `AUTH_001` | 401 | User is not authenticated | never | +| `AUTH_002` | 401 | Invalid or missing API key | never | +| `AUTH_004` | 403 | Insufficient permissions | never | +| `AUTH_005` | 403 | Account not yet activated | never | +| `AUTH_006` | 403 | KYC verification not complete | never | +| `AUTH_007` | 403 | Channel setup not complete | never | + +`AUTH_004` is also what a profile-scoped key receives when it sends `x-profile-id`. `AUTH_005`, `AUTH_006`, and `AUTH_007` are onboarding states rather than credential problems, so surface them to an operator instead of retrying. + +## VALIDATION codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `VALIDATION_001` | 400 | Request validation failed | never | +| `VALIDATION_002` | 400 | Invalid phone number format | never | +| `VALIDATION_003` | 400 | Invalid GUID format | never | +| `VALIDATION_004` | 400 | Required field is missing | never | +| `VALIDATION_005` | 400 | Field value out of valid range | never | +| `VALIDATION_006` | 400 | Invalid enum value | never | +| `VALIDATION_007` | 400 | Invalid Idempotency-Key format | never | +| `VALIDATION_008` | 400 | Invalid template variable value | never | + +`VALIDATION_002` is prevented by normalizing recipients to E.164 before the call. `VALIDATION_006` is what an unsupported `channel` value returns. `VALIDATION_008` covers several distinct template-variable problems, so read the message rather than assuming one cause. + +## RESOURCE codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `RESOURCE_001` | 404 | Contact not found | never | +| `RESOURCE_002` | 404 | Template not found | never | +| `RESOURCE_003` | 404 | Message not found | never | +| `RESOURCE_004` | 404 | Customer not found | never | +| `RESOURCE_005` | 404 | Organization not found | never | +| `RESOURCE_006` | 404 | User not found | never | +| `RESOURCE_007` | 409 | Resource already exists | do not retry blindly | +| `RESOURCE_008` | 404 | Webhook not found | never | +| `RESOURCE_009` | 404 | Brand not found | never | +| `RESOURCE_010` | 404 | Campaign not found | never | +| `RESOURCE_011` | 404 | Batch not found | never | +| `RESOURCE_012` | 404 | Phone number not found | never | +| `RESOURCE_013` | 404 | Resource not found | never | +| `RESOURCE_014` | 404 | Profile not found | never | + +`RESOURCE_014` also occurs when an organization passes its own identifier as a `profileId`, which must be a child profile. `RESOURCE_007` is the duplicate-creation signal, most visibly when inviting a user who already has access; read the existing resource and decide whether the requested state is already satisfied. + +## BUSINESS codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `BUSINESS_001` | 400 | Cannot modify inherited contact | never | +| `BUSINESS_002` | 429 | Rate limit exceeded | backoff | +| `BUSINESS_003` | 402 | Insufficient account balance | never | +| `BUSINESS_004` | 400 | Contact has opted out | never | +| `BUSINESS_005` | 400 | Template not approved | never | +| `BUSINESS_006` | 400 | Message cannot be modified in current state | never | +| `BUSINESS_007` | 400 | Channel not available | never | +| `BUSINESS_008` | 400 | Operation would exceed quota | never | +| `BUSINESS_010` | 400 | Webhook is inactive | never | +| `BUSINESS_012` | 400 | Template is not active on the requested channel | never | +| `BUSINESS_014` | 403 | Account is suspended | never | + +`BUSINESS_001` is the inheritance boundary: a profile that inherits contacts cannot modify them. `BUSINESS_010` explains why a test delivery to a disabled webhook fails; re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the dashboard after fixing the receiver. + +## CONFLICT, SERVICE, and INTERNAL codes + +| Code | HTTP | Title | Retry | +| --- | --- | --- | --- | +| `CONFLICT_001` | 409 | Concurrent idempotent request | after delay | +| `SERVICE_001` | 503 | Cache service temporarily unavailable | backoff | +| `INTERNAL_001` | 500 | Unexpected internal server error | backoff | +| `INTERNAL_002` | 500 | Database operation failed | backoff | +| `INTERNAL_003` | 500 | External service error | backoff | +| `INTERNAL_004` | 504 | Timeout waiting for operation | backoff | +| `INTERNAL_005` | 503 | Service temporarily unavailable | backoff | + +`SERVICE_001` is a deliberate safety response: the idempotency cache was unavailable, so the API refused to execute rather than risk a duplicate. Retrying the same request with the same key is correct. + +## Codes that behave differently on send + +Two documented request-level codes do not reject `POST /v3/messages`. Insufficient balance (`BUSINESS_003`, 402) and an opted-out contact (`BUSINESS_004`, 400) are catalogued as errors, but on send the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED` respectively. Client code that only inspects HTTP status will believe those sends succeeded. + +The operational consequence is that balance and consent problems appear in delivery data rather than in error handling. Monitor blocked and filtered rates as first-class metrics alongside `4xx` and `5xx` counts. + +Sent also records internal reason codes on a message for consent blocks, route denials, no-route-matched, and invalid template parameters. These are never returned in API responses or webhook payloads, so diagnosis uses the terminal status plus the channel value plus `GET /v3/messages/{id}/activities`. + +## Idempotency semantics + +`Idempotency-Key` applies to POST, PUT, and PATCH on `/v3/*` and is ignored on GET and DELETE. Values are 1 to 255 characters of `[A-Za-z0-9_-]`. + +| Situation | Behavior | +| --- | --- | +| First successful request | Response cached for 24 hours per key per customer | +| Replay of a cached key | Cached body returned with `Idempotent-Replayed: true` and `X-Original-Request-Id` | +| Response larger than 5 MB | Not cached; a duplicate re-executes | +| Duplicate arrives while the original is in flight | Waits up to five seconds, then fails `409 CONFLICT_001` | +| Idempotency cache unavailable | `503 SERVICE_001`; the request was not executed | + +Derive keys deterministically from your own domain objects — an order id plus a notification type, for example — rather than generating a random value per attempt, so that a retry after a network timeout collides with the original instead of creating a second send. Because caching is per customer, the same key used by two different customers is two independent operations. + +## Rate limits and pacing + +| Tier | Limit | Window | Applies to | +| --- | --- | --- | --- | +| Standard | 200 requests/minute | Sliding 60 seconds | Everything not listed below | +| Sensitive | 10 requests/minute | Fixed window | `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/webhooks/{id}/test` | + +`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` are present **only** on `429` responses. There is no way to read remaining quota preemptively, so pacing must be a design decision rather than an adaptive reaction. + +For bulk work, batch up to 1,000 recipients per `POST /v3/messages` and pace at roughly one request per second, which keeps a large campaign inside the standard budget while leaving headroom for transactional traffic. Note that batching multiplies with channels: 1,000 recipients on two channels is 2,000 messages and 2,000 charges from a single request. + +Rate-limit exposure follows the credential. A profile-scoped key has its own pool; an organization key acting through `x-profile-id` draws on the organization pool shared by every profile. + +## Sandbox semantics + +`"sandbox": true` runs authentication and validation and then stops. Nothing is persisted, queued, dispatched to a provider, or charged, and resource lookups do not occur — so a sandbox request will not tell you whether a template id exists. Malformed requests still return real `400` and `422` responses, which is what makes sandbox valuable in continuous integration. + +The exception worth memorizing: `DELETE /v3/webhooks/{id}` ignores the flag and always deletes. Never use sandbox as a general dry-run guard for destructive calls. + +## Ambiguous send recovery + +When a send times out or the connection drops before a response arrives, the request may or may not have been accepted. Never blind-retry. + +1. If the original carried an `Idempotency-Key`, retry with the **same** key. A cached success returns the original response with `Idempotent-Replayed: true`; a `409 CONFLICT_001` means the original is still in flight, so pause and retry once. +2. If no key was sent, search your own request and response records for a returned `message_id`. Sent exposes no reliable lookup by idempotency key or recipient that can prove an ambiguous request did not execute. +3. Escalate ambiguous no-key cases for an explicit duplicate-risk decision. Only send again when your application has sufficient evidence that nothing was accepted, and attach an idempotency key this time. + +The same discipline applies to profile provisioning: a deterministic key derived from your provisioning record prevents a timeout from creating a second profile. diff --git a/skills/sent-integration-starter/references/sdk-and-frameworks.md b/skills/sent-integration-starter/references/sdk-and-frameworks.md new file mode 100644 index 0000000..713b428 --- /dev/null +++ b/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -0,0 +1,158 @@ +# SDK selection and framework wiring + +## Table of contents + +- [Package matrix](#package-matrix) +- [Client construction per language](#client-construction-per-language) +- [Configuration and environment variables](#configuration-and-environment-variables) +- [Framework wiring](#framework-wiring) +- [Background processing per ecosystem](#background-processing-per-ecosystem) +- [Multi-tenant credential patterns](#multi-tenant-credential-patterns) +- [Testing and mocking](#testing-and-mocking) +- [Deployment notes](#deployment-notes) + +## Package matrix + +| Language | Package | Install | Minimum runtime | +| --- | --- | --- | --- | +| TypeScript | `@sentdm/sentdm` | `npm install @sentdm/sentdm` | Node with ESM or CJS | +| Python | `sentdm` (imports as `sent_dm`) | `pip install sentdm` | Python 3.9 | +| Go | `github.com/sentdm/sent-dm-go` | `go get github.com/sentdm/sent-dm-go` | Go 1.22 | +| Java | `dm.sent:sent-java` | Maven or Gradle dependency | Java 8 | +| C# | `Sentdm` | `dotnet add package Sentdm` | .NET Standard 2.0 | +| PHP | `sentdm/sent-dm-php` | `composer require sentdm/sent-dm-php` | PHP 8.1 | +| Ruby | `sentdm` | `gem install sentdm` or Bundler | Ruby 3.2 | + +The distribution name and the import name differ in Python (`sentdm` installs, `sent_dm` imports) and the Ruby send method is `messages.send_` with a trailing underscore because `send` is reserved. Both are common first-hour errors. + +No SDK ships a webhook signature verifier in any language. That code is always application-owned. + +## Client construction per language + +```typescript +import SentDm from '@sentdm/sentdm'; + +// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. +export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); + +const response = await sent.messages.send({ + to: ['+14155551234'], + template: { name: 'order_confirmation', parameters: { order_id: '12345' } }, +}); +``` + +```python +from sent_dm import Sent, AsyncSent + +client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY +async_client = AsyncSent() + +response = client.messages.send( + to=["+14155551234"], + template={"name": "order_confirmation", "parameters": {"order_id": "12345"}}, +) +``` + +```go +client := sentdm.NewClient() // or option.WithAPIKey(...) +response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ + To: []string{"+14155551234"}, +}) +``` + +```java +SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +MessageSendResponse response = client.messages().send(params); +``` + +```csharp +using Sentdm; +SentClient client = new(); // reads SENT_DM_API_KEY +var response = await client.Messages.Send(body); +``` + +```php +use SentDm\Client; +$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); +``` + +```ruby +require "sentdm" +client = Sentdm::Client.new # reads SENT_DM_API_KEY +client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) +``` + +Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. + +## Configuration and environment variables + +| Variable | Purpose | Read automatically | +| --- | --- | --- | +| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | +| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | +| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | + +Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. + +For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. + +## Framework wiring + +| Framework | Client placement | Webhook raw body | +| --- | --- | --- | +| Next.js | Shared module such as `lib/sent/client.ts` | `await request.text()`; keep the route on the Node runtime | +| Express | Module singleton | `express.raw({ type: 'application/json' })` scoped to the webhook path | +| NestJS | Provider in a `SentModule` | `req.rawBody` with `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | Client built in the lifespan, injected as a dependency | `await request.body()` | +| Django | `@lru_cache` factory in a `client.py` | `request.body` | +| Flask | Cached on the app or request context | `request.get_data()` | +| Gin / Echo | Constructed in `main`, passed to handlers | `io.ReadAll(c.Request.Body)` | +| Spring Boot | `@Bean` in a configuration class | `@RequestBody String payload` | +| Laravel | Singleton in the service container | `$request->getContent()` in middleware | +| Symfony | Autowired service | `$request->getContent()` | +| Rails | Memoized in an initializer | `request.body.read` then `request.body.rewind` | +| Sinatra | Memoized module method | `request.body.read` then `request.body.rewind` | +| ASP.NET Core | Singleton via dependency injection | `new StreamReader(request.Body).ReadToEndAsync()` | + +The recurring defect is a global JSON body parser that destroys the byte-exact body needed for signature verification. Scope the parser away from the webhook path, or read the raw bytes before any parsing occurs. + +A minimal integration is four files regardless of stack: a client module, an outbound send route, an inbound webhook route, and a signature-verification helper. + +## Background processing per ecosystem + +Webhook handlers must acknowledge with `200` and then work asynchronously, because ten consecutive failed deliveries disable the endpoint and a slow handler manufactures those failures. + +| Ecosystem | Mechanism | +| --- | --- | +| Node | BullMQ or an equivalent durable queue | +| Python | Celery or another durable queue; reserve FastAPI `BackgroundTasks` for non-critical local work | +| Go | A bounded worker pool or a job queue | +| Java | `@Async` with a `ThreadPoolTaskExecutor`, or a broker | +| PHP | Laravel queued jobs, Symfony Messenger | +| Ruby | ActiveJob or Sidekiq | +| .NET | A `BackgroundService` consuming a channel or queue | + +Route bulk campaign traffic to a queue separate from transactional sends so a large campaign cannot starve time-sensitive messages, and set worker concurrency or a task rate limit that respects the 200-requests-per-minute budget. + +## Multi-tenant credential patterns + +Two patterns exist, and mixing them causes confusing `403` responses. + +A **profile-scoped key** is confined to one profile, has its own rate-limit pool, and must not send `x-profile-id` — doing so returns `403`. Prefer it for runtime send paths so a leaked key affects one tenant. + +An **organization key with `x-profile-id`** reaches permitted child profiles but draws on the organization's shared rate-limit pool, so one noisy tenant consumes everyone's quota. Prefer it for control-plane work such as provisioning. + +When each tenant supplies its own key, resolve it for the request, construct the client with that credential, and discard both afterward. Do not retain tenant credentials in a client cache merely to preserve connection pooling; isolation and rotation correctness take priority. Queued work must resolve the authorized tenant credential just in time from a secret store rather than embedding it in the job payload. Never place a key in a browser, mobile app, or any client the organization does not control, and keep separate keys per environment. `x-sender-id` is legacy v1 and v2 terminology with no role in v3. + +## Testing and mocking + +Use `"sandbox": true` for integration tests: authentication and validation still run, so a malformed request still returns `400` or `422`, but nothing is written, queued, charged, or dispatched to a provider. It is the right default in continuous integration. + +For unit tests, mock at the SDK boundary — `jest.fn()` on `messages.send`, a NestJS testing module override, a substituted `ISentClient` in .NET — and assert on the request payload rather than on transport behavior. For the receiver, generate valid headers locally with the webhook skill's signing script so tests cover the signature path without contacting Sent. + +Two notes on live verification. `POST /v3/webhooks/{id}/test` delivers exactly once with no retry, so re-run it after each fix. And `DELETE /v3/webhooks/{id}` ignores `sandbox` and always deletes, so never treat the flag as a dry-run guard for deletion. + +## Deployment notes + +Keep webhook routes on runtimes that expose Node-style crypto and raw bodies rather than on edge runtimes. Close the HTTP server gracefully on `SIGTERM` so in-flight deliveries finish instead of failing and triggering retries. Ensure load balancer idle timeouts exceed the configured `timeout_seconds`, and keep container clocks NTP-synchronized so the 300-second replay window does not reject valid traffic. Keep the route outside user-auth middleware. If abuse controls are required, make them signature-aware and capacity-safe rather than placing a generic limiter in front of verification and manufacturing the failures that lead to auto-disable. diff --git a/skills/sent-integration-starter/scripts/preflight.py b/skills/sent-integration-starter/scripts/preflight.py new file mode 100644 index 0000000..7f74185 --- /dev/null +++ b/skills/sent-integration-starter/scripts/preflight.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Offline preflight checks for a Sent v3 integration. + +Validates the things that break integrations before any network call is made: +recipient formatting, send-payload shape, channel-array intent, idempotency-key +format, batch sizing against the documented pacing budget, and the retry +classification of an error code. + +Usage +----- +Run the built-in synthetic fixtures:: + + python3 preflight.py --self-test + +Check a send payload written to a file:: + + python3 preflight.py --payload-file send.json + +Classify an error code for retry behavior:: + + python3 preflight.py --classify-error 409:CONFLICT_001 + +Exit codes: 0 all checks passed, 1 one or more findings, 3 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +E164 = re.compile(r"^\+[1-9]\d{1,14}$") +IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9_-]{1,255}$") +VALID_CHANNELS = {"sent", "sms", "whatsapp", "rcs"} +MAX_RECIPIENTS_PER_REQUEST = 1000 +STANDARD_RATE_LIMIT_PER_MINUTE = 200 +SENSITIVE_RATE_LIMIT_PER_MINUTE = 10 + +TERMINAL_FAMILIES = {"AUTH", "VALIDATION", "RESOURCE"} +RETRYABLE_FAMILIES = {"SERVICE", "INTERNAL"} + +EXIT_OK = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 3 + + +def check_recipients(recipients: object) -> list[str]: + """Validate the `to` array.""" + findings: list[str] = [] + if not isinstance(recipients, list) or not recipients: + return ["'to' must be a non-empty array of E.164 phone numbers"] + for value in recipients: + if not isinstance(value, str) or not E164.match(value): + findings.append(f"recipient {value!r} is not E.164 (leading '+', country code, digits only)") + if len(recipients) > MAX_RECIPIENTS_PER_REQUEST: + findings.append( + f"{len(recipients)} recipients exceeds the {MAX_RECIPIENTS_PER_REQUEST}-recipient per-request limit" + ) + return findings + + +def check_channels(channels: object) -> list[str]: + """Validate the `channel` array and flag broadcast intent.""" + if channels is None: + return [] + findings: list[str] = [] + if not isinstance(channels, list): + return ["'channel' must be an array when present"] + for value in channels: + if value not in VALID_CHANNELS: + findings.append(f"channel {value!r} is invalid; allowed values are {sorted(VALID_CHANNELS)}") + explicit = [value for value in channels if value != "sent"] + if len(explicit) > 1: + findings.append( + "multiple explicit channels broadcast rather than fall back: one message and one charge is created " + "per (recipient, channel) pair. Omit 'channel' or use ['sent'] for automatic routing with reroute" + ) + if "sent" in channels and len(channels) > 1: + findings.append("'sent' combined with an explicit channel is ambiguous; use one or the other") + return findings + + +def check_content(payload: dict) -> list[str]: + """Validate that exactly one content source is present.""" + has_template = isinstance(payload.get("template"), dict) + has_text = isinstance(payload.get("text"), str) and payload["text"].strip() != "" + if has_template and has_text: + return ["provide either 'template' or 'text', not both"] + if not has_template and not has_text: + return ["provide 'template' or 'text' as the message content"] + if has_template: + template = payload["template"] + if not template.get("id") and not template.get("name"): + return ["'template' requires 'id' or 'name'"] + if template.get("id") and template.get("name"): + return ["'template.id' and 'template.name' are mutually exclusive"] + parameters = template.get("parameters") + if parameters is not None and not isinstance(parameters, dict): + return ["'template.parameters' must be an object of string values"] + if isinstance(parameters, dict) and any(not isinstance(value, str) for value in parameters.values()): + return ["every 'template.parameters' value must be a string"] + return [] + + +def check_idempotency_key(key: object) -> list[str]: + """Validate an Idempotency-Key header value.""" + if key is None: + return ["no Idempotency-Key supplied; a timeout retry can produce a duplicate send"] + if not isinstance(key, str) or not IDEMPOTENCY_KEY.match(key): + return ["Idempotency-Key must be 1-255 characters of letters, digits, hyphens, or underscores"] + return [] + + +def estimate_batches(recipient_count: int, channel_count: int = 1) -> dict[str, int]: + """Return message and request estimates for a bulk send.""" + channel_count = max(1, channel_count) + messages = recipient_count * channel_count + requests = -(-recipient_count // MAX_RECIPIENTS_PER_REQUEST) + minutes = -(-requests // STANDARD_RATE_LIMIT_PER_MINUTE) + return { + "messages_created": messages, + "requests_required": requests, + "minimum_minutes_at_rate_limit": minutes, + } + + +def classify_error(status: int, code: str) -> tuple[str, str]: + """Return (classification, guidance) for a Sent error response.""" + family = code.split("_", 1)[0].upper() if code else "" + if status == 429: + return "retry", "honor Retry-After, then use jittered exponential backoff; stop if the credential is locked" + if code.upper() == "CONFLICT_001": + return "retry-once", "a concurrent duplicate is in flight; pause, then retry the same Idempotency-Key once" + if code.upper() == "SERVICE_001": + return "retry", "the idempotency store was unavailable and the request was deliberately not executed" + if family in RETRYABLE_FAMILIES or 500 <= status < 600: + return "retry", "exponential backoff with jitter and a bounded ceiling" + if family == "AUTH": + return "terminal", "stop immediately; ten consecutive auth failures lock the credential with escalating lockout" + if family in TERMINAL_FAMILIES: + return "terminal", "fix the request or the referenced resource; retrying reproduces the same result" + if family == "BUSINESS": + return "conditional", ( + "an account or policy precondition; on POST /v3/messages the send is accepted with 202 and the " + "affected messages finalize as BLOCKED or FILTERED, so resolve the condition before resending" + ) + return "unknown", "treat as terminal until classified; log meta.request_id and inspect error.doc_url" + + +def check_payload(payload: dict, idempotency_key: str | None = None) -> list[str]: + """Run every payload check and return the accumulated findings.""" + findings: list[str] = [] + findings.extend(check_recipients(payload.get("to"))) + findings.extend(check_channels(payload.get("channel"))) + findings.extend(check_content(payload)) + findings.extend(check_idempotency_key(idempotency_key)) + return findings + + +def _self_test() -> int: + failures: list[str] = [] + + good = { + "to": ["+14155551234"], + "template": {"name": "order_confirmation", "parameters": {"order_id": "12345"}}, + } + if check_payload(good, "order-12345-confirmation"): + failures.append("a well-formed payload with an idempotency key must produce no findings") + + if not check_recipients(["4155551234"]): + failures.append("a non-E.164 recipient must be flagged") + if not check_recipients([]): + failures.append("an empty recipient list must be flagged") + if not check_recipients(["+1415555%s" % "1" * 15]): + failures.append("an over-long number must be flagged") + + broadcast = check_channels(["whatsapp", "sms"]) + if not any("broadcast" in finding for finding in broadcast): + failures.append("a multi-channel array must be flagged as broadcast, not fallback") + if check_channels(["sent"]) or check_channels(None): + failures.append("automatic routing must produce no channel findings") + if not check_channels(["telegram"]): + failures.append("an unsupported channel value must be flagged") + + if not check_content({"to": ["+14155551234"]}): + failures.append("missing content must be flagged") + if not check_content({"template": {"id": "x", "name": "y"}}): + failures.append("template id and name together must be flagged") + if not check_content({"template": {"name": "t"}, "text": "hello"}): + failures.append("template and text together must be flagged") + if not check_content({"template": {"name": "t", "parameters": {"count": 2}}}): + failures.append("non-string template parameter values must be flagged") + + if not check_idempotency_key(None): + failures.append("a missing idempotency key must be flagged") + if not check_idempotency_key("bad key!"): + failures.append("an invalid idempotency key must be flagged") + + estimate = estimate_batches(2500, 2) + if estimate != {"messages_created": 5000, "requests_required": 3, "minimum_minutes_at_rate_limit": 1}: + failures.append(f"batch estimation drifted: {estimate}") + + expectations = { + (429, "BUSINESS_009"): "retry", + (409, "CONFLICT_001"): "retry-once", + (503, "SERVICE_001"): "retry", + (401, "AUTH_002"): "terminal", + (400, "VALIDATION_004"): "terminal", + (404, "RESOURCE_001"): "terminal", + (500, "INTERNAL_001"): "retry", + (402, "BUSINESS_003"): "conditional", + } + for (status, code), expected in expectations.items(): + actual, _ = classify_error(status, code) + if actual != expected: + failures.append(f"{status} {code} classified as {actual}, expected {expected}") + + if SENSITIVE_RATE_LIMIT_PER_MINUTE >= STANDARD_RATE_LIMIT_PER_MINUTE: + failures.append("the sensitive tier must be lower than the standard tier") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_FINDINGS + print("preflight self-test passed: 20 checks") + return EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Offline preflight checks for a Sent v3 integration.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--payload-file", help="path to a JSON send payload to check") + parser.add_argument("--idempotency-key", help="the Idempotency-Key that will be sent with the payload") + parser.add_argument("--estimate", type=int, metavar="RECIPIENTS", help="estimate messages, requests, and pacing") + parser.add_argument("--channels", type=int, default=1, help="number of channels used with --estimate") + parser.add_argument("--classify-error", metavar="STATUS:CODE", help="classify an error response for retry") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if args.classify_error: + try: + status_text, _, code = args.classify_error.partition(":") + classification, guidance = classify_error(int(status_text), code) + except ValueError: + print("error: --classify-error expects STATUS:CODE, for example 429:BUSINESS_009", file=sys.stderr) + return EXIT_USAGE + print(f"{args.classify_error} -> {classification}: {guidance}") + return EXIT_OK + + if args.estimate is not None: + for key, value in estimate_batches(args.estimate, args.channels).items(): + print(f"{key}: {value}") + return EXIT_OK + + if not args.payload_file: + parser.error("provide --payload-file, --estimate, --classify-error, or --self-test") + + try: + with open(args.payload_file, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + print(f"error: could not read payload: {exc}", file=sys.stderr) + return EXIT_USAGE + + if not isinstance(payload, dict): + print("error: payload must be a JSON object", file=sys.stderr) + return EXIT_USAGE + + findings = check_payload(payload, args.idempotency_key) + if payload.get("sandbox") is True: + print("note: sandbox is true, so this request validates and authenticates without executing") + if not findings: + print("payload passed all preflight checks") + return EXIT_OK + for finding in findings: + print(f"- {finding}") + return EXIT_FINDINGS + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/sent-profile-provisioning/SKILL.md b/skills/sent-profile-provisioning/SKILL.md new file mode 100644 index 0000000..a021269 --- /dev/null +++ b/skills/sent-profile-provisioning/SKILL.md @@ -0,0 +1,134 @@ +--- +name: sent-profile-provisioning +description: Executes the Sent Sender Profile lifecycle over the API — creating profiles with the right inheritance, sharing, billing, and WhatsApp options, driving profile completion and its callback, managing 10DLC campaigns per profile, and administering users and roles. Use when calling POST /v3/profiles, handling a completion callback or unclear profile status, choosing inherit or dedicated resources, wiring per-tenant onboarding, or inviting and role-managing users. +--- + +# Sent Profile Provisioning + +This skill is the execution counterpart to profile architecture: once the tenancy boundary is decided, it drives the API calls, the completion callback, the campaign registration, and the user administration that make a profile able to send. Design the boundary with `sender-profile-architect` first; provision it here. + +## Provisioning sequence + +1. **Confirm the credential.** `POST /v3/profiles` requires an organization key with `admin`. Profile-scoped keys cannot create profiles, and a profile key that sends `x-profile-id` receives `403`. +2. **Decide inheritance and sharing before the call.** These flags shape compliance posture and are awkward to unwind later. +3. **Create the profile**, validating the payload with `"sandbox": true` first when the shape is uncertain. Use a different idempotency key for the live create because a successful sandbox response is cached for 24 hours. +4. **Attach or inherit WhatsApp** via exactly one of the three supported paths. +5. **Register campaigns** for US SMS under the profile. +6. **Complete the profile** with `POST /v3/profiles/{profileId}/complete` and a reachable `webHookUrl`. +7. **Reconcile status** from the callback, or by polling if the callback is missed. +8. **Invite users** with least-privilege roles. + +## Create payload essentials + +`name` is the only required field. The consequential optional fields group into identity, sharing, inheritance, billing, WhatsApp, and brand. + +```json +{ + "name": "Northwind Retail", + "short_name": "Northwind", + "description": "Retail brand tenant", + "allow_contact_sharing": false, + "allow_template_sharing": false, + "inherit_contacts": false, + "inherit_templates": false, + "inherit_tcr_brand": true, + "inherit_tcr_campaign": true, + "billing_model": "profile", + "billing_contact": { + "name": "Ada Ops", + "email": "ops@example.com", + "phone": "+14155550100", + "address": "1 Example Way, Springfield" + }, + "sandbox": true +} +``` + +`short_name` must be 3 to 11 characters of letters, numbers, and spaces with at least one letter. Inheritance flags default to true, so a profile created with no flags consumes the organization's contacts, templates, brand, and campaigns. The example opts into contact and template isolation explicitly while inheriting the organization's compliance registrations. Sharing flags expose this profile's resources outward; inheritance flags consume the organization's resources inward. They are independent directions and are frequently confused. + +Create permits `name` alone, but completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. When `inherit_tcr_brand` is true, the API rejects a `brand` object in the create request even though the profile still needs its own KYC submission; complete that KYC through the dashboard before calling the completion endpoint. + +`billing_model` accepts `profile`, `organization`, or `profile_and_organization`. Any model that includes `profile` requires `billing_contact` when none exists, and `payment_details` is only accepted for those models. Card fields are forwarded to the payment processor and must never be logged, echoed, or persisted anywhere in the application. + +Field-by-field rules, error codes, and the update-only fields are in [references/profile-lifecycle.md](references/profile-lifecycle.md). + +## Inheritance decisions + +| Flag | `true` means | Consequence | +| --- | --- | --- | +| `inherit_tcr_brand` | Use the organization's registered brand | A `brand` object in the same request is rejected | +| `inherit_tcr_campaign` | Use the organization's campaigns | Those campaigns are read-only for this profile; creating one returns a validation error | +| `inherit_contacts` | Read the organization's contacts | No contact isolation between tenants | +| `inherit_templates` | Read the organization's templates | No template isolation between tenants | + +An inherited brand with `inherit_tcr_campaign: false` is a supported and common pattern: shared legal identity, dedicated messaging use cases per tenant. + +## WhatsApp: exactly three paths + +1. Organization Embedded Signup, performed in the Sent Dashboard. **No public endpoint starts this flow.** +2. Child-profile inheritance — omit `whatsapp_business_account` once the organization has a WABA. +3. Dedicated profile credentials — supply `whatsapp_business_account` with `waba_id` and `access_token`, optionally `phone_number_id`. + +Supplying credentials on `POST /v3/profiles` is not an Embedded Signup endpoint. Omitting `whatsapp_business_account` when the organization has no WABA configured returns `422`; complete organization Embedded Signup or supply valid direct credentials. Use `waba-embedded-signup` for the operational signup flow. + +## Completion and status + +`POST /v3/profiles/{profileId}/complete` requires `webHookUrl`. + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +A `202` means processing started and carries no final status. A `200` means the profile was already complete and its body carries a status. The callback body is `{profileId, success, status, timestamp}` and is **delivered once with no retry**, so the receiver must be live before the call and the flow must degrade to polling `GET /v3/profiles/{profileId}`. This callback is separate from subscribed Sent webhooks and is not documented as carrying the webhook HMAC headers; use a unique callback path tied to the provisioning record, reject unknown profile ids, and treat polling as the authoritative recovery path. + +Profile status vocabulary differs by surface: the create response demonstrates lowercase `incomplete`, the completion `200` demonstrates lowercase `completed`, the completion callback uses `COMPLETED`, `SUBMITTED`, and `failed`, and `GET /v3/profiles/{id}` documents `approved`, `submitted`, `processing`, and `failed`. Do not assert a closed enum, do not lowercase-normalize into a fixed set, and record which surface produced each value. Compare statuses case-insensitively and preserve unknown strings. + +## Campaigns per profile + +Campaign management lives under the profile: `GET|POST /v3/profiles/{profileId}/campaigns` and `PUT|DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. There are no standalone brand endpoints; a dedicated brand is created with the profile. + + +```json +{ + "campaign": { + "name": "Northwind order notifications", + "description": "Order and delivery notifications for opted-in Northwind customers.", + "type": "App", + "useCases": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Northwind: Your order 12345 has shipped. Reply STOP to opt out." + ] + } + ], + "volume": "1500", + "messageFlow": "Customers opt in at checkout before notifications begin.", + "privacyPolicyLink": "https://example.com/privacy", + "termsAndConditionsLink": "https://example.com/terms" + } +} +``` + +`messagingUseCaseUs` accepts one of thirteen values, `sampleMessages` holds 1 to 5 entries of at most 1,024 characters each, and a numeric `volume` string below 2,000 selects the low-volume tier while 2,000 or above selects the standard tier. Campaign statuses are `SENT_CREATED`, `ACTIVE`, and `EXPIRED`. Use `sms-10dlc-registration` for use-case selection and sample-copy policy. + +## Users and roles + +Five operations administer access: `GET /v3/users`, `POST /v3/users` (invite), `GET /v3/users/{userId}`, `PATCH /v3/users/{userId}` (role), and `DELETE /v3/users/{userId}`. None is exposed through MCP. Assignable roles are `admin`, `billing`, and `developer`; `owner` is implicit for the creating account and never appears in the list. Mutations require `admin`. + +Role checks resolve against the email that owns the API key and pass only for the owner or an **active** user with an allowed role — `invited`, `suspended`, and `rejected` users fail. Organization-level access cascades to child profiles. Invitations expire after seven days, and inviting an existing user returns `409`. + +Before any user mutation, read the current state, then confirm explicitly with the operator. The API refuses to let you change your own role, demote the last admin, remove yourself, or remove the last admin, but checking first produces a clear explanation instead of a validation error. The full role matrix and key-hygiene rules are in [references/users-and-roles.md](references/users-and-roles.md). + +There is no endpoint to list, create, or revoke API keys; key management is a dashboard operation. Rotation is create-new, deploy, verify with `GET /v3/me`, then disable or delete the old key — deleting first only when the key is compromised. + +## Multi-tenant provisioning notes + +Webhook events never carry your application's tenant identifier. Before the first send, persist `message_id -> {tenant, profile, logical_send_id, channel}` and `receiving_number -> {tenant, profile}`. Do not infer tenant ownership from `account_id`, since many tenant profiles can share one organization. Provision one webhook registration per environment so a failing lower-environment receiver cannot auto-disable production. + +## Boundaries + +Use `sender-profile-architect` for the isolation, credential, and blast-radius design decision; `waba-embedded-signup` for the WhatsApp signup flow; `sms-10dlc-registration` for brand vetting and campaign policy; and `sent-webhook-engineer` for subscribed message-event receivers. Profile-completion callbacks use the separate verification and polling guidance in this skill. diff --git a/skills/sent-profile-provisioning/agents/openai.yaml b/skills/sent-profile-provisioning/agents/openai.yaml new file mode 100644 index 0000000..df60ac1 --- /dev/null +++ b/skills/sent-profile-provisioning/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Profile Provisioning" + short_description: "Run the Sender Profile lifecycle" + default_prompt: "Use $sent-profile-provisioning to plan the API calls that bring a new Sender Profile to a sending state." diff --git a/skills/sent-profile-provisioning/references/profile-lifecycle.md b/skills/sent-profile-provisioning/references/profile-lifecycle.md new file mode 100644 index 0000000..108f49c --- /dev/null +++ b/skills/sent-profile-provisioning/references/profile-lifecycle.md @@ -0,0 +1,167 @@ +# Sender Profile lifecycle reference + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Create field reference](#create-field-reference) +- [Update-only fields](#update-only-fields) +- [Inheritance and sharing matrix](#inheritance-and-sharing-matrix) +- [Billing configuration](#billing-configuration) +- [WhatsApp attachment paths](#whatsapp-attachment-paths) +- [Completion flow and callback](#completion-flow-and-callback) +- [Status vocabulary by surface](#status-vocabulary-by-surface) +- [Error catalog for provisioning](#error-catalog-for-provisioning) +- [Idempotency and sandbox](#idempotency-and-sandbox) +- [Offboarding](#offboarding) + +## Operation catalog + +Ten operations cover profiles and their campaigns. None is available through MCP, so provisioning is REST-only. + +| Operation | Purpose | +| --- | --- | +| `POST /v3/profiles` | Create a profile | +| `GET /v3/profiles` | List profiles | +| `GET /v3/profiles/{profileId}` | Read one profile, including current status | +| `PATCH /v3/profiles/{profileId}` | Update configuration and number references | +| `DELETE /v3/profiles/{profileId}` | Remove a profile | +| `POST /v3/profiles/{profileId}/complete` | Start completion; requires `webHookUrl` | +| `GET /v3/profiles/{profileId}/campaigns` | List campaigns | +| `POST /v3/profiles/{profileId}/campaigns` | Create a campaign | +| `PUT /v3/profiles/{profileId}/campaigns/{campaignId}` | Update a campaign | +| `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` | Delete a campaign | + +Creation requires an organization API key whose owning email holds `admin`. An organization key may target a child with `x-profile-id`; a profile-scoped key sending that header receives `403`, and a profile outside the organization returns `404`. + +## Create field reference + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `name` | string | — | The only required field | +| `icon` | string | — | Display asset | +| `description` | string | — | Free text | +| `short_name` | string | — | 3–11 chars, letters, numbers, spaces, at least one letter | +| `allow_contact_sharing` | boolean | `false` | Exposes this profile's contacts outward | +| `allow_template_sharing` | boolean | `false` | Exposes this profile's templates outward | +| `inherit_contacts` | boolean | `true` | Consumes the organization's contacts | +| `inherit_templates` | boolean | `true` | Consumes the organization's templates | +| `inherit_tcr_brand` | boolean | `true` | Uses the organization's brand; forbids a `brand` object | +| `inherit_tcr_campaign` | boolean | `true` | Inherited campaigns are read-only for this profile | +| `billing_model` | enum | `profile` | `profile`, `organization`, or `profile_and_organization` | +| `billing_contact` | object | — | `name`, `email`, `phone`, `address`; required when the model includes `profile` and none exists | +| `whatsapp_business_account` | object | — | `waba_id` and `access_token` required, `phone_number_id` optional | +| `brand` | object | — | `contact` and `compliance` required, `business` optional; forbidden when `inherit_tcr_brand` is true | +| `payment_details` | object | — | `card_number`, `expiry`, `cvc`, `zip_code`; only for models including `profile` | +| `sandbox` | boolean | `false` | Simulate without side effects | + +`payment_details` is ephemeral and forwarded to the payment processor. Never log it, never echo it back to a user, never store it, and never place it in a file that could be committed. + +Although creation requires only `name`, completion also requires `short_name`, `description`, profile KYC information, and any required campaign or channel setup. A profile inheriting the organization's TCR brand cannot include a `brand` object in the API request but still needs profile-level KYC submitted through the dashboard before completion. + +## Update-only fields + +`PATCH /v3/profiles/{profileId}` accepts the create fields plus number references: + +- `sending_phone_number_profile_id` +- `sending_whatsapp_number_profile_id` +- `sending_phone_number` +- `whatsapp_phone_number` +- `allow_number_change_during_onboarding` + +Model reference identifiers separately from literal numbers, and guard against cycles when one profile's sending number points at another profile that points back. + +## Inheritance and sharing matrix + +Inheritance pulls resources in; sharing pushes them out. They are independent. + +| Configuration | Result | +| --- | --- | +| `inherit_contacts: true`, `allow_contact_sharing: false` | Reads organization contacts; does not expose its own | +| `inherit_contacts: false`, `allow_contact_sharing: true` | Isolated contact store that other profiles may read | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: true` | Fully inherited compliance posture; campaigns read-only here | +| `inherit_tcr_brand: true`, `inherit_tcr_campaign: false` | Shared legal identity with per-tenant use cases — the common multi-tenant pattern | +| `inherit_tcr_brand: false` | Dedicated brand supplied in the same create request | + +For tenant isolation, set `inherit_contacts` and `inherit_templates` to false explicitly, because both default to true and a silently inherited store means one tenant can read another's data model. + +## Billing configuration + +| Model | Meaning | Requires | +| --- | --- | --- | +| `profile` | The profile pays | `billing_contact`, optionally `payment_details` | +| `organization` | The organization pays | Nothing profile-side | +| `profile_and_organization` | Profile first with organization fallback | `billing_contact` | + +Effective balance follows this configuration, so a balance reading for a profile with `organization` billing reflects the organization's funds. Confirm which model a profile uses before interpreting a balance or diagnosing a `BLOCKED` message. + +## WhatsApp attachment paths + +| Path | How | When | +| --- | --- | --- | +| Organization Embedded Signup | Sent Dashboard only; no public endpoint exists | The organization owns one WABA used across profiles | +| Child inheritance | Omit `whatsapp_business_account` | Tenants share the organization's WABA | +| Dedicated credentials | `whatsapp_business_account` with `waba_id` and `access_token` | The tenant owns its own WABA | + +Requesting inheritance when the organization has no WABA configured returns `422 VALIDATION_001`. Do not invent a hybrid, and do not describe the create payload as an Embedded Signup endpoint. + +## Completion flow and callback + +```json +{ + "webHookUrl": "https://provisioning.example.com/callbacks/profile-complete", + "sandbox": false +} +``` + +Responses: `202` means processing started and contains no final status; `200` means the profile was already complete and the body carries a status. + +The callback body is `{profileId, success, status, timestamp}`, documented with `COMPLETED`, `SUBMITTED`, and `failed`. It is delivered **once, with no retry**, which drives three requirements: the receiver must be reachable before the completion call, the receiver must be idempotent on `profileId`, and a reconciliation job must poll `GET /v3/profiles/{profileId}` for profiles that have been awaiting completion beyond a timeout. + +Note that this callback is not part of the `/v3/webhooks` subscription system and is not documented as carrying the `x-webhook-signature` scheme. Give each provisioning record a unique, hard-to-guess callback path, treat its payload as untrusted input, verify `profileId` against the record you created, and never take action on an unrecognized identifier. Polling the profile remains the recovery and reconciliation authority. + +## Status vocabulary by surface + +| Surface | Observed values | +| --- | --- | +| Create response | lowercase `incomplete` | +| Completion `200` | lowercase `completed` | +| Completion callback | `COMPLETED`, `SUBMITTED`, `failed` | +| `GET /v3/profiles/{profileId}` guide | `approved`, `submitted`, `processing`, `failed` | +| REST guides versus OpenAPI | Publish different status sets | + +Handle this by comparing case-insensitively, preserving unknown strings verbatim, recording which surface produced the value, and never switch-casing over an assumed closed enum. A provisioning state machine should treat any unrecognized status as "needs human review" rather than as an error. + +## Error catalog for provisioning + +| Status | Code | Meaning | +| --- | --- | --- | +| 400 | `VALIDATION_001` | Invalid payload, including a `brand` object alongside `inherit_tcr_brand: true` | +| 400 | `VALIDATION_001` | Cannot create campaigns when `inherit_tcr_campaign` is true, or the campaigns are read-only | +| 403 | `AUTH_004` | Profile key attempted `x-profile-id`, or insufficient role | +| 404 | `RESOURCE_005` | Organization not found | +| 404 | `RESOURCE_014` | Profile not found | +| 404 | `RESOURCE_009` | Brand not found for the profile | +| 404 | `RESOURCE_010` | Campaign not found | +| 422 | `VALIDATION_001` | Organization has no WABA configured | + +The response envelope carries `error.code`, `error.message`, `error.details`, and `error.doc_url`, plus `meta.request_id`. Log `request_id` for every provisioning call; it is the correlation handle for support. + +## Idempotency and sandbox + +`Idempotency-Key` is honored on POST, PUT, and PATCH with a value of 1 to 255 characters from `[A-Za-z0-9_-]`. Successful responses are cached 24 hours per key per customer, replays return the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`, a duplicate arriving while the original is in flight waits up to five seconds and then fails `409 CONFLICT_001`, and if the idempotency store is unavailable the API returns `503 SERVICE_001` rather than risk a double execution. Use a deterministic key derived from your own provisioning record so a retry after a network timeout cannot create a second profile. + +`"sandbox": true` authenticates and validates without persisting, queueing, calling providers, deducting balance, or looking up resources. Use it to prove a payload shape in CI. A successful sandbox mutation is itself cached by idempotency, so use a distinct key for the later live mutation or the live call will replay the sandbox response. Sandbox does not protect deletions in the webhook API, so never rely on it as a general dry-run guard. + +## Offboarding + +Deprovisioning a tenant is an ordered, evidence-preserving sequence rather than a single delete: + +1. Stop new sends at the application layer. +2. Disable or delete the tenant's API keys in the dashboard. +3. Remove or downgrade the tenant's users, keeping at least one admin on the organization. +4. Disable webhook registrations that pointed at tenant infrastructure. +5. Detach shared resources deliberately, checking whether other profiles inherit from them. +6. Retain delivery and consent records for the applicable retention period before deleting the profile. +7. Record the `request_id` and timestamp of each step as the audit trail. + +Contact deletion dissociates the contact from the calling customer while shared contact, capability, and delivery records persist, so deletion is not an erasure mechanism. diff --git a/skills/sent-profile-provisioning/references/users-and-roles.md b/skills/sent-profile-provisioning/references/users-and-roles.md new file mode 100644 index 0000000..fe1722c --- /dev/null +++ b/skills/sent-profile-provisioning/references/users-and-roles.md @@ -0,0 +1,108 @@ +# Users, roles, and credential hygiene + +## Table of contents + +- [User operations](#user-operations) +- [Role model](#role-model) +- [How a role check is evaluated](#how-a-role-check-is-evaluated) +- [Invitation lifecycle](#invitation-lifecycle) +- [Guardrails on user mutations](#guardrails-on-user-mutations) +- [API key model](#api-key-model) +- [Key rotation](#key-rotation) +- [Least-privilege assignments](#least-privilege-assignments) +- [Audit evidence](#audit-evidence) + +## User operations + +| Operation | Required role | Body | Notes | +| --- | --- | --- | --- | +| `GET /v3/users` | `developer` or higher | — | Lists active, invited, and suspended users | +| `POST /v3/users` | `admin` | `email`, `name`, `role`, optional `sandbox` | `201` with status `invited`; `409 RESOURCE_007` if the user exists | +| `GET /v3/users/{userId}` | `developer` or higher | — | Single user detail | +| `PATCH /v3/users/{userId}` | `admin` | `role`, optional `sandbox` | Role change only | +| `DELETE /v3/users/{userId}` | `admin` | optional `sandbox` | Removes access | + +All five accept `x-profile-id` on an organization key to act on a child profile. None is exposed through MCP. + +The user object returns `id`, `email`, `name`, `role`, `status`, `invited_at`, `last_login_at`, `created_at`, and `updated_at`. + +The roles reference documentation states that the two read operations require "any role," while the OpenAPI specification requires `developer` or higher. Follow the specification and design for `developer` as the read floor; a `billing`-only user should not be assumed able to list users. + +## Role model + +| Role | Assignable by API | Scope of access | +| --- | --- | --- | +| `owner` | No | The account that created the organization or profile. Implicit, absent from the user list, passes every role check | +| `admin` | Yes | Passes every role check: profile management, user management, and reads | +| `developer` | Yes | Passes any-role checks; dashboard access to development, messaging, number lookup, activities, API keys, webhooks, channels, and settings | +| `billing` | Yes | Passes any-role checks; dashboard access limited to billing | + +Note the practical implication of `developer` including API keys and webhooks: a developer can create credentials and change event destinations. Treat the role as privileged even though it cannot manage users. + +## How a role check is evaluated + +The check resolves against the email address that owns the API key. It passes when that email is the owner email, or when an **active** user with that email exists on the organization or profile holding an allowed role. Users in `invited`, `suspended`, or `rejected` status fail every check. + +For a Sender Profile, checks cascade upward: owner or role access at the organization level also grants access to the organization's profiles. A user can therefore hold different effective roles across profiles when invited separately, while an organization-level role applies everywhere beneath it. + +Two operational consequences. First, an invitation that has not been accepted grants nothing, so provisioning automation must not assume access after `POST /v3/users` returns `201`. Second, revoking access at the organization level is the only way to remove cascading access; deleting a profile-level user leaves an organization-level grant intact. + +## Invitation lifecycle + +```text +POST /v3/users → status "invited" → email with token → user accepts → status "active" + └── 7 days elapse → token expires → re-invite required +``` + +Inviting an email that already has access returns `409 RESOURCE_007`; read the user list first and decide between a role change and an invitation. A provisioning flow that invites tenant staff should record the invitation timestamp and re-invite after expiry rather than retrying blindly, and should verify `status == "active"` before assuming the user can act. + +## Guardrails on user mutations + +The API refuses to let a caller change their own role, demote the last admin, remove themselves, or remove the last admin. Rather than discovering these as validation errors, check first: + +1. `GET /v3/users` and identify the target plus the count of active admins. +2. Confirm the target is not the caller's own account. +3. Confirm the change leaves at least one active admin. +4. Present the intended change and require explicit confirmation from the operator immediately before the call. +5. Record the `meta.request_id` from the response. + +Role changes and removals are effectively irreversible from the target user's perspective — re-granting requires a fresh invitation and acceptance — so treat both as destructive operations that deserve a stated diff before execution. + +## API key model + +There are two key types. An organization key can act for a child profile by sending `x-profile-id` with the profile UUID. A profile-scoped key is confined to its own profile and receives `403 AUTH_004` if it sends `x-profile-id`. + +Rate-limit exposure follows the key type: a profile key draws on its own pool, while an organization key acting through `x-profile-id` draws on the organization pool, so one noisy integration can consume quota shared by every profile. + +There is **no** endpoint to list, create, or revoke API keys. Key management happens in the Sent Dashboard, where the value is masked in the table and copied with a control. Any runbook that claims to automate key creation is wrong. + +Failed authentication is tracked per presented credential rather than per IP address: ten consecutive failures lock that credential with a `429` and escalating lockout windows from one minute up to sixty. A retry loop against a bad key therefore extends its own outage, so authentication failures must stop retrying immediately and alert instead. + +`x-sender-id` is legacy v1 and v2 terminology. It has no role in v3 authentication or routing. + +## Key rotation + +1. Create a replacement key in the dashboard. +2. Update the secret store and redeploy so the new key is in use. +3. Verify with `GET /v3/me` that the new key resolves to the expected account. +4. Disable or delete the old key. + +When a key is known to be compromised, invert the first steps and delete the old key immediately, accepting the brief outage. Keep separate keys per environment so rotating production never touches development, and never place a key in a browser, mobile app, or any client the organization does not control. + +## Least-privilege assignments + +| Workload | Credential | Role | +| --- | --- | --- | +| Server-side sends for one tenant | Profile-scoped key | `developer` | +| Provisioning new profiles | Organization key | `admin` | +| Campaign and brand registration | Organization or profile key per ownership | `admin` | +| Analytics reads | Profile-scoped key where possible | `developer` | +| Webhook management | Key matching the webhook's scope | `developer` | +| Billing review | — | `billing` | +| User administration | Organization key | `admin` | + +Prefer profile-scoped keys for runtime send paths so a leak is contained to one tenant, and reserve organization keys for control-plane operations that genuinely require cross-profile reach. + +## Audit evidence + +Every response carries `meta.request_id` and `meta.timestamp`. Log both alongside the operation name, the acting credential identifier, and — when an organization key acted through `x-profile-id` — the target profile, since the credential alone does not reveal which tenant was affected. The Sent Dashboard's Activities section, visible to owner, admin, and developer roles, is the platform-side counterpart. Because there is no API for key inventory, maintain a written register of which key exists for which environment and workload, who owns it, and when it was last rotated. diff --git a/skills/sent-routing-strategist/SKILL.md b/skills/sent-routing-strategist/SKILL.md new file mode 100644 index 0000000..e63159d --- /dev/null +++ b/skills/sent-routing-strategist/SKILL.md @@ -0,0 +1,76 @@ +--- +name: sent-routing-strategist +description: Decides how a Sent message should reach the recipient — automatic routing versus a pinned channel, what the channel array actually does, how fallback and reroute work, and why a message ended as FAILED, FILTERED, BLOCKED, or channel "auto". Use when choosing the channel field, expecting WhatsApp-to-SMS fallback, debugging an unexpected route or duplicate charges from multiple channels, or interpreting message status and activity evidence. +--- + +# Sent Routing Strategist + +Routing is where the most expensive Sent misconceptions live. Two facts govern almost every decision: + +1. **The `channel` array is a broadcast list, not a preference order.** `["whatsapp", "sms"]` with two recipients creates four messages and four charges. There is no `fallback` field and no ordered-preference syntax. +2. **Automatic routing is the fallback mechanism.** Omit `channel`, or send `["sent"]`, and the platform selects a route, then reroutes across up to three distinct channel-and-provider pairs when a route-level failure occurs. + +## Decide the channel value + +| Intent | Correct value | Reason | +| --- | --- | --- | +| Reach the recipient however works best | omit `channel` or `["sent"]` | Enables route selection and reroute | +| Guarantee one specific channel | `["sms"]`, `["whatsapp"]`, or `["rcs"]` | Pinning restricts matching to that channel and never crosses channels | +| Deliberately deliver the same content on several channels | `["whatsapp", "sms"]` | Broadcast; expect one message and one charge per pair | +| "Try RCS, fall back to SMS" | omit `channel` or `["sent"]` | An ordered array would broadcast; automatic routing performs the fallback | + +Any value outside `sent`, `sms`, `whatsapp`, and `rcs` returns `400`. When a user asks for ordered fallback, name the misconception explicitly before writing code, because the failure mode is duplicate delivery and duplicate cost rather than an error. + +## What a pinned channel gives up + +Pinning restricts route matching to the named channel. Rules without a channel constraint still match and resolve to the pinned channel, so pinning does not require channel-specific rules to exist. A pinned send never crosses to a different channel, though same-channel provider hops remain possible when a rule permits them. If no route exists on the pinned channel, the message ends `FAILED` with no route matched — it does not silently fall back. + +Pin when a compliance, contractual, or content constraint requires a specific channel. Otherwise prefer automatic routing. + +## Reading the outcome + +`POST /v3/messages` returns `202` with per-recipient `message_id` values. For automatic routing, the echoed per-recipient channel is not a resolved route and is never updated afterward. Resolve the truth from evidence: + +| Question | Evidence | +| --- | --- | +| Which route was actually attempted | `message.routed` event, or `channel` on `GET /v3/messages/{id}` after routing | +| Did the recipient's device receive it | `message.delivered` | +| What sequence of routes was tried | `GET /v3/messages/{id}/activities` | +| Why did it stop | Terminal status plus channel value | + +## Terminal status interpretation + +| Status | Meaning | Correct response | +| --- | --- | --- | +| `FAILED` | A route attempt failed; automatic routing may still enqueue another attempt | Inspect the latest message state and activities before treating it as final | +| `FILTERED` | Policy gate — consent block or route denial | Never retry; a consent block is a compliance stop | +| `BLOCKED` | Account precondition — balance, onboarding quota, unapproved template | Fix the account condition, then send again | +| `SCHEDULED` | Parked by quiet-hours policy | Wait; it re-enters the pipeline automatically | + +An outcome whose `channel` is `auto` means the message ended before any route was attempted. The causes are no matching route, invalid template parameters, a consent block, or an account precondition. Account preconditions do not reject the send request: it is accepted with `202` and the affected messages surface as `BLOCKED`. + +Sent records internal send-time reason codes on the message for these cases, but does not return them in API responses or webhooks, so diagnosis relies on the status-and-channel combination plus the activity history. The mapping from observable evidence to root cause is tabulated in [references/routing-diagnosis.md](references/routing-diagnosis.md). + +## Reroute behavior + +A failed route is retried only when the terminal failure signals a route or carrier problem another route might overcome: undeliverable by this route, provider service unavailable, provider timeout, or transport error. Every other failure stays `FAILED`. + +Reroute reuses the **same `message_id`** and re-runs the pipeline, so `message.queued` and `message.routed` fire again, consent gates re-apply on every attempt, and already-attempted routes are excluded. The ceiling is three distinct channel-and-provider pairs across the initial send and all reroutes. + +The WhatsApp-to-SMS behavior customers ask about is a specific case of this: a WhatsApp message accepted and then failed for a recipient-side reason reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, so subsequent automatic sends skip WhatsApp for that recipient. It requires automatic routing; a pinned WhatsApp send cannot produce it. + +## How automatic routing selects a route + +Routes come from platform-maintained rules evaluated at send time against recipient attributes (country, number prefix, exact number, carrier, number type, ported state), sender, template attributes, channel, and whether the destination is international. Ordering is: exact-recipient rules first, then account-scoped before global, then match specificity, then rule priority, then longer number prefix, then the older rule. Inactive, deleted, expired, and below-threshold rules are excluded. Candidates whose template has an explicit non-approved review status on that channel are dropped, while a channel with no recorded review is not blocked. The first surviving candidate wins and the rest remain available as fallback routes. + +There is no fixed channel preference order, so never promise "RCS first, then WhatsApp, then SMS." Read [references/routing-model.md](references/routing-model.md) before making any claim about why a specific route was chosen. + +## Cost and volume consequences + +Because broadcast multiplies messages by recipients, review any multi-channel array against expected spend before sending. A 1,000-recipient send with two channels is 2,000 messages. The per-request recipient ceiling is 1,000, and documented pacing pairs full batches with roughly one request per second to stay inside the 200-requests-per-minute budget. + +RCS today carries text plus up to four suggestion chips, mapped from template buttons, and every outbound RCS message receives an appended STOP chip. Do not design an RCS-pinned flow that depends on rich cards, carousels, or media. + +## Boundaries + +Use `sent-messaging` to execute a single send with confirmation, `sent-two-way-messaging` for consent and inbound keyword semantics, `messaging-performance-analyzer` for aggregate delivery-rate regressions, and `sent-webhook-engineer` for receiving and deduplicating the events this skill teaches you to read. diff --git a/skills/sent-routing-strategist/agents/openai.yaml b/skills/sent-routing-strategist/agents/openai.yaml new file mode 100644 index 0000000..d92c655 --- /dev/null +++ b/skills/sent-routing-strategist/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Routing Strategist" + short_description: "Choose channels and diagnose Sent routes" + default_prompt: "Use $sent-routing-strategist to decide how my Sent messages should reach recipients across channels." diff --git a/skills/sent-routing-strategist/references/routing-diagnosis.md b/skills/sent-routing-strategist/references/routing-diagnosis.md new file mode 100644 index 0000000..c5d3d06 --- /dev/null +++ b/skills/sent-routing-strategist/references/routing-diagnosis.md @@ -0,0 +1,92 @@ +# Routing diagnosis from observable evidence + +## Table of contents + +- [Evidence sources](#evidence-sources) +- [Symptom to cause table](#symptom-to-cause-table) +- [Outcome and channel matrix](#outcome-and-channel-matrix) +- [Diagnostic sequence](#diagnostic-sequence) +- [Retry decision rules](#retry-decision-rules) +- [Cost review before a multi-channel send](#cost-review-before-a-multi-channel-send) +- [Worked examples](#worked-examples) + +## Evidence sources + +| Source | What it proves | +| --- | --- | +| `202` response `data.recipients[]` | The message ids that were created; nothing about routing | +| `GET /v3/messages/{id}` | Current status and the attempted channel once routing occurred | +| `GET /v3/messages/{id}/activities` | The sequence of attempts, which is the only way to see multiple routes | +| `message.routed` event | The concrete route chosen for that attempt | +| Terminal event `channel` | The attempted route, or `auto` when the message ended before routing | + +Sent records internal reason codes on the message for consent blocks, route denials, no-route-matched, and invalid template parameters, but does not return them through the API or webhooks. Diagnosis therefore combines the latest outcome, the channel value, and the activity history rather than reading an error code. + +## Symptom to cause table + +| Symptom | Most likely cause | Confirmation | +| --- | --- | --- | +| Recipients received the same content twice | A multi-channel array was treated as a fallback list | Count messages in the `202` response: `len(to) × len(channel)` | +| Billing higher than expected on a campaign | Same as above | Compare charged messages against recipient count | +| Expected WhatsApp-to-SMS fallback, got only a WhatsApp failure | The send pinned `["whatsapp"]` | Pinned sends never cross channels; switch to automatic routing | +| `FAILED` with channel `auto` | No routing rule matched, or template parameters were invalid | Activities show no route attempt | +| `FILTERED` with a channel value | Route denial without permitted fallback, or every candidate denied | Activities show attempts ending in denial | +| `FILTERED` with channel `auto` | Consent block before routing | Check the contact's `opt_out` state | +| `BLOCKED` | Account precondition: balance, onboarding quota, or unapproved template | Check balance and template approval state | +| Message stuck in `SCHEDULED` | Quiet-hours policy parked it | It re-enters the pipeline automatically; do not resend | +| Duplicate `queued` and `routed` events for one id | A reroute re-ran the pipeline | Activities show more than one attempted route | +| Channel changed between two events for one message | Reroute moved to another route | Expected on automatic routing | +| `400` on send | A channel value outside `sent`, `sms`, `whatsapp`, `rcs` | Inspect the request `channel` array | +| Pinned RCS message failed immediately | No route exists on the pinned channel | A pinned send does not fall back | +| No `READ` event on SMS | `READ` exists only on WhatsApp and RCS | Expected, not a defect | + +## Outcome and channel matrix + +| Outcome | Channel `auto` | Channel is a concrete route | +| --- | --- | --- | +| `FAILED` | No route matched, or invalid template parameters | One route failed; inspect newer events and activities to determine whether reroute continued | +| `FILTERED` | Consent block before routing | Route denial that did not permit fallback, or all candidates denied | +| `BLOCKED` | Account precondition evaluated before routing | Rare; treat as an account precondition regardless | +| `DELIVERED` | Not possible | Normal success | + +## Diagnostic sequence + +1. Confirm what was requested. Re-read the send body: was `channel` omitted, `["sent"]`, pinned, or multi-valued? This alone resolves most reported "fallback did not work" and "duplicate message" cases. +2. Count expected messages as `len(to) × len(channel)` and compare with the `202` response. +3. Fetch `GET /v3/messages/{id}` for a representative message and record status and channel. +4. Fetch `GET /v3/messages/{id}/activities` and list the attempted routes in order. +5. Classify the terminal state using the matrix above. +6. Decide retry eligibility using the rules below, and state the reason rather than retrying reflexively. + +## Retry decision rules + +| Terminal state | Retry | Precondition | +| --- | --- | --- | +| `FAILED` after route exhaustion on automatic routing | Only with a changed input | Three distinct routes were already tried; a new send repeats the same rules unless the recipient, template, or channel choice changes | +| `FAILED` with channel `auto` from no route matched | No | The rule set has no path to that recipient; escalate rather than loop | +| `FAILED` from invalid template parameters | Yes | After fixing the parameters | +| `FILTERED` from a consent block | Never | Sending anyway is a compliance violation | +| `FILTERED` from a route denial | No | Policy decision; escalate | +| `BLOCKED` | Yes | After the account condition is resolved | +| `SCHEDULED` | No | It resumes automatically; a resend duplicates it | +| Ambiguous send where the client never saw a response | Retry only with the original key | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient that proves non-execution | + +## Cost review before a multi-channel send + +Before executing any send whose `channel` array has more than one value, state the arithmetic to the user: recipients times channels equals messages equals charges. Confirm the intent is genuinely simultaneous multi-channel delivery. If the intent is preference or fallback, change the request to automatic routing instead. + +For volume, the per-request recipient ceiling is 1,000. Documented pacing pairs full 1,000-recipient batches with roughly one request per second to stay inside the 200-requests-per-minute limit, and rate-limit headers appear only on `429` responses, so pacing must be designed rather than discovered. + +## Worked examples + +**"We wanted WhatsApp with SMS fallback and every customer got two messages."** +The request used `["whatsapp", "sms"]`, which broadcasts. The `202` response contained two message ids per recipient, both of which were charged. The correct request omits `channel` entirely. Automatic routing then attempts a route and, on a route-level or recipient-side WhatsApp failure, reroutes to another candidate on the same `message_id`. + +**"A message failed on WhatsApp and then delivered on SMS, but our dashboard shows it as failed."** +Automatic routing rerouted on the same `message_id`, so the receiver saw `message.failed` with `channel: whatsapp` followed by newer `queued`, `routed`, and `delivered` events with `channel: sms`. The dashboard treated `FAILED` as permanently terminal. Project current state by event timestamp, preserve the append-only route history, and let a newer reroute outcome replace the earlier attempt failure. + +**"All sends to one country come back FAILED with channel auto."** +No routing rule matched for that destination. Activities show no attempted route. This is not fixable from the request payload; escalate the destination coverage rather than retrying. + +**"A pinned RCS send failed instantly and never tried SMS."** +Correct behavior. Pinning restricts matching to RCS, and a pinned send never crosses channels. Use automatic routing to allow another channel. diff --git a/skills/sent-routing-strategist/references/routing-model.md b/skills/sent-routing-strategist/references/routing-model.md new file mode 100644 index 0000000..df8eabd --- /dev/null +++ b/skills/sent-routing-strategist/references/routing-model.md @@ -0,0 +1,136 @@ +# Sent channel routing model + +## Table of contents + +- [Channel vocabulary](#channel-vocabulary) +- [Broadcast semantics](#broadcast-semantics) +- [Route selection pipeline](#route-selection-pipeline) +- [Pinned-channel behavior](#pinned-channel-behavior) +- [Send-time fallback across candidates](#send-time-fallback-across-candidates) +- [Reroute after delivery failure](#reroute-after-delivery-failure) +- [Where each channel value surfaces](#where-each-channel-value-surfaces) +- [Status lifecycle](#status-lifecycle) +- [Channel capability limits](#channel-capability-limits) + +## Channel vocabulary + +| Value | Meaning | +| --- | --- | +| `sent` | Automatic routing. The default when `channel` is omitted or supplied as an empty array | +| `sms` | Pin to SMS | +| `whatsapp` | Pin to WhatsApp | +| `rcs` | Pin to RCS | +| `auto` | Internal placeholder for an unresolved automatic route. Appears in responses and events, never as an input | + +Any other value returns `400`. Treat `auto` as read-only diagnostic output and never display it to an end user as a channel name. + +## Broadcast semantics + +The `channel` array enumerates channels to send on, not an order of preference. The number of messages created is `len(to) × len(channel)`, each with its own `message_id`, its own lifecycle, and its own charge. + +```json +{ + "to": ["+14155551234", "+14155555678"], + "channel": ["whatsapp", "sms"], + "template": { "name": "order_confirmation", "parameters": { "order_id": "12345" } } +} +``` + +That request creates four messages. The `202` response enumerates all four in `data.recipients[]`. + +There is no `fallback` field, no priority weighting, and no way to express "try this, then that" through the array. Ordered arrays or explicit failover objects are common on incumbent platforms, which makes porting them into Sent's channel array a high-risk migration defect. + +## Route selection pipeline + +For automatic routing, the platform evaluates its maintained routing rules at send time. Rules can constrain on: + +- recipient attributes: country, number prefix, exact number, carrier, number type, and ported state; +- the sender; +- template attributes: id, name, and category; +- channel; +- whether the destination is international. + +Candidates are ordered by: + +1. exact-recipient rules ahead of everything else; +2. account-scoped rules ahead of global rules; +3. match specificity; +4. explicit rule priority; +5. longer number prefix; +6. older rule as the final tie-break. + +Exclusions applied before selection: inactive, deleted, or expired rules; rules whose own minimum match threshold is not met; and candidates on a channel where the template carries an explicit non-approved review status such as rejected, pending, or paused. A channel with **no** recorded per-channel review is not excluded — absence of review is not disapproval. + +The first surviving candidate becomes the attempted route, the message moves to `ROUTED`, and `message.routed` fires. Remaining candidates stay available as fallback routes for that message. + +Two consequences worth stating to users. There is no fixed cross-channel preference order, so any claim like "RCS is tried first" is unsupported. And because rules are platform-maintained rather than caller-supplied, an application cannot express routing preference through the API; it can only choose automatic routing or pin a channel. + +## Pinned-channel behavior + +Pinning narrows candidate matching to the named channel. Rules that carry no channel constraint still match and resolve to the pinned channel, so pinning works even without channel-specific rules. + +A pinned message never crosses to a different channel. Same-channel provider hops remain possible when the matched rule permits them, which means a pinned SMS message can still be retried through a different provider. If no route matches on the pinned channel, the message ends `FAILED` with no route matched rather than falling back. + +Pin for a hard requirement: a compliance rule that mandates a channel, a contract that prices a channel, or content that only renders on one channel. Prefer automatic routing everywhere else. + +## Send-time fallback across candidates + +Fallback at send time walks the candidate list rather than the caller's array. When a candidate route carries a DENY decision that permits fallback, evaluation moves to the next candidate. When a DENY does not permit fallback — including the case where every candidate is denied — the message finalizes as `FILTERED` and the record carries the denied route's channel. + +This is why `FILTERED` must never be retried blindly. The gate is a policy decision, most often consent, and repeating the send reproduces the same outcome while risking a compliance violation. + +## Reroute after delivery failure + +Reroute happens only when a terminal failure indicates a route or carrier problem that another route might overcome: + +| Failure signal | Reroutes | +| --- | --- | +| Undeliverable by this route | Yes | +| Provider service unavailable | Yes | +| Provider timeout | Yes | +| Transport error | Yes | +| Recipient-side rejection on WhatsApp after acceptance | Yes, and records a recipient-scoped rule that WhatsApp is not deliverable for that number | +| Invalid content or template parameters | No | +| Consent block | No | +| Account precondition | No | +| Any other failure | No | + +Mechanics that affect application code: the reroute reuses the same `message_id`, re-runs the pipeline so `QUEUED` and `ROUTED` transitions and their webhooks fire again, excludes already-attempted routes, re-applies consent gates on every attempt, and stops at a ceiling of three distinct channel-and-provider pairs across the initial send and all reroutes. + +Therefore a `message.failed` event is not proof of final failure. Treat a message as finally failed only when its stored state remains failed and no further events arrive, and make the status projection idempotent so repeated `queued` and `routed` events do not double-count. + +## Where each channel value surfaces + +| Surface | Automatic routing | Pinned channel | +| --- | --- | --- | +| `202` response `data.recipients[].channel` | Not a resolved route, and never updated later | The pinned channel | +| `message.queued`, `message.routed`, `message.scheduled` | `sent` | The pinned channel | +| Terminal events after a route was attempted | The attempted route's channel | The pinned channel | +| Terminal events for a message that ended before routing | `auto` | `auto` | +| `GET /v3/messages/{id}` before routing | `auto` | The pinned channel | +| `GET /v3/messages/{id}` after routing | The attempted route's channel | The pinned channel | + +The practical rule: never treat the send response as routing evidence. Resolve routes from `message.routed`, from `GET /v3/messages/{id}` after routing, or from the activity history. + +## Status lifecycle + +| Status | Final for the logical message | Meaning | +| --- | --- | --- | +| `QUEUED` | No | Accepted into the pipeline | +| `SCHEDULED` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `ROUTED` | No | A channel and provider were selected | +| `SENT` | No | Handed to the provider | +| `DELIVERED` | Yes | Confirmed at the handset | +| `READ` | Yes | Read by the recipient; WhatsApp and RCS only | +| `FAILED` | Not always | One attempt failed; a newer automatic reroute may follow on the same message id | +| `FILTERED` | Yes | Policy gate: consent block or route denial | +| `BLOCKED` | Yes | Account precondition: balance, onboarding quota, unapproved template | +| `RECEIVED` | — | Inbound message | + +`FILTERED` and `BLOCKED` are not carrier failures. Feeding them into retry logic produces either a compliance problem or a retry loop that cannot succeed until an account action is taken. + +## Channel capability limits + +RCS currently supports text plus up to four suggestion chips, mapped from template buttons, with rich cards, carousels, and media on the roadmap. Every outbound RCS message receives an appended STOP chip, so an RCS surface always exposes an opt-out affordance the application did not author. + +`READ` reaches only WhatsApp and RCS; its absence on SMS is expected. Inbound support differs by channel as well — SMS inbound depends on an MO-capable provider and a supported number type, so alphanumeric sender IDs never receive replies. Route consent and inbound questions to the two-way messaging skill. diff --git a/skills/sent-two-way-messaging/SKILL.md b/skills/sent-two-way-messaging/SKILL.md new file mode 100644 index 0000000..ce9f32c --- /dev/null +++ b/skills/sent-two-way-messaging/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sent-two-way-messaging +description: Designs inbound and conversational Sent flows — opt-out and opt-in keyword handling, consent state on contacts, auto-replies inside the WhatsApp 24-hour window, RCS STOP chips, conversation history retrieval, and per-channel inbound capability. Use when handling message.received events, implementing STOP or HELP behavior, restoring consent after an opt-out, building a support inbox or chatbot on Sent, or paginating conversation history. +--- + +# Sent Two-Way Messaging + +Inbound messaging on Sent has one governing rule: **consent is enforced by the platform before the application sees the event.** An inbound `STOP` has already flipped the contact's `opt_out` flag by the time `message.received` arrives. The application's job is to record it, reflect it in its own UI, and never attempt to send around it. + +## Keyword handling + +Ten keywords ship as defaults: + +| Action | Keywords | +| --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | +| Help auto-reply | `HELP`, `INFO` | + +Matching requires the **entire trimmed message body** to equal a keyword, case-insensitively. "Please stop messaging me" does not match; "stop" does. Custom keywords are configured in the Sent Dashboard under Compliance, Opt Keywords, with an action of Opt Out, Opt In, or Help, and each must be a single exact token. + +Do not claim keywords that are not in the documented set. In application code, mirror the same exact-match rule only to update local subscriber state and audit evidence; never use that matcher to apply consent to Sent a second time. Keep custom dashboard keywords synchronized with the local mirror, and reconcile against the contact's `opt_out` field when uncertain. + +## Consent state + +An opt-out sets `opt_out` on the contact record. Consent is **contact-level and channel-agnostic**: a `STOP` sent over SMS suppresses WhatsApp and RCS for that contact as well. Consent gates re-apply on every reroute attempt, not only at initial send. + +Restoring consent requires the recipient's own action. A user-initiated opt-in keyword clears suppression. `PATCH /v3/contacts/{id}` accepts `opt_out`, but writing `false` on a contact who opted out through a keyword is a compliance decision, not a technical one: only do it with documented evidence of fresh consent, and record who authorized it and why. + +Downstream, a suppressed send does not fail with an error. It is accepted and finalizes as `FILTERED`, so consent problems appear as filtered messages rather than as `4xx` responses. Details are in [references/consent-and-keywords.md](references/consent-and-keywords.md). + +## Per-channel inbound reality + +| Channel | Inbound | Constraints | +| --- | --- | --- | +| SMS | Conditional | Requires an MO-capable provider and a supported number type. Alphanumeric sender IDs and SMPP paths without an inbound route never deliver inbound messages | +| RCS | Full | Typed replies match keywords; the appended STOP chip is processed directly by the consent engine | +| WhatsApp | Full | Free-form replies only inside the 24-hour customer service window; outside it, an approved template is required | + +The SMS caveat matters before promising two-way behavior: a deployment sending from an alphanumeric sender ID cannot receive `STOP` at all, which changes the compliance design rather than merely limiting a feature. + +## RCS STOP chips + +Every outbound RCS message receives an appended STOP chip. Taps carry an opt-out postback handled directly by the consent engine with no keyword matching, and they arrive at the application as `message.received` with the chip's reply text in `text`. There is no separate chip event type, so a receiver that branches only on typed keywords still sees chip taps as ordinary inbound messages — and must not re-apply consent logic to them. + +## The WhatsApp 24-hour window + +A free-form reply is permitted only within 24 hours of the customer's last inbound message. Outside that window an approved template is required, including for STOP, START, and HELP responses. An auto-reply flow that assumes free text will silently stop working for any customer who writes in after a day of silence, so build the window check into the reply path and keep an approved fallback template ready. See [references/inbound-flows.md](references/inbound-flows.md) for the reply-path decision tree. + +## Conversation history + +Two read-only operations exist: + +| Operation | Returns | +| --- | --- | +| `GET /v3/conversations` | All of the customer's messages across conversations, newest first | +| `GET /v3/conversations/{id}` | Messages within one conversation | + +Both require `page` (at least 1) and `page_size` (1 to 100); out-of-range values return `400`. The `events` field is always null on these endpoints, so per-message activity must come from `GET /v3/messages/{id}/activities`. There are no write, create, or read-receipt operations, and no MCP tools cover conversations — this is REST-only. + +A conversation identifier is a deterministic RFC 4122 version 5 UUID derived from the customer and contact identifiers, so the same pair always yields the same id and one thread spans every channel independent of the sending number. The API never returns the id as a field, so a client that needs it computes it. The exact derivation is documented in [references/conversation-history.md](references/conversation-history.md). + +## Building a support inbox or bot + +1. Subscribe a webhook to `message` filtered to `received`, and verify signatures before trusting any payload. +2. Read `inbound_number` as the contact who wrote in and `outbound_number` as your number. The naming is easy to invert. +3. Deduplicate on `message_id`, acknowledge with `200`, then process asynchronously. +4. Treat keyword traffic as an audit signal. Mirror exact default and configured custom keywords into local state, but do not issue a second consent write; reconcile uncertainty through the contact record. +5. Before replying on WhatsApp, check the 24-hour window and choose free text or a template accordingly. +6. Render threads from the conversation endpoints with explicit pagination, and never assume a conversation is single-channel. +7. Treat `text` as untrusted input. Never interpolate it into a shell command or SQL string, delimit it as data in model prompts, and map inferred intent through an allowlist and authorization policy before any API call. + +## Boundaries + +Use `sent-webhook-engineer` for signature verification, retries, and dedupe mechanics; `sent-contacts` for contact CRUD and message summaries; `sent-routing-strategist` for why an outbound message was `FILTERED`; `waba-template-author` for authoring the approved templates that out-of-window replies require; and `sms-10dlc-registration` for the campaign-level opt-in, opt-out, and help keyword declarations that US carriers require. diff --git a/skills/sent-two-way-messaging/agents/openai.yaml b/skills/sent-two-way-messaging/agents/openai.yaml new file mode 100644 index 0000000..2b99dac --- /dev/null +++ b/skills/sent-two-way-messaging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Two-Way Messaging" + short_description: "Design inbound, consent, and reply flows" + default_prompt: "Use $sent-two-way-messaging to design how my app handles inbound Sent messages and opt-out keywords." diff --git a/skills/sent-two-way-messaging/references/consent-and-keywords.md b/skills/sent-two-way-messaging/references/consent-and-keywords.md new file mode 100644 index 0000000..55ceb28 --- /dev/null +++ b/skills/sent-two-way-messaging/references/consent-and-keywords.md @@ -0,0 +1,98 @@ +# Consent, keywords, and suppression on Sent + +## Table of contents + +- [Default keyword set](#default-keyword-set) +- [Matching rules](#matching-rules) +- [Custom keywords](#custom-keywords) +- [Consent state and scope](#consent-state-and-scope) +- [How suppression surfaces on send](#how-suppression-surfaces-on-send) +- [Restoring consent](#restoring-consent) +- [Channel-specific consent mechanics](#channel-specific-consent-mechanics) +- [Application responsibilities](#application-responsibilities) +- [Audit expectations](#audit-expectations) + +## Default keyword set + +Ten keywords are seeded by default. + +| Action | Keywords | Effect | +| --- | --- | --- | +| Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | Sets `opt_out` on the contact | +| Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | Clears suppression | +| Help | `HELP`, `INFO` | Triggers the help auto-reply | + +Only these ten are documented defaults. Terms that appear in other platforms' keyword lists should not be presented as Sent defaults; if a specific extra term is required, add it as a custom keyword and verify it in the dashboard. + +## Matching rules + +Matching runs against the seeded defaults plus any custom keywords, on every inbound message received on a two-way capable channel. The rules are strict: + +- the **entire trimmed body** must equal the keyword; +- comparison is case-insensitive; +- partial phrases and keywords embedded in a sentence never match. + +So `stop`, `STOP`, and ` Stop ` all opt the contact out, while `please stop texting me` does not. This is deliberate: loose matching would opt out customers who used the word incidentally. It also means a real-world opt-out intent expressed in a sentence will not be caught automatically, which is a reason to route inbound text to a human queue rather than assuming keyword coverage is complete. + +Mirror this exact matcher in the application when local subscriber state or audit evidence is required. The platform has already applied consent by the time the event arrives, so the local matcher must never issue a second consent write. Keep configured custom keywords synchronized and reconcile uncertain state from the contact's `opt_out` field. + +## Custom keywords + +Custom keywords are configured in the Sent Dashboard under Compliance, then Opt Keywords. Each entry names a single exact token and one action: Opt Out, Opt In, or Help. There is no REST or MCP surface for keyword management, so keyword changes are a dashboard operation that cannot be scripted; treat the configured set as an environment fact to be read, documented, and version-controlled in the application's own runbook. + +When designing custom keywords, prefer short single tokens in the languages the audience actually writes in, and avoid tokens that collide with normal replies such as `YES` or `NO` if those are used for other flows. + +## Consent state and scope + +An opt-out flips `opt_out` on the contact record. Two properties of that state are load-bearing: + +**Contact-level.** Consent attaches to the contact, not to a campaign, template, or sending number. There is no per-template or per-campaign suppression list. + +**Channel-agnostic.** A keyword received on any channel suppresses every channel. A customer who texts `STOP` over SMS will not receive WhatsApp or RCS messages either. Applications that model consent per channel will over-send relative to the platform and see the difference as filtered messages. + +Consent gates re-apply on **every** reroute attempt, not only at the initial send. A message that passed the gate at send time is still re-checked when automatic routing retries it on another route. + +## How suppression surfaces on send + +A send to a suppressed contact is not rejected with a `4xx`. The request is accepted, and the affected message finalizes as `FILTERED` with a terminal channel value of `auto` when the block occurred before routing. Consequences: + +- Consent problems appear in delivery data rather than in API error handling, so a client that only inspects HTTP status codes will not notice them. +- `FILTERED` must never be retried. Retrying a consent block is a compliance violation, and it cannot succeed. +- Consent-driven filtering should be monitored as its own metric. A rising filtered rate usually means a stale local suppression list rather than a delivery problem. + +## Restoring consent + +Consent restoration is the recipient's decision. The clean path is a user-initiated opt-in keyword, which clears suppression through the same engine that set it. + +`PATCH /v3/contacts/{id}` accepts `opt_out` as a writable field, so it is technically possible to clear the flag from the API. Treat that as a compliance action rather than a data fix: + +1. Require documented evidence of fresh consent — a form submission, a recorded confirmation, or a written request. +2. Record who authorized the change, when, and against which evidence. +3. Never bulk-clear `opt_out` across a contact list, and never clear it to "fix" a filtered-message metric. +4. Prefer asking the customer to text an opt-in keyword, which produces platform-side evidence. + +An agent asked to clear `opt_out` should surface the compliance implication and require explicit confirmation naming the consent evidence before proceeding. + +## Channel-specific consent mechanics + +| Channel | Inbound keyword path | Notes | +| --- | --- | --- | +| SMS | Requires an MO-capable provider and a supported number type | Alphanumeric sender IDs and SMPP paths without an inbound route never deliver `STOP`; plan compliance around that limitation | +| RCS | Typed replies match keywords; the appended STOP chip bypasses matching | Every outbound RCS message carries a STOP chip whose tap is handled directly by the consent engine | +| WhatsApp | Full inbound support | Replies to STOP, START, or HELP outside the 24-hour window require an approved template | + +The RCS STOP chip means an RCS deployment always exposes an opt-out affordance the application did not author, and its taps arrive as ordinary `message.received` events with the chip's reply text in `text`. There is no distinct event type for a chip tap. + +## Application responsibilities + +The platform owns enforcement; the application owns reflection and evidence. + +- Mirror `opt_out` into local state by exact-matching the documented and configured keyword set, and reconcile from `GET /v3/contacts/{id}` when uncertain. +- Never use the local text match to re-apply consent to Sent; it is a mirror and audit mechanism only. +- Show suppression state in any internal UI where staff could otherwise trigger a send. +- Keep the local mirror reconciled on a schedule, since a keyword can arrive at any time and a stale mirror produces filtered messages. +- For US SMS, keep the campaign-level opt-in, opt-out, and help keyword declarations consistent with what is actually configured; carrier registration expects them to match. + +## Audit expectations + +Retain, per consent change: the inbound `message_id`, the received timestamp, the channel, the exact matched text, and the resulting state. Inbound keyword events are the strongest evidence available that a customer opted out or back in, and they are the artifact a carrier or regulator will ask for. Because Sent applies consent before the event is delivered, the event is a record of a completed action, and treating it as a request to perform an action risks double-processing. diff --git a/skills/sent-two-way-messaging/references/conversation-history.md b/skills/sent-two-way-messaging/references/conversation-history.md new file mode 100644 index 0000000..f32649f --- /dev/null +++ b/skills/sent-two-way-messaging/references/conversation-history.md @@ -0,0 +1,55 @@ +# Conversation history retrieval + +## Operations + +| Operation | Returns | Required query parameters | +| --- | --- | --- | +| `GET /v3/conversations` | All of the calling customer's messages across conversations, newest first | `page` (>= 1), `page_size` (1–100) | +| `GET /v3/conversations/{id}` | Messages within one conversation | `page` (>= 1), `page_size` (1–100) | + +Both are read-only. There is no create, update, delete, participant, or read-receipt operation, and no MCP tool covers conversations, so this is REST-only work even inside an MCP-authorized session. Out-of-range pagination values return `400`; missing credentials return `401`. + +The OpenAPI summaries for these two operations are swapped relative to their descriptions — the list endpoint is summarized as "list conversation messages" while its description says messages across all conversations, and the single-conversation variant reads the opposite way. Trust the descriptions and the behavior above. + +## Message record fields + +Each returned message carries: + +`id`, `customer_id`, `contact_id`, `phone`, `phone_international`, `region_code`, `template_id`, `template_name`, `template_category`, `channel`, `message_body` (with `header`, `content`, `footer`, and `buttons`), `status`, `direction` (`INBOUND` or `OUTBOUND`), `created_at`, `price`, `active_contact_price`, and `events`. + +`events` is always null on these endpoints. Per-message activity history must come from `GET /v3/messages/{id}/activities`, which is also the only place a reroute's sequence of attempted routes is visible. + +## Conversation identifiers + +A conversation id is a deterministic RFC 4122 version 5 UUID. It is derived from the namespace `9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99` and the name `{customer_id}:{contact_id}`, where both identifiers are lowercase canonical UUIDs and the customer id comes first. Version 5 uses SHA-1 name-based hashing, equivalent to PostgreSQL's `uuid_generate_v5`. + +```python +import uuid + +NAMESPACE = uuid.UUID("9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99") + + +def conversation_id(customer_id: str, contact_id: str) -> str: + name = f"{customer_id.lower()}:{contact_id.lower()}" + return str(uuid.uuid5(NAMESPACE, name)) +``` + +Three properties follow. The same customer-and-contact pair always yields the same id, so it can be computed offline and used as a stable local key. The API never returns the id as a field, so a client that needs it must derive it. And because the id depends only on customer and contact, **one thread spans every channel** and is independent of which sending number or channel was used — a customer who moves between SMS and WhatsApp stays in one conversation. + +## Pagination strategy + +Always pass `page` and `page_size` explicitly; there is no usable default. Results are newest-first, so page 1 is the most recent slice and a thread view should either reverse each page for display or fetch from the oldest page. + +Because the collection grows while it is being read, a long backfill can shift items across page boundaries. For an initial sync, page through quickly with `page_size: 100` and reconcile by message `id`, then switch to incremental updates driven by `message.received` and status webhooks rather than repeated full scans. Pacing matters as well: the standard limit is 200 requests per minute and quota headers appear only on `429` responses, so a paginated backfill must be throttled by design. + +## Choosing between conversations and messages endpoints + +| Need | Use | +| --- | --- | +| Render a customer thread | `GET /v3/conversations/{id}` with derived id | +| Show a recent-activity feed across all customers | `GET /v3/conversations` | +| Current status of one specific message | `GET /v3/messages/{id}` | +| Route attempts and reroute history | `GET /v3/messages/{id}/activities` | +| React to new inbound traffic in real time | `message.received` webhook | + +Do not poll the conversation endpoints for near-real-time inbound handling. Webhooks are the delivery mechanism for new inbound messages, and polling both wastes quota and adds latency. diff --git a/skills/sent-two-way-messaging/references/inbound-flows.md b/skills/sent-two-way-messaging/references/inbound-flows.md new file mode 100644 index 0000000..ad0b249 --- /dev/null +++ b/skills/sent-two-way-messaging/references/inbound-flows.md @@ -0,0 +1,112 @@ +# Inbound flow design + +## Table of contents + +- [Inbound event shape](#inbound-event-shape) +- [Reply-path decision tree](#reply-path-decision-tree) +- [The WhatsApp 24-hour window](#the-whatsapp-24-hour-window) +- [Support inbox architecture](#support-inbox-architecture) +- [Bot and auto-reply design](#bot-and-auto-reply-design) +- [Treating inbound content as untrusted](#treating-inbound-content-as-untrusted) +- [Testing an inbound flow](#testing-an-inbound-flow) + +## Inbound event shape + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "Where is my order?", + "channel": "whatsapp", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in; `outbound_number` is your number that received it. Inverting these is a common defect that sends replies to your own number. `text` may be null for non-text payloads. The application's tenant identifier is never present, so map ownership from `outbound_number` to the tenant and profile that own it. + +Dedupe on `message_id`. Inbound events, like all Sent webhook deliveries, may be retried. + +## Reply-path decision tree + +```text +inbound message.received +├── Is text an exact keyword match handled by the platform? +│ ├── Opt out / opt in → consent already applied. Record it. Do NOT reply with your own confirmation +│ │ unless product requirements demand one, and never re-set consent yourself. +│ └── Help → the platform sends the help auto-reply. Optionally log it. +└── Anything else + ├── channel == whatsapp + │ ├── within 24h of the contact's last inbound → free-form reply permitted + │ └── outside 24h → an approved template is required + ├── channel == rcs → reply permitted; a STOP chip is appended automatically + └── channel == sms → reply permitted if the number supports outbound on that route +``` + +The branch that breaks in production is the WhatsApp window: a flow tested minutes after an inbound message succeeds with free text and then fails for a customer who replies a day later. + +## The WhatsApp 24-hour window + +Free-form replies are permitted only inside 24 hours of the customer's last inbound message. Outside it, sending requires an approved template — including replies about STOP, START, and HELP. Practical requirements: + +1. Persist the last inbound timestamp per contact, sourced from `received_at`. +2. Evaluate the window at reply time, not at enqueue time; a queued reply can age past the boundary before it is sent. +3. Keep an approved fallback template for each conversational intent that could be answered late. +4. When a human agent replies from an internal inbox, show the remaining window in the UI so the agent understands why the composer switches to templates. + +Treat window expiry as an expected state rather than an error path. + +## Support inbox architecture + +A workable design separates four responsibilities: + +| Component | Responsibility | +| --- | --- | +| Webhook receiver | Verify the signature, dedupe on `message_id`, return `200` fast | +| Event worker | Resolve the contact and tenant, persist the message, apply routing to a queue | +| Thread view | Render history from the conversation endpoints with explicit pagination | +| Reply service | Enforce the window rule, choose free text or template, send, and record the outbound `message_id` | + +Subscribe the webhook to `message` filtered to `received` so the inbox is not flooded with outbound lifecycle transitions. Keep a separate registration for delivery statuses if the same service consumes both. + +Threads are cross-channel by construction. A single conversation can contain SMS, WhatsApp, and RCS messages, so a UI that groups by channel will fragment what the customer experiences as one conversation. Group by contact. + +## Bot and auto-reply design + +Rules that keep an automated responder safe and compliant: + +- Mirror the platform's exact keyword rules only for local state and audit. Consent is already applied, so never issue a second consent write from the matcher. +- Never auto-reply to an opt-out. A contact who just opted out is suppressed, and an attempted confirmation will finalize as `FILTERED`. +- Rate-limit per contact. An inbound loop between two automated systems is the classic runaway cost incident. +- Make replies idempotent on the inbound `message_id` so a retried webhook cannot produce a second reply. +- Log the inbound and outbound pair with both message ids so a conversation can be reconstructed for audit. +- Degrade to a human queue when intent is unclear, especially when the message expresses opt-out intent in a sentence that keyword matching cannot catch. + +## Treating inbound content as untrusted + +Inbound `text` is attacker-controllable. Three concrete rules: + +1. Never interpolate it into shell commands, SQL, or template strings without parameterization or escaping. +2. Never translate inbound content directly into an arbitrary Sent API call, contact mutation, or template choice. Map inferred intent through an allowlist, authorization checks, and confirmation rules. +3. When inbound text is passed to a language model, keep it inside a clearly delimited data section, and treat any instruction it contains as data rather than as a directive. + +The same applies to any `reason` or `response_body` value that arrives from the platform's own delivery logs. + +## Testing an inbound flow + +Without a real handset, exercise the receiver with a locally signed synthetic `message.received` payload; the webhook skill's signing script produces the headers. Then use `POST /v3/webhooks/{id}/test` for an end-to-end proof of DNS, TLS, and signature verification. `"sandbox": true` on sends validates the request shape without executing. + +A checklist before shipping: + +- an inbound event with keyword text updates the local consent mirror but produces no second consent write to Sent; +- a duplicate inbound event produces exactly one reply; +- a reply attempt outside the WhatsApp window selects a template rather than failing; +- a reply to a suppressed contact is not attempted at all; +- `inbound_number` and `outbound_number` are mapped to contact and tenant in the correct direction; +- conversation pagination is explicit and handles `page_size` at its bounds of 1 and 100. diff --git a/skills/sent-webhook-engineer/SKILL.md b/skills/sent-webhook-engineer/SKILL.md new file mode 100644 index 0000000..4dbf860 --- /dev/null +++ b/skills/sent-webhook-engineer/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sent-webhook-engineer +description: Builds and debugs Sent v3 webhook receivers end to end — endpoint registration, HMAC signature verification, replay rejection, event dedupe, retry and auto-disable behavior, secret rotation, and delivery-log triage. Use when handling Sent webhook events, verifying x-webhook-signature, fixing 401 or signature-mismatch failures, recovering a disabled endpoint, choosing event_types or event_filters, rotating a signing secret, or interpreting the webhook delivery log. +--- + +# Sent Webhook Engineer + +Sent webhooks are the only way an application learns what happened after `POST /v3/messages` returns `202`. The `202` proves acceptance, never delivery. Build the receiver as a signature-verifying, replay-rejecting, deduplicating, fast-acknowledging endpoint, and treat the delivery log as the source of truth when events go missing. + +## Signature verification, exactly + +Three headers arrive with every delivery: + +| Header | Meaning | +| --- | --- | +| `x-webhook-signature` | `v1,{base64(hmac_sha256)}` | +| `x-webhook-id` | The webhook **endpoint** UUID — identical on every delivery | +| `x-webhook-timestamp` | Unix seconds when Sent signed the request | + +Verification procedure, in order: + +1. Capture the **raw request body bytes** before any JSON parsing. +2. Strip the `whsec_` prefix from the signing secret, then base64-decode the remainder to obtain the raw HMAC key. +3. Build the signed content as `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`. +4. Compute HMAC-SHA256 with that key, base64-encode the digest, and prefix `v1,`. +5. Compare with a constant-time comparison. +6. Reject when `abs(now - timestamp) > 300` seconds. + +The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. + +**`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. + +## Failure triage order + +When a receiver rejects or misses events, work this sequence rather than guessing: + +1. **Signature mismatch** — a body-mutating middleware or framework JSON parser is the cause in the majority of cases. Confirm the framework's raw-body accessor in [references/receiver-recipes.md](references/receiver-recipes.md). +2. **Replay rejection** — server clock skew beyond the 300-second tolerance. +3. **Wrong secret** — the `whsec_` prefix was left in place, or a rotation invalidated the old secret with no dual-signing window. +4. **Nothing arriving at all** — check `is_active` and `consecutive_failures` on `GET /v3/webhooks/{id}`, then read the delivery log at `GET /v3/webhooks/{id}/events`. +5. **Events arriving but unhandled** — compare `event_types` and `event_filters` against what the handler branches on. + +## Retry, auto-disable, and recovery + +A delivery attempt fails on any non-2xx status, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff with the first retry roughly one minute after the failure, doubling thereafter and capped at 60 minutes between attempts, stopping on the first 2xx or when `retry_count` is exhausted. Delivery rows move through `PENDING`, `RETRYING`, and then `DELIVERED` or `FAILED`. + +`consecutive_failures` tracks consecutive failed delivery attempts. Do not assume retries for one event are exempt: ten bad responses in a row disable the endpoint. After fixing the receiver, re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the Sent Dashboard. Any successful delivery resets the counter to zero. Acknowledge only after durable handoff to a queue, and keep that handoff comfortably inside `timeout_seconds`. + +## Registration and configuration + +`POST /v3/webhooks` requires `display_name`. Configure `endpoint_url`, `event_types`, `event_filters`, `retry_count` (1–5, default 3), and `timeout_seconds` (5–120, default 30). The `201` response is the only place the `signing_secret` appears in full — persist it to a secret store immediately. + + +```json +{ + "display_name": "Production delivery events", + "endpoint_url": "https://hooks.example.com/webhooks/sent", + "event_types": ["message", "templates"], + "event_filters": { + "message": ["delivered", "failed", "received"] + }, + "retry_count": 3, + "timeout_seconds": 30 +} +``` + +Set `event_filters` deliberately. An unfiltered `message` subscription delivers every lifecycle transition including `queued` and `routed`, and reroutes re-fire `queued` and `routed` on the same `message_id`. Filter to the transitions the application acts on. + +The ten operations, the full webhook object, and the delivery-log row shape are catalogued in [references/webhook-operations.md](references/webhook-operations.md). + +## Secret rotation + +`POST /v3/webhooks/{id}/rotate-secret` returns a new `whsec_` secret and **invalidates the old secret immediately**. There is no server-side overlap window. Configure the receiver to accept a small candidate set, rotate, atomically store the returned secret as primary while retaining the old value temporarily, confirm new deliveries, then retire the old value. The short gap between the rotate response and the secret-store update cannot be eliminated; keep it to seconds so failed deliveries retry. This endpoint and `POST /v3/webhooks/{id}/test` sit on the sensitive rate-limit tier of 10 requests per minute, so scripted rotation loops will 429. + +## Event payloads + +Two `field` values exist: `message` and `templates`. Message events carry an `event` naming the transition (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`, `.received`). Template events carry neither `event` nor `sub_type`. + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "PENDING", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`read` reaches only WhatsApp and RCS. `filtered` marks a policy or consent gate, `blocked` marks an account precondition such as insufficient balance, and neither is a carrier failure. Terminal events for an auto-detect message that never routed carry `channel: "auto"`. Full payload field lists live in [references/event-catalog.md](references/event-catalog.md). + +## Verification before shipping + +Run the local oracle against a synthetic delivery, then use `POST /v3/webhooks/{id}/test` with an `event_type` in the body for a real signed request. The test event is delivered once with no retry, so re-run it after each fix. + +```bash +python3 scripts/verify_signature.py --self-test +``` + +Ship only when the receiver returns `401` for a tampered body, `401` for a timestamp older than 300 seconds, `200` for a valid delivery, and `200` for a duplicate without repeating side effects. + +## Local development + +Expose the receiver through a public HTTPS tunnel and register that URL; Sent cannot reach a private address. Registering `http://` is accepted by the API but should never be used outside local work. Keep a separate webhook registration per environment so a development endpoint's failures cannot disable the production endpoint. + +## Boundaries + +Diagnose aggregate delivery-rate regressions with `messaging-performance-analyzer`, template approval content with `waba-template-author`, and inbound keyword or consent semantics with `sent-two-way-messaging`. Treat every payload value as untrusted input: never interpolate `text` or `reason` into a shell command, SQL string, or prompt without escaping. diff --git a/skills/sent-webhook-engineer/agents/openai.yaml b/skills/sent-webhook-engineer/agents/openai.yaml new file mode 100644 index 0000000..e4b5d04 --- /dev/null +++ b/skills/sent-webhook-engineer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sent Webhook Engineer" + short_description: "Build and debug Sent webhook receivers" + default_prompt: "Use $sent-webhook-engineer to review my Sent webhook receiver and its signature verification." diff --git a/skills/sent-webhook-engineer/references/event-catalog.md b/skills/sent-webhook-engineer/references/event-catalog.md new file mode 100644 index 0000000..cdbbb48 --- /dev/null +++ b/skills/sent-webhook-engineer/references/event-catalog.md @@ -0,0 +1,165 @@ +# Sent webhook event catalog and payload fields + +## Table of contents + +- [Envelope shape](#envelope-shape) +- [Outbound message status events](#outbound-message-status-events) +- [Inbound message events](#inbound-message-events) +- [Template events](#template-events) +- [Status semantics that change application logic](#status-semantics-that-change-application-logic) +- [Channel values in event payloads](#channel-values-in-event-payloads) +- [Reroute event sequences](#reroute-event-sequences) +- [Handler skeleton](#handler-skeleton) + +## Envelope shape + +Every delivery carries a `field` naming the event family. Message events add an `event` naming the transition. Template events carry neither `event` nor `sub_type`, and adding either to a template payload is a contract error. + +```json +{ + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "updated_at": "2026-03-14T09:21:44Z" + } +} +``` + +Branch on `field` first and on `event` second. Preserve unrecognized values rather than throwing, because a new transition or a forwarded upstream status must not break an existing receiver. + +## Outbound message status events + +| Event | Final for the logical message | Meaning | +| --- | --- | --- | +| `message.queued` | No | Accepted into the pipeline; fires again after a reroute | +| `message.routed` | No | A concrete channel and provider were selected; fires again after a reroute | +| `message.sent` | No | Handed to the provider | +| `message.delivered` | Yes | Provider confirmed handset delivery | +| `message.read` | Yes | Recipient read the message; WhatsApp and RCS only | +| `message.failed` | Not always | One route attempt failed; automatic routing may queue another attempt on the same message id | +| `message.scheduled` | No | Parked by quiet-hours policy; re-enters the pipeline automatically | +| `message.filtered` | Yes | Blocked by a policy gate such as consent or a route denial | +| `message.blocked` | Yes | Blocked by an account precondition such as insufficient balance | + +Payload fields on status events include `message_id`, `message_status`, `channel`, `account_id`, `updated_at`, and a sender-profile identifier when the send was profile-scoped. The application's own tenant identifier is never present, so keep a `message_id` mapping written before the send. + +## Inbound message events + +`message.received` carries a distinct payload: + +```json +{ + "field": "message", + "event": "message.received", + "value": { + "message_id": "6c1b0a99-2d7e-4c3e-8a5f-9f4e6a2c0b1d", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "inbound_number": "+14155550123", + "outbound_number": "+14155559876", + "text": "STOP", + "channel": "sms", + "received_at": "2026-03-14T09:22:10Z", + "updated_at": "2026-03-14T09:22:10Z" + } +} +``` + +`inbound_number` is the contact who wrote in and `outbound_number` is the number they wrote to; the naming trips up receivers that assume `inbound` means "our side." `text` may be null for non-text payloads. RCS suggestion-chip taps, including the appended STOP chip, arrive as `message.received` with the chip's reply text in `text` — there is no separate chip event type. Consent keywords are processed by Sent before the event reaches the application, so an inbound `STOP` is an audit record of an opt-out that already happened, not a request to perform one. + +## Template events + +```json +{ + "field": "templates", + "value": { + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "template_name": "order_confirmation", + "whatsapp_template_id": "", + "status": "APPROVED", + "language": "en_US", + "category": "UTILITY", + "channel": "whatsapp" + } +} +``` + +`whatsapp_template_id` is an empty string until Meta approves the template. Documented statuses are `PENDING`, `APPROVED`, `REJECTED`, and `CATEGORY_UPDATED`, and upstream Meta values such as `PAUSED` and `DISABLED` are forwarded verbatim — treat the field as an open string set. `reason` is present only when a reason exists, most often on rejection. Route rejection content to template authoring rather than trying to auto-correct copy in the receiver. + +## Status semantics that change application logic + +`filtered` and `blocked` are not carrier failures and must not feed retry logic. A `filtered` message hit a policy gate, most often consent or a route denial, so retrying the same send reproduces the same result and, for consent, would be a compliance violation. A `blocked` message hit an account precondition such as insufficient balance, an onboarding quota, or an unapproved template; the fix is an account action, after which a new send is appropriate. + +`delivered` is the first event that proves the recipient's device received the message. The `202` from the send endpoint proves only acceptance, and `sent` proves only provider handoff. Any business rule with legal or financial weight should key on `delivered`. + +`read` exists only on WhatsApp and RCS, and its absence on SMS is expected rather than a failure. + +## Channel values in event payloads + +| Value | Where it appears | Interpretation | +| --- | --- | --- | +| `sms`, `whatsapp`, `rcs` | Any event after routing | The concrete attempted route | +| `sent` | `message.queued`, `message.routed`, `message.scheduled` for automatic routing | Automatic routing requested; not yet a resolved route | +| `auto` | Terminal events for a message that ended before routing | Internal placeholder; the message never reached a channel | +| `null` | Per-recipient echo in the send response for auto-detect | Not a resolved route | + +A terminal event carrying `auto` means the message failed or was gated before any route was attempted, which points at no matching route, invalid template parameters, a consent block, or an account precondition. Do not display `auto` to end users as a channel name. + +## Reroute event sequences + +Automatic routing may retry a failed message on another route, up to three distinct channel-and-provider pairs across the initial send and all reroutes. The retry re-runs the pipeline on the **same `message_id`**, so the event stream for one logical send can look like this: + +```text +message.queued channel=sent +message.routed channel=whatsapp +message.sent channel=whatsapp +message.failed channel=whatsapp (route-level failure) +message.queued channel=sent (reroute begins, same message_id) +message.routed channel=sms +message.sent channel=sms +message.delivered channel=sms +``` + +Three consequences for receiver design. A `message.failed` is not necessarily final, so reconcile the current message and activity state before triggering an irreversible failure action. Repeated `queued` and `routed` events for one id are normal and must be idempotent. The channel can change mid-stream, so store the channel per event rather than overwriting a single field and assuming it is stable. + +Only route-level or carrier-level failures trigger a reroute. Recipient-level and content-level failures stay failed. A WhatsApp message that was accepted and then failed for a recipient-side reason both reroutes and records a recipient-scoped rule that WhatsApp is not deliverable for that number, which is the mechanism behind the WhatsApp-to-SMS fallback that customers observe on automatic routing. + +## Handler skeleton + +```python +from hashlib import sha256 + + +def handle(event: dict, raw_body: bytes) -> None: + field = event.get("field") + value = event.get("value", {}) + + if not record_receipt_once(sha256(raw_body).hexdigest(), event): + return # exact transport retry; still answer 200 + + if field == "templates": + apply_template_state_once(value["template_id"], value.get("status")) + return + + if field != "message": + record_unknown_event(event) + return + + name = event.get("event") + if name == "message.received": + record_inbound(value) # consent already applied upstream + return + + # Compare payload.updated_at with the projected event timestamp. Do not use + # a global status rank: FAILED may be followed by a successful reroute. + apply_if_newer(value["message_id"], value, value.get("updated_at")) + + if value.get("message_status") == "DELIVERED": + perform_once(f"{value['message_id']}:DELIVERED", on_delivered, value) +``` + +Return `200` before doing slow work. Every branch, including the unknown-event branch, must acknowledge rather than raise, and genuine handler failures should return a non-2xx so Sent retries instead of silently discarding the event. diff --git a/skills/sent-webhook-engineer/references/receiver-recipes.md b/skills/sent-webhook-engineer/references/receiver-recipes.md new file mode 100644 index 0000000..0609e5a --- /dev/null +++ b/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -0,0 +1,246 @@ +# Webhook receiver recipes by framework + +Every recipe follows the same four steps: read the raw body, verify the signature and timestamp, acknowledge with `200`, then process asynchronously. Only the raw-body accessor and the background mechanism change. + +## Table of contents + +- [Raw-body accessor matrix](#raw-body-accessor-matrix) +- [Node and TypeScript](#node-and-typescript) +- [Python](#python) +- [Go](#go) +- [Java and Spring Boot](#java-and-spring-boot) +- [PHP](#php) +- [Ruby](#ruby) +- [ASP.NET Core](#aspnet-core) +- [Deployment traps](#deployment-traps) + +## Raw-body accessor matrix + +| Framework | Accessor | Trap that breaks the signature | +| --- | --- | --- | +| Next.js route handler | `await request.text()` | Keep the route on the Node runtime; do not re-parse the body first | +| Express | `express.raw({ type: 'application/json' })` on the webhook path | A global `express.json()` replaces the exact bytes | +| NestJS | `req.rawBody` | Requires `NestFactory.create(AppModule, { rawBody: true })` | +| FastAPI | `await request.body()` | Binding a Pydantic model first re-serializes | +| Django | `request.body` | Middleware that consumes the stream before the view | +| Flask | `request.get_data()` | `request.get_json()` first loses byte fidelity | +| Gin | `io.ReadAll(c.Request.Body)` | Body must be restored if later handlers read it | +| Echo | `io.ReadAll(c.Request().Body)` | Same | +| Spring Boot | `@RequestBody String payload` | Binding to a DTO re-serializes | +| Laravel | `$request->getContent()` | Middleware ordering; verify before any transform | +| Symfony | `$request->getContent()` | Same | +| Rails | `request.body.read` then `request.body.rewind` | `params` re-encodes the payload | +| Sinatra | `request.body.read` then `request.body.rewind` | Same | +| ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | + +Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. + +## Node and TypeScript + +```ts +import crypto from "node:crypto"; + +const TOLERANCE_SECONDS = 300; + +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { + const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; + if (!secret || !header?.startsWith("v1,")) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; + + const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); + const digest = crypto.createHmac("sha256", key).update(`${webhookId}.${timestamp}.${rawBody}`).digest("base64"); + const expected = Buffer.from(`v1,${digest}`); + const received = Buffer.from(header); + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} +``` + +Next.js route handler, kept on the Node runtime: + +```ts +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + const rawBody = await request.text(); + const ok = verify( + rawBody, + request.headers.get("x-webhook-id") ?? "", + request.headers.get("x-webhook-timestamp") ?? "", + request.headers.get("x-webhook-signature") ?? "", + ); + if (!ok) return new Response("invalid signature", { status: 401 }); + + await enqueue(JSON.parse(rawBody)); // hand off, do not process inline + return new Response(null, { status: 200 }); +} +``` + +Express, scoping the raw parser to the webhook path only: + +```js +app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { + const rawBody = req.body.toString("utf8"); + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + return res.status(401).send("invalid signature"); + } + res.status(200).end(); + queue.add("sent-event", JSON.parse(rawBody)); // after the response +}); +``` + +Mount `express.json()` on other routers rather than globally with `app.use`. In NestJS create the app with `{ rawBody: true }` and read `req.rawBody`. Use BullMQ or an equivalent queue for the background step. + +## Python + +```python +import base64, hashlib, hmac, os, time + +TOLERANCE_SECONDS = 300 + + +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: + secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") + if not secret or not header.startswith("v1,"): + return False + try: + if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: + return False + except ValueError: + return False + key = base64.b64decode(secret.removeprefix("whsec_")) + signed = f"{webhook_id}.{timestamp}.".encode() + raw_body + expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() + return hmac.compare_digest(expected, header) +``` + +FastAPI: + +```python +@app.post("/webhooks/sent", status_code=200) +async def receive(request: Request, background: BackgroundTasks): + raw = await request.body() + if not verify(raw, request.headers.get("x-webhook-id", ""), + request.headers.get("x-webhook-timestamp", ""), + request.headers.get("x-webhook-signature", "")): + raise HTTPException(status_code=401, detail="invalid signature") + background.add_task(process_event, json.loads(raw)) + return {"received": True} +``` + +Django reads `request.body` in the view and must exempt the route from CSRF. Flask reads `request.get_data()` in a decorator that wraps the view. For anything slower than a database insert, hand the parsed event to Celery with `process_event.delay(event)` and route message traffic to a dedicated queue so bulk campaigns cannot starve transactional work. + +## Go + +```go +func Verify(rawBody []byte, webhookID, timestamp, header string) bool { + secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") + if secret == "" || !strings.HasPrefix(header, "v1,") { + return false + } + sentAt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 { + return false + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(webhookID + "." + timestamp + ".")) + mac.Write(rawBody) + expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(expected), []byte(header)) == 1 +} +``` + +In Gin read with `io.ReadAll(c.Request.Body)`; in Echo use `c.Request().Body`. If any later middleware needs the body, restore it with `c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))`. Acknowledge, then dispatch to a goroutine with a bounded worker pool or a durable queue, and drain in-flight work on shutdown. + +## Java and Spring Boot + +```java +@PostMapping("/webhooks/sent") +public ResponseEntity receive( + @RequestBody String payload, + @RequestHeader("x-webhook-id") String webhookId, + @RequestHeader("x-webhook-timestamp") String timestamp, + @RequestHeader("x-webhook-signature") String signature) throws Exception { + + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + return ResponseEntity.status(401).build(); + } + events.submit(payload); // @Async executor + return ResponseEntity.ok().build(); +} +``` + +Bind the body as `String`, never as a DTO, because Jackson re-serialization changes the bytes. Verify with `Mac.getInstance("HmacSHA256")` and compare using `MessageDigest.isEqual`. Push processing onto a `ThreadPoolTaskExecutor` or a broker. + +## PHP + +Laravel middleware runs before the controller and reads `$request->getContent()`: + +```php +public function handle(Request $request, Closure $next) +{ + $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); + $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); + $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); + + if (abs(time() - (int) $request->header('x-webhook-timestamp')) > 300 + || !hash_equals($expected, (string) $request->header('x-webhook-signature'))) { + abort(401); + } + return $next($request); +} +``` + +Dispatch a `ShouldQueue` job from the controller. Symfony follows the same pattern with `$request->getContent()` and a Messenger message consumed by `messenger:consume`. + +## Ruby + +```ruby +def verified?(request) + raw = request.body.read + request.body.rewind + secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") + timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s + return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 + + key = Base64.decode64(secret.delete_prefix("whsec_")) + signed = "#{request.get_header('HTTP_X_WEBHOOK_ID')}.#{timestamp}.#{raw}" + expected = "v1,#{Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', key, signed))}" + ActiveSupport::SecurityUtils.secure_compare(expected, request.get_header("HTTP_X_WEBHOOK_SIGNATURE").to_s) +end +``` + +In Rails put this in a controller concern, skip `verify_authenticity_token` for the action, and enqueue with ActiveJob. In Sinatra read the body in the route and enqueue with Sidekiq; remember the client is memoized per Puma worker process. + +## ASP.NET Core + +```csharp +app.MapPost("/webhooks/sent", async (HttpRequest request) => +{ + using var reader = new StreamReader(request.Body); + var rawBody = await reader.ReadToEndAsync(); + + if (!WebhookSignature.Verify( + rawBody, + request.Headers["x-webhook-id"], + request.Headers["x-webhook-timestamp"], + request.Headers["x-webhook-signature"], + Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + { + return Results.Unauthorized(); + } + + await channel.Writer.WriteAsync(rawBody); // BackgroundService consumer + return Results.Ok(); +}); +``` + +Read the stream before model binding touches it, compare with `CryptographicOperations.FixedTimeEquals`, and consume from a `BackgroundService`. + +## Deployment traps + +Reverse proxies and API gateways that buffer, recompress, or normalize request bodies break the signature; configure pass-through for the webhook path. Serverless platforms that hand the body as base64 require decoding to the original bytes before verification, not after. Load balancer idle timeouts shorter than `timeout_seconds` produce phantom failures that appear in the delivery log as timeouts with no `http_status_code`. Container clocks must be NTP-synchronized or the 300-second window rejects valid traffic. Finally, keep the webhook path out of user-auth middleware. If abuse controls are needed, apply signature-aware, capacity-safe controls rather than a generic pre-verification limiter that manufactures the consecutive failures leading to auto-disable. diff --git a/skills/sent-webhook-engineer/references/webhook-operations.md b/skills/sent-webhook-engineer/references/webhook-operations.md new file mode 100644 index 0000000..ee5f9e1 --- /dev/null +++ b/skills/sent-webhook-engineer/references/webhook-operations.md @@ -0,0 +1,141 @@ +# Sent v3 webhook operations and delivery lifecycle + +## Table of contents + +- [Operation catalog](#operation-catalog) +- [Webhook object](#webhook-object) +- [Creating a webhook](#creating-a-webhook) +- [Event types and filters](#event-types-and-filters) +- [Delivery attempts, retries, and backoff](#delivery-attempts-retries-and-backoff) +- [Auto-disable and recovery](#auto-disable-and-recovery) +- [Delivery log triage](#delivery-log-triage) +- [Test deliveries](#test-deliveries) +- [Rate limits and sandbox behavior](#rate-limits-and-sandbox-behavior) +- [Environment and tenancy layout](#environment-and-tenancy-layout) + +## Operation catalog + +Ten operations manage webhooks. None of them is exposed through the Sent MCP server, so webhook work is REST-only even in an agent session that already holds an MCP connection. + +| Operation | Purpose | Notes | +| --- | --- | --- | +| `POST /v3/webhooks` | Register an endpoint | `201` body carries the only full view of `signing_secret` | +| `GET /v3/webhooks` | List endpoints | Inspect `is_active` and `consecutive_failures` here first | +| `GET /v3/webhooks/event-types` | Discover subscribable event types | Use before hardcoding an `event_types` array | +| `GET /v3/webhooks/{id}` | Inspect one endpoint | Confirms configuration and health counters | +| `PUT /v3/webhooks/{id}` | Update configuration | Replaces the mutable configuration fields | +| `DELETE /v3/webhooks/{id}` | Remove an endpoint | Ignores `sandbox` and always deletes | +| `GET /v3/webhooks/{id}/events` | Delivery log | Requires `page` and `page_size`; optional `search` | +| `POST /v3/webhooks/{id}/rotate-secret` | Replace the signing secret | Old secret dies immediately; sensitive rate tier | +| `POST /v3/webhooks/{id}/test` | Send a synthetic signed delivery | Requires `event_type`; one attempt, no retry; sensitive rate tier | +| `PATCH /v3/webhooks/{id}/toggle-status` | Enable or disable | Operational pause without losing configuration | + +## Webhook object + +| Field | Meaning | +| --- | --- | +| `id` | Endpoint UUID; the value of `x-webhook-id` on every delivery | +| `display_name` | Required label; the only required field on create | +| `endpoint_url` | Destination; scheme must be `http://` or `https://` | +| `signing_secret` | `whsec_`-prefixed secret, fully visible only in the create and rotate responses | +| `is_active` | False after auto-disable or an explicit toggle | +| `event_types` | Subscribed event families, for example `["message", "templates"]` | +| `event_filters` | Per-family narrowing, for example `{"message": ["delivered", "failed"]}` | +| `retry_count` | 1–5, default 3 | +| `timeout_seconds` | 5–120, default 30 | +| `last_delivery_attempt_at` | Timestamp of the most recent attempt of any outcome | +| `last_successful_delivery_at` | Timestamp of the most recent 2xx | +| `consecutive_failures` | Counter of consecutive failed attempts; ten disables the endpoint | +| `created_at`, `updated_at` | Audit timestamps | + +The gap between `last_delivery_attempt_at` and `last_successful_delivery_at` is the fastest health signal: a recent attempt with a stale success means the endpoint is failing right now. + +## Creating a webhook + +Only `display_name` is required, but a useful registration sets the destination, the subscriptions, and the delivery envelope explicitly. + +```json +{ + "display_name": "Staging inbound and failures", + "endpoint_url": "https://staging-hooks.example.com/webhooks/sent", + "event_types": ["message"], + "event_filters": { + "message": ["received", "failed", "filtered", "blocked"] + }, + "retry_count": 5, + "timeout_seconds": 15 +} +``` + +Choose `timeout_seconds` to match how fast the endpoint acknowledges, not how long processing takes. A receiver that returns `200` in 50 milliseconds and queues the work is compatible with the 5-second minimum; a receiver that writes to three downstream systems before responding will eventually breach even a 120-second ceiling under load and start accumulating consecutive failures. + +Choose `retry_count` against the recovery profile of the receiver. Three attempts spread over roughly seven minutes suits a stateless service behind a load balancer. Five attempts, reaching further into the capped 60-minute backoff, suits a receiver whose dependency outages last longer than a few minutes. + +## Event types and filters + +Call `GET /v3/webhooks/event-types` rather than assuming the catalog. Two `field` families exist today: `message`, which carries an `event` naming the transition, and `templates`, which carries approval-state changes without an `event` field. + +Filters matter more than they appear. An unfiltered `message` subscription delivers every transition, and because a reroute re-runs the pipeline on the same message id, `queued` and `routed` can arrive several times for one logical send. Subscribing only to the transitions the application acts on reduces both traffic and the chance of a double-processing bug. + +A practical split by consumer: + +| Consumer | Subscription | +| --- | --- | +| Delivery ledger and retries | `message` filtered to `delivered`, `failed`, `filtered`, `blocked` | +| Support inbox and auto-replies | `message` filtered to `received` | +| Read-receipt analytics | `message` filtered to `read` | +| Template governance | `templates` | +| Route debugging in a lower environment | `message` unfiltered | + +## Delivery attempts, retries, and backoff + +An attempt fails on any non-2xx response, a timeout past `timeout_seconds`, or a connection failure. Retries use exponential backoff: the first retry lands roughly one minute after the failure, each subsequent delay doubles, and the interval is capped at 60 minutes between attempts. Retries stop at the first 2xx or when `retry_count` is exhausted. + +Delivery rows report `delivery_status` as `PENDING` while queued, `RETRYING` between attempts, `DELIVERED` on success, and `FAILED` once attempts are exhausted. A `DELIVERED` outcome resets the endpoint's `consecutive_failures` to zero. + +Because retries are signed fresh, a retried delivery has a new `x-webhook-timestamp` and a new signature but the same payload — which is precisely why dedupe must key on payload semantics rather than on headers. + +## Auto-disable and recovery + +Ten consecutive failed delivery attempts disable the endpoint. Treat every failed attempt as capable of advancing the counter, whether it is a retry of one event or the first attempt for another; do not rely on event boundaries for protection. Any success resets the counter. + +Once `is_active` is false, Sent stops delivering. Recovery sequence: + +1. Read `GET /v3/webhooks/{id}` and confirm `is_active` and `consecutive_failures`. +2. Read the delivery log and identify the recurring `http_status_code` or `error_message`. +3. Fix the receiver and prove it locally against a signed synthetic delivery. +4. Re-enable the webhook with `PATCH /v3/webhooks/{id}/toggle-status` or in the Sent Dashboard. +5. Confirm recovery with `POST /v3/webhooks/{id}/test`, then verify the log shows `DELIVERED`. +6. Backfill the outage window from `GET /v3/messages/{id}` and `GET /v3/messages/{id}/activities` for messages whose state is stale, because events that failed permanently during the outage are not redelivered on re-enable. + +Add monitoring on `consecutive_failures` so an alert fires at three or four rather than at ten. + +## Delivery log triage + +Each row of `GET /v3/webhooks/{id}/events` contains `id`, `event_type`, `event_data`, `delivery_status`, `http_status_code`, `response_body`, `delivery_attempts`, `error_message`, `created_at`, `processing_started_at`, and `processing_completed_at`. Both `page` and `page_size` are required; omitting them returns a validation error rather than a default page. + +| Log evidence | Diagnosis | +| --- | --- | +| `http_status_code` 401 or 403 | The receiver is rejecting the signature, or authentication middleware sits in front of the webhook route | +| `http_status_code` 404 | Route path or environment mismatch in `endpoint_url` | +| `http_status_code` 5xx with a stack trace in `response_body` | Handler exception; fix the handler, not the registration | +| `error_message` naming a timeout with empty `http_status_code` | The receiver did not answer inside `timeout_seconds`; move work off the request path | +| `error_message` naming a connection or TLS failure | DNS, certificate, or firewall problem; the request never reached the application | +| `delivery_status` `DELIVERED` while the application has no record | The event was accepted and then dropped internally; instrument between acknowledgement and the queue | +| `delivery_attempts` climbing with `RETRYING` | Backoff is in progress; confirm the receiver recovered before it exhausts `retry_count` | + +Keep the receiver route outside user-auth middleware. Sent authenticates by signature, and an intervening session or Bearer-auth layer produces a 401 that looks exactly like a signature bug. + +## Test deliveries + +`POST /v3/webhooks/{id}/test` takes an `event_type` in the body and sends a real signed request with a synthetic payload to the registered URL. It is delivered once with no retry, so each fix needs a fresh call. Treat it as the end-to-end proof that DNS, TLS, routing, signature verification, and acknowledgement all work together; use the local signing script for iteration because the test endpoint is limited to 10 requests per minute. + +## Rate limits and sandbox behavior + +Standard endpoints allow 200 requests per minute on a sliding window. `rotate-secret` and `test` allow 10 per minute on a fixed window. Rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`) appear only on `429` responses, so a client cannot read remaining quota preemptively and must pace by design. + +`"sandbox": true` on create or update validates and authenticates without persisting anything, which makes it useful for checking a payload shape in CI. `DELETE /v3/webhooks/{id}` ignores the flag and always deletes, so never use sandbox as a dry-run guard for deletion. + +## Environment and tenancy layout + +Register one webhook per environment and never share an endpoint across environments. Because auto-disable is per endpoint, a development receiver returning 500s cannot then disable production. For multi-tenant systems, remember that events do not carry the application's tenant identifier: persist `message_id -> {tenant, profile, logical_send_id, channel}` before sending and map inbound events by the receiving number. Organization-scoped credentials with `x-profile-id` can manage a child profile's webhooks; profile-scoped keys manage only their own and receive `403` if they send `x-profile-id`. diff --git a/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md b/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md new file mode 100644 index 0000000..eb4af1f --- /dev/null +++ b/skills/sent-webhook-engineer/references/webhook-signature-and-dedupe.md @@ -0,0 +1,88 @@ +# Webhook signature verification and event deduplication + +## Table of contents + +- [Signature scheme](#signature-scheme) +- [Why the raw body matters](#why-the-raw-body-matters) +- [Replay rejection](#replay-rejection) +- [Secret handling and rotation](#secret-handling-and-rotation) +- [Deduplication without an event id](#deduplication-without-an-event-id) +- [Ordering and out-of-sequence events](#ordering-and-out-of-sequence-events) +- [Acceptance tests for a receiver](#acceptance-tests-for-a-receiver) + +## Signature scheme + +Sent signs each delivery with HMAC-SHA256 and publishes three headers. + +| Header | Example | Notes | +| --- | --- | --- | +| `x-webhook-signature` | `v1,K7t9...==` | Version tag, comma, base64 digest | +| `x-webhook-id` | `0f8fad5b-d9cb-469f-a165-70867728950e` | Endpoint UUID, constant across deliveries | +| `x-webhook-timestamp` | `1767225600` | Unix seconds | + +The signed content is the concatenation `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, where the first two components are joined by literal `.` characters and the third is the untouched request body. The HMAC key is the signing secret with the leading `whsec_` removed and the remainder base64-decoded, which yields raw key bytes rather than an ASCII string. The digest is base64-encoded and prefixed with `v1,`. + +The construction is compatible with Svix-style verification, so an existing Svix helper can usually be adapted by pointing it at these header names. No Sent SDK provides a built-in verifier in any of the seven supported languages, so this logic is application code in every deployment. + +Compare signatures with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`, `MessageDigest.isEqual`, `hash_equals`, `subtle.ConstantTimeCompare`). A plain `==` on a signature invites a timing oracle. + +## Why the raw body matters + +The signature covers exact bytes. Any transformation between the socket and the verification step invalidates it: JSON parse and re-serialize, key reordering, whitespace normalization, Unicode escaping changes, trailing-newline insertion, gzip re-encoding, or a proxy that rewrites the body. This is the single most common cause of signature failures, and it usually appears as "verification works with curl but fails behind the framework." + +Diagnose it by logging the byte length and a SHA-256 of the body at the verification point and comparing against the `Content-Length` header. A mismatch means something consumed and rebuilt the body upstream. + +## Replay rejection + +Reject a delivery when `abs(now - x-webhook-timestamp) > 300` seconds. The five-minute tolerance is the documented value and appears as `TOLERANCE_SECONDS = 300` in the official samples for every language. Two operational consequences follow. First, hosts must run NTP; clock drift beyond five minutes rejects perfectly valid traffic and the symptom looks identical to a signature bug. Second, because retries can arrive up to 60 minutes after the original attempt, each retry is signed with its own fresh timestamp — the receiver must never cache the first timestamp and compare later deliveries against it. + +For forensic replay of an archived delivery, verify the HMAC while explicitly skipping the freshness check rather than widening the production tolerance. + +## Secret handling and rotation + +The full `signing_secret` appears exactly once, in the `201` body of `POST /v3/webhooks`. Store it in a secret manager keyed by webhook id and environment. `GET /v3/webhooks/{id}` is the way to confirm which endpoint a stored secret belongs to. + +`POST /v3/webhooks/{id}/rotate-secret` returns the replacement and invalidates the previous secret immediately. There is no dual-signing window on Sent's side, so the receiver must provide the overlap: + +1. Deploy a receiver that reads a primary secret and an optional secondary secret and accepts a delivery that verifies under either. +2. Put the current secret in both slots and deploy. +3. Rotate, and write the new secret into the primary slot. +4. Confirm from the delivery log that recent attempts are `DELIVERED`. +5. Clear the secondary slot and deploy again. + +Rotate on compromise, on operator offboarding, and on a fixed schedule. The rotate endpoint is on the sensitive tier of 10 requests per minute, so automation must not loop over many webhooks quickly. + +## Deduplication without an event id + +Sent does not publish a per-event unique identifier. `x-webhook-id` names the endpoint and is identical on every delivery, so using it as a dedupe key collapses all events into a single row. Derive keys from event semantics instead: + +| Event | Idempotency key | Rationale | +| --- | --- | --- | +| Outbound status (`message.queued`, `.routed`, `.sent`, `.delivered`, `.read`, `.failed`, `.scheduled`, `.filtered`, `.blocked`) | `sha256(raw_body)` for receipt dedupe; `{message_id}:{message_status}` for one-time business effects | Exact delivery retries carry the same payload, while a reroute may legitimately repeat a status with a different channel or timestamp | +| Inbound (`message.received`) | `{message_id}` | Each inbound message has its own id | +| Template (`field: "templates"`) | `{template_id}:{status}` | Approval transitions are the meaningful unit | +| Anything unrecognized | `sha256(raw_body)` | Absorbs an exact retry without depending on a fresh retry timestamp | + +Persist the key with a unique constraint and treat an insert conflict as "already processed, return 200." A duplicate must never repeat side effects such as charging a card, sending a follow-up message, or writing a second audit row. + +Reroutes make two layers necessary. Use a raw-body hash to suppress exact transport retries, but retain distinct reroute events in an append-only receipt ledger because their channel or `updated_at` differs. Gate one-time business effects separately—for example, send a receipt only once for `{message_id}:DELIVERED`—so preserving route evidence does not duplicate side effects. + +## Ordering and out-of-sequence events + +Delivery order is not guaranteed, and a global status rank is unsafe: automatic routing can emit `FAILED`, then a newer `QUEUED`, `ROUTED`, and `DELIVERED` on the same message id. Persist the append-only receipt first, then update the current projection only when the payload's `updated_at` is newer than the projected event timestamp. Use receipt order only as a tie-breaker, preserve the attempted channel per event, and reconcile uncertain final state with `GET /v3/messages/{id}` plus activities. Never make `FAILED` permanently outrank a later successful reroute. + +## Acceptance tests for a receiver + +A receiver is ready when all of the following hold: + +1. A validly signed delivery returns `200`. +2. A body with a single byte changed returns `401`. +3. A delivery signed with a different secret returns `401`. +4. A delivery whose timestamp is 400 seconds old returns `401`. +5. A delivery whose `x-webhook-id` is altered returns `401`. +6. The same valid delivery sent twice returns `200` twice and performs side effects once. +7. A `message.delivered` followed by a late `message.sent` leaves the stored status at `DELIVERED`. +8. A handler exception still returns a non-2xx status so Sent retries, rather than swallowing the error and returning `200`. +9. Processing that exceeds one second happens after the response, not before it. + +Use `scripts/verify_signature.py --sign` to produce headers for cases 1, 6, and 7, and mutate them for the negative cases. diff --git a/skills/sent-webhook-engineer/scripts/verify_signature.py b/skills/sent-webhook-engineer/scripts/verify_signature.py new file mode 100644 index 0000000..e79391f --- /dev/null +++ b/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Reference implementation and test oracle for Sent v3 webhook signatures. + +The signed content is exactly ``{webhook_id}.{timestamp}.{raw_body}``. The key is +the signing secret with its ``whsec_`` prefix removed and the remainder +base64-decoded. The signature header value is ``v1,{base64(hmac_sha256)}``. + +Usage +----- +Self-test with synthetic fixtures (no network, no credentials):: + + python3 verify_signature.py --self-test + +Verify a captured delivery:: + + python3 verify_signature.py --body-file delivery.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --timestamp 1767225600 \ + --signature 'v1,Base64Signature==' \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Sign a synthetic delivery so a local receiver can be exercised:: + + python3 verify_signature.py --sign --body-file event.json \ + --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ + --secret-env SENT_DM_WEBHOOK_SECRET + +Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, +3 usage or configuration error. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import sys +import time + +SECRET_PREFIX = "whsec_" +SIGNATURE_PREFIX = "v1," +TOLERANCE_SECONDS = 300 + +EXIT_VALID = 0 +EXIT_INVALID = 1 +EXIT_REPLAY = 2 +EXIT_USAGE = 3 + + +def decode_secret(secret: str) -> bytes: + """Return the raw HMAC key for a Sent signing secret.""" + if not secret: + raise ValueError("signing secret is empty") + material = secret[len(SECRET_PREFIX):] if secret.startswith(SECRET_PREFIX) else secret + padding = "=" * (-len(material) % 4) + try: + return base64.b64decode(material + padding, validate=True) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise ValueError(f"signing secret is not valid base64 after the prefix: {exc}") from exc + + +def signed_content(webhook_id: str, timestamp: str, raw_body: bytes) -> bytes: + """Build the byte string Sent signs.""" + return f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body + + +def compute_signature(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str: + """Return the full ``v1,...`` header value for a delivery.""" + digest = hmac.new( + decode_secret(secret), + signed_content(webhook_id, timestamp, raw_body), + hashlib.sha256, + ).digest() + return SIGNATURE_PREFIX + base64.b64encode(digest).decode("ascii") + + +def signature_matches(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> bool: + """Constant-time comparison of a received signature header.""" + expected = compute_signature(secret, webhook_id, timestamp, raw_body) + return hmac.compare_digest(expected, header.strip()) + + +def timestamp_fresh(timestamp: str, now: int | None = None, tolerance: int = TOLERANCE_SECONDS) -> bool: + """Return True when the timestamp is inside the replay window.""" + try: + sent_at = int(timestamp) + except (TypeError, ValueError): + return False + reference = int(time.time()) if now is None else now + return abs(reference - sent_at) <= tolerance + + +def verify(secret: str, webhook_id: str, timestamp: str, raw_body: bytes, header: str) -> int: + """Return the process exit code for one delivery.""" + if not timestamp_fresh(timestamp): + return EXIT_REPLAY + return EXIT_VALID if signature_matches(secret, webhook_id, timestamp, raw_body, header) else EXIT_INVALID + + +def _self_test() -> int: + secret = SECRET_PREFIX + base64.b64encode(b"synthetic-signing-key-0123456789").decode("ascii") + webhook_id = "0f8fad5b-d9cb-469f-a165-70867728950e" + now = int(time.time()) + timestamp = str(now) + body = json.dumps( + { + "field": "message", + "event": "message.delivered", + "value": { + "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", + "message_status": "DELIVERED", + "channel": "sms", + "account_id": "3f1a7c22-5d8e-4b90-91a2-6c4d0e8f7b31", + }, + }, + separators=(",", ":"), + ).encode("utf-8") + + failures: list[str] = [] + header = compute_signature(secret, webhook_id, timestamp, body) + + if not header.startswith(SIGNATURE_PREFIX): + failures.append("signature header must start with 'v1,'") + if verify(secret, webhook_id, timestamp, body, header) != EXIT_VALID: + failures.append("a freshly signed delivery must verify") + if verify(secret, webhook_id, timestamp, body + b" ", header) != EXIT_INVALID: + failures.append("a mutated body must fail verification") + if verify(secret, "11111111-2222-3333-4444-555555555555", timestamp, body, header) != EXIT_INVALID: + failures.append("a different webhook id must fail verification") + stale = str(now - (TOLERANCE_SECONDS + 60)) + if verify(secret, webhook_id, stale, body, compute_signature(secret, webhook_id, stale, body)) != EXIT_REPLAY: + failures.append("a stale timestamp must be rejected as a replay") + if decode_secret(secret) != decode_secret(secret[len(SECRET_PREFIX):]): + failures.append("prefixed and unprefixed secrets must decode identically") + + reserialized = json.dumps(json.loads(body)).encode("utf-8") + if reserialized != body and signature_matches(secret, webhook_id, timestamp, reserialized, header): + failures.append("re-serialized JSON must not verify; raw bytes are required") + + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + if failures: + return EXIT_INVALID + print("verify_signature self-test passed: 7 checks") + return EXIT_VALID + + +def _resolve_secret(args: argparse.Namespace) -> str: + if args.secret_env: + secret = os.environ.get(args.secret_env, "") + if not secret: + raise ValueError(f"environment variable {args.secret_env} is unset or empty") + return secret + raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Verify or sign a Sent v3 webhook delivery.") + parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit") + parser.add_argument("--sign", action="store_true", help="emit headers for a synthetic signed delivery") + parser.add_argument("--body-file", help="path to the raw request body captured byte for byte") + parser.add_argument("--webhook-id", help="value of the x-webhook-id header") + parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") + parser.add_argument("--signature", help="value of the x-webhook-signature header") + parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument( + "--skip-replay-check", + action="store_true", + help="verify the HMAC only, for forensic replay of an archived delivery", + ) + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + + if not args.body_file or not args.webhook_id: + parser.error("--body-file and --webhook-id are required unless --self-test is used") + + try: + raw_body = open(args.body_file, "rb").read() + secret = _resolve_secret(args) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_USAGE + + if args.sign: + timestamp = args.timestamp or str(int(time.time())) + print(f"x-webhook-id: {args.webhook_id}") + print(f"x-webhook-timestamp: {timestamp}") + print(f"x-webhook-signature: {compute_signature(secret, args.webhook_id, timestamp, raw_body)}") + return EXIT_VALID + + if not args.timestamp or not args.signature: + parser.error("--timestamp and --signature are required when verifying") + + if args.skip_replay_check: + matched = signature_matches(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + result = EXIT_VALID if matched else EXIT_INVALID + else: + result = verify(secret, args.webhook_id, args.timestamp, raw_body, args.signature) + + print({EXIT_VALID: "valid", EXIT_INVALID: "invalid signature", EXIT_REPLAY: "replay window exceeded"}[result]) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/sent/SKILL.md b/skills/sent/SKILL.md index f13a519..9e8b3f1 100644 --- a/skills/sent/SKILL.md +++ b/skills/sent/SKILL.md @@ -49,6 +49,19 @@ Do **not** use when: | Diagnosing delivery from MDR exports, funnels, cohorts, or cross-channel failure codes | `messaging-performance-analyzer` | | Designing or auditing a tenant-facing template-builder UI | `template-builder-ui` | +### Engineering and integration + +| User intent | Target skill | +|---|---| +| Adding Sent to a codebase, choosing an SDK, or hardening retries, idempotency, and error handling before launch | `sent-integration-starter` | +| Building or debugging a webhook receiver, signature verification, dedupe, or an auto-disabled endpoint | `sent-webhook-engineer` | +| Choosing the channel field, expecting cross-channel fallback, or interpreting a route, reroute, or delivery outcome | `sent-routing-strategist` | +| Handling inbound messages, opt-out keywords, consent state, the WhatsApp 24-hour window, or conversation history | `sent-two-way-messaging` | +| Executing the Sender Profile lifecycle over the API, including completion callbacks, campaigns, and user roles | `sent-profile-provisioning` | +| Replacing Twilio, Sinch, Infobip, Vonage, or Bird with Sent, including cutover and rollback planning | `migrate-to-sent` | + +Within this group, note two frequent hand-offs: `sender-profile-architect` decides the tenancy boundary and `sent-profile-provisioning` implements it, while `migrate-to-sent` plans a provider replacement and `sent-integration-starter` hardens the resulting integration. + If the request matches one row cleanly, invoke that skill and stop. If it spans several rows, state the proposed order and begin with the prerequisite. For example, check `sent-account-readiness` before a live send, use `sent-templates` to locate an existing template before `sent-messaging`, and use `messaging-performance-analyzer` when the user provides an export rather than asking for live dashboard metrics. ## Clarifying questions to ask before routing @@ -70,7 +83,7 @@ One question per turn is fine; never fire all seven at once. This skill is not a fallback for general questions. If the user asks about: - **Balance, onboarding state, or whether the selected account can send** — use `sent-account-readiness`. - **Contracts, plan pricing, invoices, or account access that the available operations cannot answer** — direct them to Sent support or `https://docs.sent.dm`. -- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally. +- **Generic engineering** such as retries, queueing, or observability with no Sent-specific work — answer normally; route to `sent-integration-starter` once the question involves Sent's own retry, idempotency, or rate-limit contract. - **Meta, Google, TCR, or carrier policy outside a specialist skill's scope** — use current upstream documentation. If after the clarifying questions the request still doesn't fit any target skill, say so plainly. Don't force a route.