Inbound middleware
An integration is a middleware: in addition to receiving ShopiMind's lifecycle webhooks and pushing its data via sync, it can expose inbound routes that your own application calls, to trigger an event or push data in real time, without waiting for the next sync cycle.
your application ──(signed call)──► your integration ──(ctx.spm)──► ShopiMindThis is the ideal tool when an event happens on your side (a customer earns points, a review is published, an order is placed in-store) and you want to reflect it immediately in ShopiMind.
Declaring a route: the inbound field
Add an inbound field to your defineIntegration. Each key is an action; each value is a handler that receives the ctx (with ctx.spm already authenticated) and the payload received:
import { SpmEvents } from '@shopimind/integration-kit-js';
export const myIntegration = defineIntegration({
// …
inbound: {
'loyalty.points_earned': async (ctx, payload) => {
const { email, points } = payload as { email: string; points: number };
await SpmEvents.trigger(ctx.spm, 'points_earned', { email, points });
},
'review.created': async (ctx, payload) => {
const review = payload as { email: string; rating: number };
await ctx.customData('reviews').save([
{ email: review.email, rating: review.rating },
]);
},
},
});Each <action> action is exposed by the kit under POST /inbound/{action}. The handler is generic: do whatever you need in it via ctx.spm + SDK statics (SpmEvents.trigger, …), ctx.sendBulk for safe bulk, and ctx.customData(name).save(...) for custom data records.
TIP
ctx.customData(name) resolves the definition declared in provisioning.customData and upserts safely (chunked, transport-safe, surfacing per-record rejections). For path-param bulks, wrap the SDK call with ctx.sendBulk(() => SpmCustomDataRecords.bulkSave(ctx.spm, defId, [/* … */])).
Security: one HMAC signature per installation
Unlike lifecycle webhooks (signed by ShopiMind with one webhook_secret), inbound calls come from your application. The kit therefore assigns each installation its own HMAC secret, exposed under ctx.inboundSecret. Pass it to your application, typically in onActivate:
hooks: {
onActivate: async (ctx) => {
await myApp.registerShopimindEndpoint({
installationId: ctx.installationId, // opaque token, send it back as-is
inboundUrl: `${MY_BASE_URL}/inbound`,
secret: ctx.inboundSecret, // the secret of THIS installation
});
},
}Your application then signs each call, exactly like ShopiMind signs its webhooks, but with this secret and x-integration-* headers:
| Header | Content |
|---|---|
x-integration-installation | The opaque installationId (routes the call and resolves the secret). |
x-integration-timestamp | The timestamp in seconds. |
x-integration-signature | HMAC-SHA256("${timestamp}.${raw_body}", inboundSecret) in hexadecimal. |
x-idempotency-key | (optional) a unique per-call key. See Idempotency. |
// on YOUR application's side
const body = JSON.stringify({ email, points: 50 });
const ts = Math.floor(Date.now() / 1000);
const signature = hmacSha256Hex(`${ts}.${body}`, inboundSecret);
await fetch(`${inboundUrl}/loyalty.points_earned`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-integration-installation': installationId,
'x-integration-timestamp': String(ts),
'x-integration-signature': signature,
'x-idempotency-key': eventId, // optional
},
body,
});The kit verifies the signature over the raw bytes, in constant time, with an anti-replay window (300 s by default). The ShopiMind API token is never exposed to your application: it only knows the installation's inbound secret.
Idempotency
If your call carries an x-idempotency-key header, the kit logs the call and short-circuits any replay of a key already processed successfully (it responds 200 with { "replayed": true }). A call that has not yet succeeded is retried normally. Use a stable key per event (the id of the event on your side) so you can retry without risk of double processing.
Rate-limit
Each installation has a token bucket: beyond the allowed throughput, the kit responds 429 Too Many Requests. Batch your calls or retry after a short delay.
Response codes
| Code | Meaning |
|---|---|
200 | Processed ({ success: true }), or already-processed replay ({ success: true, replayed: true }). |
400 | Missing installation header, or invalid JSON body. |
401 | Unknown installation, or invalid / too-old signature. |
404 | Unknown action (no matching inbound key). |
429 | Rate-limit reached for this installation. |
500 | The handler failed (you can retry; idempotency deduplicates on success). |
Testing
The kit's test harness signs an inbound call for you:
import { makeTestApp } from '@shopimind/integration-kit-js';
const app = await makeTestApp(myIntegration);
// … activate an installation …
const { body, headers } = await app.signInbound(installationId, { email: 'a@b.co', points: 50 });
const res = await app.server.inject({ method: 'POST', url: '/inbound/loyalty.points_earned', payload: body, headers });
// res.statusCode === 200Asynchronous since kit v2
makeTestApp and signInbound both return Promises since the kit v2; await them.
Going further
- The integration kit:
ctx.inboundSecret, exposed routes. - Lifecycle & webhooks: the other inbound flow (emitted by ShopiMind).
- Synchronizing data: for batch volumes (initial + incremental).
- Events · Custom data: what your handlers push.