Complete example — the Hiboutik integration
This page walks through a real-world integration end to end: Hiboutik, a point-of-sale system (POS). The integration brings a merchant's physical store data into their ShopiMind strategies, alongside their e-commerce source. It makes a great running example: multi-store, incremental synchronization, identifier namespacing.
V1 scope
Bulk synchronization (no real time): an initial sync on activation, then incremental every 15 min. Entities: customers (+ addresses), products, orders (in-store sales) and their statuses. Each Hiboutik store becomes a distinct data source.
1. The project
integration-hiboutik/
├── package.json
├── .env
└── src/
├── main.ts ← mounts the kit runtime
├── integration.ts ← defineIntegration
├── settings.ts ← typed settings
├── print-manifest.ts ← emits the manifest
├── declarations/
│ └── config-schema.ts ← the installation form
├── partner/
│ └── client.ts ← Hiboutik API client
├── domain/
│ ├── ids.ts ← identifier namespacing
│ └── mappers.ts ← Hiboutik → ShopiMind DTO (pure functions)
├── sync-steps.ts ← the sync steps
└── provisioning-state.ts ← provisioning state keysThe kit is a published npm dependency. It depends on and re-exports the SDK @shopimind/sdk-js, so you import SDK resources and types from the kit — no separate SDK setup in your app.
// 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. Typed settings
// src/settings.ts
import type { RawConfigs } from '@shopimind/integration-kit-js';
export interface HiboutikSettings {
account: string; // xxx subdomain of xxx.hiboutik.com
apiUser: string;
apiKey: string;
storeIds: string[]; // stores to synchronize
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. The installation form
A three-step wizard: connection (validated by test_connection), store selection (dynamic list via remote), data to synchronize.
// 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' } },
],
},
],
};The store list is populated on the fly by integration.remoteData.stores (see §6), called by ShopiMind via the /webhook/remote-data/stores route.
4. Identifier namespacing
A Hiboutik customer #42 and a web customer #42 must never overwrite each other. We prefix text identifiers and offset integer identifiers:
// 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;Omnichannel unification of the same customer is still handled by ShopiMind at the contact level (by email): prefixing the customer_id does not prevent profiles from being merged.
5. The mappers (pure functions)
The DTO types come from the SDK, re-exported by the kit, so you import them from @shopimind/integration-kit-js:
// src/domain/mappers.ts (excerpts)
import type { SpmCustomer } from '@shopimind/integration-kit-js';
export function toSpmCustomer(c: HiboutikCustomer): SpmCustomer | null {
if (!c.email) return null; // SKIP if no 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
// …
};
}Business rules: a customer without an email is skipped, an anonymous sale is skipped; a negative total becomes a refund status; ean13 = barcode.
6. The synchronization steps
Customers and products are attached to the parent source (the Hiboutik customer is global); orders are attached to the store source (cursor per source).
Both steps push with safe push, which sends in chunks, throws on a transport failure, and surfaces per-item rejections without losing data — the engine holds the cursor when rejections occur. Each step accumulates counts and returns { items, errors, advanceCursorTo }.
- Global step — call
ctx.sendBulk(fn, items)directly. You call the SDK static method as the push function:ctx.sendBulk(SpmCustomers.bulkSave, …). - Per-source step — get a
SourceHandlewithctx.withSource(key), thensrc.send(fn, items)tags each item withid_data_sourceand pushes safely.
// src/sync-steps.ts (shape)
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} customer(s) rejected`);
}
return { items, errors, advanceCursorTo: ctx.window.until };
},
};
export const ordersStep: SyncStep<HiboutikSettings> = {
entity: 'orders',
cursorScope: 'per-source', // one cursor per store
enabled: (s) => s.syncOrders,
sources: (ctx) => ctx.settings.storeIds,
run: async (ctx) => {
const src = ctx.withSource(ctx.sourceKey); // store source for this cursor
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} order(s) rejected`);
}
return { items, errors, advanceCursorTo: ctx.window.until };
},
};Safe push, every time
ctx.sendBulk (and src.send) is the recommended way to push in a sync step. A transport failure throws and the cursor is not advanced, so the next run replays the window — no silent data loss. Per-item validation rejects are reported in res.rejected / res.rejected_items; the engine holds the cursor so you can fix and retry. To let the cursor advance despite validation rejects (poison-pill escape), set tolerateRejects: true on the step.
7. The integration
// 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> {
// … provision order statuses if needed …
}
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. The entry point
createIntegrationApp assembles the runtime — it builds the SDK client itself, so your app never instantiates the SDK. credentialsKey is the AES-256 key (64 hex chars) used to encrypt stored credentials; it is mandatory unless you set allowPlaintextSecrets: true for local dev.
// 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 only; prod requires CREDENTIALS_KEY
syncIntervalMinutes: env.SYNC_INTERVAL_MINUTES ? Number(env.SYNC_INTERVAL_MINUTES) : 15,
port: env.PORT ? Number(env.PORT) : 8082,
});
void app.start();Secrets in production
Set CREDENTIALS_KEY (a 64-hex AES-256 key) in production. The runtime is fail-closed: without it, the app refuses to start unless allowPlaintextSecrets is true — which you should only do on a local dev machine.
9. Generate the manifest & register
// 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.jsonThe resulting manifest is neutral (slug: 'hiboutik', categories: ['pos'], relative webhooks, no secrets). Send it to dev@shopimind.com: ShopiMind generates the webhook_secret, resolves the pos category and registers the integration. Copy the webhook_secret into WEBHOOK_SECRET, and your integration is ready to be installed by a merchant.
Recap
You now have, end to end: typed settings, an installation wizard with connection test and dynamic list, pure mappers, multi-source sync steps with safe push and a held cursor, parent/child provisioning, and a manifest ready for registration. This is the skeleton of any ShopiMind integration.