# SmartLinks Documentation — Full Corpus
Source: https://docs.smartlinks.app
Generated: 2026-06-13T08:48:27.573Z
Documents: 396
---
# Configuring your mobile portal
Source: https://docs.smartlinks.app/docs/guides/portal-framework
Configure your mobile portal experience
# Portal Framework — User Guide
Welcome to the Portal Framework! This guide walks you through everything you need to know to create, customise, and manage your branded digital portal — no coding required.
---
## What is Portal Framework?
Portal Framework is a visual layout builder that lets you create mobile-first digital experiences for your brand. Think of it as a website builder specifically designed for product engagement — connecting your physical products to digital content, loyalty programmes, authentication, and more.
Each portal is tied to a **collection** (your brand or product line) and can include multiple **apps** (mini-tools like warranty registration, competitions, product info, etc.) arranged however you like.
---
## Getting Started
### 1. Pick a Starting Template
When you first open the builder, you'll see the **Welcome Page** with several pre-built templates:
| Template | Best For |
|----------|----------|
| **Fan Connect (Sports)** | Sports clubs, teams, fan engagement |
| **Fan Connect (Music)** | Artists, labels, music experiences |
| **Smart Docent** | Museums, galleries, cultural institutions |
| **Generic** | General-purpose — works for any brand |
Click a template card to see a live preview on the right, then click **Get Started** to begin customising.
> **Tip:** Don't worry about choosing the "perfect" template — everything is fully customisable after you start. You can also switch templates later from **Settings → Advanced**.
### 2. Quick Walkthrough
After selecting a template, a short guided walkthrough helps you set the basics:
- **Portal title** and logo
- **Side menu** items
- **Bottom tabs** arrangement
You can skip any step and come back to it later.
### 3. The Builder Interface
Once you're in the full builder, you'll see:
- **Left sidebar** — Five sections: Appearance, Navigation, Actions & Tools, Content, Settings
- **Centre panel** — Configuration options for the selected section
- **Right panel** — Live phone preview showing your changes in real time
---
## Sections Overview
### 🎨 Appearance
Control how your portal looks.
#### Header
- **Enable/disable** the top header bar
- Set a **title** (defaults to your collection name)
- Toggle the **logo** — use your collection's default or upload a custom one
- Configure the **back button** (auto, always visible, or hidden)
- Show or hide **action buttons** in the header
#### Colours
- Set your brand **colour palette** — primary, secondary, accent, background, foreground, muted, and border colours
- Configure colours independently for **light mode** and **dark mode**
- Choose a **colour mode strategy**:
- **Auto** — inherits from the parent app
- **Always Light** or **Always Dark** — forces a specific mode
- **User Choice** — adds a toggle so visitors can switch
#### Typography
- Pick custom **heading** and **body fonts** from a curated library
- Fonts are loaded automatically — no manual setup needed
#### Loading
- Customise the **loading screen** appearance
- Upload a custom **loading logo**
- Set a **loading background colour**
#### Background
- Add a **background image** to the portal
- Adjust **opacity** and choose a **size mode** (cover, contain, etc.)
---
### 🧭 Navigation
Set up how users move around your portal. There are five navigation zones:
#### Side Menu
- **Enable/disable** the slide-out menu
- Set the **position** (left or right) and **style** (overlay or push)
- Add a **menu header** with title and optional logo
- **Add, remove, and reorder items** — each item can link to an app, a custom URL, or be a visual divider
- Toggle **icons** on or off
#### Top Tabs
- Horizontal tabs below the header
- Choose a **style**: underline, pills, or buttons
- Add tabs linked to specific apps
#### Bottom Tabs
- Mobile-style tab bar at the bottom of the screen
- Toggle **labels** on or off
- Add up to 5 tabs (varies by template)
#### Quick Actions
- A **three-dot menu** (⋮) in the header
- Add apps, links, or built-in actions (like QR scanner)
- Great for less-frequently-used features
#### Floating Action Button (FAB)
- A circular button that floats above the content
- Expands into a **speed dial** with multiple actions
- Configure its **position** (bottom-left, right, or centre)
- Choose **solid colour** or **gradient** style
- Customise the **icon** and individual action items
- Optionally **hide on desktop** or collapse items into the header menu
---
### ⚡ Actions & Tools
Enable built-in tools and integrations:
#### Account Management
- Toggle user **login/logout** functionality
- Choose how accounts appear:
- **Header style**: avatar, login button, or hidden
- **Side menu**: show avatar, name, email, logout button
- **Name format**: display name, first name, full name, or email
- Customise the **login button text**
- Show a **login prompt** in the side menu for logged-out users
#### QR Code Scanner
- Camera-based scanner for navigating to products and proofs
- Appears as an icon in the header
#### Language Selector
- Globe icon for switching languages
- Available languages come from your collection's locale settings
#### AI Assistant
- AI-powered chat assistant for your portal visitors
- Configure a **persona**: name, avatar, and welcome message
- Write custom **instructions** to control the AI's behaviour and tone
- Choose a **position**: bottom sheet or side panel
- Enable **voice input** and **text-to-speech**
- Select a **voice** for spoken responses
- Works with your collection's existing content automatically
#### NFC Scanner
- Near Field Communication tag reader
- For use with NFC-enabled physical products
---
### 🏠 Content
Configure what visitors see when they first arrive.
#### Collection Homepage
Choose what loads as the landing page:
| Mode | Behaviour |
|------|-----------|
| **Auto** | Shows "My Items" if logged in, otherwise shows all products |
| **My Items** | Always shows the user's owned items (requires login) |
| **All Products** | Always shows the full product catalogue |
| **Custom App** | Loads a specific app as the homepage |
You can also set **different homepages based on login state** — e.g., show a welcome app to logged-out visitors and "My Items" to logged-in users.
#### Product Catalogue
Customise how the product listing looks:
- **View modes**: grid, list, or compact — and which modes visitors can switch between
- **Card size**: small, medium, or large
- **Search bar**: enable/disable, and set a minimum product count before search appears
- **Sorting**: by name or date, ascending or descending
- **Title**: show/hide and override the default heading
---
### ⚙️ Settings
Portal-level configuration options:
#### PWA / Install
Make your portal **installable** as a native-feeling app:
- Set an **app name** and **short name** (for the home screen)
- Add a **description**
- Choose **display mode** (standalone, fullscreen, minimal UI)
- Set **orientation** (any, portrait, landscape)
- Upload **app icons** and a **splash logo**
- Optionally override the **start URL**
#### Sharing & Social
Control how links look when shared on social media:
- Set **OG title**, **description**, and **image** (1200×630px recommended)
- Preview how your link will appear
- Configure **Twitter card type** and **handle**
- Customise the **share text template** with variables like `{{url}}`, `{{title}}`, `{{collection}}`
#### Onboarding
Create a guided first-time experience for new visitors:
- **Enable/disable** the onboarding flow
- Add **steps** with title, description, and optional image
- Choose a **style**: slides, cards, or checklist
- Show **progress indicators** and **skip button**
- **Re-show** to returning visitors if needed
#### Analytics
Integrate tracking and analytics:
- **Google Analytics** — paste your GA4 measurement ID
- **Facebook Pixel** — paste your Pixel ID
- Additional tracking can be configured via the SmartLinks platform
#### Advanced
- **Switch template** — apply a completely different starting template (⚠️ this resets your customisations)
- **Export/import** configuration as JSON (coming soon)
---
## Saving and Publishing
### Save
Click the **Save** button in the top-right toolbar to store your configuration. This saves to the SmartLinks platform and is immediately available to your portal visitors.
### Reset
Click **Reset** to discard all customisations and start fresh with a new template. You'll be taken back to the welcome page to pick a new starting layout.
> ⚠️ **Reset is permanent** — a confirmation dialog will appear before any changes are discarded.
### Preview
Click **Open** in the toolbar to view your live portal in a new browser tab.
### Toggle Preview
Click the **eye icon** to show or hide the phone preview panel while you work.
---
## Tips & Best Practices
1. **Start simple** — enable only what you need. You can always add features later.
2. **Mobile first** — most of your visitors will be on phones. Use the phone preview to check everything fits.
3. **Consistent branding** — set your brand colours once in Appearance → Colours, and they'll apply everywhere.
4. **Less is more for tabs** — 3–5 bottom tabs is ideal. Too many creates confusion.
5. **Use the side menu** for secondary navigation — pages people visit occasionally.
6. **Quick actions** are perfect for utility features like QR scanning or language switching.
7. **Test with real content** — the preview shows a mockup. Use the "Open" button to see your portal with real data.
---
## FAQ
**Q: Can I change the template after I've started customising?**
A: Yes! Go to Settings → Advanced and pick a new template. Be aware this will replace your current layout configuration.
**Q: What happens if I don't set custom colours?**
A: The portal uses sensible defaults that work well in both light and dark modes. Your collection's brand colour (if set in SmartLinks) will be used as the primary colour.
**Q: How do I add a new app to my portal?**
A: Apps are added to your collection via the SmartLinks platform. Once added there, they'll appear as options when adding items to menus, tabs, or the homepage.
**Q: Can visitors install my portal as an app on their phone?**
A: Yes! Enable the PWA settings under Settings → PWA / Install. Visitors will be prompted to "Add to Home Screen" on supported devices.
**Q: How do I set up the AI assistant?**
A: Go to Actions & Tools → AI Assistant, enable it, and configure a persona name and welcome message. The AI automatically has access to your collection's content — no extra setup needed.
**Q: What's the difference between the side menu and bottom tabs?**
A: Bottom tabs are always visible and best for primary navigation (2–5 items). The side menu slides out on demand and is better for secondary pages, settings, and links.
**Q: Can I have different layouts for different products?**
A: The portal layout applies at the collection level. Individual products show their own apps and content within the portal's navigation structure.
---
## Need Help?
If you have questions or run into issues, reach out to your SmartLinks account manager or visit the SmartLinks support portal.
---
# Shopify Private App Setup
Source: https://docs.smartlinks.app/docs/guides/shopify-private-app-setup
🛍️ Shopify Private App Setup
Follow these steps to create a private app in your Shopify store and get the
API credentials needed for the referral system.
1. Access Private Apps in Shopify Admin
Log into your Shopify Admin dashboard
Go to Settings (bottom left)
Click on Apps and sales channels
Click Develop apps at the bottom
If this is your first time, click Allow custom app development
Note: You must be the store owner or have the “Develop apps” permission to create private apps.
2. Create Your Private App
Click Create an app
Enter an app name: referal app
Enter your email as the App developer
Click Create app
3. Configure API Permissions
Click on Configure Admin API scopes and enable these permissions:
✅ Required Permissions
Scope
Description
read_discounts
View discount codes
write_discounts
Create discount codes
read_orders
Track referral purchases
read_products
Access product information
⚙️ Optional (for advanced features)
Scope
Description
read_customers
Customer analytics
write_script_tags
Tracking scripts
Important: Only grant the minimum permissions needed. You can always add more later if required.
4. Install App and Get API Credentials
Click Save to save your API permissions
Click Install app (top right)
Review the permissions and click Install
You’ll now see the Admin API access token
Click Reveal token once and copy it immediately
Security Warning:
The API token will only be shown once.
Store it securely and never share it publicly.
If you lose it, you’ll need to uninstall and reinstall the app.
5. Configure in Referral System
Return to your Referral System Admin Panel
Go to Shopify Configuration
Enter your store URL (e.g. your-store.myshopify.com)
Paste the Admin API access token
Test the connection to verify everything works
🎉 You’re all set! Your referral system can now create discount codes and track purchases.
Troubleshooting
Can't find "Develop apps"?
You need to be the store owner or have developer permissions. Contact your store owner to enable custom app development.
API calls failing?
Check that you've granted the correct permissions and that your store URL is in the format your-store.myshopify.com (without https://).
Lost your API token?
You'll need to uninstall the app and reinstall it to generate a new token. The old token will be invalidated.
---
# How to Create a New Product
Source: https://docs.smartlinks.app/docs/guides/how-to-create-a-new-product
Learn how to add a new product to your Smartlinks account, including details like name, description, GTIN, and images.
This guide will walk you through the process of adding a new product item in the Smartlinks platform. You will learn how to fill out the product creation form, including essential details like product name, identifiers, and imagery.
## Step 1: Start Creating a New Product
After logging in, you will land on the **All Items** page. This page displays all of your existing products as cards.
- **Action:** Locate the card titled "Add a New Product" and click the **CREATE** button at the bottom of it.
- **You should see:** The "Create Item" form will load, ready for you to enter the new product's details.
## Step 2: Enter Basic Product Details
The first section of the form is for the product's basic setup information.
- **Action:**
1. Click into the **Name (required)** field and type the name of your product (e.g., "Zumo 1L").
2. Click into the **Description** field and type a brief description (e.g., "sparkling water").
- **You should see:** The text you type will appear in the corresponding fields.
## Step 3: Add Product Identifiers
Next, add your internal and global product identifiers.
- **Action:**
1. Enter your internal stock-keeping unit in the **Internal SKU** field.
2. Enter the product's Global Trade Item Number in the **GTIN** field.
3. If you are the brand owner of this GTIN, click the **Owner/Master** toggle to enable it.
- **You should see:** The identifiers will appear in their fields.
- **Note:** The system will automatically check if the GTIN is valid. An error message like "Not a valid GS1 GTIN" will appear if the number is incorrect.
## Step 4: Add Product Images and Links
You can add a primary header image, additional images, and a purchase link.
- **Action:**
1. Click the **Header Image** field. This will open your computer's file browser. Select your desired product image and click "Open".
2. To add more images, click the **ADD EXTRA IMAGE** button and repeat the process.
3. (Optional) If you want to link directly to a sales page, enter the URL in the **Purchase Link** field.
- **You should see:** The filename of your uploaded image(s) will appear in the respective image fields.
## Step 5: Finalize and Create the Product
Once you have filled in all the necessary information, you can create the item.
- **Action:** Scroll to the bottom of the page and click the blue **CREATE** button.
- **You should see:** The system will process the information and create the new product. It will then be visible on your "All Items" dashboard.
---
### Conclusion
You have successfully created a new product item in the Smartlinks platform. This new item now exists in your account with its name, description, unique identifiers, and product images.
---
# Dynamic Fields
Source: https://docs.smartlinks.app/docs/guides/dynamic-fields
# Liquid Templates in SmartLinks
Liquid is a templating language that allows you to dynamically insert data into text content. SmartLinks uses Liquid Templates in various APIs—such as email templates, notification messages, and dynamic content—to personalize communications with real-time data from your collections, products, proofs, and users.
---
## What are Liquid Templates?
Liquid is an open-source template language created by Shopify. It uses a simple syntax with two main components:
- **Output tags** `{{ }}` — Insert dynamic values
- **Logic tags** `{% %}` — Control flow (if/else, loops, etc.)
### Basic Example
```liquid
Hello {{ contact.name }},
Thank you for registering your {{ product.name }}!
Your proof ID is: {{ proof.id }}
{% if proof.claimed %}
This item was claimed on {{ proof.claimedAt | date: "%B %d, %Y" }}.
{% endif %}
```
---
## Core Data Objects
SmartLinks provides several core objects that can be accessed in Liquid Templates. The available objects depend on the context (e.g., a proof-level template has access to `proof`, `product`, and `collection`).
---
### Collection
A **Collection** represents a top-level business, brand, or organization. All products belong to a collection.
| Field | Type | Description |
|-------|------|-------------|
| `collection.id` | string | Unique identifier |
| `collection.name` | string | Display name of the collection |
| `collection.description` | string | Description text |
| `collection.slug` | string | URL-friendly identifier |
| `collection.logoUrl` | string | URL to the collection's logo image |
| `collection.websiteUrl` | string | Primary website URL |
| `collection.metadata` | object | Custom key-value metadata |
| `collection.createdAt` | datetime | When the collection was created |
| `collection.updatedAt` | datetime | When the collection was last updated |
#### Example Usage
```liquid
Welcome to {{ collection.name }}!
{% if collection.websiteUrl %}
Visit us at {{ collection.websiteUrl }}
{% endif %}
```
---
### Product
A **Product** represents a type or definition of a physical or digital item. Products belong to a collection and can have many proofs (instances).
| Field | Type | Description |
|-------|------|-------------|
| `product.id` | string | Unique identifier |
| `product.name` | string | Product name |
| `product.description` | string | Product description |
| `product.sku` | string | Stock keeping unit |
| `product.slug` | string | URL-friendly identifier |
| `product.imageUrl` | string | Primary product image URL |
| `product.images` | array | Array of image URLs |
| `product.category` | string | Product category |
| `product.tags` | array | Array of tag strings |
| `product.metadata` | object | Custom key-value metadata |
| `product.createdAt` | datetime | When the product was created |
| `product.updatedAt` | datetime | When the product was last updated |
#### Example Usage
```liquid
Your {{ product.name }} (SKU: {{ product.sku }})
{{ product.description }}
{% if product.tags.size > 0 %}
Tags: {{ product.tags | join: ", " }}
{% endif %}
{% for image in product.images %}
{% endfor %}
```
---
### Proof
A **Proof** is a specific instance of a product—think of it as a unique digital certificate for a physical item. Proofs can be claimed by users and carry ownership information.
| Field | Type | Description |
|-------|------|-------------|
| `proof.id` | string | Unique identifier |
| `proof.serialNumber` | string | Human-readable serial number |
| `proof.claimed` | boolean | Whether the proof has been claimed |
| `proof.claimedAt` | datetime | When the proof was claimed |
| `proof.claimedBy` | string | User ID of the claimer |
| `proof.status` | string | Current status (e.g., "active", "transferred") |
| `proof.nfcTagId` | string | Associated NFC tag ID (if applicable) |
| `proof.qrCode` | string | QR code identifier |
| `proof.shortCode` | string | Short code for easy lookup |
| `proof.metadata` | object | Custom key-value metadata |
| `proof.createdAt` | datetime | When the proof was created |
| `proof.updatedAt` | datetime | When the proof was last updated |
#### Example Usage
```liquid
Proof of Authenticity
Serial Number: {{ proof.serialNumber }}
Status: {{ proof.status }}
{% if proof.claimed %}
Claimed on: {{ proof.claimedAt | date: "%B %d, %Y at %H:%M" }}
{% else %}
This item has not been claimed yet.
{% endif %}
{% if proof.metadata.warrantyExpiry %}
Warranty expires: {{ proof.metadata.warrantyExpiry | date: "%B %d, %Y" }}
{% endif %}
```
---
### Contact
A **Contact** represents a customer or user in the system. Contacts are associated with a collection and can own multiple proofs.
| Field | Type | Description |
|-------|------|-------------|
| `contact.id` | string | Unique identifier |
| `contact.email` | string | Email address |
| `contact.name` | string | Full name |
| `contact.firstName` | string | First name |
| `contact.lastName` | string | Last name |
| `contact.phone` | string | Phone number |
| `contact.locale` | string | Preferred language/locale (e.g., "en", "de") |
| `contact.timezone` | string | Preferred timezone |
| `contact.avatarUrl` | string | Profile picture URL |
| `contact.metadata` | object | Custom key-value metadata |
| `contact.tags` | array | Array of tag strings for segmentation |
| `contact.createdAt` | datetime | When the contact was created |
| `contact.updatedAt` | datetime | When the contact was last updated |
| `contact.lastSeenAt` | datetime | Last activity timestamp |
#### Example Usage
```liquid
Hi {{ contact.firstName | default: contact.name | default: "there" }},
{% if contact.locale == "de" %}
Willkommen!
{% elsif contact.locale == "fr" %}
Bienvenue!
{% else %}
Welcome!
{% endif %}
{% if contact.phone %}
We'll send updates to {{ contact.phone }}.
{% endif %}
```
---
### User (Account)
A **User** represents an authenticated account in the system. This is typically the logged-in user performing an action.
| Field | Type | Description |
|-------|------|-------------|
| `user.id` | string | Unique identifier |
| `user.email` | string | Email address |
| `user.name` | string | Display name |
| `user.admin` | boolean | Whether user has admin privileges |
| `user.avatarUrl` | string | Profile picture URL |
| `user.createdAt` | datetime | Account creation date |
#### Example Usage
```liquid
Logged in as: {{ user.name }} ({{ user.email }})
{% if user.admin %}
🔐 You have administrator access.
{% endif %}
```
---
### Attestation
An **Attestation** is flexible data attached to a specific proof. It's used to store additional information like warranty registrations, tasting notes, service records, etc.
| Field | Type | Description |
|-------|------|-------------|
| `attestation.id` | string | Unique identifier |
| `attestation.type` | string | Attestation type (app-defined) |
| `attestation.data` | object | The attestation payload (varies by type) |
| `attestation.createdBy` | string | User ID who created it |
| `attestation.createdAt` | datetime | When the attestation was created |
| `attestation.updatedAt` | datetime | When the attestation was last updated |
#### Example Usage
```liquid
{% if attestation.type == "warranty_registration" %}
Warranty Registration Details:
- Registered: {{ attestation.createdAt | date: "%B %d, %Y" }}
- Purchase Date: {{ attestation.data.purchaseDate }}
- Store: {{ attestation.data.storeName }}
{% endif %}
{% if attestation.type == "tasting_note" %}
🍷 Tasting Note by {{ attestation.data.author }}:
"{{ attestation.data.notes }}"
Rating: {{ attestation.data.rating }}/5
{% endif %}
```
---
## Liquid Filters
Liquid provides built-in filters to transform data. Common filters include:
### Text Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `upcase` | Convert to uppercase | `{{ product.name \| upcase }}` |
| `downcase` | Convert to lowercase | `{{ product.name \| downcase }}` |
| `capitalize` | Capitalize first letter | `{{ contact.name \| capitalize }}` |
| `truncate` | Limit string length | `{{ product.description \| truncate: 100 }}` |
| `strip_html` | Remove HTML tags | `{{ content \| strip_html }}` |
| `escape` | HTML escape special chars | `{{ user_input \| escape }}` |
| `default` | Fallback value if empty | `{{ contact.name \| default: "Customer" }}` |
### Date Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `date` | Format a date | `{{ proof.claimedAt \| date: "%B %d, %Y" }}` |
Common date formats:
- `%B %d, %Y` → January 15, 2025
- `%Y-%m-%d` → 2025-01-15
- `%d/%m/%Y` → 15/01/2025
- `%H:%M` → 14:30
### Array Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `join` | Join array elements | `{{ product.tags \| join: ", " }}` |
| `first` | Get first element | `{{ product.images \| first }}` |
| `last` | Get last element | `{{ product.images \| last }}` |
| `size` | Get array length | `{{ product.tags.size }}` |
| `sort` | Sort array | `{{ items \| sort: "name" }}` |
### Number Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `plus` | Add | `{{ count \| plus: 1 }}` |
| `minus` | Subtract | `{{ total \| minus: discount }}` |
| `times` | Multiply | `{{ price \| times: quantity }}` |
| `divided_by` | Divide | `{{ total \| divided_by: 2 }}` |
| `round` | Round number | `{{ average \| round: 2 }}` |
---
## Control Flow
### Conditionals
```liquid
{% if proof.claimed %}
This item is claimed.
{% elsif proof.status == "pending" %}
Claim pending verification.
{% else %}
Available to claim.
{% endif %}
{% unless contact.email %}
No email on file.
{% endunless %}
```
### Operators
| Operator | Description |
|----------|-------------|
| `==` | Equals |
| `!=` | Not equals |
| `>` | Greater than |
| `<` | Less than |
| `>=` | Greater than or equal |
| `<=` | Less than or equal |
| `or` | Logical OR |
| `and` | Logical AND |
| `contains` | String/array contains |
```liquid
{% if product.tags contains "premium" %}
🌟 Premium Product
{% endif %}
{% if contact.email and proof.claimed %}
Send confirmation to {{ contact.email }}
{% endif %}
```
### Loops
```liquid
{% for tag in product.tags %}
{{ tag }}
{% endfor %}
{% for image in product.images limit: 3 %}
{% endfor %}
```
Loop variables:
- `forloop.index` — Current iteration (1-indexed)
- `forloop.index0` — Current iteration (0-indexed)
- `forloop.first` — Is this the first iteration?
- `forloop.last` — Is this the last iteration?
- `forloop.length` — Total number of iterations
---
## Common Use Cases
### Email Templates
```liquid
Subject: Your {{ product.name }} has been registered!
Hi {{ contact.firstName | default: "there" }},
Great news! Your {{ product.name }} (Serial: {{ proof.serialNumber }})
has been successfully registered to your account.
{% if product.metadata.warrantyYears %}
Your warranty is valid for {{ product.metadata.warrantyYears }} years
from the date of purchase.
{% endif %}
If you have any questions, please contact {{ collection.name }} support.
Best regards,
The {{ collection.name }} Team
```
### Notification Messages
```liquid
🎉 {{ contact.firstName }}, your {{ product.name }} is now verified!
Proof ID: {{ proof.shortCode }}
```
### Dynamic Content Blocks
```liquid
{% if proof.metadata.tier == "gold" %}
);
}
```
### Example 5: Voice Q&A
```typescript
async function voiceQA() {
if (!ai.voice.isSupported()) {
console.error('Voice not supported in this browser');
return;
}
console.log('Speak your question...');
// Listen for voice input
const question = await ai.voice.listen('en-US');
console.log('You asked:', question);
// Get answer from AI
const response = await ai.public.chat('my-collection', {
productId: 'coffee-maker-deluxe',
userId: 'user-123',
message: question
});
// Display answer
console.log('Answer:', response.message);
// Speak answer
await ai.voice.speak(response.message);
}
```
### Example 6: Generate Product Podcast
```typescript
async function generateProductPodcast() {
// Generate a casual 5-minute podcast about the coffee maker
const podcast = await ai.podcast.generate('my-collection', {
productId: 'coffee-maker-deluxe',
duration: 5, // minutes
style: 'casual', // Conversational style
voices: {
host1: 'nova', // Female voice
host2: 'onyx' // Male voice
},
includeAudio: true
});
console.log('Podcast Title:', podcast.script.title);
console.log('Duration:', podcast.metadata.duration, 'seconds');
// Display script
console.log('\nScript:');
podcast.script.segments.forEach((segment, i) => {
const speaker = segment.speaker === 'host1' ? 'Host 1' : 'Host 2';
console.log(`\n${speaker}: ${segment.text}`);
});
// Download audio
if (podcast.audio?.mixedUrl) {
console.log('\nDownload podcast:', podcast.audio.mixedUrl);
}
}
```
### Example 7: Podcast with Progress Tracking
```typescript
async function generatePodcastWithProgress() {
// Start podcast generation
const podcast = await ai.podcast.generate('my-collection', {
productId: 'coffee-maker-deluxe',
duration: 10,
style: 'professional',
includeAudio: true
});
const podcastId = podcast.podcastId;
// Poll for status
const checkStatus = async () => {
const status = await ai.podcast.getStatus('my-collection', podcastId);
console.log(`Status: ${status.status} (${status.progress}%)`);
if (status.status === 'completed' && status.result) {
console.log('Podcast ready!');
console.log('Listen:', status.result.audio?.mixedUrl);
return true;
} else if (status.status === 'failed') {
console.error('Generation failed:', status.error);
return true;
}
return false;
};
// Check every 5 seconds
const interval = setInterval(async () => {
const done = await checkStatus();
if (done) clearInterval(interval);
}, 5000);
}
```
---
## Error Handling
### Error Codes
| Code | Type | HTTP Status | Description |
|------|------|-------------|-------------|
| `rate_limit_exceeded` | `rate_limit_error` | 429 | User exceeded rate limit |
| `invalid_request` | `invalid_request_error` | 400 | Invalid parameters |
| `authentication_error` | `authentication_error` | 401 | Invalid/missing API key |
| `permission_denied` | `permission_error` | 403 | Insufficient permissions |
| `not_found` | `not_found_error` | 404 | Resource not found |
| `document_not_found` | `not_found_error` | 404 | Product document not indexed |
| `server_error` | `server_error` | 500 | Internal server error |
| `service_unavailable` | `server_error` | 503 | Service temporarily unavailable |
### Error Handling Pattern
```typescript
import { SmartLinksAIError } from '@proveanything/smartlinks';
async function robustChat() {
try {
const response = await ai.public.chat('my-collection', {
productId: 'coffee-maker',
userId: 'user-123',
message: 'Help!'
});
return response.message;
} catch (error) {
if (error instanceof SmartLinksAIError) {
switch (error.code) {
case 'rate_limit_exceeded':
console.error('Rate limit exceeded');
console.log('Try again at:', new Date(error.resetAt!));
break;
case 'document_not_found':
console.error('Product manual not indexed yet');
break;
case 'authentication_error':
console.error('Invalid API key');
break;
case 'invalid_request':
console.error('Invalid request:', error.message);
break;
default:
console.error('API error:', error.message);
}
} else {
console.error('Unexpected error:', error);
}
throw error;
}
}
```
### Rate Limit Retry
```typescript
async function chatWithRetry(
request: PublicChatRequest,
maxRetries = 3
) {
let retries = 0;
while (retries < maxRetries) {
try {
return await ai.public.chat('my-collection', request);
} catch (error) {
if (error instanceof SmartLinksAIError && error.isRateLimitError()) {
if (retries === maxRetries - 1) throw error;
const resetTime = new Date(error.resetAt!).getTime();
const waitTime = resetTime - Date.now();
console.log(`Rate limited. Waiting ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
retries++;
} else {
throw error;
}
}
}
}
```
---
## Rate Limiting
### Rate Limit Overview
Public endpoints are rate-limited per `userId`:
| Endpoint Type | Default Limit | Window |
|--------------|---------------|--------|
| Public Chat | 20 requests | 1 hour |
| Token Generation | 10 requests | 1 hour |
| Admin Endpoints | Unlimited* | - |
*Admin endpoints use API key authentication and are not rate-limited by default.
### Rate Limit Headers
All API responses include rate limit information:
```
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 15
X-RateLimit-Reset: 1707300000000
```
### Checking Rate Limit
```typescript
// Check rate limit status before making requests
const status = await ai.public.getRateLimit('my-collection', 'user-123');
console.log('Used:', status.used);
console.log('Remaining:', status.remaining);
console.log('Resets at:', new Date(status.resetAt));
if (status.remaining > 0) {
// Safe to make request
await ai.public.chat(/* ... */);
} else {
// Show user when they can ask again
console.log('Rate limit reached. Try again at:', status.resetAt);
}
```
### Resetting Rate Limits (Admin)
```typescript
// Reset rate limit for a specific user
await ai.rateLimit.reset('my-collection', 'user-123');
console.log('Rate limit reset for user-123');
```
---
## Best Practices
### 1. Choose the Right Model
```typescript
// For most new workflows (recommended default)
await ai.chat.responses.create('my-collection', {
model: 'openai/gpt-5.4',
input: 'Create a concise onboarding checklist.'
});
// For lower-cost structured or JSON-oriented work
await ai.chat.responses.create('my-collection', {
model: 'openai/gpt-5-mini',
input: 'Return a color palette as JSON.'
});
// For fast multimodal or cost-sensitive general use
await ai.chat.completions.create('my-collection', {
model: 'google/gemini-2.5-flash',
messages: [...]
});
// When you need the actual available catalog for this collection
const available = await ai.models.list('my-collection');
console.log(available.data.map(model => model.id));
```
### 2. Use Streaming for Long Responses
Improve perceived performance with streaming:
```typescript
// Non-streaming: User waits for full response
const response = await ai.chat.completions.create('my-collection', {
messages: [...]
});
// Streaming: User sees progress immediately
const stream = await ai.chat.completions.create('my-collection', {
stream: true,
messages: [...]
});
for await (const chunk of stream) {
updateUI(chunk.choices[0]?.delta?.content);
}
```
### 3. Maintain Session Context
Keep conversations coherent with session IDs:
```typescript
// Generate unique session ID per conversation
const sessionId = `user-${userId}-product-${productId}`;
// All questions in same conversation use same sessionId
await ai.public.chat('my-collection', {
sessionId,
message: 'First question',
...
});
await ai.public.chat('my-collection', {
sessionId,
message: 'Follow-up question',
...
});
```
### 4. Handle Rate Limits Gracefully
Show clear feedback to users:
```typescript
try {
await ai.public.chat('my-collection', {...});
} catch (error) {
if (error instanceof SmartLinksAIError && error.isRateLimitError()) {
const resetTime = new Date(error.resetAt!);
showNotification(
`You've reached your question limit. ` +
`Try again at ${resetTime.toLocaleTimeString()}`
);
}
}
```
### 5. Optimize Voice UX
Provide clear status updates:
```typescript
async function voiceAssistant() {
try {
showStatus('Listening...');
const question = await ai.voice.listen();
showStatus('Processing...');
const answer = await ai.public.chat('my-collection', {
message: question,
...
});
showStatus('Speaking...');
await ai.voice.speak(answer.message);
showStatus('Ready');
} catch (error) {
showStatus('Error', error.message);
}
}
```
### 6. Chunk Large Documents
For better RAG performance, chunk documents appropriately:
```typescript
// For technical manuals
await ai.rag.indexDocument('my-collection', {
productId: 'coffee-maker',
documentUrl: '...',
chunkSize: 500, // Smaller chunks for precise answers
overlap: 50 // Overlap maintains context
});
// For narrative content
await ai.rag.indexDocument('my-collection', {
productId: 'coffee-maker',
documentUrl: '...',
chunkSize: 1000, // Larger chunks for coherent responses
overlap: 100
});
```
### 7. Use System Prompts Effectively
Provide clear instructions:
```typescript
await ai.chat.completions.create('my-collection', {
messages: [
{
role: 'system',
content: `You are a coffee maker expert assistant.
- Be concise and clear
- Use numbered lists for steps
- Always mention safety precautions
- If unsure, ask for clarification`
},
{ role: 'user', content: 'How do I descale?' }
]
});
```
### 8. Monitor Usage and Costs
Track usage for cost management:
```typescript
const response = await ai.chat.completions.create('my-collection', {
messages: [...]
});
// Log token usage
console.log('Usage:', response.usage);
console.log('Prompt tokens:', response.usage.prompt_tokens);
console.log('Completion tokens:', response.usage.completion_tokens);
console.log('Total tokens:', response.usage.total_tokens);
// Calculate estimated cost
const model = await ai.models.get('my-collection', response.model);
const cost =
(response.usage.prompt_tokens * model.pricing.input / 1_000_000) +
(response.usage.completion_tokens * model.pricing.output / 1_000_000);
console.log('Estimated cost: $', cost.toFixed(4));
```
---
## Providing Content to the AI Assistant
The portal's built-in AI assistant can discuss the content currently visible to the user. To make this work, your app must supply contextual content when the assistant requests it. There are three extraction methods, tried in priority order:
| Priority | Method | When Used |
|----------|--------|-----------|
| 1 | **Direct prop callback** | Container/widget rendered in the parent React context |
| 2 | **PostMessage protocol** | App rendered in an iframe |
| 3 | **DOM text fallback** | Neither of the above responded |
This section covers **methods 1 and 2** — the ones you implement in your app.
### Method 1: Direct Prop (`onRequestAIContent`)
When your app is rendered as a **container** (a direct component in the parent's React tree), the framework passes an `onRequestAIContent` prop to your exported component. You do **not** call this prop yourself — the framework calls it when the AI assistant needs context.
Structure your component to make current state accessible when the callback fires:
```tsx
import { useEffect, useRef } from 'react';
export function PublicContainer(props) {
const { onRequestAIContent, appId, ...rest } = props;
// Keep a ref to the latest content so the callback always returns fresh data
const currentContentRef = useRef(null);
useEffect(() => {
currentContentRef.current = buildCurrentContent();
}, [relevantState]);
return
{/* your UI */}
;
}
```
The framework registers the content provider internally via `useAIContentExtraction.registerContentProvider()`. Your component just needs to respond when the callback is invoked.
### Method 2: PostMessage Protocol (Iframes)
For iframe-embedded apps the framework sends a `postMessage` request and expects a response within **500 ms**. If your app doesn't reply in time the framework falls back to DOM text extraction.
**Request sent by the framework to your iframe:**
```typescript
{
type: 'smartlinks:request-ai-content',
requestId: 'ai-content-1709834567890-abc123' // unique per request
}
```
**Response your app must send back:**
```typescript
{
type: 'smartlinks:ai-content-response',
requestId: 'ai-content-1709834567890-abc123', // echo back the requestId
content: AIContentResponse
}
```
**Minimal implementation:**
```typescript
window.addEventListener('message', async (event) => {
if (event.data?.type === 'smartlinks:request-ai-content') {
const content = await gatherAIContent();
window.parent.postMessage({
type: 'smartlinks:ai-content-response',
requestId: event.data.requestId,
content,
}, '*');
}
});
async function gatherAIContent(): Promise {
return {
text: 'The user is viewing the warranty registration form...',
contentLabel: 'Warranty Registration',
};
}
```
### The `AIContentResponse` Interface
```typescript
interface AIContentResponse {
/**
* Plain text or markdown for the AI to use as context.
* Injected into the system prompt. Max recommended: ~4000 characters.
*/
text: string;
/**
* Optional structured metadata (key-value pairs).
* Not used directly in prompts but available for custom providers.
*/
metadata?: Record;
/**
* Pre-built RAG configuration. When provided, the assistant can use
* SL.ai.public.chat() to ground answers in indexed product documents.
*/
ragHint?: {
/** The product ID whose indexed documents should be queried */
productId: string;
/** Optional session ID for multi-turn RAG conversations */
sessionId?: string;
/** Optional context hint to help scope the RAG query */
context?: string;
};
/**
* Human-readable label shown in context-update messages
* (e.g. "Product Manual", "FAQ").
*/
contentLabel?: string;
/**
* How the assistant should use this content:
* - 'context' (default): inject text into the system prompt
* - 'rag': use ragHint to query indexed docs via SL.ai.public.chat()
* - 'hybrid': inject text as context AND ground answers via RAG
*/
strategy?: 'context' | 'rag' | 'hybrid';
}
```
### Response Strategies
#### `context` (Default)
Return readable text. The assistant injects it into the system prompt as background knowledge. Good for descriptions, summaries, structured data, and FAQs.
```typescript
return {
text: `
## Wine Details
- **Name**: 2023 Château Margaux
- **Region**: Bordeaux, France
- **Tasting Notes**: Dark fruit, cedar, tobacco
- **Food Pairing**: Lamb, aged cheese
`,
contentLabel: 'Wine Information',
strategy: 'context',
};
```
#### `rag`
Tell the assistant to query pre-indexed documents via SmartLinks RAG. The `text` field is minimal — the real knowledge comes from the indexed docs. Good for product manuals, large document sets, and technical specs.
```typescript
return {
text: 'The user is viewing the espresso machine product page.',
contentLabel: 'Product Assistant',
strategy: 'rag',
ragHint: {
productId: 'espresso-machine-pro',
sessionId: `rag-session-${userId}`,
context: 'User is on the troubleshooting section',
},
};
```
When the assistant receives a `rag` strategy it routes the question through `SL.ai.public.chat()`:
```typescript
const response = await SL.ai.public.chat(collectionId, {
productId: ragHint.productId,
userId: currentUserId,
message: userQuestion,
sessionId: ragHint.sessionId,
});
```
#### `hybrid`
Combines both — the `text` is injected as additional context **and** the user's questions are also grounded via RAG. Good for museum exhibits, guided experiences, or any scenario with rich metadata alongside large document sets.
```typescript
return {
text: `
## Exhibit: The Starry Night
- **Artist**: Vincent van Gogh
- **Year**: 1889
- **Current Location**: Gallery 3, East Wing
- **Audio Guide**: Available in 12 languages
`,
contentLabel: 'Museum Exhibit',
strategy: 'hybrid',
ragHint: {
productId: 'starry-night-exhibit',
context: 'Art history and technique questions',
},
};
```
### Content Extraction Examples
#### Museum Guide App
```typescript
async function gatherAIContent(): Promise {
const exhibit = getCurrentExhibit();
return {
text: `
Exhibit: "${exhibit.title}" by ${exhibit.artist}
Period: ${exhibit.period}
Medium: ${exhibit.medium}
Description: ${exhibit.curatorNotes}
Related works in this gallery: ${exhibit.relatedWorks.join(', ')}
`.trim(),
contentLabel: `Exhibit: ${exhibit.title}`,
strategy: 'hybrid',
ragHint: {
productId: exhibit.smartlinksProductId,
context: `Art history, technique, and visitor information for ${exhibit.title}`,
},
metadata: {
exhibitId: exhibit.id,
gallery: exhibit.gallery,
audioGuideAvailable: exhibit.hasAudioGuide,
},
};
}
```
#### Wine Product App
```typescript
async function gatherAIContent(): Promise {
const wine = getCurrentWine();
const reviews = await fetchRecentReviews(wine.id, 5);
return {
text: `
Wine: ${wine.name} (${wine.vintage})
Winery: ${wine.winery}
Region: ${wine.region}, ${wine.country}
Grape: ${wine.grape}
ABV: ${wine.abv}%
Price: ${wine.price}
Tasting Notes: ${wine.tastingNotes}
Recent Reviews:
${reviews.map(r => `- "${r.text}" (${r.rating}/5)`).join('\n')}
`.trim(),
contentLabel: 'Wine Details',
strategy: 'context',
};
}
```
#### Equipment Manual App (RAG-only)
```typescript
async function gatherAIContent(): Promise {
const equipment = getCurrentEquipment();
return {
text: `User is viewing: ${equipment.name} (Model: ${equipment.modelNumber})`,
contentLabel: `${equipment.name} Assistant`,
strategy: 'rag',
ragHint: {
productId: equipment.smartlinksProductId,
sessionId: `manual-${equipment.id}-${Date.now()}`,
context: 'Technical manual, troubleshooting, and maintenance',
},
};
}
```
### Timing & Lifecycle
- **On navigation**: When the user navigates to a new product/proof/app, the assistant automatically requests fresh content and injects a context-update system message.
- **On first message**: If no content has been gathered yet, the assistant requests it before the first AI call.
- **On manual refresh**: The assistant can re-request content at any time (e.g. if the user's view within the app has changed).
Content is requested **lazily** — your handler is only called when the AI assistant is active and needs context. If the user never opens the assistant, your handler is never called.
### Method 3: DOM Fallback (Automatic)
If your app doesn't implement either of the above, the framework extracts `innerText` from the DOM element with `data-app-container="{appId}"`, truncated to ~4000 characters. This is a **last resort** — content quality is much lower than a structured response. Implementing Method 1 or 2 is strongly recommended.
---
## Related Documentation
- [API Summary](./API_SUMMARY.md) - Complete API reference
- [Widgets](./widgets.md) - Embedding SmartLinks components
- [Realtime](./realtime.md) - Realtime data updates
- [iframe Responder](./iframe-responder.md) - iframe integration
---
## Support
For questions or issues:
- **Documentation:** https://smartlinks.app/docs
- **GitHub:** https://github.com/Prove-Anything/smartlinks
- **Email:** support@smartlinks.app
---
# SDK guide: Liquid Templates
Source: https://docs.smartlinks.app/docs/sdk/guides/liquid-templates
Dynamic content rendering with Liquid templating for emails, notifications, and personalised content.
# Liquid Templates in SmartLinks
Liquid is a templating language that allows you to dynamically insert data into text content. SmartLinks uses Liquid Templates in various APIs—such as email templates, notification messages, and dynamic content—to personalize communications with real-time data from your collections, products, proofs, and users.
---
## What are Liquid Templates?
Liquid is an open-source template language created by Shopify. It uses a simple syntax with two main components:
- **Output tags** `{{ }}` — Insert dynamic values
- **Logic tags** `{% %}` — Control flow (if/else, loops, etc.)
### Basic Example
```liquid
Hello {{ contact.name }},
Thank you for registering your {{ product.name }}!
Your proof ID is: {{ proof.id }}
{% if proof.claimed %}
This item was claimed on {{ proof.claimedAt | date: "%B %d, %Y" }}.
{% endif %}
```
---
## Core Data Objects
SmartLinks provides several core objects that can be accessed in Liquid Templates. The available objects depend on the context (e.g., a proof-level template has access to `proof`, `product`, and `collection`).
---
### Collection
A **Collection** represents a top-level business, brand, or organization. All products belong to a collection.
| Field | Type | Description |
|-------|------|-------------|
| `collection.id` | string | Unique identifier |
| `collection.title` | string | Display title of the collection |
| `collection.description` | string | Description text |
| `collection.shortId` | string | Short identifier for the collection |
| `collection.logoImage.url` | string | URL to the collection's logo image |
| `collection.logoImage.thumbnails.x100` | string | 100px thumbnail |
| `collection.logoImage.thumbnails.x200` | string | 200px thumbnail |
| `collection.logoImage.thumbnails.x512` | string | 512px thumbnail |
| `collection.headerImage.url` | string | URL to collection header/hero image |
| `collection.headerImage.thumbnails.*` | string | Header image thumbnails (x100, x200, x512) |
| `collection.loaderImage.url` | string | URL to collection loader image |
| `collection.primaryColor` | string | Primary theme color (hex code) |
| `collection.secondaryColor` | string | Secondary theme color (hex code) |
| `collection.dark` | boolean | Whether dark mode is enabled |
| `collection.portalUrl` | string | URL for the collection's portal |
| `collection.redirectUrl` | string | Custom domain redirect URL |
| `collection.roles` | object | User roles mapping (userId → role) |
| `collection.groupTags` | array | Array of group tag names |
| `collection.languages` | array | Array of supported language objects |
| `collection.defaultAuthKitId` | string | Default auth kit ID |
| `collection.allowAutoGenerateClaims` | boolean | Allow claiming without proof ID |
#### Example Usage
```liquid
Welcome to {{ collection.title }}!
{% if collection.portalUrl %}
Visit our portal at {{ collection.portalUrl }}
{% endif %}
{% if collection.logoImage %}
{% endif %}
{% if collection.dark %}
{% endif %}
```
---
### Product
A **Product** represents a type or definition of a physical or digital item. Products belong to a collection and can have many proofs (instances).
| Field | Type | Description |
|-------|------|-------------|
| `product.id` | string | Unique identifier |
| `product.name` | string | Product name |
| `product.collectionId` | string | ID of the parent collection |
| `product.description` | string | Product description |
| `product.gtin` | string | Global Trade Item Number |
| `product.type` | string | Product type from standard types |
| `product.heroImage.url` | string | Primary product image URL |
| `product.heroImage.thumbnails.x100` | string | 100px thumbnail |
| `product.heroImage.thumbnails.x200` | string | 200px thumbnail |
| `product.heroImage.thumbnails.x512` | string | 512px thumbnail |
| `product.tags` | object | Tag map with boolean values |
| `product.data` | object | Flexible key-value data map |
| `product.admin` | object | Admin-only configuration |
| `product.admin.allowAutoGenerateClaims` | boolean | Allow claiming without proof ID |
| `product.admin.lastSerialId` | number | Last generated serial ID |
#### Example Usage
```liquid
Your {{ product.name }}
{{ product.description }}
{% if product.gtin %}
GTIN: {{ product.gtin }}
{% endif %}
{% if product.heroImage %}
{% endif %}
{% if product.tags.premium %}
🌟 Premium Product
{% endif %}
{% if product.data.warranty_years %}
Warranty: {{ product.data.warranty_years }} years
{% endif %}
```
---
### Proof
A **Proof** is a specific instance of a product—think of it as a unique digital certificate for a physical item. Proofs can be claimed by users and carry ownership information.
| Field | Type | Description |
|-------|------|-------------|
| `proof.id` | string | Unique identifier |
| `proof.collectionId` | string | ID of the parent collection |
| `proof.productId` | string | ID of the associated product |
| `proof.tokenId` | string | Unique token identifier |
| `proof.userId` | string | User ID of the owner |
| `proof.claimable` | boolean | Whether the proof can be claimed |
| `proof.virtual` | boolean | Whether this is a virtual proof |
| `proof.values` | object | Arbitrary key-value pairs for proof data |
| `proof.createdAt` | datetime | When the proof was created |
**Note**: Proof `values` object can contain any custom fields. Common examples:
- `proof.values.serialNumber` - Serial number
- `proof.values.claimedAt` - Claim timestamp
- `proof.values.status` - Current status
- `proof.values.warrantyExpiry` - Warranty expiration
#### Example Usage
```liquid
Proof of Authenticity
{% if proof.values.serialNumber %}
Serial Number: {{ proof.values.serialNumber }}
{% endif %}
{% if proof.values.status %}
Status: {{ proof.values.status }}
{% endif %}
{% if proof.claimable %}
This item is available to claim.
{% else %}
This item has been claimed.
{% endif %}
{% if proof.virtual %}
🌐 Digital Product
{% endif %}
{% if proof.values.claimedAt %}
Claimed on: {{ proof.values.claimedAt | date: "%B %d, %Y at %H:%M" }}
{% endif %}
{% if proof.values.warrantyExpiry %}
Warranty expires: {{ proof.values.warrantyExpiry | date: "%B %d, %Y" }}
{% endif %}
```
---
### Contact
A **Contact** represents a customer or user in the system. Contacts are associated with a collection and can own multiple proofs.
| Field | Type | Description |
|-------|------|-------------|
| `contact.contactId` | string | Unique identifier |
| `contact.orgId` | string | Organization/collection ID |
| `contact.userId` | string | Linked user ID (if authenticated) |
| `contact.email` | string | Primary email address |
| `contact.phone` | string | Primary phone number |
| `contact.emails` | array | Array of all email addresses |
| `contact.phones` | array | Array of all phone numbers |
| `contact.firstName` | string | First name |
| `contact.lastName` | string | Last name |
| `contact.displayName` | string | Display name |
| `contact.company` | string | Company name |
| `contact.avatarUrl` | string | Profile picture URL |
| `contact.locale` | string | Preferred language/locale (e.g., "en", "de") |
| `contact.timezone` | string | Preferred timezone |
| `contact.tags` | array | Array of tag strings for segmentation |
| `contact.source` | string | How the contact was created |
| `contact.notes` | string | Admin notes |
| `contact.externalIds` | object | External system IDs |
| `contact.customFields` | object | Custom key-value data |
| `contact.createdAt` | datetime | When the contact was created |
| `contact.updatedAt` | datetime | When the contact was last updated |
#### Example Usage
```liquid
Hi {{ contact.firstName | default: contact.displayName | default: "there" }},
{% if contact.locale == "de" %}
Willkommen!
{% elsif contact.locale == "fr" %}
Bienvenue!
{% else %}
Welcome!
{% endif %}
{% if contact.phone %}
We'll send updates to {{ contact.phone }}.
{% endif %}
{% if contact.company %}
Company: {{ contact.company }}
{% endif %}
{% if contact.customFields.vip %}
🌟 VIP Customer
{% endif %}
```
---
### User (Account)
A **User** represents an authenticated account in the system. This is typically the logged-in user performing an action.
| Field | Type | Description |
|-------|------|-------------|
| `user.uid` | string | Unique identifier |
| `user.email` | string | Email address |
| `user.displayName` | string | Display name |
| `user.accountData` | object | Account-specific data and settings |
#### Example Usage
```liquid
Logged in as: {{ user.displayName }} ({{ user.email }})
{% if user.accountData.preferences.notifications %}
Notifications are enabled.
{% endif %}
```
---
### Attestation
An **Attestation** is flexible data attached to a specific proof. It's used to store additional information like warranty registrations, tasting notes, service records, etc.
| Field | Type | Description |
|-------|------|-------------|
| `attestation.id` | string | Unique identifier |
| `attestation.public` | object | Public attestation data (varies by type) |
| `attestation.private` | object | Private attestation data (varies by type) |
| `attestation.proof` | object | Associated proof reference/data |
| `attestation.createdAt` | datetime | When the attestation was created |
| `attestation.updatedAt` | datetime | When the attestation was last updated |
**Note**: The `public` and `private` objects contain custom fields based on your use case.
#### Example Usage
```liquid
{% if attestation.public.type == "warranty_registration" %}
Warranty Registration Details:
- Registered: {{ attestation.createdAt | date: "%B %d, %Y" }}
- Purchase Date: {{ attestation.public.purchaseDate }}
- Store: {{ attestation.public.storeName }}
{% endif %}
{% if attestation.public.type == "tasting_note" %}
🍷 Tasting Note:
"{{ attestation.public.notes }}"
Rating: {{ attestation.public.rating }}/5
{% endif %}
{% if attestation.private.internalNotes %}
Notes: {{ attestation.private.internalNotes }}
{% endif %}
```
---
## Liquid Filters
Liquid provides built-in filters to transform data. Common filters include:
### Text Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `upcase` | Convert to uppercase | `{{ product.name \| upcase }}` |
| `downcase` | Convert to lowercase | `{{ product.name \| downcase }}` |
| `capitalize` | Capitalize first letter | `{{ contact.name \| capitalize }}` |
| `truncate` | Limit string length | `{{ product.description \| truncate: 100 }}` |
| `strip_html` | Remove HTML tags | `{{ content \| strip_html }}` |
| `escape` | HTML escape special chars | `{{ user_input \| escape }}` |
| `default` | Fallback value if empty | `{{ contact.name \| default: "Customer" }}` |
### Date Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `date` | Format a date | `{{ proof.claimedAt \| date: "%B %d, %Y" }}` |
Common date formats:
- `%B %d, %Y` → January 15, 2025
- `%Y-%m-%d` → 2025-01-15
- `%d/%m/%Y` → 15/01/2025
- `%H:%M` → 14:30
### Array Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `join` | Join array elements | `{{ product.tags \| join: ", " }}` |
| `first` | Get first element | `{{ product.images \| first }}` |
| `last` | Get last element | `{{ product.images \| last }}` |
| `size` | Get array length | `{{ product.tags.size }}` |
| `sort` | Sort array | `{{ items \| sort: "name" }}` |
### Number Filters
| Filter | Description | Example |
|--------|-------------|---------|
| `plus` | Add | `{{ count \| plus: 1 }}` |
| `minus` | Subtract | `{{ total \| minus: discount }}` |
| `times` | Multiply | `{{ price \| times: quantity }}` |
| `divided_by` | Divide | `{{ total \| divided_by: 2 }}` |
| `round` | Round number | `{{ average \| round: 2 }}` |
---
## Control Flow
### Conditionals
```liquid
{% if proof.claimed %}
This item is claimed.
{% elsif proof.status == "pending" %}
Claim pending verification.
{% else %}
Available to claim.
{% endif %}
{% unless contact.email %}
No email on file.
{% endunless %}
```
### Operators
| Operator | Description |
|----------|-------------|
| `==` | Equals |
| `!=` | Not equals |
| `>` | Greater than |
| `<` | Less than |
| `>=` | Greater than or equal |
| `<=` | Less than or equal |
| `or` | Logical OR |
| `and` | Logical AND |
| `contains` | String/array contains |
```liquid
{% if product.tags contains "premium" %}
🌟 Premium Product
{% endif %}
{% if contact.email and proof.claimed %}
Send confirmation to {{ contact.email }}
{% endif %}
```
### Loops
```liquid
{% for tag in product.tags %}
{{ tag }}
{% endfor %}
{% for image in product.images limit: 3 %}
{% endfor %}
```
Loop variables:
- `forloop.index` — Current iteration (1-indexed)
- `forloop.index0` — Current iteration (0-indexed)
- `forloop.first` — Is this the first iteration?
- `forloop.last` — Is this the last iteration?
- `forloop.length` — Total number of iterations
---
## Common Use Cases
### Email Templates
```liquid
Subject: Your {{ product.name }} has been registered!
Hi {{ contact.firstName | default: contact.displayName | default: "there" }},
Great news! Your {{ product.name }}{% if proof.values.serialNumber %} (Serial: {{ proof.values.serialNumber }}){% endif %}
has been successfully registered to your account.
{% if product.data.warranty_years %}
Your warranty is valid for {{ product.data.warranty_years }} years
from the date of purchase.
{% endif %}
If you have any questions, please contact {{ collection.title }} support.
Best regards,
The {{ collection.title }} Team
```
### Notification Messages
```liquid
🎉 {{ contact.firstName }}, your {{ product.name }} is now verified!
{% if proof.values.shortCode %}Proof ID: {{ proof.values.shortCode }}{% endif %}
```
### Dynamic Content Blocks
```liquid
{% if proof.values.tier == "gold" %}
As a Gold member, you get exclusive access to...
{% elsif proof.values.tier == "silver" %}
Your Silver membership includes...
{% endif %}
```
### Multilingual Content
```liquid
{% case contact.locale %}
{% when "de" %}
Vielen Dank für Ihre Registrierung!
{% when "fr" %}
Merci pour votre inscription!
{% when "es" %}
¡Gracias por registrarte!
{% else %}
Thank you for registering!
{% endcase %}
```
---
## Accessing Nested Data
Use dot notation to access nested fields in data objects:
```liquid
{{ product.data.manufacturer }}
{{ attestation.public.warranty.expiryDate }}
{{ contact.customFields.vip_level }}
{{ proof.values.serialNumber }}
```
For dynamic keys, you may need to use bracket notation (if supported):
```liquid
{{ product.data["custom-field"] }}
```
---
## Best Practices
1. **Always use `default` filter** for optional fields to avoid blank output:
```liquid
{{ contact.displayName | default: contact.firstName | default: "Valued Customer" }}
```
2. **Escape user-generated content** when outputting as HTML:
```liquid
{{ attestation.public.userNotes | escape }}
```
3. **Check for existence** before accessing nested data:
```liquid
{% if proof.values.warranty %}
Warranty: {{ proof.values.warranty.type }}
{% endif %}
```
4. **Use meaningful fallbacks** for a better user experience:
```liquid
Hi {{ contact.firstName | default: contact.displayName | default: "there" }},
```
5. **Format dates appropriately** for the user's locale:
```liquid
{{ proof.createdAt | date: "%d %B %Y" }}
```
---
## API Context
Different APIs provide different objects in the Liquid context:
| API / Feature | Available Objects |
|---------------|-------------------|
| Email Templates | `collection`, `product`, `proof`, `contact`, `attestation` |
| Push Notifications | `collection`, `product`, `proof`, `contact` |
| SMS Messages | `collection`, `product`, `proof`, `contact` |
| Wallet Passes | `collection`, `product`, `proof`, `contact` |
| Journey Actions | `collection`, `product`, `proof`, `contact`, `event` |
| Broadcast Campaigns | `collection`, `contact`, `segment` |
Check the specific API documentation for the exact objects available in each context.
---
## Further Resources
- [Liquid Template Language Documentation](https://shopify.github.io/liquid/)
- [Liquid for Designers](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers)
- SmartLinks Template API Reference
---
# SDK guide: Microapp Development Overview
Source: https://docs.smartlinks.app/docs/sdk/guides/overview
Platform overview, SDK minimums, and how to build SmartLinks microapps.
# SmartLinks Microapp Development Guide
> **Platform revision:** R4.6 · **SDK minimum:** `@proveanything/smartlinks@1.4.1`
> **Last updated:** 2026-03-03
---
## What Is a SmartLinks Microapp?
SmartLinks microapps are **modular, embeddable React applications** that extend the SmartLinks digital twin platform. They provide specialised functionality — product information displays, warranty registration, competitions, pamphlet generators, and more — while inheriting context, authentication, and theming from the parent platform.
### The Digital Twin Ecosystem
SmartLinks is a **digital twin platform** that connects physical products to digital experiences. Each physical item (a wine bottle, a luxury handbag, a piece of equipment) has a corresponding digital identity — a "proof" — that can be scanned, claimed, and enriched with data over time.
Microapps are the **extensibility layer** of this ecosystem. Rather than building monolithic features into the core platform, functionality is distributed across purpose-built apps that:
- **Embed seamlessly** via iframes in the SmartLinks Portal (public) and Admin Console (management)
- **Share context** through URL parameters (collection, product, or proof being viewed)
- **Inherit identity** from the parent platform's authentication system
- **Adapt visually** to the brand's theme configuration
- **Communicate bidirectionally** with the parent via postMessage for deep linking and navigation
### Deployment Modes
Each microapp can be consumed in one or more of the following ways:
| Mode | Description |
|------|-------------|
| **Container** | Full app in parent React tree. ~150 KB+ lazy-loaded. The primary consumer surface. |
| **Iframe App** | Full React app inside an iframe. Fallback when sandboxing is required; still the standard for setup admin screens. |
| **Widget** | Lightweight React component in parent tree. ~10 KB, loaded immediately alongside the page. |
| **Mobile Admin Container** | Separate bundle for in-the-field operator/admin workflows on mobile. Built independently; may use Capacitor, Preact, or any other runtime. |
| **Executor** | JS library, no UI. Programmatic config, SEO metadata, LLM content for AI/server. |
Widgets and containers run in the parent's React tree (not iframes). Mobile Admin Containers are a separate bundle loaded by a dedicated mobile shell (Capacitor or PWA). Executors have no UI — they expose functions that AI orchestrators and the server call directly.
> **Admin vs consumer:** Admin experiences always ship as a **separate bundle** (`mobileAdmin` or iframe). The `containers` bundle is for consumer-facing surfaces only.
---
## SDK Documentation Reference
The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive documentation in `node_modules/@proveanything/smartlinks/docs/`. **Always read these files for detailed implementation guidance.**
> **Minimum SDK version: `1.4.1`** — Ensure `@proveanything/smartlinks` is at least this version. If not, update with `npm install @proveanything/smartlinks@latest`.
| Topic | File | When to Use |
|-------|------|-------------|
| **API Reference** | `docs/API_SUMMARY.md` | Complete SDK function reference, types, error handling |
| **Building React Components** | `docs/building-react-components.md` | **READ THIS FIRST** — Dual-mode rendering, router rules, useAppContext pattern |
| **Multi-Page Architecture** | `docs/mpa.md` | Build pipeline, entry points, multi-page setup, content hashing |
| **AI & Chat** | `docs/ai.md` | Chat completions, RAG, streaming, tool calling, voice, podcasts, TTS |
| **Analytics** | `docs/analytics.md` | Fire-and-forget page/click/tag analytics plus admin dashboard queries |
| **Translations** | `docs/translations.md` | Runtime translation lookup, local-first IndexedDB caching, and translation admin APIs |
| **Theming** | `docs/theme.system.md` | Implementing dynamic themes via URL params or postMessage |
| **Theme Defaults** | `docs/theme-defaults.md` | Default colour values for light/dark modes |
| **Internationalization** | `docs/i18n.md` | Adding multi-language support, translation patterns |
| **Widgets** | `docs/widgets.md` | Building widgets, shared deps contract, settings schema |
| **Containers** | `docs/containers.md` | Building full-app embeddable containers (lazy-loaded) |
| **Mobile Admin Container** | `docs/mobile-admin-container.md` | Building a separate Capacitor-aware mobile admin bundle for field operators |
| **Executors** | `docs/executor.md` | Building executor bundles for SEO, LLM content, programmatic config |
| **Deep Linking** | `docs/deep-link-discovery.md` | URL state management, navigable states, portal menus, AI nav |
| **Interactions** | `docs/interactions.md` | Business events, outcomes, voting, competitions, and journey triggers |
| **AI-Native Manifests** | `docs/manifests.md` | `app.manifest.json`, `app.admin.json`, `ai-guide.md` structure |
| **App Config Files** | `docs/app-manifest.md` | Full field-by-field reference for both JSON config files |
| **Real-time Messaging** | `docs/realtime.md` | Adding Ably real-time features (chat, live updates) |
| **Liquid Templates** | `docs/liquid-templates.md` | Dynamic content rendering with LiquidJS |
| **Iframe Responder** | `docs/iframe-responder.md` | Iframe communication and proxy mode internals |
| **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
| **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
| **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
---
## Technical Overview
- **Iframe-based** — Apps run inside iframes in both the public Portal and Admin Console
- **Hash routing** — Uses `/#/` routes for iframe compatibility (e.g., `/#/`, `/#/admin`)
- **Context via URL params** — All contextual data is passed through URL parameters
- **SmartLinks NPM module** — All data access and platform interaction goes through `@proveanything/smartlinks`
- **No standalone auth** — Authentication is handled by the parent SmartLinks platform
- **Multi-page build** — Separate bundles for public and admin — see `docs/mpa.md`
---
## Data Model Hierarchy
```
Collection (Business/Brand)
└── Product (Product Type/Definition)
└── Proof (Specific Instance with Owner)
└── Attestation (User/Admin Data on Proof)
```
| Entity | Description | Example |
|--------|-------------|---------|
| **Collection** | Top-level container representing a business or brand | "Acme Wine Co." |
| **Product** | A type of product (not a specific item) | "2023 Cabernet Sauvignon" |
| **Proof** | A specific instance of a product, often with an owner | Bottle #12345 claimed by user@email.com |
| **Attestation** | Data attached to a proof by users or admins | Warranty registration, tasting notes |
### URL Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `collectionId` | string | The collection being viewed/managed |
| `productId` | string | The specific product (within collection) |
| `proofId` | string | The specific proof instance |
| `appId` | string | This app's unique identifier (for data scoping) |
| `dark` | `1` or `0` | Whether to use dark mode |
| `theme` | base64 | Full theme configuration (see `docs/theme.system.md`) |
---
## Authentication & Permissions
SmartLinks apps do **not** implement their own authentication. The parent platform handles all auth:
- Use `SL.auth.getAccount()` to check if a user is logged in
- The response includes whether the user is an `admin` or regular user
- Many SmartLinks API functions behave differently based on admin status
| Capability | Admin | Regular User |
|------------|-------|-------------|
| Read collection/product data | ✅ | ✅ |
| Write app configuration | ✅ | ❌ |
| Write attestations | ✅ | ✅ (own proofs only) |
| Access admin-only API fields | ✅ | ❌ |
---
## Data Storage Patterns
### ⚠️ Critical: Admin Mode Flag
**When building admin interfaces, you MUST pass `admin: true` in API options.**
Without this flag, API calls use the public endpoint and will fail to write data:
```typescript
// ❌ WRONG — will fail in admin interface
await SL.appConfiguration.setConfig({ collectionId, appId, config: myConfig });
// ✅ CORRECT — include admin: true
await SL.appConfiguration.setConfig({ collectionId, appId, config: myConfig, admin: true });
```
This applies to all write operations: `setConfig`, `setDataItem`, `updateDataItem`, etc.
### Endpoint Auth vs Data Visibility
`admin: true` is an endpoint selector, not a privacy marker.
- It tells the SDK to call the admin endpoint.
- It allows writes and admin reads.
- It does **not** mean every root-level field you save becomes admin-only.
For `appConfiguration` config blobs and `collection.getSettings()` groups, root-level fields are typically the public-facing settings. If you need private values such as tokens or secrets, store them inside a top-level `admin` object:
```typescript
await SL.appConfiguration.setConfig({
collectionId,
appId,
admin: true,
config: {
publicLabel: 'Warranty Portal',
color: '#B68C2A',
admin: {
accessToken: 'secret-token'
}
}
})
```
Public reads omit the `admin` block. Admin reads include it.
### Config vs Data
| Storage Type | Function | Use Case |
|-------------|---------|---------|
| **Config** (`getConfig`/`setConfig`) | Single JSON document | App settings, feature flags, global options |
| **Data** (`getData`/`setDataItem`) | Array of documents with IDs | Lists of items, records, entries |
| **App Objects** (`app.records` / `app.cases` / `app.threads`) | Queryable domain objects | Real app entities, workflows, conversations, richer access control |
Both can be scoped to **collection level** or **product level** by including `productId`.
For config/settings visibility, remember: root fields are the normal shared payload, while a reserved top-level `admin` object is the place for admin-only values.
Prefer `app.records` over `setDataItem` when the data is becoming a real entity that needs lifecycle, ownership, visibility, relationships, or filtering. Keep `setDataItem` for simple keyed scoped documents and config-adjacent content.
### Attestations (Proof-level data)
For data attached to specific proof instances, use `SL.attestation.create()` and `SL.attestation.list()`.
---
## Error Handling
The SmartLinks SDK returns structured `SmartlinksApiError` objects. Use `instanceof SmartlinksApiError` to access `.statusCode`, `.message`, and helper methods:
- `.isAuthError()` — 401/403 responses
- `.isNotFound()` — 404 responses
- `.isRateLimited()` — 429 responses
- `.isServerError()` — 5xx responses
See `docs/API_SUMMARY.md` for complete error handling documentation.
---
## AI Integration
SmartLinks provides comprehensive AI capabilities through the `SL.ai` namespace. **Always use SmartLinks AI for microapps** — do not integrate external AI services (OpenAI, Anthropic, Lovable Cloud AI, etc.) directly.
Key capabilities: chat completions (streaming + tool calling), RAG document Q&A, voice input/output, podcast generation, text-to-speech, model listing.
See `docs/ai.md` for complete documentation.
---
## Interactions & Event Tracking
The `SL.interactions` namespace tracks user engagement — competition entries, votes, form submissions, warranty registrations. Events can trigger automated journeys and communications.
Key functions: `submitPublicEvent()`, `appendEvent()` (admin), `countsByOutcome()`, `query()`.
See `docs/interactions.md` for complete documentation.
---
## Liquid Templating
Use the `` component, `useLiquidTemplate` hook, and `renderLiquid` utility for admin-configured dynamic content using Liquid syntax.
See `docs/liquid-templates.md` for full context variables, filters, and examples.
---
## Design Principles
### Public Interface (Portal)
- **Mobile-first** — Assume users are on phones
- **Non-technical** — Never show raw IDs or technical data
- **Accessible** — Follow WCAG guidelines
### Admin Interface
- **Feature-rich** — Power users expect advanced controls
- **Efficient** — Minimise clicks for common tasks
- **Desktop-optimised** — Admins typically use desktop browsers
### Theming
Apps receive theme configuration from the parent platform via URL parameter (`?theme=base64…`) and PostMessage updates. See `docs/theme.system.md`.
---
## Common Hooks
```typescript
// Fetch collection/product/proof data
useCollectionData(collectionId)
useProductData(collectionId, productId)
useProofData(collectionId, productId, proofId)
// Fetch app configuration
useCollectionAppConfig(collectionId, appId)
useProductAppConfig(collectionId, productId, appId)
// Fetch app data (array of items)
useCollectionAppData(collectionId, appId)
useProductAppData(collectionId, productId, appId)
// Fetch attestations
useAttestationsData(collectionId, productId, proofId)
// URL parameter management
usePersistentQueryParams()
// Theme management
useSmartLinksTheme()
// Liquid templating
useLiquidTemplate(templateString, contextData)
```
---
## API Namespaces
The SmartLinks SDK is organised into namespaces. See `docs/API_SUMMARY.md` for the complete reference. Key namespaces:
- `appConfiguration` — Config and data storage (most apps use this)
- `attestation` — Proof-level user/admin data
- `interactions` — Event tracking and analytics
- `ai` — AI content generation, chat, RAG (see `docs/ai.md`)
- `auth` / `authKit` — Admin and end-user authentication
- `contact` — Customer contact management
- `comms` / `broadcasts` — Notifications and campaigns
- `asset` — File/image uploads
- `realtime` — Ably real-time messaging (see `docs/realtime.md`)
---
## File Structure
```
├── index.html ← Public portal entry
├── admin.html ← Admin console entry
├── public/
│ ├── app.manifest.json ← Public discovery manifest
│ ├── app.admin.json ← Admin manifest (on-demand)
│ └── ai-guide.md ← AI orchestrator prose guide
├── src/
│ ├── main.tsx ← Public React entry
│ ├── admin-main.tsx ← Admin React entry
│ ├── App.tsx ← Shared providers (QueryClient, Toaster, etc.)
│ ├── PublicApp.tsx ← Public app shell (routes only public pages)
│ ├── AdminApp.tsx ← Admin app shell (routes only admin pages)
│ ├── components/ ← Reusable UI components
│ ├── containers/ ← Embeddable full-app containers
│ ├── executor/ ← Executor bundle (SEO, LLM content, config API)
│ ├── hooks/ ← React hooks (data fetching, state)
│ ├── pages/
│ │ ├── PublicPage.tsx ← Portal content (/#/)
│ │ ├── AdminPage.tsx ← Admin content (admin.html#/)
│ │ └── DevPage.tsx ← Development helper (dev only)
│ ├── widgets/ ← Embeddable widget components
│ └── utils/
│ ├── smartlinks/ ← SmartLinks utilities
│ └── theme.ts ← Theme utilities
├── vite.config.ts ← Multi-page build config
├── vite.config.widget.ts ← Widget library build config
├── vite.config.container.ts ← Container library build config
└── vite.config.executor.ts ← Executor library build config
```
---
## Anti-Patterns to Avoid
❌ **Don't implement custom auth** — Use SmartLinks auth via `SL.auth.getAccount()`
❌ **Don't use BrowserRouter** — Always use HashRouter for iframe compatibility
❌ **Don't hardcode URLs** — Use relative paths and let the platform provide context
❌ **Don't store secrets in code** — Use environment variables or platform configuration
❌ **Don't assume screen size** — Support both mobile (Portal) and desktop (Admin)
❌ **Don't expose technical IDs** — Show human-readable names to users
❌ **Don't import AdminPage in PublicApp** — Keep bundles separate for optimal size
❌ **Don't use external AI services** — Use SmartLinks AI via `SL.ai`, not OpenAI/Anthropic/Lovable Cloud AI directly
❌ **Don't forget `admin: true`** — All admin write operations require this flag
---
## Quick Reference
### Initialise the API
```typescript
import { initializeAPI } from '@/utils/smartlinks/initialization';
initializeAPI(); // Call once at app startup
```
### Get Context from URL
```typescript
const { persistentQueryParams } = usePersistentQueryParams();
const { collectionId, productId, appId } = persistentQueryParams;
```
### Check Admin Status
```typescript
const account = await SL.auth.getAccount();
const isAdmin = account?.admin === true;
```
### Save Configuration (Admin Only)
```typescript
await SL.appConfiguration.setConfig({
collectionId, appId,
config: { myKey: 'myValue' },
admin: true // Required!
});
```
### Handle Dark Mode + Theming
```typescript
// In App.tsx — already integrated in the template
useDarkMode(); // Handles ?dark=1 parameter
useSmartLinksTheme(); // Handles ?theme=base64 parameter
useDeepLinkSync(); // Syncs route changes to parent
```
### Deep Linking
```typescript
const { navigateWithState, getAppState } = usePersistentQueryParams();
const { tab = 'general' } = getAppState();
navigateWithState('/', { tab: 'advanced' });
```
See `docs/deep-link-discovery.md` for full implementation details.
---
# SDK guide: iframe Responder
Source: https://docs.smartlinks.app/docs/sdk/guides/iframe-responder
Parent-side iframe communication with API proxying, auth sync, and deep linking.
# IframeResponder - Parent-Side Iframe Communication
The `IframeResponder` enables bidirectional communication between a parent application and an embedded iframe, both using the SmartLinks SDK. This allows seamless integration of microapps within your application with automatic API proxying, authentication sync, and deep linking.
## Features
- **Automatic URL Resolution** - Fetches app configuration from collection and resolves the correct URL
- **API Request Proxying** - Forwards API requests from iframe to parent's authenticated session
- **Smart Caching** - Reduces API calls by serving cached collection, product, and proof data
- **Authentication Sync** - Handles login/logout events between parent and iframe
- **Deep Linking** - Synchronizes iframe routes with parent URL state
- **Responsive Sizing** - Calculates and reports optimal iframe dimensions
- **Chunked File Uploads** - Proxies large file uploads through the parent
## Basic Usage
### 1. Create and Attach Responder
```typescript
import * as smartlinks from '@proveanything/smartlinks';
// Initialize SDK
smartlinks.initializeApi({
baseURL: 'https://api.smartlinks.io',
apiKey: 'your-api-key',
});
// Create responder (URL resolved automatically from collection apps)
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty-registration',
productId: 'wine-bottle-123', // Optional context
onAuthLogin: async (token, user) => {
// Handle authentication from iframe
smartlinks.setBearerToken(token);
console.log('User logged in:', user);
},
onResize: (height) => {
// Update iframe height
iframeElement.style.height = `${height}px`;
},
});
// Attach to iframe element
const iframe = document.getElementById('app-iframe') as HTMLIFrameElement;
const src = await responder.attach(iframe);
iframe.src = src;
// Cleanup when done
// responder.destroy();
```
### 2. With Pre-cached Data (Faster Loading)
```typescript
// Fetch data upfront
const collection = await smartlinks.collection.get('acme-wines');
const product = await smartlinks.product.get('wine-bottle-123');
const user = await smartlinks.auth.getAccountInfo();
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty-registration',
productId: 'wine-bottle-123',
// Pre-populate cache for instant API responses
cache: {
collection,
product,
user: user ? {
uid: user.uid,
email: user.email,
displayName: user.displayName,
accountData: user.accountData,
} : null,
},
onAuthLogin: async (token, user) => {
smartlinks.setBearerToken(token);
},
});
const src = await responder.attach(iframeElement);
iframeElement.src = src;
```
## Configuration Options
### IframeResponderOptions
```typescript
interface IframeResponderOptions {
// Required
collectionId: string; // Collection context
appId: string; // App to load (e.g., 'warranty-registration')
// Optional Context
productId?: string; // Product context
proofId?: string; // Proof context
isAdmin?: boolean; // Admin mode flag
// URL Configuration
version?: 'stable' | 'development'; // App version (default: 'stable')
appUrl?: string; // Override URL (for local dev)
initialPath?: string; // Initial hash path (e.g., '/settings')
// Data
cache?: CachedData; // Pre-cached data
// Callbacks
onAuthLogin?: (token: string, user: any, accountData?: any) => Promise;
onAuthLogout?: () => Promise;
onRouteChange?: (path: string, state: Record) => void;
onResize?: (height: number) => void;
onError?: (error: Error) => void;
onReady?: () => void;
}
```
## Advanced Examples
### Deep Linking / Route Synchronization
Keep the parent URL in sync with iframe navigation:
```typescript
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty',
initialPath: '/products/wine-123',
onRouteChange: (path, state) => {
// Update parent URL with iframe route
const url = new URL(window.location.href);
url.searchParams.set('app-path', path);
Object.entries(state).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
window.history.pushState({}, '', url);
},
});
```
### Local Development Override
```typescript
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty',
// Override URL for local development
appUrl: 'http://localhost:5173',
version: 'development',
});
```
### Admin Mode with Role Detection
```typescript
const user = await smartlinks.auth.getAccountInfo();
const collection = await smartlinks.collection.get('acme-wines');
const isAdmin = smartlinks.isAdminFromRoles(user, collection);
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty',
isAdmin, // Pass admin status to iframe
cache: { collection, user },
});
```
### Responsive Height Management
```typescript
const responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty',
onResize: (height) => {
// Smooth height transitions
iframe.style.transition = 'height 0.3s ease';
iframe.style.height = `${height}px`;
// Or calculate viewport-based height
const maxHeight = window.innerHeight - 100;
iframe.style.height = `${Math.min(height, maxHeight)}px`;
},
});
```
## Helper Functions
### buildIframeSrc()
Manually construct an iframe src URL without using the full responder:
```typescript
const src = smartlinks.buildIframeSrc({
appUrl: 'https://warranty.lovable.app',
collectionId: 'acme-wines',
appId: 'warranty',
productId: 'wine-123',
isAdmin: false,
dark: true,
theme: {
primary: '#FF6B6B',
secondary: '#4ECDC4',
},
initialPath: '/register',
});
iframe.src = src;
```
### isAdminFromRoles()
Check if a user has admin access:
```typescript
const user = await smartlinks.auth.getAccountInfo();
const collection = await smartlinks.collection.get('acme-wines');
const isAdmin = smartlinks.isAdminFromRoles(user, collection);
// or with proof
const isAdminOfProof = smartlinks.isAdminFromRoles(user, collection, proof);
```
## Cache Utilities
The `cache` module provides TTL-based caching:
```typescript
import * as smartlinks from '@proveanything/smartlinks';
// Cache with 5-minute TTL
const apps = await smartlinks.cache.getOrFetch(
'apps:acme-wines',
() => smartlinks.collection.getApps('acme-wines'),
{ ttl: 5 * 60 * 1000, storage: 'session' }
);
// Invalidate cache
smartlinks.cache.invalidate('apps:acme-wines');
// Clear all cache
smartlinks.cache.clear();
```
### Cache Storage Options
- `memory` - In-memory only (default, cleared on page reload)
- `session` - sessionStorage (persists across page reloads in same tab)
- `local` - localStorage (persists across browser sessions)
## Backend API Requirement
The SDK uses the existing `getAppsConfig()` endpoint:
```
GET /public/collection/:collectionId/apps-config
Response:
{
"apps": [
{
"id": "warranty-registration",
"srcAppId": "warranty-v2",
"name": "Warranty Registration",
"publicIframeUrl": "https://warranty.lovable.app",
"active": true,
"category": "Commerce",
"usage": {
"collection": true,
"product": true,
"proof": false,
"widget": false
}
}
]
}
```
Each app must have a `publicIframeUrl` configured for the IframeResponder to work.
## Complete Integration Example
```typescript
import * as smartlinks from '@proveanything/smartlinks';
// Initialize SDK
smartlinks.initializeApi({
baseURL: 'https://api.smartlinks.io',
apiKey: 'your-api-key',
});
class AppManager {
private responder: smartlinks.IframeResponder | null = null;
async loadApp(containerId: string) {
// Create iframe
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.border = 'none';
document.getElementById(containerId)?.appendChild(iframe);
// Fetch context data
const collection = await smartlinks.collection.get('acme-wines');
const product = await smartlinks.product.get('wine-123');
// Determine admin status
try {
const user = await smartlinks.auth.getAccountInfo();
const isAdmin = smartlinks.isAdminFromRoles(user, collection);
// Create responder
this.responder = new smartlinks.IframeResponder({
collectionId: 'acme-wines',
appId: 'warranty',
productId: 'wine-123',
isAdmin,
cache: { collection, product, user },
onAuthLogin: async (token, user) => {
smartlinks.setBearerToken(token);
this.responder?.updateCache({ user });
},
onAuthLogout: async () => {
smartlinks.setBearerToken('');
this.responder?.updateCache({ user: null });
},
onRouteChange: (path, state) => {
console.log('Route changed:', path, state);
},
onResize: (height) => {
iframe.style.height = `${height}px`;
},
onError: (error) => {
console.error('Iframe error:', error);
},
});
// Attach and load
const src = await this.responder.attach(iframe);
iframe.src = src;
} catch (err) {
console.error('Failed to load app:', err);
}
}
destroy() {
this.responder?.destroy();
this.responder = null;
}
}
// Usage
const manager = new AppManager();
await manager.loadApp('app-container');
// Cleanup on unmount
// manager.destroy();
```
## Message Protocol
The responder handles these message types from the iframe:
### Route Changes
```typescript
{
type: 'smartlinks-route-change',
path: '/products/wine-123',
context: { collectionId: 'acme-wines', appId: 'warranty' },
state: { tab: 'details' }
}
```
### Resize Events
```typescript
{
_smartlinksIframeMessage: true,
type: 'smartlinks:resize',
payload: { height: 800 }
}
```
### Authentication
```typescript
{
_smartlinksIframeMessage: true,
type: 'smartlinks:authkit:login',
payload: { token: '...', user: {...}, messageId: 'abc123' }
}
```
### API Proxy
```typescript
{
_smartlinksProxyRequest: true,
id: 'req-123',
method: 'GET',
path: 'public/product/wine-123',
headers: { 'Authorization': 'Bearer ...' }
}
```
## TypeScript Support
All types are exported for full TypeScript support:
```typescript
import type {
IframeResponderOptions,
CachedData,
CollectionApp,
RouteChangeMessage,
SmartlinksIframeMessage,
} from '@proveanything/smartlinks';
```
## Browser Compatibility
- Modern browsers with ES6+ support
- Requires `postMessage`, `MessageEvent`, `ResizeObserver` APIs
- Falls back gracefully in Node.js environments (no-ops for browser-only features)
## Best Practices
1. **Pre-cache data** when possible to eliminate loading delays
2. **Handle onError** to catch and log communication issues
3. **Clean up** responder with `destroy()` when unmounting
4. **Use version control** to test against development versions
5. **Validate tokens** server-side for security
6. **Set height dynamically** based on content using `onResize`
7. **Sync routes** with parent navigation for better UX
## Troubleshooting
### App not loading
- Verify the appId exists in collection apps configuration
- Check browser console for errors
- Ensure backend API endpoint is available
### Authentication not syncing
- Implement `onAuthLogin` callback
- Verify token is being set with `setBearerToken()`
- Check that auth messages are being received
### Height not updating
- Implement `onResize` callback
- Ensure iframe has CSS styling for height changes
- Check that iframe is sending resize messages
### Cache not working
- Verify cache storage option is supported (sessionStorage/localStorage)
- Check browser storage isn't disabled
- Clear cache and retry with `smartlinks.cache.clear()`
---
# SDK guide: Container Tracking
Source: https://docs.smartlinks.app/docs/sdk/guides/container-tracking
Physical or logical groupings with hierarchical nesting, item membership, and attestation history.
# Container Tracking
> Physical or logical groupings with hierarchical nesting, item membership, and attestation history.
---
## Table of Contents
- [Overview](#overview)
- [TypeScript Interfaces](#typescript-interfaces)
- [SDK Usage](#sdk-usage)
- [Admin — Containers](#admin--containers)
- [Admin — Item Membership](#admin--item-membership)
- [Public — Read-Only](#public--read-only)
- [REST API Reference](#rest-api-reference)
- [Attestations on Containers](#attestations-on-containers)
- [Cold-Chain Tracking Example](#cold-chain-tracking-example)
- [Design Notes](#design-notes)
---
## Overview
A **container** is any physical or logical grouping that can hold items and accumulate a history. Examples:
| `containerType` | What it represents |
|---|---|
| `'pallet'` | A pallet of goods |
| `'fridge'` | A refrigerated unit |
| `'cask'` | A whiskey cask |
| `'shipping_container'` | A maritime shipping container |
| `'warehouse'` | A storage facility |
| `'ship'` | A vessel carrying other containers |
Containers support:
- **Hierarchical nesting** — a ship can contain shipping containers, which contain pallets, which contain individual items.
- **Item membership** — any `tag`, `proof`, `serial`, `order_item`, or nested `container` can be placed in and removed from a container. Membership is tracked with timestamps so the full history is available.
- **Attestations** — measurements and facts (temperature readings, ABV checks, condition reports, etc.) can be appended to any container using the `attestations` namespace.
- **Soft-delete** — deleting a container only sets `deletedAt`; the record and full item history are preserved.
---
## TypeScript Interfaces
```typescript
type ContainerStatus = 'active' | 'archived' | string
type ContainerItemType = 'tag' | 'proof' | 'serial' | 'order_item' | 'container'
interface Container {
id: string
orgId: string
collectionId: string
containerType: string // 'pallet' | 'fridge' | 'cask' | …
ref?: string // Human-readable identifier / barcode
name?: string
description?: string
status: ContainerStatus // default 'active'
metadata?: Record
parentContainerId?: string // null = top-level
children?: Container[] // Populated when ?tree=true
items?: ContainerItem[] // Populated when ?includeContents=true
createdAt: string
updatedAt: string
deletedAt?: string
}
interface ContainerItem {
id: string
orgId: string
containerId: string
collectionId?: string
itemType: ContainerItemType
itemId: string
productId?: string
proofId?: string
addedAt: string
removedAt?: string // null = currently inside; present in history view
metadata?: Record
}
interface CreateContainerInput {
containerType: string // required
ref?: string
name?: string
description?: string
status?: ContainerStatus
metadata?: Record
parentContainerId?: string
}
interface UpdateContainerInput {
containerType?: string
ref?: string
name?: string
description?: string
status?: ContainerStatus
metadata?: Record
parentContainerId?: string | null // null promotes to top-level
}
interface AddContainerItemsInput {
items: Array<{
itemType: ContainerItemType // required
itemId: string // required
productId?: string
proofId?: string
metadata?: Record
}>
}
interface RemoveContainerItemsInput {
ids: string[] // ContainerItem UUIDs to soft-remove
}
```
---
## SDK Usage
Import via the top-level SDK export:
```typescript
import { containers } from '@proveanything/smartlinks'
```
### Admin — Containers
#### Create a container
```typescript
const cask = await containers.create('coll_123', {
containerType: 'cask',
ref: 'CASK-0042',
name: 'Cask 42 — Single Malt',
metadata: { distilleryYear: 2019, capacityLitres: 200 },
})
```
#### List containers
```typescript
// All active pallets
const { containers: pallets } = await containers.list('coll_123', {
containerType: 'pallet',
status: 'active',
limit: 50,
})
// Top-level containers only (no parent)
const { containers: roots } = await containers.list('coll_123', { topLevel: true })
```
#### Get a container — flat, tree, or with contents
```typescript
// Flat (default)
const cask = await containers.get('coll_123', 'cask-uuid')
// Full hierarchy tree (3 levels deep) with current contents
const tree = await containers.get('coll_123', 'warehouse-uuid', {
tree: true,
treeDepth: 3,
includeContents: true,
})
```
#### Find containers currently holding an item
```typescript
const { containers: holding } = await containers.findForItem('coll_123', {
itemType: 'proof',
itemId: 'proof-uuid',
})
```
#### Update a container
```typescript
const updated = await containers.update('coll_123', 'cask-uuid', {
status: 'archived',
metadata: { bottledAt: '2025-04-01' },
})
// Promote to top-level (remove parent)
await containers.update('coll_123', 'cask-uuid', { parentContainerId: null })
```
#### Delete (soft) a container
```typescript
await containers.remove('coll_123', 'cask-uuid')
// deletedAt is set; record remains queryable by admins
```
### Admin — Item Membership
#### List current contents
```typescript
const { items } = await containers.listItems('coll_123', 'pallet-uuid')
```
#### List full membership history (including removed items)
```typescript
const { items: history } = await containers.listItems('coll_123', 'pallet-uuid', {
history: true,
})
// history includes items with removedAt !== null
```
#### Add items to a container
```typescript
const { items } = await containers.addItems('coll_123', 'pallet-uuid', {
items: [
{ itemType: 'tag', itemId: 'NFC-00AABBCC' },
{ itemType: 'proof', itemId: 'proof-uuid', productId: 'product-id' },
{ itemType: 'container', itemId: 'inner-crate-uuid' },
],
})
```
#### Remove items from a container (soft)
```typescript
const result = await containers.removeItems('coll_123', 'pallet-uuid', {
ids: ['container-item-uuid-1', 'container-item-uuid-2'],
})
console.log(`Removed ${result.removedCount} items`)
```
### Public — Read-Only
```typescript
// List (excludes soft-deleted and non-public containers)
const { containers: list } = await containers.publicList('coll_123', {
containerType: 'cask',
})
// Get with tree
const tree = await containers.publicGet('coll_123', 'warehouse-uuid', {
tree: true,
})
// Current contents only (no history on public side)
const { items } = await containers.publicListItems('coll_123', 'cask-uuid')
```
---
## REST API Reference
### Admin endpoints
```
POST /api/v1/admin/collection/:collectionId/containers
GET /api/v1/admin/collection/:collectionId/containers
GET /api/v1/admin/collection/:collectionId/containers/find-for-item
GET /api/v1/admin/collection/:collectionId/containers/:containerId
PATCH /api/v1/admin/collection/:collectionId/containers/:containerId
DELETE /api/v1/admin/collection/:collectionId/containers/:containerId
GET /api/v1/admin/collection/:collectionId/containers/:containerId/items
POST /api/v1/admin/collection/:collectionId/containers/:containerId/items
DELETE /api/v1/admin/collection/:collectionId/containers/:containerId/items
```
#### GET /containers query parameters
| Parameter | Description |
|---|---|
| `containerType` | Filter by type string |
| `status` | Filter by status |
| `ref` | Filter by reference |
| `parentContainerId` | Filter by parent UUID |
| `topLevel` | `true` to return only root containers |
| `limit` | Default `100` |
| `offset` | Default `0` |
#### GET /containers/:id query parameters
| Parameter | Description |
|---|---|
| `tree` | `true` to recursively embed child containers |
| `treeDepth` | Max nesting depth (default: unlimited) |
| `includeContents` | `true` to embed current items |
#### GET /containers/:id/items query parameters
| Parameter | Description |
|---|---|
| `history` | `true` to include removed items |
| `limit` | Default `100` |
| `offset` | Default `0` |
#### POST /containers/:id/items body
```json
{
"items": [
{ "itemType": "tag", "itemId": "NFC-00AABBCC" },
{ "itemType": "proof", "itemId": "proof-uuid", "productId": "product-id" }
]
}
```
#### DELETE /containers/:id/items body
```json
{ "ids": ["container-item-uuid-1", "container-item-uuid-2"] }
```
### Public endpoints
```
GET /api/v1/public/collection/:collectionId/containers
GET /api/v1/public/collection/:collectionId/containers/:containerId
GET /api/v1/public/collection/:collectionId/containers/:containerId/items
```
Same query parameters as admin (minus `history`). Soft-deleted containers and containers with `metadata.publicListing === false` are excluded from list results.
---
## Attestations on Containers
Attestations (sensor readings, condition reports, etc.) are managed via the `attestations` namespace. The admin router does **not** expose attestation sub-routes — use the standalone `/admin/.../attestations` router for all admin attestation access.
```typescript
import { attestations } from '@proveanything/smartlinks'
// Record a temperature reading against a cask
await attestations.create('coll_123', {
subjectType: 'container',
subjectId: 'cask-uuid',
attestationType: 'temperature',
recordedAt: new Date().toISOString(),
value: { celsius: 12.4 },
unit: '°C',
})
// Latest readings on all attestation types
const { latest } = await attestations.latest('coll_123', {
subjectType: 'container',
subjectId: 'cask-uuid',
})
```
Public shortcuts pre-scoped to a container are also available:
```typescript
const { latest } = await attestations.publicContainerLatest('coll_123', 'cask-uuid')
const { summary } = await attestations.publicContainerSummary('coll_123', 'cask-uuid', {
attestationType: 'temperature',
valueField: 'celsius',
groupBy: 'hour',
})
```
---
## Cold-Chain Tracking Example
**Goal:** determine whether a refrigerated item was inside a fridge when the temperature exceeded a threshold.
```typescript
import { containers, attestations } from '@proveanything/smartlinks'
const collectionId = 'coll_123'
const fridgeId = 'fridge-uuid'
const proofId = 'bottle-proof-uuid'
const threshold = 8 // °C
// 1. Get the full item membership history for the fridge
const { items: history } = await containers.listItems(collectionId, fridgeId, {
history: true,
})
// 2. Find the window(s) when our bottle was inside the fridge
const bottleIntervals = history
.filter(item => item.itemType === 'proof' && item.itemId === proofId)
.map(item => ({
addedAt: new Date(item.addedAt),
removedAt: item.removedAt ? new Date(item.removedAt) : new Date(),
}))
// 3. Get temperature attestations for the fridge
const { attestations: temps } = await attestations.list(collectionId, {
subjectType: 'container',
subjectId: fridgeId,
attestationType: 'temperature',
limit: 1000,
})
// 4. Cross-reference
const excursions = temps.filter(a => {
const celsius = a.value?.celsius as number
const recorded = new Date(a.recordedAt)
if (celsius <= threshold) return false
return bottleIntervals.some(
({ addedAt, removedAt }) => recorded >= addedAt && recorded <= removedAt
)
})
if (excursions.length > 0) {
console.warn(
`⚠️ ${excursions.length} temperature excursion(s) recorded while the bottle was in the fridge.`
)
} else {
console.log('✅ No temperature excursions during the bottle\'s time in the fridge.')
}
```
---
## Design Notes
### Soft-delete
`DELETE /containers/:id` only sets `deletedAt`. The container and its full item history remain queryable by admins. The public API automatically excludes deleted containers from all responses.
### Membership history
`ContainerItem` records are never deleted. When an item is removed, `removedAt` is set. This means you always have a full audit trail of what was in a container and when, which is essential for provenance and compliance use-cases.
### Public visibility
Containers with `metadata.publicListing === false` are excluded from public list endpoints but remain accessible by direct ID if the caller knows the UUID. Use this to hide containers from general browsing while still allowing deep-link access.
### Nesting
Containers can be nested arbitrarily. A container with `containerType='container'` in the `items` list is a child container. The `findForItem` endpoint performs a flat lookup across all containers — it does not traverse the hierarchy.
### Attestation ownership for containers
Owner elevation on attestation public endpoints resolves ownership via `container.metadata.proofId`. If you want individual users to receive owner-tier attestation data for a container, set `metadata.proofId` to a proof they own.
---
# SDK guide: App Records Pattern
Source: https://docs.smartlinks.app/docs/sdk/guides/app-records-pattern
Canonical guide for microapps storing per-product, per-variant, per-batch, or rule-targeted data.
# SmartLinks App Records Pattern
> Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
>
> Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …).
>
> Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate.
>
> SDK: `@proveanything/smartlinks` ≥ **1.11**.
> Admin shell (React only): `@proveanything/smartlinks-utils-ui` ≥ **0.7.6** — required for the admin side if using the React shell; not needed in public widgets.
---
## 0. TL;DR — pick your shape, then copy the snippet
Every records-based app fits into a 2×2:
| | **Singleton** (one record per scope) | **Collection** (many records per scope) |
| ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------- |
| **Best-match (one wins)**| Ingredients, nutrition, washing instructions | _(rare — usually you want all)_ |
| **All matches (aggregate)** | _(rare — usually you want best)_ | FAQs, recipes, SOPs, care tips, story cards |
That choice drives **three** things and nothing else:
1. **Manifest:** `cardinality: 'singleton' | 'collection'` and `allowFacetRules: boolean`.
2. **Admin:** use `` (React) or call the admin SDK functions directly. Pass `cardinality` + include `'rule'` in `scopes` if `allowFacetRules`.
3. **Public widget:** call `app.records.match()` (best match / singleton) or `app.records.resolveAll()` (all matches / collection). These are plain SDK calls with no framework dependency.
If you only remember one rule: **never write your own resolution loop**. The server already walks the chain correctly — calling `match()` or `resolveAll()` is the entire public-side implementation.
---
## 1. The data model in one paragraph
A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). Records also carry a `status` (`'active'` | `'draft'` | `'archived'`) and optional `startsAt` / `expiresAt` timestamps. The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
```ts
import * as SL from '@proveanything/smartlinks';
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref
data: { /* domain payload */ },
}, /* admin */ true);
```
Or, for a rule-targeted record:
```ts
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
facetRule: {
all: [
{ facetKey: 'brand', anyOf: ['acme'] },
{ facetKey: 'category', anyOf: ['bread', 'pastry'] },
],
},
data: { /* domain payload */ },
}, true);
```
`scope` and `facetRule` are **mutually exclusive on save**.
---
## 2. Resolution order (one canonical chain)
The server walks **most-specific → least-specific** and stops at the first match (for best-match) or collects every match (for aggregate):
```
proof → batch → variant → product → rule(*) → facet(*) → collection
```
- `rule(*)` — facet-rule records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins.
- `facet(*)` — legacy single-facet anchors, walked deterministically (alphabetical).
- `collection` — the top of the chain. **There is no "global" tier above collection.** A collection-level record is the catch-all for that collection.
The resolved value comes back tagged with `matchedAt: 'product' | 'rule' | 'facet' | …` so the UI can say things like _"Matched by rule: brand=Acme AND category=bread"_.
> ⚠️ Legacy `scope.facets[]` (colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. Use `facetRule` for everything that isn't a one-off facet pin.
---
## 3. Manifest declaration
Declare each record type once in `app.admin.json`. The shell and the platform read this to render the right scope tabs and disable the wrong affordances.
```json
{
"records": {
"ingredients": {
"label": "Ingredients",
"cardinality": "singleton",
"allowFacetRules": true,
"scopes": ["collection", "facet", "rule", "product", "variant", "batch"],
"defaultScope": "product"
},
"faq": {
"label": "FAQs",
"cardinality": "collection",
"allowFacetRules": true,
"scopes": ["collection", "rule", "product"],
"defaultScope": "collection"
}
}
}
```
| Field | Meaning |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `cardinality` | `'singleton'` (one per scope, e.g. ingredients) or `'collection'` (many per scope, e.g. FAQs). Default `'singleton'`. |
| `allowFacetRules` | `true` to enable the `rule` scope tab + `` in the shell. Default `false`. |
| `scopes` | Allowed scope kinds in **resolution order**. `'rule'` is a synthetic scope that holds rule-targeted records. |
| `defaultScope` | Where the "Create new" button lands. |
| `label` | Human-readable label used in headings and toasts. |
---
## 4. Admin side
> The admin shell and rule editor are part of `@proveanything/smartlinks-utils-ui`, which is a **React-only library**. It is only needed in admin dashboards — never import it in a public widget.
### `` (React)
The shell owns: scope tabs, browser pane, rule editor, save/discard, dirty navigation, inheritance markers, deletion, CSV, bulk apply, deep linking. **You only supply the editor for one record's `data`.**
```tsx
import * as SL from '@proveanything/smartlinks';
import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';
SL={SL}
collectionId={collectionId}
appId={appId}
recordType="ingredients"
label="Ingredients"
cardinality="singleton" // ← from manifest
scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']}
defaultScope="product"
defaultData={() => emptyConfig()}
renderEditor={(ctx) => (
)}
/>
```
### What the shell gives you for free
- **Scope tabs** including a **`Rule`** tab when `'rule'` is in `scopes`. Selecting it opens `` above your editor — no extra wiring.
- **`EditorContext.facetRule` / `onFacetRuleChange`** for rule-scoped records, plus `canSave: false` until at least one clause has values (avoids server 500s).
- **Inheritance markers** — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers.
- **Collection cardinality flow** — set `cardinality="collection"` and the shell turns the right pane into a list of items (table / cards / gallery) with `+ New` and per-item nav.
- **Telemetry** — `record.save`, `record.delete`, `scope.change`, `csv.import`, `bulk.apply`, `item.create`, etc. via `onTelemetry`.
### Standalone rule editor
If you need a rule editor outside the shell (e.g. on a settings page):
```tsx
import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor';
```
---
## 5. Public side
> **Do not import `@proveanything/smartlinks-utils-ui` in a public widget.** It is a React admin library. Public widgets only need `@proveanything/smartlinks`.
The SDK is framework-agnostic. Public widgets call two endpoints depending on cardinality:
| Cardinality | Call | What it does |
|---|---|---|
| **Singleton** (one answer) | `app.records.match()` | Server walks the chain, returns the best-matching record |
| **Collection** (all answers) | `app.records.resolveAll()` | Server walks the chain, returns every matching record |
Neither call requires React or any other framework — wrap them in whatever async pattern your widget uses.
> **Admin vs public — the rule is simple:**
>
> | Function | Public widget | Admin dashboard |
> |---|---|---|
> | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
> | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
> | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
> | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
> | `app.records.restore()` | ❌ admin only — no public path | ✅ |
> | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
### 5a. Singleton — `app.records.match()` (best match wins)
Use when the widget shows **one** answer for the current product (ingredients, nutrition, warranty terms, washing instructions).
```ts
import * as SL from '@proveanything/smartlinks';
const result = await SL.app.records.match(collectionId, appId, {
target: { productId, variantId, batchId }, // pass whatever context you have
strategy: 'best',
recordType: 'ingredients',
});
// result.data[0] → the single highest-specificity MatchEntry (when strategy: 'best')
// result.data[0].matchedAt → 'product' | 'rule' | 'facet' | 'collection' | …
// result.data[0].data → your record payload
```
The server walks `proof → batch → variant → product → rule → facet → collection` and returns the first match. `result.data` will have at most one entry when `strategy: 'best'`.
### 5b. Collection — `app.records.resolveAll()` (every match, ordered)
Use when the widget shows **many** answers across the chain (FAQs, recipes, care tips, SOPs).
```ts
const result = await SL.app.records.resolveAll(collectionId, appId, {
context: { productId }, // note: resolveAll uses 'context', not 'target'
recordType: 'faq', // singular — omit to return all record types
});
// result.records → ResolveAllEntry[] sorted most-specific first
// each entry: { record: AppRecord, matchedAt, specificity, matchedRule? }
```
### 5c. Multi-type — `app.records.resolveAll()` without a recordType filter
When you need records of all types for a context in one call (rare; executors, SEO surfaces), omit `recordType`:
```ts
const result = await SL.app.records.resolveAll(collectionId, appId, {
context: {
productId,
facets: { brand: ['acme'] }, // include facets to match rule records
},
// no recordType → returns all declared types
});
// result.records → one ResolveAllEntry per matched record, all types interleaved
// filter client-side by entry.record.recordType if you need to separate them
```
### 5d. Status filtering and the draft → active lifecycle
Every record has a `status` field with three canonical values:
| Value | Meaning | Returned to public/owner callers? |
|---|---|---|
| `active` | Live and current | ✅ Yes |
| `draft` | Being prepared, not yet published | ❌ No |
| `archived` | Previously live, retained for history | ❌ No |
**Enforcement:** `match()`, `resolveAll()`, and `GET /records` (query) now only return `status: "active"` records to public and owner callers. Admin callers receive all statuses as before; use explicit `status` filters (`status=draft`, `status=archived`) to narrow results.
**`active` is the default** when no `status` is supplied on creation, so existing records and simple creation flows are unaffected.
**Draft → publish workflow:** create the record with `status: 'draft'` so it is invisible to public widgets, then update it to `status: 'active'` when ready to publish.
```ts
// Create a record that is not yet publicly visible
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId },
data: { /* draft payload */ },
status: 'draft',
}, /* admin */ true);
// Publish it
await SL.app.records.upsert(collectionId, appId, {
recordType: 'ingredients',
scope: { productId },
data: { /* final payload */ },
status: 'active',
}, true);
```
**Composes with `startsAt` / `expiresAt`:** a record must satisfy **both** the status check and the time window to be returned to public callers. A record that is `active` but whose `startsAt` is in the future, or whose `expiresAt` has passed, is excluded.
---
### Common mistakes (do not do these)
| ❌ Anti-pattern | ✅ Do this instead |
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
| Importing anything from `@proveanything/smartlinks-utils-ui` in a public widget | That package is React-only and admin-only. Public widgets only use `@proveanything/smartlinks`. |
| Calling `SL.app.records.list()` and filtering client-side | `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). The server walks the chain. |
| Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. |
| Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. |
| Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag — it defaults to `false`. |
| Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data; it never writes records. |
| Walking the chain by hand with multiple `get` / `list` calls | One `match()` or `resolveAll()` call. The server handles the resolution order including rules. |
| Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
| Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
| Expecting `draft` or `archived` records to appear in public widget results | Public/owner callers only receive `status: "active"` records from `match()`, `resolveAll()`, and `GET /records`. Use admin calls to query by other statuses. |
| Setting `status: 'active'` and wondering why a record is still hidden | Check `startsAt` / `expiresAt` — a record must satisfy both the status check and the time window. |
---
## 6. Reference: the `EditorContext` your `renderEditor` receives
```ts
interface EditorContext {
value: TData;
onChange: (next: TData) => void;
source: 'self' | 'inherited' | 'empty';
recordId?: string;
parentValue?: TData | null;
scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
// Save lifecycle
isDirty: boolean;
isSaving?: boolean;
saveError?: unknown | null;
canSave?: boolean; // shell flips to false on empty rules
cannotSaveReason?: string;
save: () => Promise;
reset: () => void;
// Deletion
remove: () => Promise;
canRemove: boolean;
// Rule scope only
facetRule?: FacetRule | null;
onFacetRuleChange?: (next: FacetRule | null) => void;
}
```
---
## 7. Migration checklist (existing apps)
1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`.
2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`.
3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`.
4. **Pass `cardinality`** to ``.
5. **Replace any handwritten chain walking** with `app.records.match()` (singleton) or `app.records.resolveAll()` (collection). If you are using React, the `useResolvedRecord` / `useCollectedRecords` hooks from `@proveanything/smartlinks-utils-ui` wrap these calls — but they are **admin-side React helpers**, not for public widgets.
6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `` (React admin) or pass `facetRule` directly in `upsert()` calls.
7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
8. **Audit records that should not be public yet:** any record that previously relied on obscurity (e.g. not linked in the widget, no active product) is now filtered by `status`. Set `status: 'draft'` on records that are not ready and `status: 'active'` when publishing. Records without an explicit status were created as `active` and are unaffected.
---
## 8. Where the canonical exports live
### Public widgets (any framework)
| Need | Import from |
| ---- | ----------- |
| Best-match resolution (singleton) | `@proveanything/smartlinks` → `SL.app.records.match()` |
| All-matches resolution (collection) | `@proveanything/smartlinks` → `SL.app.records.resolveAll()` |
| Record CRUD (public path) | `@proveanything/smartlinks` → `SL.app.records.{list, get, create, update, remove, aggregate}` |
### Admin dashboards (React)
> All of the following are from `@proveanything/smartlinks-utils-ui`, a **React-only** package. Do not use in public widgets.
| Need | Import from |
| ---- | ----------- |
| Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` → `RecordsAdminShell` |
| Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` |
| Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` |
| Best-match hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` |
| All-matches hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useCollectedRecords` |
| Multi-type hook (React convenience wrapper) | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` |
| Rule preview hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` |
| Admin record CRUD (admin path) | `@proveanything/smartlinks` → `SL.app.records.{upsert, bulkUpsert, bulkDelete, restore, previewRule}` |
---
_End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
---
# SDK guide: Communications & Broadcasts
Source: https://docs.smartlinks.app/docs/sdk/guides/comms
Transactional sends, multi-channel broadcasts, consent management, push registration, and analytics.
# Communications (Comms & Broadcasts)
This guide covers the full communications surface of the SDK: transactional sends, multi-channel broadcasts, consent management, push registration, and analytics.
---
## What You Can Do
- **Transactional Send** — Fire a single targeted message to one contact without creating a broadcast record.
- **Broadcasts** — Define and enqueue multi-channel campaigns (email, push, SMS, wallet); preview and test before sending.
- **Comms Settings** — Configure topics and unsubscribe behaviour per collection.
- **Consent & Preferences** — Record opt-ins/outs and per-subject preferences.
- **Method Registration** — Register email, SMS, and Web Push contact methods.
- **Analytics** — Query delivery events, recipient lists, and log custom events.
---
## Transactional Send
Sends a single message to one contact using a template. No broadcast record is created. The send is logged to the contact's communication history with `sourceType: 'transactional'`.
**Endpoint:** `POST /admin/collection/:collectionId/comm.send`
```typescript
import { comms } from '@proveanything/smartlinks'
import type { TransactionalSendRequest } from '@proveanything/smartlinks'
const result = await comms.sendTransactional('collectionId', {
contactId: 'e4f2a1b0-...',
templateId: 'warranty-update',
channel: 'preferred', // 'email' | 'sms' | 'push' | 'wallet' | 'preferred' (default)
props: { claimRef: 'CLM-0042', decision: 'approved' },
include: {
productId: 'prod-abc123', // hydrate product context into template
appCase: 'c9d1e2f3-...', // hydrate an app case record
},
ref: 'warranty-decision-notification', // arbitrary string for your own tracking
appId: 'warrantyApp',
})
if (result.ok) {
console.log(`Sent via ${result.channel}`, result.messageId)
} else {
console.error('Send failed:', result.error)
}
```
### Request Body
| Field | Type | Description |
|-------|------|-------------|
| `contactId` | `string` | **Required.** Contact to send to. |
| `templateId` | `string` | **Required.** Template to render. |
| `channel` | `'email' \| 'sms' \| 'push' \| 'wallet' \| 'preferred'` | Channel to send on. `'preferred'` (default) picks the contact's best available channel. |
| `props` | `Record` | Extra variables merged into template rendering. |
| `include` | object | Hydration flags — see table below. |
| `ref` | `string` | Arbitrary reference string stored with the event. |
| `appId` | `string` | App identifier, used for context/logging. |
**`include` fields:**
| Field | Type | Description |
|-------|------|-------------|
| `collection` | `boolean` | Include collection data in template context. |
| `productId` | `string` | Hydrate a specific product. |
| `proofId` | `string` | Hydrate a specific proof. |
| `user` | `boolean` | Include the requesting user's data. |
| `appCase` | `string` | Hydrate an app case record by ID. |
| `appThread` | `string` | Hydrate an app thread record by ID. |
| `appRecord` | `string` | Hydrate an app record by ID. |
### Response
```typescript
// Success
{ ok: true; channel: 'email' | 'sms' | 'push' | 'wallet'; messageId?: string }
// Failure
{ ok: false; error: string }
```
---
## Broadcasts
Broadcasts are multi-channel campaigns defined and managed under a collection. Create a broadcast record in the platform first, then use the SDK to preview, test, and enqueue.
**Import:**
```typescript
import { broadcasts } from '@proveanything/smartlinks'
import type { BroadcastSendRequest } from '@proveanything/smartlinks'
```
### Preview
Render a broadcast template for a given channel without sending.
```typescript
const preview = await broadcasts.preview('collectionId', 'broadcastId', {
email: 'user@example.com', // optional: override recipient email for preview
channelOverride: 'push', // force a specific channel
props: { productId: 'prod_123' },
hydrate: true,
include: { product: true, proof: true }
})
// preview.channel — which channel was rendered
// preview.payload / preview.subject / preview.body — channel-specific render output
```
### Test Send
Send a single test message to a contact.
```typescript
await broadcasts.sendTest('collectionId', 'broadcastId', {
contactId: 'contact_123',
props: { promo: 'JAN' }
})
```
### Enqueue Production Send
Enqueue a background send to all recipients. When `channel` is omitted the broadcast's own mode setting drives channel selection:
- `preferred` — picks the best single channel per recipient.
- `channels` / `all` — sends on every enabled channel.
```typescript
const body: BroadcastSendRequest = {
pageSize: 200,
hydrate: true,
include: { product: true, proof: true, user: true }
}
await broadcasts.send('collectionId', 'broadcastId', body)
```
### Manual Send
```typescript
await broadcasts.sendManual('collectionId', 'broadcastId', { /* overrides */ })
```
### Topic Targeting and Consent
A broadcast must declare a `topic` under `broadcast.data.topic` (e.g. `newsletter`, `critical`). The topic key must match one defined in your collection's comms settings, and consent enforcement uses it at send time.
Consent resolution order (per recipient, per channel):
1. `preferences._default.topicsByChannel[channel][topic]`
2. `preferences._default.channels[channel]`
3. `preferences._default.topics[topic]`
4. Subject-specific preferences (when hydrating a subject context)
Minimal broadcast data shape:
```json
{
"data": {
"topic": "newsletter",
"channelSettings": {
"mode": "preferred",
"channels": [
{ "channel": "email", "enabled": true, "priority": 1, "templateId": "tmpl_newsletter_email" },
{ "channel": "push", "enabled": true, "priority": 2 }
]
}
}
}
```
### Append Recipients and Events
```typescript
// Append a single delivery event
await broadcasts.append('collectionId', {
broadcastId: 'broadcastId',
contactId: 'contact_123',
channel: 'email',
eventType: 'delivered',
outcome: 'success'
})
// Append bulk events
await broadcasts.appendBulk('collectionId', {
params: { broadcastId: 'broadcastId', channel: 'push' },
ids: ['contact_1', 'contact_2'],
idField: 'contactId'
})
// List recipients
const recips = await broadcasts.recipients('collectionId', 'broadcastId', { limit: 50 })
```
---
## Admin Comms Settings
Configure unsubscribe behaviour and topics per collection.
**Base path:** `admin/collection/:collectionId/comm.settings`
### Get Settings
```typescript
import { comms } from '@proveanything/smartlinks'
const { settings } = await comms.getSettings('collectionId', { includeSecret: true })
// settings.unsub — { requireToken, hasSecret }
// settings.topics — map of topic key → config
```
### Patch Settings
```typescript
await comms.patchSettings('collectionId', {
unsub: { requireToken: true, secret: '' },
topics: { /* ... */ }
})
```
To clear the unsubscribe secret, send `secret: ''`.
### Topics Config
Each topic key maps to a config object:
```json
{
"newsletter": {
"label": "Newsletter",
"description": "Occasional updates and stories",
"classification": "marketing",
"defaults": {
"policy": "opt-in",
"byChannel": { "email": "opt-in", "push": "opt-in" },
"channels": { "email": true, "push": true, "sms": false }
},
"rules": {
"allowChannels": ["email", "sms", "push"],
"allowUnsubscribe": true
},
"required": false
},
"critical": {
"label": "Critical Notices",
"classification": "transactional",
"defaults": {
"policy": "opt-out",
"channels": { "email": true, "push": true }
},
"rules": { "allowChannels": ["email", "push"], "allowUnsubscribe": false },
"required": true
}
}
```
- `classification`: `'transactional'` or `'marketing'` — used by the UI when no explicit policy is set.
- `defaults.policy`: `'opt-in'` or `'opt-out'` — fallback policy for channels not overridden by `byChannel`.
- `required: true` and `rules.allowUnsubscribe: false` — use for critical/mandatory messages.
### Unsubscribe Tokens
If `unsub.requireToken` is true, public unsubscribe calls must include a `token` query param.
Token generation (topic opt-out):
```
basis = "${contactId}:${topic}"
token = sha256(basis + ":" + unsub.secret) // hex
```
Token generation (channel opt-out):
```
basis = "${contactId}:channel:${channel}"
token = sha256(basis + ":" + unsub.secret)
```
Browser helper:
```typescript
async function sha256Hex(input: string): Promise {
const data = new TextEncoder().encode(input)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
const token = await sha256Hex(`${contactId}:${topic}:${secret}`)
```
---
## Public Comms API
All public endpoints live under `public/collection/:collectionId/comm/*` and do not require an API key.
### Get Topics
```typescript
const { topics } = await comms.getPublicTopics('collectionId')
```
Each topic carries UI metadata:
- `classification`: `'transactional'` | `'marketing'`
- `defaults.policy`: `'opt-in'` | `'opt-out'`
- `defaults.byChannel[channel]`: per-channel override
Helper to resolve effective policy for a toggle UI:
```typescript
type Policy = 'opt-in' | 'opt-out'
function effectivePolicy(topic: any, channel: 'email' | 'sms' | 'push' | 'wallet'): Policy {
return (
topic?.defaults?.byChannel?.[channel] ??
topic?.defaults?.policy ??
(topic?.classification === 'marketing' ? 'opt-in' : 'opt-out')
) as Policy
}
```
### Consent (Default)
Record a contact's default channel and topic opt-ins.
```typescript
await comms.upsertConsent('collectionId', {
contactId: 'contact_123',
channels: { email: true, push: true },
topics: { newsletter: true, critical: true },
topicsByChannel: { email: { newsletter: true }, push: { critical: true } }
})
```
### Preferences (Subject-Specific)
Record consent specific to a product, proof, or other subject. Omit `subject` to update defaults.
```typescript
await comms.upsertPreferences('collectionId', {
contactId: 'contact_123',
subject: { type: 'product', id: 'prod_1' },
channels: { email: true },
topics: { updates: true }
})
```
### Subscribe / Unsubscribe (Subject)
Manage subscriptions to a specific subject (e.g. product updates, proof alerts).
```typescript
// Subscribe
const { subscriptionId } = await comms.subscribe('collectionId', {
contactId: 'contact_123',
subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' },
subscribe: true,
source: 'api'
})
// Unsubscribe (same call, subscribe: false)
await comms.subscribe('collectionId', {
contactId: 'contact_123',
subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' },
subscribe: false,
source: 'api'
})
```
### Check Subscription
```typescript
const r = await comms.checkSubscription('collectionId', {
contactId: 'contact_123',
subjectType: 'proof',
subjectId: 'prf_1',
productId: 'prod_1'
})
// r.subscribed === true | false
```
### Resolve Subscriptions
Find contacts that are subscribed to a subject using identity hints.
```typescript
const res = await comms.resolveSubscriptions('collectionId', {
subject: { type: 'product', id: 'prod_1' },
hints: { userId: 'user_1', email: 'user@example.com' }
})
```
### List and Register Methods
```typescript
// List registered methods for a contact
const { methods } = await comms.listMethods('collectionId', {
contactId: 'contact_123',
type: 'email' // optional filter: 'email' | 'sms' | 'push'
})
// Register email
await comms.registerEmail('collectionId', {
contactId: 'contact_123',
email: 'user@example.com'
})
// Register SMS
await comms.registerSms('collectionId', {
contactId: 'contact_123',
phone: '+12065550100'
})
```
### Unsubscribe (Public Link / One-Click)
```typescript
await comms.unsubscribe('collectionId', {
contactId: 'contact_123',
topic: 'newsletter',
token: ''
})
```
Omit `topic` to unsubscribe from all. Omit `channel` to apply across all channels.
---
## Push Registration
Web Push requires a service worker and the browser's Push API.
### 1. Fetch the VAPID Public Key
```typescript
import { comms } from '@proveanything/smartlinks'
const { publicKey } = await comms.getPushVapidPublicKey('collectionId')
```
### 2. Subscribe in the Service Worker
```typescript
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
const subJson = subscription.toJSON() as any
```
### 3. Register the Method
```typescript
await comms.registerPush('collectionId', {
contactId: 'contact_123',
endpoint: subscription.endpoint,
keys: subJson?.keys,
meta: { userId: 'user_1' }
})
```
### VAPID Key Utility
```typescript
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i)
return output
}
```
---
## Analytics
### Query Events by User / Contact
```typescript
const events = await comms.queryByUser('collectionId', {
userId: 'user_123',
limit: 100
})
```
### Recipient IDs for a Source
```typescript
const ids = await comms.queryRecipientIds('collectionId', {
broadcastId: 'br_456'
})
```
### Recipients Without an Action
```typescript
const withoutOpen = await comms.queryRecipientsWithoutAction('collectionId', {
broadcastId: 'br_456',
actionId: 'open'
})
```
### Recipients With an Action
```typescript
const withClick = await comms.queryRecipientsWithAction('collectionId', {
broadcastId: 'br_456',
actionId: 'click',
includeOutcome: true // returns RecipientWithOutcome[] instead of RecipientId[]
})
```
### Log Events
```typescript
// Single event
await comms.logCommunicationEvent('collectionId', {
eventType: 'opened',
channel: 'email',
contactId: 'contact_123'
})
// Bulk events (same params applied to all IDs)
await comms.logBulkCommunicationEvents('collectionId', {
params: { broadcastId: 'br_456', channel: 'email', eventType: 'delivered' },
ids: ['contact_1', 'contact_2', 'contact_3'],
idField: 'contactId'
})
```
---
## End-to-End Admin Workflow
A typical sequence from setup to delivery:
```typescript
import { comms, broadcasts } from '@proveanything/smartlinks'
// 1. Configure topics and unsubscribe rules
await comms.patchSettings('collectionId', {
unsub: { requireToken: true, secret: process.env.UNSUB_SECRET },
topics: {
newsletter: {
label: 'Newsletter', classification: 'marketing',
defaults: { policy: 'opt-in', channels: { email: true, push: true } },
rules: { allowChannels: ['email', 'push', 'sms'], allowUnsubscribe: true }
}
}
})
// 2. Register contact methods (done at contact creation / profile update)
await comms.registerEmail('collectionId', { contactId: 'c_123', email: 'user@example.com' })
// 3. Record consent (done when user confirms opt-in)
await comms.upsertConsent('collectionId', {
contactId: 'c_123',
channels: { email: true }, topics: { newsletter: true }
})
// 4a. Transactional: send a one-off triggered message
const result = await comms.sendTransactional('collectionId', {
contactId: 'c_123', templateId: 'order-confirmed',
channel: 'email', props: { orderId: 'ORD-001' }
})
// 4b. Broadcast: preview → test → enqueue
const preview = await broadcasts.preview('collectionId', 'br_spring', {
channelOverride: 'email', hydrate: true, include: { product: true }
})
await broadcasts.sendTest('collectionId', 'br_spring', { contactId: 'c_123' })
await broadcasts.send('collectionId', 'br_spring', { pageSize: 200, hydrate: true })
```
---
## Prerequisites
| Feature | Requirement |
|---------|-------------|
| Email | SendGrid credentials (env or collection comms settings) |
| Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` env vars |
| SMS | Twilio credentials (env or collection comms settings) |
| Wallet | Google Wallet issuer + service account |
| Hydration | Set `hydrate: true` and populate `include` on broadcast/transactional sends |
---
# SDK guide: Building React Components
Source: https://docs.smartlinks.app/docs/sdk/guides/building-react-components
Foundational concepts for building React components in the SmartLinks ecosystem.
# Building React Components for SmartLinks
> **Read this first** before implementing widgets or containers for the SmartLinks platform.
This guide covers the fundamental concepts you need to understand to build React components that work correctly in the SmartLinks ecosystem. For implementation details, see [widgets.md](./widgets.md) and [containers.md](./containers.md).
---
## Table of Contents
1. [Dual-Mode Rendering](#dual-mode-rendering)
2. [The Router Contract](#the-router-contract)
3. [The useAppContext Pattern](#the-useappcontext-pattern)
4. [Common Pitfalls](#common-pitfalls)
---
## Dual-Mode Rendering
Every SmartLinks component can run in **two modes**. The platform decides which mode to use based on configuration — your app doesn't choose.
| Mode | How It Works | When Used |
|------|-------------|-----------|
| **Direct Component** | Your component runs directly in the parent's React context | Default - better performance, shared context |
| **Iframe** | Your app runs inside an iframe with its own URL | Fallback - full isolation when needed |
**Key insight:** You write your component code once, and it must work correctly in **both** modes.
### What Changes Between Modes?
| Aspect | Direct Component Mode | Iframe Mode |
|--------|----------------------|-------------|
| **Context source** | Props passed from parent | URL search parameters |
| **Router** | Parent provides `MemoryRouter` | Your app provides `HashRouter` |
| **Styling** | Inherits parent's CSS scope | Fully isolated CSS |
| **SDK instance** | Shared with parent via props | Own instance from `window.SL` |
| **Communication** | Direct callbacks (props) | `postMessage` |
---
## The Router Contract
This is the **most important rule** to avoid runtime errors:
### ❌ The Critical Rule
> **Your exported component (`PublicContainer` or `PublicComponent`) must NOT be wrapped in any `` component.**
If you wrap your export in ``, ``, or ``, React Router will throw:
```
Error: You cannot render a inside another
```
### ✅ Where Routing Goes
**For Widgets (no routing needed):**
```tsx
// src/exports/PublicComponent.tsx
export const PublicComponent = (props) => {
return ; // No router needed
};
```
**For Containers (with internal navigation):**
```tsx
// src/exports/PublicContainer.tsx
import { Routes, Route } from 'react-router-dom';
export const PublicContainer = (props) => {
return (
{/* ✅ Routes are fine */}
} />
} />
);
};
```
**For Iframe Entry Point:**
```tsx
// src/App.tsx (only used in iframe mode)
import { HashRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
{/* ✅ HashRouter only in iframe entry point */}
} />
);
}
```
### Why This Matters
```
Direct Component Mode:
Portal App
└─ MemoryRouter ← Parent provides this
└─ Your Component
└─ [If you add another Router here, it breaks]
Iframe Mode: