Sale webhook events

The sale.* event family delivers full sale payloads inline for every sale recorded on Flipdish, regardless of channel.

Overview

The sale.* event family fires for every sale recorded on Flipdish — regardless of channel (POS, kiosk, web, marketplace, or API). The full sale object is embedded in the event payload, so no follow-up GET is required.

EventDescription
sale.created.v1A sale was first recorded. Carries the full PublicSale object inline, including the current status field.
sale.accepted.v1A pending sale has been accepted by the receiver. Carries the full PublicSale object inline without a status field — acceptance is implied by the event name.
sale.updated.v1A sale's content changed materially (items, charges, discounts modified) or a lifecycle transition occurred. Carries the updated PublicSale object inline, including the current status field.
sale.status.updated.v1A thin lifecycle status change event (kitchen prepared, dispatched, cancelled, delivered, accepted, rejected, etc.). Does not carry a full sale object.
⚠️

Important — accepted-first lifecycle change

sale.created.v1 no longer implies the sale has been accepted.

With the accepted-first lifecycle (enabled per org via a feature flag), a sale is created in a pending state before the restaurant accepts it. sale.created.v1 fires as soon as the sale is created — whether it is pending or already accepted.

What to use instead:

  • To know when a sale is accepted, listen for sale.accepted.v1 (simplest — dedicated event, no status field needed), or sale.status.updated.v1 where status = SALE_ACCEPTED, or check the status field on sale.updated.v1.
  • To know when a sale is rejected before acceptance, listen for sale.status.updated.v1 where status = SALE_REJECTED.
  • For orgs not yet on the accepted-first lifecycle, sale.created.v1 continues to represent an already-accepted sale (status = SALE_ACCEPTED).

Sale lifecycle

The sale goes through the following lifecycle states, visible in the status field on PublicSale and in sale.status.updated.v1 events:

StatusMeaningTerminal?
SALE_CREATEDPending — created but not yet accepted by the restaurantNo
SALE_ACCEPTEDAccepted by the restaurant; fulfilment in progressNo
SALE_PREPARED_BY_KITCHENKitchen has finished preparing the saleNo
SALE_DISPATCHEDDispatched / handed to the courierNo
SALE_ON_THE_WAYEn route to the customerNo
SALE_DELIVEREDDelivered to the customerYes
SALE_REJECTEDRejected before acceptance — sale will not be fulfilledYes
SALE_CANCELLEDCancelled after acceptanceYes

Simplified lifecycle flow:

SALE_CREATED ──accept──► SALE_ACCEPTED ──► SALE_PREPARED_BY_KITCHEN ──► ...
             │
             └──reject──► SALE_REJECTED  (terminal — never fulfilled)

SALE_ACCEPTED ──► (any fulfilment state) ──cancel──► SALE_CANCELLED  (terminal)
📘

SALE_REJECTED vs SALE_CANCELLED

  • SALE_REJECTED — the sale was never accepted. The restaurant rejected the pending order before acceptance. These sales must not be counted as accepted revenue or cancelled fulfilments.
  • SALE_CANCELLED — the sale was accepted but was subsequently cancelled.

To distinguish them, listen to sale.status.updated.v1: the status field will be SALE_REJECTED or SALE_CANCELLED accordingly.
Note: the status field in PublicSale (on sale.created.v1 / sale.updated.v1) shows SALE_CANCELLED for both; sale.status.updated.v1 is the authoritative source for the rejection signal.


Events

sale.created.v1

Fires when a sale is first recorded. With the accepted-first lifecycle enabled, this fires for pending sales (before acceptance). Check the data.sale.status field to determine whether the sale is pending (SALE_CREATED) or already accepted (SALE_ACCEPTED).

The data.sale field carries the complete PublicSale object at the moment of creation: items, modifiers, charges, discounts, payments, customer, delivery/dine-in details, menu reference, and the current status. See the Sale status table and Sale object schema for details.

{
  "eventType": "sale.created.v1",
  "eventId": "01HXYZ...",
  "eventCreatedAt": "2025-10-28T14:00:00.000Z",
  "data": {
    "orgId": "org123",
    "brandId": "br123",
    "propertyId": "p123",
    "salesChannelId": "sc123",
    "salesChannelType": "UberEats",
    "saleId": "3N356",
    "sale": {
      "status": "SALE_CREATED",
      "dispatchType": "Delivery",
      "externalId": "sale-external-123",
      "displayId": "2A003",
      "customer": { "..." },
      "items": [ "..." ],
      "charges": [ "..." ]
    }
  }
}
📘

sale.created.v1 with status: "SALE_CREATED" — what this means

