LettBestilt

Public API (v1)

Embed ordering on your restaurant's website. Money is integer øre (1 NOK = 100 øre). Timestamps are ISO-8601 UTC. Tenant key is the ?slug= query parameter. The OpenAPI spec is at /api/v1/openapi.json.

Authentication

Write endpoints (POST /api/v1/orders, POST /api/v1/coupons/validate) require a public API key UNLESS the request originates from an allow-listed browser origin. Generate the key under Settings → API-tilgang. The plaintext is shown once; only a sha256 hash is stored.

curl -X POST 'https://lettbestilt.no/api/v1/orders?slug=demo' \
  -H 'Authorization: Bearer lbs_…' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 6f5e0a3b' \
  -d @order.json

CORS

An origin is allowed if any of:

  • It matches NEXT_PUBLIC_APP_URL exactly.
  • Its hostname equals NEXT_PUBLIC_APP_DOMAIN or ends with .${NEXT_PUBLIC_APP_DOMAIN}.
  • It's listed in PUBLIC_API_ALLOWED_ORIGINS (comma-separated).

Preflight is supported via OPTIONS on every route.Authorization and Idempotency-Key are in the allow-list.

Rate limits

Per (slug, ip). Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. 429 responses additionally include Retry-After.

EndpointLimit
POST /api/v1/orders10 / 60s
POST /api/v1/coupons/validate30 / 60s
POST /api/v1/coupons/preview-auto30 / 60s
POST /api/v1/newsletter/signup5 / 60s
GET /api/v1/orders/[token]60 / 60s (per IP)
GET /api/v1/menuNo limit (cached, ETag)
GET /api/v1/dine-in/availability60 / 60s

Error envelope

All 4xx/5xx responses have the same shape:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Validation failed",
    "details": { "issues": [...] }
  }
}

Idempotency

POST /api/v1/orders honours an Idempotency-Keyheader. A replay of the same key (per restaurant) returns the existing order — useful when the embed's network drops mid-redirect.

Webhooks

Configure HTTPS endpoints to receive ORDER_CREATED, ORDER_PAID, ORDER_STATUS_CHANGED or ORDER_CANCELLED. Every delivery is signed with HMAC-SHA256:

X-LettBestilt-Webhook-Id:        wh_…
X-LettBestilt-Webhook-Timestamp: 1746540000
X-LettBestilt-Webhook-Signature: sha256=<hex>

# signature = HMAC_SHA256(secret, `${timestamp}.${rawBody}`)

3 retries with backoff (0s, 2s, 5s). 4xx responses are treated as permanent client errors and not retried; 5xx and timeouts retry.

Endpoints

GET /api/v1/restaurant?slug=

Branding/contact only. Lighter alternative to /menu for header components.

GET /api/v1/menu?slug=

Full payload: restaurant, menus, categories, products (variants, addons, allergens), coupons, allergens, upsell config, opening hours, delivery zones. Cache-Control + weak ETag — supports If-None-Match.

GET /api/v1/dine-in/availability?slug=&date=&partySize=

Returnerer 15-minutters slots for valgt dato/gruppestørrelse. Hver slot har time (HH:mm Oslo), iso (UTC, send som dineInAt ved POST /api/v1/reservations) og available. Skjul slots med available: false i UI. Tom liste ⇒ restauranten er stengt eller ingen bord passer.

POST /api/v1/orders?slug=

Create a takeaway order. Returns { orderId, publicToken } for cash, { orderId, publicToken, stripeUrl } for card, or { orderId, publicToken, vippsUrl } for Vipps. Redirect the customer to stripeUrl/vippsUrl to pay — read them as stripeUrl ?? vippsUrl and treat the names as historical, not as a promise about which provider processes the payment. Which methods are available is exposed as restaurant.payment ({ card, vipps, cash }) on GET /api/v1/restaurant.

Kun takeaway (15 % MVA på mat). orderType: "DINE_IN" returnerer 422 DINE_IN_NOT_SUPPORTED — bordbestilling går via POST /api/v1/reservations som forespørsel.

POST /api/v1/reservations?slug=

