@mantaray-digital/plato-sdk
v0.5.0
Published
Official SDK for the Plato restaurant SaaS. Build a custom storefront against the Plato backend.
Maintainers
Readme
@mantaray-digital/plato-sdk
Official SDK for the Plato restaurant SaaS. Use it to build a custom-branded customer-facing storefront on top of a restaurant's Plato backend — delivery, pickup, takeaway, and dine-in QR ordering. Every order placed through this SDK appears in the restaurant's Plato dashboard, KDS, and reporting exactly like an in-house order.
- HTTP-first: one-shot requests over Convex's HTTP client. No WebSocket. React hooks fetch once and expose
refetch(). - Typed: full TypeScript surface. No
anyin your storefront. - React-friendly: optional React hooks subpath (
@mantaray-digital/plato-sdk/react) for one-line data fetching with a provider. - All order types: delivery, pickup, takeaway, and dine-in via printed table QR codes (see §10).
- COD-only in v1: every order is created with
paymentMethod: "cash"andpaymentStatus: "pending". The restaurant collects payment in person / on delivery. Online payments will arrive in a future release. - Guest or logged-in: place orders with OTP login (recommended for repeat customers) or as a guest by passing
customerPhone+customerNameon each call. See §9.
1. Installation
pnpm add @mantaray-digital/plato-sdk convex react
# or with npm:
npm install @mantaray-digital/plato-sdk convex react
reactis only required if you use the@mantaray-digital/plato-sdk/reactsubpath. Pure-JS callers can skip it.
2. Configuration
You need two values from the Plato dashboard:
- Convex URL — shown at the top of the Integrations → SDK API Keys page. Looks like
https://wise-warbler-42.convex.cloud. - API key — click Generate New Key. The plain key is shown once in a modal; copy it immediately. It will look like
plato_sk_AbCd1234....
Both are intended to live on the client (storefront browser bundle). They are public per-restaurant credentials, not service secrets. Put them in NEXT_PUBLIC_* env vars (or your framework's equivalent) so the storefront has access.
# .env.local
NEXT_PUBLIC_PLATO_CONVEX_URL=https://wise-warbler-42.convex.cloud
NEXT_PUBLIC_PLATO_API_KEY=plato_sk_AbCd1234ExampleEFGH56789ijklmnopQRSSecurity note. The API key gates every SDK call. Anyone who reads it from your bundle can place orders / send OTP texts on the restaurant's behalf. This is the same exposure model as Supabase anon keys or Convex deployment URLs. Mitigations (per-key rate limits, scoped permissions) are on the roadmap. Don't reuse a production storefront key in untrusted environments.
3. Quick start
Create a single shared store instance and reuse it across the app:
// src/lib/plato.ts
import { PlatoStore } from "@mantaray-digital/plato-sdk";
export const plato = new PlatoStore({
convexUrl: process.env.NEXT_PUBLIC_PLATO_CONVEX_URL!,
apiKey: process.env.NEXT_PUBLIC_PLATO_API_KEY!,
});Fetch the menu:
const categories = await plato.menu.categories();
const items = await plato.menu.items({ branchId });Or, with React, wrap your tree with the provider and use the hooks:
// app/layout.tsx
"use client";
import { PlatoProvider } from "@mantaray-digital/plato-sdk/react";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<PlatoProvider
config={{
convexUrl: process.env.NEXT_PUBLIC_PLATO_CONVEX_URL!,
apiKey: process.env.NEXT_PUBLIC_PLATO_API_KEY!,
}}
>
{children}
</PlatoProvider>
);
}// app/menu/page.tsx
"use client";
import { usePlatoMenu } from "@mantaray-digital/plato-sdk/react";
export default function MenuPage() {
const { categories, items, loading, refetch } = usePlatoMenu();
if (loading) return <div>Loading…</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
{categories?.map((c) => (
<section key={c._id}>
<h2>{c.name}</h2>
{items?.filter((i) => i.categoryId === c._id).map((i) => (
<div key={i._id}>{i.name} — {i.price} {/* EGP */}</div>
))}
</section>
))}
</div>
);
}Hooks fetch once on mount. Call refetch() to pull fresh data — e.g. after the user toggles a filter, or on a setInterval for an order-tracking page that should refresh every 15 s.
4. Core concepts
4.1 The store
new PlatoStore(config) constructs a single client with these modules:
| Module | What it covers |
|---|---|
| plato.restaurant | Branch / location list for the API key |
| plato.menu | Categories, items, modifier groups |
| plato.customer | OTP login, profile, addresses |
| plato.cart | Pricing preview (no DB write) |
| plato.delivery | Zone list + per-address fee check |
| plato.promo | Promo code validation |
| plato.orders | Create / read / list / cancel orders |
| plato.tables | Resolve a dine-in table by ID — used in the QR flow (see §10) |
All methods return Promises. All errors are instances of PlatoError (see §10).
Reuse one PlatoStore per app — don't construct a new one for every component.
4.2 Customer sessions
Customers sign in with phone + OTP. After verifyOtp succeeds, the SDK gets a long-lived (90-day) session token and auto-persists it to localStorage. Subsequent app loads pick up the token automatically; the customer stays signed in until they logout, or the token expires/is revoked.
The token lives on PlatoStore (not on the customer module):
plato.getCustomerToken(); // current token or null
plato.setCustomerToken(t); // manual override (e.g. hydrate from cookie)
plato.clearCustomerToken(); // logout without server roundtripTo use a different storage strategy (sessionStorage, AsyncStorage on React Native, an HTTP-only cookie via your own server, etc.), pass a storage adapter to the constructor:
import { PlatoStore, memoryStorage, type TokenStorage } from "@mantaray-digital/plato-sdk";
const cookieStorage: TokenStorage = {
get: () => readCookie("plato_token"),
set: (t) => writeCookie("plato_token", t),
clear: () => deleteCookie("plato_token"),
};
export const plato = new PlatoStore({
convexUrl: ...,
apiKey: ...,
storage: cookieStorage, // or memoryStorage(), or "none"
});4.3 Order source and payments
Orders created via the SDK are tagged by source:
orderSource: "online"— delivery, pickup, takeaway orders (default).orderSource: "qr_table"— dine-in orders placed by scanning a printed table QR code (see §10). The order is automatically attached to an activetableSessionfor that table and shows up on the POS table view + KDS station feed with the table number.
Two payment paths are supported, matching the built-in storefront:
- Cash on delivery / on collection —
orders.create(...)lands the order withpaymentMethod: "cash",paymentStatus: "pending", settled in person (driver, pickup counter, or POS for dine-in). - Online card (pay-first) —
payments.initiateOnlineOrder(...)prices the cart, returns a hosted MyFatoorah payment URL, and only creates the real order once the charge clears (abandoned payments leave no orphan order). See §10A. Checkpayments.availability()first; the restaurant must have a validated gateway configured.
Dine-in tabs add a third path — pay the running tab by card (session.payOnline) or request a waiter to take cash (session.requestCash). See §10B.
4.4 Branches
The Plato backend is multi-branch. Every menu read and order create takes a branchId. Fetch the list once and let the customer pick (or pre-pick by geolocation):
const branches = await plato.restaurant.getBranches();In v1 each API key authorizes one restaurant, so getBranches() returns at most one entry.
5. Customer auth flow
End-to-end OTP sign-in. The example assumes React; the imperative API works the same in vanilla JS.
"use client";
import { useState } from "react";
import { useCustomer } from "@mantaray-digital/plato-sdk/react";
export default function SignIn() {
const { customer, isLoggedIn, requestOtp, verifyOtp, logout } = useCustomer();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "code">("phone");
if (isLoggedIn) {
return (
<div>
Hi {customer?.name ?? customer?.phone}!
<button onClick={() => logout()}>Sign out</button>
</div>
);
}
if (step === "phone") {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await requestOtp(phone);
setStep("code");
}}
>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="01012345678" />
<button>Send code</button>
</form>
);
}
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await verifyOtp(phone, code);
// useCustomer auto-rerenders with isLoggedIn=true.
}}
>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" />
<button>Verify</button>
</form>
);
}Notes
- Phone numbers are normalized to E.164 server-side. Local Egyptian formats (
01012345678) are accepted and converted to+201012345678. - A second
requestOtpcall for the same phone within 10 minutes counts toward a rate limit (max 3 sends). Surface the error ifrequestOtpthrows. verifyOtprevokes any prior active session for that phone in this restaurant and mints a fresh one. Old devices get logged out.
6. Cart pricing
plato.cart.calculate(input) returns a server-computed breakdown without creating an order. Use it to render an accurate cart total, including tax, service charge, additional fees, delivery, and promo discount.
const breakdown = await plato.cart.calculate({
branchId,
orderType: "delivery",
items: [
{ menuItemId, quantity: 2 },
{ menuItemId: id2, quantity: 1, modifiers: [{ groupName: "Size", optionName: "Large", priceAdjustment: 10 }] },
],
addressLatitude: 30.0444,
addressLongitude: 31.2357,
promoCode: "WELCOME10",
});
// {
// currency: "EGP",
// lines: [...],
// subtotal: 120,
// tax: 16.8, // 14% Egyptian VAT
// serviceCharge: 0,
// additionalFees: [],
// additionalFeesTotal: 0,
// deliveryFee: 25,
// delivery: { inZone: true, distance: 2.4, estimatedTime: 35, zoneName: "Downtown" },
// promoDiscount: 12,
// promo: { code: "WELCOME10", name: "Welcome 10%", type: "percentage", ... },
// promoError: null,
// total: 149.8,
// meetsDeliveryMin: true,
// }With React, use usePlatoCart — it re-runs every time the input changes:
import { usePlatoCart } from "@mantaray-digital/plato-sdk/react";
const { data: breakdown, loading } = usePlatoCart(cartItems.length === 0 ? null : {
branchId,
orderType: "delivery",
items: cartItems,
addressLatitude: selectedAddress?.latitude,
addressLongitude: selectedAddress?.longitude,
promoCode: enteredPromo,
});Pass null (or an empty items array) to disable the hook when the cart is empty.
7. Delivery
// All active delivery zones (for an info dialog / map overlay)
const zones = await plato.delivery.zones();
// Check whether a specific address is deliverable + what it costs
const check = await plato.delivery.check(30.0444, 31.2357);
if (check.success) {
// { success: true, inZone: true, deliveryFee, distance, estimatedTime, zoneName?, minOrderAmount? }
} else {
// { success: false, reason: "outside_zones" | "outside_radius" | "delivery_disabled" | ..., distance }
}React hooks: useDeliveryZones(), useDeliveryCheck(coords | null).
8. Promo codes
const result = await plato.promo.validate({
code: "WELCOME10",
subtotal: 120,
orderType: "delivery",
customerPhone: "+201012345678", // optional — enables per-customer usage limit checks
});
if (result.valid) {
// { valid: true, code, name, type, discountAmount, isFreeDelivery, ... }
} else {
// { valid: false, error: "This promo code has expired" }
}This is a pure read — usage is recorded automatically when you place the order via plato.orders.create. Don't call validate and then expect totalUsed to bump; only an actual order does that.
9. Orders
Create — logged-in
The SDK auto-injects the customer token from storage once verifyOtp succeeds.
const { orderId, orderNumber, total, paymentMethod, estimatedReadyAt } =
await plato.orders.create({
branchId,
orderType: "delivery", // "dine_in" | "takeaway" | "pickup" | "delivery"
items: [
{ menuItemId, quantity: 2 },
{ menuItemId: id2, quantity: 1, modifiers: [...], notes: "no onion" },
],
addressId: savedAddressId, // logged-in only
// — or, raw fields (always usable):
deliveryAddress: "12 Tahrir St, Cairo",
deliveryLatitude: 30.0444,
deliveryLongitude: 31.2357,
deliveryNotes: "Ring the bell twice",
promoCode: "WELCOME10",
notes: "Birthday — please add a candle",
});The server recomputes every price from menu data + restaurant settings — never trust client-side totals. If a price changed since your last cart calculation, orders.create will use the new price. Show the user the final total from the response before they navigate away.
paymentMethod will always be "cash" in this release.
Create — guest checkout
Customers can place orders without an OTP login. Pass customerPhone (and optionally customerName) in place of the token:
const { orderId, orderNumber, total } = await plato.orders.create({
branchId,
orderType: "delivery",
items: [{ menuItemId, quantity: 1 }],
customerPhone: "01012345678", // required for guests; E.164-normalized server-side
customerName: "Sara A.", // optional but recommended
deliveryAddress: "12 Tahrir St, Cairo",
deliveryLatitude: 30.0444,
deliveryLongitude: 31.2357,
});Behavior:
- The server upserts a
customersrow keyed by(restaurantId, phone)— the restaurant's CRM, KDS, and reporting see the guest exactly like a returning customer. - Saved addresses (
addressId) are not available to guests. Use rawdeliveryAddress+ lat/lng fields. - The guest receives no session token. To check or cancel the order later they must pass the same
customerPhoneagain (see below).
Get / list / cancel — logged-in
import { usePlatoOrder, usePlatoOrders } from "@mantaray-digital/plato-sdk/react";
// Single order — status, ETA, items. Call refetch() to refresh.
const { data: order, refetch } = usePlatoOrder(orderId);
// order.status changes:
// "pending" → "in_progress" → "waiting_for_pickup" → "out_for_delivery" → "delivered"
// (or "cancelled" if the customer or restaurant cancels)
// Order history
const { data: orders } = usePlatoOrders({ limit: 20 });
const activeOnly = usePlatoOrders({ status: "in_progress" });Imperative: plato.orders.get(orderId), plato.orders.list({ status?, limit? }), plato.orders.cancel(orderId, { reason }).
Get / list / cancel — guest
Pass { customerPhone } as the second arg. The server verifies the phone matches the order's customerPhone (server-side E.164 normalization).
// Imperative
const order = await plato.orders.get(orderId, { customerPhone: "01012345678" });
const orders = await plato.orders.list({
customerPhone: "01012345678",
status: "out_for_delivery",
limit: 5,
});
await plato.orders.cancel(orderId, {
customerPhone: "01012345678",
reason: "Wrong address",
});// React
const { data: order } = usePlatoOrder(orderId, { customerPhone });
const { data: history } = usePlatoOrders({ customerPhone, limit: 20 });Order-tracking pages: since this SDK is polling, not WebSocket-based, an order-status screen should call
refetch()on asetInterval(e.g. every 15 seconds). Example:useEffect(() => { const id = setInterval(refetch, 15_000); return () => clearInterval(id); }, [refetch]);
Cancel
Allowed only while status === "pending" (i.e. before the kitchen fires the ticket). After firing, the customer cannot self-cancel — they must contact the restaurant.
10. Dine-in QR ordering
The Plato dashboard prints a QR code for every table. Each QR encodes a URL on your storefront in the form:
{your-storefront-url}/table/{tableId}The base URL comes from Integrations → SDK API Keys → Storefront URL in the Plato dashboard — set it once and every printed QR code points at your site. Customers scan the code at the table, your storefront pre-binds the order to that table, and the order lands in the kitchen tagged Dine-in · {tableNumber}.
10.1 Resolve the table after the scan
When the storefront loads at /table/{tableId}, call plato.tables.resolve(tableId) first. This:
- Validates the table belongs to your restaurant (no cross-tenant leakage).
- Returns the human-readable table number to display to the customer.
- Returns the
branchIdyou must pass when creating the order.
import { plato } from "@/lib/plato";
const table = await plato.tables.resolve(tableId);
if (!table) {
// Unknown table or wrong restaurant — show "Table not found".
return;
}
// table.tableNumber → "5"
// table.branchId → use this for cart preview + order create
// table.branchName → optional UX hint ("Ordering at Downtown branch")
// table.status → "vacant" | "occupied" | "reserved" | "cleaning"If table.status === "cleaning" you should block ordering and show a friendly "this table is being cleaned" message — the backend will reject the order anyway, but failing fast is better UX.
With React:
import { useState, useEffect } from "react";
import { usePlatoStore } from "@mantaray-digital/plato-sdk/react";
import type { ResolveTableResult } from "@mantaray-digital/plato-sdk";
export function useTable(tableId: string | null) {
const plato = usePlatoStore();
const [table, setTable] = useState<ResolveTableResult | null | undefined>(undefined);
useEffect(() => {
if (!tableId) return;
plato.tables.resolve(tableId).then(setTable);
}, [tableId, plato]);
return table; // undefined = loading, null = not found, object = resolved
}10.2 Place the dine-in order
Pass orderType: "dine_in" + tableId to plato.orders.create(...). No delivery address needed.
const { orderId, orderNumber, total } = await plato.orders.create({
branchId: table.branchId, // from resolveTable
orderType: "dine_in",
tableId: table.tableId, // from resolveTable
items: [
{ menuItemId, quantity: 2 },
{ menuItemId: id2, quantity: 1, modifiers: [...], notes: "no onion" },
],
customerPhone: "01012345678", // required (E.164-normalized server-side)
customerName: "Sara A.", // optional but recommended
notes: "Allergic to nuts",
});What happens server-side:
- The backend verifies the table belongs to your API key's restaurant and to the
branchIdyou passed. - If there's no active session for this table, one is opened automatically — every subsequent QR order from the same table accrues to the same session/bill until POS closes it for cleaning.
- The table flips to
occupied(if it wasn't already) andcurrentOrderIdis updated to your new order. - The order is tagged
orderSource: "qr_table",type: "dine_in", and stamped withtableNumber. The KDS station feed displaysDine-in · 5next to the items. - The customer can still call
plato.orders.get(orderId, { customerPhone })to track status, just like any other order.
10.3 Validation rules
orderType: "dine_in"requirestableId. Omitting it throws.tableIdis rejected for non-dine-in orders (delivery, pickup, takeaway) — pass it only withdine_in.- The table must belong to the same
branchIdyou pass. Mixed branch/table will error withcross_tenant_access. - Tables in
status: "cleaning"reject orders until the POS marks the cleanup complete.
10.4 Call the waiter
Guests sitting at a dine-in table can summon the waiter from the storefront — for the bill, water, a question, anything. Wire a single button:
await plato.tables.callWaiter(table.tableId);What happens server-side:
- The backend verifies the table belongs to your API key's restaurant.
- If a pending
call_waiternotification already exists for this table, the same id is returned withdeduped: trueand no new row is created — the waiter is already on the way. - Otherwise a Pending notification of type
call_waiteris inserted with message"Table {N} needs help". It's routed to the table's assigned waiter (if any) and shows up on the waiter app's bell + notifications list as a red "Customer calling" entry. Tapping it acknowledges the call and jumps the waiter to the table.
const { notificationId, deduped } = await plato.tables.callWaiter(table.tableId);
// deduped === true means an in-flight call was already pending — show a
// "Waiter is on the way" message rather than another "Sent" toast.No rate-limit error is thrown — the dedupe makes a "Call waiter" button safe to tap repeatedly. The badge only clears when the waiter acknowledges from the waiter app.
10.5 Multiple guests at the same table
The first scan opens the session; every subsequent order from any device that scans the same QR joins the same session and the orders are billed together. There's no separate "join" call — passing the same tableId automatically merges. Each customerPhone shows up as a distinct customer in the restaurant's CRM, but the bill is one.
If the restaurant wants a fresh session (new party of guests sitting down), the waiter clears the table via POS — which closes the previous session and the next scan starts a new one.
10A. Online card payments (pay-first)
Card payment uses a pay-first flow: the cart is priced and stashed server-side, the customer pays on a hosted MyFatoorah page, and the real order is created only once the charge clears. Abandoned payments never leave an orphan order.
// 1. Only offer card if the restaurant has a gateway configured.
const { available } = await plato.payments.availability();
// 2. Start the payment. Logged-in: token auto-injected. Guest: pass customerPhone.
const { paymentUrl, pendingOrderId } = await plato.payments.initiateOnlineOrder({
branchId,
orderType: "delivery",
items, // same shape as orders.create
deliveryAddress, deliveryLatitude, deliveryLongitude,
customerPhone: "01012345678", // guest checkout
language: "en", // hosted-page language
});
// 3. Stash the id and redirect to the hosted page.
localStorage.setItem("pendingOrderId", pendingOrderId);
window.location.href = paymentUrl;
// 4. On return, poll until the order is created (or payment failed).
const pending = await plato.payments.getPendingOrder(pendingOrderId); // guest: (id, { customerPhone })
if (pending?.createdOrderId) {
// Paid — forward to tracking.
const order = await plato.payments.getOrderTracking(pending.createdOrderId);
} else if (pending?.lastFailureAt) {
// Declined / abandoned — offer a retry (no re-pricing).
const { paymentUrl } = await plato.payments.retryPayment(pendingOrderId);
}getOrderTracking(orderId) returns the live order with the full breakdown and the out-for-delivery driver reveal: driverName / driverNameAr / driverPhone are null until the order is out for delivery (a server-side privacy gate), then populated so you can show "Your driver is on the way" with a call button. Poll it on an interval for a live tracking screen.
10B. Dine-in running tab & split billing
Beyond placing a single dine-in order (§10), the SDK exposes the full running tab: multiple guests at one table, a live split bill, and settlement by card or cash — mirroring the built-in /tab page.
// Scan → start (or resume) the table's session.
const { sessionId, token } = await plato.session.start(tableId);
// Identify each guest so their items + split shares are attributed.
const { guestId } = await plato.session.join(tableId, { name: "Sara", phone: "01012345678" });
// …place orders with orders.create({ orderType: "dine_in", tableId, ... }) …
// Live split bill. splitMode: "by_items" (default) or "equal".
const bill = await plato.session.guestBalances(sessionId);
// → { guests: [{ guestId, guestName, balance, paid, remaining, paymentStatus }], totalRemaining, allPaid, ... }
// Settle a guest's share (or the whole tab) by card…
const { paymentUrl } = await plato.session.payOnline({
sessionId, guestId,
amount: bill.guests[0].remaining,
customerName: "Sara",
});
window.location.href = paymentUrl;
// …or ask a waiter to collect cash.
await plato.session.requestCash({ sessionId, guestId, amount: bill.guests[0].remaining });
// "Pay whole table" concurrency lock (so two phones don't both pay the full tab).
await plato.session.lockFullTab(sessionId, guestId);
// …pay…
await plato.session.unlockFullTab(sessionId, guestId);Other reads: session.resolveByToken(token) (from the QR/"view my bill" link), session.resolveTable(tableId) ({ status: "active" | "inactive" | "not_found" }), session.guests(sessionId), and session.balance(sessionId) (whole-tab total/paid/remaining).
10C. Pickup branches & cart reconcile
// Nearest pickup branches (multi-branch brands), sorted by distance when a
// location is given — each with distance, open status, minutes-until-open, ETA.
const branches = await plato.restaurant.pickupBranches({ latitude, longitude });
// → [{ slug, name, distanceKm, open, opensInMinutes, etaMinutes, isCurrent, isPrimary }]
// navigate to a branch's `slug` to switch the storefront to it.
// Re-check a browsed cart against the CURRENT menu right before checkout.
const verdicts = await plato.cart.reconcile(
cart.map((l) => ({ id: l.lineId, menuItemId: l.menuItemId, variationName: l.variationName, unitPrice: l.unitPrice })),
branchId,
);
// → [{ id, status: "available" | "unavailable" | "price_changed", currentPrice? }]11. Error handling
Every method that hits the backend throws a PlatoError on failure:
import { PlatoError } from "@mantaray-digital/plato-sdk";
try {
await plato.orders.create({ ... });
} catch (err) {
if (err instanceof PlatoError) {
switch (err.code) {
case "customer_session_missing":
case "customer_session_expired":
case "customer_session_revoked":
// Redirect to login
break;
case "invalid_api_key":
case "revoked_api_key":
// Configuration bug — surface to the dev, not the user
break;
case "cross_tenant_access":
// Data does not belong to this restaurant/customer
break;
case "not_found":
// Missing order / address / etc.
break;
default:
// Other ConvexErrors come through with code: "unknown" and the
// raw message on err.message. Examples: "delivery not available:
// outside_zones", "promo code: This promo code has expired".
}
}
}Error codes
| Code | Meaning |
|---|---|
| api_key_missing | No API key passed to new PlatoStore() |
| invalid_api_key | Key not found in the backend (typo or deleted) |
| revoked_api_key | Key was revoked from the dashboard |
| customer_session_missing | Calling a customer-scoped method with no token |
| customer_session_invalid | Token doesn't match any session |
| customer_session_expired | 90-day TTL elapsed |
| customer_session_revoked | Session revoked (logout or re-OTP from another device) |
| cross_tenant_access | Token's restaurant ≠ API key's restaurant, or accessing another customer's data |
| not_found | Address / order doesn't exist |
| unknown | Any other backend error; check err.message |
12. Reference
new PlatoStore(config) → PlatoStore
interface PlatoStoreConfig {
convexUrl: string;
apiKey: string;
storage?: TokenStorage | "none"; // default: browser localStorage
}PlatoStore surface
class PlatoStore {
constructor(config: PlatoStoreConfig);
// Token state
getCustomerToken(): string | null;
setCustomerToken(token: string): void;
clearCustomerToken(): void;
onTokenChange(cb: (token: string | null) => void): () => void;
// Restaurant
restaurant: {
getBranches(): Promise<Branch[]>;
pickupBranches(location?: { latitude: number; longitude: number }): Promise<PickupBranch[]>;
};
// Menu
menu: {
categories(): Promise<MenuCategory[]>;
items(opts?: { branchId?: string; categoryId?: string }): Promise<MenuItem[]>;
item(itemId: string, opts?: { branchId?: string }): Promise<MenuItem>;
modifiers(itemId: string): Promise<ModifierGroup[]>;
};
// Customer
customer: {
requestOtp(phone: string, channel?: "sms" | "whatsapp"): Promise<{ sent: true; usedChannel: string }>;
verifyOtp(phone: string, code: string): Promise<{ customerToken: string; customer: Customer | null }>;
me(): Promise<Customer | null>;
updateProfile(input: UpdateProfileInput): Promise<Customer | null>;
logout(): Promise<void>;
isLoggedIn(): boolean;
getAddresses(): Promise<CustomerAddress[]>;
addAddress(input: AddAddressInput): Promise<CustomerAddress | null>;
updateAddress(addressId: string, input: Partial<AddAddressInput>): Promise<CustomerAddress | null>;
deleteAddress(addressId: string): Promise<void>;
};
// Pricing preview + reconcile
cart: {
calculate(input: CartCalculateInput): Promise<CartBreakdown>;
reconcile(items: CartReconcileLine[], branchId?: string): Promise<CartReconcileVerdict[]>;
};
// Online card payments (pay-first MyFatoorah). See §10A.
payments: {
availability(): Promise<{ available: boolean }>;
initiateOnlineOrder(input: InitiateOnlineOrderInput): Promise<InitiateOnlineOrderResult>;
retryPayment(pendingOrderId: string, opts?: { customerName?: string; customerMobile?: string; language?: string }): Promise<RetryPaymentResult>;
getPendingOrder(pendingOrderId: string, guest?: { customerPhone: string }): Promise<PendingOnlineOrder | null>;
getOrderTracking(orderId: string, guest?: { customerPhone: string }): Promise<OrderTracking | null>;
};
// Dine-in running tab + split billing. See §10B.
session: {
resolveByToken(token: string): Promise<TableSessionInfo | null>;
resolveTable(tableId: string): Promise<TableResolveResult>;
guests(sessionId: string): Promise<SessionGuest[]>;
balance(sessionId: string): Promise<SessionBalance>;
guestBalances(sessionId: string, splitMode?: SplitMode): Promise<GuestBalances>;
start(tableId: string): Promise<StartSessionResult>;
join(tableId: string, guest: { name: string; phone: string }): Promise<JoinTableResult>;
requestCash(input: { sessionId: string; guestId?: string; amount: number; tipAmount?: number }): Promise<{ notificationId: string }>;
payOnline(input: { sessionId: string; guestId?: string; amount: number; tipAmount?: number; customerName: string; customerEmail?: string; customerMobile?: string; language?: string }): Promise<{ paymentUrl: string; invoiceId: number }>;
lockFullTab(sessionId: string, guestId: string): Promise<{ success: boolean }>;
unlockFullTab(sessionId: string, guestId: string): Promise<{ success: boolean }>;
};
// Delivery
delivery: {
zones(): Promise<DeliveryZone[]>;
check(latitude: number, longitude: number): Promise<DeliveryCheckResult>;
};
// Promo codes
promo: {
validate(input: { code: string; subtotal: number; orderType?; customerPhone? }): Promise<PromoValidateResult>;
};
// Orders — each method works for logged-in or guest callers.
// For dine-in QR orders, pass `orderType: "dine_in"` + `tableId` to create().
orders: {
create(input: CreateOrderInput): Promise<CreateOrderResult>;
get(orderId: string, guest?: { customerPhone: string }): Promise<Order>;
list(input?: { status?: OrderStatus; limit?: number; customerPhone?: string }): Promise<OrderHeader[]>;
cancel(orderId: string, opts?: { reason?: string; customerPhone?: string }): Promise<void>;
};
// Tables — dine-in QR flow. See §10.
tables: {
resolve(tableId: string): Promise<ResolveTableResult | null>;
callWaiter(tableId: string): Promise<CallWaiterResult>;
};
}React hooks (@mantaray-digital/plato-sdk/react)
Wrap your tree in <PlatoProvider config={...}> once. All hooks then read the store from context and return { data, error, loading, refetch } unless noted.
// Provider
<PlatoProvider config={{ apiKey, convexUrl }}>...</PlatoProvider>
// Store access
usePlatoStore(): PlatoStore // the underlying instance
// Restaurant / menu
usePlatoBranches()
usePlatoCategories()
usePlatoItems(opts?)
usePlatoItem(itemId | null, opts?)
usePlatoModifiers(itemId | null)
usePlatoMenu(opts?) // { categories, items, error, loading, refetch }
// Customer
useCustomer() // { customer, isLoggedIn, loading, error, requestOtp, verifyOtp, logout, updateProfile, refetch }
useCustomerAddresses()
// Cart / delivery / promo
usePlatoCart(input | null) // null = disabled
useDeliveryZones()
useDeliveryCheck(coords | null)
usePromoValidate(input | null)
// Orders
usePlatoOrder(orderId | null)
usePlatoOrders({ status?, limit? }?)All hooks fetch once on mount and re-run when their deps change. There are no WebSocket subscriptions. Use refetch() (returned from every hook) when you need a fresh read, or pair it with setInterval for an order-tracking screen.
All exported types
import type {
PlatoStore, PlatoStoreConfig, PlatoError, PlatoErrorCode,
TokenStorage,
Branch,
MenuCategory, MenuItem, MenuItemVariation, ModifierGroup, ModifierOption,
Customer, CustomerAddress, OtpChannel,
RequestOtpResult, VerifyOtpResult, UpdateProfileInput, AddAddressInput,
CartOrderType, CartLineInput, CartCalculateInput, CartLine, CartAppliedFee, CartBreakdown,
DeliveryZone, DeliveryCheckResult,
PromoValidateResult, PromoValidateInput,
OrderStatus, OrderPaymentStatus, OrderHeader, OrderItem, Order,
CreateOrderInput, CreateOrderResult, ListOrdersInput,
ResolveTableResult, CallWaiterResult,
// Online card payments
InitiateOnlineOrderInput, InitiateOnlineOrderResult, RetryPaymentResult,
PendingOnlineOrder, OrderTracking,
// Dine-in tab / split billing
SplitMode, TableSessionInfo, TableResolveResult, SessionGuest,
SessionBalance, GuestBalance, GuestBalances, StartSessionResult, JoinTableResult,
// Pickup picker + cart reconcile
PickupBranch, CartReconcileLine, CartReconcileVerdict,
} from "@mantaray-digital/plato-sdk";13. Limitations (v1)
- Payments: cash-on-delivery and online card (pay-first MyFatoorah) are supported (§10A); online card requires the restaurant to have a validated gateway configured. No Stripe/other gateways yet.
- Live updates: this SDK is HTTP-only; there are no WebSocket subscriptions. Order tracking + dine-in tab balances should poll via
refetch()on an interval. - Webhooks: Plato does not currently push events (e.g.
order.created,order.delivered) to a URL of your choosing. Poll on an interval instead. - Rate limits: per-key rate limits are not yet enforced. Don't ship the key to untrusted environments.
- Per-key scopes: every key has full storefront privileges. Read-only / scoped keys are future work.
- Transports:
convexpeer dep is required for the underlyingConvexHttpClient. There is no REST or GraphQL transport in v1.
14. Troubleshooting
| Symptom | Cause / fix |
|---|---|
| PlatoError { code: "invalid_api_key" } on every call | Wrong key, or key was deleted. Regenerate from the Plato dashboard. |
| PlatoError { code: "revoked_api_key" } | The restaurant revoked this key from the dashboard. Generate a new one. |
| requestOtp works once then fails | Rate limit (3 sends per 10 min per phone). Wait, or use a different number for testing. |
| verifyOtp succeeds but useCustomer shows isLoggedIn: false | The hook reads from localStorage. If you're using a custom storage adapter, make sure its get() is synchronous and returns the token immediately after set(). |
| usePlatoStore must be used inside <PlatoProvider> | A hook was called outside the provider tree. Move the component under <PlatoProvider> or call the imperative API directly. |
| Order status doesn't change automatically | The SDK is polling, not subscription-based. Call refetch() on a setInterval, or rely on user-triggered refresh. |
| delivery not available: outside_zones | The customer's address is outside every delivery zone the restaurant has defined. Either ask the customer for a different address or extend the zones in the Plato dashboard. |
| promo code: This promo code has expired | The promo's validUntil has passed. Use a different code. |
| Order create says cannot cancel an order in status in_progress | Once the kitchen fires the ticket, the customer can't self-cancel. They have to contact the restaurant. |
| plato.tables.resolve(tableId) returns null | Either the table doesn't exist or it belongs to a different restaurant than your API key. Verify the QR was printed from the matching Plato dashboard. |
| dine_in orders require tableId | You called orders.create({ orderType: "dine_in" }) without a tableId. Resolve the table from the QR URL first. |
| tableId is only valid for dine_in orders | You passed tableId with orderType: "delivery" (or other non-dine-in). Drop tableId from non-dine-in calls. |
| table is being cleaned | The waiter has closed this table for cleaning. New orders are blocked until POS marks it vacant. |
15. Versioning
This SDK uses semver. 0.x.y versions are pre-1.0 and may include breaking changes. The Plato backend will keep older SDK versions working at least one minor cycle past their release.
16. Reporting issues
When something doesn't work, the Plato team will need:
- The SDK version (
pnpm list @mantaray-digital/plato-sdk) - The Convex URL you're hitting (not the API key)
- The
PlatoError.codeand.message - A minimal reproduction (the input args to the failing method)
