Example: loyalty program
This page walks through a second end-to-end integration — the custom-data-centric counterpart of the Hiboutik POS example. Where Hiboutik pushes customers, products and orders into data sources, a loyalty integration revolves around custom data, real-time events and a widget. To stay generic we call the partner a loyalty platform; the same shape fits any points-and-tiers program.
What we build
- A custom data definition (
loyalty_account) holding one record per contact — balance, tier, progress to the next tier. - Three custom events triggered in real time from the integrator's app: points earned, tier changed, points expiring.
- A periodic custom-data sync that reconciles accounts changed since the last cursor.
- One static image widget: a progress bar to the next tier, rendered by ShopiMind into emails and pop-ups.
This integration uses three of the kit's higher-level helpers — ctx.customData(name) to upsert into the definition, SpmEvents.trigger to fire events, and provisioning to declare the definition and events idempotently. All of these are re-exported from the JavaScript integration kit, @shopimind/integration-kit-js.
1. Typed settings
The settings are minimal: the partner API base URL and an API key. parseSettings turns the raw form values into a typed object.
interface LoyaltySettings { apiBaseUrl: string; apiKey: string }
parseSettings: (raw) => ({
apiBaseUrl: String(raw.api_base_url ?? ''),
apiKey: String(raw.api_key ?? ''),
}),2. The config form
A single connection step, validated by test_connection when it completes. The API key is sensitive so the kit encrypts it at rest.
configSchema: {
steps: [{
key: 'connection',
label: { en: 'Connect your loyalty platform', fr: 'Connectez votre plateforme de fidélité' },
fields: [
{ key: 'api_base_url', type: 'url', required: true, label: { en: 'API base URL', fr: "URL de l'API" } },
{ key: 'api_key', type: 'password', required: true, sensitive: true, label: { en: 'API key', fr: 'Clé API' } },
],
on_complete: { action: 'test_connection' },
}],
},
testConnection: async (ctx) => { /* ping your platform with ctx.settings */ return true; },3. Provisioning — custom data + events
The provisioning function declares everything the integration needs server-side, idempotently, on activation. Here it declares one custom data definition and three events.
The loyalty_account definition keys records by contact_id (its unique_keys) and relates them to ShopiMind contacts through a system relationship, so the loyalty state attaches to the right profile. At push time the contact is resolved by email via the { by: 'email', value } resolver and the server stores the canonical id_contact.
const ACCOUNT = 'loyalty_account'; // our custom data definition name
provisioning: () => ({
customData: [{
name: ACCOUNT,
description: 'Loyalty account state — one record per contact.',
unique_keys: ['contact_id'],
fields: [
{ name: 'contact_id', type: 'number', required: true },
{ name: 'points_balance', type: 'number' },
{ name: 'tier', type: 'text' },
{ name: 'points_to_next_tier', type: 'number' },
{ name: 'tier_progress_pct', type: 'number' },
{ name: 'next_expiry_date', type: 'date' },
],
relationships: [
{ sourceField: 'contact_id', targetSchemaType: 'system', targetSchema: 'contacts', targetField: 'id_contact' },
],
}],
events: [
{ code_name: 'loyalty_points_earned', name: { en: 'Loyalty points earned', fr: 'Points de fidélité gagnés' } },
{ code_name: 'loyalty_tier_changed', name: { en: 'Loyalty tier changed', fr: 'Palier de fidélité modifié' } },
{ code_name: 'loyalty_points_expiring', name: { en: 'Loyalty points expiring', fr: 'Points bientôt expirés' } },
],
}),| Piece | Purpose |
|---|---|
customData | The definition records are upserted into; name matches ctx.customData(name). |
unique_keys: ['contact_id'] | Identifies a record on upsert — save keys by it. |
relationships | Binds contact_id to the system contacts schema (matched on id_contact). |
events | The three code_names you later fire with SpmEvents.trigger. |
Don't resolve ids by hand
ctx.customData(ACCOUNT) resolves the definition declared here and returns a CustomDataHandle { id, save(records) }. Never look up the definition id yourself — let the handle do it.
4. The static image widget
A widget lets ShopiMind render loyalty state inside emails and pop-ups. This one is a static image (render_type: 'image'): ShopiMind builds an <img> whose URL is the image_url_template, substituting the record fields and the widget config.
{var=loyalty_account.*}reads fields from the contact's custom data record.{var=widget.*}reads the widget's own config (here, the bar color).
widgets: [{
key: 'loyalty_progress',
name: { en: 'Loyalty progress bar', fr: 'Barre de progression fidélité' },
description: { en: 'Progress to the next loyalty tier.', fr: 'Progression vers le palier suivant.' },
targets: ['email_template', 'popup'],
render_type: 'image',
image_url_template:
'https://cdn.your-loyalty.example/progress.png?pct={var=loyalty_account.tier_progress_pct}&tier={var=loyalty_account.tier}&color={var=widget.bar_color}',
default_width: 480,
config_schema: {
fields: [
{ key: 'bar_color', type: 'color', default: '#94c840', strip_hash: true, preview_value: '94c840',
label: { en: 'Bar color', fr: 'Couleur de la barre' } },
],
},
}],Where the values come from
The merchant picks bar_color once when adding the widget. The loyalty_account.* values come from the custom data record you upsert (next sections) — so the bar reflects each contact's real progress at render time.
5. Real-time inbound — trigger events + upsert
Your app calls an inbound route whenever a customer's loyalty changes. The kit secures these routes with a per-installation HMAC (x-integration-*, secret = ctx.inboundSecret). The handler does two things:
- Upsert the account as custom data — a safe push, keyed by the unique key
contact_id(the contact is resolved by email at push time). - Fire the matching event in real time, which drives ShopiMind automation scenarios.
inbound: {
'account-updated': async (ctx, payload) => {
const p = payload as AccountUpdate;
// 1) Upsert the account as custom data (safe push, keyed by 'contact_id'; contact resolved by email).
await ctx.customData(ACCOUNT).save([toAccountRecord(p)]);
// 2) Fire the matching event in real time (drives ShopiMind automation scenarios).
if (p.change?.kind === 'earned') {
await SpmEvents.trigger(ctx.spm, 'loyalty_points_earned', {
email: p.email, points: p.change.points, new_balance: p.points_balance,
});
} else if (p.change?.kind === 'tier') {
await SpmEvents.trigger(ctx.spm, 'loyalty_tier_changed', {
email: p.email, previous_tier: p.change.previous_tier, new_tier: p.tier,
});
}
},
},SpmEvents.trigger(ctx.spm, codeName, payload) calls the SDK on the raw SpmHttpClient (ctx.spm). It returns the SDK envelope { ok, statusCode, data, error } and never throws on an HTTP error.
Hand your app the inbound secret
In hooks.onActivate, register your platform's webhook and pass it ctx.installationId and ctx.inboundSecret so it can sign the inbound calls above.
hooks: {
onActivate: async (ctx) => {
// await yourLoyaltyApi.registerWebhook(ctx.settings, {
// installationId: ctx.installationId, secret: ctx.inboundSecret,
// });
},
},The payload shape and the record mapper are tiny pure helpers:
interface AccountUpdate {
email: string; points_balance: number; tier: string;
points_to_next_tier: number; tier_progress_pct: number; next_expiry_date?: string;
change?: { kind: 'earned'; points: number } | { kind: 'tier'; previous_tier: string };
}
const toAccountRecord = (a: AccountUpdate) => ({
contact_id: { by: 'email', value: a.email }, // system-relation resolver -> server stores id_contact
points_balance: a.points_balance, tier: a.tier,
points_to_next_tier: a.points_to_next_tier, tier_progress_pct: a.tier_progress_pct,
next_expiry_date: a.next_expiry_date ?? null,
});6. Periodic custom-data sync
Real-time events keep ShopiMind fresh, but a periodic step reconciles anything the webhooks missed (downtime, replays). The sync step pulls accounts changed since the cursor and upserts them in pages with the same CustomDataHandle.
ctx.customData(ACCOUNT).save(...) is a safe push — chunked, transport-safe, surfacing per-item rejections — and returns { sent, rejected, rejected_items }. Returning advanceCursorTo: ctx.window.until moves the cursor only when the page batch is processed.
syncSteps: [{
entity: 'loyalty_accounts',
cursorScope: 'global',
enabled: () => true,
run: async (ctx) => {
const accounts = ctx.customData(ACCOUNT); // -> { id, save(records) }
let sent = 0;
const errors: string[] = [];
for await (const page of ctx.paginate((p) => fetchChangedAccounts(ctx.settings, ctx.window, p))) {
const counts = await accounts.save(page.map(toAccountRecord));
sent += counts.sent;
if (counts.rejected) errors.push(`${counts.rejected} account(s) rejected`);
}
return { items: sent, errors, advanceCursorTo: ctx.window.until };
},
}],declare function fetchChangedAccounts(
s: LoyaltySettings,
w: { since: Date | null; until: Date },
page: number,
): Promise<AccountUpdate[]>;Rejections hold the cursor
When save surfaces per-item rejections in a sync step, the engine holds the cursor so nothing is silently dropped. The next run replays the window. Opt out per-step with tolerateRejects: true only when a poison-pill record must not block progress.
7. The integration
Putting it together — defineIntegration wires settings, provisioning, the widget, the sync step, the inbound routes and the activation hook:
// integration.ts
import {
defineIntegration, SpmEvents,
type Integration,
} from '@shopimind/integration-kit-js';
interface LoyaltySettings { apiBaseUrl: string; apiKey: string }
const ACCOUNT = 'loyalty_account'; // our custom data definition name
export const loyalty: Integration<LoyaltySettings> = defineIntegration({
slug: 'loyalty',
meta: { name: 'Loyalty', version: '1.0.0', categories: ['loyalty'] },
configSchema: {
steps: [{
key: 'connection',
label: { en: 'Connect your loyalty platform', fr: 'Connectez votre plateforme de fidélité' },
fields: [
{ key: 'api_base_url', type: 'url', required: true, label: { en: 'API base URL', fr: "URL de l'API" } },
{ key: 'api_key', type: 'password', required: true, sensitive: true, label: { en: 'API key', fr: 'Clé API' } },
],
on_complete: { action: 'test_connection' },
}],
},
parseSettings: (raw) => ({ apiBaseUrl: String(raw.api_base_url ?? ''), apiKey: String(raw.api_key ?? '') }),
testConnection: async (ctx) => { /* ping your platform with ctx.settings */ return true; },
// Custom data definition + 3 events, created idempotently on activation.
provisioning: () => ({
customData: [{
name: ACCOUNT,
description: 'Loyalty account state — one record per contact.',
unique_keys: ['contact_id'],
fields: [
{ name: 'contact_id', type: 'number', required: true },
{ name: 'points_balance', type: 'number' },
{ name: 'tier', type: 'text' },
{ name: 'points_to_next_tier', type: 'number' },
{ name: 'tier_progress_pct', type: 'number' },
{ name: 'next_expiry_date', type: 'date' },
],
relationships: [
{ sourceField: 'contact_id', targetSchemaType: 'system', targetSchema: 'contacts', targetField: 'id_contact' },
],
}],
events: [
{ code_name: 'loyalty_points_earned', name: { en: 'Loyalty points earned', fr: 'Points de fidélité gagnés' } },
{ code_name: 'loyalty_tier_changed', name: { en: 'Loyalty tier changed', fr: 'Palier de fidélité modifié' } },
{ code_name: 'loyalty_points_expiring',name: { en: 'Loyalty points expiring', fr: 'Points bientôt expirés' } },
],
}),
// A STATIC IMAGE widget: a progress bar to the next tier, rendered by ShopiMind.
// {var=loyalty_account.*} read the custom data record; {var=widget.*} read the widget config.
widgets: [{
key: 'loyalty_progress',
name: { en: 'Loyalty progress bar', fr: 'Barre de progression fidélité' },
description: { en: 'Progress to the next loyalty tier.', fr: 'Progression vers le palier suivant.' },
targets: ['email_template', 'popup'],
render_type: 'image',
image_url_template:
'https://cdn.your-loyalty.example/progress.png?pct={var=loyalty_account.tier_progress_pct}&tier={var=loyalty_account.tier}&color={var=widget.bar_color}',
default_width: 480,
config_schema: {
fields: [
{ key: 'bar_color', type: 'color', default: '#94c840', strip_hash: true, preview_value: '94c840',
label: { en: 'Bar color', fr: 'Couleur de la barre' } },
],
},
}],
// Periodic reconciliation: pull accounts changed since the cursor, upsert as custom data.
syncSteps: [{
entity: 'loyalty_accounts',
cursorScope: 'global',
enabled: () => true,
run: async (ctx) => {
const accounts = ctx.customData(ACCOUNT); // -> { id, save(records) }
let sent = 0;
const errors: string[] = [];
for await (const page of ctx.paginate((p) => fetchChangedAccounts(ctx.settings, ctx.window, p))) {
const counts = await accounts.save(page.map(toAccountRecord));
sent += counts.sent;
if (counts.rejected) errors.push(`${counts.rejected} account(s) rejected`);
}
return { items: sent, errors, advanceCursorTo: ctx.window.until };
},
}],
// Real-time: YOUR app calls these inbound routes when a customer's loyalty changes.
inbound: {
'account-updated': async (ctx, payload) => {
const p = payload as AccountUpdate;
// 1) Upsert the account as custom data (safe push, keyed by contact_id; contact resolved by email).
await ctx.customData(ACCOUNT).save([toAccountRecord(p)]);
// 2) Fire the matching event in real time (drives ShopiMind automation scenarios).
if (p.change?.kind === 'earned') {
await SpmEvents.trigger(ctx.spm, 'loyalty_points_earned', {
email: p.email, points: p.change.points, new_balance: p.points_balance,
});
} else if (p.change?.kind === 'tier') {
await SpmEvents.trigger(ctx.spm, 'loyalty_tier_changed', {
email: p.email, previous_tier: p.change.previous_tier, new_tier: p.tier,
});
}
},
},
hooks: {
onActivate: async (ctx) => {
// Hand the per-installation inbound secret to your app so it can sign its inbound calls.
// await yourLoyaltyApi.registerWebhook(ctx.settings, { installationId: ctx.installationId, secret: ctx.inboundSecret });
},
},
});8. The entry point
createIntegrationApp assembles the runtime — the kit builds the SDK client itself, so the integration never instantiates the SDK here.
// main.ts
import 'dotenv/config';
import { createIntegrationApp } from '@shopimind/integration-kit-js';
import { loyalty } from './integration.js';
const env = process.env;
const app = createIntegrationApp(loyalty, {
databasePath: env.DATABASE_PATH ?? './data/loyalty.sqlite',
webhookSecret: env.WEBHOOK_SECRET!,
credentialsKey: env.CREDENTIALS_KEY ?? null,
allowPlaintextSecrets: env.NODE_ENV !== 'production', // dev only; prod requires CREDENTIALS_KEY
syncIntervalMinutes: env.SYNC_INTERVAL_MINUTES ? Number(env.SYNC_INTERVAL_MINUTES) : 60,
port: env.PORT ? Number(env.PORT) : 8083,
});
void app.start();9. Register
As with any integration, build the manifest and send it to ShopiMind for registration. ShopiMind generates the webhook_secret, resolves the loyalty category and registers the integration; copy the secret into WEBHOOK_SECRET. See the Hiboutik example for the manifest and registration steps in full.
Recap
You now have a custom-data-centric integration end to end: a custom data definition with a contact relationship, three real-time events, a safe-push inbound handler that upserts and fires events, a periodic reconciliation sync, and a static image widget the merchant drops into emails and pop-ups. This is the template for any program whose value lives in custom data and events rather than data sources.
Going further
- Custom data · Events · Synchronizing data
- Inbound middleware · Widgets · Complete example — Hiboutik
- Modeling the same program at the API level (no kit): Tiered loyalty (modeling)