Send en bordreservasjons-forespørsel. Returnerer status: "PENDING_APPROVAL" og tableId: null. Restauranten godkjenner eller avslår fra dashbordet — kunden får en e-post når status endres. Bord tildeles først ved godkjenning, så samme tidspunkt kan ha flere ventende forespørsler uten å blokkere hverandre. Bruk publicToken for å spore status via GET /api/v1/orders/{token}, eller bygg eget UI rundt webhook-eventene RESERVATION_REQUESTED, RESERVATION_APPROVED, RESERVATION_REJECTED.

Påkrevde felter: dineInAt (ISO-8601 UTC), partySize, guestName. Sterkt anbefalt: guestEmail (uten denne kan vi ikke varsle kunden om godkjenning/avslag).

Drop-in embed (anbefalt for nye sider)

Slipper du å integrere mot /api/v1/* selv: drop-in embed-en bygger bestillingsflyten som en iframe på lettbestilt.no, så du slipper å håndtere CORS, Bearer-tokens og rate-limits selv.

<script src="https://lettbestilt.no/embed.js" defer></script>
<div data-lettbestilt slug="demo" mode="checkout"></div>

GET /api/v1/orders/[token]

Public order tracking by publicToken (CUID, unguessable).

POST /api/v1/coupons/validate

Always returns 200. Body is either { valid: true, discount, coupon } or { valid: false, discount: 0, reason } with one of:

  • UNKNOWN_OR_INACTIVE
  • NOT_YET_VALID
  • EXPIRED
  • EXHAUSTED
  • MINIMUM_NOT_MET
  • WRONG_DAY / WRONG_TIME / WRONG_FULFILLMENT
  • NOT_APPLICABLE_TO_CART
  • SUBSCRIBER_ONLY — kupongen krever at email er en aktiv abonnent. Send email i body for å aktivere koden.
  • ALREADY_REDEEMED — kupongen er engangs-per-kunde og denne email har allerede brukt den.

POST /api/v1/coupons/preview-auto

Forhåndsviser hvilket automatisk tilbud (auto-promo) serveren vil anvende på kurven i dag. Bruk dette til å rendere en Tilbud: −X kr-linje i din egen kasse-oppsummering. Serveren re-evaluerer ved POST /api/v1/orders, så dette er kun for visning.

Returnerer { applies: true, discount, coupon } hvis et tilbud gjelder, ellers { applies: false, discount: 0 }. Husk å sende quantity og variantName per linje — FIXED_PRICE-tilbud med per-variant priser (Medium/Stor) trenger variantnavnet for å treffe.

curl -X POST 'https://lettbestilt.no/api/v1/coupons/preview-auto' \
  -H 'Authorization: Bearer lbs_…' \
  -H 'Content-Type: application/json' \
  -d '{
    "slug": "demo",
    "subtotal": 58500,
    "fulfillment": "PICKUP",
    "cart": [
      { "productId": "prod_…", "quantity": 1, "variantName": "Stor",   "lineTotal": 35000 },
      { "productId": "prod_…", "quantity": 1, "variantName": "Medium", "lineTotal": 23500 }
    ]
  }'

# → { "applies": true, "discount": 8000,
#     "coupon": { "code": "FASTPRIS", "displayName": "Mandag og tirsdagstilbud",
#                 "discountType": "FIXED_PRICE", "appliesTo": "CATEGORIES" } }

POST /api/v1/newsletter/signup?slug=

Idempotent påmelding til restaurantens nyhetsbrev. Returnerer en velkomstkode kunden kan bruke i kassen. Kupongen er typisk låst til abonnenter (requiresSubscriber: true) — bruk samme e-post når du senere kaller /coupons/validate eller /orders.

Konfigurasjonen ligger som restaurant.newsletterPopup i GET /api/v1/restaurant. Render popup-en kun når feltet ikke er null.

curl -X POST 'https://lettbestilt.no/api/v1/newsletter/signup?slug=demo' \
  -H 'Authorization: Bearer lbs_…' \
  -H 'Content-Type: application/json' \
  -d '{"email":"kari@eksempel.no","name":"Kari Nordmann"}'

# → { "couponCode": "VELKOMMEN10", "discountType": "PERCENT", "discountValue": 10,
#     "successMessage": "Bruk koden {{code}} i kassen for {{discount}}% rabatt." }