Aller au contenu principal

Tutorial: Hello World

Build a small plugin that receives all 20 SSP events, verifies every delivery, and handles each one — in about sixty lines of Node.js.

This is the events half of the SDK. Getting Started covers creating a project, provisioning a sandbox and calling the API; Webhooks is the reference catalog with full payloads. This page is the build-along: it assumes you already have a sandbox, a pik_ API key and a webhook secret.

What you'll build​

An HTTP endpoint that:

  • verifies X-SSP-Signature against the raw request bytes,
  • answers inside SSP's 10-second budget and does the work afterwards,
  • ignores an event it has already processed,
  • and routes all 20 events to a handler.

Step 1 — Create the project​

mkdir ssp-hello-world && cd ssp-hello-world
npm init -y
npm install express dotenv

Create .env with the webhook secret from your sandbox:

PORT=3000
SSP_WEBHOOK_SECRET=your_webhook_secret_here

Step 2 — Verify the signature over the raw bytes​

Every delivery carries X-SSP-Signature, the hex HMAC-SHA256 of the exact bytes SSP transmitted, keyed with your webhook secret.

Keep those bytes. express.json() gives you a parsed object, and re-serializing it is not guaranteed to reproduce what was signed:

const crypto = require('crypto');
const express = require('express');
require('dotenv').config();

const app = express();
const SECRET = process.env.SSP_WEBHOOK_SECRET;

// `verify` hands you the raw buffer before the JSON is parsed.
app.use(
'/webhooks/ssp',
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
}),
);

