@proveanything/smartlinks 2.1.12 → 2.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,540 +1,545 @@
1
- # Interactions: Event Tracking & Analytics
2
-
3
- The `interactions` namespace is a **critical pattern** for tracking user engagement. Many apps rely heavily on it for logging events that can trigger journeys, feed analytics dashboards, and drive aggregated results such as vote counts.
4
-
5
- If you want generic page analytics, outbound click tracking, QR landing telemetry, or tag scan dashboards, use [analytics.md](analytics.md) instead. `interactions` is for structured business events and outcomes, not general web analytics.
6
-
7
- ---
8
-
9
- ## Overview
10
-
11
- Interactions have two distinct layers:
12
-
13
- | Layer | Purpose |
14
- |-------|---------|
15
- | **Interaction Types** | Definitions stored in the database, once per collection: permissions, display metadata, effects. **The server mints each type's `id` (a UUID).** |
16
- | **Interaction Events** | Individual event records logged each time a user performs that interaction. Each event's `interactionId` is a type's minted `id`. |
17
-
18
- ## The one rule: create in admin → store the id in config → read it everywhere
19
-
20
- **You never choose an interaction id, and you never write one as a string literal.** You choose a readable *key* (e.g. `'vote'`); the server mints the *id* (e.g. `'52bab6fa-…'`). Events must carry the id.
21
-
22
- ```typescript
23
- // 1. ADMIN / setup screen — find-or-create the type by your key, then store its minted id in app config.
24
- const vote = await SL.interactions.ensureType(collectionId, {
25
- appId: 'my-app',
26
- key: 'vote', // your readable name — NOT the id
27
- permissions: { allowPublicSubmit: true, uniquePerUser: true },
28
- display: { title: 'Vote' },
29
- });
30
- const config = await SL.appConfiguration.getConfig({ collectionId, appId: 'my-app', admin: true });
31
- await SL.appConfiguration.setConfig({
32
- collectionId, appId: 'my-app', admin: true,
33
- config: { ...config, interactionIds: { ...config?.interactionIds, vote: vote.id } },
34
- });
35
-
36
- // 2. ANYWHERE ELSE (public widget, container, server function) — read the id from config.
37
- const { interactionIds } = await SL.appConfiguration.getConfig({ collectionId, appId: 'my-app' });
38
- await SL.interactions.submitPublicEvent(collectionId, {
39
- appId: 'my-app',
40
- interactionId: interactionIds.vote, // the minted id, from config
41
- outcome: 'option-a',
42
- });
43
- ```
44
-
45
- Why it's split this way:
46
-
47
- - **Types can only be created on the admin surface.** Public code (widgets, the portal) can submit events but can't create types — so the public side can only learn the id from somewhere admin put it. App config is that place.
48
- - **`ensureType` is safe to re-run.** It returns the existing type when one with that `key` exists for your app, so run it every time your setup screen saves.
49
- - **Unknown ids are configuration errors.** If `interactionIds.vote` is missing, the app hasn't been set up in this collection: show "not configured", don't submit, and never fall back to a literal like `'vote'`. (`interactions.create()` exists too, but it ignores any `id` you pass — read `id` from the record it returns.)
50
-
51
- The examples below use `interactionIds` — the map you read from config in step 2.
52
-
53
- ---
54
-
55
- ## Common Use Cases
56
-
57
- | Use Case | type `key` (for `ensureType`) | `outcome` example |
58
- |----------|-------------------------------|-------------------|
59
- | Competition entry | `competition-entry` | `"entered"` |
60
- | Voting / polling | `vote` | `"option-a"` |
61
- | Mailing list signup | `newsletter-signup` | `"subscribed"` |
62
- | Warranty registration | `warranty-registration` | `"activated"` |
63
- | Product scan / view | `product-view` | `"scanned"` |
64
- | Form submission | `registration-form` | `"submitted"` |
65
-
66
- ---
67
-
68
- ## Interaction Types (Definitions)
69
-
70
- Interaction types are defined once per collection and control permissions, display metadata, and uniqueness constraints.
71
-
72
- ### Create a Type (find-or-create)
73
-
74
- Admin only. Returns the type record; its `id` is the minted UUID — store it in app config (see [the one rule](#the-one-rule-create-in-admin--store-the-id-in-config--read-it-everywhere)).
75
-
76
- ```typescript
77
- const vote = await SL.interactions.ensureType(collectionId, {
78
- appId: 'my-app',
79
- key: 'vote', // stored as data.interactionType; how ensureType finds it again
80
- permissions: {
81
- allowPublicSubmit: true,
82
- uniquePerUser: true,
83
- startAt: '2026-06-01T00:00:00Z',
84
- endAt: '2026-06-30T23:59:59Z',
85
- allowPublicSummary: true,
86
- },
87
- display: {
88
- title: 'Vote',
89
- description: 'Cast your vote for the competition.',
90
- },
91
- });
92
- // vote.id → '52bab6fa-d868-4616-9342-6731b0f332b2'
93
- ```
94
-
95
- ### Update / Delete a Type
96
-
97
- ```typescript
98
- // Update permissions or display
99
- await SL.interactions.update(collectionId, interactionIds.vote, {
100
- permissions: { endAt: '2026-07-15T23:59:59Z' },
101
- });
102
-
103
- // Delete the definition (does not delete existing events) — and remove it from your config
104
- await SL.interactions.remove(collectionId, interactionIds.vote);
105
- ```
106
-
107
- ### List / Get Types
108
-
109
- ```typescript
110
- // Admin: list all types for an app (each has data.interactionType = its key)
111
- const { items } = await SL.interactions.list(collectionId, { appId: 'my-app' });
112
-
113
- // Admin: get a single type
114
- const type = await SL.interactions.get(collectionId, interactionIds.vote);
115
-
116
- // Public: list available types (respects permissions)
117
- const { items } = await SL.interactions.publicList(collectionId, { appId: 'my-app' });
118
- ```
119
-
120
- ---
121
-
122
- ## Logging Events
123
-
124
- Every `interactionId` below is a minted type id read from your app config (`interactionIds`, see [the one rule](#the-one-rule-create-in-admin--store-the-id-in-config--read-it-everywhere)). Events never create types: an id that isn't a type defined for this collection and app is a configuration error.
125
-
126
- ### Admin Event Append
127
-
128
- Use on the server side or in admin flows. Requires `userId` **or** `contactId`.
129
-
130
- ```typescript
131
- await SL.interactions.appendEvent(collectionId, {
132
- appId: 'my-app',
133
- interactionId: interactionIds.vote,
134
- outcome: 'option-a', // The result / choice — used by countsByOutcome()
135
- userId: 'user_abc123', // One of userId or contactId is required
136
- productId: 'prod_xyz', // Optional — scope to a specific product
137
- scope: 'round-1', // Optional — custom segmentation string
138
- metadata: { source: 'mobile', region: 'UK' },
139
- });
140
- ```
141
-
142
- ### Public Event Submit
143
-
144
- Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
145
-
146
- ```typescript
147
- // Authenticated submission
148
- await SL.interactions.submitPublicEvent(collectionId, {
149
- appId: 'my-app',
150
- interactionId: interactionIds.competitionEntry,
151
- outcome: 'entered',
152
- contactId: currentUser.contactId,
153
- metadata: { answer: 'Paris' },
154
- });
155
-
156
- // Anonymous submission (interaction must have allowAnonymousSubmit: true)
157
- const response = await SL.interactions.submitPublicEvent(collectionId, {
158
- appId: 'my-app',
159
- interactionId: interactionIds.npsScore,
160
- outcome: '9',
161
- metadata: {
162
- anonId: SL.utils.getAnonId(), // device-level dedup signal
163
- },
164
- });
165
-
166
- if (!response.success) {
167
- if (response.reason === 'duplicate_anon') {
168
- // this device has already submitted
169
- }
170
- }
171
- ```
172
-
173
- > **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
174
-
175
- ### Update an Existing Event
176
-
177
- ```typescript
178
- await SL.interactions.updateEvent(collectionId, {
179
- eventId: 'evt_abc123', // Required — the event to update
180
- interactionId: interactionIds.vote,
181
- userId: 'user_abc123',
182
- outcome: 'option-b', // Override the outcome
183
- status: 'deleted', // Soft-delete the event
184
- });
185
- ```
186
-
187
- ### Event Body Fields
188
-
189
- | Field | Type | Required | Description |
190
- |-------|------|----------|-------------|
191
- | `interactionId` | string | ✅ | The type's minted `id` (from `ensureType`, stored in app config) — never a literal you made up |
192
- | `userId` or `contactId` | string | ✅ (one of) | The actor. `appendEvent` / `updateEvent` require one of these |
193
- | `appId` | string | ❌ | Scopes the event to your app |
194
- | `outcome` | string | ❌ | The result or choice — what `countsByOutcome()` aggregates |
195
- | `scope` | string | ❌ | Custom segmentation (e.g., `"round-1"`, `"region-uk"`) |
196
- | `productId` | string | ❌ | Scope to a product |
197
- | `proofId` | string | ❌ | Scope to a proof |
198
- | `broadcastId` | string | ❌ | Links the event to a broadcast campaign |
199
- | `journeyId` | string | ❌ | Links the event to a journey run |
200
- | `metadata` | object | ❌ | Arbitrary extra data stored with the event |
201
- | `timestamp` | string | ❌ | ISO datetime override (defaults to server time) |
202
- | `source` | string | ❌ | Free-text source tag (e.g., `"mobile"`, `"email-link"`) |
203
-
204
- ---
205
-
206
- ## Reading Results
207
-
208
- ### Counts by Outcome (Aggregations)
209
-
210
- The primary analytics function — returns how many times each outcome was recorded:
211
-
212
- ```typescript
213
- // Admin (full access, deduplication options)
214
- const results = await SL.interactions.countsByOutcome(collectionId, {
215
- appId: 'my-app',
216
- interactionId: interactionIds.vote,
217
- scope: 'round-1', // Optional — filter by scope
218
- from: '2026-06-01', // Optional — date range
219
- to: '2026-06-30',
220
- dedupeLatest: true, // Count only the latest event per user (for re-votes)
221
- });
222
- // Returns: [{ outcome: 'option-a', count: 42 }, { outcome: 'option-b', count: 37 }]
223
-
224
- // Public (respects allowPublicSummary permission)
225
- const results = await SL.interactions.publicCountsByOutcome(
226
- collectionId,
227
- { appId: 'my-app', interactionId: interactionIds.vote },
228
- authToken // Optional — pass if user is authenticated
229
- );
230
- ```
231
-
232
- ### Query Event History
233
-
234
- Flexible admin query for raw interaction events:
235
-
236
- ```typescript
237
- const events = await SL.interactions.query(collectionId, {
238
- appId: 'my-app',
239
- interactionId: interactionIds.vote,
240
- userId: 'user_abc123', // Filter by user
241
- outcome: 'option-a', // Filter by outcome
242
- from: '2026-06-01T00:00Z',
243
- to: '2026-06-30T23:59Z',
244
- limit: 100,
245
- order: 'DESC',
246
- latestPerEventId: true, // Deduplicate: one row per interactionId per user
247
- include: ['interaction'], // Embed the interaction type definition in each row
248
- });
249
- ```
250
-
251
- ### Public: User's Own History
252
-
253
- Lets authenticated users see their own events:
254
-
255
- ```typescript
256
- const myEvents = await SL.interactions.publicMyInteractions(
257
- collectionId,
258
- { appId: 'my-app', interactionId: interactionIds.vote },
259
- authToken
260
- );
261
- ```
262
-
263
- ---
264
-
265
- ## Permissions Reference
266
-
267
- Set on the interaction type definition via `permissions`:
268
-
269
- | Permission | Type | Description |
270
- |------------|------|-------------|
271
- | `enabled` | boolean | Master on/off switch for submissions (default: enabled) |
272
- | `allowPublicSubmit` | boolean | Allow unauthenticated / public submissions |
273
- | `allowAnonymousSubmit` | boolean | Allow submissions without any session |
274
- | `requireAuth` | boolean | Block submissions unless user is authenticated |
275
- | `allowedOrigins` | string[] | Restrict to specific site domains (substring match) |
276
- | `startAt` | string (ISO) | Earliest time submissions are accepted |
277
- | `endAt` | string (ISO) | Latest time submissions are accepted |
278
- | `uniquePerUser` | boolean | Prevent duplicate submissions per user |
279
- | `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
280
- | `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
281
- | `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
282
- | `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
283
- | `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
284
- | `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
285
- | `allowOwnRead` | boolean | Let users read their own event history via public API |
286
-
287
- ---
288
-
289
- ## Integration with Journeys
290
-
291
- Interactions are the primary bridge between user actions and automated workflows:
292
-
293
- ```text
294
- User submits interaction event
295
- ↓
296
- Platform emits event to Journey trigger
297
- ↓
298
- Journey step runs: send confirmation email, update CRM, award points, etc.
299
- ```
300
-
301
- When defining a journey trigger, reference the interaction type (its minted `id`) that should fire it. The interaction `outcome` and `metadata` are available as variables in journey steps.
302
-
303
- ---
304
-
305
- ## Interaction Effects
306
-
307
- **Interaction effects** are automatic side-effects that fire immediately after an interaction event is recorded. They are defined directly on the interaction definition (`data.effects`) and run inline, fire-and-forget — they never block or fail the interaction response.
308
-
309
- Common use cases: send a confirmation email, post a webhook to a CRM, tag the contact, award loyalty points, create an app record, or add the contact to a segment.
310
-
311
- ### How It Works
312
-
313
- 1. Event is logged to BigQuery
314
- 2. Interaction definition is loaded
315
- 3. Each configured effect runs **in order**, fire-and-forget
316
- 4. Response is returned to the caller immediately
317
-
318
- A failure in one effect is logged and swallowed; subsequent effects still run.
319
-
320
- > **Note:** `transactional` and `webhook` effects currently run inline. They are planned to move to background jobs for retry-on-failure support once the jobs infrastructure is fully operational.
321
-
322
- ### Configuring Effects
323
-
324
- Pass `data.effects` when creating or updating an interaction type:
325
-
326
- ```typescript
327
- await SL.interactions.ensureType(collectionId, {
328
- appId: 'my-app',
329
- key: 'donation',
330
- permissions: { allowPublicSubmit: true },
331
- data: {
332
- effects: [
333
- {
334
- type: 'transactional',
335
- config: {
336
- templateId: 'tmpl_donation_receipt',
337
- channel: 'email',
338
- props: {
339
- donationAmount: '{{metadata.amount}}',
340
- campaignName: '{{metadata.campaign}}',
341
- },
342
- },
343
- },
344
- {
345
- type: 'tag',
346
- config: { tags: ['donor'] },
347
- },
348
- ],
349
- },
350
- });
351
- ```
352
-
353
- ### Token Interpolation
354
-
355
- All effect `config` values support `{{token}}` interpolation resolved from the event context at runtime. Nested paths using dot notation are supported (e.g. `{{metadata.amount}}`).
356
-
357
- | Token | Description |
358
- |---|---|
359
- | `{{eventId}}` | BigQuery event UUID |
360
- | `{{collectionId}}` | Collection / brand ID |
361
- | `{{appId}}` | App ID that submitted the event |
362
- | `{{interactionId}}` | Interaction type id (the minted UUID) |
363
- | `{{contactId}}` | Contact UUID (if resolved) |
364
- | `{{userId}}` | Firebase UID (if authenticated) |
365
- | `{{productId}}` | Product ID (if provided with event) |
366
- | `{{proofId}}` | Proof ID (if provided with event) |
367
- | `{{variantId}}` | Variant ID (if provided with event) |
368
- | `{{batchId}}` | Batch ID (if provided with event) |
369
- | `{{outcome}}` | Event outcome (e.g. `"submitted"`) |
370
- | `{{eventType}}` | Event type string |
371
- | `{{source}}` | Event source string |
372
- | `{{timestamp}}` | ISO 8601 event timestamp |
373
- | `{{metadata.*}}` | Any key from the event `metadata` object |
374
-
375
- Tokens that resolve to `null` or `undefined` become an empty string.
376
-
377
- ### Effect Types
378
-
379
- #### `loyalty`
380
-
381
- Awards loyalty points by evaluating `LoyaltyEarningRule` records configured against this interaction. Earning rules are managed separately via the Loyalty API. If no rules are configured, this effect is a no-op.
382
-
383
- ```json
384
- { "type": "loyalty", "config": {} }
385
- ```
386
-
387
- #### `transactional`
388
-
389
- Sends a message to the contact using a comms template. Supports all channels with full Liquid template hydration.
390
-
391
- ```json
392
- {
393
- "type": "transactional",
394
- "config": {
395
- "templateId": "tmpl_donation_receipt",
396
- "channel": "email",
397
- "props": {
398
- "donationAmount": "{{metadata.amount}}"
399
- }
400
- }
401
- }
402
- ```
403
-
404
- `channel` defaults to `"preferred"` — auto-selects the contact's best available channel respecting consent, suppression, and template availability. Optionally pass `include` to hydrate product/proof/user context, and `props` for additional Liquid variables.
405
-
406
- #### `webhook`
407
-
408
- Posts a JSON payload to an HTTP endpoint. All config values support `{{token}}` interpolation.
409
-
410
- ```json
411
- {
412
- "type": "webhook",
413
- "config": {
414
- "url": "https://crm.example.com/api/events",
415
- "headers": { "Authorization": "Bearer sk_live_abc123" },
416
- "body": {
417
- "contactId": "{{contactId}}",
418
- "amount": "{{metadata.amount}}"
419
- },
420
- "timeout": 8000
421
- }
422
- }
423
- ```
424
-
425
- When `body` is omitted, a standard payload is sent containing `eventId`, `collectionId`, `appId`, `interactionId`, `contactId`, `userId`, `productId`, `proofId`, `outcome`, `eventType`, `metadata`, and `timestamp`.
426
-
427
- #### `tag`
428
-
429
- Adds or removes string tags on the contact record. Skipped if there is no `contactId` on the event.
430
-
431
- ```json
432
- {
433
- "type": "tag",
434
- "config": {
435
- "tags": ["donor", "tier-{{metadata.tier}}"],
436
- "action": "add"
437
- }
438
- }
439
- ```
440
-
441
- `action` defaults to `"add"`. Duplicate adds are no-ops; removing a tag not present is a no-op.
442
-
443
- #### `appRecord`
444
-
445
- Creates or upserts an app record scoped to the event context. Use `singletonPer` for cardinality control — if a matching singleton already exists it is updated rather than duplicated.
446
-
447
- ```json
448
- {
449
- "type": "appRecord",
450
- "config": {
451
- "recordType": "participation",
452
- "singletonPer": "contact",
453
- "data": {
454
- "participatedAt": "{{timestamp}}",
455
- "outcome": "{{outcome}}"
456
- }
457
- }
458
- }
459
- ```
460
-
461
- Common `singletonPer` values: `"contact"`, `"product"`, `"proof"`, `"global"`.
462
-
463
- #### `segment`
464
-
465
- Adds or removes the contact from a **static** segment. Skipped if there is no `contactId` on the event.
466
-
467
- ```json
468
- {
469
- "type": "segment",
470
- "config": {
471
- "segmentId": "seg_donors_2026",
472
- "action": "add"
473
- }
474
- }
475
- ```
476
-
477
- The segment must already exist and be of `filterType: 'static'`.
478
-
479
- ### Effects Error Handling
480
-
481
- | Scenario | Behaviour |
482
- |---|---|
483
- | Effect throws (e.g. template not found, webhook 500) | Error logged server-side; next effect continues |
484
- | Contact has no email, `transactional` channel = `email` | Effect throws, logged, skipped |
485
- | `contactId` absent on anonymous event | `tag`, `appRecord`, `segment` log a warning and skip |
486
- | Unknown `type` value | Warning logged, effect skipped |
487
- | `loyalty` — no earning rules configured | Silent no-op |
488
- | Interaction has no `data.effects` | No effects run, no overhead |
489
-
490
- Effects do **not** propagate errors back to the API caller.
491
-
492
- ---
493
-
494
- ## TypeScript Types
495
-
496
- ```typescript
497
- import type {
498
- AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
499
- UpdateInteractionBody, // Event body for updateEvent
500
- SubmitInteractionResponse, // { success: true; eventId: string }
501
- SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
502
- InteractionEventRow, // Raw event record returned by query()
503
- OutcomeCount, // { outcome: string | null; count: number }
504
- InteractionPermissions, // Full permissions config shape
505
- InteractionTypeRecord, // Definition record from create() / get()
506
- InteractionTypeList, // { items, limit, offset }
507
- CreateInteractionTypeBody, // Body for create() — the server mints the id
508
- EnsureInteractionTypeInput, // Input for ensureType() — { appId, key, permissions?, display?, data? }
509
- UpdateInteractionTypeBody, // Body for update()
510
- AdminInteractionsQueryRequest, // query() filter options
511
- AdminInteractionsCountsByOutcomeRequest,
512
- PublicInteractionsCountsByOutcomeRequest,
513
- PublicInteractionsByUserRequest,
514
- // Effects
515
- InteractionEffect, // Single effect entry { type, config? }
516
- EffectType, // 'loyalty' | 'transactional' | 'webhook' | 'tag' | 'appRecord' | 'segment'
517
- EffectConfig, // Union of all effect config shapes
518
- LoyaltyEffectConfig,
519
- TransactionalEffectConfig,
520
- WebhookEffectConfig,
521
- TagEffectConfig,
522
- AppRecordEffectConfig,
523
- SegmentEffectConfig,
524
- InteractionEventContext, // Token interpolation context shape
525
- } from '@proveanything/smartlinks';
526
- ```
527
-
528
- ---
529
-
530
- ## Best Practices
531
-
532
- - Never hardcode an `interactionId`: create types with `ensureType` in admin, keep their ids in app config (`interactionIds.<key>`), read them everywhere else
533
- - Use descriptive type keys: `warranty-registration`, `competition-entry`, `newsletter-vote`
534
- - Use `outcome` to capture the choice — it's the key field that `countsByOutcome()` aggregates on
535
- - Include `metadata` for richer analytics and debugging (`source`, `device`, `region`, etc.)
536
- - Use `uniquePerUser: true` for actions that should only happen once (votes, registrations)
537
- - Set `startAt`/`endAt` on the type definition — don't enforce time limits in app code
538
- - Use `scope` to segment a single interaction type across multiple rounds, regions, or variants
539
- - Prefer `submitPublicEvent` in client-side widget code; use `appendEvent` in server-side / admin flows
540
- - Keep `getSEO()` and `getLLMContent()` calls separate from interaction submission paths
1
+ # Interactions: Event Tracking & Analytics
2
+
3
+ The `interactions` namespace is a **critical pattern** for tracking user engagement. Many apps rely heavily on it for logging events that can trigger journeys, feed analytics dashboards, and drive aggregated results such as vote counts.
4
+
5
+ If you want generic page analytics, outbound click tracking, QR landing telemetry, or tag scan dashboards, use [analytics.md](analytics.md) instead. `interactions` is for structured business events and outcomes, not general web analytics.
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ Interactions have two distinct layers:
12
+
13
+ | Layer | Purpose |
14
+ |-------|---------|
15
+ | **Interaction Types** | Definitions stored in the database, once per collection: permissions, display metadata, effects. **The server mints each type's `id` (a UUID).** |
16
+ | **Interaction Events** | Individual event records logged each time a user performs that interaction. Each event's `interactionId` is a type's minted `id`. |
17
+
18
+ ## The one rule: create in admin → store the id in config → read it everywhere
19
+
20
+ **You never choose an interaction id, and you never write one as a string literal.** You choose a readable *key* (e.g. `'vote'`); the server mints the *id* (e.g. `'52bab6fa-…'`). Events must carry the id.
21
+
22
+ ```typescript
23
+ // 1. ADMIN / setup screen — find-or-create the type by your key, then store its minted id in app config.
24
+ const vote = await SL.interactions.ensureType(collectionId, {
25
+ appId: 'my-app',
26
+ key: 'vote', // your readable name — NOT the id
27
+ permissions: { allowPublicSubmit: true, uniquePerUser: true },
28
+ display: { title: 'Vote' },
29
+ });
30
+ const config = await SL.appConfiguration.getConfig({ collectionId, appId: 'my-app', admin: true });
31
+ await SL.appConfiguration.setConfig({
32
+ collectionId, appId: 'my-app', admin: true,
33
+ config: { ...config, interactionIds: { ...config?.interactionIds, vote: vote.id } },
34
+ });
35
+
36
+ // 2. ANYWHERE ELSE (public widget, container, server function) — read the id from config.
37
+ const { interactionIds } = await SL.appConfiguration.getConfig({ collectionId, appId: 'my-app' });
38
+ await SL.interactions.submitPublicEvent(collectionId, {
39
+ appId: 'my-app',
40
+ interactionId: interactionIds.vote, // the minted id, from config
41
+ outcome: 'option-a',
42
+ });
43
+ ```
44
+
45
+ Why it's split this way:
46
+
47
+ - **Types can only be created on the admin surface.** Public code (widgets, the portal) can submit events but can't create types — so the public side can only learn the id from somewhere admin put it. App config is that place.
48
+ - **`ensureType` is safe to re-run.** It returns the existing type when one with that `key` exists for your app, so run it every time your setup screen saves.
49
+ - **Unknown ids are configuration errors.** If `interactionIds.vote` is missing, the app hasn't been set up in this collection: show "not configured", don't submit, and never fall back to a literal like `'vote'`. (`interactions.create()` exists too, but it ignores any `id` you pass — read `id` from the record it returns.)
50
+
51
+ The examples below use `interactionIds` — the map you read from config in step 2.
52
+
53
+ ---
54
+
55
+ ## Common Use Cases
56
+
57
+ | Use Case | type `key` (for `ensureType`) | `outcome` example |
58
+ |----------|-------------------------------|-------------------|
59
+ | Competition entry | `competition-entry` | `"entered"` |
60
+ | Voting / polling | `vote` | `"option-a"` |
61
+ | Mailing list signup | `newsletter-signup` | `"subscribed"` |
62
+ | Warranty registration | `warranty-registration` | `"activated"` |
63
+ | Product scan / view | `product-view` | `"scanned"` |
64
+ | Form submission | `registration-form` | `"submitted"` |
65
+
66
+ ---
67
+
68
+ ## Interaction Types (Definitions)
69
+
70
+ Interaction types are defined once per collection and control permissions, display metadata, and uniqueness constraints.
71
+
72
+ ### Create a Type (find-or-create)
73
+
74
+ Admin only. Returns the type record; its `id` is the minted UUID — store it in app config (see [the one rule](#the-one-rule-create-in-admin--store-the-id-in-config--read-it-everywhere)).
75
+
76
+ ```typescript
77
+ const vote = await SL.interactions.ensureType(collectionId, {
78
+ appId: 'my-app',
79
+ key: 'vote', // stored as data.interactionType; how ensureType finds it again
80
+ permissions: {
81
+ allowPublicSubmit: true,
82
+ uniquePerUser: true,
83
+ startAt: '2026-06-01T00:00:00Z',
84
+ endAt: '2026-06-30T23:59:59Z',
85
+ allowPublicSummary: true,
86
+ },
87
+ display: {
88
+ title: 'Vote',
89
+ description: 'Cast your vote for the competition.',
90
+ },
91
+ });
92
+ // vote.id → '52bab6fa-d868-4616-9342-6731b0f332b2'
93
+ ```
94
+
95
+ ### Update / Delete a Type
96
+
97
+ ```typescript
98
+ // Update permissions or display
99
+ await SL.interactions.update(collectionId, interactionIds.vote, {
100
+ permissions: { endAt: '2026-07-15T23:59:59Z' },
101
+ });
102
+
103
+ // Delete the definition (does not delete existing events) — and remove it from your config
104
+ await SL.interactions.remove(collectionId, interactionIds.vote);
105
+ ```
106
+
107
+ ### List / Get Types
108
+
109
+ ```typescript
110
+ // Admin: list all types for an app (each has data.interactionType = its key)
111
+ const { items } = await SL.interactions.list(collectionId, { appId: 'my-app' });
112
+
113
+ // Admin: get a single type
114
+ const type = await SL.interactions.get(collectionId, interactionIds.vote);
115
+
116
+ // Public: list available types (respects permissions)
117
+ const { items } = await SL.interactions.publicList(collectionId, { appId: 'my-app' });
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Logging Events
123
+
124
+ Every `interactionId` below is a minted type id read from your app config (`interactionIds`, see [the one rule](#the-one-rule-create-in-admin--store-the-id-in-config--read-it-everywhere)). Events never create types: an id that isn't a type defined for this collection and app is a configuration error.
125
+
126
+ ### Admin Event Append
127
+
128
+ Use on the server side or in admin flows. Requires `userId` **or** `contactId`.
129
+
130
+ ```typescript
131
+ await SL.interactions.appendEvent(collectionId, {
132
+ appId: 'my-app',
133
+ interactionId: interactionIds.vote,
134
+ outcome: 'option-a', // The result / choice — used by countsByOutcome()
135
+ userId: 'user_abc123', // One of userId or contactId is required
136
+ productId: 'prod_xyz', // Optional — scope to a specific product
137
+ scope: 'round-1', // Optional — custom segmentation string
138
+ metadata: { source: 'mobile', region: 'UK' },
139
+ });
140
+ ```
141
+
142
+ ### Public Event Submit
143
+
144
+ Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
145
+
146
+ ```typescript
147
+ // Authenticated submission
148
+ await SL.interactions.submitPublicEvent(collectionId, {
149
+ appId: 'my-app',
150
+ interactionId: interactionIds.competitionEntry,
151
+ outcome: 'entered',
152
+ contactId: currentUser.contactId,
153
+ metadata: { answer: 'Paris' },
154
+ });
155
+
156
+ // Anonymous submission (interaction must have allowAnonymousSubmit: true)
157
+ const response = await SL.interactions.submitPublicEvent(collectionId, {
158
+ appId: 'my-app',
159
+ interactionId: interactionIds.npsScore,
160
+ outcome: '9',
161
+ metadata: {
162
+ anonId: SL.utils.getAnonId(), // device-level dedup signal
163
+ },
164
+ });
165
+
166
+ if (!response.success) {
167
+ if (response.reason === 'duplicate_anon') {
168
+ // this device has already submitted
169
+ }
170
+ }
171
+ ```
172
+
173
+ > **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
174
+
175
+ ### Update an Existing Event
176
+
177
+ ```typescript
178
+ await SL.interactions.updateEvent(collectionId, {
179
+ eventId: 'evt_abc123', // Required — the event to update
180
+ interactionId: interactionIds.vote,
181
+ userId: 'user_abc123',
182
+ outcome: 'option-b', // Override the outcome
183
+ status: 'deleted', // Soft-delete the event
184
+ });
185
+ ```
186
+
187
+ ### Event Body Fields
188
+
189
+ | Field | Type | Required | Description |
190
+ |-------|------|----------|-------------|
191
+ | `interactionId` | string | ✅ | The type's minted `id` (from `ensureType`, stored in app config) — never a literal you made up |
192
+ | `userId` or `contactId` | string | ✅ (one of) | The actor. `appendEvent` / `updateEvent` require one of these |
193
+ | `appId` | string | ❌ | Scopes the event to your app |
194
+ | `outcome` | string | ❌ | The result or choice — what `countsByOutcome()` aggregates |
195
+ | `scope` | string | ❌ | Custom segmentation (e.g., `"round-1"`, `"region-uk"`) |
196
+ | `productId` | string | ❌ | Scope to a product |
197
+ | `proofId` | string | ❌ | Scope to a proof |
198
+ | `broadcastId` | string | ❌ | Links the event to a broadcast campaign |
199
+ | `journeyId` | string | ❌ | Links the event to a journey run |
200
+ | `metadata` | object | ❌ | Arbitrary extra data stored with the event |
201
+ | `timestamp` | string | ❌ | ISO datetime override (defaults to server time) |
202
+ | `source` | string | ❌ | Free-text source tag (e.g., `"mobile"`, `"email-link"`) |
203
+
204
+ ---
205
+
206
+ ## Reading Results
207
+
208
+ ### Counts by Outcome (Aggregations)
209
+
210
+ The primary analytics function — returns how many times each outcome was recorded:
211
+
212
+ ```typescript
213
+ // Admin (full access, deduplication options)
214
+ const results = await SL.interactions.countsByOutcome(collectionId, {
215
+ appId: 'my-app',
216
+ interactionId: interactionIds.vote,
217
+ scope: 'round-1', // Optional — filter by scope
218
+ from: '2026-06-01', // Optional — date range
219
+ to: '2026-06-30',
220
+ dedupeLatest: true, // Count only the latest event per user (for re-votes)
221
+ });
222
+ // Returns: [{ outcome: 'option-a', count: 42 }, { outcome: 'option-b', count: 37 }]
223
+
224
+ // Public (respects allowPublicSummary permission)
225
+ const results = await SL.interactions.publicCountsByOutcome(
226
+ collectionId,
227
+ { appId: 'my-app', interactionId: interactionIds.vote },
228
+ authToken // Optional — pass if user is authenticated
229
+ );
230
+ ```
231
+
232
+ ### Query Event History
233
+
234
+ Flexible admin query for raw interaction events:
235
+
236
+ ```typescript
237
+ const events = await SL.interactions.query(collectionId, {
238
+ appId: 'my-app',
239
+ interactionId: interactionIds.vote,
240
+ userId: 'user_abc123', // Filter by user
241
+ outcome: 'option-a', // Filter by outcome
242
+ from: '2026-06-01T00:00Z',
243
+ to: '2026-06-30T23:59Z',
244
+ limit: 100,
245
+ order: 'DESC',
246
+ latestPerEventId: true, // Deduplicate: one row per interactionId per user
247
+ include: ['interaction'], // Embed the interaction type definition in each row
248
+ });
249
+ ```
250
+
251
+ ### Public: User's Own History
252
+
253
+ Lets authenticated users see their own events:
254
+
255
+ ```typescript
256
+ const myEvents = await SL.interactions.publicMyInteractions(
257
+ collectionId,
258
+ { appId: 'my-app', interactionId: interactionIds.vote },
259
+ authToken
260
+ );
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Permissions Reference
266
+
267
+ Set on the interaction type definition via `permissions`:
268
+
269
+ | Permission | Type | Description |
270
+ |------------|------|-------------|
271
+ | `enabled` | boolean | Master on/off switch for submissions (default: enabled) |
272
+ | `allowPublicSubmit` | boolean | Allow unauthenticated / public submissions |
273
+ | `allowAnonymousSubmit` | boolean | Allow submissions without any session |
274
+ | `requireAuth` | boolean | Block submissions unless user is authenticated |
275
+ | `allowedOrigins` | string[] | Restrict to specific site domains (substring match) |
276
+ | `startAt` | string (ISO) | Earliest time submissions are accepted |
277
+ | `endAt` | string (ISO) | Latest time submissions are accepted |
278
+ | `uniquePerUser` | boolean | Prevent duplicate submissions per user |
279
+ | `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
280
+ | `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
281
+ | `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
282
+ | `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
283
+ | `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
284
+ | `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
285
+ | `allowOwnRead` | boolean | Let users read their own event history via public API |
286
+ | `requires` | `{ entitlement, fallback? }` | Gate submissions on a membership check: `entitlement` is a predicate such as `'tier>=gold'`, `'member'`, `{ points: { gte: 500 } }`; `fallback` is `deny` (default), `allow` or `degrade` when no source can answer. Answered on demand from the collection's configured sources (a signed claim the caller presents as `entitlementToken` / `X-SmartLinks-Entitlement`, the loyalty connector's cached `lookup`, the contact's own loyalty fields) — see [entitlements.md](entitlements.md). A refused submit answers `403 { reason: 'entitlement_denied' }`. |
287
+
288
+ ---
289
+
290
+ ## Integration with Journeys
291
+
292
+ Interactions are the primary bridge between user actions and automated workflows:
293
+
294
+ ```text
295
+ User submits interaction event
296
+ ↓
297
+ Platform emits event to Journey trigger
298
+ ↓
299
+ Journey step runs: send confirmation email, update CRM, award points, etc.
300
+ ```
301
+
302
+ When defining a journey trigger, reference the interaction type (its minted `id`) that should fire it. The interaction `outcome` and `metadata` are available as variables in journey steps.
303
+
304
+ ---
305
+
306
+ ## Interaction Effects
307
+
308
+ **Interaction effects** are automatic side-effects that fire immediately after an interaction event is recorded. They are defined directly on the interaction definition (`data.effects`) and run inline, fire-and-forget — they never block or fail the interaction response.
309
+
310
+ Common use cases: send a confirmation email, post a webhook to a CRM, tag the contact, award loyalty points, create an app record, or add the contact to a segment.
311
+
312
+ ### How It Works
313
+
314
+ 1. Event is logged to BigQuery
315
+ 2. Interaction definition is loaded
316
+ 3. Each configured effect runs **in order**, fire-and-forget
317
+ 4. Response is returned to the caller immediately
318
+
319
+ A failure in one effect is logged and swallowed; subsequent effects still run.
320
+
321
+ > **Note:** `transactional` and `webhook` effects currently run inline. They are planned to move to background jobs for retry-on-failure support once the jobs infrastructure is fully operational.
322
+
323
+ ### Configuring Effects
324
+
325
+ Pass `data.effects` when creating or updating an interaction type:
326
+
327
+ ```typescript
328
+ await SL.interactions.ensureType(collectionId, {
329
+ appId: 'my-app',
330
+ key: 'donation',
331
+ permissions: { allowPublicSubmit: true },
332
+ data: {
333
+ effects: [
334
+ {
335
+ type: 'transactional',
336
+ config: {
337
+ templateId: 'tmpl_donation_receipt',
338
+ channel: 'email',
339
+ props: {
340
+ donationAmount: '{{metadata.amount}}',
341
+ campaignName: '{{metadata.campaign}}',
342
+ },
343
+ },
344
+ },
345
+ {
346
+ type: 'tag',
347
+ config: { tags: ['donor'] },
348
+ },
349
+ ],
350
+ },
351
+ });
352
+ ```
353
+
354
+ ### Token Interpolation
355
+
356
+ All effect `config` values support `{{token}}` interpolation resolved from the event context at runtime. Nested paths using dot notation are supported (e.g. `{{metadata.amount}}`).
357
+
358
+ | Token | Description |
359
+ |---|---|
360
+ | `{{eventId}}` | BigQuery event UUID |
361
+ | `{{collectionId}}` | Collection / brand ID |
362
+ | `{{appId}}` | App ID that submitted the event |
363
+ | `{{interactionId}}` | Interaction type id (the minted UUID) |
364
+ | `{{contactId}}` | Contact UUID (if resolved) |
365
+ | `{{userId}}` | Firebase UID (if authenticated) |
366
+ | `{{productId}}` | Product ID (if provided with event) |
367
+ | `{{proofId}}` | Proof ID (if provided with event) |
368
+ | `{{variantId}}` | Variant ID (if provided with event) |
369
+ | `{{batchId}}` | Batch ID (if provided with event) |
370
+ | `{{outcome}}` | Event outcome (e.g. `"submitted"`) |
371
+ | `{{eventType}}` | Event type string |
372
+ | `{{source}}` | Event source string |
373
+ | `{{timestamp}}` | ISO 8601 event timestamp |
374
+ | `{{metadata.*}}` | Any key from the event `metadata` object |
375
+
376
+ Tokens that resolve to `null` or `undefined` become an empty string.
377
+
378
+ ### Effect Types
379
+
380
+ #### `loyalty`
381
+
382
+ Awards loyalty points by evaluating `LoyaltyEarningRule` records configured against this interaction. Earning rules are managed separately via the Loyalty API. If no rules are configured, this effect is a no-op.
383
+
384
+ ```json
385
+ { "type": "loyalty", "config": {} }
386
+ ```
387
+
388
+ #### `transactional`
389
+
390
+ Sends a message to the contact using a comms template. Supports all channels with full Liquid template hydration.
391
+
392
+ ```json
393
+ {
394
+ "type": "transactional",
395
+ "config": {
396
+ "templateId": "tmpl_donation_receipt",
397
+ "channel": "email",
398
+ "props": {
399
+ "donationAmount": "{{metadata.amount}}"
400
+ }
401
+ }
402
+ }
403
+ ```
404
+
405
+ `channel` defaults to `"preferred"` — auto-selects the contact's best available channel respecting consent, suppression, and template availability. Optionally pass `include` to hydrate product/proof/user context, and `props` for additional Liquid variables.
406
+
407
+ #### `webhook`
408
+
409
+ Posts a JSON payload to an HTTP endpoint. All config values support `{{token}}` interpolation.
410
+
411
+ ```json
412
+ {
413
+ "type": "webhook",
414
+ "config": {
415
+ "url": "https://crm.example.com/api/events",
416
+ "headers": { "Authorization": "Bearer sk_live_abc123" },
417
+ "body": {
418
+ "contactId": "{{contactId}}",
419
+ "amount": "{{metadata.amount}}"
420
+ },
421
+ "timeout": 8000
422
+ }
423
+ }
424
+ ```
425
+
426
+ When `body` is omitted, a standard payload is sent containing `eventId`, `collectionId`, `appId`, `interactionId`, `contactId`, `userId`, `productId`, `proofId`, `outcome`, `eventType`, `metadata`, `timestamp` — and `envelope`, the same [event envelope](integrations.md#event-sourced-flows-and-the-envelope) outbound flows send (identity under `config.identity.policy`, default `hashed`).
427
+
428
+ Two more config fields: `signing: { credentialRef, previousCredentialRef? }` adds `X-SmartLinks-Signature` over the body (see [Signed deliveries](integrations.md#signed-deliveries)), and `identity: { policy }` sets what the envelope carries about the person. The destination must be a public host.
429
+
430
+ The effect runs **once**, with no retry and no delivery log. For retries, a run log, rate limits and event mapping, use an outbound **flow** with `sourceEntity: 'interaction'` subscribed to `interaction.submitted:<typeId>` instead — same payload shape, production guarantees.
431
+
432
+ #### `tag`
433
+
434
+ Adds or removes string tags on the contact record. Skipped if there is no `contactId` on the event.
435
+
436
+ ```json
437
+ {
438
+ "type": "tag",
439
+ "config": {
440
+ "tags": ["donor", "tier-{{metadata.tier}}"],
441
+ "action": "add"
442
+ }
443
+ }
444
+ ```
445
+
446
+ `action` defaults to `"add"`. Duplicate adds are no-ops; removing a tag not present is a no-op.
447
+
448
+ #### `appRecord`
449
+
450
+ Creates or upserts an app record scoped to the event context. Use `singletonPer` for cardinality control — if a matching singleton already exists it is updated rather than duplicated.
451
+
452
+ ```json
453
+ {
454
+ "type": "appRecord",
455
+ "config": {
456
+ "recordType": "participation",
457
+ "singletonPer": "contact",
458
+ "data": {
459
+ "participatedAt": "{{timestamp}}",
460
+ "outcome": "{{outcome}}"
461
+ }
462
+ }
463
+ }
464
+ ```
465
+
466
+ Common `singletonPer` values: `"contact"`, `"product"`, `"proof"`, `"global"`.
467
+
468
+ #### `segment`
469
+
470
+ Adds or removes the contact from a **static** segment. Skipped if there is no `contactId` on the event.
471
+
472
+ ```json
473
+ {
474
+ "type": "segment",
475
+ "config": {
476
+ "segmentId": "seg_donors_2026",
477
+ "action": "add"
478
+ }
479
+ }
480
+ ```
481
+
482
+ The segment must already exist and be of `filterType: 'static'`.
483
+
484
+ ### Effects Error Handling
485
+
486
+ | Scenario | Behaviour |
487
+ |---|---|
488
+ | Effect throws (e.g. template not found, webhook 500) | Error logged server-side; next effect continues |
489
+ | Contact has no email, `transactional` channel = `email` | Effect throws, logged, skipped |
490
+ | `contactId` absent on anonymous event | `tag`, `appRecord`, `segment` log a warning and skip |
491
+ | Unknown `type` value | Warning logged, effect skipped |
492
+ | `loyalty` — no earning rules configured | Silent no-op |
493
+ | Interaction has no `data.effects` | No effects run, no overhead |
494
+
495
+ Effects do **not** propagate errors back to the API caller.
496
+
497
+ ---
498
+
499
+ ## TypeScript Types
500
+
501
+ ```typescript
502
+ import type {
503
+ AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
504
+ UpdateInteractionBody, // Event body for updateEvent
505
+ SubmitInteractionResponse, // { success: true; eventId: string }
506
+ SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
507
+ InteractionEventRow, // Raw event record returned by query()
508
+ OutcomeCount, // { outcome: string | null; count: number }
509
+ InteractionPermissions, // Full permissions config shape
510
+ InteractionTypeRecord, // Definition record from create() / get()
511
+ InteractionTypeList, // { items, limit, offset }
512
+ CreateInteractionTypeBody, // Body for create() — the server mints the id
513
+ EnsureInteractionTypeInput, // Input for ensureType() — { appId, key, permissions?, display?, data? }
514
+ UpdateInteractionTypeBody, // Body for update()
515
+ AdminInteractionsQueryRequest, // query() filter options
516
+ AdminInteractionsCountsByOutcomeRequest,
517
+ PublicInteractionsCountsByOutcomeRequest,
518
+ PublicInteractionsByUserRequest,
519
+ // Effects
520
+ InteractionEffect, // Single effect entry { type, config? }
521
+ EffectType, // 'loyalty' | 'transactional' | 'webhook' | 'tag' | 'appRecord' | 'segment'
522
+ EffectConfig, // Union of all effect config shapes
523
+ LoyaltyEffectConfig,
524
+ TransactionalEffectConfig,
525
+ WebhookEffectConfig,
526
+ TagEffectConfig,
527
+ AppRecordEffectConfig,
528
+ SegmentEffectConfig,
529
+ InteractionEventContext, // Token interpolation context shape
530
+ } from '@proveanything/smartlinks';
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Best Practices
536
+
537
+ - Never hardcode an `interactionId`: create types with `ensureType` in admin, keep their ids in app config (`interactionIds.<key>`), read them everywhere else
538
+ - Use descriptive type keys: `warranty-registration`, `competition-entry`, `newsletter-vote`
539
+ - Use `outcome` to capture the choice — it's the key field that `countsByOutcome()` aggregates on
540
+ - Include `metadata` for richer analytics and debugging (`source`, `device`, `region`, etc.)
541
+ - Use `uniquePerUser: true` for actions that should only happen once (votes, registrations)
542
+ - Set `startAt`/`endAt` on the type definition — don't enforce time limits in app code
543
+ - Use `scope` to segment a single interaction type across multiple rounds, regions, or variants
544
+ - Prefer `submitPublicEvent` in client-side widget code; use `appendEvent` in server-side / admin flows
545
+ - Keep `getSEO()` and `getLLMContent()` calls separate from interaction submission paths