Sale webhook events
sale.created.v1, sale.accepted.v1, and sale.updated.v1 carry the full sale inline. sale.status.updated.v1 reports lifecycle transitions and does not include the sale object.
Overview
The sale.* event family fires for every sale recorded on Flipdish — regardless of channel (POS, kiosk, web, marketplace, or API). sale.created.v1, sale.accepted.v1, and sale.updated.v1 embed the full sale object, so no follow-up GET is required for those events. sale.status.updated.v1 reports lifecycle transitions and does not include the sale object.
| Event | Description |
|---|---|
sale.created.v1 | A sale was first recorded. Carries the full PublicSale object inline, including the current status field. |
sale.accepted.v1 | A 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.v1 | A 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.v1 | A thin lifecycle status change event (accepted, rejected, kitchen prepared, dispatched, delivered, cancelled, fully refunded, or partially refunded). Does not carry a full sale object. |
Important — accepted-first lifecycle change
sale.created.v1no 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.v1fires 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), orsale.status.updated.v1wherestatus=SALE_ACCEPTED, or check thestatusfield onsale.updated.v1.- To know when a sale is rejected before acceptance, listen for
sale.status.updated.v1wherestatus=SALE_REJECTED.- For orgs not yet on the accepted-first lifecycle,
sale.created.v1continues 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. SALE_PARTIALLY_REFUNDED is reported on sale.status.updated.v1 and does not replace the fulfilment status on the sale. The Terminal? column applies to that status's own track. A sale in SALE_DELIVERED can still receive SALE_PARTIALLY_REFUNDED or SALE_FULLY_REFUNDED. Consumers must keep processing refund events after delivery.
| Status | Meaning | Terminal? |
|---|---|---|
SALE_CREATED | Pending — created but not yet accepted by the restaurant | No |
SALE_ACCEPTED | Accepted by the restaurant; fulfilment in progress | No |
SALE_PREPARED_BY_KITCHEN | Kitchen has finished preparing the sale | No |
SALE_DISPATCHED | Dispatched / handed to the courier | No |
SALE_ON_THE_WAY | En route to the customer | No |
SALE_DELIVERED | Delivered to the customer | Yes |
SALE_REJECTED | Rejected before acceptance — sale will not be fulfilled | Yes |
SALE_CANCELLED | Cancelled after acceptance | Yes |
SALE_FULLY_REFUNDED | The cumulative refunded amount covers the full refundable sale amount. Distinct from SALE_CANCELLED. | Yes |
SALE_PARTIALLY_REFUNDED | A refund that does not cover the full refundable amount. The sale keeps its current fulfilment status. Can fire more than once. | No |
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)
Refunds
SALE_FULLY_REFUNDEDis terminal. It fires when the cumulative refunded amount covers the refundable sale amount, including when a later instalment completes the total. Do not report it asSALE_CANCELLED.
SALE_PARTIALLY_REFUNDEDis not terminal and does not replace the sale's fulfilment status. A delivered sale stays delivered. Consumers tracking fulfilment must not overwrite that state with this status. The event fires once per partial refund, so the same sale can report it more than once beforeSALE_FULLY_REFUNDED.Refund amounts and the payment breakdown stay on the accompanying
sale.updated.v1(paymentsandtotalRefundson the embedded sale), not on the thinsale.status.updated.v1event.
SALE_REFUNDEDis deprecated and is not produced. UseSALE_FULLY_REFUNDED.
SALE_REJECTEDvsSALE_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: thestatusfield will beSALE_REJECTEDorSALE_CANCELLEDaccordingly.
Note: thestatusfield inPublicSale(onsale.created.v1/sale.updated.v1) showsSALE_CANCELLEDfor both;sale.status.updated.v1is the authoritative source for the rejection signal.
Events
sale.created.v1
sale.created.v1Fires 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.v1withstatus: "SALE_CREATED"— what this meansThe 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.v1withstatus: "SALE_ACCEPTED"(or checksale.updated.v1when the same sale'sstatusbecomesSALE_ACCEPTED) to know when fulfilment begins.For orgs without the accepted-first lifecycle,
sale.created.v1will carrystatus: "SALE_ACCEPTED"— behaviour is unchanged.
sale.accepted.v1
sale.accepted.v1Fires 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 betweensale.accepted.v1,sale.updated.v1, andsale.status.updated.v1for acceptanceAll three events fire on acceptance. Which to use:
Event When 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
sale.updated.v1Fires 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
sale.status.updated.v1A unified thin status event covering lifecycle transitions after creation: acceptance, rejection, kitchen preparation, dispatch, delivery, cancellation, and refunds. Does not carry a full sale object — only the lifecycle identifiers and the relevant timestamp field. Creation is sale.created.v1 only.
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_CREATEDis not asale.status.updated.v1status
SALE_CREATED(pending sale) is signalled bysale.created.v1alone — nosale.status.updated.v1is emitted at creation.sale.status.updated.v1fires only for later transitions: acceptance, rejection, kitchen preparation, dispatch, delivery, cancellation, and refunds.
Status values:
| Status | Meaning |
|---|---|
SALE_ACCEPTED | Sale accepted by restaurant |
SALE_REJECTED | Never-accepted sale rejected (terminal) |
SALE_PREPARED_BY_KITCHEN | Kitchen finished preparing |
SALE_DISPATCHED | Dispatched to courier |
SALE_ON_THE_WAY | En route |
SALE_DELIVERED | Delivered |
SALE_CANCELLED | Cancelled after acceptance |
SALE_FULLY_REFUNDED | Cumulative refund covers the refundable amount (terminal) |
SALE_PARTIALLY_REFUNDED | Partial refund; fulfilment status unchanged (not terminal; may repeat) |
Refund amounts and the payment breakdown stay on the accompanying sale.updated.v1 (payments and totalRefunds), not on this event. SALE_REFUNDED is deprecated and is not produced.
SALE_REJECTED additionally carries: rejectionReason (required — one of the SaleRejectionReason values), rejectionNotes (optional free-text), rejectedBy (optional identifier of the actor).
externalIdon status events
sale.status.updated.v1does not carry asalesub-object.externalIdis present as a top-level field when known. If you need additional sale fields for a givensaleId, correlate via the org-wide GET endpoint.
Webhook body structure
All sale.* webhook deliveries share the same outer envelope:
| Field | Notes |
|---|---|
eventType | Event name, e.g. sale.created.v1. |
eventId | Unique delivery ID for this event instance. |
eventCreatedAt | ISO 8601 UTC timestamp of when the event was produced. |
data | Event-specific payload (see table below). |
data fields
data fieldsThe following fields appear inside data for all sale.* events:
| Field | Type | Notes |
|---|---|---|
orgId | string | Org the sale belongs to. |
brandId | string | Brand the sale belongs to. |
propertyId | string | Property (physical location) the sale belongs to. |
salesChannelId | string | ID of the sales channel (e.g. sc123). |
salesChannelType | enum | Platform that originated the sale: UberEats, FlipdishKIOSK, POS, etc. |
saleId | string | Flipdish sale identifier (Crockford base32, e.g. 3N356). |
sale | object | Full 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 value | Meaning |
|---|---|
SALE_CREATED | Pending — not yet accepted. Do not count as revenue. |
SALE_ACCEPTED | Accepted — fulfilment may begin. |
Reporting and analytics
- Do not count
SALE_CREATEDorSALE_REJECTEDsales 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
- Subscribe to Flipdish Events (v3) — delivery contract and signature verification
- Sales Management API — full
PublicSaleschema, creating/cancelling sales, org-wide queries
Updated 4 days ago

