@alvera-ai/platform-sdk 0.19.2 → 0.20.0-next.gcd2f231
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/.agent/AGENTS.md +10 -8
- package/.agent/account_management.md +14 -13
- package/.agent/action_status_updaters.md +5 -4
- package/.agent/ai_sandbox.md +3 -3
- package/.agent/authenticated_links.md +247 -0
- package/.agent/cookbook/organic-marketing.md +220 -126
- package/.agent/cookbook/payments-compliance.md +108 -63
- package/.agent/cookbook/primary-care.md +91 -52
- package/.agent/cookbook/subscription-saas.md +130 -99
- package/.agent/data_activation_clients.md +107 -4
- package/.agent/datalakes.md +1 -1
- package/.agent/interoperability_contracts.md +1 -1
- package/.agent/mdm.md +4 -4
- package/.agent/messages.md +9 -3
- package/.agent/mock-services.md +4 -5
- package/.agent/mutations.md +4 -4
- package/.agent/tool-call-configs.md +2 -2
- package/.agent/tools.md +10 -3
- package/.agent/type_naming.md +3 -4
- package/.agent/workflows.md +45 -41
- package/README.md +1 -1
- package/dist/index.d.mts +394 -606
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +124 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/.agent/connected_apps.md +0 -525
package/.agent/AGENTS.md
CHANGED
|
@@ -93,7 +93,7 @@ corpus page documents the surface you already know:
|
|
|
93
93
|
| Sampling | Dataset event triggers | `data_activation_clients.md` §6.4 logs |
|
|
94
94
|
| Tools | Tool Calls (polymorphic body)| `tools.md` |
|
|
95
95
|
| Roots | Datalake boundaries | `datalakes.md` |
|
|
96
|
-
| Human-in-the-loop |
|
|
96
|
+
| Human-in-the-loop | Authenticated links | `authenticated_links.md` |
|
|
97
97
|
| Sandboxed execution | Liquid + SQL fragment lanes | `ai_sandbox.md` |
|
|
98
98
|
|
|
99
99
|
A few caveats on the mapping:
|
|
@@ -204,9 +204,9 @@ admin/bootstrap flows (signup, confirmation, tenant creation,
|
|
|
204
204
|
test-suite setup) — they are NOT app-building surfaces; an app that
|
|
205
205
|
holds a root or tenantless Bearer is misdesigned. An app
|
|
206
206
|
authenticates against exactly one tenant with that tenant's
|
|
207
|
-
publishable key. Likewise,
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
publishable key. Likewise, the host behind an action's
|
|
208
|
+
`link_prefix` resolves its links against one tenant, on that tenant's
|
|
209
|
+
publishable key (see `authenticated_links.md`).
|
|
210
210
|
|
|
211
211
|
Consumers holding multiple concurrent clients in the same file
|
|
212
212
|
(e.g. integration tests that need root + tenantless + tenant-
|
|
@@ -248,7 +248,6 @@ numbered steps inside the cookbook whose use case motivates it.
|
|
|
248
248
|
consumed by other resources.
|
|
249
249
|
ai_agents.md LLM-backed workers.
|
|
250
250
|
action_status_updaters.md Polling reconcilers.
|
|
251
|
-
connected_apps.md External app bridges.
|
|
252
251
|
mdm.md Entity resolution + identity
|
|
253
252
|
verification across domains.
|
|
254
253
|
ai_sandbox.md Three-layer safety contract
|
|
@@ -279,13 +278,16 @@ numbered steps inside the cookbook whose use case motivates it.
|
|
|
279
278
|
action_logs.md The action_logs DATASET — per-
|
|
280
279
|
action outcomes, batch_id joins,
|
|
281
280
|
audience export.
|
|
281
|
+
authenticated_links.md Every URL a message carries, as
|
|
282
|
+
<link_prefix>/<token>; the host
|
|
283
|
+
behind the prefix resolves taps
|
|
284
|
+
and reports the page.
|
|
282
285
|
```
|
|
283
286
|
|
|
284
287
|
Axes are mostly disjoint but a few resources cross. AI Agents
|
|
285
288
|
anchor INFRASTRUCTURE and appear in DATA ACTIVATION (they
|
|
286
289
|
tokenize during ingestion) and WORKFLOWS (workflows invoke
|
|
287
|
-
them).
|
|
288
|
-
WORKFLOWS.
|
|
290
|
+
them).
|
|
289
291
|
|
|
290
292
|
Each resource is exposed via a TypeScript namespace on the
|
|
291
293
|
typed client. The SDK namespace name follows TS conventions
|
|
@@ -299,13 +301,13 @@ wire name (snake_case):
|
|
|
299
301
|
api.tools tools.md
|
|
300
302
|
api.aiAgents ai_agents.md
|
|
301
303
|
api.actionStatusUpdaters action_status_updaters.md
|
|
302
|
-
api.connectedApps connected_apps.md
|
|
303
304
|
api.mdm mdm.md
|
|
304
305
|
api.genericTables generic_tables.md
|
|
305
306
|
api.advancedMigrations advanced_migrations.md
|
|
306
307
|
api.interoperabilityContracts interoperability_contracts.md
|
|
307
308
|
api.dataActivationClients data_activation_clients.md
|
|
308
309
|
api.workflows workflows.md
|
|
310
|
+
api.authenticatedLinks authenticated_links.md
|
|
309
311
|
|
|
310
312
|
Two corpus pages document DATASETS, not namespaces — read
|
|
311
313
|
through `api.datasets` with SINGULAR dataset types (the plural
|
|
@@ -99,15 +99,16 @@ a tenant (creator path) or accept an invitation (invitee path).
|
|
|
99
99
|
are platform-UI / admin-bootstrap surfaces (signup, confirmation,
|
|
100
100
|
tenant creation, test setup) — never ship them inside an app. An
|
|
101
101
|
app authenticates **tenant-scoped against exactly one tenant**,
|
|
102
|
-
with that tenant's publishable key;
|
|
103
|
-
|
|
102
|
+
with that tenant's publishable key; the host behind an action's
|
|
103
|
+
`link_prefix` in particular resolves links for one tenant
|
|
104
|
+
(`authenticated_links.md`).
|
|
104
105
|
|
|
105
106
|
### Key-only clients — machine-to-machine on the publishable key
|
|
106
107
|
|
|
107
|
-
`sessionToken` is optional on the client config.
|
|
108
|
-
|
|
109
|
-
key constructs with just the key — no session mint, no
|
|
110
|
-
empty token:
|
|
108
|
+
`sessionToken` is optional on the client config. The host behind an
|
|
109
|
+
action's `link_prefix`, authenticating purely on its tenant's
|
|
110
|
+
publishable key, constructs with just the key — no session mint, no
|
|
111
|
+
fabricated empty token:
|
|
111
112
|
|
|
112
113
|
```typescript
|
|
113
114
|
const api = createIsolatedPlatformApi({ baseUrl, apiKey })
|
|
@@ -118,15 +119,15 @@ and reaches exactly the `public_api` (publishable-key) allowlist —
|
|
|
118
119
|
five calls:
|
|
119
120
|
|
|
120
121
|
```
|
|
121
|
-
api.
|
|
122
|
-
api.
|
|
123
|
-
api.dataActivationClients.ingest
|
|
124
|
-
api.dataActivationClients.ingestFile
|
|
125
|
-
api.mdm.verify
|
|
122
|
+
api.authenticatedLinks.exchange a tap on a link
|
|
123
|
+
api.authenticatedLinks.updateMessageTracking opened / form_submitted
|
|
124
|
+
api.dataActivationClients.ingest submit rows
|
|
125
|
+
api.dataActivationClients.ingestFile submit a file
|
|
126
|
+
api.mdm.verify resolve an identity
|
|
126
127
|
```
|
|
127
128
|
|
|
128
|
-
(
|
|
129
|
-
|
|
129
|
+
(`authenticated_links.md` owns the details of the two link calls.)
|
|
130
|
+
Every call outside these five returns
|
|
130
131
|
401; that ceiling is the point, not an error to work around.
|
|
131
132
|
Minting a session and passing `sessionToken` unlocks the full
|
|
132
133
|
tenant-scoped API exactly as before — the two credentials are
|
|
@@ -635,9 +635,10 @@ is the part that matters:
|
|
|
635
635
|
decision (`mode: 'live'`, `manual_override: true`) — the same
|
|
636
636
|
call as any manual dispatch; there is no retry endpoint. See
|
|
637
637
|
`workflows.md` §8.
|
|
638
|
-
- **Engagement is not here either.** `
|
|
639
|
-
`
|
|
640
|
-
`
|
|
641
|
-
|
|
638
|
+
- **Engagement is not here either.** `clicked` arrives via
|
|
639
|
+
`authenticatedLinks.exchange`, and `opened` / `form_submitted`
|
|
640
|
+
with their times via `authenticatedLinks.updateMessageTracking`
|
|
641
|
+
— both called by the host behind the action's `link_prefix`
|
|
642
|
+
(`authenticated_links.md`).
|
|
642
643
|
- Field-by-field semantics of the rows being updated:
|
|
643
644
|
`messages.md`, `action_logs.md`.
|
package/.agent/ai_sandbox.md
CHANGED
|
@@ -157,7 +157,7 @@ trail this trap leaves.
|
|
|
157
157
|
|
|
158
158
|
SQL is the second consumer-author surface: WHERE clauses on
|
|
159
159
|
dataset searches (`api.datasets.createUserSearch.search_query`,
|
|
160
|
-
see `data_activation_clients.md` §6.
|
|
160
|
+
see `data_activation_clients.md` §6.6). Liquid renders some of
|
|
161
161
|
these from templates; others arrive directly as raw SQL
|
|
162
162
|
fragments. Either path lands at the same boundary.
|
|
163
163
|
|
|
@@ -165,7 +165,7 @@ fragments. Either path lands at the same boundary.
|
|
|
165
165
|
|
|
166
166
|
- SELECT against the tables exposed by the dataset search aliases
|
|
167
167
|
(`le` for legal entities, `m` for messages, `al` for action logs
|
|
168
|
-
— see `data_activation_clients.md` §6.
|
|
168
|
+
— see `data_activation_clients.md` §6.6 SQL aliases).
|
|
169
169
|
- Filter results into the platform's `search_results`
|
|
170
170
|
accumulator scoped to the materialised search id. This is the
|
|
171
171
|
one write the query path performs, and the platform builds it
|
|
@@ -305,5 +305,5 @@ guarantees it captures back-stop Layer 1.
|
|
|
305
305
|
- `account_management.md` §3 — session role vocabularies (Layer 1)
|
|
306
306
|
- `interoperability_contracts.md` §2 — template_config body shape (Layer 2)
|
|
307
307
|
- `workflows.md` gotcha 9 — the `| to_json` vs `| json` trap (Layer 2)
|
|
308
|
-
- `data_activation_clients.md` §6.
|
|
308
|
+
- `data_activation_clients.md` §6.6 — dataset search SQL aliases (Layer 3)
|
|
309
309
|
- `mdm.md` — verification surface that composes with Layer 1
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Authenticated links
|
|
2
|
+
|
|
3
|
+
Every URL in an SMS or MMS body, and every `href` in an email body, goes out
|
|
4
|
+
as an **authenticated link**:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
<link_prefix>/<token>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`link_prefix` is set on the action's tool call. It is the address of a host
|
|
11
|
+
**you** run. The platform appends only the token; any path belongs to the
|
|
12
|
+
prefix. When a person taps the link, the request reaches your host, and your
|
|
13
|
+
host asks the platform what the link is with one call and reports what the
|
|
14
|
+
person did on the page with a second.
|
|
15
|
+
|
|
16
|
+
SDK namespace: `api.authenticatedLinks` — `exchange` and
|
|
17
|
+
`updateMessageTracking`.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
workflow action (sms_request · mms_request · email_request)
|
|
21
|
+
link_prefix = "https://go.example.com/l"
|
|
22
|
+
body = "Book a slot: https://booking.example.com/slots"
|
|
23
|
+
│ send
|
|
24
|
+
▼
|
|
25
|
+
delivered "Book a slot: https://go.example.com/l/q3v9RkZ0aB7xYtL"
|
|
26
|
+
│ a person taps
|
|
27
|
+
▼
|
|
28
|
+
your host at go.example.com/l
|
|
29
|
+
exchange({ token, method, user_agent, ip, country, headers })
|
|
30
|
+
→ destination_url "https://booking.example.com/slots",
|
|
31
|
+
legal_entity_id, workflow, run, action, expires_at, message
|
|
32
|
+
send the person on to destination_url
|
|
33
|
+
│ the page opens · the form is submitted
|
|
34
|
+
▼
|
|
35
|
+
updateMessageTracking({ token, status: 'opened', opened_at })
|
|
36
|
+
updateMessageTracking({ token, status: 'form_submitted', form_submitted_at })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Both calls need only the tenant's publishable key.** The host is a key-only
|
|
40
|
+
client: no session, no user.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { createIsolatedPlatformApi } from '@alvera-ai/platform-sdk'
|
|
44
|
+
|
|
45
|
+
const links = createIsolatedPlatformApi({
|
|
46
|
+
baseUrl: 'https://api.alvera.ai',
|
|
47
|
+
apiKey: 'pk_live_tenant_publishable_key',
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This replaces Connected Apps and page tokens, which the platform retired:
|
|
52
|
+
there is no app to register, no route discovery, no `resolvePage`, and no
|
|
53
|
+
`{{ connected_app_form_url }}`. Write the real destination URL into the
|
|
54
|
+
message body; the platform turns it into an authenticated link at send.
|
|
55
|
+
|
|
56
|
+
## 1. Wire shape
|
|
57
|
+
|
|
58
|
+
### On the action — set when you build the workflow
|
|
59
|
+
|
|
60
|
+
| Field | Where | Shape |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `link_prefix` | `SmsCallRequest`, `MmsCallRequest`, `EmailCallRequest` | **required** string — the address of your host |
|
|
63
|
+
| `link_validity_days` | the same three | optional whole number, at least 1, default **365** — how many days each link resolves after the send |
|
|
64
|
+
| `metadata_template` | every action type | optional Liquid template rendered to JSON once per send |
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import type { AgenticWorkflowRequestWritable } from '@alvera-ai/platform-sdk'
|
|
68
|
+
|
|
69
|
+
type Action = NonNullable<AgenticWorkflowRequestWritable['actions']>[number]
|
|
70
|
+
|
|
71
|
+
const reviewRequest: Action['tool_call'] = {
|
|
72
|
+
tool_call_type: 'sms_request',
|
|
73
|
+
sms_type: 'transactional',
|
|
74
|
+
to: { type: 'custom', body: '{{ event_dataset.phone }}' },
|
|
75
|
+
body: {
|
|
76
|
+
type: 'custom',
|
|
77
|
+
body: 'Thanks for visiting. Tell us how it went: https://reviews.example.com/visit',
|
|
78
|
+
},
|
|
79
|
+
link_prefix: 'https://go.example.com/l',
|
|
80
|
+
link_validity_days: 30,
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `exchange` — a tap on the link
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import type { AuthenticatedLinkExchangeRequest } from '@alvera-ai/platform-sdk'
|
|
88
|
+
|
|
89
|
+
const tap: AuthenticatedLinkExchangeRequest = {
|
|
90
|
+
token: 'q3v9RkZ0aB7xYtL', // required: from <link_prefix>/<token>
|
|
91
|
+
method: 'GET', // the tap's HTTP method
|
|
92
|
+
user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
|
93
|
+
ip: '203.0.113.7',
|
|
94
|
+
country: 'US',
|
|
95
|
+
headers: { 'accept-language': 'en-US' },
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `updateMessageTracking` — what happened on the page
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import type { AuthenticatedLinkUpdateMessageTrackingRequest } from '@alvera-ai/platform-sdk'
|
|
103
|
+
|
|
104
|
+
const submitted: AuthenticatedLinkUpdateMessageTrackingRequest = {
|
|
105
|
+
token: 'q3v9RkZ0aB7xYtL', // required: the same token
|
|
106
|
+
status: 'form_submitted', // typically 'opened' or 'form_submitted'
|
|
107
|
+
form_submitted_at: '2026-10-04T10:00:00Z',
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### The answer — `AuthenticatedLinkResponse`, from both calls
|
|
112
|
+
|
|
113
|
+
| Field | Meaning |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `destination_url` | the URL as written in the message |
|
|
116
|
+
| `legal_entity_id` | the recipient's legal entity, when MDM resolved one — else `null` |
|
|
117
|
+
| `message_id` | the message the link went out in |
|
|
118
|
+
| `workflow_id` | the workflow that sent it |
|
|
119
|
+
| `workflow_execution_log_id` | the workflow run that sent it |
|
|
120
|
+
| `action_id` | the action that sent it |
|
|
121
|
+
| `expires_at` | when the link stops resolving |
|
|
122
|
+
| `link_clicked_at` | when a person first clicked this link — `null` until then |
|
|
123
|
+
| `message` | the message, a `MessageResponse` |
|
|
124
|
+
|
|
125
|
+
`message.metadata` holds the recipient's `to` merged with what the action's
|
|
126
|
+
`metadata_template` rendered, as stored at send. The message carries each
|
|
127
|
+
engagement status with its own time: `read` (`read_at`), `opened`
|
|
128
|
+
(`opened_at`), `clicked` (`clicked_at`) and `form_submitted`
|
|
129
|
+
(`form_submitted_at`).
|
|
130
|
+
|
|
131
|
+
## 2. Rules the type cannot encode
|
|
132
|
+
|
|
133
|
+
### `link_prefix` is an absolute URL with nothing after the path
|
|
134
|
+
|
|
135
|
+
An absolute `http` or `https` URL. A path is allowed —
|
|
136
|
+
`https://go.example.com/l` — but no query, no fragment and **no trailing
|
|
137
|
+
slash**: the platform appends `/<token>` itself.
|
|
138
|
+
|
|
139
|
+
### A person's tap moves the message to `clicked`; a machine's does not
|
|
140
|
+
|
|
141
|
+
`exchange` answers every tap, but only a person's moves anything. The first
|
|
142
|
+
click stamps the link's `link_clicked_at` and the message's `clicked_at`;
|
|
143
|
+
later taps keep both. The platform judges a tap from what your host forwards,
|
|
144
|
+
and these look automated and move nothing:
|
|
145
|
+
|
|
146
|
+
- a `HEAD` request;
|
|
147
|
+
- a prefetch header — `sec-purpose`, `purpose`, `x-purpose`, `x-moz`;
|
|
148
|
+
- an empty user agent, or one naming a bot, a link preview or a mail scanner.
|
|
149
|
+
|
|
150
|
+
Every tap is recorded with the details you forwarded and the signal that
|
|
151
|
+
flagged it, if one did. Headers are recorded as client headers — never cookies
|
|
152
|
+
or credentials.
|
|
153
|
+
|
|
154
|
+
### The message status only moves forward
|
|
155
|
+
|
|
156
|
+
`updateMessageTracking` applies its `status` through the message's order
|
|
157
|
+
guard: reporting `opened` after `form_submitted` leaves the message at
|
|
158
|
+
`form_submitted`. A time you leave empty keeps the message's value. The
|
|
159
|
+
`status` field is a plain string on the type; one a message cannot have is
|
|
160
|
+
refused with `422`.
|
|
161
|
+
|
|
162
|
+
### A link answers until it expires
|
|
163
|
+
|
|
164
|
+
Each link resolves for `link_validity_days` after the send, then answers
|
|
165
|
+
`410`. The response's `expires_at` says when.
|
|
166
|
+
|
|
167
|
+
### `metadata_template` renders once, at send
|
|
168
|
+
|
|
169
|
+
It renders to JSON once per send, on every action type, and is stored on the
|
|
170
|
+
message. A template that does not render **fails the send**. You read the
|
|
171
|
+
result back from `message.metadata` — not from a field on the link.
|
|
172
|
+
|
|
173
|
+
## 3. Field ownership
|
|
174
|
+
|
|
175
|
+
| Field | Written by |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `link_prefix`, `link_validity_days`, `metadata_template` | you, on the action |
|
|
178
|
+
| `destination_url` | you, as the URL written in the body |
|
|
179
|
+
| the token, `expires_at`, `message_id`, `workflow_id`, `workflow_execution_log_id`, `action_id` | the platform, at send |
|
|
180
|
+
| `legal_entity_id` | the platform, when MDM resolves the recipient |
|
|
181
|
+
| `link_clicked_at`, `message.clicked_at` | the platform, on a person's first tap |
|
|
182
|
+
| `message.opened_at`, `message.form_submitted_at` | your host, through `updateMessageTracking` |
|
|
183
|
+
|
|
184
|
+
## 4. Error envelopes
|
|
185
|
+
|
|
186
|
+
Both calls throw on a refusal, and the thrown value carries the HTTP status
|
|
187
|
+
as `_httpStatus` (see `errors.md`). They refuse in ways a host must tell
|
|
188
|
+
apart:
|
|
189
|
+
|
|
190
|
+
| Status | `exchange` | `updateMessageTracking` | Meaning |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| `404` | yes | yes | a token the platform never minted |
|
|
193
|
+
| `410` | yes | yes | the link is past `expires_at` |
|
|
194
|
+
| `503` | yes | yes | the datalake cannot be reached right now |
|
|
195
|
+
| `422` | — | yes | a status the message cannot have |
|
|
196
|
+
| `401` | yes | yes | no publishable key, or one that does not resolve |
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
async function resolveTap(token: string, userAgent: string): Promise<string> {
|
|
200
|
+
try {
|
|
201
|
+
const { data } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
|
|
202
|
+
token,
|
|
203
|
+
method: 'GET',
|
|
204
|
+
user_agent: userAgent,
|
|
205
|
+
})
|
|
206
|
+
return data.destination_url
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
209
|
+
if (status === 410) return 'https://go.example.com/expired'
|
|
210
|
+
if (status === 404) return 'https://go.example.com/not-found'
|
|
211
|
+
if (status === 503) return 'https://go.example.com/try-again'
|
|
212
|
+
throw err
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## 5. Lifecycle
|
|
218
|
+
|
|
219
|
+
1. **Build.** Give every SMS, MMS and email action a `link_prefix`, and a
|
|
220
|
+
`metadata_template` when your page needs more than the recipient.
|
|
221
|
+
2. **Send.** Each URL in the body becomes `<link_prefix>/<token>`, valid for
|
|
222
|
+
`link_validity_days`.
|
|
223
|
+
3. **Tap.** Your host calls `exchange` with the token and the tap's request
|
|
224
|
+
details, and sends the person on to `destination_url`. A person's tap
|
|
225
|
+
moves the message to `clicked`.
|
|
226
|
+
4. **Page.** Your host reports `opened` when its page opens and
|
|
227
|
+
`form_submitted` when the form is done, each with its time.
|
|
228
|
+
5. **Expiry.** After `expires_at` both calls answer `410`.
|
|
229
|
+
|
|
230
|
+
## 6. Gotchas
|
|
231
|
+
|
|
232
|
+
- **Forward what the tap said about itself.** Only the token is required, but
|
|
233
|
+
a host that forwards nothing else gives the platform nothing to judge a
|
|
234
|
+
person by. Pass the method, user agent and headers of the request that
|
|
235
|
+
reached you, not those of your own server-side call.
|
|
236
|
+
- **Link previews are taps too.** Messaging apps fetch a link to draw its
|
|
237
|
+
preview; those are recorded and do not count as clicks.
|
|
238
|
+
- **Put the path in the prefix.** The platform appends only the token, so
|
|
239
|
+
`https://go.example.com/l` gives `https://go.example.com/l/<token>`. A
|
|
240
|
+
trailing slash is refused.
|
|
241
|
+
- **`metadata` lives on the message.** Read `data.message.metadata`; the link
|
|
242
|
+
itself has no metadata field.
|
|
243
|
+
- **Coming from Connected Apps.** `connected_app_id`, `connected_app_route`
|
|
244
|
+
and `connected_app_metadata_template` are gone from actions;
|
|
245
|
+
`link_metadata_template` is now `metadata_template`; the body carries the
|
|
246
|
+
destination URL itself instead of `{{ connected_app_form_url }}`; and
|
|
247
|
+
`updateMessageTracking` is keyed by the link's token, not a page token.
|