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

Every endpoint except public order tracking requires a public API key UNLESS the request originates from an allow-listed browser origin — that includes the read endpoints GET /api/v1/menu and GET /api/v1/restaurant. Send the key from your server, never from the browser: a key shipped in client JavaScript is public. Browser calls should rely on the origin allow-list instead (ask us to register the restaurant's domain). GET /api/v1/orders/[token] stays unauthenticated — the token itself is the capability. Generate the key under Settings → API-tilgang. The plaintext is shown once; we store a peppered HMAC-SHA256 hash (a server-side secret, not just the DB, is needed to brute-force it offline). In production this fails closed if the pepper isn't configured. Older keys generated before the pepper existed keep verifying against a legacy plain SHA256 hash for backwards compatibility.

curl -X POST 'https://lettbestilt.no/api/v1/orders?slug=demo' \
  -H 'Authorization: Bearer lbs_…' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-2f8a91c6d0b34e77' \
  -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). Use napoli=https://partner.example to bind browser authentication to one restaurant. An unscoped origin only enables CORS and does not authenticate a request.

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/reservations20 / 60s
POST /api/v1/newsletter/signup5 / 60s (per IP), plus 200 / hour per restaurant
GET /api/v1/orders/[token]60 / 60s (per IP), plus 30 / 60s per token
GET /api/v1/menuNo limit (cached, ETag)
GET /api/v1/dine-in/availability60 / 60s
GET /api/v1/site-gate600 / 60s (per IP)

GET /api/v1/orders/[token]also enforces a second, per-token limit (30/60s on the first 8 chars of the token) to slow distributed brute-force guessing across many IPs — you'll only hit it by hammering the same token.

Error envelope

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

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

Idempotency

POST /api/v1/orders and POST /api/v1/reservations honour an Idempotency-Key header. A replay of the same key and payload (per restaurant) returns the original result. Reusing the key for a different payload or endpoint returns 409 IDEMPOTENCY_CONFLICT without exposing the original token.

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}`)

One immediate delivery attempt is made when the event fires. If it fails transiently, the delivery is queued and a cron job retries it with exponential backoff — 1m, 5m, 15m, 1h, 4h, 12h — up to 6 attempts total before it's marked permanently failed. Most 4xx responses are treated as permanent client errors and not retried; 408 and 429 are the exceptions and are retried like a 5xx/timeout would be.

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. Vis alle slots, men deaktiver valg med available: false slik at kunden forstår åpningstidene. Tom liste betyr at restauranten er stengt eller at ingen bord passer.

GET /api/v1/site-gate?slug=

Forhåndsvisnings-gaten på restaurantens egen nettside: { gate: { enabled, password? } }. Kall den fra nettsidens middleware/server, så kan gaten skrus av fra LettBestilt-admin ved lansering uten en redeploy.

Bearer-nøkkel er påkrevd. Origin-basert nettleser-auth godtas ikke her — svaret bærer et passord, og det skal aldri kunne hentes fra en nettleser. password følger kun med når enabled er true; er den null mens gaten er på, skal nettsiden feile lukket. Svaret er no-store, så en gate som skrus av slår inn med én gang. Cache verdien selv med kort TTL i stedet for å kalle per request.

POST /api/v1/orders?slug=

Create a takeaway order. Returns { orderId, publicToken, totals } for cash, or one hosted stripeUrl for online payment. SagaPay handles card, Vipps and any other enabled rails inside that page; the field name is historical and does not identify a provider. New integrations should read payment choices from restaurant.capabilities.payment ({ online, cash }). The legacy restaurant.payment object ({ card, vipps, cash }) remains only for older clients.

totals is always present: { subtotal, deliveryFee, discount, total, currency } — the server's authoritative numbers in øre. Show these to the customer before redirecting to payment; never the client's own running total.

Optionally send expectedTotal(the total the client displayed to the customer, in øre). If the server's recomputed total is higher — prices can drift between menu load and checkout — the request is rejected with 409 and code: "PRICE_CHANGED" ({ expectedTotal, actualTotal, currency } in error.details) instead of sending the customer to a payment page for a different price than they saw. No order is created in that case. A lower actual total is always accepted — the customer is charged less than they saw — so an automatic promotion your client cannot reproduce never turns into a checkout outage.

A replayed Idempotency-Key can also return 409 for an order that exists but has no payment link yet. code: "PAYMENT_PENDING" means the original request is still waiting on the payment provider — wait a couple of seconds and retry with the same key. code: "PAYMENT_INCOMPLETE" means the order is too old for that to be true: the provider call failed and the order was never payable ({ orderId } in error.details) — start a new order instead. Neither response returns publicToken.

Kun takeaway (15 % MVA på mat). orderType er låst til "TAKEAWAY" i valideringsskjemaet — et forsøk på å sende orderType: "DINE_IN" avvises som vanlig 422 VALIDATION_FAILED (ingen egen DINE_IN-spesifikk feilkode). 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). Setter du marketingOptIn: true, er guestEmail påkrevd og samtykket lagres i restaurantens abonnentregister.

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." }