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

# Create a menu from a proposal (always turned off)

POST https://www.lettbestilt.no/api/v1/partner/restaurants/{slug}/menus
Content-Type: application/json

**Partner key + restaurant key.** Creates a **new** menu that is turned off (`isActive: false`) and is not the default menu, with categories, products, variants and allergens in one transaction, from the same proposal `menu-imports/{importId}/commit` takes. An AI tool that has read the menu itself can send the proposal directly. Names and descriptions are cleaned like the parser's text (links, email addresses, HTML, `javascript:` and invisible direction marks are removed; the count is in `textCleaned`). 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), like `…/products/batch`. The proposal is validated before the quota is used, so an invalid proposal does not use it up. Publish it with `PATCH …/menus/{menuId}` (`isActive`, `isDefault`) or give it a schedule.

**Not idempotent:** a retry after a lost response can create a second menu, so check `GET …/menus` before you try again. 10 per hour per restaurant (shared with menu import commits) and 30 changes per minute per partner. Applies to sandboxes and real restaurants. At most 60 categories per menu (the proposal's non-empty categories; other menus do not count). A price above 0 and below 500 øre (5 kr) is saved, but gives a warning in `warnings` (`[{ path, message }]`): it looks like kroner.

Reference: https://docs.lettbestilt.no/api-reference/partner/create-partner-menu

## Authentication

- `Authorization` header (bearer token, required) — The partner key (`lbp_test_…` or `lbp_live_…`), sent as `Authorization: Bearer lbp_…` and created in LettBestilt Partner under **Partner key**. It belongs to the whole partner, not to a restaurant, and only works against `/api/v1/partner`. Server to server only: a request with an `Origin` header is rejected with `403 FORBIDDEN`, and the key must never be in a browser. A TEST key sees and creates only sandboxes; LIVE keys work once LettBestilt Partner is open. A suspended or archived partner, a revoked key and a restaurant key (`lbs_…`) here give `401 UNAUTHORIZED`; an expired key gives `401 KEY_EXPIRED`.
- `X-LettBestilt-Restaurant-Key` header (required) — The restaurant's own key (`lbs_test_…` or `lbs_live_…`) for calls about one restaurant in the partner API, sent together with the partner key. The key must have full access (scope `*`, otherwise `403 INSUFFICIENT_SCOPE`), belong to a restaurant the partner owns and be in the same environment as the partner key. A restaurant that does not exist, belongs to another partner or does not match the key always gives the same `404 RESTAURANT_NOT_FOUND`. Older keys without an environment in the prefix (`lbs_<random>`) are not accepted here; rotate them in LettBestilt Partner.

## Request

### Path parameters

- `slug` (string, required) — The restaurant's slug. It must match the restaurant key in `X-LettBestilt-Restaurant-Key`.

### Body (application/json)

This endpoint expects a PartnerMenuCreate.

- `proposal` (MenuImportCommitProposal, required) — In the proposal a price is at most 1000000 øre (10 000 kr) and a product name at most 195 characters, as in menu import; `…/products` and `…/products/batch` take prices up to 10000000 øre (100 000 kr) and names up to 200 characters.
- `name` (string, optional) — The menu name. Called `name` here (not `menuName` as in `menu-imports/{importId}/commit`). Default: «Ny meny DD.MM.ÅÅÅÅ» ("New menu" with today's date in Oslo time).

## Response

### 201

The menu is created and turned off.

- `data` (PartnerMenuCreated, required)

## Errors

### 400 Bad Request Error

`INVALID_JSON`: the body is not valid JSON.

- `error` (ErrorBody, required)

### 401 Unauthorized Error

`UNAUTHORIZED`: a key is missing, invalid or revoked, the two keys are from different environments (TEST and LIVE), or the partner is suspended or archived. `KEY_EXPIRED`: a key is past its expiry date.

- `error` (ErrorBody, required)

### 403 Forbidden Error

`FORBIDDEN`: the request has an `Origin` header (server to server only). `PARTNER_AGREEMENT_REQUIRED`: a LIVE key, but the partner agreements are not signed. `PARTNER_NOT_APPROVED`: LettBestilt has not approved the partner yet (all keys). `INSUFFICIENT_SCOPE`: the restaurant key does not have full access (`error.details.requiredScope` is `"*"`).

- `error` (ErrorBody, required)

### 404 Not Found Error

`RESTAURANT_NOT_FOUND`: the same response whether the slug does not exist, belongs to another partner or does not match the restaurant key.

- `error` (ErrorBody, required)

### 409 Conflict Error

`PRODUCT_LIMIT` (the proposal's products would take the restaurant over 800 active or 2000 products in all; nothing is created), `CATEGORY_LIMIT` (more than 60 categories in the new menu), `RESTAURANT_ARCHIVED` (the restaurant is archived) or `DEMO_RESTAURANT` (the demo restaurant cannot be changed through the API).

- `error` (ErrorBody, required)

### 422 Unprocessable Entity Error

`VALIDATION_FAILED` with `error.details.issues` (paths such as `proposal.categories.0.products.1.price` and `name`): a missing name or price, fields that are too long, a price over 1000000 øre, unknown allergen codes, too many products (at most 60 categories and 800 products, the proposal at most 1 MB) or unknown fields in the body. In the proposal a price is at most 1000000 øre (10 000 kr) and a product name at most 195 characters, as in menu import; `…/products` and `…/products/batch` take prices up to 10000000 øre (100 000 kr) and names up to 200 characters.

- `error` (ErrorBody, required)

### 429 Too Many Requests Error

`RATE_LIMITED`: more than 120 calls per minute for this partner key, or more than 600 per minute for the partner across all its keys (reads and writes). Writes also share a bucket of 30 changes per minute per partner and environment. See `Retry-After`.

- `error` (ErrorBody, required)

## Types

### MenuImportCommitProposal

What commit reads. Other fields (`confidence`, `reasons`, `warnings`, `stats` …) are ignored, so the whole `MenuImportProposal` can be sent too. At least one product, at most 60 categories and 800 products.

- `version` (any, required)
- `categories` (list of MenuImportCommitCategory, required)

### PartnerMenuCreated

- `id` (string, required)
- `name` (string, required)
- `isActive` (any, required)
- `isDefault` (any, required)
- `categoryCount` (integer, required)
- `productCount` (integer, required)
- `variantCount` (integer, required)
- `textCleaned` (integer, required) — Number of names and descriptions where links, email addresses, HTML, `javascript:` or invisible direction marks were removed.
- `warnings` (list of PartnerMenuCreatedWarningsItems, required) — Prices above 0 and below 500 øre (they look like kroner), with paths like `proposal.categories.0.products.1.price`. Saved anyway; empty when there are none.
- `notice` (string, required) — How to put the menu in service.

### ErrorBody

- `code` (enum, required) — A machine-readable error code.
  - Allowed values: `MISSING_SLUG`, `MISSING_TOKEN`, `INVALID_JSON`, `INVALID_SUCCESS_URL`, `INVALID_CANCEL_URL`, `VALIDATION_FAILED`, `RESTAURANT_NOT_FOUND`, `ORDER_NOT_FOUND`, `RATE_LIMITED`, `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `INSUFFICIENT_SCOPE`, `KEY_EXPIRED`, `POPUP_NOT_AVAILABLE`, `PRICE_CHANGED`, `PAYMENT_INCOMPLETE`, `PAYMENT_PENDING`, `PAYMENT_PROVIDER_INVALID`, `FULFILLMENT_NOT_ALLOWED`, `IDEMPOTENCY_CONFLICT`, `DINE_IN_DISABLED`, `PARTY_TOO_LARGE`, `TOO_EARLY`, `NO_TABLE`, `INVALID_PARTY_SIZE`, `INTERNAL_ERROR`, `PARTNER_AGREEMENT_REQUIRED`, `IDEMPOTENCY_KEY_REQUIRED`, `SLUG_TAKEN`, `SANDBOX_LIMIT`, `RETENTION`, `RESTAURANT_ARCHIVED`, `KEY_NOT_FOUND`, `KEY_IN_USE`, `WEBHOOK_NOT_FOUND`, `WEBHOOK_LIMIT`, `CHANGED`, `PDF_TOO_LARGE`, `PDF_INVALID`, `PDF_PASSWORD`, `PDF_TOO_MANY_PAGES`, `PDF_NO_TEXT`, `PDF_PARSE_TIMEOUT`, `MENU_IMPORT_EMPTY`, `FILE_UNSUPPORTED`, `IMAGE_HEIC`, `IMAGE_INVALID`, `IMAGE_DIMENSIONS`, `MENU_IMPORT_UNAVAILABLE`, `MENU_IMPORT_AI_LIMIT`, `MENU_IMPORT_TOO_BIG`, `MENU_IMPORT_NOT_FOUND`, `MENU_IMPORT_CLOSED`, `PARTNER_NOT_APPROVED`, `PARTNER_DATA_LOCKED`, `DOMAIN_NOT_FOUND`, `DOMAIN_TAKEN`, `DOMAIN_LIMIT`, `DOMAIN_MANAGED`, `DOMAIN_NOT_VERIFIED`, `OWNER_EXISTS`, `OWNER_EMAIL_NOT_ALLOWED`, `OWNER_INVITE_NOT_FOUND`, `SANDBOX_NOT_SUPPORTED`, `INVITE_SEND_FAILED`, `GO_LIVE_NOT_REQUIRED`, `GO_LIVE_NOT_READY`, `GO_LIVE_CHECKS_FAILED`, `GO_LIVE_ALREADY_REQUESTED`, `GO_LIVE_ALREADY_APPROVED`, `GO_LIVE_NOTHING_TO_WITHDRAW`, `DEMO_RESTAURANT`, `LEGAL_LOCKED`, `HOURS_OVERRIDE_NOT_FOUND`, `HOURS_OVERRIDE_LIMIT`, `DELIVERY_ZONE_NOT_FOUND`, `DELIVERY_ZONE_LIMIT`, `MENU_NOT_FOUND`, `MENU_IS_DEFAULT`, `CATEGORY_NOT_FOUND`, `PRODUCT_NOT_FOUND`, `ADDON_GROUP_NOT_FOUND`, `CATEGORY_NOT_EMPTY`, `CATEGORY_LIMIT`, `PRODUCT_LIMIT`, `ADDON_GROUP_LIMIT`, `KEY_ROLE_FORBIDDEN`, `WHITE_LABEL_REQUIRED`, `LOGO_NOT_ALLOWED`, `FILE_TOO_LARGE`, `UPLOAD_FAILED`, `PARTNER_ARCHIVED`, `PARTNER_NEWS_FORBIDDEN`, `NEWS_NOT_FOUND`, `NOT_DRAFT`, `NOT_PUBLISHED`, `NOT_SCHEDULED`, `NOT_CLAIMABLE`, `NO_RECIPIENTS`, `IMAGE_NOT_ALLOWED`, `SENDER_DOMAIN_ALREADY_SET`, `SENDER_DOMAIN_NOT_SET`, `DOMAIN_EXISTS`, `SENDER_DOMAIN_REJECTED`, `EMAIL_SERVICE_UNAVAILABLE`, `HUMAN_REQUIRED`, `LENGTH_REQUIRED`, `PAYLOAD_TOO_LARGE`
- `message` (string, required) — A human-readable explanation, often in Norwegian. For business rules it is usually suitable to show the customer.
- `requestId` (string, required) — The same value as the `X-Request-Id` response header. Search for it in the API log in LettBestilt Partner, and include it when you contact LettBestilt.
- `details` (any, optional) — Extra data for some codes, for example `issues` for `VALIDATION_FAILED`, the totals for `PRICE_CHANGED` or `requiredScope` for `INSUFFICIENT_SCOPE`.

### MenuImportCommitCategory

- `name` (string, required)
- `products` (list of MenuImportCommitProduct, required) — Categories without products are left out.
- `key` (string, optional) — Optional. Not used by commit, but validated (1–40 characters).

### PartnerMenuCreatedWarningsItems

- `path` (string, required) — Path in the request body, or an empty string for the whole menu.
- `message` (string, required)

### MenuImportCommitProduct

The price is either on `price` or on `variants` (the first variant becomes the default). Without variants, `price` must be set.

- `name` (string, required)
- `key` (string, optional) — Optional. Not used by commit, but validated (1–40 characters).
- `number` (integer, optional, nullable) — Optional (default `null`). 12 gives the name «12. Pepperoni».
- `description` (string, optional, nullable) — Optional (default `null`).
- `price` (integer, optional, nullable) — In øre. Optional (default `null`) when the product has variants.
- `variants` (list of MenuImportCommitVariant, optional) — Optional (default empty).
- `allergens` (list of enum, optional) — Optional (default empty).
  - Allowed values: `WHEAT`, `RYE`, `BARLEY`, `OATS`, `SPELT`, `KAMUT`, `CRUSTACEANS`, `EGGS`, `FISH`, `PEANUTS`, `SOYBEANS`, `MILK`, `NUTS`, `CELERY`, `MUSTARD`, `SESAME`, `SULPHITES`, `LUPIN`, `MOLLUSCS`

### MenuImportCommitVariant

- `name` (string, required)
- `price` (integer, required) — In øre.

## Examples

**Request**

```json
{
  "proposal": {
    "version": null,
    "categories": [
      {
        "name": "Pizza",
        "products": [
          {
            "name": "Margherita",
            "price": 16900,
            "allergens": [
              "WHEAT",
              "MILK"
            ]
          }
        ]
      }
    ]
  },
  "name": "Hovedmeny"
}
```

**Response**

```json
{
  "data": {
    "id": "string",
    "name": "string",
    "isActive": null,
    "isDefault": null,
    "categoryCount": 1,
    "productCount": 1,
    "variantCount": 1,
    "textCleaned": 1,
    "warnings": [
      {
        "path": "string",
        "message": "string"
      }
    ],
    "notice": "string"
  }
}
```

**SDK Code**

```python Partner_createPartnerMenu_example
import requests

url = "https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus"

payload = {
    "proposal": {
        "version": None,
        "categories": [
            {
                "name": "Pizza",
                "products": [
                    {
                        "name": "Margherita",
                        "price": 16900,
                        "allergens": ["WHEAT", "MILK"]
                    }
                ]
            }
        ]
    },
    "name": "Hovedmeny"
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript Partner_createPartnerMenu_example
const url = 'https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"proposal":{"version":null,"categories":[{"name":"Pizza","products":[{"name":"Margherita","price":16900,"allergens":["WHEAT","MILK"]}]}]},"name":"Hovedmeny"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go Partner_createPartnerMenu_example
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus"

	payload := strings.NewReader("{\n  \"proposal\": {\n    \"version\": null,\n    \"categories\": [\n      {\n        \"name\": \"Pizza\",\n        \"products\": [\n          {\n            \"name\": \"Margherita\",\n            \"price\": 16900,\n            \"allergens\": [\n              \"WHEAT\",\n              \"MILK\"\n            ]\n          }\n        ]\n      }\n    ]\n  },\n  \"name\": \"Hovedmeny\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby Partner_createPartnerMenu_example
require 'uri'
require 'net/http'

url = URI("https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"proposal\": {\n    \"version\": null,\n    \"categories\": [\n      {\n        \"name\": \"Pizza\",\n        \"products\": [\n          {\n            \"name\": \"Margherita\",\n            \"price\": 16900,\n            \"allergens\": [\n              \"WHEAT\",\n              \"MILK\"\n            ]\n          }\n        ]\n      }\n    ]\n  },\n  \"name\": \"Hovedmeny\"\n}"

response = http.request(request)
puts response.read_body
```

```java Partner_createPartnerMenu_example
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"proposal\": {\n    \"version\": null,\n    \"categories\": [\n      {\n        \"name\": \"Pizza\",\n        \"products\": [\n          {\n            \"name\": \"Margherita\",\n            \"price\": 16900,\n            \"allergens\": [\n              \"WHEAT\",\n              \"MILK\"\n            ]\n          }\n        ]\n      }\n    ]\n  },\n  \"name\": \"Hovedmeny\"\n}")
  .asString();
```

```php Partner_createPartnerMenu_example
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus', [
  'body' => '{
  "proposal": {
    "version": null,
    "categories": [
      {
        "name": "Pizza",
        "products": [
          {
            "name": "Margherita",
            "price": 16900,
            "allergens": [
              "WHEAT",
              "MILK"
            ]
          }
        ]
      }
    ]
  },
  "name": "Hovedmeny"
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp Partner_createPartnerMenu_example
using RestSharp;

var client = new RestClient("https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"proposal\": {\n    \"version\": null,\n    \"categories\": [\n      {\n        \"name\": \"Pizza\",\n        \"products\": [\n          {\n            \"name\": \"Margherita\",\n            \"price\": 16900,\n            \"allergens\": [\n              \"WHEAT\",\n              \"MILK\"\n            ]\n          }\n        ]\n      }\n    ]\n  },\n  \"name\": \"Hovedmeny\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift Partner_createPartnerMenu_example
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "proposal": [
    "version": ,
    "categories": [
      [
        "name": "Pizza",
        "products": [
          [
            "name": "Margherita",
            "price": 16900,
            "allergens": ["WHEAT", "MILK"]
          ]
        ]
      ]
    ]
  ],
  "name": "Hovedmeny"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://www.lettbestilt.no/api/v1/partner/restaurants/slug/menus")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```