Skip to navigation

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:

Authorization: Bearer lbp_test_...
X-LettBestilt-Restaurant-Key: lbs_test_...
AreaEndpoints
SettingsGET, PATCH …/{slug}/settings
Opening hoursGET, PUT …/{slug}/hours
Exceptions to the hoursPOST …/{slug}/hours/overrides, PATCH, DELETE …/{slug}/hours/overrides/{overrideId}
Delivery zonesGET, POST …/{slug}/delivery-zones, PATCH, DELETE …/{slug}/delivery-zones/{zoneId}
MenusGET, POST …/{slug}/menus, GET, PATCH, DELETE …/{slug}/menus/{menuId}, PUT …/{slug}/menus/{menuId}/schedules
CategoriesPOST …/{slug}/menus/{menuId}/categories, PATCH, DELETE …/{slug}/categories/{categoryId}
ProductsPOST …/{slug}/categories/{categoryId}/products, POST …/{slug}/products/batch, PATCH, DELETE …/{slug}/products/{productId}
Addon groupsGET, POST …/{slug}/addon-groups, PATCH, DELETE …/{slug}/addon-groups/{groupId}

… is https://www.lettbestilt.no/api/v1/partner/restaurants. The examples below use these variables:

API="https://www.lettbestilt.no/api/v1/partner/restaurants"
SLUG="pizzaroma-test"
AUTH=(-H "Authorization: Bearer $LETTBESTILT_PARTNER_KEY" -H "X-LettBestilt-Restaurant-Key: $LETTBESTILT_RESTAURANT_KEY")

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 as expectedVersion to 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: true once 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.

curl -X PATCH "$API/$SLUG/settings" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"expectedVersion": "3f9a1c0d7be24e51",
"name": "Pizza Roma",
"description": "Neapolitan pizza baked in a wood-fired oven.",
"phone": "+47 33 00 00 00",
"publicAddressLine1": "Storgata 1",
"publicPostalCode": "3110",
"publicCity": "Tønsberg",
"primaryColor": "#B3261E",
"pickupEnabled": true,
"deliveryEnabled": true,
"defaultPrepMinutes": 20,
"defaultDeliveryMinutes": 45
}'

The response is the settings after the change, with a new version.

Rules worth knowing:

  • Remove version, updatedAt and legal before you send a GET response back. They are not settings, and sending them gives 422.
  • slug, email, storefrontEnabled, the font, invoicing, agreements, payment and printers cannot be changed through the API (422, “Feltet kan ikke endres via API-et”).
  • latitude and longitude go together: both or neither.
  • logoUrl and coverImageUrl must be files uploaded to LettBestilt. Any other address gives 422. A value that is already stored is accepted, so a round trip from GET works.
  • googleReviewUrl must be https. logoUrl, coverImageUrl and googleReviewUrl are at most 2048 characters, website at most 320.
  • Through LettBestilt MCP, sending website or googleReviewUrl for a real restaurant that is live gives 403 HUMAN_REQUIRED (the field in error.details.field): a person changes them, in the dashboard, in LettBestilt Partner or with this endpoint.
  • At least one of pickupEnabled and deliveryEnabled must stay true.
  • Free text (name, description, cuisineType and 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 has textCleaned with 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 gives 409 LEGAL_LOCKED with the fields in error.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. orgNumber must 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.

curl -X PUT "$API/$SLUG/hours" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"days": [
{ "dayOfWeek": "MONDAY", "opensAt": "15:00", "closesAt": "22:00", "isClosed": false },
{ "dayOfWeek": "TUESDAY", "opensAt": "15:00", "closesAt": "22:00", "isClosed": false },
{ "dayOfWeek": "WEDNESDAY", "opensAt": "15:00", "closesAt": "22:00", "isClosed": false },
{ "dayOfWeek": "THURSDAY", "opensAt": "15:00", "closesAt": "22:00", "isClosed": false },
{ "dayOfWeek": "FRIDAY", "opensAt": "15:00", "closesAt": "23:00", "isClosed": false },
{ "dayOfWeek": "SATURDAY", "opensAt": "13:00", "closesAt": "23:00", "isClosed": false }
]
}'
  • Times are HH:MM in Norwegian time (Europe/Oslo). Each weekday at most once. An open day where opensAt equals closesAt gives 422.
  • A window over midnight only applies to that day’s row. closesAt before opensAt, for example FRIDAY 23: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: SATURDAY 13:00–02:00 means open 00:00–02:00 and 13:00–24:00 on Saturday.
  • The week has no updatedAt, so PUT takes no expectedUpdatedAt (it gives 422). 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:

curl -X POST "$API/$SLUG/hours/overrides" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "label": "Julaften", "startDate": "2026-12-24", "endDate": "2026-12-24", "isClosed": true }'
  • Dates are YYYY-MM-DD in Norwegian time, and endDate is inclusive.
  • An open override (isClosed: false) needs both opensAt and closesAt, and they cannot be equal (422).
  • PATCH …/hours/overrides/{overrideId} changes the fields you send. To end an override early, set endDate to the last day it should still apply (endDate is inclusive, so today means it still applies for the rest of today). An override that has not started yet is removed with DELETE.
  • A restaurant has at most 200 overrides (409 HOURS_OVERRIDE_LIMIT). Delete old ones before you add new ones.
  • Send expectedUpdatedAt (the updatedAt you read) with PATCH to get 409 CHANGED instead 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:

curl -X POST "$API/$SLUG/delivery-zones" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "name": "Sentrum", "type": "POSTAL_CODES", "postalCodes": ["3110", "3111", "3112"], "deliveryFee": 4900, "minimumOrder": 20000, "estimatedMinutes": 40 }'
curl -X POST "$API/$SLUG/delivery-zones" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "name": "5 km", "type": "RADIUS", "centerLat": 59.2675, "centerLng": 10.4076, "radiusKm": 5, "deliveryFee": 6900 }'
  • Amounts are integers in øre (4900 = 49 kr). estimatedMinutes is 0–240, radiusKm above 0 and at most 100.
  • Postal codes are four digits. A POSTAL_CODES zone needs at least one; a RADIUS zone needs centerLat, centerLng and radiusKm. Fields for the other type (postalCodes on a RADIUS zone, or a centre or radius on a POSTAL_CODES zone) give 422, both when you create and when you change a zone; an empty list or null is fine.
  • PATCH …/delivery-zones/{zoneId} changes the fields you send, and checks the result: changing type to RADIUS without a centre gives 422 with the path centerLat.
  • Turn a zone off with { "isActive": false }. If it was the last active zone, delivery stops without a warning while deliveryEnabled is still true.
  • A restaurant can have up to 50 zones (409 DELIVERY_ZONE_LIMIT).
  • name and centerLabel are cleaned like menu text; textCleaned is in the response when something was removed. The same goes for label on opening hours overrides.
  • The go-live check has a delivery point: 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.

curl -X POST "$API/$SLUG/menus" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"name": "Hovedmeny",
"proposal": {
"version": 1,
"categories": [
{
"name": "Pizza",
"products": [
{ "number": 1, "name": "Margherita", "description": "Tomat, mozzarella, basilikum", "price": 16900, "allergens": ["WHEAT", "MILK"] },
{ "number": 2, "name": "Pepperoni", "variants": [ { "name": "Liten", "price": 15900 }, { "name": "Stor", "price": 21900 } ], "allergens": ["WHEAT", "MILK"] }
]
}
]
}
}'
  • A product has either price or variants; 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 textCleaned in the response says how many were changed.
  • Not idempotent. A retry after a lost response can create a second menu. Check GET …/menus before 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_LIMIT and 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 give 409 DEMO_RESTAURANT on 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.

