Set up a restaurant through the API
Settings, opening hours, delivery and the menu, first in a sandbox and then again on the real restaurant, up to a go-live request, and your own partner account
The LettBestilt Partner API creates restaurants and connects them to a website. The endpoints on this page set up the restaurant itself: its profile and brand, opening hours, delivery zones and menu. With them, your own code or an AI tool through LettBestilt MCP can take a restaurant from an empty sandbox to a go-live request without opening the dashboard.
They do exactly what the restaurant dashboard does for the same fields, with the same rules, because both call the same code. A partner can change what a developer on the restaurant’s partner team can change in the dashboard, and nothing more: payment, customers, the team, printers and accounting stay with the restaurant.
Test partner keys (lbp_test_…) reach your sandboxes. Live partner keys (lbp_live_…) reach your real restaurants once LettBestilt Partner is open. Everything below works the same in both environments.
What you can set up
Every call uses both keys, like everything else about one restaurant in the partner API:
… is https://www.lettbestilt.no/api/v1/partner/restaurants. The examples below use these variables:
1. Start with an empty sandbox
Create a sandbox with POST /partner/restaurants and store the restaurant key from the response; see Create a restaurant. An AI tool can do the same with the MCP tool create_sandbox, but it never gets the key: a person creates it on the sandbox’s API tab in LettBestilt Partner.
A new sandbox has a name and a slug, and nothing else: no opening hours, no delivery zones and no menu. GET /partner/restaurants/{slug} shows what is missing in setup.
The sandbox is where you try the steps and build the website. Nothing in it is ever copied to a real restaurant: when the real restaurant is ready, you do steps 2–7 again against it with live keys (see 8. Go live).
2. Settings
GET …/settings returns the profile, visiting address, brand, ordering options, the texts on the tracking page and the receipt, and the legal fields, plus three fields that are not settings:
version: a version of the settings. Send it back asexpectedVersionto make sure you do not overwrite a change someone else made.updatedAt: when anything on the restaurant last changed, including orders. For information only.legal.locked:trueonce the owner has signed the customer agreement.
PATCH …/settings changes the fields you send; a field you leave out stays as it is. null or "" clears a text field that may be empty.
The response is the settings after the change, with a new version.
Rules worth knowing:
- Remove
version,updatedAtandlegalbefore you send aGETresponse back. They are not settings, and sending them gives422. slug,email,storefrontEnabled, the font, invoicing, agreements, payment and printers cannot be changed through the API (422, “Feltet kan ikke endres via API-et”).latitudeandlongitudego together: both or neither.logoUrlandcoverImageUrlmust be files uploaded to LettBestilt. Any other address gives422. A value that is already stored is accepted, so a round trip fromGETworks.googleReviewUrlmust behttps.logoUrl,coverImageUrlandgoogleReviewUrlare at most 2048 characters,websiteat most 320.- Through LettBestilt MCP, sending
websiteorgoogleReviewUrlfor a real restaurant that is live gives403 HUMAN_REQUIRED(the field inerror.details.field): a person changes them, in the dashboard, in LettBestilt Partner or with this endpoint. - At least one of
pickupEnabledanddeliveryEnabledmust staytrue. - Free text (
name,description,cuisineTypeand the texts on the tracking page and the receipt) is cleaned like menu text: links, email addresses, HTML and invisible direction marks are removed. When something was removed, the response hastextCleanedwith the number of fields. - Legal fields (
orgNumber,legalName,addressLine1,postalCode,city) go into the customer agreement. You can change them until the owner has signed it. After that, a new value gives409 LEGAL_LOCKEDwith the fields inerror.details.fields; the same value as stored is accepted. The lock applies in the restaurant dashboard too, so the owner sees the same rule. The visiting address (publicAddressLine1,publicPostalCode,publicCity) and the coordinates can always be changed.orgNumbermust have a valid check digit (mod 11). - When the owner needs new legal details after signing (a new legal address, name or organisation number), ask the owner to contact you, and contact LettBestilt at support@lettbestilt.no. LettBestilt changes them; no endpoint, MCP tool or dashboard form can.
3. Opening hours
PUT …/hours replaces the whole week. A weekday you leave out is closed.
- Times are
HH:MMin Norwegian time (Europe/Oslo). Each weekday at most once. An open day whereopensAtequalsclosesAtgives422. - A window over midnight only applies to that day’s row.
closesAtbeforeopensAt, for exampleFRIDAY23:00–02:00, means open 00:00–02:00 and 23:00–24:00 on Friday, not Friday night into Saturday. To stay open until 02:00 after Friday, put those hours on Saturday’s row:SATURDAY13:00–02:00 means open 00:00–02:00 and 13:00–24:00 on Saturday. - The week has no
updatedAt, soPUTtakes noexpectedUpdatedAt(it gives422). Two saves at the same time run one after the other and the last one wins. The same week twice is a no-op. - Setting the hours does not tick off the opening hours step in the owner’s Get started (Kom i gang); the owner confirms that.
Exceptions (public holidays, holidays, summer hours) are overrides for a date range:
- Dates are
YYYY-MM-DDin Norwegian time, andendDateis inclusive. - An open override (
isClosed: false) needs bothopensAtandclosesAt, and they cannot be equal (422). PATCH …/hours/overrides/{overrideId}changes the fields you send. To end an override early, setendDateto the last day it should still apply (endDateis inclusive, so today means it still applies for the rest of today). An override that has not started yet is removed withDELETE.- A restaurant has at most 200 overrides (
409 HOURS_OVERRIDE_LIMIT). Delete old ones before you add new ones. - Send
expectedUpdatedAt(theupdatedAtyou read) withPATCHto get409 CHANGEDinstead of overwriting a change made in between.
GET …/hours returns the week and every override, oldest first.
4. Delivery
Delivery needs deliveryEnabled: true in the settings and at least one active zone. A zone is either a list of postal codes or a radius around a point:
- Amounts are integers in øre (
4900= 49 kr).estimatedMinutesis 0–240,radiusKmabove 0 and at most 100. - Postal codes are four digits. A
POSTAL_CODESzone needs at least one; aRADIUSzone needscenterLat,centerLngandradiusKm. Fields for the other type (postalCodeson aRADIUSzone, or a centre or radius on aPOSTAL_CODESzone) give422, both when you create and when you change a zone; an empty list ornullis fine. PATCH …/delivery-zones/{zoneId}changes the fields you send, and checks the result: changingtypetoRADIUSwithout a centre gives422with the pathcenterLat.- Turn a zone off with
{ "isActive": false }. If it was the last active zone, delivery stops without a warning whiledeliveryEnabledis stilltrue. - A restaurant can have up to 50 zones (
409 DELIVERY_ZONE_LIMIT). nameandcenterLabelare cleaned like menu text;textCleanedis in the response when something was removed. The same goes forlabelon opening hours overrides.- The go-live check has a
deliverypoint: a warning when delivery is on without an active zone.
5. A menu from a proposal
POST …/menus creates a new menu that is turned off from a structured proposal: categories, products, sizes (variants) with prices in øre, and allergens. It is the same proposal menu import produces, so an AI tool that has read the menu itself can send it straight in, and a menu import draft can be sent as it is.
- A product has either
priceorvariants; the first variant becomes the default. At most 60 categories (per menu), 800 products and 8 variants per product. - A price above 0 and under 500 øre (5 kr) looks like kroner. It is saved, and the response has an entry in
warnings({ path, message }, with the path to the price in the proposal). Check it against the menu. - The proposal has the same limits as menu import: a price is at most 1 000 000 øre (10 000 kr) and a product name at most 195 characters. Products created or changed one by one (
…/products,…/products/batch) take prices up to 10 000 000 øre (100 000 kr) and names up to 200 characters. Allergen codes are the EU codes in the API reference (WHEAT,MILK,EGGS…). - Names and descriptions are cleaned like menu import: links, email addresses, HTML and invisible direction marks are removed, and
textCleanedin the response says how many were changed. - Not idempotent. A retry after a lost response can create a second menu. Check
GET …/menusbefore you try again, and delete the extra one. - No existing menu is changed.
- Every product in the proposal is active and counts against the restaurant’s product limit (800 active, 2000 in all): over it,
409 PRODUCT_LIMITand nothing is created. The same applies when you commit a menu import (POST …/menu-imports/{importId}/commit), which also cleans the text like this endpoint (line breaks in descriptions are kept). Uploading a file to menu import and committing it give409 DEMO_RESTAURANTon LettBestilt’s demo restaurant; reading an import does not.
GET …/menus lists every menu with its schedule, counts and isLiveNow (whether customers see it right now). GET …/menus/{menuId} returns the whole menu in the same shape as GET /api/v1/menu, also when it is turned off, including inactive categories and products.
Edit the menu piece by piece
To add to a menu, or change it later, work on its categories, products and addon groups one at a time. The ids are in GET …/menus/{menuId}, and the addon groups in GET …/addon-groups. Every create and change (POST, PATCH) answers with the object as it is now, textCleaned, warnings (a list of { path, message }, empty when there is nothing to say) and a notice (about the cache, and anything the change left incomplete). DELETE answers with id, deleted, soft and, when there is something to say, notice.
- Categories have
name,description,sortOrderandisActive. A menu has at most 60 categories, subcategories included (409 CATEGORY_LIMIT). - Products have
name,description,basePrice(øre; needed without sizes, otherwise the default size’s price),variants(sizes, up to 8, at most oneisDefault),allergens,isVegan,isVegetarian,isPopular,allowedFulfillment(PICKUP,DELIVERYornullfor both),isActiveandisAvailable(false= sold out). New ones can be created hidden withisActive: false. A price above 0 and under 500 øre gives a warning inwarnings(it is saved). POST …/products/batchcreates 1–50 products in one call, each with its owncategoryId. It is all or nothing: one invalid product (for example acategoryIdfrom another menu, pathproducts.2.categoryId) and none are created. Split larger menus into several calls.- Addon groups have
name,scope(GLOBALfor every product,CATEGORYwithcategoryId, orPRODUCTwithproductIds),minSelect,maxSelect,isRequiredand up to 20addons(name,pricein øre,isAvailable). WithoutisRequired, a group is required whenminSelectis above 0, andisRequiredmust agree withminSelect(true exactly when it is above 0).minSelectabove the number of available addons gives422, since no customer could order the product. A requiredGLOBALgroup applies to every product in the restaurant, so the response has a warning inwarnings(pathscope).
Send the ids back when you change sizes or addons. variants on a product and addons on a group are replaced as a set. A row with the id you read is updated in place; a row without id is new and gets a new id; a row you leave out is deleted. Through LettBestilt MCP a set that would delete an existing size or addon gives 422 VALIDATION_FAILED instead (path variants or addons): an AI tool never removes them, a person does, in the dashboard or with these endpoints. Keep the ids: a customer’s basket on the website holds the size and addon ids from the cached menu, so new ids can make orders fail for up to six minutes. Sizes also keep their addonTierKey, and addons their size prices, only when you send the id.
Send expectedVersion when you change a product or an addon group. Every product and addon group in GET …/menus/{menuId} and GET …/addon-groups has a version (16 hex characters), and so does every write response. PATCH …/products/{productId} and PATCH …/addon-groups/{groupId} take it as expectedVersion (optional here; LettBestilt MCP always requires it); when the row, its sizes or its addons changed after you read them, you get 409 CHANGED and nothing is saved. Read again and retry. Without expectedVersion, a change the owner made a minute ago can be overwritten.
Rules worth knowing:
- Size prices for addons cannot be set through the API. The API has no
tiersortierPrices. A new addon in a group that has sizes costs the same for every size, and a new size withoutaddonTierKeygives addons their base price.noticesays when that happens; set size prices in the dashboard. - Images:
imageUrl(onPATCH …/products/{productId}) takes anhttpsimage from LettBestilt’s image library (only images that are still active) or one uploaded to LettBestilt, the value already stored, ornullto remove it. Any other address gives422.imageIsStockfollows the image:truefor an image from the library,falsefor your own photo or no image. When it istrue, the website must show “Illustrasjonsfoto” next to it (Norwegian marketing law). - Deleting a category with active products or subcategories gives
409 CATEGORY_NOT_EMPTY: turn the products off or delete them first. A category whose inactive products have order history is turned off instead (soft: true). ItsCATEGORYaddon groups go with it (removedAddonGroupIds). - Deleting a product with order history turns it off instead (
soft: true), so old orders keep it. A product that is part of a combo or in upsell is turned off instead (soft: true), andnoticesays where it is used. - Limits: 60 categories per menu (
409 CATEGORY_LIMIT, also inPOST …/menusand when committing a menu import), and per restaurant 800 active products and 2000 in all, also inactive ones (409 PRODUCT_LIMIT, also when an inactive product is turned on again), and 100 addon groups (409 ADDON_GROUP_LIMIT). Prices are at most 10 000 000 øre (100 000 kr), and product names at most 200 characters; a proposal inPOST …/menusallows 10 000 kr and 195 characters. - Categories, products and addon groups have no
updatedAt. Like menus, the write only goes through if the row is as it was when the API read it;expectedUpdatedAtis not accepted (422). Products and addon groups takeexpectedVersion(above) to guard against a change made since you read them. - Combo products, the image library itself (search and upload), upsell and the order of menus and categories are done in the dashboard.
6. Publish the menu
Only PATCH …/menus/{menuId} puts a menu in front of customers:
{ "isActive": true, "isDefault": true }makes it the main menu. The previous default menu stops being the default in the same transaction, so there is always one main menu.- A menu with a schedule (
PUT …/menus/{menuId}/schedules, for example a lunch menu on weekdays 11:00–14:00) replaces the default menu inside its time windows and is hidden outside them. A main menu without a schedule for the whole week must therefore be the default menu to be seen. priority(a whole number from 0 to 100, inGET …/menus) decides which menu is shown when several have a time window at the same moment: the highest wins, and with equal priority all are shown. Reordering menus in the dashboard rewrites it (top is highest).priorityon a schedule row is stored but not used.
A menu with dates (startsOn and endsOn, YYYY-MM-DD, inclusive), for example a Christmas menu, is published in one of three ways:
- Next to the main menu in the period:
{ "isActive": true, "isDefault": true, "startsOn": …, "endsOn": … }. A menu with dates never takes the default from the main menu (defaultKept: truein the response), so customers see both. - Instead of the main menu in the period:
{ "isActive": true, "startsOn": …, "endsOn": …, "priority": … }withoutisDefault, with apriorityhigher than the main menu’s, and then a schedule with all seven days 00:00–23:59. Both are needed: without the schedule the menu is never shown, and when the main menu has a schedule of its own, equal priority shows both menus and a lower one shows only the main menu. AfterendsOnthe main menu is back by itself. - A new main menu:
{ "isActive": true, "isDefault": true }without dates. The previous main menu stops being the default.
A menu with dates but without isDefault or a schedule is never shown while another menu is the default.
warningsin the response says when no menu is visible afterwards, or when another menu becomes visible.noticeexplains how the menu now shows.{ "isActive": false }takes a menu out of service.GET /api/v1/menuis cached for up to six minutes (30 seconds, then up to five more while it is refreshed), so the website can show the old menu for a while. Every write response for menus, categories, products and addon groups says so innotice.
DELETE …/menus/{menuId} deletes a menu that is not the default (409 MENU_IS_DEFAULT: make another menu the default first). A menu whose products have order history is turned off instead (soft: true), so old orders keep their products.
7. Domains and webhooks
Connect the website as described in the LettBestilt Partner API: add and verify the website’s domains, choose the domain for tracking links, and add webhooks. LettBestilt MCP can do the same from an AI tool.
8. Go live
Nothing is copied from the sandbox. The real restaurant starts empty like the sandbox did, so do steps 2–7 again against it with its live keys (lbp_live_… and the real restaurant’s lbs_live_…), and point the website at it; see From sandbox to live.
For a real restaurant, GET /partner/restaurants/{slug} shows the go-live status in goLive. With the steps on this page, the checks for the menu, opening hours, delivery (a warning when delivery is on without an active zone) and allergens can be fixed through the API; campaign tracking is a script on the website (see Campaign tracking). Online payment cannot: the owner sets it up with SagaPay, signs the agreements and sets the restaurant live. When the steps are done, ask LettBestilt for approval with POST …/go-live-review; see Go-live approval and From sandbox to live.
Your own partner account
Everything above is about one restaurant. These endpoints are about you, the partner: your brand, your contact and invoice details, your sender domain and your news to your restaurants. They need only the partner key, no restaurant key, and are server-side only like the rest of the partner API. LettBestilt MCP calls the same endpoints.
The paths are under https://www.lettbestilt.no/api/v1/partner. The examples use:
Your brand, profile and sender domain belong to you as a partner, not to an environment. A test key changes what real restaurant owners see, also while LettBestilt Partner is closed. Only news is bound to the key’s environment.
Who the key is. Reading needs no role. Every change needs a partner key created by an owner or administrator on your partner team (or by LettBestilt), and that person must still have the access in the portal. A key created by a developer, support or read-only member gets 403 KEY_ROLE_FORBIDDEN with the access in error.details.capability (brand.manage, profile.manage or news.manage). Create a new partner key as an owner or administrator under Innstillinger → Partnernøkkel (API). A rotated key counts as created by the person who rotated it, so a developer who rotates the owner’s key gets 403 KEY_ROLE_FORBIDDEN on brand, profile and news; LettBestilt Partner shows per key whether it can change the partner’s own settings.
Your account
GET /me returns your name, kind, approved, whiteLabelEnabled, the key’s environment, restaurantCount (restaurants in that environment that are not archived), and brand, profile and senderDomain as below. Never a key.
Brand
GET /brand returns logoUrl, logoLightUrl, themeSidebarColor, themeActionColor, themeFont, supportEmail, supportPhone, websiteUrl, showPoweredBy, whiteLabelEnabled (set by LettBestilt, read only) and updatedAt. Your restaurant owners see the brand. PATCH /brand changes the fields you send; null clears one, and expectedUpdatedAt (the updatedAt you read) gives 409 CHANGED instead of overwriting a change made in between. showPoweredBy: false needs white label (403 WHITE_LABEL_REQUIRED). websiteUrl must be https://; it decides which webhook hosts are trusted, so LettBestilt MCP never sets it (403 HUMAN_REQUIRED). LettBestilt MCP uploads logos as files and never sends logoUrl or logoLightUrl (403 HUMAN_REQUIRED).
A new logo is uploaded as a file, never as a link to an image somewhere else (422 LOGO_NOT_ALLOWED, with the field in error.details.field). POST /brand/logo takes multipart/form-data with the field file: PNG, JPEG or WebP, at most 2 MB, read from the file’s bytes. It stores the logo as logoUrl in the same call and returns the brand. The request needs a Content-Length (411 LENGTH_REQUIRED), and takes an optional text field expectedUpdatedAt. At most 10 logo uploads per hour per partner, for both logos together, shared with LettBestilt MCP. A new logo replaces the previous one. When you upload it through the API or LettBestilt MCP, the previous file is deleted only when LettBestilt has a record that you uploaded it (through the API, MCP, or LettBestilt Partner from 20 October 2026) and the other logo does not use it; replacing a logo in LettBestilt Partner never deletes the old file.
Light logo. The sidebar of your restaurant owners’ dashboard is dark. POST /brand/logo-light uploads a light version of your logo for it, with exactly the same rules as /brand/logo, and stores it as logoLightUrl. Without a light logo, the dashboard shows your normal logo with its lightness inverted. PATCH /brand with logoLightUrl: null removes it.
Colours and font. With white label you can give the owners’ dashboard your colours and font: themeSidebarColor (the sidebar), themeActionColor (buttons and links) and themeFont, one of dm-sans, plus-jakarta-sans, nunito-sans, work-sans, source-sans-3 and figtree. Colours are hex (#1f2a44, stored as #rrggbb) and need a contrast of at least 4.5:1, the sidebar colour against the light menu text and the main colour against the light background; otherwise 422 VALIDATION_FAILED. A new colour or font without white label gives 403 WHITE_LABEL_REQUIRED; null restores LettBestilt’s default and is always allowed. The values only apply in the owners’ dashboard, and only while you have white label.
Sender domain
With white label, e-mail to your restaurant owners can go from your own domain (noreply@post.yourcompany.no). POST /brand/sender-domain with { "domain": "post.yourcompany.no" } registers it and returns 201 with the DNS records to create at your DNS provider (records[] with type, name, fullName, value and status). Then POST /brand/sender-domain/verify checks them again. status is NOT_STARTED, PENDING, VERIFIED or FAILED; e-mail goes from your domain only when it is VERIFIED. DELETE /brand/sender-domain removes it, also without white label. A free e-mail service (such as gmail.com, or a domain under it) gives 422 VALIDATION_FAILED on domain. At most 10 changes per hour and 10 checks per 10 minutes per partner, shared with the portal.
Contact and invoice details
GET /profile returns contactName, contactEmail, contactPhone, preferredContactMethod (EMAIL or PHONE), invoiceDeliveryMethod (EHF or EMAIL), invoiceEmail, invoiceAddressLine, invoicePostalCode, invoiceCity, invoiceReference, orgNumber (read only) and updatedAt. PATCH /profile changes the fields you send, with expectedUpdatedAt. EMAIL as delivery method needs invoiceEmail (422 VALIDATION_FAILED). The legal name and organisation number are in your signed agreements and only change through LettBestilt. An archived partner gets 409 PARTNER_ARCHIVED.
News to your restaurants
News is a message from you in the restaurant dashboard’s inbox, optionally by e-mail, for external partners only (403 PARTNER_NEWS_FORBIDDEN). News is bound to the key’s environment: with a test key, news goes to sandboxes you name in restaurantSlugs (audienceAll: true gives 422); with a live key, to real restaurants. GET /news lists the news the key can reach, newest first, with limit (1–50) and cursor, status, recipients as slugs and read counts.
POST /news creates a draft (201): title, body (limited markdown; images only uploaded to LettBestilt, 422 IMAGE_NOT_ALLOWED), level (INFO, UPDATE, WARNING), audienceAll, restaurantSlugs, sendEmail and expiresAt (YYYY-MM-DD). A slug that is not yours gives 404 RESTAURANT_NOT_FOUND. PATCH and DELETE /news/{newsId} change or delete a draft (409 NOT_DRAFT otherwise). PATCH with an empty restaurantSlugs on news that is not for all restaurants gives 422: news always has at least one recipient or audienceAll.
POST /news/{newsId}/publish takes { "action": "publish" | "schedule" | "unschedule" | "archive", "scheduledAt"? }. Publishing locks the recipients (422 NO_RECIPIENTS when there is nobody to send to). At most 60 news changes per 10 minutes and 10 sends per day per partner, shared with the portal. LettBestilt MCP writes drafts for real restaurants but never publishes them (403 HUMAN_REQUIRED); a person does, in the portal or with this endpoint.
Errors for your own account
Every change is logged in your activity log with the partner key that was used (actor «Partner-API», or «MCP» from LettBestilt MCP).
Changes at the same time
The restaurant’s owner, the partner portal and your code can change the same thing at the same time. Every write is conditional, so a change made in between is never overwritten silently; you get 409 CHANGED instead. Read again and retry.
The demo restaurant
LettBestilt’s demo restaurant can be read, but every write gives 409 DEMO_RESTAURANT.
Errors
The partner API’s usual errors apply (400 INVALID_JSON, 413 PAYLOAD_TOO_LARGE for a JSON body over 1 MB, 422 VALIDATION_FAILED with error.details.issues, 404 RESTAURANT_NOT_FOUND, 409 RESTAURANT_ARCHIVED, 429 RATE_LIMITED). The message in each issue is in Norwegian, like the rest of the API’s messages; use path in your code. These are specific to the endpoints on this page:
An archived restaurant can be read, but every write on this page gives 409 RESTAURANT_ARCHIVED.
Rate limits
Over a limit you get 429 RATE_LIMITED with Retry-After.
What the API does not do
- Payment. Card, Vipps, cash and the SagaPay application are the restaurant’s own relationship with SagaPay and its customers. The owner sets them up in the dashboard.
- Customers, the team, printers and accounting. Never through the partner API.
- Slug, email and the ordering page on
{slug}.lettbestilt.no. The slug is in every key and domain, and the email is the owner’s login. - Combo products, size prices for addons, searching and uploading to the image library, upsell and the order of menus and categories. These are done in the dashboard.
- Ticking off the owner’s steps in Get started. The owner confirms them.
Every endpoint, field and error is in the API reference.

