Saltar al contenido principal

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.

Location pinning changes everything you see

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

ParameterTypeNotes
location_idintegerIgnored when the installation is location-pinned
statusstringMatches the internal status column — CREATED, COOKING, SERVED, COMPLETED, PAID, CANCELLED
sincedatetimeISO 8601 — orders created at or after this instant
limitinteger1–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 }
}
The filter and the response use different vocabularies

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()];
}
Prefer webhooks over backfilling

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"}}'
FieldRequiredNotes
order_statusYesin_progress, ready, completed, paid, or cancelled
cancellation_reasonWhen cancellingMax 2000 characters
metadataNoShallow-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.

Deprecated: PUT with open

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

Some orders aren't plugin-reachable

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, and total come 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 (internally COOKING), not open. The create response (and an idempotent replay) reports the order's real current state. There is nothing to send or confirm: your next PUT is ready or cancelled.
  • The kitchen ticket names your plugin. It is labelled with your plugin's display_name from 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:

FormFieldsPricing
Preset-backedtemplate_id (+ optional quantity)Server snapshots the template's price
Ad-hocname + price (+ optional description)Plugin-authoritative

Violations return 422:

ErrorCause
customization_location_mismatchThe template belongs to a different location
customization_rule_violationBreaks 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" }
}
FieldRequiredNotes
amountYesPositive; must not exceed the remaining balance
currencyYesISO 4217, 3 characters, must match the order's location currency
payment_methodYesupi, qr_code, card, cash, wallet, bank_transfer, other
providerYesYour provider's name
provider_transaction_idYesUnique per organization — this is the idempotency key
captured_atYesISO 8601
payer_referenceNoVPA, card last 4, wallet ID — for reconciliation
metadataNoProvider-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"
}
}
These key names differ from the request

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 PUT

partially_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​

ErrorHTTPCause
feature_not_enabled403No payments:capture capability
subscription_locked403The organization's subscription state blocks capture
currency_mismatch422Doesn't match the location's currency — response includes both
overpayment422Exceeds the remaining balance
validation_error422Bad 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.


Reading​

GET /menu — filters: location_id, category_id, available_only.

available_only defaults to true

Unless 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_active

is_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:

FieldRange
is_availableboolean
price0 – 9999.99
preparation_time0 – 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.

Location-pinned installations see less

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"
}
FieldRequiredNotes
location_idYesWhere it happened
deltaYesSigned, non-zero. Negative consumes, positive adds
reasonYesreceive, adjust, consume, waste, transfer, count
reference_type / reference_idNoCross-reference into your own system
noteNoFree 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"]
}
}
EventFires when
inventory.stock_updatedAlways
inventory.low_stockCrossing down to/below the reorder point with no open alert
inventory.low_stock_resolvedCrossing 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.

Events aren't money

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 /orders every 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-availability beats 40 PATCH calls.

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_progress for a new order (previously always open).
  • Subscribed plugins receive order.status_changed from CREATED to COOKING right after order.created.
  • PUT /orders/{orderId} with the state the order is already in returns 200 and changes nothing (previously 422 invalid_transition). A PUT in_progress right 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 open after POST /orders; read order_status from the response.
  • Drop any PUT with open, and any PUT in_progress that only existed to start the order. Your next call is ready (or cancelled).
  • If you poll with ?status=CREATED to find orders you created, use ?status=COOKING, or rely on the create response and webhooks.
  • If you act on order.status_changed, expect CREATED → COOKING for every order you create.

Next steps​