Plugin Data API
The REST surface your running plugin uses to read and write a restaurant's data. This page is the narrative guide; for the terse endpoint/error/limit tables see API Reference.
Before you start
Base URL: https://api.ssppos.com/api/plugin/v1
Header: X-Plugin-Api-Key: pik_...
Same URL for sandbox and production — the key decides. Always begin with
GET /installation: it tells you the scope you're operating in.
curl "https://api.ssppos.com/api/plugin/v1/installation" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY"
{
"data": {
"installation_id": 1234,
"plugin_id": 89,
"plugin_name": "acme-payments",
"organization_id": 56,
"location_id": 7,
"status": "active",
"config": { "provider_store_id": "store_xyz" },
"enabled_features": ["payments:capture"],
"installed_at": "2026-03-01T09:00:00+00:00"
}
}
Note the data wrapper — read body.data.location_id, not body.location_id.
When location_id is non-null, the installation is pinned to that location.
Every list endpoint silently filters to it, and passing a different
location_id won't widen the scope. When it's null, you see the whole
organization.
Storing your own settings
curl -X POST "https://api.ssppos.com/api/plugin/v1/installation/config" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"config": {"sync_cursor": "2026-04-17T10:00:00Z"}}'
Config merges — keys you don't send are preserved.
Orders
Listing
GET /orders
| Parameter | Type | Notes |
|---|---|---|
location_id | integer | Ignored when the installation is location-pinned |
status | string | Matches the internal status column — CREATED, COOKING, SERVED, COMPLETED, PAID, CANCELLED |
since | datetime | ISO 8601 — orders created at or after this instant |
limit | integer | 1–100, default 50 |
curl "https://api.ssppos.com/api/plugin/v1/orders?status=CREATED&limit=10" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY"
{
"data": [
{
"id": 123,
"unique_orderid": "ORD-2026-001",
"order_status": "open",
"order_type": "DINE_IN",
"table_number": "T5",
"subtotal": "45.00",
"tax": "3.60",
"tip": "9.00",
"total_amount": "57.60",
"customer_id": 456,
"location_id": 1,
"created_at": "2026-01-10T18:30:00+00:00",
"updated_at": "2026-01-10T18:35:00+00:00"
}
],
"meta": { "count": 1, "limit": 50 }
}
You filter with internal statuses and read back plugin statuses. Asking
for ?status=CREATED returns rows whose order_status is "open". This is
deliberate — the response speaks the same language as the write path. See the
mapping.
An order you create through POST /orders starts in COOKING (it is sent
to the kitchen at creation), so it matches ?status=COOKING, not
?status=CREATED.
Order detail
GET /orders/{orderId} — accepts a numeric ID or a unique_orderid, which
is handy when your provider only knows the human-readable reference.
Returns the full order under data, with items, customer, and payments
(SSP's sale transactions) embedded.
Pagination
There is no cursor, and GET /orders sorts newest-first while since
filters created_at >= since. That combination means the obvious recipe —
feeding the last row's timestamp back as the next since — re-requests the
same newest page forever.
Page backwards instead, using the oldest row you have seen as an upper bound you apply client-side:
async function fetchAllOrders(apiKey, { pageSize = 100 } = {}) {
const seen = new Map(); // id -> order, dedupes shared timestamps
let before = null; // oldest created_at collected so far
for (;;) {
const url = new URL('https://api.ssppos.com/api/plugin/v1/orders');
url.searchParams.set('limit', String(pageSize));
const { data } = await (await fetch(url, {
headers: { 'X-Plugin-Api-Key': apiKey },
})).json();
// Descending order, so filter to strictly-older rows ourselves.
const older = before ? data.filter((o) => o.created_at < before) : data;
const fresh = older.filter((o) => !seen.has(o.id));
if (fresh.length === 0) break; // nothing new — we are done
fresh.forEach((o) => seen.set(o.id, o));
before = fresh[fresh.length - 1].created_at;
if (data.length < pageSize) break; // last page
}
return [...seen.values()];
}
This walk costs one request per page against a 60/min budget. Use it for an
initial backfill, then subscribe to order.* and keep your own copy current.
For an incremental catch-up, a single since set to your last successful sync
is both correct and cheap — the ordering problem only bites when you try to
walk backwards through history.
Orders — state machine
PUT /orders/{orderId}
Only status transitions are accepted. Line items, totals and discounts stay server-authoritative — a plugin can move an order through service, but it can't rewrite the bill.
curl -X PUT "https://api.ssppos.com/api/plugin/v1/orders/123" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"order_status": "in_progress", "metadata": {"prep_started_at": "2026-04-15T12:00:00Z"}}'
| Field | Required | Notes |
|---|---|---|
order_status | Yes | in_progress, ready, completed, paid, or cancelled |
cancellation_reason | When cancelling | Max 2000 characters |
metadata | No | Shallow-merged into existing metadata |
Allowed transitions
open → in_progress | cancelled
in_progress → ready | cancelled
ready → completed | cancelled
completed → paid
paid, cancelled → (terminal)
An illegal target returns 422 invalid_transition with the legal targets:
{
"error": "invalid_transition",
"message": "Cannot transition from 'open' to 'completed'.",
"current_status": "open",
"allowed_next_states": ["in_progress", "cancelled"]
}
Use allowed_next_states to recover rather than hardcoding the graph.
Asking for the state the order is already in succeeds. A PUT whose
order_status equals the current state returns 200 with the order unchanged:
the status is not rewritten and no order.status_changed is sent. metadata,
if supplied, is still merged. This makes a retried PUT safe, and it means a
PUT in_progress on an order you just created (which already starts
in_progress) is a harmless no-op.
PUT with openBefore the 2026-09-28 change (see the Changelog), an order created
through the API started open. During a transition, a PUT with
"order_status": "open" on an in_progress order that your plugin created is
accepted as a no-op 200 (the order is not moved back, and the body reports its
real state, in_progress). The response is marked:
Deprecation: @1790553600
Sunset: Wed, 31 Mar 2027 00:00:00 GMT
Link: <https://docs.ssppos.com/docs/sdk/plugin-data-api#changelog>; rel="deprecation"
{
"data": { "id": 123, "order_status": "in_progress", "...": "..." },
"warnings": [
{ "code": "deprecated_order_status_open", "message": "..." }
]
}
After 2027-03-31 this call returns 400 validation_error, as open does on
any other order today. Remove it from your integration. See the
Changelog.
An order sitting in an internal-only status (REFUNDED, VOIDED,
PARTIALLY_PAID, AWAITING_AUTHENTICATION, and others) has no plugin-facing
state, so every PUT against it returns 422 invalid_transition. Read the
order first if you need to branch.
Orders — creation
POST /orders — for delivery origination and external ordering. Requires the
orders:create capability.
{
"location_id": 1,
"order_type": "DELIVERY",
"external_order_id": "UE-ABC123",
"customer": {
"email": "customer@example.com",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+15551234567",
"delivery_address": "456 Oak Ave",
"delivery_city": "Montreal",
"delivery_postal_code": "H2X 1Y4"
},
"items": [
{
"menu_item_id": 42,
"quantity": 2,
"notes": "No onions",
"customizations": [
{ "template_id": 103, "quantity": 1 },
{ "name": "Rush priority", "price": 5.00 }
]
}
],
"tip_amount": 3.00,
"kitchen_instructions": "Pack sauces separately",
"metadata": { "delivery_platform": "uber_eats" }
}
What the server decides
- Totals are recomputed.
subtotal,tax, andtotalcome from menu prices and the location's tax rules. Anything you send is ignored. - The order goes to the kitchen as it is created. Its lines are on the
restaurant's kitchen display straight away, so it starts
in_progress(internallyCOOKING), notopen. The create response (and an idempotent replay) reports the order's real current state. There is nothing to send or confirm: your nextPUTisreadyorcancelled. - The kitchen ticket names your plugin. It is labelled with your plugin's
display_namefrom the developer portal, so staff can tell which partner an order came through.
Subscribed to order.*? A created order produces order.created (with the
internal order_status CREATED) followed by order.status_changed from
CREATED to COOKING as it is sent to the kitchen. Treat both as expected.
Order types
DELIVERY, TAKEAWAY, PICKUP, TAKEOUT — the last two normalise to
TAKEAWAY. DINE_IN is rejected: table management belongs to the POS
register.
Customer handling
Optional. Supply one and SSP matches on email or phone, creating a customer if there's no match. Omit it entirely when the platform owns the relationship — Uber Eats and DoorDash typically don't share customer identity.
Customizations
Each entry uses exactly one of two forms — never both, never neither:
| Form | Fields | Pricing |
|---|---|---|
| Preset-backed | template_id (+ optional quantity) | Server snapshots the template's price |
| Ad-hoc | name + price (+ optional description) | Plugin-authoritative |
Violations return 422:
| Error | Cause |
|---|---|
customization_location_mismatch | The template belongs to a different location |
customization_rule_violation | Breaks the item's min/max selection rules |
Limits
100 items per order, quantity 1–999 per item, 50 customizations per item.
Idempotency
Send external_order_id in the body, or an X-Idempotency-Key header. A repeat
returns 200 with the original order instead of creating a second one; a
first-time create returns 201.
Delivery platforms retry aggressively — always send your platform's order ID as
external_order_id.
Payments
POST /orders/{orderId}/payments — requires the payments:capture
capability.
This is the authoritative money path: it writes a sale transaction that appears in financial reports, tax filing, and settlement reconciliation. Use it whenever your plugin has actually collected funds.
{
"amount": 42.50,
"currency": "CAD",
"payment_method": "upi",
"provider": "razorpay",
"provider_transaction_id": "pay_K9A2X",
"captured_at": "2026-04-15T23:14:07+05:30",
"payer_reference": "customer@paytm",
"metadata": { "vpa": "customer@paytm" }
}
| Field | Required | Notes |
|---|---|---|
amount | Yes | Positive; must not exceed the remaining balance |
currency | Yes | ISO 4217, 3 characters, must match the order's location currency |
payment_method | Yes | upi, qr_code, card, cash, wallet, bank_transfer, other |
provider | Yes | Your provider's name |
provider_transaction_id | Yes | Unique per organization — this is the idempotency key |
captured_at | Yes | ISO 8601 |
payer_reference | No | VPA, card last 4, wallet ID — for reconciliation |
metadata | No | Provider-specific context |
Partial payments
Pay less than the balance and the order moves to partially_paid with the
balance decremented. Pay exactly the balance and it transitions to paid, which
also fires order.paid.
The response echoes the order's new state:
{
"data": {
"payment_id": 789,
"order_id": 123,
"amount_captured": "42.50",
"amount_remaining": "15.10",
"order_status": "partially_paid",
"currency": "CAD",
"payment_method": "upi",
"provider": "razorpay",
"provider_transaction_id": "pay_K9A2X",
"captured_at": "2026-04-15T23:14:07+00:00"
}
}
The capture response uses payment_id and amount_captured — not
transaction_id and amount, which is what you sent. Reading
data.transaction_id here yields undefined.
partially_paid is a dead end for PUTpartially_paid has no equivalent in the plugin state machine, so once an order
is in it every PUT /orders/{id} returns 422 invalid_transition. Settle
the balance with further captures — the order reaches paid on its own when the
balance hits zero. Don't plan on driving a partially-paid order by status.
Errors
| Error | HTTP | Cause |
|---|---|---|
feature_not_enabled | 403 | No payments:capture capability |
subscription_locked | 403 | The organization's subscription state blocks capture |
currency_mismatch | 422 | Doesn't match the location's currency — response includes both |
overpayment | 422 | Exceeds the remaining balance |
validation_error | 422 | Bad field values |
Multi-location chains can span currencies; resolve per location, not per organization.
Idempotency
A repeated provider_transaction_id returns 200 with the original
transaction — not an error. Providers retry captures, so always pass the
provider's own transaction ID.
Menu
Reading
GET /menu — filters: location_id, category_id, available_only.
available_only defaults to trueUnless you pass available_only=false, the response contains only items that
are currently orderable (is_available and is_active both true). A
delivery plugin syncing a menu therefore never sees the 86'd items it needs to
mark unavailable upstream. Pass available_only=false for a full menu.
{
"data": [
{
"id": 50,
"name": "Margherita Pizza",
"description": "Fresh tomatoes, mozzarella, basil",
"price": "15.00",
"is_available": true,
"is_active": true,
"location_id": 1,
"allergens": ["gluten", "dairy"],
"vegan": false,
"vegetarian": true,
"spicy_level": 0,
"preparation_time": 15
}
],
"meta": { "count": 1, "limit": 50 }
}
GET /menu/{itemId} adds updated_at — useful as a sync cursor.
is_available vs is_activeis_active means the item exists on the menu at all. is_available means it's
orderable right now — this is the 86'd flag you toggle when stock runs out.
Updating one item
PATCH /menu/{itemId} accepts exactly three fields:
| Field | Range |
|---|---|
is_available | boolean |
price | 0 – 9999.99 |
preparation_time | 0 – 480 minutes |
Anything else is ignored, and a request with none of them returns 422. Names,
descriptions, allergens and categories are owned by the restaurant.
Bulk availability
POST /menu/bulk-availability — up to 100 items, with partial success: each
item reports its own result, so one bad ID doesn't fail the batch. Check the
per-item results rather than assuming a 200 means everything applied.
This is the endpoint for "the supplier didn't deliver, 86 these twelve items".
Locations
GET /locations returns every location visible to the installation:
{
"data": [
{
"id": 1,
"name": "Downtown Bistro",
"address": "100 Main Street",
"city": "Montreal",
"state": "QC",
"postal_code": "H2Y 1C6",
"country": "Canada",
"timezone": "America/Toronto",
"status": "active"
}
]
}
timezone matters: business-day boundaries follow the location's timezone, not
UTC. GET /locations/{locationId} returns one location's detail.
Customers
GET /customers — filters: location_id, search (name, email, or phone),
since, limit.
{
"data": [
{
"id": 42,
"organization_id": 1,
"first_name": "Alice",
"last_name": "Anderson",
"name": "Alice Anderson",
"email": "alice@example.com",
"phone": "+15550001",
"is_repeat_customer": true,
"order_count": 5,
"total_spent": "287.25",
"created_at": "2026-04-01T10:00:00+00:00"
}
],
"meta": { "count": 1, "limit": 50 }
}
GET /customers/{id} adds the full delivery address, the order count visible
to this installation, and the last 5 order IDs.
A pinned installation only sees customers who have ordered at its location, and
order_count_visible reflects that scope rather than the customer's lifetime
total.
Customer records are read-only here — creating one happens implicitly when you
create an order with a customer block.
Inventory
Reading stock
GET /inventory — filters: location_id, ingredient_id, low_stock,
limit.
{
"data": [
{
"ingredient": {
"id": 1,
"name": "Tomato",
"sku": "ING-TOMATO",
"unit": "kg",
"cost_per_unit": "2.5000",
"supplier": "Fresh Farms",
"allergens": ["nightshade"]
},
"stock_level": {
"location_id": 1,
"on_hand": "15.0000",
"reserved": "0.0000",
"reorder_point": "5.0000",
"par_level": "20.0000",
"last_counted_at": null
}
}
],
"meta": { "count": 1, "limit": 50, "low_stock_filter": false }
}
low_stock=true returns only rows where on_hand ≤ reorder_point — a one-call
reorder report.
GET /inventory/{ingredientId} returns the ingredient with all its
per-location stock levels, SKUs, and the last 20 movements.
Recording a movement
POST /inventory/{ingredientId}/movements — an append-only ledger write.
{
"location_id": 1,
"delta": -3.0,
"reason": "consume",
"reference_type": "order",
"reference_id": 456,
"note": "Lunch service usage"
}
| Field | Required | Notes |
|---|---|---|
location_id | Yes | Where it happened |
delta | Yes | Signed, non-zero. Negative consumes, positive adds |
reason | Yes | receive, adjust, consume, waste, transfer, count |
reference_type / reference_id | No | Cross-reference into your own system |
note | No | Free text |
The response echoes the resulting stock_level and an events_dispatched
array, so you know exactly which webhooks fired:
{
"data": {
"id": 1201,
"ingredient_id": 7,
"location_id": 1,
"delta": "-3.0000",
"reason": "consume",
"created_at": "2026-04-17T12:00:00+00:00",
"stock_level": { "on_hand": "12.0000", "reorder_point": "5.0000", "par_level": "20.0000" },
"events_dispatched": ["inventory.stock_updated"]
}
}
| Event | Fires when |
|---|---|
inventory.stock_updated | Always |
inventory.low_stock | Crossing down to/below the reorder point with no open alert |
inventory.low_stock_resolved | Crossing back up, resolving an open alert |
Because these are crossing events, you get one alert per depletion cycle rather than one per movement while stock is low.
Updating ingredient metadata
PATCH /inventory/{ingredientId} — allow-listed: name, sku, unit,
cost_per_unit, supplier, allergens, metadata.
Stock levels are deliberately not writable here. Quantities only move through the movements ledger, so history is always complete.
Inbound webhooks
POST /webhooks/external lets your plugin push provider events into SSP for
audit and future processing. Requires X-SSP-Signature. Full detail:
Webhooks.
POST /webhooks/external records an event. It does not create a
transaction. If funds moved, call POST /orders/{id}/payments.
Working with the API
Handle rate limits and retries
async function call(url, apiKey, init = {}, attempt = 0) {
const res = await fetch(url, {
...init,
headers: { 'X-Plugin-Api-Key': apiKey, ...init.headers },
});
if (res.ok) return res.json();
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 60);
await sleep(wait * 1000);
return call(url, apiKey, init, attempt); // 429s don't count as attempts
}
// 409 = sandbox resetting; 5xx = transient. Back off and retry.
if ((res.status === 409 || res.status >= 500) && attempt < 3) {
await sleep(2 ** attempt * 1000);
return call(url, apiKey, init, attempt + 1);
}
const err = await res.json().catch(() => ({}));
throw new Error(`${err.error ?? res.status}: ${err.message ?? res.statusText}`);
}
Branch on the error code, never on the message text.
Budget your 60 calls a minute
The limit is per installation, per minute. A plugin reacting to a busy dinner service can burn it fast.
- Let webhooks tell you what changed instead of polling. A plugin polling
/ordersevery 5 seconds spends 12 of its 60 calls doing nothing. - Cache what rarely moves. Locations and menu structure change far less often than orders.
- Batch. One
POST /menu/bulk-availabilitybeats 40PATCHcalls.
Cache read-mostly data
const TTL = 60_000;
const cache = new Map();
async function cached(key, fetcher) {
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.value;
const value = await fetcher();
cache.set(key, { value, at: Date.now() });
return value;
}
Use updated_at on menu items as a sync cursor rather than re-fetching whole
menus.
Changelog
2026-09-28 — Created orders start in_progress
What changed. An order created with POST /orders is now sent to the
restaurant's kitchen at creation. Previously it started open, and nothing
reached the kitchen display until it was advanced.
- The create response and an idempotent replay report the order's real state:
in_progressfor a new order (previously alwaysopen). - Subscribed plugins receive
order.status_changedfromCREATEDtoCOOKINGright afterorder.created. PUT /orders/{orderId}with the state the order is already in returns200and changes nothing (previously422 invalid_transition). APUT in_progressright after creation, as this guide used to recommend, keeps working.- The kitchen ticket is labelled with your plugin's
display_name. - Your own created orders now match
GET /orders?status=COOKING, not?status=CREATED.
Transition. PUT with "order_status": "open" on an in_progress order
your plugin created is accepted as a no-op 200 until 2027-03-31, marked
with Deprecation, Sunset and Link headers and a warnings entry with code
deprecated_order_status_open. After that date it returns 400 validation_error.
What to change.
- Don't expect
openafterPOST /orders; readorder_statusfrom the response. - Drop any
PUTwithopen, and anyPUT in_progressthat only existed to start the order. Your next call isready(orcancelled). - If you poll with
?status=CREATEDto find orders you created, use?status=COOKING, or rely on the create response and webhooks. - If you act on
order.status_changed, expectCREATED→COOKINGfor every order you create.
Next steps
- API Reference — the terse tables
- Webhooks — reacting instead of polling
- Payment Gateways · Delivery Platforms