UNPKG

@proveanything/smartlinks

Version:

Official JavaScript/TypeScript SDK for the Smartlinks API

582 lines (455 loc) 17.2 kB
# Communications (Comms & Broadcasts) This guide covers the full communications surface of the SDK: transactional sends, multi-channel broadcasts, consent management, push registration, and analytics. --- ## What You Can Do - **Transactional Send** Fire a single targeted message to one contact without creating a broadcast record. - **Broadcasts** Define and enqueue multi-channel campaigns (email, push, SMS, wallet); preview and test before sending. - **Comms Settings** Configure topics and unsubscribe behaviour per collection. - **Consent & Preferences** Record opt-ins/outs and per-subject preferences. - **Method Registration** Register email, SMS, and Web Push contact methods. - **Analytics** Query delivery events, recipient lists, and log custom events. --- ## Transactional Send Sends a single message to one contact using a template. No broadcast record is created. The send is logged to the contact's communication history with `sourceType: 'transactional'`. **Endpoint:** `POST /admin/collection/:collectionId/comm/send` ```typescript import { comms } from '@proveanything/smartlinks' import type { TransactionalSendRequest } from '@proveanything/smartlinks' const result = await comms.sendTransactional('collectionId', { contactId: 'e4f2a1b0-...', templateId: 'warranty-update', channel: 'preferred', // 'email' | 'sms' | 'push' | 'wallet' | 'preferred' (default) props: { claimRef: 'CLM-0042', decision: 'approved' }, include: { productId: 'prod-abc123', // hydrate product context into template appCase: 'c9d1e2f3-...', // hydrate an app case record }, ref: 'warranty-decision-notification', // arbitrary string for your own tracking appId: 'warrantyApp', }) if (result.ok) { console.log(`Sent via ${result.channel}`, result.messageId) } else { console.error('Send failed:', result.error) } ``` ### Request Body | Field | Type | Description | |-------|------|-------------| | `contactId` | `string` | **Required.** Contact to send to. | | `templateId` | `string` | **Required.** Template to render. | | `channel` | `'email' \| 'sms' \| 'push' \| 'wallet' \| 'preferred'` | Channel to send on. `'preferred'` (default) picks the contact's best available channel. | | `props` | `Record<string, unknown>` | Extra variables merged into template rendering. | | `include` | object | Hydration flags see table below. | | `ref` | `string` | Arbitrary reference string stored with the event. | | `appId` | `string` | App identifier, used for context/logging. | **`include` fields:** | Field | Type | Description | |-------|------|-------------| | `collection` | `boolean` | Include collection data in template context. | | `productId` | `string` | Hydrate a specific product. | | `proofId` | `string` | Hydrate a specific proof. | | `user` | `boolean` | Include the requesting user's data. | | `appCase` | `string` | Hydrate an app case record by ID. | | `appThread` | `string` | Hydrate an app thread record by ID. | | `appRecord` | `string` | Hydrate an app record by ID. | ### Response ```typescript // Success { ok: true; channel: 'email' | 'sms' | 'push' | 'wallet'; messageId?: string } // Failure { ok: false; error: string } ``` --- ## Broadcasts Broadcasts are multi-channel campaigns defined and managed under a collection. Create a broadcast record in the platform first, then use the SDK to preview, test, and enqueue. **Import:** ```typescript import { broadcasts } from '@proveanything/smartlinks' import type { BroadcastSendRequest } from '@proveanything/smartlinks' ``` ### Preview Render a broadcast template for a given channel without sending. ```typescript const preview = await broadcasts.preview('collectionId', 'broadcastId', { email: 'user@example.com', // optional: override recipient email for preview channelOverride: 'push', // force a specific channel props: { productId: 'prod_123' }, hydrate: true, include: { product: true, proof: true } }) // preview.channel which channel was rendered // preview.payload / preview.subject / preview.body channel-specific render output ``` ### Test Send Send a single test message to a contact. ```typescript await broadcasts.sendTest('collectionId', 'broadcastId', { contactId: 'contact_123', props: { promo: 'JAN' } }) ``` ### Enqueue Production Send Enqueue a background send to all recipients. When `channel` is omitted the broadcast's own mode setting drives channel selection: - `preferred` picks the best single channel per recipient. - `channels` / `all` sends on every enabled channel. ```typescript const body: BroadcastSendRequest = { pageSize: 200, hydrate: true, include: { product: true, proof: true, user: true } } await broadcasts.send('collectionId', 'broadcastId', body) ``` ### Manual Send ```typescript await broadcasts.sendManual('collectionId', 'broadcastId', { /* overrides */ }) ``` ### Topic Targeting and Consent A broadcast must declare a `topic` under `broadcast.data.topic` (e.g. `newsletter`, `critical`). The topic key must match one defined in your collection's comms settings, and consent enforcement uses it at send time. Consent resolution order (per recipient, per channel): 1. `preferences._default.topicsByChannel[channel][topic]` 2. `preferences._default.channels[channel]` 3. `preferences._default.topics[topic]` 4. Subject-specific preferences (when hydrating a subject context) Minimal broadcast data shape: ```json { "data": { "topic": "newsletter", "channelSettings": { "mode": "preferred", "channels": [ { "channel": "email", "enabled": true, "priority": 1, "templateId": "tmpl_newsletter_email" }, { "channel": "push", "enabled": true, "priority": 2 } ] } } } ``` ### Append Recipients and Events ```typescript // Append a single delivery event await broadcasts.append('collectionId', { broadcastId: 'broadcastId', contactId: 'contact_123', channel: 'email', eventType: 'delivered', outcome: 'success' }) // Append bulk events await broadcasts.appendBulk('collectionId', { params: { broadcastId: 'broadcastId', channel: 'push' }, ids: ['contact_1', 'contact_2'], idField: 'contactId' }) // List recipients const recips = await broadcasts.recipients('collectionId', 'broadcastId', { limit: 50 }) ``` --- ## Admin Comms Settings Configure unsubscribe behaviour and topics per collection. **Base path:** `admin/collection/:collectionId/comm.settings` ### Get Settings ```typescript import { comms } from '@proveanything/smartlinks' const { settings } = await comms.getSettings('collectionId', { includeSecret: true }) // settings.unsub { requireToken, hasSecret } // settings.topics map of topic key config ``` ### Patch Settings ```typescript await comms.patchSettings('collectionId', { unsub: { requireToken: true, secret: '<your-secret>' }, topics: { /* ... */ } }) ``` To clear the unsubscribe secret, send `secret: ''`. ### Topics Config Each topic key maps to a config object: ```json { "newsletter": { "label": "Newsletter", "description": "Occasional updates and stories", "classification": "marketing", "defaults": { "policy": "opt-in", "byChannel": { "email": "opt-in", "push": "opt-in" }, "channels": { "email": true, "push": true, "sms": false } }, "rules": { "allowChannels": ["email", "sms", "push"], "allowUnsubscribe": true }, "required": false }, "critical": { "label": "Critical Notices", "classification": "transactional", "defaults": { "policy": "opt-out", "channels": { "email": true, "push": true } }, "rules": { "allowChannels": ["email", "push"], "allowUnsubscribe": false }, "required": true } } ``` - `classification`: `'transactional'` or `'marketing'` used by the UI when no explicit policy is set. - `defaults.policy`: `'opt-in'` or `'opt-out'` fallback policy for channels not overridden by `byChannel`. - `required: true` and `rules.allowUnsubscribe: false` use for critical/mandatory messages. ### Unsubscribe Tokens If `unsub.requireToken` is true, public unsubscribe calls must include a `token` query param. Token generation (topic opt-out): ``` basis = "${contactId}:${topic}" token = sha256(basis + ":" + unsub.secret) // hex ``` Token generation (channel opt-out): ``` basis = "${contactId}:channel:${channel}" token = sha256(basis + ":" + unsub.secret) ``` Browser helper: ```typescript async function sha256Hex(input: string): Promise<string> { const data = new TextEncoder().encode(input) const hash = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(hash)) .map(b => b.toString(16).padStart(2, '0')) .join('') } const token = await sha256Hex(`${contactId}:${topic}:${secret}`) ``` --- ## Public Comms API All public endpoints live under `public/collection/:collectionId/comm/*` and do not require an API key. ### Get Topics ```typescript const { topics } = await comms.getPublicTopics('collectionId') ``` Each topic carries UI metadata: - `classification`: `'transactional'` | `'marketing'` - `defaults.policy`: `'opt-in'` | `'opt-out'` - `defaults.byChannel[channel]`: per-channel override Helper to resolve effective policy for a toggle UI: ```typescript type Policy = 'opt-in' | 'opt-out' function effectivePolicy(topic: any, channel: 'email' | 'sms' | 'push' | 'wallet'): Policy { return ( topic?.defaults?.byChannel?.[channel] ?? topic?.defaults?.policy ?? (topic?.classification === 'marketing' ? 'opt-in' : 'opt-out') ) as Policy } ``` ### Consent (Default) Record a contact's default channel and topic opt-ins. ```typescript await comms.upsertConsent('collectionId', { contactId: 'contact_123', channels: { email: true, push: true }, topics: { newsletter: true, critical: true }, topicsByChannel: { email: { newsletter: true }, push: { critical: true } } }) ``` ### Preferences (Subject-Specific) Record consent specific to a product, proof, or other subject. Omit `subject` to update defaults. ```typescript await comms.upsertPreferences('collectionId', { contactId: 'contact_123', subject: { type: 'product', id: 'prod_1' }, channels: { email: true }, topics: { updates: true } }) ``` ### Subscribe / Unsubscribe (Subject) Manage subscriptions to a specific subject (e.g. product updates, proof alerts). ```typescript // Subscribe const { subscriptionId } = await comms.subscribe('collectionId', { contactId: 'contact_123', subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' }, subscribe: true, source: 'api' }) // Unsubscribe (same call, subscribe: false) await comms.subscribe('collectionId', { contactId: 'contact_123', subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' }, subscribe: false, source: 'api' }) ``` ### Check Subscription ```typescript const r = await comms.checkSubscription('collectionId', { contactId: 'contact_123', subjectType: 'proof', subjectId: 'prf_1', productId: 'prod_1' }) // r.subscribed === true | false ``` ### Resolve Subscriptions Find contacts that are subscribed to a subject using identity hints. ```typescript const res = await comms.resolveSubscriptions('collectionId', { subject: { type: 'product', id: 'prod_1' }, hints: { userId: 'user_1', email: 'user@example.com' } }) ``` ### List and Register Methods ```typescript // List registered methods for a contact const { methods } = await comms.listMethods('collectionId', { contactId: 'contact_123', type: 'email' // optional filter: 'email' | 'sms' | 'push' }) // Register email await comms.registerEmail('collectionId', { contactId: 'contact_123', email: 'user@example.com' }) // Register SMS await comms.registerSms('collectionId', { contactId: 'contact_123', phone: '+12065550100' }) ``` ### Unsubscribe (Public Link / One-Click) ```typescript await comms.unsubscribe('collectionId', { contactId: 'contact_123', topic: 'newsletter', token: '<sha256-hex-when-requireToken-is-true>' }) ``` Omit `topic` to unsubscribe from all. Omit `channel` to apply across all channels. --- ## Push Registration Web Push requires a service worker and the browser's Push API. ### 1. Fetch the VAPID Public Key ```typescript import { comms } from '@proveanything/smartlinks' const { publicKey } = await comms.getPushVapidPublicKey('collectionId') ``` ### 2. Subscribe in the Service Worker ```typescript const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }) const subJson = subscription.toJSON() as any ``` ### 3. Register the Method ```typescript await comms.registerPush('collectionId', { contactId: 'contact_123', endpoint: subscription.endpoint, keys: subJson?.keys, meta: { userId: 'user_1' } }) ``` ### VAPID Key Utility ```typescript function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = atob(base64) const output = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i) return output } ``` --- ## Analytics ### Query Events by User / Contact ```typescript const events = await comms.queryByUser('collectionId', { userId: 'user_123', limit: 100 }) ``` ### Recipient IDs for a Source ```typescript const ids = await comms.queryRecipientIds('collectionId', { broadcastId: 'br_456' }) ``` ### Recipients Without an Action ```typescript const withoutOpen = await comms.queryRecipientsWithoutAction('collectionId', { broadcastId: 'br_456', actionId: 'open' }) ``` ### Recipients With an Action ```typescript const withClick = await comms.queryRecipientsWithAction('collectionId', { broadcastId: 'br_456', actionId: 'click', includeOutcome: true // returns RecipientWithOutcome[] instead of RecipientId[] }) ``` ### Log Events ```typescript // Single event await comms.logCommunicationEvent('collectionId', { eventType: 'opened', channel: 'email', contactId: 'contact_123' }) // Bulk events (same params applied to all IDs) await comms.logBulkCommunicationEvents('collectionId', { params: { broadcastId: 'br_456', channel: 'email', eventType: 'delivered' }, ids: ['contact_1', 'contact_2', 'contact_3'], idField: 'contactId' }) ``` --- ## End-to-End Admin Workflow A typical sequence from setup to delivery: ```typescript import { comms, broadcasts } from '@proveanything/smartlinks' // 1. Configure topics and unsubscribe rules await comms.patchSettings('collectionId', { unsub: { requireToken: true, secret: process.env.UNSUB_SECRET }, topics: { newsletter: { label: 'Newsletter', classification: 'marketing', defaults: { policy: 'opt-in', channels: { email: true, push: true } }, rules: { allowChannels: ['email', 'push', 'sms'], allowUnsubscribe: true } } } }) // 2. Register contact methods (done at contact creation / profile update) await comms.registerEmail('collectionId', { contactId: 'c_123', email: 'user@example.com' }) // 3. Record consent (done when user confirms opt-in) await comms.upsertConsent('collectionId', { contactId: 'c_123', channels: { email: true }, topics: { newsletter: true } }) // 4a. Transactional: send a one-off triggered message const result = await comms.sendTransactional('collectionId', { contactId: 'c_123', templateId: 'order-confirmed', channel: 'email', props: { orderId: 'ORD-001' } }) // 4b. Broadcast: preview test enqueue const preview = await broadcasts.preview('collectionId', 'br_spring', { channelOverride: 'email', hydrate: true, include: { product: true } }) await broadcasts.sendTest('collectionId', 'br_spring', { contactId: 'c_123' }) await broadcasts.send('collectionId', 'br_spring', { pageSize: 200, hydrate: true }) ``` --- ## Prerequisites | Feature | Requirement | |---------|-------------| | Email | SendGrid credentials (env or collection comms settings) | | Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` env vars | | SMS | Twilio credentials (env or collection comms settings) | | Wallet | Google Wallet issuer + service account | | Hydration | Set `hydrate: true` and populate `include` on broadcast/transactional sends |