LettBestilt
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.
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.jsonAn origin is allowed if any of:
NEXT_PUBLIC_APP_URL exactly.NEXT_PUBLIC_APP_DOMAIN or ends with .${NEXT_PUBLIC_APP_DOMAIN}.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.
Per (slug, ip). Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. 429 responses additionally include Retry-After.
| Endpoint | Limit |
|---|---|
POST /api/v1/orders | 10 / 60s |
POST /api/v1/coupons/validate | 30 / 60s |
POST /api/v1/coupons/preview-auto | 30 / 60s |
POST /api/v1/reservations | 20 / 60s |
POST /api/v1/newsletter/signup | 5 / 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/menu | No limit (cached, ETag) |
GET /api/v1/dine-in/availability | 60 / 60s |
GET /api/v1/site-gate | 600 / 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.
All 4xx/5xx responses have the same shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"details": { "issues": [...] }
}
}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.
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.
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.
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/validateAlways returns 200. Body is either { valid: true, discount, coupon } or { valid: false, discount: 0, reason } with one of:
UNKNOWN_OR_INACTIVENOT_YET_VALIDEXPIREDEXHAUSTEDMINIMUM_NOT_METWRONG_DAY / WRONG_TIME / WRONG_FULFILLMENTNOT_APPLICABLE_TO_CARTSUBSCRIBER_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-autoForhå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." }