@proveanything/smartlinks 2.1.13 → 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.
- package/dist/docs/API_SUMMARY.md +97 -7
- package/dist/docs/connectors.md +42 -4
- package/dist/docs/entitlements.md +90 -0
- package/dist/docs/integrations.md +159 -9
- package/dist/docs/interactions.md +545 -540
- package/dist/docs/loyalty.md +17 -0
- package/dist/docs/server-functions.md +2 -0
- package/dist/openapi.yaml +192 -2
- package/dist/types/appManifest.d.ts +19 -1
- package/dist/types/integrations.d.ts +148 -3
- package/docs/API_SUMMARY.md +97 -7
- package/docs/connectors.md +42 -4
- package/docs/entitlements.md +90 -0
- package/docs/integrations.md +159 -9
- package/docs/interactions.md +545 -540
- package/docs/loyalty.md +17 -0
- package/docs/server-functions.md +2 -0
- package/openapi.yaml +192 -2
- package/package.json +87 -87
package/docs/interactions.md
CHANGED
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
| `{{
|
|
361
|
-
| `{{
|
|
362
|
-
| `{{
|
|
363
|
-
| `{{
|
|
364
|
-
| `{{
|
|
365
|
-
| `{{
|
|
366
|
-
| `{{
|
|
367
|
-
| `{{
|
|
368
|
-
| `{{
|
|
369
|
-
| `{{
|
|
370
|
-
| `{{
|
|
371
|
-
| `{{
|
|
372
|
-
| `{{
|
|
373
|
-
| `{{
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
"
|
|
395
|
-
|
|
396
|
-
"
|
|
397
|
-
"
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
"
|
|
414
|
-
|
|
415
|
-
"
|
|
416
|
-
"
|
|
417
|
-
|
|
418
|
-
"
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
|
487
|
-
|
|
488
|
-
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
-
|
|
538
|
-
- Use
|
|
539
|
-
-
|
|
540
|
-
-
|
|
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
|