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

# Add a product to a category

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

**Partner key + restaurant key.** A new product with sizes and allergens (codes from the catalogue), placed last in the category: `name`, `description`, `basePrice` (needed without sizes, otherwise the default size's price), up to 8 `variants` (at most one `isDefault`), `allergens`, `isVegan`, `isVegetarian`, `isPopular`, `allowedFulfillment`, `isActive` and `isAvailable`. Prices in øre. Combo menus, images from the image library and upsell are created in the restaurant dashboard. At most 800 active and 2000 products in all (also inactive) per restaurant. A price above 0 and below 500 øre (5 kr) is saved, but gives a warning in `warnings` (`[{ path, message }]`): it looks like kroner (149 for 149 kr). Names and descriptions are trimmed and cleaned (links, email addresses, HTML, `javascript:` and invisible direction marks are removed); the count is in `textCleaned`, and a name that ends up empty gives 422. `GET /api/v1/menu` is CDN-cached (`s-maxage=30, stale-while-revalidate=300`, at most 330 seconds), so the website can show the old menu for up to six minutes; the response has `notice` about it.

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

## 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`.
- `categoryId` (string, required) — The category's `id`.

### Body (application/json)

This endpoint expects a PartnerProductCreate.

- `name` (string, required)
- `description` (string, optional, nullable)
- `basePrice` (integer, optional) — In øre. Required without `variants`; with sizes it defaults to the default size's price.
- `variants` (list of PartnerProductCreateVariantsItems, optional) — The sizes, at most 8, in order (unique names, case-insensitive). Replaced as a set when changing. Send the ids from GET to keep the rows: a row with `id` is updated in place, a row without `id` is new (new id), and rows that are left out are deleted. New ids can make orders fail for up to six minutes, because the website shows the cached menu.
- `allergens` (list of enum, optional) — Allergen codes from the catalogue (as in the menu proposal). Replaced as a set when changing.
  - Allowed values: `WHEAT`, `RYE`, `BARLEY`, `OATS`, `SPELT`, `KAMUT`, `CRUSTACEANS`, `EGGS`, `FISH`, `PEANUTS`, `SOYBEANS`, `MILK`, `NUTS`, `CELERY`, `MUSTARD`, `SESAME`, `SULPHITES`, `LUPIN`, `MOLLUSCS`
- `isVegan` (boolean, optional)
- `isVegetarian` (boolean, optional)
- `isPopular` (boolean, optional)
- `allowedFulfillment` (enum, optional) — `PICKUP` = pickup only, `DELIVERY` = delivery only, null = both. Enforced by `POST /api/v1/orders` (422 `FULFILLMENT_NOT_ALLOWED`).
  - Allowed values: `PICKUP`, `DELIVERY`
- `isActive` (boolean, optional) — false = hidden from the menu. Default true.
- `isAvailable` (boolean, optional) — false = sold out (shown, but cannot be ordered). Default true.

## Response

### 201

The product is created.

- `data` (ApiV1PartnerRestaurantsSlugCategoriesCategoryIdProductsPostResponsesContentApplicationJsonSchemaData, 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 slug does not exist, belongs to another partner or does not match the restaurant key), or `CATEGORY_NOT_FOUND` (the category does not exist or belongs to another restaurant).

- `error` (ErrorBody, required)

### 409 Conflict Error

`PRODUCT_LIMIT`, `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` (path and message per field): a missing price without sizes, a price over 100 000 kr, an unknown allergen code, more than one default size, too many sizes, an `id` on a new size, or an unknown field.

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

### PartnerProductCreateVariantsItems

- `name` (string, required)
- `price` (integer, required) — In øre (at most 100 000 kr).
- `id` (string, optional) — The size's id from GET (only when changing). Without an id the size is new.
- `isDefault` (boolean, optional) — At most one. With none, the first size is the default.
- `addonTierKey` (string, optional, nullable) — Size key for addon prices (`tiers[].key` in an addon group). Left out when changing = inherited from the size with the same name.

### ApiV1PartnerRestaurantsSlugCategoriesCategoryIdProductsPostResponsesContentApplicationJsonSchemaData

- `id` (string, required)
- `version` (string, required) — Hash of the row as it is now (16 hex characters). Send it as `expectedVersion` in PATCH.
- `categoryId` (string, required)
- `name` (string, required)
- `description` (string, required, nullable)
- `basePrice` (integer, required) — In øre.
- `isActive` (boolean, required)
- `isAvailable` (boolean, required)
- `isPopular` (boolean, required)
- `isVegan` (boolean, required)
- `isVegetarian` (boolean, required)
- `isCombo` (boolean, required) — Combo menus are created in the restaurant dashboard.
- `allowedFulfillment` (enum, required) — `PICKUP` = pickup only, `DELIVERY` = delivery only, null = both. Enforced by `POST /api/v1/orders` (422 `FULFILLMENT_NOT_ALLOWED`).
  - Allowed values: `PICKUP`, `DELIVERY`
- `imageUrl` (string, required, nullable)
- `imageIsStock` (boolean, required) — true = the image is an illustration from the image library, not a photo of the dish. The customer website MUST then show «Illustrasjonsfoto» (illustration photo), as required by the Norwegian Marketing Control Act.
- `sortOrder` (integer, required)
- `variants` (list of PartnerProductVariant, required)
- `allergens` (list of enum, required)
  - Allowed values: `WHEAT`, `RYE`, `BARLEY`, `OATS`, `SPELT`, `KAMUT`, `CRUSTACEANS`, `EGGS`, `FISH`, `PEANUTS`, `SOYBEANS`, `MILK`, `NUTS`, `CELERY`, `MUSTARD`, `SESAME`, `SULPHITES`, `LUPIN`, `MOLLUSCS`
- `warnings` (list of ApiV1PartnerRestaurantsSlugCategoriesCategoryIdProductsPostResponsesContentApplicationJsonSchemaDataWarningsItems, required) — Prices above 0 and below 500 øre (they look like kroner). Empty when there are none.
- `textCleaned` (integer, required) — Number of text fields that were cleaned.
- `notice` (string, required) — About the CDN cache on `GET /api/v1/menu`.

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

### PartnerProductVariant

- `id` (string, required)
- `name` (string, required)
- `price` (integer, required) — In øre.
- `isDefault` (boolean, required)
- `sortOrder` (integer, required)
- `addonTierKey` (string, required, nullable)

### ApiV1PartnerRestaurantsSlugCategoriesCategoryIdProductsPostResponsesContentApplicationJsonSchemaDataWarningsItems

- `path` (string, required) — Path in the request body, for example `basePrice`, `variants.1.price` or `products.2.basePrice`.
- `message` (string, required)

## Examples

**Request**

```json
{
  "name": "Pepperoni",
  "variants": [
    {
      "name": "Liten",
      "price": 15900,
      "isDefault": true
    },
    {
      "name": "Stor",
      "price": 21900
    }
  ],
  "allergens": [
    "WHEAT",
    "MILK"
  ]
}
```

**Response**

```json
{
  "data": {
    "id": "string",
    "version": "string",
    "categoryId": "string",
    "name": "string",
    "description": "string",
    "basePrice": 1,
    "isActive": true,
    "isAvailable": true,
    "isPopular": true,
    "isVegan": true,
    "isVegetarian": true,
    "isCombo": true,
    "allowedFulfillment": "PICKUP",
    "imageUrl": "string",
    "imageIsStock": true,
    "sortOrder": 1,
    "variants": [
      {
        "id": "string",
        "name": "string",
        "price": 1,
        "isDefault": true,
        "sortOrder": 1,
        "addonTierKey": "string"
      }
    ],
    "allergens": [
      "WHEAT"
    ],
    "warnings": [
      {
        "path": "string",
        "message": "string"
      }
    ],
    "textCleaned": 1,
    "notice": "string"
  }
}
```

**SDK Code**

```python Partner_createPartnerProduct_example
import requests

url = "https://www.lettbestilt.no/api/v1/partner/restaurants/slug/categories/categoryId/products"

payload = {
    "name": "Pepperoni",
    "variants": [
        {
            "name": "Liten",
            "price": 15900,
            "isDefault": True
        },
        {
            "name": "Stor",
            "price": 21900
        }
    ],
    "allergens": ["WHEAT", "MILK"]
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

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

print(response.json())
```

```javascript Partner_createPartnerProduct_example
const url = 'https://www.lettbestilt.no/api/v1/partner/restaurants/slug/categories/categoryId/products';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"name":"Pepperoni","variants":[{"name":"Liten","price":15900,"isDefault":true},{"name":"Stor","price":21900}],"allergens":["WHEAT","MILK"]}'
};

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

```go Partner_createPartnerProduct_example
package main

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

func main() {

	url := "https://www.lettbestilt.no/api/v1/partner/restaurants/slug/categories/categoryId/products"

	payload := strings.NewReader("{\n  \"name\": \"Pepperoni\",\n  \"variants\": [\n    {\n      \"name\": \"Liten\",\n      \"price\": 15900,\n      \"isDefault\": true\n    },\n    {\n      \"name\": \"Stor\",\n      \"price\": 21900\n    }\n  ],\n  \"allergens\": [\n    \"WHEAT\",\n    \"MILK\"\n  ]\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_createPartnerProduct_example
require 'uri'
require 'net/http'

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

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  \"name\": \"Pepperoni\",\n  \"variants\": [\n    {\n      \"name\": \"Liten\",\n      \"price\": 15900,\n      \"isDefault\": true\n    },\n    {\n      \"name\": \"Stor\",\n      \"price\": 21900\n    }\n  ],\n  \"allergens\": [\n    \"WHEAT\",\n    \"MILK\"\n  ]\n}"

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

```java Partner_createPartnerProduct_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/categories/categoryId/products")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"name\": \"Pepperoni\",\n  \"variants\": [\n    {\n      \"name\": \"Liten\",\n      \"price\": 15900,\n      \"isDefault\": true\n    },\n    {\n      \"name\": \"Stor\",\n      \"price\": 21900\n    }\n  ],\n  \"allergens\": [\n    \"WHEAT\",\n    \"MILK\"\n  ]\n}")
  .asString();
```

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

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://www.lettbestilt.no/api/v1/partner/restaurants/slug/categories/categoryId/products', [
  'body' => '{
  "name": "Pepperoni",
  "variants": [
    {
      "name": "Liten",
      "price": 15900,
      "isDefault": true
    },
    {
      "name": "Stor",
      "price": 21900
    }
  ],
  "allergens": [
    "WHEAT",
    "MILK"
  ]
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

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

```csharp Partner_createPartnerProduct_example
using RestSharp;

var client = new RestClient("https://www.lettbestilt.no/api/v1/partner/restaurants/slug/categories/categoryId/products");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"name\": \"Pepperoni\",\n  \"variants\": [\n    {\n      \"name\": \"Liten\",\n      \"price\": 15900,\n      \"isDefault\": true\n    },\n    {\n      \"name\": \"Stor\",\n      \"price\": 21900\n    }\n  ],\n  \"allergens\": [\n    \"WHEAT\",\n    \"MILK\"\n  ]\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift Partner_createPartnerProduct_example
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "name": "Pepperoni",
  "variants": [
    [
      "name": "Liten",
      "price": 15900,
      "isDefault": true
    ],
    [
      "name": "Stor",
      "price": 21900
    ]
  ],
  "allergens": ["WHEAT", "MILK"]
] 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/categories/categoryId/products")! 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()
```