Exemple complet — l'intégration Hiboutik
Cette page parcourt de bout en bout une intégration réelle : Hiboutik, une caisse (POS). L'intégration fait remonter les données du magasin physique d'un marchand dans ses stratégies ShopiMind, aux côtés de sa source e-commerce. Elle constitue un excellent fil rouge : multi-magasins, synchronisation incrémentale, espacement de noms des identifiants.
Périmètre V1
Synchronisation en masse (pas de temps réel) : une synchro initiale à l'activation, puis incrémentale toutes les 15 min. Entités : clients (+ adresses), produits, commandes (ventes en magasin) et leurs statuts. Chaque magasin Hiboutik devient une source de données distincte.
1. Le projet
integration-hiboutik/
├── package.json
├── .env
└── src/
├── main.ts ← monte le runtime du kit
├── integration.ts ← defineIntegration
├── settings.ts ← réglages typés
├── print-manifest.ts ← émet le manifeste
├── declarations/
│ └── config-schema.ts ← le formulaire d'installation
├── partner/
│ └── client.ts ← client de l'API Hiboutik
├── domain/
│ ├── ids.ts ← espacement de noms des identifiants
│ └── mappers.ts ← Hiboutik → DTO ShopiMind (fonctions pures)
├── sync-steps.ts ← les étapes de synchro
└── provisioning-state.ts ← clés d'état du provisioningLe kit est une dépendance npm publiée. Il dépend du SDK @shopimind/sdk-js et le ré-exporte, vous importez donc les ressources et les types du SDK depuis le kit — aucune configuration distincte du SDK dans votre application.
// package.json
{
"name": "@shopimind/integration-hiboutik",
"type": "module",
"scripts": {
"build": "tsc -b",
"start": "node dist/main.js",
"print:manifest": "node dist/print-manifest.js"
},
"dependencies": {
"@shopimind/integration-kit-js": "^1.6.0",
"dotenv": "^16.4.0"
}
}2. Les réglages typés
// src/settings.ts
import type { RawConfigs } from '@shopimind/integration-kit-js';
export interface HiboutikSettings {
account: string; // sous-domaine xxx de xxx.hiboutik.com
apiUser: string;
apiKey: string;
storeIds: string[]; // magasins à synchroniser
syncCustomers: boolean;
syncOrders: boolean;
syncProducts: boolean;
defaultLang: string;
defaultCurrency: string;
}
export function parseSettings(raw: RawConfigs): HiboutikSettings {
return {
account: String(raw.hiboutik_account ?? ''),
apiUser: String(raw.hiboutik_api_user ?? ''),
apiKey: String(raw.hiboutik_api_key ?? ''),
storeIds: Array.isArray(raw.store_ids) ? raw.store_ids.map(String) : [],
syncCustomers: Boolean(raw.sync_customers),
syncOrders: Boolean(raw.sync_orders),
syncProducts: Boolean(raw.sync_products),
defaultLang: String(raw.default_lang ?? 'fr'),
defaultCurrency: String(raw.default_currency ?? 'EUR'),
};
}3. Le formulaire d'installation
Un wizard en trois étapes : connexion (validée par test_connection), choix des magasins (liste dynamique via remote), données à synchroniser.
// src/declarations/config-schema.ts
import type { ConfigSchema } from '@shopimind/integration-kit-js';
export const configSchema: ConfigSchema = {
steps: [
{
key: 'connection',
label: { fr: 'Connexion Hiboutik', en: 'Hiboutik connection' },
fields: [
{ key: 'hiboutik_account', type: 'text', required: true, label: { fr: 'Compte Hiboutik' } },
{ key: 'hiboutik_api_user', type: 'text', required: true, label: { fr: 'Utilisateur API' } },
{ key: 'hiboutik_api_key', type: 'password', required: true, sensitive: true, label: { fr: 'Clé API' } },
],
on_complete: { action: 'test_connection' },
},
{
key: 'stores',
label: { fr: 'Magasins', en: 'Stores' },
fields: [
{
key: 'store_ids',
type: 'multiselect',
required: true,
label: { fr: 'Points de vente à synchroniser' },
remote: { resource: 'stores', label_field: 'label', value_field: 'value' },
},
],
},
{
key: 'data',
label: { fr: 'Données à synchroniser' },
fields: [
{ key: 'sync_customers', type: 'checkbox', default: false, label: { fr: 'Clients' } },
{ key: 'sync_orders', type: 'checkbox', default: false, label: { fr: 'Commandes (ventes en magasin)' } },
{ key: 'sync_products', type: 'checkbox', default: false, label: { fr: 'Catalogue produits' } },
{ key: 'default_lang', type: 'select', default: 'fr', options: [{ value: 'fr', label: 'Français' }, { value: 'en', label: 'English' }], label: { fr: 'Langue par défaut' } },
{ key: 'default_currency', type: 'select', default: 'EUR', options: [{ value: 'EUR', label: 'EUR (€)' }], label: { fr: 'Devise' } },
],
},
],
};La liste des magasins est peuplée à la volée par integration.remoteData.stores (voir §6), appelée par ShopiMind via la route /webhook/remote-data/stores.
4. Espacement de noms des identifiants
Un client Hiboutik #42 et un client web #42 ne doivent jamais s'écraser. On préfixe les identifiants texte et on décale les identifiants entiers :
// src/domain/ids.ts
const HBK_INT_OFFSET = 1_000_000_000;
export const spmCustomerId = (id: string | number) => `hib_${id}`;
export const spmOrderId = (id: string | number) => `hib_${id}`;
export const spmProductId = (id: number) => id + HBK_INT_OFFSET;
export const spmAddressId = (id: number) => id + HBK_INT_OFFSET;L'unification omnicanale d'un même client reste assurée par ShopiMind au niveau du contact (par email) : préfixer le customer_id n'empêche pas la fusion des profils.
5. Les mappers (fonctions pures)
Les types de DTO proviennent du SDK, ré-exportés par le kit, vous les importez donc depuis @shopimind/integration-kit-js :
// src/domain/mappers.ts (extraits)
import type { SpmCustomer } from '@shopimind/integration-kit-js';
export function toSpmCustomer(c: HiboutikCustomer): SpmCustomer | null {
if (!c.email) return null; // SKIP si pas d'email
return {
customer_id: spmCustomerId(c.customers_id),
email: c.email,
first_name: c.first_name,
last_name: c.last_name,
is_newsletter_subscribed: false, // opt-in only
// …
};
}Règles métier : on ignore un client sans email, une vente anonyme ; un total négatif devient un statut refund ; ean13 = barcode.
6. Les étapes de synchronisation
Clients et produits sont rattachés à la source parente (le client Hiboutik est global) ; les commandes à la source magasin (curseur par source).
Les deux étapes poussent avec le push sécurisé, qui envoie par lots, lève une exception en cas d'échec de transport, et fait remonter les rejets par élément sans perdre de données — le moteur conserve le curseur lorsque des rejets surviennent. Chaque étape accumule des compteurs et renvoie { items, errors, advanceCursorTo }.
- Étape globale — appelez directement
ctx.sendBulk(fn, items). Vous passez la méthode statique du SDK comme fonction de push :ctx.sendBulk(SpmCustomers.bulkSave, …). - Étape par source — récupérez un
SourceHandleavecctx.withSource(key), puissrc.send(fn, items)étiquette chaque élément avecid_data_sourceet pousse de façon sécurisée.
// src/sync-steps.ts (forme)
import { SpmCustomers, SpmOrders } from '@shopimind/integration-kit-js';
import type { SyncStep } from '@shopimind/integration-kit-js';
import type { HiboutikSettings } from './settings.js';
import { toSpmCustomer, toSpmOrder } from './domain/mappers.js';
export const customersStep: SyncStep<HiboutikSettings> = {
entity: 'customers',
cursorScope: 'global',
enabled: (s) => s.syncCustomers,
run: async (ctx) => {
let items = 0;
const errors: string[] = [];
for await (const page of ctx.paginate((p) => fetchCustomers(p, ctx.window))) {
const customers = page.map(toSpmCustomer).filter(Boolean);
const res = await ctx.sendBulk(SpmCustomers.bulkSave, customers);
items += res.sent;
if (res.rejected) errors.push(`${res.rejected} client(s) rejeté(s)`);
}
return { items, errors, advanceCursorTo: ctx.window.until };
},
};
export const ordersStep: SyncStep<HiboutikSettings> = {
entity: 'orders',
cursorScope: 'per-source', // un curseur par magasin
enabled: (s) => s.syncOrders,
sources: (ctx) => ctx.settings.storeIds,
run: async (ctx) => {
const src = ctx.withSource(ctx.sourceKey); // source magasin pour ce curseur
let items = 0;
const errors: string[] = [];
for await (const page of ctx.paginate((p) => fetchSales(p, ctx.sourceKey, ctx.window))) {
const orders = page.map(toSpmOrder).filter(Boolean);
const res = await src.send(SpmOrders.bulkSave, orders);
items += res.sent;
if (res.rejected) errors.push(`${res.rejected} commande(s) rejetée(s)`);
}
return { items, errors, advanceCursorTo: ctx.window.until };
},
};Push sécurisé, à chaque fois
ctx.sendBulk (et src.send) est la manière recommandée de pousser dans une étape de synchro. Un échec de transport lève une exception et le curseur n'est pas avancé, la prochaine exécution rejoue donc la fenêtre — aucune perte silencieuse de données. Les rejets de validation par élément sont signalés dans res.rejected / res.rejected_items ; le moteur conserve le curseur pour que vous puissiez corriger et réessayer. Pour laisser le curseur avancer malgré des rejets de validation (échappatoire « poison-pill »), définissez tolerateRejects: true sur l'étape.
7. L'intégration
// src/integration.ts
import { defineIntegration } from '@shopimind/integration-kit-js';
import type { Integration, IntegrationContext, ProvisioningPlan, RemoteOption } from '@shopimind/integration-kit-js';
import type { HiboutikSettings } from './settings.js';
import { parseSettings } from './settings.js';
import { configSchema } from './declarations/config-schema.js';
import { syncSteps } from './sync-steps.js';
import { HiboutikClient } from './partner/client.js';
import { PARENT_SOURCE_KEY, storeSourceKey, readProvisioning } from './provisioning-state.js';
const clientOf = (s: HiboutikSettings) => new HiboutikClient({ account: s.account, user: s.apiUser, key: s.apiKey });
async function postProvision(ctx: IntegrationContext<HiboutikSettings>): Promise<void> {
// … provisionne les statuts de commande si besoin …
}
export const hiboutik: Integration<HiboutikSettings> = defineIntegration({
slug: 'hiboutik',
meta: { name: 'Hiboutik', version: '1.0.0', categories: ['pos'] },
configSchema,
parseSettings,
syncSteps,
testConnection: (ctx) => clientOf(ctx.settings).testConnection(),
remoteData: {
stores: async (ctx): Promise<RemoteOption[]> => {
const stores = await clientOf(ctx.settings).getStores();
return stores.map((st) => ({ value: String(st.store_id), label: st.store_name ?? `Magasin ${st.store_id}` }));
},
},
async provisioning(ctx): Promise<ProvisioningPlan> {
const dataSources: NonNullable<ProvisioningPlan['dataSources']> = [
{ key: PARENT_SOURCE_KEY, decl: { label: 'Hiboutik POS', type: 'api' } },
];
for (const id of ctx.settings.storeIds) {
dataSources.push({ key: storeSourceKey(id), parentKey: PARENT_SOURCE_KEY, decl: { label: `Magasin ${id}`, type: 'api' } });
}
return { dataSources };
},
hooks: {
onActivate: (ctx) => postProvision(ctx),
onConfigUpdated: (ctx) => postProvision(ctx),
},
});8. Le point d'entrée
createIntegrationApp assemble le runtime — il construit lui-même le client SDK, votre application n'instancie donc jamais le SDK. credentialsKey est la clé AES-256 (64 caractères hexadécimaux) utilisée pour chiffrer les identifiants stockés ; elle est obligatoire sauf si vous définissez allowPlaintextSecrets: true pour le développement local.
// src/main.ts
import 'dotenv/config';
import { createIntegrationApp } from '@shopimind/integration-kit-js';
import { hiboutik } from './integration.js';
const env = process.env;
const app = createIntegrationApp(hiboutik, {
databasePath: env.DATABASE_PATH ?? './data/hiboutik.sqlite',
webhookSecret: env.WEBHOOK_SECRET!,
credentialsKey: env.CREDENTIALS_KEY ?? null,
allowPlaintextSecrets: env.NODE_ENV !== 'production', // dev uniquement ; prod exige CREDENTIALS_KEY
syncIntervalMinutes: env.SYNC_INTERVAL_MINUTES ? Number(env.SYNC_INTERVAL_MINUTES) : 15,
port: env.PORT ? Number(env.PORT) : 8082,
});
void app.start();Secrets en production
Définissez CREDENTIALS_KEY (une clé AES-256 de 64 caractères hexadécimaux) en production. Le runtime est fail-closed : sans elle, l'application refuse de démarrer sauf si allowPlaintextSecrets vaut true — ce que vous ne devriez faire que sur une machine de développement locale.
9. Générer le manifeste & enregistrer
// src/print-manifest.ts
import { buildIntegrationManifest } from '@shopimind/integration-kit-js';
import { hiboutik } from './integration.js';
console.log(JSON.stringify(buildIntegrationManifest(hiboutik), null, 2));yarn build
yarn print:manifest > integration.manifest.jsonLe manifeste obtenu est neutre (slug: 'hiboutik', categories: ['pos'], webhooks relatifs, aucun secret). Envoyez-le à dev@shopimind.com : ShopiMind génère le webhook_secret, résout la catégorie pos et enregistre l'intégration. Reportez le webhook_secret dans WEBHOOK_SECRET, et votre intégration est prête à être installée par un marchand.
Récapitulatif
Vous avez désormais, de bout en bout : des réglages typés, un wizard d'installation avec test de connexion et liste dynamique, des mappers purs, des étapes de synchro multi-sources à push sécurisé et curseur conservé, un provisioning parent/enfant, et un manifeste prêt pour l'enregistrement. C'est le squelette de n'importe quelle intégration ShopiMind.