Inbound callbacks
Some actions cannot be performed by ShopiMind: creating a discount voucher inside your shop, opening a customer account, subscribing someone to your newsletter. ShopiMind therefore calls you, on routes derived from url_client.
This is the connector channel. It has its own signature scheme, different from the integration webhook channel, do not mix them up.
The three actions
Implement only those whose features you want. A missing route means an unavailable feature, not a global error.
| Action | Route | Triggered by | Priority |
|---|---|---|---|
generateVouchers | POST {url_client}/vouchers | A scenario generating a voucher (personalised or not) | High: without it, scenario vouchers do not exist |
createCustomer | POST {url_client}/customers | A widget with account creation | Depends on your widgets |
subscribeCustomer | POST {url_client}/subscribe-customer | A newsletter opt-in from a widget | Depends on your widgets |
The type-request header carries the action name, handy for routing.
Transport
| Method | POST |
| Content-Type | application/x-www-form-urlencoded |
| Encoding | Bracket notation for nesting: voucherInfos[type]=percent, values encodeURIComponent-ed |
| Timeout | 90 seconds |
| Retries | 3 attempts, exponential backoff |
| Redirects | Followed same-site only (apex ↔ www tolerated), preserving method and body. Any cross-origin redirect, or one to /, is refused with an explicit error |
The body is not JSON
It is x-www-form-urlencoded with bracket notation. If your framework auto-parses as JSON you will receive an empty body and the signature check will fail. The simplest approach is to grab the raw body, parse it yourself, then sign over the resulting structure.
Headers received
| Header | Content |
|---|---|
Shopimind-Client-Identifiant | Your shop identifier, compare it to the one you stored |
Shopimind-Token | The body's HMAC (see below) |
Shopimind-Client-Version | Echo of what you declared at connection |
Shopimind-Client-Build | Same |
type-request | Action name |
Normalise the case
HTTP headers are case-insensitive, and depending on the server or reverse proxy (HTTP/2, normalising proxies), you will receive them lowercased or in mixed case. The safest approach is to lowercase the keys before reading.
Signature
The secret
The HMAC is computed with the shop's primary key: the one that ran the last POST /v1/shop/connection. Precisely:
secret = the part after the dot in the API key // "a1b2c3d4.xK9p…" → "xK9p…"
hmacKey = sha256(secret) in HEXADECIMAL // the hex STRING, not the bytesFlattening the body
1. Take the DESERIALISED body (the object, not the raw string)
2. Sort FIRST-LEVEL keys only
→ nested levels keep their insertion order
3. Walk recursively and concatenate into "key;value;key;value…"
→ objects are traversed WITHOUT emitting their own key
→ values strictly equal to '0' are SKIPPED
4. Strip ALL spaces from the resulting string
5. token = HMAC-SHA256(hmacKey, flattenedString) in hexadecimalSorting every level breaks the signature
Only the first level is sorted. Sorting recursively produces a different string for any nested payload , so a systematic rejection of generateVouchers, which is precisely the nested one. This is pitfall #1 in reimplementations.
Check vector. For the body { testConnection: 1785412468, shopIdShop: '1' }, the flattened string is exactly:
shopIdShop;1;testConnection;1785412468Verifying with the SDK
The SDK exposes the connector channel's public primitive, do not write your own if you are on Node:
import { SpmRequestValidator } from '@shopimind/sdk-js';
// The body arrives as x-www-form-urlencoded, bracket notation
const body = SpmRequestValidator.parseFormData(rawBody);
const check = SpmRequestValidator.validateRequest({
clientId: headers['shopimind-client-identifiant'],
hmacToken: headers['shopimind-token'],
body,
apiIdentification: process.env.SPM_SHOP_IDENTIFIER, // shop identifier
apiPassword: process.env.SPM_API_KEY, // full "prefix.secret" key
});
if (!check.valid) {
return res.json({ success: false, message: 'Unauthorized.' });
}parseFormData filters __proto__, constructor and prototype: an encoded body cannot pollute Object.prototype. If you parse it yourself, reproduce that protection.
Verifying without a dependency (WebCrypto)
For an edge runtime (Workers, Deno) where node:crypto is unavailable:
function implodeRecursive(obj) {
let out = '';
const walk = (data) => {
if (!data || typeof data !== 'object') return;
for (const k of Object.keys(data)) {
const v = data[k];
if (v !== null && typeof v === 'object') walk(v);
else if (v !== '0') out += out === '' ? `${k};${v}` : `;${k};${v}`;
}
};
const sorted = {};
Object.keys(obj).sort().forEach((k) => { sorted[k] = obj[k]; }); // first level only
walk(sorted);
return out.replace(/ /g, '');
}
const hex = (buf) => [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, '0')).join('');
async function verifyShopimindToken(body, receivedToken, apiKey) {
const secret = String(apiKey).split('.')[1] ?? apiKey;
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
const keyHex = hex(digest); // the hex STRING is the HMAC key
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(keyHex),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign(
'HMAC', key, new TextEncoder().encode(implodeRecursive(body)),
);
// Constant-time comparison
const a = hex(sig), b = String(receivedToken ?? '');
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}Hardening
A few good practices to strengthen the security of your callback routes:
- Strict HTTPS, with no redirect on the callback routes.
- Idempotence on
codeToGeneratefor vouchers: a replay must not create a second voucher. - Allowlist ShopiMind's outbound IPs if your infrastructure allows it, ask us for the list.
- Constant-time comparison of the token, as in the examples above.
generateVouchers: discount vouchers
The most used action: a scenario offering a discount calls this route so the voucher actually exists in your shop.
// Body received (shown as JSON for readability)
{
"testConnection": 1785412468,
"shopIdShop": "1", // only when multi-store
"voucherInfos": {
"codeToGenerate": "SPM-4F2A9C", // the code to create, YOUR IDEMPOTENCE KEY
"type": "percent", // "amount" | "percent" | "shipping"
"amount": "10",
"nbDayValidate": "7", // validity in days
"minimumOrder": "50", // optional
"amountCurrency": "EUR", // optional
"duplicateCode": "PROMO_TEMPLATE",// optional, duplicate an existing rule
"dateStart": "…", // optional
"dynamicPrefix": "…" // optional
},
"voucherEmails": [ // PRESENT = PERSONALISED voucher
{ "email": "customer@example.com", "description": "Cart reminder" }
]
}Personalised or generic
voucherEmailspresent → one voucher per email, tied to the matching customer account. If an email matches no account, the simplest approach is to skip that entry to avoid an orphan voucher.voucherEmailsabsent → a single generic voucher, usable by anyone.
Expected response
// Personalised, one object per email
{
"success": true,
"vouchers": {
"customer@example.com": {
"voucher_number": "SPM-4F2A9C",
"voucher_date_limit": "2026-08-11 23:59:59"
}
}
}
// Generic
{
"success": true,
"vouchers": { "voucher_number": "SPM-4F2A9C", "voucher_date_limit": "2026-08-11 23:59:59" }
}success: true is not enough
ShopiMind considers the operation successful only if success is true and vouchers is populated. A { success: true, vouchers: {} } counts as a failure, and the message will go out without its discount code.
The three discount types:
type | Meaning |
|---|---|
amount | Absolute discount, in amountCurrency (or the default currency). |
percent | Percentage discount. |
shipping | Free shipping. amount is then ignored. |
nbDayValidate is a number of days from now. By convention, set expiry to 23:59:59 on the due day.
duplicateCode, when present, names an existing discount rule in your shop to duplicate: the merchant configures the promotion once, ShopiMind generates unique-code copies of it. If the referenced code does not exist, an explicit error in the response is preferable.
createCustomer: account creation
{
"testConnection": 1785412468,
"shopIdShop": "1",
"customer": {
"firstName": "Marie",
"lastName": "Dupont",
"email": "marie@example.com",
"password": "…",
"lang": "fr",
"newsletter": 1,
"birthday": "1990-04-12" // optional
}
}{ "success": true, "message": "Customer created successfully.", "id_customer": "4212" }id_customer must be your customer identifier, the same one you push as customer_id through the API. It is what joins the created account to the ShopiMind contact.
If the email already exists, return success: false: ShopiMind then shows an "email already registered" message rather than a technical error.
subscribeCustomer: newsletter opt-in
{ "testConnection": 1785412468, "shopIdShop": "1", "id_customer": "4212" }{ "success": true, "message": "Customer subscribed successfully." }General response rules
- The expected response is always an
HTTP 200with a JSON body; the business result is carried bysuccess. - On invalid signature:
{ "success": false, "message": "Unauthorized." }. - It is better to catch your exceptions than to let a 500 escape: ShopiMind would classify it as a transport incident and retry the call three times, which could create duplicates on
generateVouchers. - Consider verifying the signature before any processing, even read-only.
Next
→ Reference: terminology, parity, troubleshooting.