> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.lettbestilt.no/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.lettbestilt.no/_mcp/server.

# Set up a restaurant through the API

The [LettBestilt Partner API](/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](/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.

> **Note**
>
> 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:

```http
Authorization: Bearer lbp_test_...
X-LettBestilt-Restaurant-Key: lbs_test_...
```

| Area                    | Endpoints                                                                                                                         |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Settings                | `GET`, `PATCH …/{slug}/settings`                                                                                                  |
| Opening hours           | `GET`, `PUT …/{slug}/hours`                                                                                                       |
| Exceptions to the hours | `POST …/{slug}/hours/overrides`, `PATCH`, `DELETE …/{slug}/hours/overrides/{overrideId}`                                          |
| Delivery zones          | `GET`, `POST …/{slug}/delivery-zones`, `PATCH`, `DELETE …/{slug}/delivery-zones/{zoneId}`                                         |
| Menus                   | `GET`, `POST …/{slug}/menus`, `GET`, `PATCH`, `DELETE …/{slug}/menus/{menuId}`, `PUT …/{slug}/menus/{menuId}/schedules`           |
| Categories              | `POST …/{slug}/menus/{menuId}/categories`, `PATCH`, `DELETE …/{slug}/categories/{categoryId}`                                     |
| Products                | `POST …/{slug}/categories/{categoryId}/products`, `POST …/{slug}/products/batch`, `PATCH`, `DELETE …/{slug}/products/{productId}` |
| Addon groups            | `GET`, `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:

```bash
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](/partner-api#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](#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.

```bash
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](/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](mailto: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.

```bash
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:

```bash
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:

```bash
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](/partner-api#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.

```bash
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`.

```bash
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](/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:

```bash
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](/partner-api#domains): add and verify the website's domains, choose the domain for tracking links, and add webhooks. [LettBestilt MCP](/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](/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](/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](/partner-api#go-live-approval) and [From sandbox to live](/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](/mcp#your-partner-account) calls the same endpoints.

| Area                        | Endpoints                                                                                   |
| --------------------------- | ------------------------------------------------------------------------------------------- |
| Your account                | `GET /me`                                                                                   |
| Brand                       | `GET`, `PATCH /brand`, `POST /brand/logo`, `POST /brand/logo-light`                         |
| Sender domain               | `GET`, `POST`, `DELETE /brand/sender-domain`, `POST /brand/sender-domain/verify`            |
| Contact and invoice details | `GET`, `PATCH /profile`                                                                     |
| News                        | `GET`, `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:

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

> **Warning**
>
> 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.

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

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```bash
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`.

```bash
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`.

```bash
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.

```bash
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

| Code                                                                                  | Status | When                                                                                                                                                                                          |
| ------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEY_ROLE_FORBIDDEN`                                                                  | 403    | The partner key was not created by an owner or administrator. `error.details.capability` says which access is missing                                                                         |
| `WHITE_LABEL_REQUIRED`                                                                | 403    | `showPoweredBy: false`, a sender domain, or new dashboard colours or font, without white label. `error.details.field` names the field                                                         |
| `HUMAN_REQUIRED`                                                                      | 403    | LettBestilt 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_FORBIDDEN`                                                              | 403    | News is for external partners only                                                                                                                                                            |
| `NEWS_NOT_FOUND`                                                                      | 404    | The news item is not yours                                                                                                                                                                    |
| `FILE_TOO_LARGE`                                                                      | 413    | The logo is over 2 MB                                                                                                                                                                         |
| `LENGTH_REQUIRED`                                                                     | 411    | The logo upload has no `Content-Length`                                                                                                                                                       |
| `LOGO_NOT_ALLOWED`                                                                    | 422    | `logoUrl` or `logoLightUrl` is a link to an image that was not uploaded to LettBestilt. `error.details.field` says which                                                                      |
| `FILE_UNSUPPORTED`                                                                    | 422    | The logo is not PNG, JPEG or WebP                                                                                                                                                             |
| `IMAGE_NOT_ALLOWED`                                                                   | 422    | An image in the news text was not uploaded to LettBestilt                                                                                                                                     |
| `NO_RECIPIENTS`                                                                       | 422    | Nobody to send the news to                                                                                                                                                                    |
| `SENDER_DOMAIN_REJECTED`                                                              | 422    | The e-mail service did not accept the domain                                                                                                                                                  |
| `CHANGED`                                                                             | 409    | `expectedUpdatedAt` is out of date                                                                                                                                                            |
| `NOT_DRAFT`, `NOT_PUBLISHED`, `NOT_SCHEDULED`, `NOT_CLAIMABLE`                        | 409    | The news item is not in the state the action needs                                                                                                                                            |
| `SENDER_DOMAIN_ALREADY_SET`, `SENDER_DOMAIN_NOT_SET`, `DOMAIN_TAKEN`, `DOMAIN_EXISTS` | 409    | One sender domain per partner; the domain is someone else's or already registered                                                                                                             |
| `PARTNER_ARCHIVED`                                                                    | 409    | Your partner account is archived or suspended                                                                                                                                                 |
| `UPLOAD_FAILED`                                                                       | 502    | The logo could not be stored right now                                                                                                                                                        |
| `EMAIL_SERVICE_UNAVAILABLE`                                                           | 503    | The 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.

| Object                   | How you guard a change                                                                                                                                                                 |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Settings                 | Send `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 overrides          | Send `expectedUpdatedAt`. Without it, a change made between your read and your write still gives `409`                                                                                 |
| Delivery zones           | No 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 groups   | Send `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, categories        | No 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:

| Code                       | Status | When                                                                                                                                                                                |
| -------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HOURS_OVERRIDE_NOT_FOUND` | 404    | The override does not belong to the restaurant                                                                                                                                      |
| `DELIVERY_ZONE_NOT_FOUND`  | 404    | The zone does not belong to the restaurant                                                                                                                                          |
| `MENU_NOT_FOUND`           | 404    | The menu does not belong to the restaurant                                                                                                                                          |
| `CATEGORY_NOT_FOUND`       | 404    | The category does not belong to the restaurant                                                                                                                                      |
| `PRODUCT_NOT_FOUND`        | 404    | The product does not belong to the restaurant                                                                                                                                       |
| `ADDON_GROUP_NOT_FOUND`    | 404    | The addon group does not belong to the restaurant                                                                                                                                   |
| `CHANGED`                  | 409    | Someone 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_LOCKED`             | 409    | A legal field would get a new value after the owner signed the customer agreement. `error.details.fields` lists them                                                                |
| `DEMO_RESTAURANT`          | 409    | A write to LettBestilt's demo restaurant                                                                                                                                            |
| `DELIVERY_ZONE_LIMIT`      | 409    | The restaurant already has 50 delivery zones                                                                                                                                        |
| `HOURS_OVERRIDE_LIMIT`     | 409    | The restaurant already has 200 opening hours overrides. Delete old ones before you add new ones                                                                                     |
| `MENU_IS_DEFAULT`          | 409    | You tried to delete the default menu                                                                                                                                                |
| `CATEGORY_NOT_EMPTY`       | 409    | The category still has active products or subcategories                                                                                                                             |
| `CATEGORY_LIMIT`           | 409    | The menu would get more than 60 categories                                                                                                                                          |
| `PRODUCT_LIMIT`            | 409    | The restaurant would get more than 800 active or 2000 products in all (also when committing a menu import)                                                                          |
| `ADDON_GROUP_LIMIT`        | 409    | The restaurant already has 100 addon groups                                                                                                                                         |

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

## Rate limits

| What                     | Limit                                                                                                                                              |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| All partner endpoints    | 120 calls per minute per partner key, and 600 per minute per partner across all its keys (reads and writes)                                        |
| Every write on this page | Shares the partner's 30 changes per minute with keys, webhooks and domains. Counted per environment: test keys and live keys have separate budgets |
| `POST …/menus`           | Also 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](/api-reference).