Skip to navigation

Build an ordering flow

From menu to paid order, tracking and webhooks

This guide walks through a complete takeaway checkout on your own site. The examples call the API from your server with the API key. All endpoints in this guide also accept calls from the browser on a verified domain; see Authentication.

Shared setup (TypeScript)
const API = "https://www.lettbestilt.no/api/v1";
const SLUG = "pizzaroma";
const headers = {
Authorization: `Bearer ${process.env.LETTBESTILT_API_KEY}`,
"Content-Type": "application/json",
};
1

Load the menu

Fetch GET /menu on your server and cache it briefly. It is cached at the edge for about 30 seconds anyway, and it supports If-None-Match.

curl "https://www.lettbestilt.no/api/v1/menu?slug=pizzaroma" \
-H "Authorization: Bearer $LETTBESTILT_API_KEY"

Before you show an order button, check:

  • restaurant.capabilities: offer only pickup, delivery and payment methods that are true.
  • restaurant.openingHours: the kitchen’s hours, in Oslo time. Outside them, the server rejects orders (unless the customer picks a later time with requestedPickupAt).
  • GET /prep-time: the live “ready in about X minutes”, including rush.

categories only contains menus that are active right now, and products that are hidden or sold out are left out. A product that is not in the menu cannot be ordered.

2

Build the cart

Each cart line is a product, an optional variant, optional add-ons and a quantity. Calculate the price you display like this; the server does the same calculation when the order is created.

  • Unit price: the chosen variant’s price if the product has variants, otherwise basePrice.
  • Add-ons: the groups that apply to a product are product.addonGroups, the category’s addonGroups and the shared restaurant.addonGroups. The customer must pick between minSelect and maxSelect in each group. An add-on costs tierPrices[variant.addonTierKey] if that exists, otherwise price.
  • Line total: (unit price + add-ons) × quantity.
  • Combo deals (isCombo: true): the price is basePrice plus add-ons. The customer fills each of comboSlots with minQty–maxQty products from allowedProductIds; send them as comboSelections.
  • Pickup or delivery only: hide or disable products whose allowedFulfillment does not match the customer’s choice. null means both.
Line price (TypeScript)
function linePrice(product, variant, addons, quantity) {
const unit = variant ? variant.price : product.basePrice;
const tier = variant?.addonTierKey;
const addonsTotal = addons.reduce(
(sum, addon) => sum + (tier && addon.tierPrices[tier] !== undefined ? addon.tierPrices[tier] : addon.price),
0,
);
return (unit + addonsTotal) * quantity;
}
3

Find the delivery address

For delivery, let the customer search for the address and send what you get back with the order:

curl "https://www.lettbestilt.no/api/v1/address-search?slug=pizzaroma&q=Storgata%201" \
-H "Authorization: Bearer $LETTBESTILT_API_KEY"

Send lat/lng as deliveryLat/deliveryLng and the postal code as deliveryPostalCode. Radius-based delivery zones need the coordinates; postal-code zones need the postal code. The zone decides the delivery fee and minimum order; you find the zones in restaurant.deliveryZones in the menu.

4

Show discounts

An order gets either the coupon code the customer typed or the best automatic promotion, never both.

  • No code: call POST /coupons/preview-auto with the cart to show the automatic promotion.
  • Code typed: call POST /coupons/validate, and send the code as couponCode when you create the order.

Both are for display; the server calculates the discount again when the order is created. Details in Coupons and promotions.

5

Create the order

Send the cart to POST /orders. Three things make this step safe:

  • Idempotency-Key: create one key per checkout attempt and reuse it when you retry after a timeout or network error. A replay returns the same order instead of a second one.
  • expectedTotal: the total you showed the customer. If the server’s total is higher, you get 409 PRICE_CHANGED and no order is created.
  • successUrl with {TOKEN}: LettBestilt replaces {TOKEN} with the order’s publicToken when the customer returns from payment.
curl -X POST "https://www.lettbestilt.no/api/v1/orders?slug=pizzaroma&successUrl=https%3A%2F%2Fwww.pizzaroma.no%2Forder%2F%7BTOKEN%7D&cancelUrl=https%3A%2F%2Fwww.pizzaroma.no%2Fcheckout" \
-H "Authorization: Bearer $LETTBESTILT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f6c2b8e-6c1d-4a51-9a63-2f0d5c7e9b14" \
-d '{
"items": [
{ "productId": "PRODUCT_ID", "variantId": "VARIANT_ID", "addonIds": [], "quantity": 2 }
],
"fulfillment": "PICKUP",
"customerName": "Kari Nordmann",
"customerEmail": "kari@example.com",
"customerPhone": "+4799999999",
"paymentMethod": "STRIPE",
"expectedTotal": 33800,
"locale": "nb"
}'

The response has totals (subtotal, deliveryFee, discount, total, currency). They are the amounts the customer pays.

6

Send the customer to payment

  • Online (paymentMethod: "STRIPE"): redirect the customer to stripeUrl, SagaPay’s hosted payment page. The customer chooses card, Vipps or another method there, and comes back to your successUrl. The name stripeUrl is historical.
  • Cash (paymentMethod: "CASH"): there is no stripeUrl. The order is already confirmed and on its way to the kitchen, so show your confirmation page right away.
TypeScript
if (body.stripeUrl) {
return Response.redirect(body.stripeUrl, 303);
}
return Response.redirect(`https://www.pizzaroma.no/order/${body.publicToken}`, 303);

More in Payments.

7

Show the order and its status

Your confirmation page gets the publicToken from the URL. Fetch the order with it; no API key is needed:

curl "https://www.lettbestilt.no/api/v1/orders/ORDER_PUBLIC_TOKEN"

Do not treat the redirect to successUrl as proof of payment. Check order.status instead: while an online order is AWAITING_PAYMENT, the server asks the payment provider for the latest status on every call, so poll every few seconds until it changes. Then poll every 15–30 seconds to show progress: PAID → CONFIRMED → PREPARING → READY_FOR_PICKUP or OUT_FOR_DELIVERY → COMPLETED. Stop polling at COMPLETED, CANCELLED or FAILED.

If pickupTimeRevisedAt is set, the restaurant has moved the time. Tell the customer instead of changing the time silently.

LettBestilt’s emails to the customer link to /order/{publicToken} on the restaurant’s website domain. If that domain is your site, serve the order page at exactly that path.

8

Listen for webhooks

For your own systems (a back office, a CRM, notifications), subscribe to ORDER_CREATED, ORDER_PAID, ORDER_STATUS_CHANGED and ORDER_CANCELLED instead of polling. See Webhooks.

Handle errors

ResponseWhat to do
409 PRICE_CHANGEDFetch the menu again, show the new total and let the customer confirm. Then create the order with a new Idempotency-Key
409 PAYMENT_PENDINGWait a couple of seconds and retry with the same key
409 PAYMENT_INCOMPLETEThe payment link was never created. Start over with a new key
422 VALIDATION_FAILED / FULFILLMENT_NOT_ALLOWEDShow error.message (Norwegian). The cart or customer details need to change
400 BAD_REQUESTThe restaurant is closed at that time, or does not offer the chosen fulfillment or payment method. Show error.message
429 RATE_LIMITEDWait for Retry-After seconds
503 INTERNAL_ERRORThe payment provider did not answer. Offer to try again with a new key, or to pay in cash
Network error or timeoutRetry with the same key

The full list is in Errors, rate limits and retries.