curl -X POST "$API/$SLUG/menus/$MENU_ID/categories" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "name": "Pizza", "description": "Steinovnsbakt" }'
curl -X POST "$API/$SLUG/categories/$CATEGORY_ID/products" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"name": "Pepperoni",
"variants": [ { "name": "Liten", "price": 15900, "isDefault": true }, { "name": "Stor", "price": 21900 } ],
"allergens": ["WHEAT", "MILK"],
"allowedFulfillment": null
}'
  • Categories have name, description, sortOrder and isActive. 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 one isDefault), allergens, isVegan, isVegetarian, isPopular, allowedFulfillment (PICKUP, DELIVERY or null for both), isActive and isAvailable (false = sold out). New ones can be created hidden with isActive: false. A price above 0 and under 500 øre gives a warning in warnings (it is saved).
  • POST …/products/batch creates 1–50 products in one call, each with its own categoryId. It is all or nothing: one invalid product (for example a categoryId from another menu, path products.2.categoryId) and none are created. Split larger menus into several calls.
  • Addon groups have name, scope (GLOBAL for every product, CATEGORY with categoryId, or PRODUCT with productIds), minSelect, maxSelect, isRequired and up to 20 addons (name, price in øre, isAvailable). Without isRequired, a group is required when minSelect is above 0, and isRequired must agree with minSelect (true exactly when it is above 0). minSelect above the number of available addons gives 422, since no customer could order the product. A required GLOBAL group applies to every product in the restaurant, so the response has a warning in warnings (path scope).

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 tiers or tierPrices. A new addon in a group that has sizes costs the same for every size, and a new size without addonTierKey gives addons their base price. notice says when that happens; set size prices in the dashboard.
  • Images: imageUrl (on PATCH …/products/{productId}) takes an https image from LettBestilt’s image library (only images that are still active) or one uploaded to LettBestilt, the value already stored, or null to remove it. Any other address gives 422. imageIsStock follows the image: true for an image from the library, false for your own photo or no image. When it is true, 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). Its CATEGORY addon 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), and notice says where it is used.
  • Limits: 60 categories per menu (409 CATEGORY_LIMIT, also in POST …/menus and 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 in POST …/menus allows 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; expectedUpdatedAt is not accepted (422). Products and addon groups take expectedVersion (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:

curl -X PATCH "$API/$SLUG/menus/$MENU_ID" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "isActive": true, "isDefault": true }'
  • { "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, in GET …/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). priority on 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:

  1. 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: true in the response), so customers see both.
  2. Instead of the main menu in the period: { "isActive": true, "startsOn": …, "endsOn": …, "priority": … } without isDefault, with a priority higher 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. After endsOn the main menu is back by itself.
  3. 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.

  • warnings in the response says when no menu is visible afterwards, or when another menu becomes visible. notice explains how the menu now shows.
  • { "isActive": false } takes a menu out of service.
  • GET /api/v1/menu is 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 in notice.

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.

AreaEndpoints
Your accountGET /me
BrandGET, PATCH /brand, POST /brand/logo, POST /brand/logo-light
Sender domainGET, POST, DELETE /brand/sender-domain, POST /brand/sender-domain/verify
Contact and invoice detailsGET, PATCH /profile
NewsGET, POST /news, GET, PATCH, DELETE /news/{newsId}, POST /news/{newsId}/publish

The paths are under https://www.lettbestilt.no/api/v1/partner. The examples use:

PARTNER="https://www.lettbestilt.no/api/v1/partner"
PAUTH=(-H "Authorization: Bearer $LETTBESTILT_PARTNER_KEY")

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.

curl "$PARTNER/me" "${PAUTH[@]}"

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).

curl -X PATCH "$PARTNER/brand" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"expectedUpdatedAt": "2026-10-20T10:00:00.000Z",
"supportEmail": "support@yourcompany.no",
"supportPhone": "+47 33 00 00 00"
}'

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.

curl -X POST "$PARTNER/brand/logo" "${PAUTH[@]}" -F "file=@logo.png"

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.

curl -X POST "$PARTNER/brand/logo-light" "${PAUTH[@]}" -F "file=@logo-light.png"

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.

curl -X PATCH "$PARTNER/brand" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "themeSidebarColor": "#1f2a44", "themeActionColor": "#1d3d48", "themeFont": "figtree" }'

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.

curl -X POST "$PARTNER/brand/sender-domain" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "domain": "post.yourcompany.no" }'
curl -X POST "$PARTNER/brand/sender-domain/verify" "${PAUTH[@]}"

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.

curl -X PATCH "$PARTNER/profile" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "invoiceDeliveryMethod": "EMAIL", "invoiceEmail": "invoice@yourcompany.no" }'

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.

curl -X POST "$PARTNER/news" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{
"title": "New ordering page",
"body": "Your ordering page has a new look from Monday.",
"level": "UPDATE",
"audienceAll": false,
"restaurantSlugs": ["pizzaroma-test"]
}'

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.

curl -X POST "$PARTNER/news/$NEWS_ID/publish" "${PAUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "action": "schedule", "scheduledAt": "2026-11-02T09:00:00+01:00" }'

Errors for your own account

