Using SupplySynk as a backend
Another app (for example a site built on bzlabs.io, an internal tool, or an AI agent) can use SupplySynk as its inventory and order backend: products, stock, orders, customers, purchasing, insights and Square sales, through the REST API or MCP. Both run the same services as the dashboard, with the same checks, audit log and webhooks, so an app can't do anything the business couldn't do itself.
Pick an interface
| You are building | Use | Auth |
|---|---|---|
| A website that shows the shop's products and takes pickup orders | Storefront API /api/storefront/v1 (guide) | Publishable key pk_live_…, safe in a web page |
| A server (site backend, sync job, integration) | REST /api/v1 | API key, sent only from your server |
| An AI agent acting for a person | MCP /api/mcp/<business-slug> | OAuth sign-in (the person approves scopes and an access level) |
| An AI agent running unattended | MCP /api/mcp | API key |
| Something that reacts to changes | Webhooks (register over REST) | Signing secret per endpoint |
Authentication and tenancy
- API keys are created by an owner or admin in Developers → API Keys,
with only the scopes the app needs. Send
Authorization: Bearer ssk_…(or HTTP BasicclientId:clientSecret). The key alone decides which business you act on; there is no organization id in requests. On a business host (acme.supplysynk.comor a custom domain) the key must belong to that business. - Scopes are
read:*/write:*per area (products, inventory, orders, customers, suppliers, locations, categories, purchase_orders), plusread:reports,read:ai-insights,write:ai-insights,manage:webhooks,manage:integrationsandread:settings/write:settings(the online store switch,GET/PUT /storefront/settings). Each endpoint and tool names its scope. - Never put an API key in a browser or mobile app. Keys are secret. For a web page use a publishable key with the Storefront API (below).
REST API
- Base URL:
https://<your-business>.supplysynk.com/api/v1(or the app URL). - Spec:
GET /api/v1/openapi.json(OpenAPI 3.1, public, generated from the same schemas the server validates with). Generate a typed client from it. - Pagination:
?limit=(1–100, default 25) and?offset=; responses are{ data, total, limit, offset }. - Idempotency: send
Idempotency-Key: <uuid>onPOST /orders,/purchase-orders,/transfersand/counter/sales(counter billing, guide). A retry with the same key and body returns the first result instead of creating a duplicate. - Errors: every error (auth, validation, not found, conflicts, 500) uses
one envelope,
{ "error": { "code", "message", "details", "request_id" } }(detailsonly when there is something to add), and the same id is in theX-Request-Idheader.400 invalid_request(validation, with field details) orinvalid_json,401,403 forbidden(missing scope or role),404 not_found(unknown id, another organization's id, or an id that isn't a UUID at all),409(conflict,invalid_state,insufficient_stock,insufficient_available),429. - Usage: every call made with an API key (REST and MCP) is logged and shown under Developers → Usage Analytics (kept 90 days).
- Rate limits: per key (default 1,000 requests/hour). Every response has
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset; a 429 addsRetry-After. - Money is in the business currency. Amounts (prices, costs, order and
purchase-order totals, insights) are plain numbers in the organization's
currency, USD or INR for now; nothing is converted.
GET /api/v1/insights(and theget_sales_insightsMCP tool) report it asperiod.currency, with the business's country (USorIN, D-22) asperiod.country. Purchase orders and new suppliers default to the business currency. For an Indian business, phone numbers are checked and stored as+91 98765 43210, and addresses are stored as{ line1, line2, city, district, region, regionCode, postalCode, country }with the State/UT name inregion, its GST state code inregionCodeand a 6-digit PIN; an invalid one is a400naming the field (e.g.billingAddress.postalCode). Old address keys (street,state,zip) are still accepted and saved in the new shape. - Stock is never written directly. Use orders (reserve → fulfil →
return), adjustments, transfers, counts and purchase-order receipts; each
writes a ledger movement and fires webhooks. A product's stock history is
GET /api/v1/inventory/movements?productId=…(newest first, withlocationNameand the adjustmentreasonasnotes).
# Products with stock at each location
curl -s "https://acme.supplysynk.com/api/v1/products?limit=50" -H "Authorization: Bearer $SUPPLYSYNK_KEY"
# Place an order that reserves stock, safely retryable
curl -s -X POST https://acme.supplysynk.com/api/v1/orders \
-H "Authorization: Bearer $SUPPLYSYNK_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: 7d3c0d2e-6a4f-4d55-9f43-2f0a0d9f1b11" \
-d '{"channel":"web","channelOrderId":"WEB-1042","customerEmail":"sam@example.com","items":[{"productId":"<uuid>","quantity":2}]}'
Images and AI
- Photos: upload in the dashboard, or add from a public link with the
add_product_photo_from_urlMCP tool. Each image inGET /products/{id}hasurl,mediumUrl,thumbUrl,alt,isPrimaryand, for AI-made images,generated: true. Label or filter those in your shop (D-17). POST /products/{id}/images/generatemakes an AI image (studio, lifestyle, cleaned-up background or custom) and adds it to the product. Uses the business's monthly AI image allowance (GET /ai/image-allowance).POST /products/{id}/describeandPOST /products/describe-photo(multipart upload) draft a name, description, tags, category and alt text from a photo. Nothing is saved; apply what you keep withPATCH /products/{id}.
Webhooks
Owners and admins add endpoints in Developers → Webhooks, or an app adds
its own over the API with a manage:webhooks key (POST /api/v1/webhooks;
list event types with GET /api/v1/webhooks/events, check recent deliveries
with GET /api/v1/webhooks/{id}/deliveries). Events:
product.created|updated|deleted, inventory.updated|low_stock|out_of_stock,
order.created|updated|fulfilled|cancelled|returned|paid|ready_for_pickup,
customer.created|updated, contact.created, contact.consent_changed,
supplier.created|updated, store_page.published|unpublished (an online
store page changed; data has id, kind, slug, path, title) and more (the
dashboard lists them all).
Each delivery is a POST with body
{ "id", "type", "created_at", "organization_id", "data" } and headers
SupplySynk-Event-Id, SupplySynk-Event-Type and
SupplySynk-Signature: t=<unix seconds>,v1=<hex> where v1 is
HMAC-SHA256(secret, "<t>.<raw body>"). Verify it with a constant-time
compare, reject old timestamps (e.g. > 5 minutes), and deduplicate on
SupplySynk-Event-Id: delivery is at least once. Answer 2xx quickly; failures
are retried with backoff for about two days, and an endpoint that keeps failing
is disabled.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && given.length === expected.length && timingSafeEqual(given, expected);
}
MCP
POST /api/mcp/<business-slug> (or the shared POST /api/mcp) exposes the
same operations as tools (products, stock, orders, purchasing, transfers,
counts, insights, import, Square, master data, plus task-shaped tools).
Agents sign in with OAuth or use an API key; tools/list shows only what the
caller may use. OAuth connections have an access level; changes that need a
person's go-ahead return { status: "pending_approval", approvalId } and wait
in the approval queue (GET /api/v1/approvals; members approve in the app).
GET /api/v1/ai/connection returns the business's MCP address. Full guide:
MCP.md.
Square
A business that sells in person with Square connects it once in Settings →
Square. From then on Square sales arrive as ordinary orders with
channel: "square" (so GET /orders, order.created webhooks and Insights
include them), and the sync log, catalog link, stock push and daily takings
are available over REST and MCP. Guide: SQUARE.md.
Online store checkout (pickup)
The built-in store takes pickup orders once a way to pay is on (D-21; guide: online payments). Money always goes to the shop's own account; SupplySynk never holds it.
- Public endpoints used by the store pages live under the store, not
/api/v1:POST /api/store/<site>/cart(current prices),POST /api/store/<site>/checkout(place an order; sendIdempotency-Key),GET /api/store/<site>/orders/<token>(the shopper's order page) andPOST /api/store/<site>/orders/<token>/reference(UPI UTR).<site>is the business slug or verified custom domain; the business comes only from it, never from the body. They answer 404 for unpublished stores, accept POSTs only from the same origin, are rate limited per IP and per store, and never return costs or stock counts. Other sites get the same through the Storefront API (next section). - Orders from the store have
channel: "online"and are confirmed (stock reserved).paymentStatusispending,to_verify(UPI reference entered) orpaid;fulfillmentStatusbecomesready_for_pickupwhen staff mark it. Filter withGET /orders?channel=online; details (method, UTR, pickup, phone, hold) atGET /orders/{id}/online; staff actionsPOST /orders/{id}/mark-paid,/ready-for-pickup,/picked-up. - Settings:
GET/PUT /api/v1/payments/connections;POST /payments/connections/{provider}/test(a harmless call with the stored keys) andDELETE /payments/connections/{provider}(disconnect). Keys and tokens are sealed (AES-256-GCM) and never returned (storedCredentialslists names only). Each provider has astate:connected,needs_attention,not_connectedornot_set_up(the server lacks its platform keys). - Hosted checkout (Stripe, PayPal, Razorpay, Square online checkout): the
checkout answers
next: { kind: "redirect", url }; the shopper pays on the provider's page and comes back to their order page (?return=1), which checks for the confirmation. Payments are created on the shop's own account: Stripe direct charges withStripe-Account(Connect Standard, OAuth; never an application fee), the shop's own PayPal REST app and Razorpay keys, and the shop's Square connection. Adapters live insrc/lib/payments/behindPaymentProvider(types.ts); no SDKs. - Provider webhooks:
POST /api/payments/webhooks/<provider>/<connectionId>(PayPal: verified with PayPal's verify API and the shop's webhook id; Razorpay:X-Razorpay-Signaturewith the shop's webhook secret), and Stripe's single Connect endpointPOST /api/payments/stripe/webhook(Stripe-Signature, 5-minute tolerance, routed by the event'saccount). Square online payments arrive on the Square webhook. Events are stored once (payment_events, by provider event id): a paid event confirms the order once (order.paid), a closed-unpaid page ends the hold at once, a refund is noted. PayPal is captured when the shopper returns (GET /api/payments/paypal/return,PayPal-Request-Idper checkout) or onCHECKOUT.ORDER.APPROVED, never after the hold ended. A payment that arrives after the hold ended is recorded and flagged on the order for a refund. - Payments that need a person: a payment for a different amount than the
order is recorded as
to_verify(orderpaymentStatus: to_verify, stock stays held) and owners/admins are emailed; staff mark it paid or refund it. A payment after the hold ended is recorded, noted and emailed too. Hosted pages that can be closed (Square payment links, Stripe sessions, Razorpay links) are closed when the hold ends. Public payment endpoints are rate limited per IP and refuse bodies over 512 KB. - Disconnecting with checkouts still paying answers 409
open_checkouts;?force=truecancels those orders first. Only what verifies late webhooks is kept (the Stripe account id, the Razorpay webhook secret), so late payments are recorded and flagged rather than lost. - Refunds:
POST /orders/{id}/refundrefunds the provider payment in full on the shop's account (owners and admins signed in to SupplySynk only; not API keys or agents). Refunds made in the provider's dashboard are noted by webhook. - Holds end unpaid orders (cancelled, stock released) after 48 h (pay at
pickup), 30 min (UPI) or 35 min (hosted checkout: the provider's page closes
at 31). Expiry runs lazily and daily; for timely expiry on
Vercel Hobby, call
GET /api/cron/checkoutswithAuthorization: Bearer $CRON_SECRETevery few minutes from an external scheduler.
Storefront API (publishable keys)
For a website (a bzlabs.io site, a theme, a static page) that shows the
shop's products and takes pickup orders. Step-by-step with a copy-paste
example: Storefront API guide. Spec:
GET /api/storefront/v1/openapi.json. Decision: D-24.
- Key:
pk_live_…, created by an owner or admin in Developers → Storefront keys (orPOST /api/v1/storefront/keys,write:settings; MCPcreate_storefront_key). Shown once, stored hashed, revocable (DELETE /api/v1/storefront/keys/{id}), with its last use recorded. SendAuthorization: Bearer pk_live_…. A publishable key is refused by/api/v1and MCP; a secret key is refused by the Storefront API. - Allowed websites (CORS): each key lists origins such as
https://papaswillow.com(https, orhttp://localhostwhile building). A browser call from any other origin is 403origin_not_allowed. Calls with noOriginheader (your server) are allowed. - Endpoints:
GET /store(branding, contact, hours with open now, ways to pay with the shop's note, pickup locations),GET /categories,GET /products?category=&q=&sort=&limit=&cursor=,GET /products/{slug},POST /cart,POST /checkout(sendIdempotency-Key),GET /orders/{token}. - Same data and rules as the built-in store: published stores only (404 otherwise), active products, availability as a label, never costs, suppliers, SKUs or stock counts; checkout prices from the database, stock held, shopper and staff emails, rate limited per shopper and per store.
- Rate limits (own buckets): browsers 120 calls a minute per shopper IP
and 3,000 per key; servers 60 a minute per key, so cache on your side
(responses are
Cache-Control: private, max-age=30). 429 withRetry-After.
Marketing (contacts, buy links, post drafts)
/api/v1/marketing/** (tag Marketing in the OpenAPI spec; scopes
read:marketing, read:contacts (personal data; not in the read-only
bundle), write:marketing, manage:marketing): contacts with
per-channel consent (list, add, consent history, record consent with a source
and reason, CSV import with a required consent_source column, export of
opted-in contacts as CSV or vCard, erasure), store sign-ups waiting for
confirmation (email double opt-in; WhatsApp confirmed by a manager), saved segments, short buy links
(/go/<code> on the business address; store pages only, or an https address
added by an admin) with clicks, orders and revenue, post drafts with images
and AI captions, and the store's marketing settings. Only message contacts
whose consent.<channel>.subscribed is true, and include their unsubscribe
link (GET /marketing/contacts/{id} → unsubscribeUrls). Orders placed in
the built-in store are attributed to the link or UTMs of the visit (30 days,
last touch); orders through the Storefront API aren't attributed yet. Shop
owners' guide: guides/marketing.md; background:
product/marketing.md.
Tax (GST and US sales tax)
Guide for shop owners: tax. Decision: D-25. Engine:
src/lib/tax (pure, amounts in minor units, rounding half-up per line
component).
- Settings:
GET/PATCH /api/v1/tax/settings(read:settings/write:settings, owner or admin).regime: Indianone|composition|regular; USnone|sales_tax. Alsogstin(checked),legalName,tradeName,pricesIncludeTax(India only; US prices exclude tax),defaultRate(India: a GST slab; US: the sales tax rate) androunding(none|nearest_1).configuredis false until a regime is saved; until then orders keep the old arithmetic exactly (no tax, no round-off). - Products:
taxRate(null = the business default; India: a GST slab),taxExempt,hsnCode(4/6/8 digits),mrp,unit(pcs,kg,g,L,ml,m,dozen,pair,set). India: asellingPrice(or a variant's) above themrpis 400above_mrp. Clothing and footwear HSN codes take their rate from the price band (5% up to ₹2,500 a piece, 18% above; to verify), overridingtaxRate. - Products, packaged-goods details (India Stage 4, D-29; stored for any
country, shown on India stores):
countryOfOrigin,manufacturerRole(manufacturer|packer|importer, needsmanufacturerName),manufacturerName,manufacturerAddress,netQuantity(up to 3 decimals) withnetQuantityUnit(same codes asunit; one needs the other),bestBefore,legalNote. A variant with a field null uses its family's. - Online store seller details (India):
PUT /storefront/settingswithsellerInfo: { grievanceName, grievanceDesignation, grievanceEmail, grievancePhone, returnsPolicy }(owner or admin; null clears a field). The store footer shows them with the legal name and GSTIN from the tax settings; the Storefront API returns them asselleronGET /store. - Locations:
gstinandstateCode(India): the place of supply for sales made there. Default: the business GSTIN, the state from the address. - Orders:
POST /ordersworks tax out unless ataxAmountis given on any line or the order; then the given amounts are used as before and the tax fields stay null. OptionalplaceOfSupply(state name or GST code; default the selling location's: IGST when it differs) andbuyerGstin. Responses addtaxRegime,taxInclusive,placeOfSupply,roundOff,sellerGstin,buyerGstin,taxBreakup.components[](component, rate, taxableValue, amount) and, per item,hsn,taxRate,taxableValueandtaxes[]. Components:CGST,SGST,UTGST,IGST,SALES_TAX.- Prices include tax (
taxInclusive: true, India):totalAmount = subtotal − discountAmount + shippingAmount + roundOff(the tax is inside); otherwise+ taxAmountas well. - An order-level discount is spread over the lines before tax; a line's
totalAmountis what's paid for it after its share. - India: an item
unitPriceabove the product's MRP is 400above_mrp.
- Prices include tax (
- Purchase orders: without a
taxAmount, GST-registered Indian businesses get input tax from each product's rate on its cost (taxes[]per item; IGST from a supplier in another state, by the supplier's address;taxNotesays when the supplier's state was unknown and the same state was assumed). Viewers don't see it. - Preview:
GET /api/v1/tax/preview?items=[{"productId":"…","quantity":2}](JSON in the query) orPOSTwith{ items, locationId?, placeOfSupply?, discountAmount?, shippingAmount? }(read:products). Lines without a product takeunitPriceandtaxRate. Nothing is saved. - Checkout: the store cart and checkout responses add
tax(inclusive,label,amount,roundOff,total) and the checkoutsubtotal;totalis what the shopper pays. Square orders keep Square's own tax.
GST documents (India)
Decision D-27; guide tax.
POST /orders/{id}/invoice(write:orders): issues the Tax Invoice or Bill of Supply, or returns the one issued (also automatic on full fulfilment).GET /gst/documents?from=&to=&type=&orderId=andGET /gst/documents/{id}(read:orders): number, totals,snapshot(everything printed) andpublicUrl(/bill/<token>, add?format=thermalfor 80 mm).POST /gst/documents/{id}/credit-note(write:orders):{ items?: [{ productId, quantity }], reason }; without items, everything not yet credited. Automatic for returns and cancellations of invoiced orders.POST /orders/{id}/gst-retry(write:orders): issues whatever the order is still owed (invoice, credit notes); orders showgstPendingandgstPendingErrorwhile one can't be issued. Errors:buyer_details_missing,hsn_missing,number_taken,series_full(409).- Files (not JSON;
read:reports):GET /gst/gstr1?period=2026-10|2026-Q3&format=json|csv§ion=,GET /gst/registers/sales?from=&to=,GET /gst/registers/purchases?from=&to=(staff and up),GET /gst/tally?from=&to=|period=&inventory=&part=all|masters|vouchers(Tally Prime import XML; members need the manager role). - Tally ledger names:
GET /gst/tally/settings(read:reports),PATCH(write:settings;nullresets a name). See guides/tally.md.
Not yet
These are needed for some backend uses and are on the roadmap
(product/roadmap.md):
| Missing | Workaround today | Planned |
|---|---|---|
| Connecting Square over the API | One-time connect in the dashboard (the seller must approve on Square's page) | By design |
| Customer sign-in for shoppers | Your app's own accounts; store them as customers via /customers | Phase 3 |