Webhooks
Webhooks flow in both directions:
- SSP → your plugin — 20 event types telling you what happened in the restaurant.
- Your plugin → SSP — forwarding events from the third-party provider you integrate.
Payloads are deliberately small. When you need the full picture, call the Plugin Data API with the IDs you were given.
Receiving webhooks from SSP
The envelope
Every delivery has the same shape:
{
"event": "order.created",
"timestamp": "2026-05-30T12:00:00+00:00",
"installation_id": 123,
"data": { }
}
| Field | Description |
|---|---|
event | The event name |
timestamp | ISO 8601 with offset |
installation_id | Which installation this is for — your tenant key |
data | Event-specific payload |
Headers
| Header | Value |
|---|---|
X-SSP-Event | The event name |
X-SSP-Signature | Hex HMAC-SHA256 of the raw body, keyed with your webhook_secret |
Content-Type | application/json |
Verification examples in every language: Authentication.
Delivery guarantees
| Property | Value |
|---|---|
| Transport | HTTPS only |
| Request timeout | 10 seconds |
| Attempts | 3 |
| Backoff | 60s, 300s, 900s |
| Success | Any 2xx |
| Redirects | Not followed |
| Destination | Must be a public address — private, loopback, link-local and reserved ranges are refused |
You have 10 seconds. Verify the signature, enqueue the work, and return 2xx immediately — don't do the processing on the request path.
Every attempt is logged with its status and error message.
skipped means SSP didn't attempt delivery at all — no webhook_endpoint, no
webhook_secret, the event isn't in your subscriptions, or the installation
isn't active. Note that a skipped row is only written for webhooks fired
from the portal's tester; an organic event that gets filtered out simply
produces no log entry, so an empty log is itself a signal that your
subscription or endpoint configuration is wrong.
Subscribing
Set supported_events on your plugin listing:
{ "supported_events": ["order.created", "order.paid", "payment.*", "installation.*"] }
Wildcards cover a whole family: order.*, payment.*, menu.*, inventory.*,
sandbox.*, installation.*, subscription.*, trial.*.
If you set 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.* (and trial.* /
subscription.* if it's paid).
Event catalog
20 events across eight families.
Order events
order.created
A new order became real. Orders held in a pending state emit this on the transition out, so you see exactly one per order.
{
"order_id": 456,
"unique_orderid": "ORD-2026-001",
"table_number": "T5",
"order_status": "CREATED",
"total": "57.60",
"party_size": 4,
"created_at": "2026-01-10T18:30:00+00:00"
}
| Field | Type | Notes |
|---|---|---|
order_id | integer | Internal ID — use it against the Data API |
unique_orderid | string | Human-readable identifier |
table_number | string|null | Null for delivery and takeaway |
order_status | string | Internal status, e.g. CREATED |
total | string | Order total |
party_size | integer|null | Guest count |
created_at | string | ISO 8601 |
total, party_size, and created_at are omitted when the event fires from a
pending-state transition rather than at creation. Treat them as optional.
order.status_changed
{
"order_id": 456,
"unique_orderid": "ORD-2026-001",
"old_status": "CREATED",
"new_status": "COOKING"
}
Statuses here are internal values, not the plugin vocabulary. See the mapping table.
order.items_added
{
"order_id": 456,
"unique_orderid": "ORD-2026-001",
"previous_item_count": 2,
"new_item_count": 5,
"items_added": 3
}
Fetch GET /orders/{order_id} for the actual items.
order.paid
{
"order_id": 456,
"unique_orderid": "ORD-2026-001",
"total": "57.60",
"paid_at": "2026-01-10T19:30:00+00:00"
}
Fires automatically whenever an order reaches PAID — including from your own
POST /orders/{id}/payments call.
order.cancelled
{
"order_id": 456,
"unique_orderid": "ORD-2026-001",
"reason": "Customer requested cancellation",
"cancelled_at": "2026-01-10T19:15:00+00:00"
}
Payment events
payment.succeeded
{
"transaction_id": 789,
"order_id": 456,
"amount": "57.60",
"currency": "CAD",
"payment_method": "card",
"tip_amount": "9.00",
"tax_amount": "3.60",
"discount_amount": "0.00",
"stripe_payment_intent_id": "pi_abc123",
"transaction_type": "sale",
"processed_at": "2026-01-10T19:30:00+00:00"
}
payment.succeeded has two different shapesThe payload above is what the observer emits when a sale transaction is
created or completed. A capture made through POST /orders/{id}/payments
additionally emits a second, differently-shaped payment.succeeded:
{
"payment_id": 789,
"order_id": 456,
"amount": "42.50",
"currency": "CAD",
"payment_method": "upi",
"provider": "razorpay",
"provider_transaction_id": "pay_K9A2X",
"remaining_balance": 15.10
}
It carries payment_id, not transaction_id, and omits tip_amount,
tax_amount, discount_amount, stripe_payment_intent_id,
transaction_type and processed_at entirely.
Because writing that transaction also trips the observer, one plugin-recorded
capture delivers two payment.succeeded webhooks with different bodies.
Handle both: read data.transaction_id ?? data.payment_id, and treat missing
keys as absent rather than null.
stripe_payment_intent_id is absent from the capture-path payload, and null on
the observer path for non-Stripe payments.
payment.failed
{
"transaction_id": 789,
"order_id": 456,
"amount": "57.60",
"currency": "CAD",
"payment_method": "card",
"error": "Payment failed",
"failed_at": "2026-01-10T19:30:00+00:00"
}
payment.refunded
{
"transaction_id": 789,
"order_id": 456,
"amount": "57.60",
"currency": "CAD",
"refund_reason": "Customer complaint",
"refunded_at": "2026-01-10T20:00:00+00:00"
}
Menu events
menu.item_created / menu.item_updated
{
"item_id": 50,
"name": "Margherita Pizza",
"description": "Fresh tomatoes, mozzarella, basil",
"price": "16.00",
"is_available": true,
"is_active": true,
"location_id": 1,
"location_coll_id": null,
"allergens": ["gluten", "dairy"],
"vegan": false,
"vegetarian": true,
"spicy_level": 0,
"preparation_time": 15,
"changes": { "price": "16.00" }
}
changes appears on menu.item_updated only, and contains just the modified
fields — ideal for keeping a delivery-platform menu in sync without a full
re-push.
menu.item_deleted
{
"item_id": 50,
"name": "Margherita Pizza",
"location_id": 1,
"deleted_at": "2026-01-10T16:00:00+00:00"
}
Inventory events
All three share one payload shape:
{
"movement_id": 1201,
"ingredient_id": 7,
"location_id": 1,
"delta": -3.0,
"reason": "consume",
"stock_level": {
"on_hand": "12.0000",
"reorder_point": "5.0000",
"par_level": "20.0000"
}
}
| Event | Fires when |
|---|---|
inventory.stock_updated | Every movement, always |
inventory.low_stock | Stock crosses down to or below the reorder point, and no alert is already open |
inventory.low_stock_resolved | Stock crosses back up above the reorder point, resolving an open alert |
The crossing conditions mean you get one alert per depletion cycle, not one per movement while low.
Installation lifecycle
installation.created
A restaurant installed and activated your plugin. This is your signal to provision a tenant.
{
"installation_id": 1234,
"plugin_id": 89,
"organization_id": 56,
"location_id": 7,
"is_trial": false,
"trial_ends_at": null
}
is_trial and trial_ends_at are present on the paid path. For a free plugin,
is_trial is false and trial_ends_at is absent.
installation.removed
Fired while the installation is still active, so you can deprovision.
{
"installation_id": 1234,
"plugin_id": 89,
"organization_id": 56,
"location_id": 7
}
Subscription and trial events
Paid plugins only.
| Event | Fires when |
|---|---|
trial.started | A free trial begins |
trial.ending | A few days before the trial converts |
subscription.activated | A paid subscription becomes active — after checkout, or when a trial converts |
trial.started and subscription.activated carry the same payload as
installation.created. trial.ending adds the countdown:
{
"installation_id": 1234,
"plugin_id": 89,
"organization_id": 56,
"location_id": 7,
"trial_ends_at": "2026-02-01T00:00:00+00:00",
"days_remaining": 3
}
A paid install sits in pending_payment until checkout completes. Wait for
trial.started or subscription.activated.
Sandbox lifecycle
sandbox.expiry_warning
Fires 7 days before idle expiry would drop your sandbox schema.
{
"sandbox_id": 42,
"sandbox_name": "my-dev-sandbox",
"schema_name": "sandbox_42",
"purpose": "developer_portal",
"last_accessed_at": "2026-01-15T10:30:00+00:00",
"would_expire_at": "2026-04-15T10:30:00+00:00",
"protected_until": null,
"action": "Access your sandbox to reset the idle clock, or call protectSandbox to extend the protection window."
}
This is a platform event, not restaurant data. Any API call with the sandbox's
pik_ key resets the clock. See Sandboxes.
Summary table
| Event | Key data |
|---|---|
order.created | order_id, unique_orderid, table_number, total |
order.status_changed | order_id, old_status, new_status |
order.items_added | order_id, items_added |
order.paid | order_id, total, paid_at |
order.cancelled | order_id, reason |
payment.succeeded | transaction_id, amount, payment_method |
payment.failed | transaction_id, error |
payment.refunded | transaction_id, refund_reason |
menu.item_created | item_id, name, price |
menu.item_updated | item_id, changes |
menu.item_deleted | item_id, name |
inventory.stock_updated | movement_id, ingredient_id, delta, stock_level |
inventory.low_stock | ingredient_id, location_id, stock_level |
inventory.low_stock_resolved | ingredient_id, location_id, stock_level |
installation.created | installation_id, organization_id, is_trial |
installation.removed | installation_id, organization_id |
trial.started | installation_id, trial_ends_at |
trial.ending | installation_id, days_remaining |
subscription.activated | installation_id, organization_id |
sandbox.expiry_warning | sandbox_id, would_expire_at |
Handling events well
Idempotency
SSP retries up to three times, and a network failure after your handler succeeded looks identical to a failure before it. Make handlers idempotent.
async function handle(event) {
// Natural keys work well, but payload shapes vary — `payment.succeeded`
// arrives with either `transaction_id` or `payment_id` (see above), so fall
// through both before giving up.
const id = event.data.transaction_id ?? event.data.payment_id ?? event.data.order_id
?? event.data.movement_id ?? event.data.item_id ?? event.data.installation_id;
const key = `${event.event}:${event.installation_id}:${id}`;
const inserted = await db.processedEvents.insertIfAbsent({ key });
if (!inserted) return; // already handled
await doWork(event);
}
Persist the marker in your database, not in memory — a restart must not replay.
Route by installation
installation_id is your tenant key. Look up the corresponding pik_ key and
act in that installation's context:
app.post('/webhooks/ssp', express.raw({ type: 'application/json' }), async (req, res) => {
// verify signature first…
const event = JSON.parse(req.body.toString('utf8'));
const install = await db.installations.findByInstallationId(event.installation_id);
if (!install) return res.status(200).json({ status: 'ignored' }); // still 2xx
res.json({ status: 'ok' });
queue.push({ event, install });
});
Return 2xx even for events you don't care about — a non-2xx triggers retries that will never succeed.
Tolerate new fields
Payloads gain fields over time. Read what you need and ignore the rest; never fail on an unrecognised key.
Sending webhooks to SSP
Forward events from the provider you integrate.
POST /api/plugin/v1/webhooks/external
Headers
| Header | Required | Value |
|---|---|---|
X-Plugin-Api-Key | Yes | Your pik_ installation key |
X-SSP-Signature | Yes | Hex HMAC-SHA256 of the raw body, keyed with your webhook_secret |
Content-Type | Yes | application/json |
Body
{
"event_type": "external.payment_confirmed",
"payload": { "transaction_id": "TXN-123", "amount": 42.50 },
"idempotency_key": "evt-abc123"
}
| Field | Required | Rules |
|---|---|---|
event_type | Yes | Max 100 chars. A catalog event, an external.* name, or a wildcard match (e.g. payment.custom matches payment.*) |
payload | Yes | Object |
idempotency_key | No | Max 128 chars — dedupes for 24 hours per installation |
Responses
| Status | Meaning |
|---|---|
201 | Accepted — {"data":{"log_id":…,"event_type":…,"status":"received"}} |
200 | Duplicate within 24h — the original log, with "duplicate": true |
400 | validation_error or unknown_event_type |
401 | invalid_signature |
429 | Rate limited — 60/min applies here too; the 600/min ceiling is checked after it |
A bad signature is logged with signature_valid: false before the 401, so
misconfigured signing shows up in the audit trail rather than vanishing.
Checking delivery status
curl "https://api.ssppos.com/api/plugin/v1/webhooks/external/1234" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY"
Returns the log entry with its status, direction, and any error_message.
Worked example — forwarding a Razorpay capture
const crypto = require('crypto');
app.post('/webhooks/razorpay', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify Razorpay's own signature over the raw bytes.
const expected = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (req.headers['x-razorpay-signature'] !== expected) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.json({ status: 'ok' }); // acknowledge Razorpay immediately
// 2. Record the capture as an authoritative payment in SSP.
const entity = event.payload.payment.entity;
await axios.post(
`https://api.ssppos.com/api/plugin/v1/orders/${entity.notes.ssp_order_id}/payments`,
{
amount: entity.amount / 100,
currency: entity.currency,
payment_method: 'upi',
provider: 'razorpay',
provider_transaction_id: entity.id, // idempotency key
captured_at: new Date(entity.created_at * 1000).toISOString(),
},
{ headers: { 'X-Plugin-Api-Key': process.env.SSP_PLUGIN_API_KEY } },
);
});
POST /orders/{id}/payments writes an authoritative transaction that appears in
financial reports and reconciliation. POST /webhooks/external records an event
for audit and future processing — it does not move money.
Testing
From the portal
The webhook tester fires a real, signed webhook built from your sandbox data, and returns a log ID you can poll. Statuses and caveats: Sandboxes.
Locally
SSP delivers only to public HTTPS addresses, so tunnel:
ngrok http 3000
# Set webhook_endpoint to the https://<id>.ngrok.io URL
In your test suite
function signedRequest(body, secret) {
const raw = JSON.stringify(body);
return {
raw,
signature: crypto.createHmac('sha256', secret).update(raw).digest('hex'),
};
}
it('rejects a tampered payload', async () => {
const { raw, signature } = signedRequest({ event: 'order.created' }, SECRET);
const res = await request(app)
.post('/webhooks/ssp')
.set('X-SSP-Signature', signature)
.set('Content-Type', 'application/json')
.send(raw.replace('order.created', 'order.paid')); // same signature, different body
expect(res.status).toBe(401);
});
Troubleshooting
Nothing arrives. Check webhook_endpoint is set and is a public https URL;
confirm the event is covered by supported_events; check the delivery log for
a skipped reason.
Every delivery is failed. Your endpoint is answering non-2xx. The log's
error_message carries the status code and the first 500 bytes of your
response body.
Signatures never verify. A body parser is consuming the stream before your handler. Mount raw parsing on the webhook route.
Deliveries stop after a rotation. The old secret is invalidated instantly. Deploy the new one and confirm with a test webhook.
Duplicate processing. Retries are expected — add the idempotency marker above.
Next steps
- Plugin Data API — turning event IDs into full records
- Setup Handoff — provisioning on
installation.created - Security — hardening your webhook endpoint