The sale has been recorded and is awaiting acceptance. It is not yet accepted and must not be counted as confirmed revenue. Listen for sale.status.updated.v1 with status: "SALE_ACCEPTED" (or check sale.updated.v1 when the same sale's status becomes SALE_ACCEPTED) to know when fulfilment begins.

For orgs without the accepted-first lifecycle, sale.created.v1 will carry status: "SALE_ACCEPTED" — behaviour is unchanged.

sale.accepted.v1

Fires when a pending sale has been accepted by the receiver. This is the simplest event to listen to for acceptance — the sale status is implied by the event name (SALE_ACCEPTED) and is not included as a field in the payload. No status field to check.

The embedded sale carries the full sale payload at the moment of acceptance: items, charges, customer details, delivery/dine-in info, and monetary totals.

{
  "eventType": "sale.accepted.v1",
  "eventCreatedAt": "2025-10-28T14:05:00.000Z",
  "data": {
    "orgId": "org123",
    "saleId": "3N356",
    "sale": {
      "dispatchType": "Delivery",
      "externalId": "sale-external-123",
      "customer": { "..." },
      "items": [ "..." ],
      "charges": [ "..." ]
    }
  }
}
📘

Choosing between sale.accepted.v1, sale.updated.v1, and sale.status.updated.v1 for acceptance

All three events fire on acceptance. Which to use:

EventWhen to use
sale.accepted.v1You only need to act on acceptance and want the full sale payload. Simplest choice — no status field to check.
sale.updated.v1You already subscribe for content changes and want to handle acceptance in the same handler. Check data.sale.status === 'SALE_ACCEPTED'.
sale.status.updated.v1You only need identifiers and the transition time, not the full sale payload.

sale.updated.v1

Fires when a sale's content changes materially — items added or removed, charges or discounts modified — or when a lifecycle transition occurs (e.g. accepted, rejected). The embedded sale reflects the post-update state, including the current status field.

Check data.sale.status to understand the current lifecycle position when handling this event.

{
  "eventType": "sale.updated.v1",
  "eventCreatedAt": "2025-10-28T14:05:00.000Z",
  "data": {
    "orgId": "org123",
    "saleId": "3N356",
    "sale": {
      "status": "SALE_ACCEPTED",
      "..."
    }
  }
}

sale.status.updated.v1

A unified thin status event covering all lifecycle transitions: pending creation, acceptance, rejection, kitchen preparation, dispatch, delivery, and cancellation. Does not carry a full sale object — only the lifecycle identifiers and the relevant timestamp field.

All sale.status.updated.v1 events carry saleStatusUpdatedTime — a single ISO 8601 UTC timestamp for when the transition occurred. SALE_DISPATCHED and SALE_ON_THE_WAY also carry a deprecated dispatchTime; SALE_DELIVERED carries a deprecated deliveryTime — use saleStatusUpdatedTime in preference.

📘

SALE_CREATED is not a sale.status.updated.v1 status

SALE_CREATED (pending sale) is signalled by sale.created.v1 alone — no sale.status.updated.v1 is emitted at creation. sale.status.updated.v1 fires only for transitions: acceptance, rejection, kitchen preparation, dispatch, delivery, and cancellation.

Status values:

StatusMeaning
SALE_ACCEPTEDSale accepted by restaurant
SALE_REJECTEDNever-accepted sale rejected (terminal)
SALE_PREPARED_BY_KITCHENKitchen finished preparing
SALE_DISPATCHEDDispatched to courier
SALE_ON_THE_WAYEn route
SALE_DELIVEREDDelivered
SALE_CANCELLEDCancelled after acceptance

SALE_REJECTED additionally carries: rejectionReason (required — one of the SaleRejectionReason values), rejectionNotes (optional free-text), rejectedBy (optional identifier of the actor).

📘

externalId on status events

sale.status.updated.v1 does not carry a sale sub-object. externalId is present as a top-level field when known. If you need additional sale fields for a given saleId, correlate via the org-wide GET endpoint.


Webhook body structure

All sale.* webhook deliveries share the same outer envelope:

FieldNotes
eventTypeEvent name, e.g. sale.created.v1.
eventIdUnique delivery ID for this event instance.
eventCreatedAtISO 8601 UTC timestamp of when the event was produced.
dataEvent-specific payload (see table below).

data fields

The following fields appear inside data for all sale.* events:

FieldTypeNotes
orgIdstringOrg the sale belongs to.
brandIdstringBrand the sale belongs to.
propertyIdstringProperty (physical location) the sale belongs to.
salesChannelIdstringID of the sales channel (e.g. sc123).
salesChannelTypeenumPlatform that originated the sale: UberEats, FlipdishKIOSK, POS, etc.
saleIdstringFlipdish sale identifier (Crockford base32, e.g. 3N356).
saleobjectFull PublicSale payload, including status. Present on sale.created.v1 and sale.updated.v1 only.

Integration guide

Listening for sale acceptance

Old approach (pre-accepted-first lifecycle): Subscribe to sale.created.v1 — all created sales were already accepted.

New approach — simplest: Subscribe to sale.accepted.v1. No status field to check — acceptance is implied by the event name:

function handleSaleAccepted(event) {
  if (event.eventType === 'sale.accepted.v1') {
    // Sale has been accepted — begin fulfilment processing
    // data.sale carries the full sale payload, without a status field
    processSaleAccepted(event.data);
  }
}

Alternatively, if you already subscribe to multiple events:

function handleSaleEvent(event) {
  const { eventType, data } = event;

  if (eventType === 'sale.accepted.v1') {
    // Dedicated acceptance event — no status field check needed
    processSaleAccepted(data);
    return;
  }

  if (eventType === 'sale.created.v1') {
    // May be pending (SALE_CREATED) or already accepted (legacy path)
    storePendingSale(data);
  }

  if (eventType === 'sale.status.updated.v1') {
    const { status, saleId } = data;
    if (status === 'SALE_REJECTED') handleRejection({ saleId, ...data });
    if (status === 'SALE_CANCELLED') handleCancellation({ saleId, ...data });
  }
}

Identifying pending vs accepted sales

Use the status field in data.sale on sale.created.v1 and sale.updated.v1:

status valueMeaning
SALE_CREATEDPending — not yet accepted. Do not count as revenue.
SALE_ACCEPTEDAccepted — fulfilment may begin.

Reporting and analytics

  • Do not count SALE_CREATED or SALE_REJECTED sales as accepted revenue. These represent pending/failed orders.
  • Filter your analytics pipeline to only process sales with status === 'SALE_ACCEPTED' (or a post-acceptance status).
  • Use sale.status.updated.v1(SALE_REJECTED) to identify and separately track never-accepted rejections.

Subscribing

Subscribe to sale.* events via the Webhook Service API or the Flipdish portal. See Subscribe to Flipdish Events (v3) for the full delivery contract, headers, and signature verification.


Related