CodeStatusWhen
KEY_ROLE_FORBIDDEN403The partner key was not created by an owner or administrator. error.details.capability says which access is missing
WHITE_LABEL_REQUIRED403showPoweredBy: false, a sender domain, or new dashboard colours or font, without white label. error.details.field names the field
HUMAN_REQUIRED403LettBestilt MCP tried something only a person does: set the website or a logo link, publish news to real restaurants, or set website or googleReviewUrl on a real restaurant that is live
PARTNER_NEWS_FORBIDDEN403News is for external partners only
NEWS_NOT_FOUND404The news item is not yours
FILE_TOO_LARGE413The logo is over 2 MB
LENGTH_REQUIRED411The logo upload has no Content-Length
LOGO_NOT_ALLOWED422logoUrl or logoLightUrl is a link to an image that was not uploaded to LettBestilt. error.details.field says which
FILE_UNSUPPORTED422The logo is not PNG, JPEG or WebP
IMAGE_NOT_ALLOWED422An image in the news text was not uploaded to LettBestilt
NO_RECIPIENTS422Nobody to send the news to
SENDER_DOMAIN_REJECTED422The e-mail service did not accept the domain
CHANGED409expectedUpdatedAt is out of date
NOT_DRAFT, NOT_PUBLISHED, NOT_SCHEDULED, NOT_CLAIMABLE409The news item is not in the state the action needs
SENDER_DOMAIN_ALREADY_SET, SENDER_DOMAIN_NOT_SET, DOMAIN_TAKEN, DOMAIN_EXISTS409One sender domain per partner; the domain is someone else’s or already registered
PARTNER_ARCHIVED409Your partner account is archived or suspended
UPLOAD_FAILED502The logo could not be stored right now
EMAIL_SERVICE_UNAVAILABLE503The e-mail service did not answer; try again

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.

ObjectHow you guard a change
SettingsSend expectedVersion (the version from GET). An order does not change the version. Without it, the last write wins
Opening hours (the week)No token. Saves run one after another and the last one wins
Hours overridesSend expectedUpdatedAt. Without it, a change made between your read and your write still gives 409
Delivery zonesNo token (zones have no updatedAt). The write only goes through if the whole zone is as it was when the API read it
Products, addon groupsSend expectedVersion (the version from GET …/menus/{menuId} or GET …/addon-groups). Without it, the write still only goes through if the row is as it was when the API read it
Menus, categoriesNo token (neither has updatedAt). The write only goes through if the row is as it was when the API read it

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:

CodeStatusWhen
HOURS_OVERRIDE_NOT_FOUND404The override does not belong to the restaurant
DELIVERY_ZONE_NOT_FOUND404The zone does not belong to the restaurant
MENU_NOT_FOUND404The menu does not belong to the restaurant
CATEGORY_NOT_FOUND404The category does not belong to the restaurant
PRODUCT_NOT_FOUND404The product does not belong to the restaurant
ADDON_GROUP_NOT_FOUND404The addon group does not belong to the restaurant
CHANGED409Someone changed the same thing at the same time, or expectedVersion / expectedUpdatedAt is out of date (settings, products, addon groups, hours overrides). Nothing was changed
LEGAL_LOCKED409A legal field would get a new value after the owner signed the customer agreement. error.details.fields lists them
DEMO_RESTAURANT409A write to LettBestilt’s demo restaurant
DELIVERY_ZONE_LIMIT409The restaurant already has 50 delivery zones
HOURS_OVERRIDE_LIMIT409The restaurant already has 200 opening hours overrides. Delete old ones before you add new ones
MENU_IS_DEFAULT409You tried to delete the default menu
CATEGORY_NOT_EMPTY409The category still has active products or subcategories
CATEGORY_LIMIT409The menu would get more than 60 categories
PRODUCT_LIMIT409The restaurant would get more than 800 active or 2000 products in all (also when committing a menu import)
ADDON_GROUP_LIMIT409The restaurant already has 100 addon groups

An archived restaurant can be read, but every write on this page gives 409 RESTAURANT_ARCHIVED.

Rate limits

WhatLimit
All partner endpoints120 calls per minute per partner key, and 600 per minute per partner across all its keys (reads and writes)
Every write on this pageShares the partner’s 30 changes per minute with keys, webhooks and domains. Counted per environment: test keys and live keys have separate budgets
POST …/menusAlso 10 per hour per restaurant, shared with committing menu imports

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.