Order Management
Order Ingestion
After a merchant connects one of your locations to Stream, you will be able to send orders to us to forward to their POS system.
To send orders to Stream to forward to their POS system via the New Webhook Event (DSP -> Stream) endpoint.
For merchant_managed_delivery orders, include a delivery_address with state and country sent as ISO codes (e.g. FL and US) — full names such as Florida can cause the POS to reject the order. See the Delivery Address format.
Order Identifiers
For the order.id field, you should use an order ID that is uniquely identifying the order. This should be the ID of the order as it exists in your system. If we see the same order ID re-used across multiple orders, we will not process the subsequent orders.
To ensure successful ingestion of the order, ensure the line item identifiers align with the following.
| Order Field | Menu Field | Menu V2 Field |
|---|---|---|
| Order.items[x].id | Menu[x].categories[x].items[x].variations[x].stream_id | MenuV2[x].categories[x].item_families[x].items[x].stream_id |
| Order.items[x].item_id | Menu[x].categories[x].items[x].stream_id | MenuV2[x].categories[x].item_families[x].stream_id |
| Order.items[x].modifiers[x].id | Menu[x].categories[x].items[x].modifier_groups[x].modifiers[x].stream_id | MenuV2[x].categories[x].item_families[x].modifier_groups[x].modifiers[x].stream_id |
| Order.items[x].modifiers[x].modifier_group_id | Menu[x].categories[x].items[x].modifier_groups[x].stream_id | MenuV2[x].categories[x].item_families[x].modifier_groups[x].stream_id |
Item and Modifier Quantities
A line's quantity is the number of units of that item. A modifier's quantity is how many times that modifier was selected on one unit of its parent, not the total across the line. Stream forwards modifier quantities to the merchant's POS unchanged, and the POS applies the line's quantity to them.
For example, a customer who orders two Popcorn Chickens, each "Not Spicy", sends one line with quantity: 2 and the modifier with quantity: 1:
{
"id": "CHICKEN_VARIATION_STREAM_ID",
"item_id": "CHICKEN_ITEM_STREAM_ID",
"name": "Popcorn Chicken",
"quantity": 2,
"modifiers": [
{
"id": "NOT_SPICY_MODIFIER_STREAM_ID",
"modifier_group_id": "SPICE_LEVEL_GROUP_STREAM_ID",
"name": "Not Spicy",
"quantity": 1
}
]
}
The same rule applies to nested modifiers: their quantity is per unit of the modifier they belong to.
Multiplying the modifier quantity by the line quantity (sending quantity: 2 on "Not Spicy" above) tells the POS that each chicken has "Not Spicy" twice. On a modifier group where each option can be chosen only once, the POS rejects the order and you receive a location.order.fail event with order_validation_error. In that case invalid_item_ids is empty, because no individual item is invalid.
When an order has units with different modifiers (one "Not Spicy" chicken and one "Extra Spicy" chicken), send them as separate lines, each with its own quantity and modifiers.
How many times a modifier can be selected on one unit comes from the menu Stream published to you. See Modifier Group Rules: with selection_type single (or omitted), each option can be chosen at most once per unit.
Portion and Multiplier Modifiers on Orders
On the V2 menu schema, portion and multiplier modifiers ("1st Half", "Extra", "Light") are published under one ID per parent path, with a price that is a delta over the parent and can be negative. On an order, send them back exactly as published:
id: the publishedstream_id, unchanged (mod_half_1__mod_pepperoni, notmod_half_1). Stream resolves it to the POS modifier.modifier_group_id: the published groupstream_id, unchanged (mg_portions__mod_pepperoni).price: the published delta for the ordered item. Use the item'smodifier_overridesrow when there is one, at every nesting level, and the modifier'spricewhen there is not. Send negative values as negative.- Nesting: keep the selection nested under its parent, in the same order as the menu (topping → portion → pre-modifier). Stream prices a portion from the modifier it is nested under, so a portion sent at another level is mispriced.
A Large pizza with Pepperoni on the 1st half, Extra:
{
"id": "item_pizza_large",
"item_id": "fam_pizza",
"name": "Large",
"quantity": 1,
"price": 1200,
"currency": "usd",
"modifiers": [
{
"id": "mod_pepperoni",
"modifier_group_id": "mg_toppings",
"name": "Pepperoni",
"quantity": 1,
"price": 1200,
"currency": "usd",
"modifiers": [
{
"id": "mod_half_1__mod_pepperoni",
"modifier_group_id": "mg_portions__mod_pepperoni",
"name": "1st Half",
"quantity": 1,
"price": -600,
"currency": "usd",
"modifiers": [
{
"id": "mod_extra__mod_half_1__mod_pepperoni",
"modifier_group_id": "mg_premod__mod_half_1__mod_pepperoni",
"name": "Extra",
"quantity": 1,
"price": 600,
"currency": "usd",
"modifiers": []
}
]
}
]
}
]
}
The topping comes to 1200 + (-600) + 600 = 1200, which is what the merchant's POS charges for an extra half of Pepperoni on a Large.
The same selection on a Small uses the Small's rows: 800, -400, 400. A topping with no size rows (Grilled Chicken at 1200 on every size) uses the modifier prices: 1200, -600, 600.
A half-and-half pizza is two toppings, each with its own portion. With quantities per unit of the parent, two such pizzas are one line with quantity: 2:
{
"id": "item_pizza_large",
"item_id": "fam_pizza",
"name": "Large",
"quantity": 2,
"price": 1200,
"currency": "usd",
"modifiers": [
{
"id": "mod_pepperoni",
"modifier_group_id": "mg_toppings",
"name": "Pepperoni",
"quantity": 1,
"price": 1200,
"currency": "usd",
"modifiers": [
{
"id": "mod_half_1__mod_pepperoni",
"modifier_group_id": "mg_portions__mod_pepperoni",
"name": "1st Half",
"quantity": 1,
"price": -600,
"currency": "usd",
"modifiers": []
}
]
},
{
"id": "mod_chicken",
"modifier_group_id": "mg_toppings",
"name": "Grilled Chicken",
"quantity": 1,
"price": 1200,
"currency": "usd",
"modifiers": [
{
"id": "mod_half_2__mod_chicken",
"modifier_group_id": "mg_portions__mod_chicken",
"name": "2nd Half",
"quantity": 1,
"price": -600,
"currency": "usd",
"modifiers": []
}
]
}
]
}
What Goes Wrong
Stream compares each modifier line's price with the price the merchant's POS charges for that selection, and records any difference on the order. Each line reaches the POS at the POS's price. Depending on the merchant's setup, the sum of the differences is added to the POS order as a separate charge.
| What you send | Result |
|---|---|
| Published IDs and the published price for the ordered size | No price difference on any line. |
The base delta on a Large (-500 for "1st Half" where the Large's row says -600) | A difference of +100 on "1st Half". The customer pays 700 for a half that the POS charges 600. |
The absolute portion price (600 for a half) where the delta (-600) was published | The customer is charged for the topping and the half on top of it. A difference of +1200 on "1st Half". |
A shortened or rebuilt ID (mod_half_1, or the ID from under another topping) | Stream accepts it and resolves it to the POS modifier, but the price you looked up can belong to another path, so the line is mispriced. Do not rely on it: the ID format can change without notice. |
After we process an order, there are multiple actions that may take place before the final fulfillment of the order. These actions include:
Accepting Orders
When an order is successfully injected into the merchant's POS system, we will notify you via the New Webhook Event (Stream -> DSP) endpoint with an event type of location.order.accept.
Failing Orders
When we are unable to inject an order into the merchant's POS system, we will notify you via the New Webhook Event (Stream -> DSP) endpoint with an event type of location.order.fail.
The fail event payload includes a failure_reason indicating why the order could not be processed, along with optional failure_details providing additional context.
For a scheduled order that Stream is holding, this validation runs when the order is submitted to the POS, so a fail event can arrive long after you sent us the order.
Failure Reasons:
| Reason | Description |
|---|---|
restaurant_not_accepting_orders | The restaurant is not currently accepting new orders (e.g. the store is paused or offline in POS). |
location_not_found | The location could not be found in Stream. |
pos_missing_items | One or more items in the order do not exist in the POS menu. |
items_unavailable | One or more items in the order are currently marked as unavailable (86'd). |
order_validation_error | The order failed validation (e.g. missing required fields, invalid modifiers, or a modifier quantity that the modifier group does not allow). |
unknown | The failure reason could not be determined. See below. |
When does unknown occur?
An unknown failure reason is returned when Stream cannot map the failure to a specific cause. This typically happens when:
- Order information issues — The order payload contains data that the POS cannot process, but the POS does not return a specific error code that Stream can categorize.
- Retry exhaustion — Stream was unable to successfully inject the order after multiple retry attempts (e.g. due to transient POS connectivity issues or timeouts), and the order ultimately failed without a classifiable error.
If you are seeing a high volume of unknown failures, reach out to the Stream team for assistance in diagnosing the root cause.
Failure Details:
The failure_details object may include:
invalid_item_ids— An array of item IDs that contributed to the failure (e.g. forpos_missing_itemsoritems_unavailable).error_message— A human-readable message describing the error that occurred.
Notifying Order Status Change
When the status of an order changes in a way that affects the customer (e.g. Ready to pickup, Out for delivery), we will notify you via the New Webhook Event (Stream -> DSP) endpoint with an event type of location.order.notify_status_change.
The completed status is only sent if the merchant performs the completion of the order in POS or Stream. If the order status changes to completed from an automated expiration, you will not recieve this event.
Order Discounts
When a promotion reduces what the customer pays, send it in the order's discounts array. Stream forwards each discount to the merchant's POS as a discount line, so the merchant sees what was discounted and the POS can report on it.
Each discount carries:
| Field | Required | Description |
|---|---|---|
id | Yes | A stable identifier for the promotion in your system (e.g. SUMMER10). Forwarded to the POS as the discount's identifier, so reuse the same value every time the same promotion is applied. |
amount | Yes | The amount taken off, in the lowest denomination of the store's currency (cents), as a positive number. |
item_id | No | Makes the discount item-level. Set it to the id of the line in items the discount applies to. Omit it for an order-level discount. |
Send prices before discounts
Send item price, modifier price, and the order subtotal at their full, undiscounted values, and express the promotion only in discounts. The discount is applied to the order when it is inserted into the merchant's POS.
If you subtract the discount from subtotal or from an item's price and also send it in discounts, the merchant's POS takes it off a second time and the order is under-recorded. Send the discount once, in discounts.
Order-Level Discounts
Use an order-level discount for a promotion that applies to the whole basket rather than to particular items — for example "$5 off orders over $25". Omit item_id:
{
"order": {
"id": "1234567890",
"items": [
{
"id": "BURGER_VARIATION_STREAM_ID",
"item_id": "BURGER_ITEM_STREAM_ID",
"name": "Cheeseburger",
"quantity": 1,
"price": 1599,
"currency": "usd",
"modifiers": []
},
{
"id": "FRIES_VARIATION_STREAM_ID",
"item_id": "FRIES_ITEM_STREAM_ID",
"name": "Large Fries",
"quantity": 1,
"price": 1099,
"currency": "usd",
"modifiers": []
}
],
"subtotal": 2698,
"discounts": [
{
"id": "FIVE_OFF_25",
"amount": 500
}
]
}
}
The subtotal is the full 2698. The POS records a $5.00 discount against the order as a whole.
Item-Level Discounts
Use an item-level discount when the promotion is tied to a specific item — for example "$3 off a Cheeseburger" or "free fries". Set item_id to the id of the discounted line:
{
"order": {
"id": "1234567891",
"items": [
{
"id": "BURGER_VARIATION_STREAM_ID",
"item_id": "BURGER_ITEM_STREAM_ID",
"name": "Cheeseburger",
"quantity": 1,
"price": 1599,
"currency": "usd",
"modifiers": []
},
{
"id": "FRIES_VARIATION_STREAM_ID",
"item_id": "FRIES_ITEM_STREAM_ID",
"name": "Large Fries",
"quantity": 1,
"price": 1099,
"currency": "usd",
"modifiers": []
}
],
"subtotal": 2698,
"discounts": [
{
"id": "BURGER_3_OFF",
"amount": 300,
"item_id": "BURGER_VARIATION_STREAM_ID"
}
]
}
}
The POS records the $3.00 against the Cheeseburger line rather than against the order.
A discount's item_id is not the order item's item_id field. It must match the line's id — the Stream ID of the variation (BURGER_VARIATION_STREAM_ID above), not the parent item ID (BURGER_ITEM_STREAM_ID). If the value does not match the id of any line in items, the order is still accepted, but the discount is sent to the POS as an order-level discount and the item attribution is lost.
Discounts Across Multiple Items
Each discount entry can reference only one line. When one promotion covers several lines — for example a "buy one, get one" across two different items, or a combo deal — send one entry per discounted line, each with that line's share of the discount, and reuse the promotion's id so the entries can be grouped back together:
{
"order": {
"id": "1234567892",
"items": [
{
"id": "BURGER_VARIATION_STREAM_ID",
"item_id": "BURGER_ITEM_STREAM_ID",
"name": "Cheeseburger",
"quantity": 1,
"price": 1599,
"currency": "usd",
"modifiers": []
},
{
"id": "SHAKE_VARIATION_STREAM_ID",
"item_id": "SHAKE_ITEM_STREAM_ID",
"name": "Chocolate Shake",
"quantity": 1,
"price": 699,
"currency": "usd",
"modifiers": []
}
],
"subtotal": 2298,
"discounts": [
{
"id": "COMBO_DEAL",
"amount": 300,
"item_id": "BURGER_VARIATION_STREAM_ID"
},
{
"id": "COMBO_DEAL",
"amount": 200,
"item_id": "SHAKE_VARIATION_STREAM_ID"
}
]
}
}
Together the entries take $5.00 off (300 + 200 = 500), with each line carrying its own share.
Do not send the whole multi-item promotion as one entry against a single line. The order total comes out right, but the merchant's reports show one item heavily discounted and the other at full price.
Why Send Item-Level Discounts
An order-level discount is enough to get the order's total right. Attributing a discount to the item it applies to gives the merchant more:
- Accurate item sales. POS product-mix and item sales reports net discounts against the items they were applied to. With only order-level discounts, every discounted item is reported at full price and the discount sits in a single unallocated line, overstating sales of promoted items.
- Promotion performance. The merchant can see which items a promotion moved and what it cost per item, rather than only a total cost per promotion.
- Cleaner partial refunds and voids. When a single item is voided or refunded at the POS, an item-level discount stays with that item, so the merchant refunds what the customer actually paid for it. An order-level discount leaves the merchant to work out the item's share.
- Reconciliation with your payouts. Matching discounts at the item level makes it easier for the merchant to reconcile the POS against your statements.
If your platform knows which item a promotion applied to, send it as an item-level discount. Use an order-level discount only for promotions that genuinely apply to the whole order.
Payment State
Orders are submitted to the merchant's POS as paid unless you tell us otherwise. Payment state is carried by the unpaid field on the order — it is not inferred from fulfillment_type or from any other field, so an order sent with fulfillment_type: "drive_thru" but without unpaid: true reaches the POS as an already-paid order.
To have the merchant collect payment at fulfillment, send unpaid: true along with cash_amount_due. Unpaid submission is limited to specific fulfillment types.
Read more about unpaid orders.
Adjusting Orders
If an order changes on your platform after you have sent it to Stream (e.g. the customer modifies a scheduled order), you can notify us via the New Webhook Event (DSP -> Stream) endpoint with an event type of order_adjustment.
The adjustment event carries the full order with the adjusted details, in the same shape as a new_order event. The adjusted order replaces the previously sent order details in their entirety:
- The
order.idmust match theidof the original order. - Re-state every field of the order, not just the changed ones — any field omitted from the adjusted order is treated as removed.
- For scheduled orders, re-state the scheduling fields (
status: scheduled,is_future_order,estimated_ready_at_ms). If they are omitted, the adjusted order is treated as an ASAP order and is prepared immediately.
{
"type": "order_adjustment",
"location_id": "YOUR_LOCATION_ID",
"order": {
"id": "1234567890",
"status": "scheduled",
"is_future_order": true,
"estimated_ready_at_ms": 1781311215716,
"items": [...],
"subtotal": 3498,
"tax": 180
}
}
How the adjustment is applied depends on whether the order has reached the merchant's POS system:
- Before POS submission (e.g. a scheduled order that Stream is still holding): the held order is updated with the adjusted details, and the adjusted order is what gets submitted to the POS. The scheduled preparation time is retained unless the adjustment changes
estimated_ready_at_ms. - After POS submission: the adjustment is not applied to the POS order automatically. The merchant is notified of the adjustment so they can update the order on their POS.
In both cases the merchant is notified that the order was adjusted.
Canceling Orders
Although we do our best to make sure that orders that can not be fulfilled do not get accepted. Sometimes orders are accepted and then canceled by the merchant. When this happens, we will notify you via the New Webhook Event (Stream -> DSP) endpoint with an event type of location.order.cancel.
The cancel order event payload may include:
- cancel_source (optional): Who initiated the cancelation. Accepted values:
customer,restaurant,driver,dsp. - cancel_reason (optional): The reason for the cancelation. Can be one of the normalized values (e.g.
customer_requested,item_out_of_stock,store_closed,driver_unavailable,payment_failed,other) for cross-DSP reporting. - cancel_reason_details (optional): Free-text detail supplied by the merchant, primarily when
cancel_reasonisother. Use it to surface the merchant's own wording to customers or drivers.
See full cancel order payload.
If a customer decides to cancel an order from your platform, you can notify us via the New Webhook Event (DSP -> Stream) endpoint.