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 guide

This guide replaces Build a POS Integration, which targeted the v1.0 API surface (numeric menu IDs, order.created webhooks, Order objects). 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:

📘

Audience

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

🚧

Beta

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

ConceptFormatNotes
ClientThe business that has signed up to Flipdish (a chain, franchise, or independent operator). Your integration is offered to clients. A client has one org.
Orgorg123The data-model envelope that a client's details are linked to. Every public API path is scoped to an orgId. Contains brands and properties.
Brandbr123The distinct identity (name, logo) associated with one or more sales channels. An org can have multiple brands.
Propertyp123A physical location where one or more brands of the same org operate.
Sales channelsc123The 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 typeenum: this guide focuses on POS. Other values exist (KIOSK, FlipdishWebApp, FlipdishMobileApp, UberEats, JustEats, Deliveroo, ExternalApp, etc.) and follow the same patternsWhere the order is captured from the customer. For this guide, treat any sales channel whose salesChannelType is POS as in scope; ignore the rest.
CustomerThe end user of a client (the person placing an order). Also called consumer or end user. Don't confuse with a Flipdish client.
MenuUUID, with numeric revisionIdOwned by an org, published to one or more sales channels. Every published revision is an immutable snapshot.
SalesaleId (e.g. AF34FV) plus optional externalIdA 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:

  1. Add a property: Portal sidebar → PropertiesAdd property. Set a name and address.
  2. Add a POS sales channel under that property. Note the sales channel's id (sc...); you'll need it for every sales call. Confirm salesChannelType is POS.
  3. Create a menu under MenusNew menu, add a category and at least one item, then Publish the menu to the sales channel from step 2. Note the menuId and revisionId shown 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 toolsOAuth appsAdd 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=api

The 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

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:

LoopTriggerPurpose
OnboardingA client connects their Flipdish org to your platformDiscover the org's brands, properties, and sales channels
Reference syncmenu.published.v1 webhook + scheduled refreshKeep a local copy of the catalog and org structure
Sale ingestsales.created.v1 / sales.cancelled.v1 / status webhooks + daily backfillPull 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 on eventId if 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

When 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 revision
  • GET /menuManagement/orgs/{orgId}/menus/{menuId}/current: the current published revision in full
  • GET /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 conceptUse it for
category / categoryGroupItem category, for sales-by-category breakdowns
item.id (UUID)The menuItemId you'll see on sales
item.productIdStable cross-revision product reference
item.externalIdRound-trip key if your platform owns the master catalog
item.allergens / alcoholCompliance reports, allergen tracking
item.pricingProfiles[].*TaxExpected tax per channel per dispatch type
modifier / modifierItemsRecursively 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

EventRelevance to POS analyticsKey payload fields
sales.created.v1Primary trigger: a new POS sale was recorded.orgId, propertyId, salesChannelId, salesChannelType, saleId, externalId
sales.cancelled.v1A sale was voided. Mark it cancelled in your store.…, cancellationReason, cancellationNotes, cancelledBy
sales.pos.status.updated.v1Kitchen / 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.v1Rarely 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):

FieldTypeNotes
salesChannelIdstringChannel the sale belongs to. Required.
sourcestringFree-text origin (e.g. POS).
dispatchTypeenumDineIn | TakeAway | Collection | Delivery. Required. POS sales are typically DineIn or TakeAway. Note TakeAway (capital A).
requestedFulfillmentTimeISO UTCWhen the restaurant hands over responsibility.
desiredAsapbooleanTrue when the customer wants it ASAP.
menuId / menuRevisionIdUUID/strThe exact menu revision this sale was placed against. Use to fetch matching reference data.
externalIdstringPartner reference. Round-tripped on every webhook.
displayIdstringCustomer- and staff-facing short ID.
customerobjectOptional. phoneNumber is normalised (no leading +).
delivery / dineInobjectConditional on dispatchType.
items[]arrayRecursive: each item has menuItemId, quantity, unitPrice, optional notes, optional modifierItems[] (same schema).
charges[]arraytypeDelivery, Service, Tip, Other; optional itemId if line-level.
discounts[]arraytypeVoucher, Loyalty, Spot, Other; code required for Voucher/Loyalty. Discounts apply to the sale, not the line.
payments[]arraytypeSale, Refund; paymentMethodCash, Credit, Online, PhonePayment, ExternalPayment.
metadatastringFree-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 saleId

