Aller au contenu principal

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.
Webhooks are notifications, not data

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": { }
}
FieldDescription
eventThe event name
timestampISO 8601 with offset
installation_idWhich installation this is for — your tenant key
dataEvent-specific payload

Headers​

HeaderValue
X-SSP-EventThe event name
X-SSP-SignatureHex HMAC-SHA256 of the raw body, keyed with your webhook_secret
Content-Typeapplication/json

Verification examples in every language: Authentication.

Delivery guarantees​

PropertyValue
TransportHTTPS only
Request timeout10 seconds
Attempts3
Backoff60s, 300s, 900s
SuccessAny 2xx
RedirectsNot followed
DestinationMust be a public address — private, loopback, link-local and reserved ranges are refused
Answer fast

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.*.

Lifecycle events need explicit opt-in

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"
}
FieldTypeNotes
order_idintegerInternal ID — use it against the Data API
unique_orderidstringHuman-readable identifier
table_numberstring|nullNull for delivery and takeaway
order_statusstringInternal status, e.g. CREATED
totalstringOrder total
party_sizeinteger|nullGuest count
created_atstringISO 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 shapes

The 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"
}
{
"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.

{
"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"
}
}
EventFires when
inventory.stock_updatedEvery movement, always
inventory.low_stockStock crosses down to or below the reorder point, and no alert is already open
inventory.low_stock_resolvedStock 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.

EventFires when
trial.startedA free trial begins
trial.endingA few days before the trial converts
subscription.activatedA 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
}
Grant paid access on activation, not install

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​

EventKey data
order.createdorder_id, unique_orderid, table_number, total
order.status_changedorder_id, old_status, new_status
order.items_addedorder_id, items_added
order.paidorder_id, total, paid_at
order.cancelledorder_id, reason
payment.succeededtransaction_id, amount, payment_method
payment.failedtransaction_id, error
payment.refundedtransaction_id, refund_reason
menu.item_createditem_id, name, price
menu.item_updateditem_id, changes
menu.item_deleteditem_id, name
inventory.stock_updatedmovement_id, ingredient_id, delta, stock_level
inventory.low_stockingredient_id, location_id, stock_level
inventory.low_stock_resolvedingredient_id, location_id, stock_level
installation.createdinstallation_id, organization_id, is_trial
installation.removedinstallation_id, organization_id
trial.startedinstallation_id, trial_ends_at
trial.endinginstallation_id, days_remaining
subscription.activatedinstallation_id, organization_id
sandbox.expiry_warningsandbox_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

HeaderRequiredValue
X-Plugin-Api-KeyYesYour pik_ installation key
X-SSP-SignatureYesHex HMAC-SHA256 of the raw body, keyed with your webhook_secret
Content-TypeYesapplication/json

Body

{
"event_type": "external.payment_confirmed",
"payload": { "transaction_id": "TXN-123", "amount": 42.50 },
"idempotency_key": "evt-abc123"
}
FieldRequiredRules
event_typeYesMax 100 chars. A catalog event, an external.* name, or a wildcard match (e.g. payment.custom matches payment.*)
payloadYesObject
idempotency_keyNoMax 128 chars — dedupes for 24 hours per installation

Responses

StatusMeaning
201Accepted — {"data":{"log_id":…,"event_type":…,"status":"received"}}
200Duplicate within 24h — the original log, with "duplicate": true
400validation_error or unknown_event_type
401invalid_signature
429Rate limited — 60/min applies here too; the 600/min ceiling is checked after it
Rejected signatures are recorded

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 } },
);
});
Prefer the payments endpoint for money

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​