Build a POS sales analytics integration
How to build a back-office or analytics platform on top of Flipdish POS sales data, using the Sales Management API, Menu Management API, Org Management API, and v3 webhooks
Supersedes the legacy POS integration guideThis guide replaces Build a POS Integration, which targeted the v1.0 API surface (numeric menu IDs,
order.createdwebhooks,Orderobjects). New integrations should follow this guide and build against the v3.0 public APIs and v3 webhooks described below.
Introduction
This guide is for engineers building a back-office analytics integration (forecasting, scheduling, inventory, margin, or reporting tools) that consumes a Flipdish client's POS sales data. You build the integration once, list it on the Flipdish App Store, and any shared client can install it from their own Flipdish portal. The install is what grants your integration access to the client's org: you can't install on the client's behalf, but once they have, your platform pulls their POS sales (and the reference data needed to make sense of them) into your canonical model without further action from them.
This guide is scoped to the POS sales channel type. The same Sales Management API surface covers other channel types (kiosk, web, mobile app, third-party marketplaces); when you're ready to broaden, the same patterns apply: just widen your sales-channel filter.
You will work entirely against Flipdish's v3.0 public surface area:
- REST API at
https://api.flipdish.co- Sales Management API: list and read sales by sales channel and date range
- Menu Management API: resolve menu items, categories, allergens, per-channel pricing and tax
- Org Management API: enumerate orgs, brands, properties, sales channels, opening hours, VAT info
- Webhook Service API: manage webhook subscriptions
- Webhooks (v3): see Subscribe to Flipdish Events (v3) for the delivery contract and signature verification
AudienceEngineers at an analytics, forecasting, scheduling, or back-office platform building a generic integration that any shared Flipdish client can self-serve. Familiarity with HTTP, JSON, OAuth-style API keys, and HMAC signatures is assumed.
BetaThe Sales Management API, Menu Management API, Operations Updates API, and v3 webhooks are still in beta. You can develop end-to-end against your own free test org today (see Prerequisites below): sign up, push synthetic sales, read them back, drive your analytics pipeline. When you're ready to go live against real shared clients' orgs, email [email protected].
Concept model
These terms are Flipdish's canonical vocabulary; use them as-is in your own docs and code so support, sales, and engineering all share one model.
| Concept | Format | Notes |
|---|---|---|
| Client | The business that has signed up to Flipdish (a chain, franchise, or independent operator). Your integration is offered to clients. A client has one org. | |
| Org | org123 | The data-model envelope that a client's details are linked to. Every public API path is scoped to an orgId. Contains brands and properties. |
| Brand | br123 | The distinct identity (name, logo) associated with one or more sales channels. An org can have multiple brands. |
| Property | p123 | A physical location where one or more brands of the same org operate. |
| Sales channel | sc123 | The digital, non-physical version of a property. One brand, one sales channel type, and (typically) its own menu. A property has many sales channels. |
| Sales channel type | enum: this guide focuses on POS. Other values exist (KIOSK, FlipdishWebApp, FlipdishMobileApp, UberEats, JustEats, Deliveroo, ExternalApp, etc.) and follow the same patterns | Where the order is captured from the customer. For this guide, treat any sales channel whose salesChannelType is POS as in scope; ignore the rest. |
| Customer | The end user of a client (the person placing an order). Also called consumer or end user. Don't confuse with a Flipdish client. | |
| Menu | UUID, with numeric revisionId | Owned by an org, published to one or more sales channels. Every published revision is an immutable snapshot. |
| Sale | saleId (e.g. AF34FV) plus optional externalId | A single transaction. Lives under a sales channel; carries the menuId + menuRevisionId it was placed against. |
saleId is a Flipdish-globally-unique short identifier (Crockford base32). externalId is the partner reference round-tripped on every sale event; useful when your platform also originates the sale (see "Pushing sales back" below).
Webhook event types are versioned with a .v1 suffix (e.g. sales.created.v1).
Prerequisites
Everything below is self-serve against a free test org. You need credentials, an orgId you control, at least one property + sales channel + published menu so you have somewhere to push sample sales, and then an end-to-end push-and-read loop to drive your analytics pipeline.
1. Create a free Flipdish account and test org
Sign up at https://portal.flipdish.com/signup. The portal provisions a brand-new org for the account at signup; this is the org you'll use as your sandbox.
Note the orgId. The fastest way is to look at the Portal URL after sign-up; it includes the org ID (org123), or hit GET /orgManagement/orgs once you have an access token (step 3 below).
2. Add a property, sales channel, and published menu
You need a property, a sales channel on it, and a published menu before you can push a sale (every sale carries salesChannelId, menuId, and menuRevisionId).
The simplest path is the Portal UI:
- Add a property: Portal sidebar → Properties → Add property. Set a name and address.
- Add a POS sales channel under that property. Note the sales channel's
id(sc...); you'll need it for every sales call. ConfirmsalesChannelTypeisPOS. - Create a menu under Menus → New menu, add a category and at least one item, then Publish the menu to the sales channel from step 2. Note the
menuIdandrevisionIdshown on the menu page.
If you'd rather provision via API, the same flow exists end-to-end in the Org Management API (POST /orgManagement/orgs/{orgId}/properties, POST .../properties/{propertyId}/salesChannels) and Menu Management API (POST /menuManagement/orgs/{orgId}/menus, then POST .../revisions/{revisionId}/publish). See Use v3 menus as an integrator for the full menu lifecycle.
3. Create an OAuth app and request an access token
In the Portal, click your avatar → Developer tools → OAuth apps → Add new. Give the app a name and create it. Reveal and copy the client ID and secret key. Treat the secret key like a password; server-side only.
Request an access token using the client-credentials grant:
curl -X POST https://api.flipdish.co/identity/connect/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=client_credentials \
-d client_id=<your_client_id> \
-d client_secret=<your_secret_key> \
-d scope=apiThe response includes an access_token. Send it as Authorization: Bearer <access_token> on every v3.0 public API call (Sales Management, Menu Management, Org Management, Operations Updates); the middleware transparently exchanges the bearer token for the internal FD-Authorization JWT, so you never need to perform that exchange yourself. See Getting Started for screenshots of the Developer Tools flow.
4. Push a couple of synthetic sales
POST /salesManagement/orgs/{orgId}/sales is how you seed test data into your sandbox so the analytics read path has something to return. The minimal POS payload references the salesChannelId, menuId, and menuRevisionId you noted in step 2:
curl -X POST https://api.flipdish.co/salesManagement/orgs/<your_orgId>/sales \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-H 'x-idempotency-key: 11111111-1111-1111-1111-111111111111' \
-H 'User-Agent: <your_app_name>/0.1' \
-d '{
"salesChannelId": "<your_salesChannelId>",
"source": "POS",
"requestedFulfillmentTime": "2026-01-01T13:00:00Z",
"desiredAsap": true,
"dispatchType": "TakeAway",
"menuId": "<your_menuId>",
"menuRevisionId": "<your_revisionId>",
"items": [ { "menuItemId": "<your_menuItemId>", "quantity": 1, "unitPrice": 10.00, "modifierItems": [] } ],
"charges": [],
"discounts": [],
"payments": [ { "type": "Sale", "paymentMethod": "Cash", "amount": 10.00, "paidAt": "2026-01-01T13:00:00Z" } ]
}'Send a few of these with different dispatchType (POS sales are typically DineIn or TakeAway), different items, and a fresh x-idempotency-key per call. Vary requestedFulfillmentTime across a couple of business dates so you have something to slice in step 5.
The x-idempotency-key header is required on POST .../sales; supply a fresh GUID per logical create. Replays of the same key return the original response, so the safe pattern is one GUID per intended sale.
5. Read them back through the analytics endpoints
Confirm the same sales come back via the read path you'll build your analytics on:
# List headers for a business date
curl "https://api.flipdish.co/salesManagement/orgs/<your_orgId>/salesChannels/<your_salesChannelId>/sales?fromDate=2026-01-01&toDate=2026-01-01" \
-H 'Authorization: Bearer <access_token>' \
-H 'User-Agent: <your_app_name>/0.1'
# Fetch detail for one sale
curl https://api.flipdish.co/salesManagement/orgs/<your_orgId>/salesChannels/<your_salesChannelId>/sales/<saleId> \
-H 'Authorization: Bearer <access_token>' \
-H 'User-Agent: <your_app_name>/0.1'That round-trip is the loop your integration runs in production: push (or, in production, real customers push via the various sales channel types) → list → fetch detail → transform → store.
6. Cancel a sale
Exercise the cancel path against one of the sales you just pushed. cancellationReason is required; cancellationNotes is free-text context that round-trips on sales.cancelled.v1.
curl -X POST https://api.flipdish.co/salesManagement/orgs/<your_orgId>/sales/<sale_id>/cancel \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-H 'User-Agent: <your_app_name>/0.1' \
-d '{
"salesChannelId": "<your_sales_channel_id>",
"cancellationReason": "Cancelled by customer",
"cancellationNotes": "Customer requested cancellation via phone"
}'A successful cancel fires sales.cancelled.v1 to any subscribed webhook endpoints (see Listening for sales).
7. Read the rest of the contract
- Subscribe to Flipdish Events (v3): webhook delivery contract, headers, and signature verification.
- User Agents: pick one and send it on every request.
- Getting Started: OAuth app screenshots and the original credential walkthrough.
Going live
Self-serve covers everything you need to build, test, and dogfood the integration on your own org. When you're ready for production access against real shared clients' orgs (including provisioning your app on those orgs and getting webhooks delivered from real sales), email [email protected].
Authentication
All v3.0 endpoints (Sales Management, Menu Management, Org Management, Operations Updates) authenticate with the OAuth bearer token from the client-credentials flow, sent in the Authorization header:
curl https://api.flipdish.co/salesManagement/orgs/org123/salesChannels/sc123/sales?fromDate=2025-10-01&toDate=2025-10-01 \
-H 'Authorization: Bearer <access_token>' \
-H 'User-Agent: <your_app_name>/<version>'The middleware transparently exchanges the bearer token for the internal FD-Authorization JWT, so callers never perform that exchange themselves.
The Webhook Service API uses the same Authorization: Bearer <access_token> header.
Rules of thumb:
- Always use HTTPS; plain HTTP is rejected.
- Treat the API key like a password: server-side only, never in mobile apps or browsers.
- Rotate keys via Flipdish if they leak.
Architecture overview
A typical analytics integration is built around three loops, all driven by v3.0 endpoints and webhooks:
| Loop | Trigger | Purpose |
|---|---|---|
| Onboarding | A client connects their Flipdish org to your platform | Discover the org's brands, properties, and sales channels |
| Reference sync | menu.published.v1 webhook + scheduled refresh | Keep a local copy of the catalog and org structure |
| Sale ingest | sales.created.v1 / sales.cancelled.v1 / status webhooks + daily backfill | Pull each sale, normalise into your canonical schema |
End-to-end:
A client connects their Flipdish org to your platform
│
▼
Your platform GETs the org tree via Org Management API:
orgs → brands → properties → sales channels → VAT info
│
▼
Your platform GETs current published menus via Menu Management API
and caches catalog reference data (items, categories, allergens, pricing).
│
▼
Flipdish ──menu.published.v1 webhook──▶ your platform
(cache invalidate / re-fetch)
│
▼
A customer places an order on a POS sales channel
│
▼
Flipdish ──sales.created.v1 / sales.cancelled.v1
/ sales.delivery.status.updated.v1
/ sales.pos.status.updated.v1──▶ your platform
│
▼
Your platform GETs the sale detail via Sales Management API,
joins with cached menu + org reference data,
transforms into your canonical sales/line-item model.
│
▼
Reconciler: every 24h pull GET sales by date range per channel
to catch anything missed.Three patterns are non-negotiable, regardless of your stack:
- Acknowledge webhooks fast. Verify the signature, persist the event, return
2xx. Do real work asynchronously. Flipdish times out after 10 seconds and retries. - Be idempotent on
saleId(and oneventIdif present in headers). Webhooks are at-least-once; the daily reconciler will replay the same sales. - Keep a reconciler. Webhooks are best-effort delivery. A daily date-range pull catches anything you missed.
Onboarding a client
Once a client has approved your platform on their org and you have a key scoped to it, walk the org tree using Org Management:
# 1. List orgs your key can access
curl https://api.flipdish.co/orgManagement/orgs \
-H 'FD-Authorization: <your_api_key>' \
-H 'User-Agent: <your_app_name>/<version>'
# 2. List properties (locations) under the org
curl https://api.flipdish.co/orgManagement/orgs/org123/properties \
-H 'FD-Authorization: <your_api_key>'
# 3. List the sales channels on each property
curl https://api.flipdish.co/orgManagement/orgs/org123/properties/p789/salesChannels \
-H 'FD-Authorization: <your_api_key>'
# 4. (Optional) VAT info per property, useful for tax reconciliation
curl https://api.flipdish.co/orgManagement/orgs/org123/properties/p789/vat \
-H 'FD-Authorization: <your_api_key>'GET /salesChannels returns an array of channels with their id, salesChannelType, brand association, and dispatch types. For this guide, filter to salesChannelType == "POS" and ignore the others. GET /vat returns isVatApplicable, isVatInclusive, vatPercentage, and the operativeVatBands configured for the property; this is the catalog tax context, not per-line transactional tax.
Persist the mapping for every linked client (POS channels only):
{
"orgId": "org123",
"brandId": "br123",
"propertyId": "p789",
"posSalesChannels": [
{ "id": "sc123", "type": "POS" }
],
"vat": { "isVatApplicable": true, "isVatInclusive": true, "vatPercentage": 13.5 }
}A property typically has at most one POS sales channel, but the API returns an array; handle the multi-POS case defensively. Refresh on a schedule (daily is typical) so newly added POS channels and re-VAT'd properties are picked up.
Syncing the menu (reference data)
Sales reference items by menuItemId (UUID). To turn that into "Margherita Pizza in the Pizzas category, contains gluten and dairy" you need the menu. Flipdish menus are the source of truth: pull on demand and listen for changes; never store and mutate independently.
Listen for menu.published.v1
menu.published.v1When a menu is (re)published to a sales channel, Flipdish fires menu.published.v1. The payload includes the full published snapshot so most integrations don't need a follow-up GET:
{
"orgId": "org123",
"brandId": "br456",
"propertyId": "p789",
"salesChannelId": "sc123",
"salesChannelType": "POS",
"menuId": "123e4567-e89b-12d3-a456-426614174000",
"menuRevisionId": 1,
"menuPublishId": "223e4567-e89b-12d3-a456-426614174000",
"menu": { /* categories, items, modifiers, pricingProfiles, charges, … */ }
}Every item in the payload exposes id (the menuItemId referenced from sales), productId, externalId, caption, description, allergens, alcohol, pricingProfiles[] (per-priceBandId, with collection / delivery / dineIn / takeaway price, taxable price, tax, taxable flag), and availabilityOverrides[]. Modifiers and category groups are present alongside.
Pull on demand
If you need to fetch a menu directly (initial import, recovery, audit):
GET /menuManagement/orgs/{orgId}/menus/headers: list menus (lightweight)GET /menuManagement/orgs/{orgId}/menus/published: every menu that currently has a published revisionGET /menuManagement/orgs/{orgId}/menus/{menuId}/current: the current published revision in fullGET /menuManagement/orgs/{orgId}/menus/{menuId}/revisions/{revisionId}: a specific revision (use this to reconcile a historical sale against the menu it was placed against)
Sales carry menuRevisionId; for an exact reconciliation against the menu the customer actually saw, fetch that revision rather than the current one.
What to map for analytics
| Flipdish concept | Use it for |
|---|---|
category / categoryGroup | Item category, for sales-by-category breakdowns |
item.id (UUID) | The menuItemId you'll see on sales |
item.productId | Stable cross-revision product reference |
item.externalId | Round-trip key if your platform owns the master catalog |
item.allergens / alcohol | Compliance reports, allergen tracking |
item.pricingProfiles[].*Tax | Expected tax per channel per dispatch type |
modifier / modifierItems | Recursively flatten as child line items |
charges[] | Item-level surcharges (bag, deposit) configured on the menu |
Listening for sales
Once reference data is in place, drive the per-sale loop with webhooks.
Sale lifecycle events
| Event | Relevance to POS analytics | Key payload fields |
|---|---|---|
sales.created.v1 | Primary trigger: a new POS sale was recorded. | orgId, propertyId, salesChannelId, salesChannelType, saleId, externalId |
sales.cancelled.v1 | A sale was voided. Mark it cancelled in your store. | …, cancellationReason, cancellationNotes, cancelledBy |
sales.pos.status.updated.v1 | Kitchen / POS state changed (e.g. order prepared). | …, status (SALE_PREPARED_BY_KITCHEN, SALE_PICKUP_TIME_CHANGED + pickupTime, SALE_DELIVERY_TIME_CHANGED + deliveryTime, SALE_CANCELLED + cancellationReason) |
sales.delivery.status.updated.v1 | Rarely fires for POS sales; usually only for delivery channels. Subscribe if you want completeness; otherwise skip. | …, status (SALE_DISPATCHED + dispatchTime, SALE_ON_THE_WAY + dispatchTime, SALE_DELIVERED + deliveryTime) |
Example sales.created.v1:
{
"source": "rms",
"detail-type": "sales.created.v1",
"detail": {
"properties": {
"orgId": "org123",
"propertyId": "p789",
"salesChannelId": "sc123",
"salesChannelType": "POS",
"saleId": "AF34FV",
"externalId": "your-platform-ref-12345"
},
"metadata": { "actor": { "id": "user-123", "email": "[email protected]", "name": "Jane" } }
}
}Filter to POS at your handler. Sale events fire for every sales channel type. Drop the event if detail.properties.salesChannelType != "POS"; you'll skip ~all of them in early test orgs but in production this stops kiosk / web / marketplace traffic from polluting your analytics pipeline.
Subscribe via the Webhook Service API or the Flipdish portal. With the API:
curl -X POST https://api.flipdish.co/webhooks/orgs/org123/subscriptions \
-H 'Authorization: <your_api_key>' \
-H 'Content-Type: application/json' \
-d '{
"callbackUrl": "https://your-platform.example.com/flipdish/webhooks",
"secret": "<a strong random string you generate>",
"eventTypes": [
"sales.created.v1",
"sales.cancelled.v1",
"sales.pos.status.updated.v1",
"sales.delivery.status.updated.v1",
"menu.published.v1",
"menu.validation.failed.v1",
"menu.item.snoozed.v1",
"menu.item.unsnoozed.v1",
"sales_channel.snoozed.v1",
"sales_channel.unsnoozed.v1"
]
}'The secret is the value Flipdish will use to sign deliveries; store it alongside the subscription.
Hydrating each sale
Sale event payloads carry identifiers only; fetch the sale detail to get items, charges, discounts, payments, and customer:
curl https://api.flipdish.co/salesManagement/orgs/org123/salesChannels/sc123/sales/AF34FV \
-H 'Authorization: Bearer <access_token>' \
-H 'User-Agent: <your_app_name>/<version>'Response (same shape as the create-sale request body):
{
"data": {
"salesChannelId": "sc123",
"source": "POS",
"requestedFulfillmentTime": "2025-10-28T14:30:00Z",
"desiredAsap": true,
"dispatchType": "DineIn",
"externalId": "your-platform-ref-12345",
"displayId": "2A003",
"menuId": "123e4567-e89b-12d3-a456-426614174000",
"menuRevisionId": "23",
"customer": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "John Smith",
"phoneNumber": "353871234567"
},
"dineIn": {
"tableId": "12",
"guests": 2
},
"items": [
{
"menuItemId": "12a85f64-5717-4562-b3fc-2c963f66afb5",
"quantity": 1,
"unitPrice": 13.99,
"modifierItems": [
{ "menuItemId": "10a85f64-5717-4562-b3fc-2c963f66afb3", "quantity": 1, "unitPrice": 1.50 }
]
}
],
"charges": [ { "type": "Service", "amount": 1.55 } ],
"discounts": [ { "type": "Spot", "amount": 1.00 } ],
"payments": [
{
"type": "Sale",
"paymentMethod": "Credit",
"amount": 16.04,
"paidAt": "2025-10-28T14:30:00Z",
"description1": "Visa ****4921"
}
],
"metadata": "{\"posTerminal\":\"till-2\"}"
}
}Field reference (request and detail-read share this shape):
| Field | Type | Notes |
|---|---|---|
salesChannelId | string | Channel the sale belongs to. Required. |
source | string | Free-text origin (e.g. POS). |
dispatchType | enum | DineIn | TakeAway | Collection | Delivery. Required. POS sales are typically DineIn or TakeAway. Note TakeAway (capital A). |
requestedFulfillmentTime | ISO UTC | When the restaurant hands over responsibility. |
desiredAsap | boolean | True when the customer wants it ASAP. |
menuId / menuRevisionId | UUID/str | The exact menu revision this sale was placed against. Use to fetch matching reference data. |
externalId | string | Partner reference. Round-tripped on every webhook. |
displayId | string | Customer- and staff-facing short ID. |
customer | object | Optional. phoneNumber is normalised (no leading +). |
delivery / dineIn | object | Conditional on dispatchType. |
items[] | array | Recursive: each item has menuItemId, quantity, unitPrice, optional notes, optional modifierItems[] (same schema). |
charges[] | array | type ∈ Delivery, Service, Tip, Other; optional itemId if line-level. |
discounts[] | array | type ∈ Voucher, Loyalty, Spot, Other; code required for Voucher/Loyalty. Discounts apply to the sale, not the line. |
payments[] | array | type ∈ Sale, Refund; paymentMethod ∈ Cash, Credit, Online, PhonePayment, ExternalPayment. |
metadata | string | Free-text JSON for additional partner-specific fields. |
Backfill and reconciliation
A daily reconciliation pass closes the gap when a webhook is dropped, your endpoint is down, or a sale is amended out-of-band.
List sales by date range
For each tracked sales channel, list the sales in a business-date window:
curl "https://api.flipdish.co/salesManagement/orgs/org123/salesChannels/sc123/sales?fromDate=2025-10-28&toDate=2025-10-28" \
-H 'Authorization: Bearer <access_token>' \
-H 'User-Agent: <your_app_name>/<version>'Both fromDate and toDate are required and are inclusive YYYY-MM-DD business dates. Response:
{
"data": [
{
"saleId": "AF34FV",
"dateTime": "2025-10-28T14:30:00Z",
"salesChannelId": "sc123",
"source": "POS",
"requestedFulfillmentTime": "2025-10-28T14:30:00Z",
"desiredAsap": true,
"dispatchType": "DineIn",
"externalId": "your-platform-ref-12345",
"displayId": "2A003",
"menuId": "123e4567-e89b-12d3-a456-426614174000",
"menuRevisionId": "23",
"totalCharges": 1.55,
"totalDiscounts": 1.00,
"totalRefunds": 0,
"totalSaleAmount": 16.04,
"totalPaidAmount": 16.04,
"metadata": "{\"posTerminal\":\"till-2\"}"
}
]
}The list endpoint returns headers only (PublicSaleHeader). For the items / charges / discounts / payments breakdown, follow up with the detail endpoint per saleId.
Reconciler pattern
For each linked (orgId, salesChannelId):
headers = GET /salesManagement/.../salesChannels/{scId}/sales?fromDate=YDay&toDate=Today
for each header:
if (saleId, lastSeenStatus) is unchanged in your store: skip
detail = GET /salesManagement/.../salesChannels/{scId}/sales/{saleId}
upsert into your canonical store, idempotent on saleIdThe header totals (totalCharges, totalDiscounts, totalRefunds, totalSaleAmount, totalPaidAmount) are useful as a quick checksum: confirm your line-level roll-up agrees with Flipdish's headers before you trust the row.
Read scope (current beta limitation)While the Sales Management API is in beta, the
GETendpoints (/saleslist and/sales/{saleId}detail) only return sales that were originally ingested through this same public API, i.e. sales pushed in by a partner usingPOST /salesManagement/orgs/{orgId}/sales. Real customer-facing POS sales recorded directly on the Flipdish POS aren't yet exposed on these reads.This means the self-serve loop in Prerequisites (push synthetic POS sales → read them back) lets you build and validate the full analytics pipeline today. To pull real POS sales from a shared client's live till, contact [email protected]; we are actively expanding the read surface.
Mapping to a canonical schema
A common pattern is to flatten Flipdish's sale into a header + line-items shape. The mapping below is what most analytics platforms end up with.
Header
| Canonical field | Flipdish source |
|---|---|
| Order ID | saleId |
| External ref | externalId |
| Property | Webhook payload propertyId (also resolvable from salesChannelId via Org API) |
| Sales channel + type | salesChannelId, webhook salesChannelType |
| Order channel (DineIn / Takeaway / Delivery) | dispatchType; note Collection ≅ Takeaway |
| Created timestamp (UTC) | Header dateTime |
| Requested fulfilment (UTC) | requestedFulfillmentTime |
| Guest count | dineIn.guests (DineIn only) |
| Payment method(s) | payments[].paymentMethod |
| Discount(s) (sale-level) | discounts[]; each with type and optional code |
| Total charges / discounts / refunds / sale / paid | List endpoint total* fields |
Line items
Flatten items[] recursively. Each line is one row:
| Canonical field | Flipdish source |
|---|---|
| Catalog item ID | items[].menuItemId |
| Catalog item name | Resolve via Menu Management (item caption) |
| Catalog item category | Resolve via Menu Management (category containing the item) |
| Quantity | items[].quantity |
| Unit price | items[].unitPrice |
| Parent line | The recursion: a modifierItems[] entry's parent is the enclosing item |
| Notes | items[].notes |
Charges, discounts and payments
- Charges map to surcharge lines:
type∈Delivery,Service,Tip,Other. Charges with anitemIdare line-level (e.g. an item-attached deposit); charges without are sale-level. - Discounts are sale-level. If your model tracks line-level discount, apportion proportionally across
items[]and document the rule. - Payments are recorded twice when there's a refund: a positive
Saleline and a negative-directionRefundline, both with their ownpaymentMethod.
What is not yet on the sale today
The current beta sale schema does not yet include some fields that downstream analytics often expects. Plan for these:
- Currency: not yet on the sale header or detail. The
menu.published.v1payload carries a top-levelcurrencyfield (ISO 4217, e.g."EUR") on each publish event. Cache it keyed bymenuId(orsalesChannelId) and join when you process a sale. The field is nullable, so fall back to the property's country defaults if it's missing until currency lands on the sale itself. - Per-line tax: only aggregated totals are exposed on the sale today (
totalSaleAmountincludes tax). Per-linetaxableAmount/taxAmountare not on the sale yet. For an expected-tax view, joinitems[].menuItemIdagainst the matching menu revision'spricingProfiles[].{collection,delivery,dineIn,takeaway}Taxkeyed bypriceBandId. - Per-line refunds and voids: refunds are sale-level entries in
payments[]; voids cancel the whole sale viaPOST /salesManagement/orgs/{orgId}/sales/{saleId}/cancel. There is no line-level void model yet. - Deferred revenue / gift cards: not modelled on the sale today.
For each gap, the workaround today is to derive from menu reference data (tax) or omit and document the assumption in your reconciliation reports (currency, deferred revenue). If a missing field is blocking your integration, raise it with us: email [email protected], or post in the Flipdish partner Slack if you have access.
Verifying webhooks
Every v3 webhook delivery includes:
X-Flipdish-Event-Type: sales.created.v1
X-Flipdish-Event-Version: v1
X-Flipdish-Timestamp: 2025-10-28T14:34:56.789Z
X-Flipdish-Signature: t=2025-10-28T14:34:56.789Z,sha256=<hex>Verification:
- Read the raw request body; do not parse JSON before verifying.
- Parse
X-Flipdish-Signatureto extract thet=timestamp and thesha256=hex digest. - Compute
HMAC-SHA256( secret, "${timestamp}.${rawBody}" ). - Compare the result to the parsed
sha256=value using a constant-time comparison. - Reject with
401on mismatch.
import crypto from 'node:crypto';
export function parseSignatureHeader(
header: string,
): { timestamp: string; signature: string } | null {
const parts = header.split(',');
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const sig = parts.find((p) => p.startsWith('sha256='))?.slice(7);
if (!t || !sig) return null;
return { timestamp: t, signature: sig };
}
export function verifySignature(
rawBody: string,
timestamp: string,
signature: string,
secret: string,
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Wire them together at your handler:
const parsed = parseSignatureHeader(req.headers['x-flipdish-signature'] as string);
if (!parsed || !verifySignature(rawBody, parsed.timestamp, parsed.signature, secret)) {
return res.status(401).send();
}The secret is the one you supplied when creating the subscription via the Webhook Service API.
Idempotency, retries and failure handling
- At-least-once delivery. Flipdish retries failed webhook deliveries. Dedupe on the
eventIdfromX-Flipdish-Idempotency-Key(when present), or onsaleId+ event type. - 10-second budget. If your handler can't finish in 10 seconds, persist the event and process it asynchronously.
- Auto-disable. Subscriptions that fail persistently are disabled. Re-create them via the Webhook Service API, or update via
POST /webhooks/orgs/{orgId}/subscriptions/{subId}withenabled: true. - 3xx is not success. Only
2xxcounts. - Reconciler safety net. The daily date-range pull is the final line of defence; anything missed by webhooks should be picked up by it.
Testing
The Webhook Service API does not expose a synthetic trigger today, so test against real events on your sandbox org. The push → read loop you ran in Prerequisites step 4 already exercises the full pipeline; for the cancel and delivery-status events, drive them yourself:
- Push a sale via
POST /salesManagement/orgs/{orgId}/salesagainst your sandbox. - Confirm
sales.created.v1arrives at your endpoint, signature verifies, and thesaleIdis queued for hydration. - Confirm your
GET .../sales/{saleId}call returns the same payload you posted. - Run
POST /salesManagement/orgs/{orgId}/sales/{saleId}/canceland confirmsales.cancelled.v1is delivered. - Run
POST /salesManagement/orgs/{orgId}/sales/{saleId}/deliveryStatuswithSALE_DISPATCHEDthenSALE_DELIVEREDand confirm the matchingsales.delivery.status.updated.v1deliveries.
If you need a synthetic-event trigger to validate your signature verifier in isolation, raise it via [email protected] or the Flipdish partner Slack.
Distribute as a Flipdish App
The Prerequisites flow gives you one OAuth app authenticating against one test org: your sandbox. To run against shared clients' orgs, you publish your integration as a Flipdish App Store app: a listing in the Flipdish portal that any client can find, click Install, and grant your OAuth app scoped access to their org.
There are two layers, both created from the Developer tools area of the portal:
| Layer | What it is | Who sees it |
|---|---|---|
| OAuth app | The credential pair (client_id + secret) you've already been using. | Just you. Created in step 3 of Prerequisites. |
| App Store app | The published listing (name, logo, description, configuration form). | Every Flipdish client, in the portal App Store, once it's approved. |
When a client installs your App Store app, Flipdish associates their org with your OAuth app at the teammate permission level the app declares; that's how the same client_id / secret you used in your sandbox can subsequently call /salesManagement/... and the rest of the v3 surface against any installed client's orgId.
Who installs the appThe client initiates the install from their own Flipdish portal. The install action is the consent that grants your OAuth app access to their org, so there is no path for the integrator to install on a client's behalf or pre-provision installs from their own admin tooling. What you can do: deep-link to your App Store listing from your marketing or onboarding pages, walk a client through the install live during a demo, and use the Flipdish hosted configuration type to collect any client-side fields (API keys, store IDs) at install time so the client has nothing left to do after clicking Install.
Decisions you make on the App Store app
When you create the App Store app (Developer tools → App Store apps → Create new), three choices matter most for a POS analytics integration:
| Field | What to pick for POS analytics | Why |
|---|---|---|
| Permission level | A teammate level that grants reads on Sales Management, Menu Management, Org Management, plus the Webhook Service permissions you need. Match it to the smallest set your integration actually uses. | Each client install grants exactly this scope on their org. Over-asking blocks installs. |
| Store selector type | Multiple: one install covers every POS sales channel under the org. (Fall back to Single if your product is licensed per location.) | A POS analytics platform typically pulls every POS channel under an org. None is for app-wide integrations that don't target specific channels. |
| Configuration type | External link if you have your own onboarding page. Flipdish hosted if you want Flipdish to collect a small set of fields (e.g. an API key) at install time and forward them to you. | Both flows end the same way: your backend gets a webhook telling it the install has happened. |
The other listing fields (name, description, Developed by, logo, categories, supported countries) only affect discovery in the App Store; fill them in to match your product. See How to create a Flipdish App Store app for the full step-by-step.
Handle the install lifecycle
When a client installs (or reconfigures, or uninstalls) your App Store app, Flipdish sends a webhook to your callback URL. Subscribe to the install / configuration / uninstall events on your OAuth app so your backend can:
- On install: record the new
(orgId, brandId)mapping, walk the org tree (Prerequisites → step in Onboarding a client), filter to POS sales channels, and create the per-org webhook subscriptions forsales.created.v1etc. via the Webhook Service API. - On configuration update: pull the latest configuration from the webhook payload (Flipdish-hosted config) or from your own portal (external link) and re-sync.
- On uninstall: stop hydrating sales for that org, optionally archive the data, and clean up webhook subscriptions.
The exact event names are configured on the OAuth app's webhook subscription; see How to create a Flipdish App Store app for which events to tick at the OAuth-app level.
Submit for approval
App Store apps are gated by a manual review:
- Build and test your app end-to-end against your sandbox org.
- Click Submit for approval on the listing page.
- The Flipdish integrations team will schedule a validation call to walk through the install, configuration, and uninstall flows, and confirm your data handling is correct.
- Once approved, the listing goes live in every Flipdish client's portal in the supported countries you selected.
For changes to fields that aren't editable post-approval, or for app-performance metrics, email [email protected].
Going live
- While building: iterate against your own sandbox org (Prerequisites). The same OAuth credentials work in both modes.
- When ready: submit the App Store app for approval. Until it's approved, only your sandbox can use it.
- Post-approval: each install is a real client granting access to their real org. From your code's perspective the orgId just shows up in the install webhook and you onboard it the same way you onboarded your sandbox.
OAuth 2.0 authorization-code flowThe current install flow grants access via the App Store install (client-credentials at the API call). The App Store install model is the supported path today; if a user-mediated OAuth 2.0 authorization-code flow with per-scope consent matters for your integration, request it via [email protected] or the Flipdish partner Slack.
Pre-launch checklist
- OAuth access token stored securely server-side; sent as
Authorization: Bearer <access_token>on every v3.0 API call -
User-Agentset on every request - Per-client mapping persisted:
orgId,brandId,propertyId, and the property's POSsalesChannelId - Org tree refresh job runs daily (catches new POS channels and re-VAT'd properties)
- Sales channel filter applied: only
salesChannelType == "POS"is processed - Subscribed to
menu.published.v1(andmenu.validation.failed.v1); local catalog cache invalidated on receipt - Subscribed to
sales.created.v1,sales.cancelled.v1,sales.pos.status.updated.v1(andsales.delivery.status.updated.v1if you want completeness for delivery POS sales) - Each sale event hydrated via
GET /salesManagement/orgs/{orgId}/salesChannels/{salesChannelId}/sales/{saleId} - Sale detail joined against the correct
menuRevisionIdfor catalog metadata - Daily reconciler runs
GET /salesManagement/.../salesChannels/{salesChannelId}/sales?fromDate=&toDate=per POS channel and reconciles against your store - Webhook endpoint verifies HMAC over the raw body using a timing-safe compare
- Webhook endpoint returns
2xxwithin 10 seconds; heavy work is queued - Handlers idempotent on
saleId/eventId - End-to-end push → cancel → delivery-status flow exercised against your sandbox
- Re-enable / monitor logic in place if a subscription is auto-disabled
- Documented assumptions for any field not yet on the sale (currency, per-line tax, refund/void model, deferred revenue)
- App Store app listing created with name, logo, description, supported countries, and category
- App Store app permission level matches the smallest set of teammate permissions your integration actually uses
- Store selector type chosen (
Multiplefor org-wide POS analytics;Singlefor per-location licensing) - Configuration type chosen (
External linkorFlipdish hosted) and the post-install handoff tested - OAuth app subscribed to install / configuration / uninstall events; backend handler creates per-org webhook subscriptions on install
- End-to-end install → onboard → first sale tested against your sandbox before submitting for approval
- App Store app submitted for approval and validation call scheduled with [email protected]
Further reading
- Subscribe to Flipdish Events (v3): full webhook delivery contract and signature verification
- Use v3 menus as an integrator: Menu Management API end-to-end
- What is the Flipdish App Store?: why publishing as an App Store app matters
- How to create a Flipdish App Store app: full listing, configuration, and approval flow
- Build a marketplace integration: companion guide for partners ingesting sales from non-POS channels (web, kiosk, marketplaces); shares the same Sales Management API surface
- API Reference (v3.0): every v3 endpoint and model
- User Agents