The 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 GET endpoints (/sales list and /sales/{saleId} detail) only return sales that were originally ingested through this same public API, i.e. sales pushed in by a partner using POST /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 fieldFlipdish source
Order IDsaleId
External refexternalId
PropertyWebhook payload propertyId (also resolvable from salesChannelId via Org API)
Sales channel + typesalesChannelId, webhook salesChannelType
Order channel (DineIn / Takeaway / Delivery)dispatchType; note Collection ≅ Takeaway
Created timestamp (UTC)Header dateTime
Requested fulfilment (UTC)requestedFulfillmentTime
Guest countdineIn.guests (DineIn only)
Payment method(s)payments[].paymentMethod
Discount(s) (sale-level)discounts[]; each with type and optional code
Total charges / discounts / refunds / sale / paidList endpoint total* fields

Line items

Flatten items[] recursively. Each line is one row:

Canonical fieldFlipdish source
Catalog item IDitems[].menuItemId
Catalog item nameResolve via Menu Management (item caption)
Catalog item categoryResolve via Menu Management (category containing the item)
Quantityitems[].quantity
Unit priceitems[].unitPrice
Parent lineThe recursion: a modifierItems[] entry's parent is the enclosing item
Notesitems[].notes

Charges, discounts and payments

  • Charges map to surcharge lines: typeDelivery, Service, Tip, Other. Charges with an itemId are 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 Sale line and a negative-direction Refund line, both with their own paymentMethod.

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.v1 payload carries a top-level currency field (ISO 4217, e.g. "EUR") on each publish event. Cache it keyed by menuId (or salesChannelId) 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 (totalSaleAmount includes tax). Per-line taxableAmount / taxAmount are not on the sale yet. For an expected-tax view, join items[].menuItemId against the matching menu revision's pricingProfiles[].{collection,delivery,dineIn,takeaway}Tax keyed by priceBandId.
  • Per-line refunds and voids: refunds are sale-level entries in payments[]; voids cancel the whole sale via POST /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:

  1. Read the raw request body; do not parse JSON before verifying.
  2. Parse X-Flipdish-Signature to extract the t= timestamp and the sha256= hex digest.
  3. Compute HMAC-SHA256( secret, "${timestamp}.${rawBody}" ).
  4. Compare the result to the parsed sha256= value using a constant-time comparison.
  5. Reject with 401 on 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 eventId from X-Flipdish-Idempotency-Key (when present), or on saleId + 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} with enabled: true.
  • 3xx is not success. Only 2xx counts.
  • 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:

  1. Push a sale via POST /salesManagement/orgs/{orgId}/sales against your sandbox.
  2. Confirm sales.created.v1 arrives at your endpoint, signature verifies, and the saleId is queued for hydration.
  3. Confirm your GET .../sales/{saleId} call returns the same payload you posted.
  4. Run POST /salesManagement/orgs/{orgId}/sales/{saleId}/cancel and confirm sales.cancelled.v1 is delivered.
  5. Run POST /salesManagement/orgs/{orgId}/sales/{saleId}/deliveryStatus with SALE_DISPATCHED then SALE_DELIVERED and confirm the matching sales.delivery.status.updated.v1 deliveries.

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:

LayerWhat it isWho sees it
OAuth appThe credential pair (client_id + secret) you've already been using.Just you. Created in step 3 of Prerequisites.
App Store appThe 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 app

The 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 appsCreate new), three choices matter most for a POS analytics integration:

FieldWhat to pick for POS analyticsWhy
Permission levelA 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 typeMultiple: 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 typeExternal 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:

  1. 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 for sales.created.v1 etc. via the Webhook Service API.
  2. On configuration update: pull the latest configuration from the webhook payload (Flipdish-hosted config) or from your own portal (external link) and re-sync.
  3. 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:

  1. Build and test your app end-to-end against your sandbox org.
  2. Click Submit for approval on the listing page.
  3. 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.
  4. 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 flow

The 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-Agent set on every request
  • Per-client mapping persisted: orgId, brandId, propertyId, and the property's POS salesChannelId
  • 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 (and menu.validation.failed.v1); local catalog cache invalidated on receipt
  • Subscribed to sales.created.v1, sales.cancelled.v1, sales.pos.status.updated.v1 (and sales.delivery.status.updated.v1 if 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 menuRevisionId for 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 2xx within 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 (Multiple for org-wide POS analytics; Single for per-location licensing)
  • Configuration type chosen (External link or Flipdish 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