Exemple : programme de fidélité
Cette page parcourt une deuxième intégration de bout en bout — la contrepartie centrée sur les données personnalisées de l'exemple Hiboutik POS. Là où Hiboutik pousse les contacts, les produits et les commandes vers des sources de données, une intégration de fidélité gravite autour des données personnalisées, des événements en temps réel et d'un widget. Pour rester générique, nous appelons le partenaire une plateforme de fidélité ; la même forme convient à tout programme de points et de paliers.
Ce que nous construisons
- Une définition de données personnalisées (
loyalty_account) contenant un enregistrement par contact — solde, palier, progression vers le palier suivant. - Trois événements personnalisés déclenchés en temps réel depuis l'application de l'intégrateur : points gagnés, palier modifié, points bientôt expirés.
- Une synchronisation périodique des données personnalisées qui réconcilie les comptes modifiés depuis le dernier curseur.
- Un widget image statique : une barre de progression vers le palier suivant, affichée par ShopiMind dans les e-mails et les pop-ups.
Cette intégration utilise trois des helpers de plus haut niveau du kit — ctx.customData(name) pour faire un upsert dans la définition, SpmEvents.trigger pour déclencher des événements, et provisioning pour déclarer la définition et les événements de manière idempotente. Tous sont ré-exportés depuis le kit d'intégration JavaScript, @shopimind/integration-kit-js.
1. Paramètres typés
Les paramètres sont minimaux : l'URL de base de l'API partenaire et une clé API. parseSettings transforme les valeurs brutes du formulaire en un objet typé.
interface LoyaltySettings { apiBaseUrl: string; apiKey: string }
parseSettings: (raw) => ({
apiBaseUrl: String(raw.api_base_url ?? ''),
apiKey: String(raw.api_key ?? ''),
}),2. Le formulaire de configuration
Une seule étape de connexion, validée par test_connection à son achèvement. La clé API est sensitive, donc le kit la chiffre au repos.
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 — données personnalisées + événements
La fonction provisioning déclare tout ce dont l'intégration a besoin côté serveur, de manière idempotente, à l'activation. Ici, elle déclare une définition de données personnalisées et trois événements.
La définition loyalty_account clé les enregistrements par contact_id (ses unique_keys) et les relie aux contacts ShopiMind via une relation système, de sorte que l'état de fidélité s'attache au bon profil. Au moment du push, le contact est résolu par e-mail via le résolveur { by: 'email', value } et le serveur stocke l'id_contact canonique.
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' } },
],
}),| Élément | Rôle |
|---|---|
customData | La définition dans laquelle les enregistrements sont upsertés ; name correspond à ctx.customData(name). |
unique_keys: ['contact_id'] | Identifie un enregistrement à l'upsert — save clé dessus. |
relationships | Lie contact_id au schéma système contacts (apparié sur id_contact). |
events | Les trois code_name que vous déclenchez ensuite avec SpmEvents.trigger. |
Ne résolvez pas les identifiants à la main
ctx.customData(ACCOUNT) résout la définition déclarée ici et renvoie un CustomDataHandle { id, save(records) }. Ne recherchez jamais vous-même l'identifiant de la définition — laissez le handle s'en charger.
4. Le widget image statique
Un widget permet à ShopiMind d'afficher l'état de fidélité dans les e-mails et les pop-ups. Celui-ci est une image statique (render_type: 'image') : ShopiMind construit une balise <img> dont l'URL est l'image_url_template, en substituant les champs de l'enregistrement et la configuration du widget.
{var=loyalty_account.*}lit les champs de l'enregistrement de données personnalisées du contact.{var=widget.*}lit la configuration propre au widget (ici, la couleur de la barre).
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' } },
],
},
}],D'où viennent les valeurs
Le marchand choisit bar_color une fois, au moment d'ajouter le widget. Les valeurs loyalty_account.* proviennent de l'enregistrement de données personnalisées que vous upsertez (sections suivantes) — ainsi la barre reflète la progression réelle de chaque contact au moment de l'affichage.
5. Entrant en temps réel — déclencher des événements + upsert
Votre application appelle une route entrante chaque fois que la fidélité d'un client change. Le kit sécurise ces routes avec un HMAC par installation (x-integration-*, secret = ctx.inboundSecret). Le handler fait deux choses :
- Upsert du compte en tant que données personnalisées — un push sûr, clé sur la clé unique
contact_id(le contact est résolu par e-mail au moment du push). - Déclenchement de l'événement correspondant en temps réel, ce qui pilote les scénarios d'automatisation ShopiMind.
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) appelle le SDK sur le SpmHttpClient brut (ctx.spm). Il renvoie l'enveloppe SDK { ok, statusCode, data, error } et ne lève jamais d'exception sur une erreur HTTP.
Transmettez le secret entrant à votre application
Dans hooks.onActivate, enregistrez le webhook de votre plateforme et passez-lui ctx.installationId et ctx.inboundSecret afin qu'il puisse signer les appels entrants ci-dessus.
hooks: {
onActivate: async (ctx) => {
// await yourLoyaltyApi.registerWebhook(ctx.settings, {
// installationId: ctx.installationId, secret: ctx.inboundSecret,
// });
},
},La forme du payload et le mapper d'enregistrement sont de tout petits helpers purs :
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. Synchronisation périodique des données personnalisées
Les événements en temps réel gardent ShopiMind à jour, mais une étape périodique réconcilie tout ce que les webhooks ont manqué (interruptions, rejeux). L'étape de synchronisation récupère les comptes modifiés depuis le curseur et les upserte par pages avec le même CustomDataHandle.
ctx.customData(ACCOUNT).save(...) est un push sûr — découpé en morceaux, sûr pour le transport, faisant remonter les rejets par élément — et renvoie { sent, rejected, rejected_items }. Renvoyer advanceCursorTo: ctx.window.until ne déplace le curseur que lorsque le lot de la page est traité.
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} compte(s) rejeté(s)`);
}
return { items: sent, errors, advanceCursorTo: ctx.window.until };
},
}],declare function fetchChangedAccounts(
s: LoyaltySettings,
w: { since: Date | null; until: Date },
page: number,
): Promise<AccountUpdate[]>;Les rejets retiennent le curseur
Lorsque save fait remonter des rejets par élément dans une étape de synchronisation, le moteur retient le curseur afin que rien ne soit silencieusement abandonné. L'exécution suivante rejoue la fenêtre. Désactivez ce comportement par étape avec tolerateRejects: true uniquement lorsqu'un enregistrement « poison » ne doit pas bloquer la progression.
7. L'intégration
En assemblant le tout — defineIntegration câble les paramètres, le provisioning, le widget, l'étape de synchronisation, les routes entrantes et le hook d'activation :
// 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} compte(s) rejeté(s)`);
}
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. Le point d'entrée
createIntegrationApp assemble le runtime — le kit construit lui-même le client SDK, de sorte que l'intégration n'instancie jamais le SDK ici.
// 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. Enregistrement
Comme pour toute intégration, construisez le manifeste et envoyez-le à ShopiMind pour enregistrement. ShopiMind génère le webhook_secret, résout la catégorie loyalty et enregistre l'intégration ; copiez le secret dans WEBHOOK_SECRET. Consultez l'exemple Hiboutik pour le manifeste et les étapes d'enregistrement au complet.
Récapitulatif
Vous disposez maintenant d'une intégration centrée sur les données personnalisées de bout en bout : une définition de données personnalisées avec une relation contact, trois événements en temps réel, un handler entrant à push sûr qui upserte et déclenche des événements, une synchronisation de réconciliation périodique, et un widget image statique que le marchand glisse dans les e-mails et les pop-ups. C'est le modèle pour tout programme dont la valeur réside dans les données personnalisées et les événements plutôt que dans les sources de données.
Pour aller plus loin
- Données personnalisées · Événements · Synchroniser les données
- Middleware entrant · Widgets · Exemple complet — Hiboutik
- Modéliser le même programme au niveau API (sans kit) : Fidélité par paliers (modélisation)