function signatureIsValid(req) {
if (!SECRET || !req.rawBody) return false;

const received = Buffer.from(req.headers['x-ssp-signature'] || '', 'utf8');
const expected = Buffer.from(
crypto.createHmac('sha256', SECRET).update(req.rawBody).digest('hex'),
'utf8',
);

// timingSafeEqual throws when lengths differ, so compare lengths first.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
The mistake almost everyone makes

Signing JSON.stringify(req.body) instead of the raw buffer. It often works in testing and then rejects valid production traffic, because key order, whitespace and unicode escaping can all differ from the bytes that were signed.

A correctly signed body sent as { "event" : "order.paid" , … } produces:

hmac over raw bytes     673a14935b01d2866d9e…
hmac over re-serialized ff092db53a12264500d1…

Two different signatures for one valid request. Always hash req.rawBody.

Step 3 — Answer fast​

You have 10 seconds. Verify, acknowledge, then work:

app.post('/webhooks/ssp', (req, res) => {
if (!signatureIsValid(req)) {
return res.status(401).json({ error: 'invalid_signature' });
}

res.json({ status: 'accepted' }); // acknowledge first

setImmediate(() => { // a real plugin uses a job queue
handleEvent(req.body).catch((err) => console.error(`handler failed: ${err.message}`));
});
});

Return 2xx even for events you don't care about. A non-2xx triggers retries that will never succeed.

Step 4 — Don't process the same event twice​

SSP retries up to three times, and a network failure after your handler succeeded is indistinguishable from one before it. Build a key from the event, the installation and the subject:

const seen = new Set();                       // a real plugin uses its database

function eventKey({ event, installation_id: installationId, data = {} }) {
const subject =
data.transaction_id ??
data.payment_id ??
data.order_id ??
data.movement_id ??
data.ingredient_id ??
data.item_id ??
data.sandbox_id ??
data.installation_id ??
installationId;

return `${event}:${installationId}:${subject}`;
}
Two ID fields for one event

payment.succeeded is dispatched from two places in SSP and arrives with transaction_id from one and payment_id from the other. Fall through both rather than assuming either, which is why the chain above starts with them.

Persist the marker in your database, not in memory — a restart must not replay.

Step 5 — A handler for every event​

All 20, in eight families:

EventFires when
installation.createdAn organization installs and activates your plugin
installation.removedAn organization uninstalls your plugin
trial.startedA free trial begins for a paid plugin
trial.endingA few days before a trial converts to paid
subscription.activatedA paid subscription becomes active, at checkout or on trial conversion
order.createdAn order becomes real — see the note below
order.items_addedItems are added to an existing order
order.status_changedAn order's status changes, with old_status and new_status
order.paidAn order is marked paid
order.cancelledAn order is cancelled
payment.succeededA payment succeeds
payment.failedA payment fails
payment.refundedA payment is refunded
menu.item_createdA menu item is created
menu.item_updatedA menu item is updated
menu.item_deletedA menu item is deleted
inventory.stock_updatedAn ingredient's stock level changes, after every movement
inventory.low_stockStock drops to or below the reorder point, with no alert already active
inventory.low_stock_resolvedStock rises back above the reorder point while an alert is active
sandbox.expiry_warningSeven days before idle-expiry would drop your sandbox schema
order.created means accepted, not inserted

An order awaiting the restaurant's acceptance does not fire order.created. The event fires when the order becomes real, so you never print, push or start a prep timer for food nobody has agreed to cook. You get exactly one per order.

Route them:

const log = (label) => (data, envelope) =>
console.log(` ${label}`, JSON.stringify({ installation: envelope.installation_id, ...data }));

const handlers = {
'order.created': log('New order'),
'order.items_added': log('Items added'),
'order.status_changed': log('Status changed'),
'order.paid': log('Order paid'),
'order.cancelled': log('Order cancelled'),

'payment.succeeded': log('Payment succeeded'),
'payment.failed': log('Payment failed'),
'payment.refunded': log('Payment refunded'),

'menu.item_created': log('Menu item created'),
'menu.item_updated': log('Menu item updated'),
'menu.item_deleted': log('Menu item deleted'),

'inventory.stock_updated': log('Stock moved'),
'inventory.low_stock': log('Low stock'),
'inventory.low_stock_resolved': log('Low stock resolved'),

'installation.created': log('Installed'),
'installation.removed': log('Uninstalled'),

'trial.started': log('Trial started'),
'trial.ending': log('Trial ending'),
'subscription.activated': log('Subscription active'),

'sandbox.expiry_warning': log('Sandbox expiring'),
};

async function handleEvent(envelope) {
const handler = handlers[envelope.event];
if (!handler) {
console.log(` (no handler for ${envelope.event} — ignored)`);
return;
}
await handler(envelope.data ?? {}, envelope);
}

Replace each log(…) with real work as you build. Read the fields you need and ignore the rest — payloads gain fields over time, and an unrecognised key must never fail a handler. Full payloads per event: Webhooks.

Don't shadow process

Naming that function process shadows Node's global, and process.env silently becomes undefined — the server then crashes on startup reading your config.

Step 6 — Subscribe​

Set supported_events on your plugin listing. Wildcards cover a family:

{ "supported_events": ["order.*", "payment.*", "menu.*", "inventory.*", "installation.*", "trial.*", "subscription.*", "sandbox.*"] }
Lifecycle events need explicit opt-in

With an explicit supported_events list, the installation, subscription and trial families are not included by default. A plugin that provisions tenants headlessly must subscribe to installation.*.

Step 7 — Run it and prove it works​

node index.js
# listening on 3000

Send a correctly signed event:

SECRET=your_webhook_secret_here
BODY='{"event":"order.created","timestamp":"2026-05-30T12:00:00+00:00","installation_id":42,"data":{"order_id":1001,"unique_orderid":"ABC12345","table_number":"7","total":48.5}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -s -X POST http://127.0.0.1:3000/webhooks/ssp \
-H "Content-Type: application/json" \
-H "X-SSP-Event: order.created" \
-H "X-SSP-Signature: $SIG" \
--data-raw "$BODY"

What you should see:

valid signature      -> 200 {"status":"accepted"}
replay (same event) -> 200 {"status":"duplicate"}
wrong signature -> 401 {"error":"invalid_signature"}
no signature -> 401 {"error":"invalid_signature"}
tampered body -> 401 {"error":"invalid_signature"}

And in the server log:

  New order {"installation":42,"order_id":1001,"unique_orderid":"ABC12345","table_number":"7","total":48.5}

A replay returning 200 rather than an error is deliberate — the event was handled, so the delivery succeeded.

Step 8 — Let SSP reach you​

SSP delivers only to public HTTPS addresses. Expose your local server:

ngrok http 3000
# use the https://<id>.ngrok.io URL as your webhook_endpoint

Then fire a test from the Developer Portal's webhook tester and poll the returned log ID for the outcome.

When delivery fails​

PropertyValue
Request timeout10 seconds
Attempts3
Backoff60s, 300s, 900s
SuccessAny 2xx
RedirectsNot followed

A skipped log means SSP never attempted delivery — no endpoint, no secret, the event isn't in your subscriptions, or the installation isn't active. An organic event that gets filtered out writes no log row at all, so an empty log is itself a signal that your subscription or endpoint is misconfigured.

What's next​