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-Signatureagainst 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);
}
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}`;
}
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:
| Event | Fires when |
|---|---|
installation.created | An organization installs and activates your plugin |
installation.removed | An organization uninstalls your plugin |
trial.started | A free trial begins for a paid plugin |
trial.ending | A few days before a trial converts to paid |
subscription.activated | A paid subscription becomes active, at checkout or on trial conversion |
order.created | An order becomes real — see the note below |
order.items_added | Items are added to an existing order |
order.status_changed | An order's status changes, with old_status and new_status |
order.paid | An order is marked paid |
order.cancelled | An order is cancelled |
payment.succeeded | A payment succeeds |
payment.failed | A payment fails |
payment.refunded | A payment is refunded |
menu.item_created | A menu item is created |
menu.item_updated | A menu item is updated |
menu.item_deleted | A menu item is deleted |
inventory.stock_updated | An ingredient's stock level changes, after every movement |
inventory.low_stock | Stock drops to or below the reorder point, with no alert already active |
inventory.low_stock_resolved | Stock rises back above the reorder point while an alert is active |
sandbox.expiry_warning | Seven days before idle-expiry would drop your sandbox schema |
order.created means accepted, not insertedAn 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.
processNaming 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.*"] }
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
| Property | Value |
|---|---|
| Request timeout | 10 seconds |
| Attempts | 3 |
| Backoff | 60s, 300s, 900s |
| Success | Any 2xx |
| Redirects | Not 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
- Webhooks — the full event catalog and payloads
- Plugin Data API — reading and writing back to SSP
- Plugin Lifecycle — getting from draft to active
- Security — handling credentials and tenant data