openapi: 3.0.3 info: title: Shoporama REST API description: | Shoporama REST interface for integrating with warehouse and accounting systems. Uses HTTP protocol over SSL/TLS with JSON data format. Authorization works at shop level with tokens allowing 1,000 daily calls by default. But can be adjusted in the Shoporama admin interface. Authorization header format: `Authorization: ` ## Token Scopes Each token is issued with a scope — `read`, `write`, or `all`: - `read` — only HTTP `GET` is permitted - `write` — only HTTP `POST`, `PUT`, `DELETE` are permitted - `all` — any method When a token attempts a method outside its scope, the API returns **`403 Forbidden`** with a JSON body like: ```json { "error": "insufficient_scope", "message": "This token has 'read' access and cannot perform POST requests. Required scope: 'write'. ...", "method": "POST", "path": "product", "current_scope": "read", "required_scope": "write" } ``` `401 Unauthorized` is only returned when the token itself is missing or invalid. ## Request bodies `POST` and `PUT` require a JSON body. A missing body returns **`400 Bad Request`** with `{"error": {"msg": "Missing body"}}`, and a body that is not valid JSON returns **`400 Bad Request`** with `{"error": {"msg": "Parse error in JSON"}}`. Both used to be returned with HTTP 200, so always check the status code — not just the presence of a body. ## Resource ids Shoporama ids are positive integers. A path whose id segment is `0` — for example `/product/0` or `/product/0/copy` — returns **`404 Not Found`** with `{"error": "invalid_id", "message": ..., "path": ...}`. This matters because `0` is what an unset variable casts to in most languages, so it usually means the id was never looked up rather than that record `0` was wanted. `GET /product/0` previously fell through to the collection and returned the **entire product list** with HTTP 200, which was easy to mistake for a successful single-record read. Endpoints whose path segments are names rather than numbers (`theme-file`, `theme-settings`, `library-folder`, `theme-text`) are unaffected — `0` can be a legitimate name there. ## Rate limiting Each token has a daily call quota (`1,000` by default, adjustable per token in the admin interface under API Access). The quota is counted per token, not per shop, and it resets at midnight server time. **Every** response — successful or not — carries the current state of the quota, so a client can slow down *before* it hits the ceiling: | Header | Description | | --- | --- | | `X-RateLimit-Limit` | The token's daily quota | | `X-RateLimit-Remaining` | Calls left in the current window (never negative; the current request is already counted) | | `X-RateLimit-Reset` | Unix timestamp of the next reset | When the quota is used up the API returns **`429 Too Many Requests`** with `Content-Type: application/json`, a `Retry-After` header (seconds, with at least 60 seconds of margin so a retry cannot race the reset job), and a body like: ```json { "error": { "msg": "Rate limit exceeded. The limit is 1000 requests per day per token and resets at midnight server time.", "limit": 1000, "remaining": 0, "reset": 1785110400, "retry_after": 21600 } } ``` Rejected calls still count towards the quota, so a client that keeps hammering after a `429` does not shorten its own wait. Back off using `Retry-After`. All four headers are listed in `Access-Control-Expose-Headers`, so browser-based clients can read them too. Endpoint: `https://www.shoporama.dk/REST` ## Specification The spec is served as YAML at `/open-api` (and `/open-api.yaml`), and rendered by Swagger UI at `/swagger`. There is currently no JSON variant; any other path under `/swagger/` or `/open-api*` returns `404`. Lists contain paging information with offset, limit, count, and total parameters. ## Image Upload Support Products and categories support image uploads via two methods: **Base64 Upload**: Send images as base64-encoded strings in JSON payload - Use the `data` field with base64-encoded image data - Supports JPEG, PNG, GIF formats - Recommended for smaller images due to JSON size limitations **URL Import**: Provide image URLs for asynchronous import - Use the `url` field with publicly accessible HTTPS URLs - Images are fetched and processed asynchronously - May not appear immediately in API responses **Image Management**: - Update existing images using `image_id` — the image must belong to the product you are updating, otherwise the call fails with 400 - Set display order with `weight` (lower numbers first) - Add alt text with `description` - Remove individual images with `remove: true` - Remove all images with `remove_images: true` All uploaded images are automatically processed to PNG format at 1000x1000px resolution with "fit" scaling to maintain aspect ratio. ## Background Processing For improved performance with large datasets, certain operations support background processing: **Stock Updates**: Use the `background=true` parameter when updating stock for many products simultaneously. This prevents timeouts and improves reliability for bulk operations. **Image Processing**: Images uploaded via URL are processed asynchronously and may not appear immediately in API responses. **File Uploads**: PDF and general file uploads via URL are processed in the background. ## Bulk Operations Several endpoints support bulk operations by accepting arrays instead of single objects: **Products**: Create multiple products in a single request using an array of product objects. Use `noreturn=true` to improve performance by skipping response data. **Stock**: Update stock for multiple products simultaneously using SKU (own_id) or GTIN identifiers. Supports multi-location stock with location tags. Recommended to use `background=true` for large batches. ## Auto-Creation Features The API supports automatic creation of related entities: **Suppliers and Brands**: Use `supplier_name` or `brand_name` in product creation to automatically create these entities if they don't exist. **Attributes**: Product attributes and values can be automatically created during product creation if they don't exist. ## Important Notes **Consistent JSON Responses**: All API responses, including errors, are returned in JSON format with appropriate HTTP status codes. **Pagination Limits**: Large limit values are silently capped to default values. Products endpoint maximum is 250 items per request. **Brand Endpoint**: Returns a direct array instead of a paginated object structure like other list endpoints. **Extension Data**: Product responses include comprehensive extension_data arrays with additional metadata and feature flags. ## Theme File Management The `/theme-file` endpoint provides programmatic access to theme template files. Unlike other endpoints, theme file operations use **raw file content** in the request/response body instead of JSON. **Token Requirement**: The API token must have `allow_theme_files` enabled. This is a separate permission that must be explicitly enabled per token in the admin panel under API Access. **File Operations**: - **GET** returns raw file content with appropriate `Content-Type` header and metadata in `X-File-*` headers - **POST** creates new files (raw content in body, returns `409 Conflict` if file already exists) - **PUT** is an upsert operation (creates if file doesn't exist, updates if it does) - **DELETE** removes files **Content-Type**: POST and PUT requests accept any content type (the body is treated as raw file content). The `Content-Type: application/json` requirement that applies to other endpoints is waived for theme-file operations. **File Size Limit**: Maximum 2MB per file for POST/PUT operations. **File Permissions**: Files created via SFTP may not be writable by the web server. The file listing includes an `is_writable` flag, and GET responses include an `X-File-Writable` header. Operations on non-writable files return `403 Forbidden`. version: 1.0.0 contact: name: Shoporama Support email: support@shoporama.dk url: https://www.shoporama.dk servers: - url: https://localshoporama.dk/REST description: Local Shoporama REST API security: - ShoporamaAuth: [] components: securitySchemes: ShoporamaAuth: type: apiKey in: header name: Authorization description: "Use format: " AppAuth: type: apiKey in: header name: Authorization description: "App-level authentication token for multi-webshop operations" headers: X-RateLimit-Limit: description: The token's daily call quota. Returned on every response. schema: type: integer example: 1000 X-RateLimit-Remaining: description: >- Calls left in the current window, never negative. The request that returned this header is already counted. Returned on every response. schema: type: integer example: 998 X-RateLimit-Reset: description: Unix timestamp of the next quota reset (midnight server time). Returned on every response. schema: type: integer example: 1785110400 Retry-After: description: >- Seconds to wait before retrying, with at least 60 seconds of margin so a retry cannot race the reset job. Returned on 429 responses. schema: type: integer example: 21600 responses: TooManyRequests: description: >- The token's daily quota is used up. Back off using Retry-After; rejected calls still count towards the quota. headers: Retry-After: $ref: '#/components/headers/Retry-After' X-RateLimit-Limit: $ref: '#/components/headers/X-RateLimit-Limit' X-RateLimit-Remaining: $ref: '#/components/headers/X-RateLimit-Remaining' X-RateLimit-Reset: $ref: '#/components/headers/X-RateLimit-Reset' content: application/json: schema: $ref: '#/components/schemas/RateLimitError' schemas: ThemeFile: type: object properties: path: type: string description: Relative file path within the theme directory example: "templates/front.html" size: type: integer description: File size in bytes example: 1227 modified: type: integer description: Last modification time as Unix timestamp example: 1709942400 is_writable: type: boolean description: Whether the file is writable by the web server. Files created via SFTP may not be writable. example: true NewsletterList: type: object properties: newsletter_list_id: type: integer name: type: string description: type: string is_default: type: boolean subscriber_count: type: integer created: type: string format: date-time NewsletterSubscriber: type: object properties: newsletter_subscriber_id: type: integer email: type: string firstname: type: string lastname: type: string status: type: string enum: [pending, active, unsubscribed, bounced] source: type: string lists: type: array description: IDs of the lists the subscriber is on items: type: integer consent_time: type: string format: date-time created: type: string format: date-time NewsletterCampaign: type: object properties: newsletter_campaign_id: type: integer name: type: string subject: type: string status: type: string enum: [draft, scheduled, sending, ab_testing, sent, cancelled] send_at: type: string format: date-time nullable: true sent_at: type: string format: date-time nullable: true total_recipients: type: integer sent_count: type: integer list_ids: type: array items: type: integer is_ab_test: type: boolean is_online: type: boolean description: Whether the campaign is published as a public, shareable web page (/nyhedsbrev/{slug}). public_url: type: string nullable: true description: Full public, shareable URL of the newsletter web version. Only set when is_online is true; otherwise null. created: type: string format: date-time NewsletterCampaignBlock: type: object description: A content block ("modul") inside a draft newsletter campaign. There is no send capability on this resource. properties: newsletter_campaign_block_id: type: integer newsletter_campaign_id: type: integer block_type: type: string enum: [header, text, products, image, image_row, button, divider, html] weight: type: integer description: Sort order, ascending (0 = top). custom_title: type: string nullable: true description: Optional internal label shown above the block in the editor. data: type: object additionalProperties: true description: Block-type-specific settings. The accepted keys depend on block_type (e.g. products = source_type/source_id/max_products/...). ImageUpload: type: object description: Object for uploading or managing product/category images properties: image_id: type: integer description: >- ID of existing image to update (required for updates). The image MUST already be attached to the product or category you are updating — an image_id belonging to another object is rejected with 400 `image_id N is not attached to this product` (or `... category`), even when it belongs to the same shop. Read the object first and use the image_id values it returns; never construct or guess them. example: 9728 weight: type: integer description: Display order/weight of image (lower numbers show first) minimum: 0 example: 1 description: type: string description: Alt text/description for the image example: "Main product image" data: type: string format: base64 description: Base64 encoded image data (for direct upload) example: "/9j/4AAQSkZJRgABAQAASABIAAD..." url: type: string format: uri description: URL to fetch image from (alternative to data field - asynchronous import) example: "https://example.com/image.jpg" variant_name: type: string nullable: true description: >- Products only. Associates this image with a product variant value, matched case-insensitively against the product's variant attribute values (e.g. "Rød"). Symmetric with the `variant_name` returned by GET. Send an empty string or null to clear the association. An unknown value returns 400 with the list of valid variants. Only applied for images identified by `image_id` or uploaded via `data` in the same request — it is ignored for asynchronous `url` imports (no image id exists yet; set it in a follow-up call using the resulting `image_id`). example: "Rød" rotate: type: integer nullable: true enum: [0, 90, 180, 270] description: >- Non-destructive rotation in degrees clockwise. The stored original is never modified; the rotation is applied when the image variants are generated, so it can be changed or removed at any time. Send 0 or null to remove it. example: 90 crop: type: array nullable: true minItems: 4 maxItems: 4 items: type: number format: float minimum: 0 maximum: 1 description: >- Non-destructive crop as [x, y, width, height], relative to the image size (0-1) and expressed in the ROTATED image, so `rotate` is applied first. Send null to remove the crop. example: [0.1, 0.0, 0.8, 0.6] focal: type: array nullable: true minItems: 2 maxItems: 2 items: type: number format: float minimum: 0 maximum: 1 description: >- Focal point as [x, y] relative to the cropped image (0-1). Used when a theme crops the image to a fixed format, so the subject stays in frame instead of the crop always being centred. Send null to remove it. example: [0.42, 0.31] remove: type: boolean description: Set to true to delete this specific image example: false oneOf: - required: [data] - required: [url] - required: [image_id] ImageResponse: type: object description: Image object returned in API responses properties: image_id: type: string description: Unique image ID example: "9728" weight: type: string description: Display order/weight of image example: "1" description: type: string description: Alt text/description for the image example: "Main product image" url: type: string format: uri description: Full URL to the processed image (PNG format, 1000x1000px) example: "http://example.com/cache/9/7/2/8/main-product-image-fit-1000x1000x75.png" variant_name: type: string nullable: true description: Associated variant name (products only) example: null rotate: type: integer nullable: true description: Non-destructive rotation in degrees clockwise (90/180/270), or null example: null crop: type: array nullable: true items: type: number format: float description: Non-destructive crop as [x, y, width, height] relative to the rotated image, or null example: null focal: type: array nullable: true items: type: number format: float description: Focal point as [x, y] relative to the cropped image, or null example: null PagingInfo: type: object properties: offset: type: integer description: Current offset in dataset limit: type: integer description: Maximum number of items returned count: type: integer description: Number of items in current response total: type: integer description: Total number of items available Product: type: object properties: product_id: type: integer description: Unique product identifier own_id: type: string description: Own product ID sec_own_id: type: string nullable: true description: Secondary own product ID gtin: type: string nullable: true description: Global Trade Item Number mpn: type: string nullable: true description: Manufacturer Part Number profile_id: type: integer description: Product profile ID supplier_id: type: integer nullable: true description: Supplier ID manufacturer_id: type: integer nullable: true description: Manufacturer ID safety_profile_id: type: integer nullable: true description: Safety profile ID brand_id: type: integer nullable: true description: Brand ID main_category_id: type: integer nullable: true description: Main category ID created: type: string format: date-time description: Creation timestamp is_online: type: integer description: Online status (1 = online, 0 = offline) online_since: type: string format: date-time description: Online since timestamp no_index: type: integer description: No index flag no_shopping: type: integer description: No shopping flag removed_on: type: string format: date-time nullable: true description: Removal timestamp rewrite_url: type: string description: URL rewrite path name: type: string description: Product name list_description: type: string description: List description description: type: string description: Full product description vat: type: number nullable: true description: VAT rate price: type: number description: Product price sale_price: type: number nullable: true description: Sale price sale_starts: type: string format: date nullable: true description: Start-dato for tilbudspris (YYYY-MM-DD). Hvis datoen er i fremtiden, aktiveres tilbudsprisen automatisk natten til den dato af en cronjob. sale_ends: type: string format: date nullable: true description: Slut-dato for tilbudspris (YYYY-MM-DD). Tilbudsprisen fjernes automatisk natten efter denne dato af en cronjob. sale_limit: type: integer nullable: true minimum: 0 description: >- Antal stk. der er TILBAGE på tilbud. null = ingen antalsbegrænsning. Tallet tæller ned for hvert stk. der sælges (på tværs af varianter) og lægges tilbage igen når en ordre annulleres eller returneres. Køber en kunde flere end der er tilbage, får kun de resterende stk. tilbudsprisen; resten koster den vejledende pris. Når tallet rammer 0, fjernes tilbudsprisen, variant-tilbudspriserne og tilbudsperioden helt, og feltet sættes til null. Sendes 0 ind, afsluttes tilbuddet med det samme. purchase_price: type: number nullable: true description: Purchase price location: type: string nullable: true description: Physical location bulk_discount_over: type: number nullable: true description: Bulk discount threshold bulk_discount: type: number nullable: true description: Bulk discount percentage discount_intervals: type: object additionalProperties: oneOf: - type: number description: Price for this quantity (e.g. 99.00) - type: array items: type: object properties: attribute_value_id: type: integer price: type: string description: | Volume discount prices for the base product (attribute_value_id = 0). Keys are quantity thresholds, values are prices as floats. GET response example: {"3": 99.00, "6": 89.00, "9": 79.00} POST/PUT format (simple): {"discount_intervals": {"3": "99.00", "6": "89.00", "9": "79.00"}} POST/PUT format (with variants): {"discount_intervals": {"3": [{"attribute_value_id": 0, "price": "99.00"}, {"attribute_value_id": 123, "price": "95.00"}]}} IMPORTANT: Both quantity AND price must be provided. Sending only quantities without prices will result in null values. variant_discount_intervals: type: object additionalProperties: type: object additionalProperties: type: number description: | Volume discount prices for specific variants. Keys are attribute_value_id, values are objects with quantity thresholds and prices. GET response example: {"22269": {"5": 99.00, "10": 89.00}, "22270": {"5": 95.00, "10": 85.00}} PUT request example (same format as GET): {"variant_discount_intervals": {"22269": {"5": 99.00, "10": 89.00}, "22270": {"5": 95.00, "10": 85.00}}} campaigns: type: array nullable: true description: | Active campaigns this product is part of (read-only). Informational only — the actual cart-dependent discount is applied at checkout / basket-quote, not here. Useful for showing a "this product is on campaign" hint (e.g. in POS when scanning a product). items: type: object properties: name: type: string price_model: type: string enum: [threshold, bundle, fixed, cheapest_free, percent] description: Campaign pricing model min_product_count: type: integer description: Minimum matching products required to trigger the campaign price: type: number format: float description: Campaign price (used by fixed/threshold models) percent: type: integer description: Discount percent (used by percent/bundle models) created: type: string format: date-time expires: type: string format: date-time nullable: true description: Campaign end time, or null if it never expires shipping: type: number nullable: true description: Shipping cost shipping_weight: type: number nullable: true description: >- Physical shipping weight in GRAMS (e.g. 200 = 200 g). Used for weight-based shipping prices and by the carrier integrations. never_free_shipping: type: integer description: Never free shipping flag delivery_time: type: string description: Delivery time in stock delivery_time_not_in_stock: type: string description: Delivery time when not in stock allow_negative_stock: type: integer description: Allow negative stock flag tariff_code: type: string description: Tariff code package: type: string nullable: true description: Package information auto_offline: type: integer description: Auto offline flag weight: type: integer description: >- Manual sort order (lower sorts first), as set by drag-and-drop in the admin product list and used by order_by=weight. This is NOT a physical weight — for that see shipping_weight. meta: type: string description: Meta keywords meta_title: type: string description: Meta title meta_description: type: string description: Meta description search: type: string description: Search terms no_internal_search: type: integer description: No internal search flag canonical_id: type: integer nullable: true description: Canonical product ID mailing_list_ids: type: string nullable: true description: Mailing list IDs bundle: type: array items: type: object description: Bundle products requires_age_verification: type: integer nullable: true description: >- Minimum age (15, 16 or 18) the customer must verify at payment. Sent to payment gateways that support age verification (ePay). null = no requirement. last_modified: type: string format: date-time description: Last modification timestamp url: type: string description: Product URL min_stock_order: type: integer nullable: true description: Minimum stock order min_stock_value: type: number description: Minimum stock value country_of_origin: type: string nullable: true description: Country of origin avg_rating: type: number nullable: true description: Average rating google_category: type: string nullable: true description: Google category primary_search_phrase: type: string nullable: true description: Primary SEO keyword (single phrase) — main on-page focus keyword used by the SEO feedback widget in admin. open_graph_title: type: string nullable: true description: Open Graph title for social media open_graph_description: type: string nullable: true description: Open Graph description for social media open_graph_image: type: integer nullable: true description: >- image_id of the product image used when the product is shared on social media. Always one of this product's own images — see images[].image_id. null means no image is marked, and the theme falls back to the first product image. shopping_feed_image: type: integer nullable: true description: >- image_id of the image forced to be the primary image (g:image_link) in the Google Shopping feed. Always one of this product's own images. null means the feed follows the normal rules: on products with variants each variant uses its own image, and on products without variants the first image is primary. google_shopping_title: type: string nullable: true description: Google Shopping optimized title no_partner_ads: type: integer description: No partner ads flag withdrawal_exempt_reason: type: string nullable: true enum: [made_to_order, perishable, sealed_hygiene, inseparable, sealed_media, newspapers, dated_service, digital_content, price_fluctuation, other] description: >- Legal exemption from the 14-day right of withdrawal (Danish Consumer Agreements Act §18(2) / EU Directive 2011/83 art. 16). null = normal right of withdrawal. made_to_order = made to the consumer's specifications/personalised; perishable = perishable goods; sealed_hygiene = sealed goods unsuitable for return once unsealed for health/hygiene reasons; inseparable = inseparably mixed with other goods after delivery; sealed_media = sealed audio/video recordings or software unsealed after delivery; newspapers = newspapers, periodicals or magazines (not subscriptions); dated_service = accommodation, transport, catering or leisure services for a specific date or period; digital_content = digital content supplied with the consumer's prior consent and acknowledgement; price_fluctuation = price depends on fluctuations in the financial market; other = another exemption (explain in withdrawal_exempt_note). withdrawal_exempt_note: type: string nullable: true description: >- Optional free-text shown to the customer explaining the exemption. Overrides the standard explanation. Only stored when withdrawal_exempt_reason is set. note: type: string nullable: true description: >- Internal note ("Intern note") shown only in admin — never to shoppers. Free-text; writing replaces the existing note. Useful for reorder reminders or supplier notes, e.g. "10 stk. bestilt BS1 d.14/7". product_deposit_id: type: integer nullable: true description: >- Deposit type id (pant/depositum, e.g. Danish bottle deposit "Pant A"). Deposit types are managed in admin under Indstillinger → Pant & depositum. The deposit is shown separately from the product price and added on top at checkout as its own order line (order_product rows with product_deposit_id set and product_id = 0). Pass 0/null on update to remove the deposit from the product. product_deposit_count: type: integer nullable: true description: >- Number of deposit units per sales unit, e.g. 24 for a 24-can case. null = 1. Can be overridden per variant via stock_settings.deposit_count. deposit: type: object nullable: true readOnly: true description: >- Resolved deposit info for the product (null when the product has no active deposit type). Enriched on GET so clients (e.g. POS) do not need to look up the type themselves. properties: product_deposit_id: type: integer name: type: string description: Deposit type name shown to customers, e.g. "Pant A". amount: type: number description: Deposit amount per unit, ALWAYS gross (incl. VAT) — Danish statutory rates are defined incl. VAT. own_id: type: string description: SKU used for bookkeeping exports (falls back to "DEPOSIT-{id}"). count: type: integer description: Deposit units per sales unit (product-level; variants may override). unit_amount: type: number nullable: true description: >- Unit-price base quantity (#663): number of base units in the package, e.g. 24 for a 24-can case, or 0.5 for a 500 g bag. Combined with unit_label it drives the "price per unit" line (e.g. "3,29 kr/stk") shown under the price on product cards and the product page in the built-in themes. null = no unit price shown. unit_label: type: string nullable: true description: >- Unit-price label (#663): the base unit as free text, e.g. "stk", "L", "kg", "ml", "g". Defaults to "stk" in the storefront when empty. price_per_unit: type: number nullable: true readOnly: true description: >- Computed price per unit (#663): getRealPrice() / unit_amount, null when unit_amount is not set. Read-only. categories: type: array items: type: object description: Product categories related: type: array items: type: object description: Related products similar: type: array items: type: object description: Similar products bundle_data: type: array items: type: object description: Bundle data html_fields: type: array items: type: object description: HTML fields attributes: type: array description: >- The product's structured (non-variant) attribute values, one entry per attribute defined on its profile. Write them back via the `attributes` field on a product create/update (see ProductInput). items: type: object properties: attribute_id: type: integer description: Id of the profile attribute. name: type: string description: Human-readable attribute name (e.g. "Color"). tag: type: string nullable: true description: Attribute tag, if one is set on the profile attribute. data_type: type: string description: "Attribute type: valuelist, string, integer, boolean, html or file." input_mode: type: string enum: [shop, customer] description: >- `shop`: the shop fills a per-product value (spec data). `customer`: the customer fills the value on the product page (personalization) — the value lives on the ORDER LINE (see the order's products[].attributes), not on the product. is_required: type: integer description: Customer input only — 1 = must be filled before add-to-basket. max_length: type: integer nullable: true description: Customer input text fields only — max characters. placeholder: type: string nullable: true description: Customer input only — placeholder text. value: type: string nullable: true description: >- The current value as text (resolved label for valuelist attributes, raw text for free-text attributes). null if unset, and always null for `file` attributes (the file is per order line and not accessible via REST). attribute_value_id: type: integer nullable: true description: >- For valuelist attributes, the id of the selected value (use it to write the value back via `value`). null for free-text attributes or when unset. stock_settings: type: array items: type: object description: Stock settings product_labels: type: array items: type: object description: Product labels images: type: array items: $ref: '#/components/schemas/ImageResponse' description: Array of product images with full URLs and metadata pdf_files: type: array items: type: object description: PDF files extension_data: type: array items: $ref: '#/components/schemas/ExtensionData' description: Extension data for various product features environment: $ref: '#/components/schemas/ProductEnvironment' ProductEnvironment: type: object description: >- Miljødata til EPR-indberetning (emballage, elektronik/WEEE og batterier). Returneres kun når produktet har miljødata. Ved skrivning er det partial update: kun medsendte felter ændres, og en tom streng rydder et felt. Sæt enabled=false for at slette al miljødata for produktet. properties: enabled: type: boolean description: Aktiver miljødata. false sletter rækken og al miljødata. packaging_paper: type: integer nullable: true description: Papir/pap i gram pr. enhed packaging_plastic: type: integer nullable: true description: Plast i gram pr. enhed packaging_metal: type: integer nullable: true description: Metal i gram pr. enhed packaging_glass: type: integer nullable: true description: Glas i gram pr. enhed packaging_wood: type: integer nullable: true description: Træ i gram pr. enhed packaging_other: type: integer nullable: true description: Andet emballagemateriale i gram pr. enhed packaging_total: type: integer description: "Kun i response: sum af alle emballage-vægte (read-only)" packaging_producer: type: string nullable: true description: >- Hvem er producent af emballagen i Danmark (producentansvar for emballage). shop = shoppen (fx egen import eller shoppens navn på emballagen), supplier = en dansk leverandør. null = ikke angivet. enum: - shop - supplier weee_weight: type: integer nullable: true description: Vægt af elektronisk enhed i gram (uden batteri, emballage og manualer) weee_category: type: string nullable: true description: >- WEEE-kategori efter elektronikaffaldsbekendtgørelsens bilag 3 (gældende siden 15.8.2018): temperature_exchange = temperaturudvekslingsudstyr, screens_over_100cm2 = skærme over 100 cm², light_sources = lyskilder, large_over_50cm = stort udstyr (en udvendig side over 50 cm), small_under_50cm = småt udstyr, small_it_telecom = småt it- og teleudstyr, photovoltaic_panels = solcellepaneler. De gamle kategorier fra før 2018 (large_equipment, small_equipment, it_telecom, consumer_equipment, lighting, electrical_tools, toys_leisure_sports, medical_devices, monitoring_control, dispensers) accepteres og returneres fortsat, men bør genklassificeres. enum: - temperature_exchange - screens_over_100cm2 - light_sources - large_over_50cm - small_under_50cm - small_it_telecom - photovoltaic_panels - large_equipment - small_equipment - it_telecom - consumer_equipment - lighting - electrical_tools - toys_leisure_sports - medical_devices - monitoring_control - dispensers weee_end_user: type: string nullable: true description: "Slutbruger for elektronikken. null = husholdning, business = erhverv" enum: - business battery_weight: type: integer nullable: true description: Batterivægt i gram (samlet pr. produkt) battery_category: type: string nullable: true description: >- Batterikategori efter EU-batteriforordningen (2023/1542): portable = bærbart, lmt = lette transportmidler (fx elcykel), sli = startbatteri til køretøjer, industrial = industribatteri, ev = batteri til elbiler. enum: - portable - lmt - sli - industrial - ev battery_type: type: string nullable: true description: >- Batteriets kemi. button-cell (knapcelle) er et format og ikke en kemi; værdien accepteres og returneres fortsat, men bør erstattes af kemien. enum: - li-ion - li-polymer - lithium-primary - nimh - nicd - alkaline - zinc-carbon - silver-oxide - zinc-air - lead-acid - other - button-cell battery_integrated: type: integer nullable: true description: 1 = batteri integreret, 0 = kan fjernes battery_replaceable: type: integer nullable: true description: 1 = slutbrugeren kan selv udskifte batteriet, 0 = nej (EU-batteriforordningen art. 11) epr_registration_number: type: string nullable: true description: Producent-/EPR-registreringsnummer (fx hos Emballageretur, Elretur eller Batteriretur) note: type: string nullable: true description: Intern note om datakilden eller andre bemærkninger ExtensionData: type: object description: | Theme-defined custom field ("udvidet felt") on a product, category, blog post, page, or landing page. The full set of fields is defined per theme in `user_templates///extensions/.json`. properties: title: type: string description: Human-readable label example: "YouTube Id" type: type: string description: Field type — drives how `value` is shaped enum: ["text", "color", "list", "multi", "richtext", "longtext", "number", "bool", "image", "images", "date", "datetime", "repeater"] example: "text" id: type: string description: Field identifier (slug used in writes) example: "youtube_id" value: description: | Field value. Shape depends on `type`: - text/color/list/richtext/longtext/date/datetime → string - number → integer - bool → boolean - multi → array of strings - image → image URL (string) - images → array of image URLs - repeater → array of row objects (one per repeater row), keyed by subfield id. Image subfields are returned as an image URL (string); all other subfields as strings. Empty repeaters return `[]`. Fx en FAQ-udvidelse: `[{"question": "...", "answer": "..."}, ...]` example: false ExtensionDataInput: description: | Write payload for theme-defined custom fields. Accepts either of two shapes, both produce the same result: 1) Keyed object — concise: `{ "youtube_id": "abc123", "tags": ["a","b"] }` 2) Array of `{id, value}` — matches the GET response so you can read, modify and POST back the same structure. Image fields accept `{url}`, `{data, filename}` or `{remove: true}` per item; for `images` (multi) pass an array of those entries. oneOf: - type: object additionalProperties: true - type: array items: type: object required: [id] properties: id: type: string description: Field id (same as `id` on the read response) value: description: Value — shape depends on the field's `type` (see ExtensionData) ProductList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' products: type: array items: $ref: '#/components/schemas/Product' Brand: type: object properties: brand_id: type: integer description: Brand ID example: 256 name: type: string description: Brand name example: "hat" link_to: type: boolean nullable: true description: External link for the brand example: false weight: type: integer nullable: true description: Display weight/order example: null BrandList: type: array description: Direct array of brands (Note - brands endpoint returns array, not paged object) items: $ref: '#/components/schemas/Brand' BrandInput: type: object required: - name properties: name: type: string description: Brand name Webhook: type: object properties: webhook_id: type: integer description: Webhook ID example: 42 url: type: string description: Webhook endpoint URL example: "https://example.com/webhook" events: type: array items: type: string enum: [order, stock, product, category] description: List of events to trigger webhook example: ["order", "stock"] is_active: type: boolean description: Whether webhook is active example: true created: type: string format: date-time description: When webhook was created example: "2025-01-01 12:00:00" secret: type: string description: HMAC-SHA256 signing secret (only returned on creation) example: "a1b2c3d4e5f6..." WebhookInput: type: object required: - url - events properties: url: type: string description: Webhook endpoint URL example: "https://example.com/webhook" events: type: array items: type: string enum: [order, stock, product, category] description: List of events to trigger webhook example: ["order", "stock"] WebhookUpdate: type: object properties: url: type: string description: Webhook endpoint URL events: type: array items: type: string enum: [order, stock, product, category] description: List of events to trigger webhook is_active: type: boolean description: Whether webhook is active Manufacturer: type: object properties: manufacturer_id: type: integer description: Manufacturer ID example: 42 name: type: string description: Manufacturer name example: "JKL Medical" address: type: string nullable: true description: Manufacturer address example: "Kongevejen 123, 2840 Holte" email: type: string nullable: true description: Manufacturer email example: "info@jklmedical.dk" website: type: string nullable: true description: Manufacturer website example: "https://jklmedical.dk" is_outside_eu: type: integer description: Is manufacturer located outside EU (0 = EU, 1 = non-EU) example: 0 eu_name: type: string nullable: true description: EU representative name (required if is_outside_eu = 1) eu_address: type: string nullable: true description: EU representative address eu_email: type: string nullable: true description: EU representative email eu_website: type: string nullable: true description: EU representative website ManufacturerInput: type: object required: - name properties: name: type: string description: Manufacturer name address: type: string description: Manufacturer address email: type: string description: Manufacturer email website: type: string description: Manufacturer website is_outside_eu: type: integer description: Is manufacturer located outside EU (0 = EU, 1 = non-EU) eu_name: type: string description: EU representative name eu_address: type: string description: EU representative address eu_email: type: string description: EU representative email eu_website: type: string description: EU representative website ProductInput: type: object required: - name properties: name: type: string description: Product name description: type: string description: Product description price: type: number description: Product price sale_price: type: number description: Sale price sale_starts: type: string format: date nullable: true description: Start-dato for tilbudspris (YYYY-MM-DD) sale_ends: type: string format: date nullable: true description: Slut-dato for tilbudspris (YYYY-MM-DD) sale_limit: type: integer nullable: true minimum: 0 description: >- Antal stk. der er tilbage på tilbud. null = ingen antalsbegrænsning. Tæller ned ved salg og lægges tilbage ved annullering/retur. Rammer det 0, afsluttes tilbuddet helt. purchase_price: type: number description: Purchase price vat: type: number description: VAT rate profile_id: type: integer description: Product profile ID supplier_id: type: integer description: Supplier ID brand_id: type: integer description: Brand ID is_online: type: integer description: Online status (1 = online, 0 = offline) weight: type: integer description: >- Manual sort order (lower sorts first), used by order_by=weight. NOT a physical weight — for that see shipping_weight. shipping_weight: type: number description: >- Physical shipping weight in GRAMS (e.g. 200 = 200 g). Used for weight-based shipping prices and by the carrier integrations. unit_amount: type: number nullable: true description: >- Enhedspris (#663): antal basis-enheder i pakken, fx 24 for en ramme eller 0.5 for en 500 g pose. Sammen med unit_label giver det linjen "3,29 kr/stk" under prisen på produktkort/produktside. Tom/null fjerner enhedsprisen. unit_label: type: string nullable: true description: >- Enhedspris (#663): basis-enheden som fri tekst, fx "stk", "L", "kg", "ml", "g". Bruges kun sammen med unit_amount. location: type: string description: Physical location tariff_code: type: string description: Tariff code package: type: string nullable: true description: Package information product_deposit_id: type: integer nullable: true description: >- Deposit type id (pant/depositum). The type must belong to the webshop and be active. Pass 0/null to remove the deposit from the product (also clears product_deposit_count). product_deposit_count: type: integer nullable: true description: >- Deposit units per sales unit (e.g. 24 for a 24-can case). Minimum 1; empty/null resets to the default of 1. Per-variant overrides via stock_settings.deposit_count. delivery_time: type: string description: Delivery time in stock delivery_time_not_in_stock: type: string description: Delivery time when not in stock allow_negative_stock: type: integer description: Allow negative stock flag meta_title: type: string description: Meta title meta_description: type: string description: Meta description images: type: array items: $ref: '#/components/schemas/ImageUpload' description: Array of images to upload or manage remove_images: type: boolean description: Remove all existing images from product example: false variant_image: type: object additionalProperties: type: integer description: >- ID-based alternative for associating images with product variants: a map of `{ "": }`. This replaces the full set of variant-image associations on the product. For a name-based, merge-friendly workflow prefer `images[].variant_name` instead (matched case-insensitively). example: "9728": 4412 "9729": 4413 supplier_name: type: string description: Auto-create supplier by name if it doesn't exist brand_name: type: string description: Auto-create brand by name if it doesn't exist pdf_files: type: array items: type: object properties: name: type: string description: PDF file name data: type: string format: base64 description: Base64 encoded PDF data url: type: string format: uri description: URL to fetch PDF from (alternative to data field) description: Array of PDF files to upload files: type: array items: type: object properties: name: type: string description: File name data: type: string format: base64 description: Base64 encoded file data url: type: string format: uri description: URL to fetch file from (alternative to data field) description: Array of general files to upload locations: type: object additionalProperties: type: number description: Stock per location (key = location, value = stock quantity) example: "Main Warehouse": 100 "Store A": 25 open_graph_title: type: string description: Open Graph title for social media open_graph_description: type: string description: Open Graph description for social media open_graph_image: type: integer nullable: true description: >- image_id of the image used when the product is shared on Facebook/LinkedIn/etc. Must be the image_id of one of THIS product's own images (see images[].image_id); anything else is rejected with 400. Send 0 or null to clear, after which the theme falls back to the first product image. Images uploaded in the same request can be referenced here. shopping_feed_image: type: integer nullable: true description: >- image_id forced to be the primary image (g:image_link) in the Google Shopping feed. Must be the image_id of one of THIS product's own images; anything else is rejected with 400. WARNING: on products WITH variants an empty value means "each variant uses its own image" — setting this field makes ALL variants share the same feed image. On products without variants the first image is already primary, so the field is only needed to promote a different one. Send 0 or null to clear. google_shopping_title: type: string description: Google Shopping optimized title google_category: type: string nullable: true description: | Google product taxonomy used in product feeds, e.g. "Apparel & Accessories > Clothing > Outerwear > Coats & Jackets". Overrides the parent category's google_category if set. primary_search_phrase: type: string nullable: true description: | Primary SEO keyword (single phrase) — the main on-page focus keyword used by the SEO feedback widget in admin to rate how well the product page targets the phrase. rewrite_url: type: string description: | URL slug for the storefront (e.g. "sort-uld-hat" → /shop/sort-uld-hat). If omitted on POST, the slug is auto-generated from the product name. On PUT, changing the slug automatically creates a 301 redirect from the old URL to the new one (via Product::setRewriteUrl → Redirect::renameHelper). main_category_id: type: integer nullable: true description: Primary category id (used for breadcrumbs and canonical URL) categories: type: array items: type: integer description: | Full replacement of the product's category list. Pass the complete set of category ids you want the product to belong to. Categories not in this list will be removed. Use `add_to_categories` / `remove_from_categories` instead if you only want to add or remove a few categories without affecting the rest. add_to_categories: type: array items: type: integer description: | Idempotent add: attach the product to these categories without touching any of its existing category memberships. Already-attached categories are silently left alone. remove_from_categories: type: array items: type: integer description: | Idempotent remove: detach the product from these categories without touching the rest of its category list. Non-attached categories are silently ignored. Useful for cleaning up wrong categorisations without having to read the full list first. extension_data: $ref: '#/components/schemas/ExtensionDataInput' stock_settings: type: array description: >- Per-variant settings for a product that uses a profile with a variant attribute. Each entry targets one variant value via `attribute_value_name`. If the named variant value does not exist yet on the product's variant attribute, it is created automatically (so you do not need to create the value separately first). items: type: object properties: attribute_value_name: type: string description: >- Name of the variant value to target (e.g. "50 x 90 cm", "Red"). Auto-created on the product's variant attribute if it does not already exist. example: "50 x 90 cm" price: type: number description: Price for this variant example: 195 sale_price: type: number description: Sale price for this variant purchase_price: type: number description: Purchase price for this variant own_id: type: string description: SKU / own id for this variant sec_own_id: type: string description: Secondary own id for this variant gtin: type: string description: GTIN / EAN barcode for this variant mpn: type: string description: Manufacturer Part Number for this variant min_stock_value: type: integer description: Minimum stock threshold for this variant location: type: string description: Physical stock location for this variant deposit_count: type: integer description: >- Deposit (pant) units for this variant, overriding the product-level product_deposit_count. Empty string = inherit from the product; 0 = no deposit for this variant. example: - attribute_value_name: "50 x 90 cm" price: 195 - attribute_value_name: "70 x 133 cm" price: 350 own_id: "TOWEL-70133" attributes: type: array description: >- Set structured attribute values (the product's "specifications" — e.g. Material=Cotton, Color=Red) defined by its profile. The product must be on a profile first (set `profile_id`). This handles NON-variant attributes only; variant size/colour with their own stock are set via `stock_settings`. Only the attributes you include are changed; the rest are left untouched. Identify the attribute by `attribute_id` (see GET /profile/{id}/attribute) or by `tag`, and give the value as `value_name` (text — easiest, auto-created for valuelist attributes) or `value` (the attribute_value_id for valuelist attributes, or the literal text for free-text attributes). Pass `value_name: ""` to clear a value. `file` attributes (customer uploads) are silently ignored here — the file belongs to the order line, not the product. items: type: object properties: attribute_id: type: integer description: >- Id of the profile attribute to set. Get it from GET /profile/{id}/attribute or the product's `attributes` array in a product GET response. tag: type: string description: >- Alternative to attribute_id: identify the attribute by its tag. The attribute is created on the product's profile if it does not exist. value_name: type: string description: >- The value as text/label (e.g. "Red"). For valuelist attributes the matching value is found or auto-created; for free-text attributes (string/html/integer/boolean) it is stored verbatim. Pass "" to clear. value: description: >- For valuelist attributes: either the attribute_value_id (integer, see GET /profile-attribute-value) or the label as text — a numeric value is resolved as an id first and falls back to a label lookup. For free-text attributes: the literal text. Prefer value_name unless you already have the id. If both are sent, value_name wins. example: - attribute_id: 3672 value_name: "Red" - tag: "material" value_name: "Cotton" environment: allOf: - $ref: '#/components/schemas/ProductEnvironment' description: >- Miljødata til EPR-indberetning (emballage, elektronik/WEEE og batterier). Partial update: kun medsendte felter ændres, og en tom streng rydder et felt. Sæt enabled=false for at slette al miljødata. packaging_total er read-only og ignoreres ved skrivning. example: enabled: true packaging_paper: 12 packaging_plastic: 5 weee_category: small_equipment battery_type: li-ion battery_weight: 30 CategoryInput: type: object required: - name properties: name: type: string description: Category name description_a: type: string description: Description A description_b: type: string description: Description B parent_id: type: integer description: Parent category ID in_menu: type: integer description: Show in menu (1 = yes, 0 = no) is_online: type: integer description: Online status (1 = online, 0 = offline) is_front: type: integer description: Front page status sort_order: type: string enum: [normal, no_stock_last, random, product_score, alphabetic, newest, availability] description: > Product sort order on the category. normal = manual order, no_stock_last = in-stock first, random, product_score, alphabetic, newest, availability = in-stock + with image first, then in-stock without image, then out-of-stock with image, then out-of-stock without image (manual order is used as tiebreaker within each group). meta_title: type: string description: Meta title meta_description: type: string description: Meta description open_graph_title: type: string description: Open Graph title for social media. Falls back to meta_title if empty. open_graph_description: type: string description: Open Graph description for social media. Falls back to meta_description if empty. open_graph_image: type: string description: URL of the Open Graph image used when the category page is shared on social media. primary_search_phrase: type: string description: >- Primary SEO keyword (single phrase) — the main on-page focus keyword used by the SEO feedback widget in admin to rate how well the category page targets the phrase. google_category: type: string description: >- Google product taxonomy used in product feeds, e.g. "Apparel & Accessories > Clothing". Products without their own google_category inherit it from their category. images: type: array items: $ref: '#/components/schemas/ImageUpload' description: >- Array of images to upload or manage. To set the alt text on an existing category image, pass its image_id together with a new description (e.g. [{image_id: 9728, description: "Roser i haven"}]). remove_images: type: boolean description: Remove all existing images from the category before processing the images array example: false extension_data: $ref: '#/components/schemas/ExtensionDataInput' SupplierInput: type: object required: - name properties: name: type: string description: Supplier name contact_person: type: string description: Contact person email: type: string description: Email address phone: type: string description: Phone number address: type: string description: Address OrderInput: type: object required: - customer_email properties: customer_email: type: string description: Customer email customer_name: type: string description: Customer name customer_phone: type: string description: Customer phone delivery_address: type: string description: Delivery address billing_address: type: string description: Billing address notes: type: string description: Order notes status: type: string description: Order status send_invoice: type: boolean description: Send invoice email to customer example: false send_invoice_only_to_customer: type: boolean description: Send invoice email only to customer (not to shop admin) example: false time: type: string description: "Backdate the order. Format: 'Y-m-d' or 'Y-m-d H:i:s'. Mainly used by migration scripts importing historical orders so they land on the correct day in stats, CLV and cohort analysis. Omit for normal orders — they default to NOW() on creation." example: "2024-03-15 14:30:00" add_points: type: integer description: >- Award (or deduct, if negative) loyalty points to the order's customer. Creates an approved entry in the customer's point ledger with an auto-generated note referencing the order. Silently ignored if the order has no customer attached. For point entries outside an order context (with a custom note, status or expiry), use POST /customer/{id}/point instead. add_points_in_currency: type: number description: >- Same as add_points, but expressed as a currency amount — the value is divided by the shop's lp_base_points setting to compute the points. Silently ignored if the order has no customer or lp_base_points is not set. products: type: array description: "Order line items. When sent on an existing order, items with `order_product_id` update the matching line, items without create a new line. Pass `amount: 0` to remove a line." items: type: object properties: order_product_id: type: integer description: Existing line ID (for updates) product_id: type: integer description: Product ID (required unless own_id or product_deposit_id is given) own_id: type: string description: Product SKU/own_id (alternative to product_id) product_deposit_id: type: integer description: >- Create/update a deposit (pant) line instead of a product line. Pass INSTEAD of product_id/own_id. amount = number of deposit units; unit_price/unit_vat default to the deposit type's gross amount and the order country's VAT share. name defaults to the type's name. Used by e.g. the POS. name: type: string description: Line name override (only used for deposit lines; product lines use the product's name). amount: type: integer description: Quantity. Pass 0 to remove the line. unit_price: type: number description: Override unit price (excl. VAT). Optional — defaults to the product's current price. unit_vat: type: number description: Override unit VAT. Optional. from_bundle: type: boolean description: > Set to true on sub-product lines of a bundle (samlepakke) so email templates, PDFs and datalayer tracking group them under their parent instead of showing them as separate paid lines. Pair with `bundle_product_id` pointing at the parent. example: false bundle_product_id: type: integer description: > product_id of the parent bundle this line belongs to. Only meaningful when `from_bundle` is true. attributes: description: >- Line attributes. Two forms are accepted. Legacy single object: `{attribute_id, attribute_value_id}` sets the variant. New (additive): a LIST of such objects, where each item is either `{attribute_id, attribute_value_id}` (variant/valuelist) or `{attribute_id, value}` (free text). `file` attributes cannot be written via REST (400). The first variant item also directs the optional `stock` movement. IMPORTANT — `{attribute_id, value}` only writes to a CUSTOMER-INPUT attribute (one with `input_mode: customer`, see GET /profile-attribute/{id}). It is the personalization field the customer fills in themselves, e.g. an engraving name, and it is then stored verbatim on the order line. Sending `value` for anything else — a variant, one of the shop's own spec attributes, or an unknown attribute_id — is IGNORED: the order is still created and returns 201, but no attribute row is written and the value is not stored anywhere. The shop owner gets a notice about it in the admin. Set a variant or a valuelist value with `attribute_value_id` instead. example: - attribute_id: 3667 attribute_value_id: 32945 - attribute_id: 3830 value: "Emma" StockInput: type: object required: - stock properties: SKU: type: string description: Product SKU/own_id (primary identifier for stock updates) GTIN: type: string description: Product GTIN/EAN (alternative identifier for stock updates) stock: type: number description: New stock quantity tag: type: string description: Stock location tag (optional) oneOf: - required: [SKU] - required: [GTIN] OrderLabelInput: type: object required: - name properties: name: type: string description: Label name example: "e-conomic-ok" color: type: string description: Color code (hex format) example: "#77d413" auto_filter: type: string description: Email filter for automatic label assignment example: "customer@example.com" auto_from_subscription: type: boolean description: Automatically assign to subscription orders example: false auto_from_bundle: type: boolean description: Automatically assign to bundle orders example: false OrderLabel: type: object properties: order_label_id: type: integer description: Order label ID example: 123 name: type: string description: Label name example: "e-conomic-ok" color: type: string description: Color code (hex format) example: "#77d413" auto_filter: type: string description: Email filter for automatic label assignment example: "customer@example.com" auto_from_subscription: type: integer description: Automatically assign to subscription orders (1 = yes, 0 = no) example: 0 auto_from_bundle: type: integer description: Automatically assign to bundle orders (1 = yes, 0 = no) example: 0 VoucherInput: type: object required: - code - discount properties: code: type: string description: Voucher code discount: type: number description: Discount amount discount_type: type: string description: Discount type (percentage or fixed) valid_from: type: string format: date description: Valid from date valid_to: type: string format: date description: Valid to date minimum_order: type: number description: Minimum order amount Category: type: object properties: category_id: type: integer description: Category ID parent_id: type: integer nullable: true description: Parent category ID name: type: string description: Category name description_a: type: string description: Description A description_b: type: string description: Description B in_menu: type: integer description: Show in menu (1 = yes, 0 = no) is_online: type: integer description: Online status (1 = online, 0 = offline) is_front: type: integer description: Front page status no_index: type: integer description: No index flag (1 = no index, 0 = index) weight: type: integer description: Category weight/order no_internal_search: type: integer description: No internal search flag (1 = no search, 0 = searchable) meta_title: type: string description: Meta title meta_description: type: string description: Meta description open_graph_title: type: string nullable: true description: Open Graph title for social media. Falls back to meta_title if empty. open_graph_description: type: string nullable: true description: Open Graph description for social media. Falls back to meta_description if empty. open_graph_image: type: string nullable: true description: URL of the Open Graph image used when the category page is shared on social media. primary_search_phrase: type: string nullable: true description: >- Primary SEO keyword (single phrase) — the main on-page focus keyword used by the SEO feedback widget in admin to rate how well the category page targets the phrase. google_category: type: string nullable: true description: >- Google product taxonomy used in product feeds, e.g. "Apparel & Accessories > Clothing". Products without their own google_category inherit it from their category. rewrite_url: type: string description: URL rewrite path url: type: string description: Category URL images: type: array items: $ref: '#/components/schemas/ImageResponse' description: Array of category images with full URLs and metadata extension_data: type: array items: $ref: '#/components/schemas/ExtensionData' description: Theme-defined custom fields for the category CategoryList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' categories: type: array items: $ref: '#/components/schemas/Category' LandingPageInput: type: object required: - name properties: name: type: string description: Landing page name description: type: string description: Description rewrite_url: type: string description: URL rewrite path meta_title: type: string description: Meta title for SEO meta_description: type: string description: Meta description for SEO meta: type: string description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Keys are lowercased; empty lines are dropped. On write, REPLACES the whole field. extension_data: $ref: '#/components/schemas/ExtensionDataInput' LandingPageUpdate: type: object properties: name: type: string description: Landing page name description_a: type: string description: Description A (top content) description_b: type: string description: Description B (bottom content) rewrite_url: type: string description: URL rewrite path meta_title: type: string description: Meta title for SEO meta_description: type: string description: Meta description for SEO meta: type: string description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Keys are lowercased; empty lines are dropped. On write, REPLACES the whole field. is_online: type: integer enum: [0, 1] description: Whether the landing page is online tag: type: string description: Tag for identification extension_data: $ref: '#/components/schemas/ExtensionDataInput' Supplier: type: object properties: supplier_id: type: integer description: Supplier ID name: type: string description: Supplier name SupplierList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' suppliers: type: array items: $ref: '#/components/schemas/Supplier' Order: type: object properties: order_id: type: integer description: Order ID shipping_weight: type: integer nullable: true description: > Manually entered shipping weight in grams, or null when none is set. Set it to weigh the packed parcel instead of relying on the products' own weights. The value REPLACES the calculated weight rather than adding to it, so packaging weight must already be included. It is only used for shipping labels — never for calculating the shipping price, which always uses the product weights. Send 0 to clear it. label_weight: type: integer readOnly: true description: > Read-only. The weight in grams that will actually be printed on a shipping label for the whole order: shipping_weight when set, otherwise the calculated sum of the product weights plus the shop's default packaging weight. webshop_order_id: type: integer description: Webshop order ID customer_id: type: integer nullable: true description: Customer ID shipping_id: type: integer nullable: true description: Shipping ID status: type: string description: Order status created: type: string format: date-time description: Order creation timestamp total: type: number description: Order total voucher_id: type: integer nullable: true description: Voucher (discount code) ID used on this order points_used: type: integer nullable: true description: Number of loyalty points used on this order (negative value) points_earned: type: integer nullable: true description: Number of loyalty points earned from this order currency_pr_point: type: integer nullable: true description: Currency value per loyalty point shipments: type: array description: >- Partial deliveries, present only when the order is shipped in more than one parcel. Absent for ordinary single-parcel orders, so existing consumers see an unchanged response. items: $ref: '#/components/schemas/OrderShipment' products: type: array description: >- The order lines. Only the `attributes` array is described here; the remaining line fields (order_product_id, product_id, own_id, sec_own_id, name, amount, comment, unit_price, unit_vat, is_sale_at_purchase, from_bundle, bundle_product_id, product_deposit_id, location, gtin, shipping_weight, is_bundle, images) are returned as well. items: type: object properties: attributes: type: array description: >- The attribute values stored on this order line — variant choices, the shop's own spec values, and the customer's personalization input, in one list. The variant row is always returned first. items: type: object properties: order_product_id: type: integer attribute_id: type: integer description: Id of the profile attribute (see GET /profile-attribute/{id}). attribute_value_id: type: integer description: >- The chosen value's id for valuelist attributes, 0 for free text. name: type: string description: The attribute's name as it was when the order was placed. val: type: string description: The value as text (the label for valuelist attributes). is_variant: type: integer nullable: true description: >- 1 when the row is the line's variant choice (size/colour with its own stock and SKU), 0 when it is not. null when the profile attribute has since been deleted — treat null as "unknown", not as 0. Read this instead of guessing from attribute_value_id: a customer-input valuelist has a real attribute_value_id too. data_type: type: string nullable: true enum: [valuelist, string, integer, boolean, html, file] description: >- The profile attribute's type, or null if the attribute has been deleted. input_mode: type: string nullable: true enum: [shop, customer] description: >- `shop` for variant and spec rows, `customer` for a value the customer typed/picked/uploaded themselves (personalization). null if the attribute has been deleted. Everything from before the personalization feature is `shop`, so filtering on `input_mode == 'customer'` is safe on historical orders — that is how e.g. the e-conomic integration keeps the customer's own text out of the invoice line. customer_file_id: type: integer nullable: true description: >- Id of the file the customer uploaded for this attribute (`data_type: file`), or null. The file itself is not available over the REST API — download it from the order in the admin. OrderList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' orders: type: array items: $ref: '#/components/schemas/Order' OrderReturn: type: object properties: order_return_id: type: integer description: Order return ID order_id: type: integer description: Order ID webshop_order_id: type: integer description: Webshop order ID customer_id: type: integer description: Customer ID shipping_id: type: integer description: Shipping ID tracking_numbers: type: array items: type: string description: Tracking numbers OrderReturnList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' order_returns: type: array items: $ref: '#/components/schemas/OrderReturn' OrderShipmentLine: type: object properties: order_shipment_product_id: type: integer order_product_id: type: integer description: The order line this quantity is taken from product_id: type: integer own_id: type: string nullable: true description: SKU snapshot name: type: string description: Product name as it appeared on the order amount: type: integer description: Number of units of this order line included in this shipment OrderShipment: type: object description: >- A partial delivery: a subset of an order's lines sent as one parcel. An order keeps one price, one invoice and one capture — the shipment is a record of what physically left the building. The order only moves to status "sent" once every ordered unit is covered by a shipment in status "sent". properties: order_shipment_id: type: integer order_id: type: integer status: type: string enum: [new, sent, cancelled] description: new = being packed, sent = dispatched, cancelled = withdrawn part_number: type: integer description: 1-based position among the order's non-cancelled shipments part_count: type: integer description: Total number of non-cancelled shipments on the order tracking_number: type: string nullable: true description: Also mirrored onto the order's tracking_numbers stock_location_id: type: integer nullable: true description: Which stock location the parcel was packed from. Declarative only — it does not move stock note: type: string nullable: true time: type: string format: date-time sent_at: type: string format: date-time nullable: true lines: type: array items: $ref: '#/components/schemas/OrderShipmentLine' OrderShipmentList: type: object properties: shipments: type: array items: $ref: '#/components/schemas/OrderShipment' OrderShipmentInput: type: object properties: lines: type: object description: >- Map of order_product_id to quantity. Quantities are clamped to what is still unshipped on that order line, so a call can never dispatch more than was ordered. additionalProperties: type: integer example: "900251": 1 "900252": 2 tracking_number: type: string stock_location_id: type: integer nullable: true note: type: string send: type: boolean description: >- Mark the shipment as dispatched. Mirrors the tracking number onto the order, emails the customer about this parcel, and moves the order to "sent" if this was the last outstanding line. Stock: type: object properties: product_id: type: integer description: Product ID name: type: string description: Product name sku: type: string description: Primary SKU (own_id) stock: type: number description: Total stock quantity stock_locations: type: array items: type: object properties: name: type: string description: Location name tag: type: string description: Location tag identifier stock: type: number description: Stock at this location description: Stock breakdown by location variants: type: array items: type: object properties: name: type: string description: Variant name sku: type: string description: Variant SKU (own_id) stock: type: number description: Variant stock level description: Stock levels for product variants StockList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' stock: type: array items: $ref: '#/components/schemas/Stock' Batch: type: object description: Batch/lot tracking object for inventory management properties: product_batch_id: type: integer description: Batch ID product_id: type: integer description: Product ID this batch belongs to product_name: type: string description: Product name batch_number: type: string description: Batch/lot number (unique identifier) name: type: string description: Optional batch name expiry_date: type: string format: date description: Expiry date (yyyy-mm-dd) manufacturing_date: type: string format: date description: Manufacturing date (yyyy-mm-dd) received_date: type: string format: date-time description: Date batch was received supplier_id: type: integer description: Supplier ID notes: type: string description: Notes about the batch stock_amount: type: integer description: Current stock amount for this batch is_expired: type: boolean description: Whether the batch has expired days_until_expiry: type: integer description: Days until expiry (null if no expiry date) created: type: string format: date-time description: When the batch was created BatchList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' batches: type: array items: $ref: '#/components/schemas/Batch' BatchInput: type: object required: - product_id - batch_number properties: product_id: type: integer description: Product ID (required) batch_number: type: string description: Batch/lot number (required) name: type: string description: Optional batch name expiry_date: type: string format: date description: Expiry date (yyyy-mm-dd) manufacturing_date: type: string format: date description: Manufacturing date (yyyy-mm-dd) supplier_id: type: integer description: Supplier ID notes: type: string description: Notes about the batch BatchUpdate: type: object properties: batch_number: type: string description: Batch/lot number name: type: string description: Optional batch name expiry_date: type: string format: date description: Expiry date (yyyy-mm-dd) manufacturing_date: type: string format: date description: Manufacturing date (yyyy-mm-dd) supplier_id: type: integer description: Supplier ID notes: type: string description: Notes about the batch Voucher: type: object properties: voucher_id: type: integer description: Voucher ID code: type: string description: Voucher code discount: type: number description: Discount amount allow_on_sale: type: integer description: Allow on sale products (1 = yes, 0 = no) allow_on_campaign: type: integer description: Allow on campaign products (1 = yes, 0 = no) allow_with_points: type: integer description: Allow with points (1 = yes, 0 = no) single_user: type: integer description: Single user only (1 = yes, 0 = no) save_change: type: integer description: Save change (1 = yes, 0 = no) free_shipping: type: integer description: Free shipping (1 = yes, 0 = no) ignore_free_shipping_threshold: type: integer description: >- Treat the voucher as a payment method (e.g. a gift card) so its discount is not subtracted when checking the free shipping threshold (1 = yes, 0 = no). Set automatically on gift cards created from voucher products. is_public: type: integer description: Is public (1 = yes, 0 = no) batch_number: type: integer description: Batch number price_discount: type: integer nullable: true description: Price discount amount percent_discount: type: integer nullable: true description: Percentage discount amount_left: type: integer nullable: true description: Amount left start: type: string nullable: true description: Start date ("valid from"). Accepts ISO date "2026-07-15", datetime "2026-07-15 00:00:00", or unix timestamp on write; returned as datetime string on read. Before this moment the voucher cannot be used — set it to create the voucher now but activate it automatically on a future date. Null/omitted = active immediately. expires: type: string nullable: true description: Expiry date. Accepts ISO date "2026-04-30", datetime "2026-04-30 23:59:59", or unix timestamp on write. Returned as datetime string on read. only_over: type: number nullable: true description: Minimum basket total before voucher applies note: type: string description: Internal note products: type: array items: type: integer description: Restrict the voucher to specific product_ids. Empty/missing means basket-wide. On write, REPLACES the existing list. categories: type: array items: type: integer description: Restrict the voucher to products in specific category_ids. Combines with `products`. On write, REPLACES the existing list. countries: type: array items: type: integer description: Restrict the voucher's FREE SHIPPING to specific country_ids (from the webshop's own country list). Empty/missing means free shipping applies in every country. Only affects free_shipping — the percent/amount discount always applies in all countries. On write, REPLACES the existing list. VoucherList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' vouchers: type: array items: $ref: '#/components/schemas/Voucher' Country: type: object properties: country_id: type: integer description: Country ID name: type: string description: Country name is_default: type: integer description: Is default country (1 = yes, 0 = no) default_shipping_id: type: integer nullable: true description: Default shipping method ID code: type: string description: Country code (ISO 3166-1 alpha-2) b2b_vat: type: number nullable: true description: B2B VAT rate b2c_vat: type: number nullable: true description: B2C VAT rate free_above: type: number nullable: true description: Free shipping threshold shipping_methods: type: array items: type: object properties: shipping_id: type: integer description: Shipping method ID name: type: string description: Shipping method name description: Available shipping methods for this country weight: type: integer description: Display order weight CountryList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' countries: type: array items: $ref: '#/components/schemas/Country' CountryInput: type: object required: - name - code properties: name: type: string description: Country name is_default: type: integer description: Is default country (1 = yes, 0 = no) default_shipping_id: type: integer description: Default shipping method ID code: type: string description: Country code (ISO 3166-1 alpha-2) b2b_vat: type: number description: B2B VAT rate b2c_vat: type: number description: B2C VAT rate free_above: type: number description: Free shipping threshold weight: type: integer description: Display order weight shipping_methods: type: array items: type: integer description: Array of shipping method IDs available for this country Profile: type: object properties: profile_id: type: integer description: Profile ID name: type: string description: Profile name tag: type: string nullable: true description: External identifier/tag for the profile description: type: string nullable: true description: Profile description created: type: string format: date-time description: Creation timestamp modified: type: string format: date-time description: Last modification timestamp ProfileList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' profiles: type: array items: $ref: '#/components/schemas/Profile' ProfileInput: type: object required: - name properties: name: type: string description: Profile name tag: type: string description: External identifier/tag for the profile description: type: string description: Profile description ProfileAttribute: type: object description: >- Note: the id and type fields are each returned under two names. The database column names (`attribute_id`, `data_type`) are the originals; `profile_attribute_id` and `type` are aliases with identical values. Both are always present — prefer `attribute_id`/`data_type`. properties: attribute_id: type: integer description: Profile attribute ID profile_attribute_id: type: integer description: Alias for `attribute_id` (same value) profile_id: type: integer description: Profile ID data_type: type: string enum: [valuelist, string, integer, boolean, html, file] description: >- Attribute type. `valuelist` means the value is picked from a fixed list (see /profile-attribute-value); all other types are free text stored directly on the product. `file` is a customer upload field (personalization) — always `input_mode: customer`, and the file itself cannot be read or written via the REST API. type: type: string enum: [valuelist, string, integer, boolean, html, file] description: Alias for `data_type` (same value) name: type: string description: Attribute name tag: type: string nullable: true description: >- Machine tag used to match the attribute programmatically (e.g. "color"). Used by product import (attributes[].tag on PUT /product resolves the attribute by tag) and theme/feed logic. Send an empty string to clear the tag. purpose: type: string nullable: true enum: [size, color, gender, material, pattern, age_group] description: >- Semantic role of the attribute, used by product feeds (Google Shopping etc.) to map it to a standard field. Null when it has no special meaning. unit: type: string nullable: true description: Unit label shown after the value (e.g. "cm", "g") is_variant: type: integer description: Is variant attribute (1 = yes, 0 = no) has_price: type: integer description: Has price modifier (1 = yes, 0 = no) input_mode: type: string enum: [shop, customer] description: >- Who fills the value. `shop` (default): the shop sets a per-product value (spec data). `customer`: the customer fills the field on the product page (personalization — e.g. an engraving name); the value is stored per ORDER LINE, not on the product. Customer-input attributes are never variants. is_required: type: integer description: >- Customer input only — 1 means the customer must fill the field before the product can be added to the basket. max_length: type: integer nullable: true description: >- Customer input text fields only — max characters (capped at 255). Null means no explicit limit (255 applies). placeholder: type: string nullable: true description: Customer input only — placeholder text shown in the empty field. weight: type: integer description: Display order weight variant_fields: type: string nullable: true description: Internal comma-separated list of extra per-variant fields ProfileAttributeList: type: array description: >- GET /profile-attribute returns a plain array — there is no paging envelope on this endpoint. items: $ref: '#/components/schemas/ProfileAttribute' ProfileAttributeInput: type: object required: - name - type properties: name: type: string description: >- Attribute name. Must be non-empty on both POST and PUT — sending an empty string returns 400. (PUT used to accept it, which left attributes with a blank name that rendered as an empty label in the shop.) type: type: string enum: [valuelist, string, integer, boolean, html, file] description: >- Attribute type. Required on POST. `data_type` is accepted as an alias on both POST and PUT, so a GET response can be sent straight back in. An unknown or missing type returns 400. `file` (customer upload) is always customer input and cannot be a variant — invalid combinations return 400. data_type: type: string enum: [valuelist, string, integer, boolean, html, file] description: Alias for `type` — accepted on both POST and PUT. tag: type: string nullable: true description: >- Machine tag used to match the attribute programmatically (e.g. "color"). Used by product import (attributes[].tag on PUT /product resolves the attribute by tag) and theme/feed logic. Send an empty string to clear the tag. is_variant: type: integer description: >- Is variant attribute (1 = yes, 0 = no). A profile can have AT MOST ONE variant attribute — the platform does not support combining two variant axes (e.g. size AND colour). Setting 1 when another attribute on the same profile already has is_variant=1 returns 400; setting 0 is always allowed. Setting 1 on a customer-input attribute also returns 400 (set input_mode to shop first). has_price: type: integer description: Has price modifier (1 = yes, 0 = no) input_mode: type: string enum: [shop, customer] description: >- `customer` marks the attribute as customer input (personalization filled on the product page). Requires is_variant=0 and a data_type other than html; `file` attributes are always customer. Invalid combinations return 400. is_required: type: integer description: >- The customer must fill the field. Requires input_mode=customer (either already set or sent in the same request) — otherwise 400. 0 is always accepted. max_length: type: integer nullable: true description: >- Max characters, capped at 255; 0 clears it. Only applies to `string` attributes — sending it for any other type returns 400, because nothing else enforces a length and the value would look saved while being inert. placeholder: type: string nullable: true description: >- Placeholder text shown in the empty field. Requires input_mode=customer (either already set or sent in the same request) — otherwise 400. An empty string clears it and is always accepted. weight: type: integer description: Display order weight ProfileAttributeValue: type: object properties: profile_attribute_value_id: type: integer description: Profile attribute value ID profile_attribute_id: type: integer description: Profile attribute ID value: type: string description: Attribute value tag: type: string description: Optional tag for grouping/matching values weight: type: integer description: Display order weight ProfileAttributeValueInput: type: object required: - value properties: value: type: string description: Attribute value tag: type: string description: Optional tag for grouping/matching values weight: type: integer description: Display order weight ProductLabel: type: object properties: product_label_id: type: integer description: Product label ID name: type: string description: Label name color: type: string description: Color code (hex format) weight: type: integer description: Display order weight ProductLabelList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' product_labels: type: array items: $ref: '#/components/schemas/ProductLabel' ProductLabelInput: type: object required: - name properties: name: type: string description: Label name color: type: string description: Color code (hex format) weight: type: integer description: Display order weight OrderReturnInput: type: object required: - order_id properties: order_id: type: integer description: Order ID webshop_order_id: type: integer description: Webshop order ID customer_id: type: integer description: Customer ID shipping_id: type: integer description: Shipping method ID tracking_numbers: type: array items: type: string description: Tracking numbers for the return status: type: string description: Return status notes: type: string description: Return notes AppStatus: type: object properties: app_id: type: integer description: App ID status: type: string description: App status webshops: type: array items: type: object properties: webshop_id: type: integer description: Webshop ID name: type: string description: Webshop name status: type: string description: Webshop status last_sync: type: string format: date-time description: Last synchronization timestamp description: List of webshops associated with the app Customer: type: object properties: customer_id: type: integer description: Customer ID email: type: string format: email description: Customer email address name: type: string description: Customer full name company: type: string nullable: true description: Company name vat_number: type: string nullable: true description: VAT/Tax number own_id: type: string nullable: true description: Customer's own reference ID phone: type: string nullable: true description: Phone number address: type: string nullable: true description: Billing address zipcode: type: string nullable: true description: Billing postal code city: type: string nullable: true description: Billing city country_id: type: integer nullable: true description: Billing country ID shipping_name: type: string nullable: true description: Shipping contact name shipping_address: type: string nullable: true description: Shipping address shipping_zipcode: type: string nullable: true description: Shipping postal code shipping_city: type: string nullable: true description: Shipping city shipping_country_id: type: integer nullable: true description: Shipping country ID ean_number: type: string nullable: true description: EAN number. Public-sector customers (municipalities, hospitals) are invoiced on it. customer_group_id: type: integer nullable: true description: The customer's own customer group. Drives group discount, fixed group prices and brand visibility. customer_company_id: type: integer nullable: true description: B2B company the customer belongs to. customer_company_role: type: string nullable: true enum: [admin, member] description: Role in the B2B company. 'admin' may manage the company's other members. customer_company_name: type: string nullable: true description: Name of the linked B2B company. List responses only. effective_customer_group_id: type: integer description: The group that actually applies - the customer's own, or the one inherited from their company. 0 when neither. List responses only. subscription_id: type: string nullable: true description: External subscription ID (e.g., from payment gateway or external system) enable_shared_basket: type: integer description: Shared basket enabled (1 = yes, 0 = no) has_one_click: type: integer description: One-click checkout enabled (1 = yes, 0 = no) card_info: type: string nullable: true description: Stored payment card information created: type: string format: date-time description: Customer creation timestamp token: type: string description: Customer login token (for building passwordless ?token= sign-in links). Only returned in the POST /customer 201 response, never on GET or list responses. order_count: type: integer description: Total number of orders placed total_spent: type: number description: Total amount spent by customer. Single-customer GET only — list responses carry the same value as total_amount. total_amount: type: number description: Total amount spent by customer (list responses only) point_balance: type: integer description: Current active loyalty point balance. Included on single-customer GET, and on list responses when with_points=1 is passed. CustomerList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' customers: type: array items: $ref: '#/components/schemas/Customer' CustomerPoint: type: object properties: customer_point_id: type: integer customer_id: type: integer order_id: type: integer nullable: true description: Order the entry is linked to, if any time: type: string format: date-time description: When the entry was created status: type: string enum: [approved, pending, revoked, expired] pending_until: type: string format: date-time nullable: true description: When a pending entry will be approved (only used for status pending) points: type: integer description: Positive = earned/awarded, negative = spent/deducted expires: type: string format: date-time nullable: true description: When the points expire (null = never) note: type: string nullable: true maxLength: 255 CustomerPointInput: type: object required: - points properties: points: type: integer description: Must be an integer. Positive values award points, negative values deduct them. Max absolute value is 1000000. status: type: string enum: [approved, pending, revoked, expired] default: approved pending_until: type: string format: date-time nullable: true description: Only applied when status is pending expires: type: string format: date-time nullable: true description: Expiry date for the points (null = never) order_id: type: integer nullable: true description: Optional order to link the entry to (must belong to the shop) note: type: string nullable: true maxLength: 255 CustomerPointList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' balance: type: integer description: Active loyalty point balance (FIFO-based - expired-but-unspent points are deducted) pending: type: integer description: Sum of pending point entries customer_points: type: array items: $ref: '#/components/schemas/CustomerPoint' CustomerInput: type: object required: - email properties: email: type: string format: email description: Customer email address (must be unique) name: type: string description: Customer full name company: type: string description: Company name vat_number: type: string description: VAT/Tax number own_id: type: string description: Customer's own reference ID phone: type: string description: Phone number address: type: string description: Billing address zipcode: type: string description: Billing postal code city: type: string description: Billing city country_id: type: integer description: Billing country ID shipping_name: type: string description: Shipping contact name shipping_address: type: string description: Shipping address shipping_zipcode: type: string description: Shipping postal code shipping_city: type: string description: Shipping city shipping_country_id: type: integer nullable: true description: Shipping country ID. Must exist in this webshop (see GET /country) - an unknown id is rejected with 400. Pass null to clear it. ean_number: type: string description: EAN number for public-sector invoicing customer_group_id: type: integer nullable: true description: Customer group. Must exist in this webshop (see GET /customer-group) - an unknown id is rejected with 400. Pass null to remove the customer from their group. customer_group_name: type: string description: | Import convenience - the group's NAME instead of its id. The group is created if it does not exist yet (same principle as country_name in the admin CSV import), so a migration does not need a separate lookup per group. Wins over customer_group_id when both are given. customer_company_id: type: integer nullable: true description: B2B company. Must exist in this webshop (see GET /customer-company). Pass null to detach the customer. customer_company_role: type: string enum: [admin, member] description: Role in the B2B company. Defaults to 'member' when the customer is linked to a company. points: type: integer description: | Loyalty points granted on CREATION only (and only when the loyalty program is active on the shop). Ignored on update, and on bulk rows that match an existing customer - otherwise re-running the same import would grant the points again. Use POST /customer/{id}/point to adjust points later. password: type: string description: Customer password. Stored as an md5 hash. A random one is generated when not provided. subscription_id: type: string description: External subscription ID (e.g., from payment gateway or external system) send_welcome: type: boolean description: Send welcome email with login credentials to the customer after creation CustomerBulkResult: type: object description: | Result of a bulk POST /customer. One entry per submitted row, in the order they were sent. Rows are independent: a rejected row does not roll back the others. A row that fails on a relation (unknown country, group or company) may already have had its plain fields written - fix the row and send it again, the import is safe to re-run. properties: created: type: integer description: Rows that created a new customer updated: type: integer description: Rows whose email already existed and were updated instead failed: type: integer description: Rows that were rejected. The rest of the rows are still processed. results: type: array items: type: object properties: index: type: integer description: Position in the submitted array email: type: string customer_id: type: integer nullable: true status: type: string enum: [created, updated, error] error: type: string description: Only present when status is error CustomerGroupBrand: type: object description: One brand rule on a customer group. properties: brand_id: type: integer discount_percent: type: number nullable: true description: Extra discount on this brand for members of the group (0-100) visibility: type: string enum: [default, blocked, exclusive] description: | default = the group sees the brand like everyone else. blocked = members of THIS group cannot see or buy the brand. exclusive = only groups that mark the brand exclusive can see it - it is hidden from everyone else, including logged-out visitors, feeds and the sitemap. CustomerGroup: type: object properties: customer_group_id: type: integer name: type: string description: Unique per webshop. Doubles as the key customer_group_name matches on during import. weight: type: integer nullable: true description: Sort order in admin (lower first) discount_percent: type: number nullable: true description: Percentage discount members get on everything (0-100) min_order_value: type: number nullable: true description: Minimum order value for members customer_count: type: integer description: Customers whose OWN group this is. Members that only inherit the group from their company are not counted. brands: type: array items: $ref: '#/components/schemas/CustomerGroupBrand' CustomerGroupInput: type: object required: - name properties: name: type: string description: Group name. Must be unique in the webshop - a duplicate is rejected with 409. weight: type: integer description: Sort order in admin discount_percent: type: number nullable: true description: Percentage discount for members, clamped to 0-100. Pass null for none. min_order_value: type: number nullable: true description: Minimum order value. Pass null for none. brands: type: array description: REPLACES the group's entire brand setup. A brand left out loses both its discount and its visibility rule. items: $ref: '#/components/schemas/CustomerGroupBrand' CustomerGroupList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' customer_groups: type: array items: $ref: '#/components/schemas/CustomerGroup' CustomerGroupPrice: type: object properties: product_id: type: integer product_name: type: string sku: type: string nullable: true attribute_value_id: type: integer description: 0 = the product itself. A value above 0 prices one variant. min_quantity: type: integer description: Lower bound of the quantity tier. 1 = from the first unit. price: type: number description: Price in the shop's own price convention (excluding VAT when the shop keeps prices excluding VAT), same as product.price. CustomerGroupPriceList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' prices: type: array items: $ref: '#/components/schemas/CustomerGroupPrice' CustomerGroupPriceInput: type: object required: - product_id properties: product_id: type: integer description: Must be a product in this webshop price: type: number nullable: true description: The group's fixed price. 0 or null REMOVES the row. min_quantity: type: integer default: 1 description: Quantity from which the price applies. Send several rows for the same product to build a quantity ladder. attribute_value_id: type: integer default: 0 description: Price one specific variant. Leave out (or 0) for the product itself. CustomerGroupPriceResult: type: object properties: set: type: integer removed: type: integer results: type: array items: type: object properties: index: type: integer product_id: type: integer attribute_value_id: type: integer min_quantity: type: integer status: type: string enum: [set, removed, error] error: type: string CustomerCompany: type: object description: A B2B company. Groups several customers, and can carry a customer group that all members inherit. properties: customer_company_id: type: integer name: type: string customer_group_id: type: integer nullable: true description: Group the whole company inherits. Members without their own group get this one. vat_number: type: string nullable: true vat_number_valid: type: integer nullable: true description: Result of the last VIES check (1 = valid) vat_number_checked: type: string format: date-time nullable: true vat_number_name: type: string nullable: true description: Company name as returned by VIES ean_number: type: string nullable: true address: type: string nullable: true zipcode: type: string nullable: true city: type: string nullable: true contact_name: type: string nullable: true contact_email: type: string nullable: true member_count: type: integer members: type: array description: Single-company GET only items: type: object properties: customer_id: type: integer name: type: string email: type: string role: type: string enum: [admin, member] CustomerCompanyInput: type: object required: - name properties: name: type: string customer_group_id: type: integer nullable: true description: Must exist in this webshop. Pass null for none. vat_number: type: string ean_number: type: string address: type: string zipcode: type: string city: type: string contact_name: type: string contact_email: type: string CustomerCompanyList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' customer_companies: type: array items: $ref: '#/components/schemas/CustomerCompany' CustomerField: type: object properties: customer_field_id: type: integer description: Customer field ID name: type: string description: Display name of the field tag: type: string description: Technical tag/identifier for the field type: type: string enum: [string, list] description: Field type - 'string' for text input, 'list' for dropdown values: type: array items: type: string description: Available values (for list type fields) weight: type: integer description: Sort order (lower numbers appear first) CustomerFieldList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' customer_fields: type: array items: $ref: '#/components/schemas/CustomerField' CustomerFieldInput: type: object required: - name - type properties: name: type: string description: Display name of the field tag: type: string description: Technical tag/identifier for the field type: type: string enum: [string, list] description: Field type - 'string' for text input, 'list' for dropdown values: type: string description: Comma-separated list of values (for list type fields) example: "Value 1,Value 2,Value 3" weight: type: integer description: Sort order (lower numbers appear first) BlogPost: type: object properties: blog_post_id: type: integer description: Blog post ID user_id: type: integer description: Author user ID title: type: string description: Blog post title body: type: string description: Blog post content (HTML) rewrite_url: type: string description: SEO-friendly URL slug url: type: string description: Full URL to blog post created: type: string format: date-time description: Creation timestamp updated: type: string format: date-time description: Last update timestamp publish_after: type: string format: date-time nullable: true description: Scheduled publication date meta_title: type: string nullable: true description: SEO meta title meta_description: type: string nullable: true description: SEO meta description no_index: type: integer description: SEO no-index flag (1 = no index, 0 = index) open_graph_title: type: string nullable: true description: Open Graph title for social media open_graph_description: type: string nullable: true description: Open Graph description for social media open_graph_image: type: string nullable: true description: URL of the Open Graph image used when the blog post is shared on social media categories: type: array items: type: integer description: Array of category IDs products: type: array items: type: integer description: Array of related product IDs images: type: array items: $ref: '#/components/schemas/ImageResponse' description: Array of blog post images meta: type: string nullable: true description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Freeform theme values the storefront template can read via getMetaValue(). extension_data: type: array items: $ref: '#/components/schemas/ExtensionData' description: Theme-defined custom fields for the blog post BlogPostList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' blog_posts: type: array items: $ref: '#/components/schemas/BlogPost' BlogPostInput: type: object required: - title properties: title: type: string description: Blog post title body: type: string description: Blog post content (HTML) rewrite_url: type: string description: SEO-friendly URL slug. Auto-generated as "blog/" on POST if omitted. is_online: type: boolean description: Visibility flag. 1 = visible, 0 = draft. publish_after: type: string format: date-time description: Scheduled publication date meta_title: type: string description: SEO meta title meta_description: type: string description: SEO meta description meta: type: string description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Keys are lowercased; empty lines are dropped. On write, REPLACES the whole field. no_index: type: boolean description: SEO no-index flag open_graph_title: type: string description: Open Graph title for social media open_graph_description: type: string description: Open Graph description for social media open_graph_image: type: string description: URL of the Open Graph image used when the blog post is shared on social media categories: type: array items: type: integer description: Attach the blog post to these category_ids. On write, REPLACES the existing list. images: type: array items: $ref: '#/components/schemas/ImageUpload' description: Array of images to upload or manage remove_images: type: boolean description: Remove all existing images from blog post example: false extension_data: $ref: '#/components/schemas/ExtensionDataInput' Page: type: object properties: page_id: type: integer description: Page ID headline: type: string description: Page headline/title text: type: string description: Page content (HTML) rewrite_url: type: string description: SEO-friendly URL slug url: type: string description: Full URL to page weight: type: integer description: Display order weight is_front: type: integer description: Front page flag (1 = front page, 0 = regular page) meta_title: type: string nullable: true description: SEO meta title meta_description: type: string nullable: true description: SEO meta description meta: type: string nullable: true description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Freeform theme values the storefront template can read via getMetaValue(). no_index: type: integer description: SEO no-index flag (1 = no index, 0 = index) tag: type: string nullable: true description: Page tag/category search: type: string nullable: true description: Additional search content last_modified: type: string format: date-time description: Last modification timestamp open_graph_title: type: string nullable: true description: Open Graph title for social media open_graph_description: type: string nullable: true description: Open Graph description for social media open_graph_image: type: string nullable: true description: Open Graph image URL for social media primary_search_phrase: type: string nullable: true description: Primary SEO keyword images: type: array items: $ref: '#/components/schemas/ImageResponse' description: Array of page images extension_data: type: array items: $ref: '#/components/schemas/ExtensionData' description: Theme-defined custom fields for the static page PageList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' pages: type: array items: $ref: '#/components/schemas/Page' PageInput: type: object required: - headline properties: headline: type: string description: Page headline/title text: type: string description: Page content (HTML) rewrite_url: type: string description: SEO-friendly URL slug weight: type: integer description: Display order weight is_front: type: boolean description: Front page flag meta_title: type: string description: SEO meta title meta_description: type: string description: SEO meta description meta: type: string description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Keys are lowercased; empty lines are dropped. On write, REPLACES the whole field. no_index: type: boolean description: SEO no-index flag tag: type: string description: Page tag/category open_graph_title: type: string description: Open Graph title for social media open_graph_description: type: string description: Open Graph description for social media open_graph_image: type: string description: Open Graph image URL for social media primary_search_phrase: type: string description: Primary SEO keyword images: type: array items: $ref: '#/components/schemas/ImageUpload' description: Array of images to upload or manage remove_images: type: boolean description: Remove all existing images from page example: false extension_data: $ref: '#/components/schemas/ExtensionDataInput' BasketQuoteLineInput: type: object required: - amount properties: product_id: type: integer description: Product ID. Either product_id or own_id is required. own_id: type: string description: Shop's own product ID/SKU (alternative to product_id). amount: type: integer minimum: 1 description: Quantity of this line. attributes: type: object description: > Variant selection as {attribute_id: attribute_value_id}. For a POS line that only knows the variant value id, use attribute_value_id instead. additionalProperties: type: integer attribute_value_id: type: integer description: Bare variant value id (the attribute id is resolved automatically). BasketQuoteInput: type: object required: - products properties: products: type: array items: $ref: '#/components/schemas/BasketQuoteLineInput' customer_id: type: integer description: Apply this customer's group/B2B prices and loyalty context. voucher_code: type: string description: Voucher/discount code to apply (validated for active + usable). use_points: type: integer description: Loyalty points to redeem (reduces the payable amount). del_country_id: type: integer description: Delivery country id (drives VAT + shipping). Defaults to the shop's home country. shipping_id: type: integer description: Shipping/pickup method id (drives the shipping line). BasketQuoteLine: type: object properties: product_id: type: integer own_id: type: string nullable: true name: type: string amount: type: integer attributes: type: object unit_price: type: number description: Discounted unit price (incl. VAT when the shop is VAT-inclusive). original_unit_price: type: number description: Unit price for 1 pc with no quantity/cart discount. line_total: type: number description: amount × unit_price before cart-level (campaign/voucher) discounts. discounted: type: boolean in_stock: type: integer bundle: type: array description: For bundle products, the contained products (name + count). items: type: object properties: name: type: string count: type: integer age_limit: type: integer nullable: true description: Age restriction on the product (e.g. 16 or 18), or null. upsell: type: object nullable: true description: > Upsell hint when a quantity/tiered or bulk discount is within reach of the current quantity ("buy X more and pay less per unit"), or null. The tier price is computed with the same engine, so it respects the customer's B2B/group prices. properties: type: type: string enum: [tier, bulk] need: type: integer description: How many more units to reach the next discount threshold. threshold: type: integer description: The quantity at which the better price applies. unit_price: type: number description: Unit price at the threshold (tier type only). saving: type: number description: Saving per unit versus the current unit price. BasketQuote: type: object properties: currency: type: string prices_incl_vat: type: boolean lines: type: array items: $ref: '#/components/schemas/BasketQuoteLine' items_total: type: number description: Sum of line_total (before cart-level discounts). campaign_discount: type: number campaign_hint: type: string nullable: true voucher_discount: type: number voucher_code: type: string nullable: true point_discount: type: number subtotal: type: number description: Items after campaign + voucher (excl. deposits). shipping: type: number deposits: type: array items: type: object properties: name: type: string units: type: integer unit_amount: type: number total: type: number deposit_total: type: number vat: type: number description: Total VAT of the order (items + shipping), matching the booked order. total: type: number description: Grand total incl. shipping and deposits. payable: type: number description: total minus point_discount (the amount to charge). BasketOrderInput: type: object required: - products - email properties: products: type: array items: $ref: '#/components/schemas/BasketQuoteLineInput' email: type: string description: Order email (required by the checkout engine). name: type: string description: Order/customer name. Defaults to "POS". phone: type: string company_name: type: string customer_id: type: integer description: Link the order to this customer and apply their group/B2B prices. voucher_code: type: string use_points: type: integer del_country_id: type: integer shipping_id: type: integer payment_gateway_id: type: integer description: Payment gateway. Invoice/other gateways auto-send the invoice. paid: type: boolean description: Mark the order paid on creation (e.g. cash/card paid at the register). order_label_id: type: integer description: Order label to add (e.g. a POS label). internal_comments: type: string description: Internal order note (e.g. a POS staff note), not shown to the customer. send_invoice: type: boolean description: Force-send the invoice email even for non-invoice gateways. send_invoice_only_to_customer: type: boolean description: When sending the invoice, do not copy the shop owner. BasketOrderResult: type: object properties: order_id: type: integer webshop_order_id: type: integer description: Shop-facing order number. total_price: type: number paid: type: integer description: 1 if the order was marked paid, else 0. Error: type: object properties: error: type: string description: Error message or code (e.g. "insufficient_scope" for 403 scope-mismatch responses) message: type: string description: Human-readable explanation (included on 403 insufficient_scope responses) code: type: integer description: HTTP status code method: type: string description: Request method that was rejected (403 insufficient_scope only) path: type: string description: Request path that was rejected (403 insufficient_scope only) current_scope: type: string enum: [read, write, all] description: Scope the token has (403 insufficient_scope only) required_scope: type: string enum: [read, write] description: Scope the endpoint would need (403 insufficient_scope only) RateLimitError: type: object properties: error: type: object properties: msg: type: string description: Human-readable explanation of the limit example: "Rate limit exceeded. The limit is 1000 requests per day per token and resets at midnight server time." limit: type: integer description: The token's daily quota example: 1000 remaining: type: integer description: Calls left in the current window (always 0 on a 429) example: 0 reset: type: integer description: Unix timestamp of the next reset example: 1785110400 retry_after: type: integer description: Seconds to wait before retrying, matching the Retry-After header example: 21600 Webshop: type: object properties: webshop_id: { type: integer } name: { type: string } description: { type: string } domain: { type: string } rewrite_url: { type: string } currency: { type: string, example: DKK } language: { type: string, example: DA } country_code: { type: string, example: DK } prices_incl_vat: { type: integer, enum: [0, 1] } use_stock: { type: integer, enum: [0, 1] } use_batch: { type: integer, enum: [0, 1] } allow_negative_stock: { type: integer, enum: [0, 1] } after_basket: { type: string, enum: [product, basket, also, back] } skip_approve: { type: integer, enum: [0, 1] } hide_delivery: { type: integer, enum: [0, 1] } page_limit: { type: integer } search_wildcard: { type: integer, enum: [0, 1] } search_large: { type: integer, enum: [0, 1] } search_sku: { type: integer, enum: [0, 1] } send_notice: { type: integer, enum: [0, 1] } order_notice_to: { type: string } order_mail_subject: { type: string } order_mail_message: { type: string } terms: { type: string, nullable: true } robots_txt: { type: string, nullable: true } stylesheet: { type: string, nullable: true, description: "Custom CSS injected on the storefront (the same field edited under /theme-setup/stylesheet in admin)." } theme: { type: string } private_theme: { type: string } partner_theme_id: { type: integer } default_country: type: object nullable: true properties: country_id: { type: integer } name: { type: string } code: { type: string } status: { type: string } live_since: { type: string, format: date-time, nullable: true } created: { type: string, format: date-time } lp_active: { type: integer, enum: [0, 1], description: "Loyalty program enabled. All lp_* fields are read-only via REST — configured in admin under Loyalty program." } lp_free_shipping: { type: integer, enum: [0, 1], description: "Free shipping when redeeming points" } lp_base_points: { type: integer, description: "Points earned per currency unit spent" } lp_approval_period: { type: integer, description: "Days before earned points are approved" } lp_add_time: { type: string, nullable: true, description: "When points are awarded (e.g. on order sent)" } lp_signup_points: { type: integer, description: "Points awarded on customer signup" } lp_base_cost: { type: integer, description: "Points required per currency unit when redeeming" } lp_min_points: { type: integer, description: "Minimum points required before redeeming" } lp_max_discount: { type: integer, description: "Maximum discount (percent of order) payable with points" } lp_expires: { type: integer, description: "Days until earned points expire (0 = never)" } lp_allow_on_sale: { type: integer, enum: [0, 1], description: "Allow redeeming points on discounted products" } lp_exclude_sale_earn: { type: integer, enum: [0, 1], description: "Exclude discounted products from earning points" } lp_exclude_bundles_earn: { type: integer, enum: [0, 1], description: "Exclude bundles from earning points" } lp_exclude_bundles_redeem: { type: integer, enum: [0, 1], description: "Exclude bundles from point redemption" } lp_exclude_shipping_redeem: { type: integer, enum: [0, 1], description: "Points cannot cover shipping — the point discount is capped at the value of the qualifying products" } lp_mail_expires: { type: string, nullable: true, description: "Days before expiry to send a reminder mail (null = off)" } lp_categories: { type: array, items: { type: integer }, description: "Category IDs the loyalty program is limited to (empty = all)" } lp_excluded_products: { type: array, items: { type: integer }, description: "Product IDs excluded from the loyalty program" } WebshopUpdate: type: object description: | Whitelisted writable fields. All fields optional — only the keys you supply are updated. Sensitive fields (api_token, payment credentials, sms passwords) are intentionally NOT writable here. properties: name: { type: string } description: { type: string } currency: { type: string } language: { type: string } country_code: { type: string } prices_incl_vat: { type: integer, enum: [0, 1] } use_stock: { type: integer, enum: [0, 1] } use_batch: { type: integer, enum: [0, 1] } allow_negative_stock: { type: integer, enum: [0, 1] } after_basket: { type: string, enum: [product, basket, also, back] } skip_approve: { type: integer, enum: [0, 1] } hide_delivery: { type: integer, enum: [0, 1] } page_limit: { type: integer } search_wildcard: { type: integer, enum: [0, 1] } search_large: { type: integer, enum: [0, 1] } search_sku: { type: integer, enum: [0, 1] } send_notice: { type: integer, enum: [0, 1] } order_notice_to: { type: string } order_mail_subject: { type: string } order_mail_message: { type: string } terms: { type: string } robots_txt: { type: string } stylesheet: { type: string, description: "Custom CSS injected on the storefront (the same field edited under /theme-setup/stylesheet in admin)." } default_country_id: { type: integer } logo: type: object nullable: true description: | Shop logo. Used by themes that call $webshop->getLogo() (e.g. Montana). NOTE: logo and favicon are shop-level, NOT theme settings — they survive a theme change and therefore do not appear in the GET /theme-settings schema. Set them here, not via PUT /theme-settings/theme/logo. On write, supply exactly one of `data`, `url` or `image_id` — or `remove: true` to clear it. Any existing image is replaced. On read, `image_id`, `filename` and a public `url` are returned (null when no logo is set). properties: image_id: { type: integer, description: "Read: the stored image id. Write: reuse an image already in the shop." } filename: { type: string, description: "Read: stored filename. Write: optional filename for the upload." } url: { type: string, description: "Read: public image URL. Write: remote image URL to download." } data: { type: string, description: "Write only: base64-encoded image data" } remove: { type: boolean, description: "Write only: set true to delete the current image" } favicon: type: object nullable: true description: | Shop favicon. Same read/write shape and the same shop-level (not theme-level) semantics as `logo` above. properties: image_id: { type: integer, description: "Read: the stored image id. Write: reuse an image already in the shop." } filename: { type: string, description: "Read: stored filename. Write: optional filename for the upload." } url: { type: string, description: "Read: public image URL. Write: remote image URL to download." } data: { type: string, description: "Write only: base64-encoded image data" } remove: { type: boolean, description: "Write only: set true to delete the current image" } ThemeSetting: type: object properties: element: type: string description: Top-level group (e.g. "colors", "general") name: type: string description: Setting name within the group value: type: string nullable: true image: type: object nullable: true description: Set instead of `value` when the setting is an uploaded image properties: image_id: { type: integer } url: type: string description: >- Public URL to the image on the shop's own domain. Identical for the single-setting and the list endpoint. ThemeText: type: object properties: theme_text_id: type: integer description: Identifier used when writing a translation original: type: string description: >- The source string exactly as the theme wrote it. Read-only — it is the identity of the row (stored alongside its md5 checksum) and is owned by the theme. translated: type: string nullable: true description: >- The translation. null or empty means untranslated, and the storefront falls back to `original`. hint: type: string nullable: true description: Optional note from the theme about where the string is used section: type: string nullable: true description: Optional grouping supplied by the theme, e.g. "Mail" or "Order" ThemeTextList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' sections: type: array description: All distinct section values for this shop — the valid values for `?section=` items: type: string theme_texts: type: array items: $ref: '#/components/schemas/ThemeText' ThemeTextUpdate: type: object properties: translated: type: string nullable: true description: >- The translation. Pass null or an empty string to clear it. Sending `original` or `checksum` is rejected with 400 — they are read-only. ThemeTextBulkUpdate: type: object required: - translations properties: translations: type: array minItems: 1 maxItems: 500 items: type: object required: - theme_text_id properties: theme_text_id: type: integer translated: type: string nullable: true ThemeTextBulkResult: type: object description: >- Best effort — unknown ids never abort the batch, so this always comes back with 200 and a per-row status. properties: updated: type: integer description: Rows whose translation actually changed unchanged: type: integer description: Rows that already held exactly that value failed: type: integer description: Rows that could not be written results: type: array items: type: object properties: theme_text_id: type: integer nullable: true status: type: string enum: [updated, unchanged, not_found, invalid] PageBuilderPage: type: object properties: theme_page_id: { type: integer } type: type: string description: | Page type. Fixed pages: search, basket, address, shipping, approve, payment, thanks, also, order, return, user-sign-in, user-orders, user-wishlists. Dynamic pages: product, landing_page, category, static_page, blog_post. Theme tags start with `#`. name: type: string nullable: true ids: type: array items: type: integer is_online: type: boolean description: | Whether the page is permanently published. False + no date range = hidden draft. False + date range = visible only inside the date range (i.e. the schedule activates the page during the interval and deactivates it again afterwards). online_from: type: string format: date-time nullable: true description: | Schedule when the page becomes visible. Datetime "YYYY-MM-DD HH:MM:SS". Null = no start restriction. Combined with is_online=false this acts as a scheduled activation (page appears at this date even though is_online=0). online_to: type: string format: date-time nullable: true description: | Schedule when the page stops being visible. Datetime "YYYY-MM-DD HH:MM:SS". Null = no end restriction. Page deactivates after this date regardless of is_online. priority: type: integer description: | Tie-breaker when multiple pages are eligible for the same target — higher wins (then highest theme_page_id). status: type: string enum: [online, offline, scheduled, expired] description: | Computed effective status given is_online and the time window: `online` (visible now), `offline` (manually hidden), `scheduled` (online but not started yet), `expired` (online_to passed). is_active_now: type: boolean description: | True if this page is the one currently being served on the storefront for its target — i.e. it is online, in its time window, and has the highest priority among eligible siblings. PageBuilderPageWithRows: allOf: - $ref: '#/components/schemas/PageBuilderPage' - type: object properties: rows: type: array items: $ref: '#/components/schemas/PageBuilderRow' PageBuilderRow: type: object properties: theme_page_row_id: { type: integer } theme_page_id: { type: integer } name: type: string description: Module/component name weight: { type: integer } position: { type: string, enum: [top, bottom] } custom_title: { type: string, nullable: true } data: type: object description: | Field id → value map. Field types (see GET /page-builder-component for each module's schema): - Plain fields (text/textarea/list/link): strings. - Video fields (type "video"): on input EITHER a string URL to a video (mp4/webm) already hosted in the shop's public files, OR an upload object with one of {data: "<base64>"}, {url: "<remote url>"} (with optional {filename}) or {remove: true}. Uploads are stored raw (no re-encoding) in public-files and the field is set to the resulting public URL. On output: the stored URL string. - Multi-select fields (products, categories): arrays of ids on input, comma-joined strings on output. - Image fields (type "image"): on input an object with one of {data: "<base64>"}, {url: "<remote url>"}, {image_id: N} or {remove: true}; on output an object {image_id, url} where `url` is an absolute URL on the shop's own domain, ready to render. - Repeater fields (type "repeater"): an array of objects, one per repeater row, keyed by subfield id. Image subfields use the same image object shape. On output each row also carries `_id` and `_weight`. Sending the array replaces all existing repeater rows. Campaign: type: object properties: campaign_id: { type: integer } name: { type: string } description: { type: string } price_model: type: string enum: [fixed, cheapest_free, percent, threshold, bundle] description: | - `fixed`: bundle costs a fixed `price` for `min_product_count` items - `cheapest_free`: cheapest of `min_product_count` items is free - `percent`: `percent`% off matching items (requires `min_product_count`) - `threshold`: when basket exceeds `threshold_amount` (incl. VAT), discount `threshold_percent`% on matching items. Optional `threshold_exclude_sale` skips items on `sale_price`. Ignores `min_product_count`. - `bundle`: when any product from `trigger_products`/`trigger_categories` is in basket, give `percent`% discount on all `products`/`categories` (targets). Optional `bundle_exclude_sale` skips target items on `sale_price`. Trigger and target must not overlap. min_product_count: { type: integer } price: { type: number, format: float, nullable: true } percent: { type: integer, nullable: true } threshold_amount: type: number format: float nullable: true description: For `threshold` price_model. The basket subtotal (incl. VAT) required to activate the discount. threshold_percent: type: integer nullable: true description: For `threshold` price_model. Percent discount applied to matching items when threshold is met. threshold_exclude_sale: type: integer enum: [0, 1] description: For `threshold` price_model. When 1, items with active sale_price are skipped (don't count toward threshold and don't receive discount). bundle_exclude_sale: type: integer enum: [0, 1] description: For `bundle` price_model. When 1, target items with active sale_price are skipped (don't receive the bundle discount). created: { type: string, format: date-time } start: type: string format: date-time nullable: true description: Start date ("valid from"). Accepts ISO date "2026-07-15", datetime "2026-07-15 00:00:00", or unix timestamp on write; returned as datetime string on read. Before this moment the campaign is inactive — set it to create the campaign now but activate it automatically on a future date. Null/omitted = active immediately. expires: type: string format: date-time nullable: true description: Expiry date. Accepts ISO date "2026-04-30", datetime "2026-04-30 23:59:59", or unix timestamp on write. Returned as datetime string on read. note: { type: string } products: type: array items: { type: integer } description: Target product_ids. For `bundle` price_model, these are the products that receive the discount. For other types, these are the products the campaign applies to. Combines with `categories`. On write, REPLACES the existing list. categories: type: array items: { type: integer } description: Target category_ids. For `bundle` price_model, products in these categories receive the discount. For other types, products in these categories are eligible. Combines with `products`. On write, REPLACES the existing list. trigger_products: type: array items: { type: integer } description: For `bundle` price_model only. Trigger product_ids — when any of these are in the basket, the discount activates on the target `products`/`categories`. Must not overlap with `products`. trigger_categories: type: array items: { type: integer } description: For `bundle` price_model only. Trigger category_ids. Must not overlap with `categories`. landing_pages: type: array items: { type: integer } description: Landing pages this campaign is scoped to. A campaign can be attached to one or more landing pages in admin — when set, the campaign only applies on those landing pages (combined with `products`/`categories` if also set). On write, REPLACES the existing list. CampaignUpdate: type: object properties: name: { type: string } description: { type: string } price_model: type: string enum: [fixed, cheapest_free, percent, threshold, bundle] min_product_count: { type: integer } price: { type: number, format: float } percent: { type: integer } threshold_amount: { type: number, format: float, description: "For price_model=threshold: basket subtotal (incl. VAT) required." } threshold_percent: { type: integer, description: "For price_model=threshold: percent discount when threshold is met." } threshold_exclude_sale: { type: integer, enum: [0, 1], description: "For price_model=threshold: skip items on sale_price." } bundle_exclude_sale: { type: integer, enum: [0, 1], description: "For price_model=bundle: skip target items on sale_price." } start: type: string format: date-time description: 'Start date ("valid from"). Accepts ISO date, datetime string, or unix timestamp. Before this moment the campaign is inactive; null/omitted = active immediately.' expires: type: string format: date-time description: Accepts ISO date, datetime string, or unix timestamp. note: { type: string } products: type: array items: { type: integer } description: Replace the campaign's product restriction with this list. For bundle, these are the target products. categories: type: array items: { type: integer } description: Replace the campaign's category restriction with this list. For bundle, these are the target categories. trigger_products: type: array items: { type: integer } description: For price_model=bundle only. Trigger products that activate the discount. Must not overlap with `products`. trigger_categories: type: array items: { type: integer } description: For price_model=bundle only. Trigger categories that activate the discount. Must not overlap with `categories`. landing_pages: type: array items: { type: integer } description: Landing page IDs the campaign should be scoped to. REPLACES the existing list. MenuItem: type: object properties: menu_item_id: { type: integer } menu_id: { type: integer } weight: { type: integer } title: { type: string, nullable: true } class: { type: string, nullable: true } type: type: string enum: [product, category, landing_page, page, url, menu, blog] val: type: string description: Object id for typed items, literal URL for type=url url: type: string nullable: true description: Resolved URL (cached_url) — auto-computed from type+val ThemeSettingsReport: type: object properties: theme: { type: string } changed: { type: integer } unchanged: { type: integer } settings: type: array items: type: object properties: element: { type: string } name: { type: string } before: type: object properties: value: { type: string, nullable: true } image_id: { type: integer, nullable: true } after: type: object properties: value: { type: string, nullable: true } image_id: { type: integer, nullable: true } changed: { type: boolean } notes: { type: array, items: { type: string } } StockSettings: type: object properties: product_id: { type: integer } variant: { type: string, nullable: true } min_stock: { type: integer } purchase_price: { type: number, nullable: true } StockLocation: type: object properties: stock_location_id: { type: integer } name: { type: string } tag: { type: string, nullable: true } is_default: { type: boolean } weight: { type: integer, nullable: true } WebshopSettingsSection: type: object properties: section: { type: string } label: { type: string } admin_url: { type: string } fields: type: object additionalProperties: type: object properties: label: { type: string } type: { type: string, enum: [string, text, html, int, bool, enum, language, country_code] } value: {} impact: { type: string, enum: [normal, wide] } allowed: { type: array, items: { type: string } } StatsEnvelope: type: object properties: report: { type: string, example: sales/revenue } period: type: object nullable: true description: null for snapshot reports. properties: from: { type: string, format: date } to: { type: string, format: date } days: { type: integer } compare: type: object nullable: true properties: mode: { type: string, enum: [previous, last_year] } from: { type: string, format: date } to: { type: string, format: date } currency: { type: string, example: DKK } totals: type: object additionalProperties: true description: Key figures for the period. Differs per report. compare_totals: type: object nullable: true additionalProperties: true change_pct: type: object nullable: true description: Percentage change per numeric total (not for fields ending in _pct). null when the compare value is 0. additionalProperties: { type: number, nullable: true } rows: type: array items: { type: object, additionalProperties: true } row_count: { type: integer, description: Rows in total before limit/offset } offset: { type: integer } limit: { type: integer } truncated: { type: boolean } breakdowns: type: object additionalProperties: true description: Extra distributions, e.g. by_weekday and by_hour for sales/timing. notes: type: array items: { type: string } admin_url: { type: string, format: uri } generated_at: { type: string, format: date-time } cached: { type: boolean } StatsSummary: type: object properties: webshop_id: { type: integer } currency: { type: string } today: { $ref: '#/components/schemas/StatsPeriod' } yesterday: { $ref: '#/components/schemas/StatsPeriod' } this_month: { $ref: '#/components/schemas/StatsPeriod' } last_month: { $ref: '#/components/schemas/StatsPeriod' } this_year: { $ref: '#/components/schemas/StatsPeriod' } last_year: { $ref: '#/components/schemas/StatsPeriod' } avg_order_price: { type: number, format: float } drilldown: type: object nullable: true properties: month: { type: string } order_count: { type: integer } revenue: { type: number, format: float } revenue_ex_vat: { type: number, format: float } shipping_revenue: { type: number, format: float } credit_count: { type: integer } credit_amount: { type: number, format: float } StatsPeriod: type: object properties: date: { type: string, nullable: true } month: { type: string, nullable: true } year: { type: integer, nullable: true } order_count: { type: integer } revenue: { type: number, format: float } NotFoundEntry: type: object properties: not_found_id: { type: integer } target_url: { type: string } referer_url: { type: string } notice: { type: string, nullable: true } time: { type: string, format: date-time } cnt: type: integer description: Number of times this URL was hit (from GROUP BY) SearchLogEntry: type: object properties: term: { type: string } last_seen: { type: string, format: date-time } results: type: integer description: Number of results that search returned search_count: type: integer description: How many times users searched for this term Shipping: type: object properties: shipping_id: type: integer description: Unique shipping method ID name: type: string description: Shipping method name is_default: type: integer description: Default shipping method flag (0 or 1) cost: type: number format: float description: >- Base shipping cost. Per-country prices (shipping_country.price) and the free-shipping threshold (country.free_above) are managed separately in admin and are not set through this endpoint. weight: type: integer description: Display order weight tracking_url: type: string description: Tracking URL template (use %ID% as placeholder) tag: type: string nullable: true description: >- Free-text tag that can be linked to a stock location (stock_location.tag). Used to show different shipping options depending on which warehouse the customer is shopping from. carrier: type: string nullable: true enum: [null, none, postnord, gls, dao, dhl, bring, burd, swipbox, budbee, instabox, coolrunner, dkfragt, ups, pickup] description: >- Which carrier delivers the parcel. Determines the carrier logo shown in checkout in the built-in themes. null (or an empty string on write) means the carrier is derived automatically from the pickup-point module; "none" suppresses the logo entirely. Any other value must be one of the listed carrier slugs. PaymentGateway: type: object properties: payment_gateway_id: type: integer description: Unique payment gateway ID name: type: string description: Payment gateway name module: type: string description: Payment module identifier is_active: type: integer description: Active status (0 or 1) is_online: type: integer description: Online payment flag (0 or 1) send_invoice: type: integer description: Send invoice flag (0 or 1) capture_on_sent: type: integer description: Capture payment on order sent flag (0 or 1) weight: type: integer description: Display order weight fee: type: number format: float description: Payment gateway fee nullable: true ShippingDetailed: allOf: - $ref: '#/components/schemas/Shipping' - type: object properties: countries: type: array items: type: object properties: country_id: type: integer description: Country ID name: type: string description: Country name iso_code: type: string description: ISO country code description: List of countries where this shipping method is available ShippingInput: type: object required: - name properties: name: type: string description: Shipping method name is_default: type: integer description: Default shipping method flag (0 or 1) cost: type: number format: float description: >- Base shipping cost. Per-country prices (shipping_country.price) and the free-shipping threshold (country.free_above) are managed separately in admin and are not set through this endpoint. weight: type: integer description: Display order weight tracking_url: type: string description: Tracking URL template (use %ID% as placeholder) tag: type: string nullable: true description: >- Free-text tag that can be linked to a stock location (stock_location.tag). Used to show different shipping options depending on which warehouse the customer is shopping from. carrier: type: string description: Carrier logo in checkout (PUT only). "" = derived from the module, "none" = no logo. free_above: type: number nullable: true description: PUT only. Free shipping from this order total for this method (overrides the country's limit). null = use the country's limit. never_free: type: boolean description: PUT only. Never free, even above the free-shipping limit. is_vat_free: type: boolean description: PUT only. No VAT on this shipping method. max_weight: type: integer nullable: true description: PUT only. Max basket weight in grams, null = no limit. show_when_too_heavy: type: boolean description: PUT only. Show the method (disabled) when the basket is too heavy. send_mail_notice: type: boolean description: PUT only. Mail the customer when the order is sent. send_mail_ready_for_pickup: type: boolean description: PUT only. Mail the customer when the order is ready for pickup. send_mail_picked_up: type: boolean description: PUT only. Mail the customer when the order is picked up. ShippingPriceCell: type: object properties: country_id: { type: integer } country: { type: string } country_code: { type: string } shipping_id: { type: integer } shipping: { type: string } enabled_in_country: { type: boolean } price_excl_vat: { type: number, nullable: true } price_incl_vat: { type: number, nullable: true } vat_pct: { type: number } recurring_price_excl_vat: { type: number, nullable: true, description: Price for subscription renewals } weight_prices: type: array items: type: object properties: weight_grams: { type: integer } price_excl_vat: { type: number } price_incl_vat: { type: number } free_above: { type: number, nullable: true } never_free: { type: boolean } in_running_split_test: { type: boolean } Redirect: type: object properties: redirect_id: type: integer description: Unique redirect ID from: type: string description: Source URL path (without leading slash) to: type: string description: Destination URL (can be relative or absolute) is_regex: type: integer description: Regex flag (0 or 1) redirect_args: type: integer description: Redirect query string arguments flag (0 or 1) usage_count: type: integer description: Number of times this redirect has been used used: type: string deprecated: true description: >- Raw database value behind usage_count, returned as a string. Kept for backwards compatibility — use usage_count. last_used: type: string format: date-time description: Last time this redirect was used RedirectList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' redirects: type: array items: $ref: '#/components/schemas/Redirect' RedirectInput: type: object required: - from - to properties: from: type: string description: Source URL path (without leading slash) to: type: string description: Destination URL (can be relative or absolute) is_regex: type: integer description: Regex flag (0 or 1) default: 0 redirect_args: type: integer description: Redirect query string arguments flag (0 or 1) default: 0 Menu: type: object properties: menu_id: type: integer description: Menu ID name: type: string description: Menu name tag: type: string description: Menu tag for identification location: type: string nullable: true enum: ["", top, bottom, left, right] description: >- Where the theme renders the menu. Only these four values exist — anything else returns 400. An empty value means the menu is not bound to a location and will not be rendered by the theme. parent_id: type: integer description: >- Parent menu ID (0 for root). This field ONLY controls how the menu tree is drawn in admin (and themes that walk it via getChildren()). It does NOT make the menu appear as a dropdown in the shop: what renders a submenu is a menu item of type=menu in the parent menu whose `val` is this menu's id. Use POST /menu/{id}/submenu to create both halves at once. Setting parent_id alone gives you a menu that is nested in admin but invisible in the storefront. reference_count: type: integer description: >- Number of menu items of type=menu (anywhere in this shop) that link this menu in as a submenu. May be greater than 1 — the same menu is allowed to hang in several places. is_submenu: type: boolean description: >- True when reference_count > 0, i.e. the menu is linked in as a submenu somewhere and is therefore rendered by the theme through its parent rather than by its own tag/location. MenuDetail: allOf: - $ref: '#/components/schemas/Menu' - type: object properties: items: type: array description: >- Menu items in this menu. Same shape as GET /menu-item (minus menu_id, which is implied by the parent menu). items: $ref: '#/components/schemas/MenuItem' LandingPage: type: object properties: landing_page_id: type: integer description: Landing page ID name: type: string description: Landing page name weight: type: integer description: Display order weight is_online: type: integer description: Whether landing page is online (0 or 1) no_index: type: integer description: NoIndex flag for SEO (0 or 1) no_internal_search: type: integer description: Exclude from internal search (0 or 1) activate_rss: type: integer description: RSS feed activation (0 or 1) stock: type: integer description: Stock filter value rating: type: integer description: Rating filter value online_under: type: integer description: Hours since product online limit: type: integer description: Product limit rewrite_url: type: string description: URL rewrite path empty_redir: type: string description: Redirect URL when no products tag: type: string description: Tag for identification description_a: type: string description: Primary description description_b: type: string description: Secondary description meta_title: type: string description: Meta title for SEO meta_description: type: string description: Meta description for SEO meta: type: string nullable: true description: >- Extra fields ("Ekstra-felter") as key=value pairs, one per line (e.g. "kategorier=aaret-rundt"). Freeform theme values the storefront template can read via getMetaValue(). type: type: string description: Combination type (AND/OR) order_by: type: string enum: [name, price, sale_price, weight, rand, most_sold, newest, rating, product_score, availability] description: > Product ordering method. availability = in-stock + with image first, then in-stock without image, then out-of-stock with image, then out-of-stock without image (product weight is tiebreaker within each group; order_method is ignored for availability). order_method: type: string description: Sort direction (ASC/DESC) stock_is: type: string description: Stock comparison operator sales: type: string description: Sales filter rating_is: type: string description: Rating comparison operator allow_negative_stock: type: string description: Allow negative stock filter xml_filename: type: string description: XML feed filename last_modified: type: string format: date-time description: Last modification timestamp link_to: type: string description: Full URL to the landing page product_count: type: integer description: Number of products in this landing page images: type: array items: type: object properties: image_id: type: integer description: Image ID weight: type: integer description: Display order weight description: type: string description: Image description/alt text url: type: string description: Full URL to image (1000x1000) description: Array of landing page images products: type: array items: type: object properties: id: type: integer description: Product ID name: type: string description: Product name url: type: string description: Product URL description: Array of products extension_data: type: array items: $ref: '#/components/schemas/ExtensionData' description: Theme-defined custom fields for the landing page LandingPageDetail: allOf: - $ref: '#/components/schemas/LandingPage' - type: object properties: link_to: type: string description: Public URL of landing page product_count: type: integer description: Number of matching products match_type: type: string enum: ['AND', 'OR'] description: How rules are combined. AND = all rules must match, OR = at least one rule must match. rules: type: array description: >- Filter rules (landing_page_item rows) that determine which products appear on the page. Same fields as the /landing-page-item endpoint, included here for read convenience so a separate call is not needed. Writes still go through /landing-page-item. items: $ref: '#/components/schemas/LandingPageItemDetail' LandingPageList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' landing-pages: type: array items: allOf: - $ref: '#/components/schemas/LandingPage' - type: object properties: link_to: type: string description: Public URL of landing page product_count: type: integer description: Number of matching products LandingPageItem: type: object properties: landing_page_item_id: type: integer description: Landing page item ID type: type: string enum: [price, sale_price, real_price, profile_id, category_id, label_id, attribute_id, attribute_value_id, product_id, supplier_id, brand_id, search, extension_field] description: Type of filter is: type: string enum: ['=', '<', '>', 'in', 'not in'] description: Comparison operator val: type: string description: Filter value LandingPageItemDetail: allOf: - $ref: '#/components/schemas/LandingPageItem' - type: object properties: landing_page_id: type: integer description: Parent landing page ID pretty_val: type: string description: Human-readable value representation LandingPageItemList: type: object properties: landing_page_id: type: integer description: Parent landing page ID items: type: array items: allOf: - $ref: '#/components/schemas/LandingPageItem' - type: object properties: landing_page_id: type: integer description: Parent landing page ID pretty_val: type: string description: Human-readable value representation LibraryImage: type: object properties: library_image_id: type: integer filename: type: string description: type: string description: Description / alt-text folder: type: string nullable: true url: type: string description: Stable public image URL for the library image LibraryImageList: type: object properties: paging: type: object properties: offset: type: integer limit: type: integer count: type: integer total: type: integer library_images: type: array items: $ref: '#/components/schemas/LibraryImage' LibraryImageInput: type: object description: Provide either url or data. properties: url: type: string description: Public http(s) URL to fetch the image from data: type: string description: Base64-encoded image data (alternative to url) filename: type: string folder: type: string description: Folder to place the image in (created if it does not exist) description: type: string description: Description / alt-text LibraryImageUpdate: type: object properties: folder: type: string description: Move to this folder. An empty string removes it from its folder. description: type: string description: Description / alt-text LibraryFolder: type: object properties: name: type: string count: type: integer LibraryFolderList: type: object properties: library_folders: type: array items: $ref: '#/components/schemas/LibraryFolder' LibraryFolderInput: type: object required: - name properties: name: type: string LegacyPageBuilderSetup: type: object properties: theme_setup_id: { type: integer } name: { type: string } location: type: string description: | Which pages the setup applies to. Page groups: default (everything not covered by another setup), front, products, landingpages, categories, staticpages, blogposts, blog. Fixed pages: also, search, basket, address, shipping, approve, payment, thanks, order, product_review, subscription, user-sign-up, user-sign-in, user-sign-out, user-edit, user-reset-password, user-profile, user-orders, user-subscriptions, user-change-card, 404. The value `url` means the setup is bound to `location_url` instead. location_name: type: string description: Human-readable label for `location`, in the account language. location_url: type: string nullable: true description: Only set when location is `url`. Matched against the request URL. is_regex: type: boolean description: When true, `location_url` is a regular expression. is_active: { type: boolean } preview_token: type: string description: | Append `?preview=<token>` to a storefront URL to render the draft version. Only works on shops still running Smarty 2. preview_url: type: string nullable: true description: Ready-made preview link. Null for setups that have no single URL. row_count: { type: integer } column_count: { type: integer } module_count: { type: integer } has_unpublished_changes: type: boolean description: True when the draft layout differs from the published one. LegacyPageBuilderSetupList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' setups: type: array items: $ref: '#/components/schemas/LegacyPageBuilderSetup' LegacyPageBuilderSetupWithLayout: allOf: - $ref: '#/components/schemas/LegacyPageBuilderSetup' - type: object properties: layout: description: | With `version=published` or `version=draft` this is a single `LegacyPageBuilderLayout`. With `version=both` it is an object with `published` and `draft` keys, each holding a `LegacyPageBuilderLayout`. oneOf: - $ref: '#/components/schemas/LegacyPageBuilderLayout' - type: object properties: published: $ref: '#/components/schemas/LegacyPageBuilderLayout' draft: $ref: '#/components/schemas/LegacyPageBuilderLayout' LegacyPageBuilderLayout: type: object properties: rows: type: array items: type: object properties: index: { type: integer } id: type: string nullable: true description: The row's identifier inside the layout. Display only. css_classes: type: string description: The classes the storefront puts on the row element. columns: type: array items: type: object properties: index: { type: integer } id: { type: string, nullable: true } size: type: integer description: Bootstrap column width, 1-12. css_classes: type: string description: | The classes the storefront puts on the column element. Note that only the md breakpoint follows `size` — `col-xs-12 col-sm-6` is fixed, so every column is full width on phones and half width on small tablets regardless of `size`. modules: type: array items: $ref: '#/components/schemas/LegacyPageBuilderModule' LegacyPageBuilderModule: type: object properties: index: type: integer description: Position within the column. Only present inside a layout. theme_module_instance_id: { type: integer } type: type: string description: Module type, e.g. hero, wysiwyg, product_list. See /legacy-page-builder-module-type. module_name: { type: string, nullable: true } name: { type: string } is_removed: type: boolean description: | The module is soft-deleted. Note that a soft-deleted module that is still referenced by a layout IS still rendered on the storefront, so these are included inside layouts on purpose. is_orphan: type: boolean description: True when no setup layout references this module. theme_setup_id: type: integer description: | Raw stored value. Unreliable — on most rows it points at a setup that no longer exists, and some rows store 0. Use `referenced_by_setup_ids` instead. referenced_by_setup_ids: type: array description: Setups whose published or draft layout actually contains this module. items: type: integer data: type: object nullable: true description: | Published module settings. Null until the module has been published for the first time, so a module can hold content in `preview_data` and nothing here. The keys depend on the module type. preview_data: type: object nullable: true description: Draft module settings. has_unpublished_changes: { type: boolean } images: type: array description: | Every image attached to the module, with public URLs. Same shape as image arrays elsewhere in the API, including `rotate`, `crop` and `focal`. items: $ref: '#/components/schemas/ImageResponse' image_fields: type: array description: | Maps the module's own image fields to public URLs. Module settings store an image as a bare id, which is not resolvable from outside; this pairs each such field with the image it points at. Only images actually owned by the module are included. items: type: object properties: field: { type: string } image_id: { type: integer } url: { type: string } references: type: array description: | Decoded references from the module's settings to other content in the shop. Modules store these with a prefix syntax (`C:12` = category, `L:3` = landing page) that is not self-explanatory from the outside. items: type: object properties: field: { type: string } kind: type: string enum: [category, landing_page, product, image] id: { type: integer } template: type: string nullable: true description: | The Smarty template that turns `data` into HTML for this module. Omitted in /legacy-page-builder-export unless `template_origin` is `instance`, since core and theme templates are identical for every module of the same type and are listed once under `module_types`. template_origin: type: string nullable: true enum: [instance, theme, core] description: | Where the template comes from. `instance` means the template was edited on this specific module, and it then replaces both the module template and the wrapper. missing: type: boolean description: | Only present inside a layout, and only when the layout references a module id that no longer resolves. The entry then carries no data. rendered: type: string nullable: true description: | Only present with `render=1`. The module's HTML. Note that `default_contents` renders empty here — it is a placeholder for the theme's own page content, which only exists during a real storefront request. render_error: type: string nullable: true description: Only present with `render=1`. Why rendering failed, if it did. LegacyPageBuilderModuleList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' modules: type: array items: $ref: '#/components/schemas/LegacyPageBuilderModule' LegacyPageBuilderModuleType: type: object properties: theme_module_id: { type: integer } type: { type: string } name: { type: string } description: { type: string } fields: type: array description: | The setting keys this module type accepts, read from the module's own settings form. A name ending in `[]` is stored as a keyed object or list rather than a single value. items: type: string supported_by_theme: type: boolean nullable: true description: | Whether the active theme lists this module type as supported. Null when the theme does not declare a list. template: { type: string, nullable: true } template_origin: { type: string, nullable: true, enum: [instance, theme, core] } template_path: { type: string, nullable: true } LegacyPageBuilderModuleTypeList: type: object properties: paging: $ref: '#/components/schemas/PagingInfo' wrapper_template: type: string nullable: true description: | The shared outer template wrapped around every module. Listed once here because it is the same for all types. module_types: type: array items: $ref: '#/components/schemas/LegacyPageBuilderModuleType' LegacyPageBuilderExport: type: object properties: webshop_id: { type: integer } theme: { type: string, nullable: true } private_theme: { type: string, nullable: true } smarty_version: { type: integer } has_page_builder: type: boolean description: Whether the active theme supports the current Page Builder. version: type: string enum: [published, draft, both] exported_at: { type: string, format: date-time } setup_count: { type: integer } orphan_count: { type: integer } setups: type: array items: $ref: '#/components/schemas/LegacyPageBuilderSetupWithLayout' orphan_modules: type: array description: Modules that no layout references. These are common in this data. items: $ref: '#/components/schemas/LegacyPageBuilderModule' module_types: type: array items: $ref: '#/components/schemas/LegacyPageBuilderModuleType' paths: /brand: get: summary: Get all brands description: Retrieve all brands tags: - Brands parameters: - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/BrandList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new brand description: Create a new brand tags: - Brands requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BrandInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Brand' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /brand/{id}: get: summary: Get specific brand description: Retrieve a specific brand by ID tags: - Brands parameters: - name: id in: path required: true schema: type: integer description: Brand ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Brand' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update brand description: Update an existing brand tags: - Brands parameters: - name: id in: path required: true schema: type: integer description: Brand ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BrandInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Brand' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete brand description: Delete a brand tags: - Brands parameters: - name: id in: path required: true schema: type: integer description: Brand ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /webhook: get: summary: Get all webhooks description: | Retrieve all webhooks created by the current API token. Each API token can only see and manage webhooks it has created. tags: - Webhooks responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/Webhook' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new webhook description: | Create a new webhook. The webhook will be associated with the API token used to create it. **Important**: The `secret` field is only returned in the creation response. Store it securely as it cannot be retrieved later. **Webhook Signature Verification**: All webhook requests include an `X-Webhook-Signature` header containing an HMAC-SHA256 signature of the request body. Verify the signature on your server: ```php $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']; $body = file_get_contents('php://input'); $expected = 'sha256=' . hash_hmac('sha256', $body, $secret); if (hash_equals($signature, $expected)) { // Signature valid } ``` tags: - Webhooks requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebhookInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Webhook' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden (read-only token) content: application/json: schema: $ref: '#/components/schemas/Error' /webhook/{id}: get: summary: Get specific webhook description: Retrieve a specific webhook by ID. Only webhooks created by the current API token can be accessed. tags: - Webhooks parameters: - name: id in: path required: true schema: type: integer description: Webhook ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Webhook' '403': description: Forbidden (webhook not owned by this token) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update webhook description: Update an existing webhook. Only webhooks created by the current API token can be updated. tags: - Webhooks parameters: - name: id in: path required: true schema: type: integer description: Webhook ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebhookUpdate' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete webhook description: Delete a webhook. Only webhooks created by the current API token can be deleted. tags: - Webhooks parameters: - name: id in: path required: true schema: type: integer description: Webhook ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /manufacturer: get: summary: Get all manufacturers description: Retrieve all manufacturers (producenter) tags: - Manufacturers responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/Manufacturer' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new manufacturer description: Create a new manufacturer (producent) tags: - Manufacturers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ManufacturerInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Manufacturer' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /manufacturer/{id}: get: summary: Get specific manufacturer description: Retrieve a specific manufacturer by ID tags: - Manufacturers parameters: - name: id in: path required: true schema: type: integer description: Manufacturer ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Manufacturer' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update manufacturer description: Update an existing manufacturer tags: - Manufacturers parameters: - name: id in: path required: true schema: type: integer description: Manufacturer ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ManufacturerInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Manufacturer' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete manufacturer description: Delete a manufacturer tags: - Manufacturers parameters: - name: id in: path required: true schema: type: integer description: Manufacturer ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /product: get: summary: Get all products description: Retrieve all products with optional filtering tags: - Products parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 250 default: 5 description: Maximum number of results (max 250) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: last_modified in: query schema: type: string format: date-time description: Filter by last modified date - name: fields in: query schema: type: string description: Comma-separated list of fields to return (e.g. "product_id,name,price") example: "product_id,similar,own_id,stock_settings" - name: own_id in: query schema: type: string description: Exact match on product SKU (own_id). Cannot be combined with other filters. example: "1241630" - name: gtin in: query schema: type: string description: Exact match on product GTIN/EAN. Cannot be combined with other filters. example: "5701234567890" - name: search in: query schema: type: string description: >- Free text search in product names/descriptions, SKU, GTIN, MPN, stock location and the product's extra search keywords. If the webshop setting "Inkluder brand i søgning" is enabled, the brand name is matched as well. example: "laptop" - name: profile_id in: query schema: type: integer description: Filter products by profile ID - name: supplier_id in: query schema: type: integer description: Filter products by supplier ID - name: brand_id in: query schema: type: integer description: Filter products by brand ID - name: category_id in: query schema: type: integer description: Filter products by category ID - name: product_label_id in: query schema: type: integer description: Filter products by product label ID - name: exclude_product_label_id in: query schema: type: integer description: Exclude products that have the given product label ID (returns only products WITHOUT this label) - name: has_images in: query schema: type: string enum: ["yes", "no"] description: Filter products with/without images - name: only_discount in: query schema: type: string enum: ["yes", "no"] description: Filter products with discount only - name: in_stock in: query schema: type: string enum: ["yes", "no"] description: Filter products that are in stock - name: is_online in: query schema: type: string enum: ["yes", "no"] description: Filter products that are online - name: sort in: query schema: type: string description: Field to sort by example: "name" - name: sort_order in: query schema: type: string enum: ["asc", "desc"] default: "asc" description: Sort order - name: product_ids in: query schema: type: string description: Comma-separated list of specific product IDs to fetch (exclusive with other filters) example: "123,456,789" - name: fields in: query schema: type: string description: Comma-separated list of fields to return (e.g. "product_id,name,price") example: "product_id,name,price,stock_settings" responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProductList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new product description: Create a new product (supports bulk creation) tags: - Products parameters: - name: noreturn in: query schema: type: boolean default: false description: Don't return created product data (useful for bulk operations) requestBody: required: true content: application/json: schema: oneOf: - $ref: '#/components/schemas/ProductInput' - type: array items: $ref: '#/components/schemas/ProductInput' examples: productWithImages: summary: Product with image uploads value: name: "Example Product" description: "Product with multiple images" price: 99.99 images: - data: "/9j/4AAQSkZJRgABAQAASABIAAD..." description: "Main product image" weight: 1 - url: "https://example.com/image.jpg" description: "Secondary image" weight: 2 bulkProducts: summary: Bulk product creation with advanced features value: - name: "Product 1" price: 29.99 supplier_name: "ACME Corp" brand_name: "SuperBrand" images: - data: "/9j/4AAQSkZJRgABAQAASABIAAD..." description: "Product 1 image" weight: 1 pdf_files: - name: "manual.pdf" url: "https://cdn.example.com/manuals/product1.pdf" locations: "Main Warehouse": 100 "Store A": 25 - name: "Product 2" price: 39.99 supplier_name: "TechSupplier" images: - url: "https://cdn.example.com/images/product2-sample.jpg" description: "Product 2 image" weight: 1 open_graph_title: "Amazing Product 2" google_shopping_title: "Product 2 - Best Price Online" responses: '201': description: Created content: application/json: schema: oneOf: - $ref: '#/components/schemas/Product' - type: array items: $ref: '#/components/schemas/Product' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: Request Too Large content: application/json: schema: $ref: '#/components/schemas/Error' /product/{id}: get: summary: Get specific product description: Retrieve a specific product by ID tags: - Products parameters: - name: id in: path required: true schema: type: integer description: Product ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Product' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '410': description: Gone (Product removed) content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update product description: Update an existing product tags: - Products parameters: - name: id in: path required: true schema: type: integer description: Product ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProductInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Product' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete product description: Delete a product tags: - Products parameters: - name: id in: path required: true schema: type: integer description: Product ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /product/{id}/copy: post: summary: Copy a product description: >- Duplicate an existing product within the same shop (e.g. for sister/variant articles). The copy is ALWAYS created as a draft (is_online=false) unless you override it, so it never goes live with the source's identity before you finish it. Copied: name (with a " (kopi)" suffix unless overridden), price + sale price, descriptions, meta/SEO fields, images (physically duplicated), categories + main category, product labels, related products, the variant profile with its attributes/variant values, and extension data. A fresh unique rewrite_url is generated. Not copied by default: EAN/barcode (gtin), SKU (own_id), stock levels (stock is event-sourced), the similar-products list, and canonical (the copy is independent). Use the copy_* flags to opt in. Overrides (name, gtin, own_id, price, rewrite_url) are applied in the same call and always win over the copy_* flags. tags: - Products parameters: - name: id in: path required: true schema: type: integer description: product_id of the source product to copy requestBody: required: false content: application/json: schema: type: object properties: name: type: string description: Override the copy's name. Default = source name + " (kopi)". gtin: type: string description: Set the copy's EAN/barcode. Default = none (avoids a duplicate EAN). Wins over copy_gtin. own_id: type: string description: Set the copy's SKU. Default = none. Wins over copy_own_id. price: type: number description: Override the copy's price. Default = same as the source. rewrite_url: type: string description: Override the copy's URL slug. A unique slug is auto-generated if omitted. is_online: type: boolean description: true = publish the copy immediately. Default false (draft, hidden). copy_gtin: type: boolean description: true = carry over the source EAN/barcode (creates a duplicate EAN). Default false. copy_own_id: type: boolean description: true = carry over the source SKU. Default false. copy_similar: type: boolean description: true = copy the similar-products list. Default false. canonical_to_source: type: boolean description: true = set the copy's canonical to the source product (SEO dedup for genuine duplicate variants). Default false (independent). examples: plain_copy: summary: Plain draft copy (no overrides) value: {} sister_article: summary: Sister article with its own identity, published value: name: "Søster t-shirt" gtin: "5799999000011" own_id: "SIS-001" price: 149.50 rewrite_url: "soester-t-shirt" is_online: true responses: '201': description: The newly created copy (draft unless is_online=true) content: application/json: schema: $ref: '#/components/schemas/Product' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden (the source product belongs to another shop) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Source product not found content: application/json: schema: $ref: '#/components/schemas/Error' '410': description: Gone (the source product has been removed) content: application/json: schema: $ref: '#/components/schemas/Error' /category: get: summary: Get all categories description: | Retrieve all categories. Without `limit` or `search`, returns the full unpaged array. When either is set, the response is wrapped in `{paging: {offset, limit, count, total}, categories: [...]}`. Pass `search` for a free-text filter on name, rewrite_url, description, and meta fields. tags: - Categories parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 description: Maximum number of results. Omit for the full unpaged list. Defaults to 25 when `search` is set. - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Optional free-text search on name, rewrite_url, description, and meta fields. - name: fields in: query schema: type: string description: Comma-separated list of fields to return (e.g. "category_id,parent_id,tag") example: "category_id,parent_id,tag" responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CategoryList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new category description: Create a new category tags: - Categories requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CategoryInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Category' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /category/{id}: get: summary: Get specific category description: Retrieve a specific category by ID tags: - Categories parameters: - name: id in: path required: true schema: type: integer description: Category ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Category' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update category description: Update an existing category tags: - Categories parameters: - name: id in: path required: true schema: type: integer description: Category ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CategoryInput' responses: '204': description: No Content — the update was applied. Fetch the category with GET to read back the stored values. '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete category description: Delete a category tags: - Categories parameters: - name: id in: path required: true schema: type: integer description: Category ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /library-image: get: summary: List library images description: List images in the central image library (Billedarkiv). Filter by folder and/or search. Paginated. tags: - Image library parameters: - name: folder in: query schema: type: string description: Only images in this exact folder name - name: search in: query schema: type: string description: Free-text search on filename and description - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 60 description: Page size (max 200) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LibraryImageList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Upload a library image description: Upload an image to the library from a public URL or base64 data. Large images are scaled to max 2000px and served as WebP. tags: - Image library requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LibraryImageInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/LibraryImage' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' /library-image/{id}: get: summary: Get a library image tags: - Image library parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LibraryImage' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update a library image description: Move to a folder (empty string removes it from its folder) and/or change the description (alt-text). tags: - Image library parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LibraryImageUpdate' responses: '204': description: No Content '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete a library image description: Deletes the image. If it was inserted into a text, it disappears there too. tags: - Image library parameters: - name: id in: path required: true schema: type: integer responses: '204': description: No Content '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /library-folder: get: summary: List library folders description: List folders in the image library, each with its image count. Includes empty folders. tags: - Image library responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LibraryFolderList' post: summary: Create a library folder tags: - Image library requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LibraryFolderInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/LibraryFolder' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' /library-folder/{name}: get: summary: Get a library folder tags: - Image library parameters: - name: name in: path required: true schema: type: string description: Folder name (URL-encoded) responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LibraryFolder' put: summary: Rename a library folder description: Renames the folder. All images in it move to the new name. tags: - Image library parameters: - name: name in: path required: true schema: type: string description: Current folder name (URL-encoded) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LibraryFolderInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete a library folder description: Deletes the folder. The images are not deleted, they are moved out of the folder. tags: - Image library parameters: - name: name in: path required: true schema: type: string description: Folder name (URL-encoded) responses: '204': description: No Content /supplier: get: summary: Get all suppliers description: Retrieve all suppliers tags: - Suppliers parameters: - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/SupplierList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new supplier description: Create a new supplier tags: - Suppliers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SupplierInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Supplier' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /supplier/{id}: get: summary: Get specific supplier description: Retrieve a specific supplier by ID tags: - Suppliers parameters: - name: id in: path required: true schema: type: integer description: Supplier ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Supplier' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update supplier description: Update an existing supplier tags: - Suppliers parameters: - name: id in: path required: true schema: type: integer description: Supplier ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SupplierInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Supplier' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete supplier description: Delete a supplier tags: - Suppliers parameters: - name: id in: path required: true schema: type: integer description: Supplier ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /basket-quote: post: summary: Price a basket with the real checkout engine (no order created) description: > Prices a basket using the exact same engine as a normal webshop checkout (Basket::price + SafeProduct::getRealPrice), so clients (e.g. a POS) get identical prices and discounts instead of reimplementing the discount math. Applies sale/variant/bulk/tiered/bundle prices per line, cart-level campaigns, vouchers, customer-group/B2B prices (via customer_id), loyalty points and shipping. Creates NO order and mutates no data. All prices are returned inclusive of VAT when the shop uses VAT-inclusive pricing. tags: - Orders requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BasketQuoteInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/BasketQuote' '400': description: Bad Request (missing products array) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /basket-order: post: summary: Create a real order via the checkout engine (full discount provenance) description: > Creates a real order using the same code path as a storefront checkout (Basket::buy). Unlike POST /order — which persists caller-supplied line prices verbatim — this endpoint prices and books ALL discounts automatically: sale/variant/bulk/tiered/bundle prices per line, campaigns (campaign_orders rows), vouchers (voucher_id + voucher_discount), loyalty-point redemption and stock removal (including bundle components). The booked order is therefore identical to the quote returned by /basket-quote for the same input. Intended for staff-POS cash/card/invoice sales. Requires email (the engine needs an order email + name). Invoice/other payment gateways auto-send the invoice (like the storefront); pass send_invoice to force it for other gateways. tags: - Orders requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BasketOrderInput' responses: '201': description: Order created content: application/json: schema: $ref: '#/components/schemas/BasketOrderResult' '400': description: Bad Request (missing products array or email, or the cart is below the customer's/shop's B2B minimum order value) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '500': description: Order creation failed content: application/json: schema: $ref: '#/components/schemas/Error' /order: get: summary: Get all orders description: Retrieve all orders tags: - Orders parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 5 description: Maximum number of results (max 100) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Search orders by various fields (order ID, customer name, email, etc.) example: "12345" - name: last_modified in: query schema: type: string format: date-time description: Orders modified after this date (exclusive with other filters) example: "2023-01-01 12:00:00" - name: time_interval in: query deprecated: true schema: type: string description: >- DEPRECATED. Time interval for order creation (comma-separated dates, both inclusive). This filter is exclusive: when it is set, status, search and all other filters are ignored, and orders are returned oldest first. Use time_from + time_to instead — they combine with the other filters. example: "2023-01-01,2023-12-31" - name: is_webshop_order_id in: query schema: type: boolean default: false description: If true, search parameter is treated as webshop_order_id instead of internal order_id - name: from in: query schema: type: integer description: Return orders with ID greater than this (exclusive) - name: time_from in: query schema: type: string format: date description: >- Start date for date range search (must be used with time_to; both dates are inclusive, and the range combines with status, search and the other filters) example: "2023-01-01" - name: time_to in: query schema: type: string format: date description: >- End date for date range search (must be used with time_from; both dates are inclusive, and the range combines with status, search and the other filters) example: "2023-12-31" - name: status in: query schema: type: string description: Filter by order status example: "completed" - name: order_label_id in: query schema: type: integer description: Filter by order label ID - name: paid in: query schema: type: integer enum: [0, 1] description: Filter by paid/unpaid orders (1 = paid, 0 = unpaid) - name: captured in: query schema: type: integer enum: [0, 1] description: Filter by captured orders (1 = captured, 0 = not captured) - name: shipping_id in: query schema: type: integer description: Filter by shipping method ID - name: del_country_id in: query schema: type: integer description: Filter by delivery country ID - name: supplier_id in: query schema: type: integer description: Filter by supplier ID - name: payment_gateway_id in: query schema: type: integer description: Filter by payment gateway ID - name: voucher_id in: query schema: type: integer description: Filter by voucher (discount code) ID - name: customer_id in: query schema: type: integer description: Filter by customer ID — returns all orders placed by the given registered customer example: 4711 - name: sort in: query schema: type: string enum: ["order_id", "time"] description: Field to sort by example: "order_id" - name: sort_order in: query schema: type: string enum: ["asc", "desc"] default: "asc" description: Sort order responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/OrderList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new order description: Create a new order tags: - Orders requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Order' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}: get: summary: Get specific order description: Retrieve a specific order by ID tags: - Orders parameters: - name: id in: path required: true schema: type: integer description: Order ID - name: is_webshop_order_id in: query schema: type: boolean default: false description: If true, treat 'id' parameter as webshop_order_id instead of internal order_id responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Order' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update order description: Update an existing order tags: - Orders parameters: - name: id in: path required: true schema: type: integer description: Order ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Order' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}/create-label: post: summary: Create shipping label description: Create a shipping label for an order via queue system. Returns queue information for monitoring the label generation process. tags: - Orders parameters: - name: id in: path required: true schema: type: integer description: Order ID - name: is_webshop_order_id in: query schema: type: boolean default: false description: If true, treat 'id' parameter as webshop_order_id instead of internal order_id requestBody: required: true content: application/json: schema: type: object required: - carrier - type properties: carrier: type: string enum: [postnord, gls] description: Shipping carrier type: type: string enum: [shipment, return] description: Label type (shipment or return) order_shipment_id: type: integer nullable: true description: > Optional. Create the label for a single partial shipment (dellevering) instead of the whole order, so weight and contents match what is actually in the box. The shipment must belong to this order (404 otherwise). Only valid together with type=shipment; combining it with type=return returns 400. Omit the field to label the whole order, which is the previous and default behaviour. weight: type: integer nullable: true description: > Optional. Manual shipping weight in grams for this label. Use it when the products have no weight of their own and the packed parcel is weighed instead. The value REPLACES the calculated weight — it is not added to it — so packaging weight must already be included in the number. Sent together with order_shipment_id it is stored on that shipment, otherwise on the order, so two partial shipments are not both declared at the full order weight. The weight is stored before the job is queued, because labels are produced asynchronously. Send 0 to clear it and fall back to the calculated weight. Omit the field to leave any existing weight untouched. examples: postnord_shipment: summary: PostNord shipment label value: carrier: "postnord" type: "shipment" gls_partial_shipment: summary: GLS label for one partial shipment value: carrier: "gls" type: "shipment" order_shipment_id: 42 postnord_weighed_parcel: summary: PostNord label for a parcel weighed on a scale value: carrier: "postnord" type: "shipment" weight: 1450 postnord_return: summary: PostNord return label value: carrier: "postnord" type: "return" gls_shipment: summary: GLS shipment label value: carrier: "gls" type: "shipment" responses: '200': description: Label creation queued successfully content: application/json: schema: type: object properties: success: type: boolean example: true queue_id: type: integer description: Queue ID for monitoring progress example: 123 order_id: type: integer description: Internal order ID example: 1143 webshop_order_id: type: integer description: Webshop order ID example: 2259 status_url: type: string description: URL to check queue status example: "/REST/order-queue/123" '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Order Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '405': description: Method Not Allowed content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}/shipment: get: summary: List partial deliveries on an order description: >- Returns the order's shipments, oldest first, including cancelled ones. An empty list means the order ships as a single parcel. tags: [Orders] parameters: - name: id in: path required: true schema: type: integer - name: is_webshop_order_id in: query required: false schema: type: integer enum: [1] description: Treat id as the shop-facing order number instead of the internal order_id responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/OrderShipmentList' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Order not found content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create a partial delivery description: >- Creates a shipment covering some of the order's lines. Quantities are clamped to what is still unshipped. A shipment with no valid lines is rejected with 400. tags: [Orders] parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderShipmentInput' responses: '200': description: The created shipment content: application/json: schema: $ref: '#/components/schemas/OrderShipment' '400': description: No valid lines content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Order not found content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}/shipment/{shipment_id}: get: summary: Get one partial delivery tags: [Orders] parameters: - name: id in: path required: true schema: type: integer - name: shipment_id in: path required: true schema: type: integer responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/OrderShipment' '404': description: Shipment not found, or it belongs to another order content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update or dispatch a partial delivery description: >- Lines can only be changed while the shipment is in status "new" — once dispatched the parcel has left, and altering the record would make it untrue (409). Send {"send": true} to dispatch it. tags: [Orders] parameters: - name: id in: path required: true schema: type: integer - name: shipment_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderShipmentInput' responses: '200': description: The updated shipment content: application/json: schema: $ref: '#/components/schemas/OrderShipment' '409': description: Shipment already dispatched and can no longer be changed content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete a partial delivery tags: [Orders] parameters: - name: id in: path required: true schema: type: integer - name: shipment_id in: path required: true schema: type: integer responses: '200': description: Deleted '404': description: Shipment not found content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}/download-label: get: summary: Download shipping label description: Download an existing shipping label PDF for an order. Returns the PDF file directly. tags: - Orders parameters: - name: id in: path required: true schema: type: integer description: Order ID - name: is_webshop_order_id in: query schema: type: boolean default: false description: If true, treat 'id' parameter as webshop_order_id instead of internal order_id - name: order_shipment_id in: query schema: type: integer description: > Optional. Download the label belonging to a single partial shipment (dellevering) instead of the order's first label. Required when an order is shipped in several parcels: all label PDFs are stored on the order, so without this parameter every parcel resolves to the same first label. The shipment must belong to this order (404 otherwise), and 404 is also returned when no label has been created for that shipment yet. If the shipment has several labels (a reprint), the most recent one is returned. Omit the field to download the order's first label, which is the previous and default behaviour. responses: '200': description: PDF label file content: application/pdf: schema: type: string format: binary headers: Content-Disposition: schema: type: string example: 'inline; filename="label_2259.pdf"' Content-Type: schema: type: string example: 'application/pdf' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Label Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '405': description: Method Not Allowed content: application/json: schema: $ref: '#/components/schemas/Error' /order-queue/{id}: get: summary: Get order queue status description: Get the status of a queued order operation. Returns the queue status and label data (base64 encoded PDF) when ready. tags: - Orders parameters: - name: id in: path required: true schema: type: integer description: Queue ID responses: '200': description: Queue status content: application/json: schema: type: object properties: queue_id: type: integer example: 123 order_id: type: integer example: 1143 webshop_order_id: type: integer example: 2259 status: type: string enum: [pending, in_progress, executed, failed] description: Queue status example: "executed" action: type: string description: >- Action type (postnord, postnord_return, gls, etc.). A label ordered for a single partial shipment carries the shipment id after a colon, e.g. "postnord:42" — see the labels field. example: "postnord" error: type: object nullable: true description: >- Present when the action produced no usable result. Three cases: the label generation failed ("Label generation failed"), the queue item ended with status 'failed' ("Action failed"), or the label action completed without an error but no PDF could be found ("Label created but PDF unavailable"). The last one is NOT a failure: the parcel is registered with the carrier and `details` names its tracking number, so the caller must show it and must not order a replacement label — doing so registers a second parcel and notifies the customer again. Absent on success and for non-label actions such as capture, invoicing or tracking. properties: message: type: string enum: [Label generation failed, Action failed, Label created but PDF unavailable] description: Short machine-readable error category example: "Label generation failed" details: type: string description: Human-readable error detail (shown in the UI) example: "PostNord kunne ikke danne pakkelabelen (HTTP 400): ugyldigt produktionskundenummer" labels: type: array description: >- Array of labels (only included when status is 'executed'). When the queue item was created for a single partial shipment (action "postnord:42"), only that shipment's label is returned — not every label on the order. items: type: object properties: tracking_number: type: string example: "123456789" carrier: type: string enum: [postnord, gls] example: "postnord" data: type: string format: byte description: Base64 encoded PDF data '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Queue Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '405': description: Method Not Allowed content: application/json: schema: $ref: '#/components/schemas/Error' /stock: get: summary: Get all stock description: | Retrieve stock information for products in the webshop. ### Common usage patterns - **Lookup by SKU or GTIN** — use `?sku=...` or `?gtin=...` to fetch a single product directly. Matches both the parent product's own_id and variant-level SKUs. - **Bulk fetch specific products** — `?product_ids=123,456,789` returns up to 500 products in one call, no paging needed. - **Polling for changes** — `?last_modified=2026-05-13T00:00:00` returns only products whose stock has changed since the given timestamp. Strongly preferred over re-fetching all products on a schedule. The response includes per-location stock breakdown (`stock_locations[]`) and per-variant levels. tags: - Stock parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 5 description: Maximum number of results (max 100) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: >- Free text search on product name, SKU, GTIN, MPN, stock location and the product's extra search keywords. If the webshop setting "Inkluder brand i søgning" is enabled, the brand name is matched as well. - name: profile_id in: query schema: type: integer description: Filter by profile ID - name: supplier_id in: query schema: type: integer description: Filter by supplier ID - name: brand_id in: query schema: type: integer description: Filter by brand ID - name: category_id in: query schema: type: integer description: Filter by category ID - name: product_label_id in: query schema: type: integer description: Filter by product label ID - name: has_images in: query schema: type: string enum: ["yes", "no"] description: Filter products with/without images - name: only_discount in: query schema: type: string enum: ["yes", "no"] description: Filter products with discount - name: in_stock in: query schema: type: string enum: ["yes", "no"] description: Filter products in stock - name: is_online in: query schema: type: string enum: ["yes", "no"] description: Filter online products - name: sort in: query schema: type: string description: Field to sort by - name: sort_order in: query schema: type: string enum: ["asc", "desc"] default: "asc" description: Sort order - name: last_modified in: query schema: type: string format: date-time description: Products modified after this date - name: product_ids in: query schema: type: string description: Comma-separated list of specific product IDs (max 500 per call) example: "123,456,789" - name: sku in: query schema: type: string description: Look up a single product by SKU (own_id). Matches both the product's primary own_id and variant-level SKUs. Mutually exclusive with `gtin` and `product_ids`. example: "FIT0938" - name: gtin in: query schema: type: string description: Look up a single product by GTIN/EAN. Matches both the product's primary GTIN and variant-level GTINs. Mutually exclusive with `sku` and `product_ids`. example: "1234567890123" responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/StockList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Set stock per location (bulk) description: | Set stock for one or more products. The endpoint computes the delta against the current stock and writes that delta to the event-sourced stock log — passing the same value twice is a no-op. ### Targeting a specific stock location When `tag` is provided, stock is set on the matching stock location only — **other locations are left untouched**. This makes the endpoint safe for syncing from a supplier feed (e.g. an external warehouse) without overwriting your own physical stock. Omit `tag` to set the default location. **`tag` requires `SKU`.** Items identified by `GTIN` ignore `tag` entirely and always write to the default location. Use `SKU` for multi-location stock. **An unknown `tag` skips the item.** If no stock location matches the tag, the item is left untouched — it is *not* written to the default location. Falling back would measure the delta against the product's total stock across all locations rather than the location's own, so a single shop's number would overwrite the whole product. The skip is reported in the response `msg` (unless `background=true`, where there is no per-item response), shown as a notice to the shop owner in the admin, and written to the error log. Valid tags are listed as `stock_locations[]` on `GET /stock/{id}`. Use `background=true` for large batches; updates are then queued and processed asynchronously. tags: - Stock parameters: - name: background in: query schema: type: boolean default: false description: Process stock update in background (recommended for bulk operations with many products) requestBody: required: true content: application/json: schema: oneOf: - $ref: '#/components/schemas/StockInput' - type: array items: $ref: '#/components/schemas/StockInput' examples: singleStock: summary: Single stock update by SKU value: SKU: "PROD-001" stock: 50 tag: "main-warehouse" bulkStock: summary: Bulk stock update (use background=true for large batches) value: - SKU: "PROD-001" stock: 50 tag: "main-warehouse" - SKU: "PROD-002" stock: 25 tag: "store-a" - GTIN: "1234567890123" stock: 0 tag: "main-warehouse" responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Stock' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /stock/{id}/settings: get: summary: Minimum stock and purchase price tags: - Stock parameters: - name: id in: path required: true schema: { type: integer } description: Product ID - name: variant_name in: query schema: { type: string } responses: '200': description: Settings content: application/json: schema: $ref: '#/components/schemas/StockSettings' put: summary: Set minimum stock and/or purchase price description: | Sets the minimum stock (reorder point) and/or the purchase price excluding VAT for a product or one variant (`variant_name`). Changing the purchase price does not change stock already registered; use POST /stock-value-apply for that. tags: - Stock parameters: - name: id in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: variant_name: { type: string } min_stock: { type: integer, minimum: 0 } purchase_price: { type: number, minimum: 0, nullable: true } responses: '200': description: Before and after content: application/json: schema: type: object properties: before: { $ref: '#/components/schemas/StockSettings' } after: { $ref: '#/components/schemas/StockSettings' } notes: { type: array, items: { type: string } } '400': description: Invalid value or variant content: application/json: schema: $ref: '#/components/schemas/Error' /product/{id}/restore: post: summary: Restore a deleted product description: Brings a product back from the trash (Papirkurv). It comes back offline. No body needed. tags: - Products parameters: - name: id in: path required: true schema: { type: integer } responses: '200': description: Restored content: application/json: schema: type: object properties: product_id: { type: integer } name: { type: string } is_removed: { type: boolean } is_online: { type: boolean } '400': description: The product is not deleted content: application/json: schema: $ref: '#/components/schemas/Error' /product-review: get: summary: List product reviews tags: - Products parameters: - name: status in: query schema: { type: string, enum: [pending, approved] } - name: product_id in: query schema: { type: integer } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 25 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Reviews, newest first content: application/json: schema: type: object properties: paging: { type: object } reviews: type: array items: type: object properties: product_review_id: { type: integer } product_id: { type: integer } product_name: { type: string } order_id: { type: integer, nullable: true } is_approved: { type: boolean } rating: { type: integer } name: { type: string, nullable: true } description: { type: string, nullable: true } created: { type: string } /product-review/{id}: put: summary: Approve or hide a review description: Sets whether the review is shown on the product page and in the review feed. The pending review counter is reset. tags: - Products parameters: - name: id in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [is_approved] properties: is_approved: { type: boolean } responses: '200': description: Updated delete: summary: Delete a review tags: - Products parameters: - name: id in: path required: true schema: { type: integer } responses: '204': description: Deleted /waitlist: get: summary: Waiting list counts per product description: Products with customers waiting for a back-in-stock mail, with the number waiting. No e-mail addresses. tags: - Products parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 50 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Products content: application/json: schema: type: object properties: paging: { type: object } products: type: array items: type: object properties: product_id: { type: integer } name: { type: string } sku: { type: string, nullable: true } is_online: { type: boolean } waiting: { type: integer } oldest_signup: { type: string } /waitlist/{product_id}: delete: summary: Clear a product's waiting list description: Removes all signups for the product. They will not get a back-in-stock mail. tags: - Products parameters: - name: product_id in: path required: true schema: { type: integer } responses: '200': description: Cleared content: application/json: schema: type: object properties: product_id: { type: integer } removed_signups: { type: integer } /wishlist-product: get: summary: Products on the most wishlists description: Aggregated, no customer data. tags: - Products parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 50 } responses: '200': description: Products content: application/json: schema: type: object properties: products: type: array items: type: object properties: product_id: { type: integer } name: { type: string } sku: { type: string, nullable: true } is_online: { type: boolean } wishlists: { type: integer } units: { type: integer } /client: get: summary: Guest customers description: Customers who ordered without an account, grouped by e-mail, based on sent and paid orders. tags: - Customers parameters: - name: sort in: query schema: { type: string, enum: [orders, amount, time], default: orders } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 25 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Guest customers content: application/json: schema: type: object properties: paging: { type: object } sort: { type: string } clients: type: array items: type: object properties: email: { type: string } name: { type: string, nullable: true } phone: { type: string, nullable: true } orders: { type: integer } total_incl_vat: { type: number } last_order: { type: string } /stock-move: post: summary: Move stock between locations description: | Moves units of a product or variant from one stock location to another as one posting out and one in (total unchanged, no mails, no shared-stock sync). Locations are a tag, an id or `none` (stock without a location). Refused if the from-location has too few units. tags: - Stock requestBody: required: true content: application/json: schema: type: object required: [product_id, from_location, to_location, amount] properties: product_id: { type: integer } variant_name: { type: string } from_location: { type: string } to_location: { type: string } amount: { type: integer, minimum: 1 } dry_run: { type: boolean } responses: '200': description: Moved content: application/json: schema: type: object properties: product_id: { type: integer } variant: { type: string, nullable: true } amount: { type: integer } from_location: { type: string } to_location: { type: string } from_before: { type: integer } from_after: { type: integer } to_before: { type: integer } to_after: { type: integer } total_in_stock: { type: integer } '400': description: Invalid request, nothing moved content: application/json: schema: $ref: '#/components/schemas/Error' /stock-value-apply: post: summary: Update stock value from purchase prices description: | For up to 50 products: fills the purchase price into stock postings (stock in) that have no price, and recalculates the stock value. Postings that have a price are not changed. If any product does not belong to the shop, nothing is changed. tags: - Stock requestBody: required: true content: application/json: schema: type: object required: [product_ids] properties: product_ids: { type: array, maxItems: 50, items: { type: integer } } dry_run: { type: boolean } responses: '200': description: Result per product content: application/json: schema: type: object properties: dry_run: { type: boolean } products: type: array items: type: object properties: product_id: { type: integer } name: { type: string } purchase_price: { type: number, nullable: true } postings_to_price: { type: integer } stock_value_before: { type: integer } stock_value_after: { type: integer, nullable: true } stock_value_before: { type: integer } stock_value_after: { type: integer, nullable: true } notes: { type: array, items: { type: string } } /stock-location: get: summary: List stock locations tags: - Stock responses: '200': description: Locations (not deleted) content: application/json: schema: type: array items: $ref: '#/components/schemas/StockLocation' post: summary: Create stock location tags: - Stock requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } tag: { type: string, description: "Letters, digits, - and _ (max 32), unique in the shop" } weight: { type: integer } responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/StockLocation' '400': description: Invalid name or tag content: application/json: schema: $ref: '#/components/schemas/Error' /stock-location/{id}: get: summary: Get stock location tags: - Stock parameters: - name: id in: path required: true schema: { type: integer } responses: '200': description: Location content: application/json: schema: $ref: '#/components/schemas/StockLocation' put: summary: Update stock location description: Rename, reorder, change tag or make it the default location (is_default can only be set to true). Locations cannot be deleted through the API. tags: - Stock parameters: - name: id in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } weight: { type: integer } tag: { type: string } is_default: { type: boolean } responses: '200': description: Before and after content: application/json: schema: type: object properties: before: { $ref: '#/components/schemas/StockLocation' } after: { $ref: '#/components/schemas/StockLocation' } '400': description: Invalid value content: application/json: schema: $ref: '#/components/schemas/Error' /stock/{id}: get: summary: Get stock for one product description: | Retrieve stock information for a specific product, broken down by location and variant. Returns: - `TOTAL` — sum across all locations - `stock_locations[]` — array of `{name, tag, stock}` per stock location (with per-variant levels) - Per-variant totals (one key per variant name) tags: - Stock parameters: - name: id in: path required: true schema: type: integer description: Product ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Stock' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Set stock count for one product description: | Set the absolute stock count for a single product (or variant, or specific location). The Stock table is event-sourced, so under the hood the endpoint computes `target - current` and writes that delta. Passing the same value twice is a no-op. ### Optional targeting - `variant_name` — required if the product has variants. Matches the variant value case-insensitively (e.g. `"100g"`, `"Rød"`). If the product has variants and `variant_name` is omitted, the response is `400` with the list of valid variant names. - `location` — stock location **tag** (e.g. `"DFRobot"`) or numeric `stock_location_id`. When set, only the matching location is updated; other locations are left untouched. Omit to update the default location. Same semantics as `tag` on `POST /stock`. - `dry_run` — answer with the change (previous, count, delta), the number of customers on the waiting list and whether the product would go online, without writing anything. The difference is registered as one stock posting with the product's purchase price on an increase. Calls from the Shoporama MCP server instead register an increase with the variant's purchase price (else the product's), a decrease as a stock-out posting with the purchase price, and refuse bundles whose stock is calculated from their products (bundle_join_stock) with 400. tags: - Stock parameters: - name: id in: path required: true schema: type: integer description: Product ID requestBody: required: true content: application/json: schema: type: object required: - count properties: count: type: integer description: New absolute stock count for the target. Alias `quantity` is accepted for legacy callers. example: 42 variant_name: type: string description: Variant value to target (required if the product has variants). Case-insensitive. example: "100g" location: type: string description: Stock location tag or numeric `stock_location_id`. Only the matching location is updated. example: "DFRobot" notify_waitlist: type: boolean default: true description: | Send back-in-stock emails to customers on the product's waiting list when stock goes above 0 (also in shops sharing the stock). Set `false` to adjust stock silently; the waiting list is kept and notified on a later stock increase with `true`. example: false dry_run: type: boolean description: Preview the change without writing. examples: simple: summary: Set stock for a product without variants value: count: 42 variant: summary: Set stock for a specific variant value: count: 42 variant_name: "100g" location: summary: Set stock on a specific location (other locations untouched) value: count: 42 location: "DFRobot" responses: '200': description: Success — returns previous count, new count and delta applied content: application/json: schema: type: object properties: product_id: type: integer previous: type: integer count: type: integer delta: type: integer variant: type: string description: Variant value, present only if `variant_name` was supplied location: type: string description: Stock location name, present only if `location` was supplied '400': description: Bad Request (missing count, unknown variant_name, or unknown location) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /batch: get: summary: Get all batches description: | Retrieve all batches (lot tracking). Requires batch feature to be enabled for the webshop. Batches are product-specific and used for lot tracking, expiry date management, and FIFO inventory control. tags: - Batches parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 500 default: 100 description: Maximum number of results (max 500) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: product_id in: query schema: type: integer description: Filter by product ID - name: expiry_before in: query schema: type: string format: date description: Only batches expiring before this date (yyyy-mm-dd) - name: expiry_after in: query schema: type: string format: date description: Only batches expiring after this date (yyyy-mm-dd) - name: with_stock in: query schema: type: integer enum: [0, 1] description: Only batches with stock > 0 responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/BatchList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Batch feature not enabled content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new batch description: Create a new batch for a product. `expiry_date`/`manufacturing_date` must be valid dates (YYYY-MM-DD; a time after the date is ignored) and `supplier_id` must belong to the shop; otherwise 400 (same rules on PUT /batch/{id}). Calls from the Shoporama MCP server also require `batch_number` to be unique for the product. tags: - Batches requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Batch' '400': description: Bad Request (missing product_id or batch_number) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Batch feature not enabled content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Product not found content: application/json: schema: $ref: '#/components/schemas/Error' /batch/{id}: get: summary: Get specific batch description: Retrieve a specific batch by ID tags: - Batches parameters: - name: id in: path required: true schema: type: integer description: Batch ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Batch' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Batch feature not enabled content: application/json: schema: $ref: '#/components/schemas/Error' '410': description: Batch has been deleted content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update batch description: Update an existing batch tags: - Batches parameters: - name: id in: path required: true schema: type: integer description: Batch ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchUpdate' responses: '204': description: Success (No Content) '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Batch feature not enabled content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete batch description: Delete a batch (soft delete) tags: - Batches parameters: - name: id in: path required: true schema: type: integer description: Batch ID responses: '204': description: Success (No Content) '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Batch feature not enabled content: application/json: schema: $ref: '#/components/schemas/Error' /voucher: get: summary: Get all vouchers description: Retrieve all vouchers/discount codes tags: - Vouchers parameters: - name: limit in: query schema: type: integer minimum: 1 default: 5 maximum: 1000 description: Maximum number of results (values above 100 are clamped to 100) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/VoucherList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new voucher description: Create a new voucher/discount code tags: - Vouchers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/VoucherInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Voucher' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /voucher/{id}: get: summary: Get specific voucher description: Retrieve a specific voucher by ID tags: - Vouchers parameters: - name: id in: path required: true schema: type: integer description: Voucher ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Voucher' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update voucher description: Update an existing voucher tags: - Vouchers parameters: - name: id in: path required: true schema: type: integer description: Voucher ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/VoucherInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Voucher' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete voucher description: Delete a voucher tags: - Vouchers parameters: - name: id in: path required: true schema: type: integer description: Voucher ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /order-label: get: summary: Get all order labels description: Retrieve all order labels tags: - Order Labels responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/OrderLabel' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new order label description: Create a new order label tags: - Order Labels requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderLabelInput' examples: basic: summary: Basic order label value: name: "Priority" color: "#ff5722" economic: summary: E-conomic integration label value: name: "e-conomic-ok" color: "#77d413" auto_filter: "customer@example.com" responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/OrderLabel' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /order-label/{id}: get: summary: Get specific order label description: Retrieve a specific order label by ID tags: - Order Labels parameters: - name: id in: path required: true schema: type: integer description: Order label ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/OrderLabel' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update order label description: Update an existing order label tags: - Order Labels parameters: - name: id in: path required: true schema: type: integer description: Order label ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderLabelInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete order label description: Delete an order label tags: - Order Labels parameters: - name: id in: path required: true schema: type: integer description: Order label ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /country: get: summary: Get all countries description: Retrieve all countries tags: - Countries parameters: - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CountryList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new country description: Create a new country tags: - Countries requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CountryInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Country' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /country/{id}: get: summary: Get specific country description: Retrieve a specific country by ID tags: - Countries parameters: - name: id in: path required: true schema: type: integer description: Country ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Country' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update country description: | Update an existing country. `b2b_vat`/`b2c_vat` must be 0-99.99, `free_above` 0 or more (null or "" removes the limit), and `default_shipping_id` must be enabled in the country; otherwise 400 and nothing is saved. `shipping_methods` replaces the list of enabled methods (an empty list means all methods are allowed). tags: - Countries parameters: - name: id in: path required: true schema: type: integer description: Country ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CountryInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Country' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete country description: Delete a country tags: - Countries parameters: - name: id in: path required: true schema: type: integer description: Country ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /profile: get: summary: Get all profiles description: Retrieve all product profiles tags: - Profiles parameters: - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new profile description: Create a new product profile tags: - Profiles requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Profile' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /profile/{id}: get: summary: Get specific profile description: Retrieve a specific product profile by ID tags: - Profiles parameters: - name: id in: path required: true schema: type: integer description: Profile ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Profile' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update profile description: Update an existing product profile tags: - Profiles parameters: - name: id in: path required: true schema: type: integer description: Profile ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Profile' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete profile description: Delete a product profile tags: - Profiles parameters: - name: id in: path required: true schema: type: integer description: Profile ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /profile-attribute: get: summary: Get profile attributes description: Retrieve profile attributes for a specific profile tags: - Profile Attributes parameters: - name: profile_id in: query required: true schema: type: integer description: Profile ID to get attributes for - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new profile attribute description: Create a new profile attribute tags: - Profile Attributes parameters: - name: profile_id in: query required: true schema: type: integer description: Profile ID to create attribute for requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/ProfileAttribute' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /profile-attribute/{id}: get: summary: Get specific profile attribute description: Retrieve a specific profile attribute by ID tags: - Profile Attributes parameters: - name: id in: path required: true schema: type: integer description: Profile attribute ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileAttribute' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update profile attribute description: Update an existing profile attribute tags: - Profile Attributes parameters: - name: id in: path required: true schema: type: integer description: Profile attribute ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileAttribute' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete profile attribute description: Delete a profile attribute tags: - Profile Attributes parameters: - name: id in: path required: true schema: type: integer description: Profile attribute ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /profile-attribute-value: get: summary: Get profile attribute values description: Retrieve values for a profile attribute tags: - Profile Attribute Values parameters: - name: profile_attribute_id in: query required: true schema: type: integer description: Profile attribute ID to get values for responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/ProfileAttributeValue' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new profile attribute value description: Create a new profile attribute value tags: - Profile Attribute Values parameters: - name: profile_attribute_id in: query required: true schema: type: integer description: Profile attribute ID to create value for requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeValueInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeValue' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /profile-attribute-value/{id}: get: summary: Get specific profile attribute value description: Retrieve a specific profile attribute value by ID tags: - Profile Attribute Values parameters: - name: id in: path required: true schema: type: integer description: Profile attribute value ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeValue' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update profile attribute value description: Update an existing profile attribute value tags: - Profile Attribute Values parameters: - name: id in: path required: true schema: type: integer description: Profile attribute value ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeValueInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProfileAttributeValue' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete profile attribute value description: Delete a profile attribute value tags: - Profile Attribute Values parameters: - name: id in: path required: true schema: type: integer description: Profile attribute value ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /product-label: get: summary: Get all product labels description: Retrieve all product labels tags: - Product Labels parameters: - name: limit in: query schema: type: integer default: 5 description: Maximum number of results - name: offset in: query schema: type: integer default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProductLabelList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new product label description: Create a new product label tags: - Product Labels requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProductLabelInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/ProductLabel' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /product-label/{id}: get: summary: Get specific product label description: Retrieve a specific product label by ID tags: - Product Labels parameters: - name: id in: path required: true schema: type: integer description: Product label ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProductLabel' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update product label description: Update an existing product label tags: - Product Labels parameters: - name: id in: path required: true schema: type: integer description: Product label ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ProductLabelInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ProductLabel' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete product label description: Delete a product label tags: - Product Labels parameters: - name: id in: path required: true schema: type: integer description: Product label ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /order-status: post: summary: Change status of up to 25 orders description: | Changes the status of the given orders. With `dry_run` nothing is changed and each order lists what the change would trigger (`effects`: mail_customer, payment_capture, payment_void, accounting_transfer, invoice_number, loyalty_points, stock_change, carrier_booking) and `warnings`. `credit` cannot be set here. `notify_customer=false` skips the "order sent" mail; ready_for_pickup and picked_up always mail the customer. If any order is not in the shop or is a credit note, nothing is changed. tags: - Orders requestBody: required: true content: application/json: schema: type: object required: [order_ids, status] properties: order_ids: { type: array, maxItems: 25, items: { type: integer } } status: { type: string, enum: [new, process, pending, ready, sent, ready_for_pickup, picked_up, cancelled] } notify_customer: { type: boolean, default: true } dry_run: { type: boolean } responses: '200': description: Result or preview per order content: application/json: schema: type: object properties: dry_run: { type: boolean } status: { type: string } orders: type: array items: type: object properties: order_id: { type: integer } webshop_order_id: { type: integer } from: { type: string } to: { type: string } changes: { type: boolean } effects: type: array items: type: object properties: type: { type: string } text: { type: string } warnings: { type: array, items: { type: string } } result: type: object properties: before: { type: string } after: { type: string } notes: { type: array, items: { type: string } } '400': description: Invalid request, nothing changed content: application/json: schema: $ref: '#/components/schemas/Error' /order/{id}/pick: put: summary: Set picked amounts on an order description: Marks how many units of each order line are picked (the pick list). 0 removes the mark. Status and stock are not changed. tags: - Orders parameters: - name: id in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [lines] properties: lines: type: array maxItems: 200 items: type: object required: [order_product_id, picked] properties: order_product_id: { type: integer } picked: { type: integer, minimum: 0 } responses: '200': description: Picked amounts '400': description: Line not on the order or amount out of range, nothing changed content: application/json: schema: $ref: '#/components/schemas/Error' /order-pick-list: get: summary: Pick queue description: | Orders waiting to be picked and packed (same queue and order as Ordrer > Plukkeliste in admin), with the lines still to pick sorted by shelf location. Read only. tags: - Orders parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 50, default: 10 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: One page of the pick queue content: application/json: schema: type: object properties: paging: type: object properties: offset: { type: integer } limit: { type: integer } count: { type: integer } total: { type: integer } orders: type: array items: type: object properties: order_id: { type: integer } webshop_order_id: { type: integer } status: { type: string } time: { type: integer, description: Unix timestamp } shipping: { type: string, nullable: true } labels: { type: array, items: { type: string } } line_count: { type: integer } lines_done: { type: integer } units_to_pick: { type: integer } units_ordered: { type: integer } units_sent: { type: integer } checkout_answers: type: array items: type: object properties: label: { type: string } value: { type: string } lines: type: array items: type: object properties: order_product_id: { type: integer } product_id: { type: integer, nullable: true } name: { type: string } variant: { type: string, nullable: true } sku: { type: string, nullable: true } gtin: { type: string, nullable: true } amount_to_pick: { type: integer } picked: { type: integer } in_unsent_package: { type: integer, description: Units already in a saved but unsent package } location: { type: string, nullable: true } stock: { type: integer, nullable: true } line_comment: { type: string, nullable: true } product_note: { type: string, nullable: true } batches: type: array items: type: object properties: inventory_batch_id: { type: integer } batch_number: { type: string } expiry_date: { type: string, nullable: true } stock: { type: integer } /stock-movement: get: summary: Stock movements description: | Stock movements (stock in and out, incl. sales, returns, imports and shared stock), newest first. Positive amount = stock in, negative = stock out. Read only. Without product_id, order_id or inventory_batch_id the period is limited to at most 92 days and defaults to the last 30 days. tags: - Stock parameters: - name: product_id in: query schema: { type: integer } - name: order_id in: query schema: { type: integer } - name: inventory_batch_id in: query schema: { type: integer } - name: stock_location_id in: query schema: { type: integer } - name: from in: query schema: { type: string, format: date } - name: to in: query schema: { type: string, format: date } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 25 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Stock movements content: application/json: schema: type: object properties: paging: type: object properties: offset: { type: integer } limit: { type: integer } count: { type: integer } total: { type: integer } stock_movements: type: array items: type: object properties: stock_movement_id: { type: integer } product_id: { type: integer } product_name: { type: string } sku: { type: string, nullable: true } amount: { type: integer } price: { type: number, nullable: true, description: Purchase price per unit } sale_price: { type: number, nullable: true } order_id: { type: integer, nullable: true } stock_location_id: { type: integer, nullable: true } stock_location_name: { type: string, nullable: true } inventory_batch_id: { type: integer, nullable: true } from_shared_stock: { type: boolean } from_import: { type: boolean } created: { type: string } '400': description: Invalid date content: application/json: schema: $ref: '#/components/schemas/Error' /order-return: get: summary: Get all order returns description: Retrieve all order returns tags: - Order Returns parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 5 description: Maximum number of results - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: order_id in: query schema: type: integer description: Filter by order ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/OrderReturnList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new order return description: Create a new order return tags: - Order Returns requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderReturnInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/OrderReturn' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /order-return/{id}/received: post: summary: Mark a return as received description: | Marks an approved return (status done) as received. If the shop puts returned products back in stock, they are added now. `notify_waitlist` (default true, same as admin) controls back-in-stock mails to the waiting list. `dry_run` shows the products and waiting list count without changing anything. tags: - Order Returns parameters: - name: id in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: notify_waitlist: { type: boolean, default: true } dry_run: { type: boolean } responses: '200': description: Received (or preview) content: application/json: schema: type: object properties: order_return_id: { type: integer } adds_stock: { type: boolean } products: { type: array, items: { type: object } } waitlist_count: { type: integer } would_mail_waitlist: { type: boolean } received_at: { type: string } dry_run: { type: boolean } '400': description: Not approved or already received content: application/json: schema: $ref: '#/components/schemas/Error' /order-return/{id}: get: summary: Get specific order return description: Retrieve a specific order return by ID tags: - Order Returns parameters: - name: id in: path required: true schema: type: integer description: Order return ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/OrderReturn' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update order return description: Update an existing order return tags: - Order Returns parameters: - name: id in: path required: true schema: type: integer description: Order return ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrderReturnInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/OrderReturn' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete order return description: Delete an order return tags: - Order Returns parameters: - name: id in: path required: true schema: type: integer description: Order return ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /app-status: get: summary: Get app status description: Get status information for all webshops associated with an app (requires app-level authentication) tags: - App Management security: - AppAuth: [] responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/AppStatus' '401': description: Unauthorized - App token required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden - Invalid app token content: application/json: schema: $ref: '#/components/schemas/Error' /coffee: get: summary: Make coffee (Easter egg) description: Returns HTTP 418 "I'm a teapot" as per RFC 2324 tags: - Easter Eggs responses: '418': description: I'm a teapot content: application/json: schema: type: object properties: error: type: string example: "I'm a teapot" post: summary: Brew coffee (Easter egg) description: Returns HTTP 418 "I'm a teapot" as per RFC 2324 tags: - Easter Eggs responses: '418': description: I'm a teapot content: text/plain: schema: type: string example: "I'm a teapot" put: summary: Update coffee (Easter egg) description: Returns HTTP 418 "I'm a teapot" as per RFC 2324 tags: - Easter Eggs responses: '418': description: I'm a teapot content: text/plain: schema: type: string example: "I'm a teapot" delete: summary: Delete coffee (Easter egg) description: Returns HTTP 418 "I'm a teapot" as per RFC 2324 tags: - Easter Eggs responses: '418': description: I'm a teapot content: text/plain: schema: type: string example: "I'm a teapot" /notices: get: summary: Get notices description: | Retrieves a list of notices (notifications) for the webshop. Notices are system-generated notifications about various events in the webshop, such as theme errors, integration issues, or other important system messages. This endpoint supports reading notices (GET) and creating a notice (POST). Individual notice access, updating, and deletion are not allowed through the API. tags: - Notices parameters: - name: limit in: query description: Number of notices to return (1-100, default 25) schema: type: integer minimum: 1 maximum: 1000 default: 25 - name: offset in: query description: Number of notices to skip schema: type: integer minimum: 0 default: 0 responses: '200': description: List of notices content: application/json: schema: type: object properties: paging: type: object properties: offset: type: integer description: Number of notices skipped limit: type: integer description: Maximum number of notices returned count: type: integer description: Number of notices in this response total: type: integer description: Total number of notices available notices: type: array items: type: object properties: notice_id: type: integer description: Unique identifier for the notice from: type: string description: Source of the notice (e.g., "Tema", "Integration") notice: type: string description: The notice message content time: type: string format: date-time description: When the notice was created required: - notice_id - from - notice - time '401': description: Unauthorized content: application/json: schema: type: object properties: error: type: string example: "Unauthorized" '405': description: Method not allowed content: application/json: schema: type: object properties: error: type: string example: "Only GET and POST methods are allowed for notices" post: summary: Create a notice description: | Creates a notice (notification) shown in the webshop admin under Messages. Identical notices (same `from` and `notice`) are grouped automatically in the admin, so keep the notice text constant per error type and put varying details (URLs, IDs, timestamps) in your own logs instead. Requires a token with write access. tags: - Notices requestBody: required: true content: application/json: schema: type: object properties: from: type: string maxLength: 255 description: Source of the notice, used for filtering in the admin (e.g., "PIM", "Integration"). 4-byte UTF-8 characters (e.g. emoji) are stripped. notice: type: string description: The notice message content. 4-byte UTF-8 characters (e.g. emoji) are stripped. required: - from - notice responses: '201': description: Notice created content: application/json: schema: type: object properties: notice_id: type: integer description: Unique identifier for the notice from: type: string description: Source of the notice notice: type: string description: The notice message content time: type: string format: date-time description: When the notice was created required: - notice_id - from - notice - time '400': description: Missing or invalid from/notice content: application/json: schema: type: object properties: error: type: string example: "from is required and must be at most 255 characters" '401': description: Unauthorized content: application/json: schema: type: object properties: error: type: string example: "Unauthorized" '403': description: Token does not have write access content: application/json: schema: type: object properties: error: type: string example: "insufficient_scope" '415': description: Content-Type is not application/json (returns an HTML error page, not JSON) /customer: get: summary: Get all customers description: Retrieve all customers with pagination and search tags: - Customers parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 25 description: Maximum number of results - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Search customers by name, email, or company (substring match) example: "john@example.com" - name: email in: query schema: type: string description: Exact e-mail lookup (case-insensitive). E-mail is unique per shop, so this returns 0 or 1 customers. Takes precedence over search. example: "john@example.com" - name: own_id in: query schema: type: string description: Exact lookup on the customer's own reference ID (the customer number from the shop's previous system). Returns 0 or 1 customers - the newest one if the old system reused the number. Takes precedence over search. example: "KVIK-102-001" - name: customer_group_id in: query schema: type: integer description: Only customers whose OWN group is this one. Members that merely inherit the group from their B2B company are not included. Combines with search. - name: customer_company_id in: query schema: type: integer description: Only members of this B2B company. Combines with search. - name: sort in: query schema: type: string enum: [name, orders, amount, created] default: orders description: Sort column - name: sort_order in: query schema: type: string enum: [asc, desc] default: desc description: Sort direction - name: with_points in: query schema: type: integer enum: [0, 1] description: Set to 1 to include point_balance on each customer (adds one query per row) responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create a customer, or bulk import up to 1000 description: | Send ONE object to create a single customer - an email that already exists is rejected with 409. Send an ARRAY of up to 1000 objects to import in bulk. Bulk rows are matched on email: a known email updates that customer, an unknown one creates them. That makes an import re-runnable after a correction without cleaning up first. The response is 200 with a per-row result, so one bad row does not take the other 999 with it. Use customer_group_name per row to file customers into groups while importing - groups that do not exist yet are created. tags: - Customers requestBody: required: true content: application/json: schema: oneOf: - $ref: '#/components/schemas/CustomerInput' - type: array maxItems: 1000 items: $ref: '#/components/schemas/CustomerInput' examples: bulkImport: summary: Bulk import with customer groups and delivery addresses value: - email: "noreply+66112453@sundapotek.dk" name: "Agnete Greibe" address: "Hovedgaden 69N" zipcode: "8410" city: "Rønde" customer_group_name: "Kvik id 102" shipping_name: "Agnete Greibe" shipping_address: "Hovedgaden 69N" shipping_zipcode: "8410" shipping_city: "Rønde" - email: "noreply+66112454@sundapotek.dk" name: "Anna Kaag Holm" address: "Bygmarken 97" zipcode: "8560" city: "Kolind" customer_group_name: "Kvik id 102" b2bCustomer: summary: Customer linked to a B2B company and a customer group value: email: "indkoeb@syddjurs.dk" name: "Indkøb" customer_company_id: 4 customer_company_role: "admin" customer_group_id: 15 ean_number: "5798004812891" own_id: "KVIK-102-001" basicCustomer2: summary: Basic customer creation basicCustomer: summary: Basic customer creation value: email: "john.doe@example.com" name: "John Doe" phone: "+45 12 34 56 78" address: "Main Street 123" zipcode: "1234" city: "Copenhagen" country_id: 1 businessCustomer: summary: Business customer with shipping address value: email: "contact@example.com" name: "Jane Smith" company: "ACME Corporation" vat_number: "DK12345678" phone: "+45 87 65 43 21" address: "Business Park 456" zipcode: "5678" city: "Aarhus" country_id: 1 subscription_id: "sub_1234567890" shipping_name: "ACME Warehouse" shipping_address: "Industrial Road 789" shipping_zipcode: "9012" shipping_city: "Aalborg" shipping_country_id: 1 responses: '200': description: Bulk import processed. Check the per-row results - failed rows are reported here, not as an error status. content: application/json: schema: $ref: '#/components/schemas/CustomerBulkResult' '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Customer' '400': description: Bad Request - Missing required fields, an unknown country/group/company id, or more than 1000 rows in one bulk call content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Conflict - Customer with email already exists content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /customer/{id}: get: summary: Get specific customer description: Retrieve a specific customer by ID with order statistics and point balance tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Customer' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update customer description: Update an existing customer tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Conflict - Email already exists content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete customer description: Delete a customer and all associated data tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /customer/{id}/point: get: summary: List a customer's loyalty point entries description: Returns the customer's point ledger (newest first) along with the active balance and pending sum tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 25 description: Maximum number of results - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerPointList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Add a loyalty point entry description: Creates an entry in the customer's point ledger. Positive values award points, negative values deduct them. Does not require an order. Use this instead of add_points on the order endpoint when awarding points outside an order context (e.g. migrations, compensations, signup bonuses). tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerPointInput' examples: awardPoints: summary: Award points with a note value: points: 500 note: "Compensation for delayed delivery" deductPoints: summary: Deduct points value: points: -200 note: "Manual adjustment" responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/CustomerPoint' '400': description: Bad Request - points must be an integer content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /customer/{id}/point/{point_id}: get: summary: Get a loyalty point entry tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID - name: point_id in: path required: true schema: type: integer description: Customer point entry ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerPoint' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update a loyalty point entry description: Update points, status, pending_until, expires and/or note on an existing entry. Setting status to anything but pending clears pending_until (mirrors admin behavior). tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID - name: point_id in: path required: true schema: type: integer description: Customer point entry ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerPointInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete a loyalty point entry description: Permanently removes the entry from the ledger tags: - Customers parameters: - name: id in: path required: true schema: type: integer description: Customer ID - name: point_id in: path required: true schema: type: integer description: Customer point entry ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /customer-group: get: summary: List customer groups description: | Customer groups drive B2B pricing: a group can carry a percentage discount, a minimum order value, fixed prices per product (see /customer-group/{id}/price) and rules for which brands its members may see. A customer gets a group either directly (customer.customer_group_id) or through their B2B company. tags: - Customers parameters: - name: search in: query schema: type: string description: Filter by name (substring match) - name: sort in: query schema: type: string enum: [weight, name, customer_group_id] default: weight - name: sort_order in: query schema: type: string enum: [asc, desc] default: asc - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 100 - name: offset in: query schema: type: integer minimum: 0 default: 0 responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerGroupList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create customer group tags: - Customers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerGroupInput' examples: b2bGroup: summary: B2B group with a discount and a blocked brand value: name: "Erhverv" weight: 10 discount_percent: 15 min_order_value: 500 brands: - brand_id: 42 visibility: "blocked" responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/CustomerGroup' '400': description: Bad Request - missing name, invalid discount or an unknown brand_id content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Conflict - a group with this name already exists content: application/json: schema: $ref: '#/components/schemas/Error' /customer-group/{id}: parameters: - name: id in: path required: true schema: type: integer get: summary: Get customer group tags: - Customers responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerGroup' '404': description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update customer group description: Only the fields you send are changed. Sending `brands` replaces the whole brand setup. tags: - Customers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerGroupInput' responses: '204': description: Updated '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Conflict - another group already has this name content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete customer group description: | Customers and companies in the group are NOT deleted - they lose the group, and with it the group's discount and fixed prices. The group's brand rules and product prices are removed. tags: - Customers responses: '204': description: Deleted '404': description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' /customer-group/{id}/price: parameters: - name: id in: path required: true schema: type: integer description: Customer group ID get: summary: List the group's fixed product prices description: The group's B2B price list. A group price beats both the normal price and campaigns for members of the group. tags: - Customers parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 250 - name: offset in: query schema: type: integer minimum: 0 default: 0 responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerGroupPriceList' '404': description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Set or remove the group's fixed prices description: | Takes a single object or an array of up to 1000 rows. A price of 0 or null removes that row. Quantity ladders are built by sending several rows for the same product with different min_quantity. tags: - Customers requestBody: required: true content: application/json: schema: oneOf: - $ref: '#/components/schemas/CustomerGroupPriceInput' - type: array maxItems: 1000 items: $ref: '#/components/schemas/CustomerGroupPriceInput' examples: quantityLadder: summary: 24.95 from the first unit, 19.95 from ten value: - product_id: 242949 price: 24.95 - product_id: 242949 min_quantity: 10 price: 19.95 responses: '200': description: Processed. Rows that failed are reported in results. content: application/json: schema: $ref: '#/components/schemas/CustomerGroupPriceResult' '400': description: Bad Request - more than 1000 rows content: application/json: schema: $ref: '#/components/schemas/Error' /customer-company: get: summary: List B2B companies description: | A company groups several customers and can carry a customer group, so every employee gets the same prices without being put in the group one by one. Members are linked from the customer side (customer.customer_company_id). tags: - Customers parameters: - name: search in: query schema: type: string description: Filter by name or VAT number - name: sort in: query schema: type: string enum: [name, customer_company_id] default: name - name: sort_order in: query schema: type: string enum: [asc, desc] default: asc - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 100 - name: offset in: query schema: type: integer minimum: 0 default: 0 responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerCompanyList' post: summary: Create B2B company description: Add members afterwards by setting customer_company_id on the customers themselves. tags: - Customers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerCompanyInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/CustomerCompany' '400': description: Bad Request - missing name or an unknown customer_group_id content: application/json: schema: $ref: '#/components/schemas/Error' /customer-company/{id}: parameters: - name: id in: path required: true schema: type: integer get: summary: Get B2B company description: Includes the company's members and their roles. tags: - Customers responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerCompany' '404': description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update B2B company tags: - Customers requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerCompanyInput' responses: '204': description: Updated '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete B2B company description: The members stay as ordinary customers and simply lose the company link. tags: - Customers responses: '204': description: Deleted '404': description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' /customer-field: get: summary: Get all customer profile fields description: Retrieve all customer profile fields for the webshop tags: - Customer Fields responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerFieldList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new customer field description: Create a new customer profile field tags: - Customer Fields requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerFieldInput' examples: stringField: summary: Text field value: name: "EAN nummer" tag: "ean" type: "string" listField: summary: Dropdown field value: name: "Kunde type" tag: "kunde_type" type: "list" values: "Privatkunde,Erhvervskunde,Offentlig kunde" responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/CustomerField' '400': description: Bad Request - Missing required fields or invalid type content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /customer-field/{id}: get: summary: Get specific customer field description: Retrieve a specific customer profile field by ID tags: - Customer Fields parameters: - name: id in: path required: true schema: type: integer description: Customer field ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/CustomerField' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update customer field description: Update an existing customer profile field tags: - Customer Fields parameters: - name: id in: path required: true schema: type: integer description: Customer field ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CustomerFieldInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete customer field description: Delete a customer profile field and all associated values tags: - Customer Fields parameters: - name: id in: path required: true schema: type: integer description: Customer field ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /blog-post: get: summary: Get all blog posts description: Retrieve all blog posts with optional filtering. Paginated response — iterate `offset` to fetch all posts when `paging.total` exceeds `limit`. tags: - Blog Posts parameters: - name: limit in: query schema: type: integer minimum: 1 default: 25 maximum: 1000 description: Page size. Defaults to 25, capped at 100. - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Free-text search on title, body, meta_title and meta_description. responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/BlogPostList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new blog post description: Create a new blog post tags: - Blog Posts requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BlogPostInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/BlogPost' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /blog-post/{id}: get: summary: Get specific blog post description: Retrieve a specific blog post by ID tags: - Blog Posts parameters: - name: id in: path required: true schema: type: integer description: Blog post ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/BlogPost' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update blog post description: Update an existing blog post tags: - Blog Posts parameters: - name: id in: path required: true schema: type: integer description: Blog post ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BlogPostInput' responses: '204': description: No Content — the update was applied. Fetch the post with GET to read back the stored values. '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete blog post description: Delete a blog post tags: - Blog Posts parameters: - name: id in: path required: true schema: type: integer description: Blog post ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /page: get: summary: Get all pages description: Retrieve all static pages with optional filtering. Paginated response — iterate `offset` to fetch all pages when `paging.total` exceeds `limit`. tags: - Pages parameters: - name: limit in: query schema: type: integer minimum: 1 default: 25 maximum: 1000 description: Page size. Defaults to 25, capped at 100. - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Free-text search on headline, text, rewrite_url and meta fields. responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/PageList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new page description: Create a new page tags: - Pages requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PageInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Page' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /page/{id}: get: summary: Get specific page description: Retrieve a specific page by ID tags: - Pages parameters: - name: id in: path required: true schema: type: integer description: Page ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Page' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update page description: Update an existing page tags: - Pages parameters: - name: id in: path required: true schema: type: integer description: Page ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PageInput' responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Page' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete page description: Delete a page tags: - Pages parameters: - name: id in: path required: true schema: type: integer description: Page ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /shipping: get: summary: Get all shipping methods description: Retrieve all shipping methods tags: - Shipping responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/Shipping' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new shipping method description: Create a new shipping method tags: - Shipping requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ShippingInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Shipping' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /shipping-price: get: summary: Shipping prices per country and method description: | Every combination of country and active shipping method with the price excluding and including VAT, subscription price, weight prices, free-shipping limit and whether the method is enabled in the country. Prices are stored excluding VAT. tags: - Shipping parameters: - name: country_id in: query schema: { type: integer } - name: shipping_id in: query schema: { type: integer } responses: '200': description: Price cells content: application/json: schema: type: object properties: currency: { type: string } notes: { type: array, items: { type: string } } prices: type: array items: $ref: '#/components/schemas/ShippingPriceCell' put: summary: Set shipping prices (one cell at a time) description: | Sets the price for the given country/shipping method cells only (max 20). Other cells and the stored subscription price are kept unless `recurring_price_excl_vat` is given. Pass `price_excl_vat` or `price_incl_vat` (VAT is removed with the country's rate, or 0 for VAT-free methods). Everything is validated first; if anything is invalid nothing is saved (400). Methods in a running shipping split test are refused. `dry_run` previews. tags: - Shipping requestBody: required: true content: application/json: schema: type: object required: [prices] properties: dry_run: { type: boolean } prices: type: array maxItems: 20 items: type: object required: [shipping_id] properties: country_id: { type: integer } country_code: { type: string, description: ISO code, e.g. NO } country: { type: string, description: Country name as in the shop } shipping_id: { type: integer } price_excl_vat: { type: number } price_incl_vat: { type: number } recurring_price_excl_vat: { type: number, nullable: true } responses: '200': description: Saved (or previewed) content: application/json: schema: type: object properties: dry_run: { type: boolean } currency: { type: string } changed: { type: integer } unchanged: { type: integer } prices: type: array items: type: object properties: country_id: { type: integer } country: { type: string } shipping_id: { type: integer } shipping: { type: string } price_excl_vat_before: { type: number, nullable: true } price_excl_vat: { type: number } price_incl_vat: { type: number } vat_pct: { type: number } recurring_price_excl_vat_before: { type: number, nullable: true } recurring_price_excl_vat: { type: number, nullable: true } changed: { type: boolean } warnings: { type: array, items: { type: string } } notes: { type: array, items: { type: string } } '400': description: Validation failed, nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' /shipping-weight-price: put: summary: Change prices of existing weight intervals description: | Changes the price of weight intervals that already exist for a country and shipping method (max 20). New intervals are created in admin. Prices excluding VAT. `dry_run` previews. tags: - Shipping requestBody: required: true content: application/json: schema: type: object required: [weight_prices] properties: dry_run: { type: boolean } weight_prices: type: array maxItems: 20 items: type: object required: [shipping_id, weight_grams, price_excl_vat] properties: country_id: { type: integer } country_code: { type: string } country: { type: string } shipping_id: { type: integer } weight_grams: { type: integer } price_excl_vat: { type: number } responses: '200': description: Saved (or previewed) '400': description: Validation failed, nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' /shipping/{id}: get: summary: Get specific shipping method description: Retrieve a specific shipping method by ID tags: - Shipping parameters: - name: id in: path required: true schema: type: integer description: Shipping method ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ShippingDetailed' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update shipping method description: Update an existing shipping method tags: - Shipping parameters: - name: id in: path required: true schema: type: integer description: Shipping method ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ShippingInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete shipping method description: Delete a shipping method tags: - Shipping parameters: - name: id in: path required: true schema: type: integer description: Shipping method ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /payment_gateway: get: summary: Get all payment gateways description: Retrieve all payment gateways for the webshop tags: - Payment Gateways responses: '200': description: Success content: application/json: schema: type: object properties: payment_gateways: type: array items: $ref: '#/components/schemas/PaymentGateway' total: type: integer description: Total number of payment gateways '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /payment_gateway/{id}: get: summary: Get specific payment gateway description: Retrieve a specific payment gateway by ID tags: - Payment Gateways parameters: - name: id in: path required: true schema: type: integer description: Payment gateway ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/PaymentGateway' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /redirect: get: summary: Get all redirects description: Retrieve all redirects with pagination tags: - Redirects parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 25 description: Maximum number of results - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: >- Free-text filter — matches a substring in either `from` or `to`. An empty string means no filtering. example: "gammel-side" responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/RedirectList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new redirect description: Create a new URL redirect tags: - Redirects requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RedirectInput' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Redirect' '400': description: Bad Request (duplicate from address, redirect loop, or identical from/to) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /redirect/{id}: get: summary: Get specific redirect description: Retrieve a specific redirect by ID tags: - Redirects parameters: - name: id in: path required: true schema: type: integer description: Redirect ID responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/Redirect' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update redirect description: Update an existing redirect tags: - Redirects parameters: - name: id in: path required: true schema: type: integer description: Redirect ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RedirectInput' responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete redirect description: Delete a redirect tags: - Redirects parameters: - name: id in: path required: true schema: type: integer description: Redirect ID responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /menu: get: summary: Get all menus description: Retrieve all menus tags: - Menus responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/Menu' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create menu description: Create a new menu tags: - Menus requestBody: required: true content: application/json: schema: type: object required: - name properties: name: type: string description: Menu name tag: type: string description: Menu tag for identification location: type: string enum: [top, bottom, left, right] description: >- Where the theme renders the menu. Only these four values are accepted; anything else returns 400. Omit to leave the menu unbound to a location (it will then not be rendered). responses: '201': description: Created headers: Location: description: URL of created menu schema: type: string content: application/json: schema: $ref: '#/components/schemas/Menu' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /menu/{id}: get: summary: Get menu by ID description: Retrieve a specific menu by ID tags: - Menus parameters: - name: id in: path required: true description: Menu ID schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/MenuDetail' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update menu description: Update an existing menu tags: - Menus parameters: - name: id in: path required: true description: Menu ID schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: name: type: string description: Menu name tag: type: string description: Menu tag for identification location: type: string enum: [top, bottom, left, right] description: >- Where the theme renders the menu. Only these four values are accepted; anything else returns 400. Omit to leave the menu unbound to a location (it will then not be rendered). parent_id: type: integer description: >- Parent menu ID (0 detaches). Controls the admin tree view only — it does NOT render a dropdown in the shop. Use POST /menu/{id}/submenu for that. Rejected with 400 if it points at the menu itself or at one of the menu's own descendants (that would create a loop). Nothing else in the request body is written when parent_id is rejected. responses: '204': description: No Content '400': description: >- Bad Request — invalid `location`, or a `parent_id` that is the menu itself or one of its own descendants. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete menu description: Delete a menu tags: - Menus parameters: - name: id in: path required: true description: Menu ID schema: type: integer responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /menu/{id}/submenu: post: summary: Create a submenu under a menu description: >- Creates a new menu AND the menu item (type=menu) in the parent menu that makes the theme render it as a dropdown — both halves in one call. This is the only correct way to nest menus: setting `parent_id` on its own only affects the admin tree view, so the submenu would be invisible in the shop. The new menu is deliberately created without `tag` and `location` (an auto-generated tag could silently take over which menu a theme picks up); add links to it with POST /menu-item using the returned menu_id. tags: - Menus parameters: - name: id in: path required: true description: The existing menu the submenu is hung under schema: type: integer requestBody: required: true content: application/json: schema: type: object required: - name properties: name: type: string description: Name of the new submenu, shown in admin title: type: string description: >- Link text of the dropdown in the parent menu. Defaults to `name` when omitted. class: type: string description: Optional CSS class for the dropdown link in the parent menu responses: '201': description: Created headers: Location: description: URL of the created submenu schema: type: string content: application/json: schema: allOf: - $ref: '#/components/schemas/Menu' - type: object properties: menu_item_id: type: integer description: >- The menu item created in the parent menu that links the new submenu in. '400': description: Bad Request — `name` missing, or the submenu could not be created content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden — the parent menu belongs to another shop content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found — no such parent menu content: application/json: schema: $ref: '#/components/schemas/Error' /landing-page: get: summary: Get all landing pages description: Retrieve all landing pages with pagination. Pass `search` to filter by name, rewrite_url, description, and meta fields (admin context — also returns landing pages with `no_internal_search=1`). tags: - Landing Pages parameters: - name: limit in: query schema: type: integer minimum: 1 default: 25 maximum: 100 description: Maximum number of results (capped at 100). - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results - name: search in: query schema: type: string description: Optional free-text search on name, rewrite_url, description, and meta fields. responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LandingPageList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create landing page description: Create a new landing page tags: - Landing Pages requestBody: required: true content: application/json: schema: type: object required: - name properties: name: type: string description: Landing page name description: type: string description: Landing page description rewrite_url: type: string description: URL rewrite path meta_title: type: string description: Meta title for SEO meta_description: type: string description: Meta description for SEO responses: '201': description: Created headers: Location: description: URL of created landing page schema: type: string content: application/json: schema: $ref: '#/components/schemas/LandingPage' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' /landing-page/{id}: get: summary: Get landing page by ID description: Retrieve a specific landing page by ID tags: - Landing Pages parameters: - name: id in: path required: true description: Landing page ID schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LandingPageDetail' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update landing page description: Update an existing landing page tags: - Landing Pages parameters: - name: id in: path required: true description: Landing page ID schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: name: type: string description: Landing page name description_a: type: string description: Primary description description_b: type: string description: Secondary description rewrite_url: type: string description: URL rewrite path meta_title: type: string description: Meta title for SEO meta_description: type: string description: Meta description for SEO is_online: type: integer enum: [0, 1] description: Whether landing page is online tag: type: string description: Tag for identification responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete landing page description: Delete a landing page tags: - Landing Pages parameters: - name: id in: path required: true description: Landing page ID schema: type: integer responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /landing-page-item: get: summary: Get landing page items description: Retrieve all items for a specific landing page tags: - Landing Page Items parameters: - name: landing_page_id in: query required: true description: Landing page ID schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LandingPageItemList' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create landing page item description: Create a new landing page item tags: - Landing Page Items requestBody: required: true content: application/json: schema: type: object required: - landing_page_id - type properties: landing_page_id: type: integer description: Landing page ID type: type: string enum: [price, sale_price, real_price, profile_id, category_id, label_id, attribute_id, attribute_value_id, product_id, supplier_id, brand_id, search, extension_field] description: Type of item is: type: string enum: ['=', '<', '>', 'in', 'not in'] default: '=' description: Operator val: type: string description: Value responses: '201': description: Created headers: Location: description: URL of created landing page item schema: type: string content: application/json: schema: $ref: '#/components/schemas/LandingPageItem' '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' /landing-page-item/{id}: get: summary: Get landing page item by ID description: Retrieve a specific landing page item by ID tags: - Landing Page Items parameters: - name: id in: path required: true description: Landing page item ID schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/LandingPageItemDetail' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Update landing page item description: Update an existing landing page item tags: - Landing Page Items parameters: - name: id in: path required: true description: Landing page item ID schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: type: type: string enum: [price, sale_price, real_price, profile_id, category_id, label_id, attribute_id, attribute_value_id, product_id, supplier_id, brand_id, search, extension_field] description: Type of item is: type: string enum: ['=', '<', '>', 'in', 'not in'] description: Operator val: type: string description: Value responses: '204': description: No Content '400': description: Bad Request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete landing page item description: Delete a landing page item tags: - Landing Page Items parameters: - name: id in: path required: true description: Landing page item ID schema: type: integer responses: '204': description: No Content '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Not Found content: application/json: schema: $ref: '#/components/schemas/Error' /newsletter-list: get: summary: Get all newsletter lists tags: [Newsletter] responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/NewsletterList' post: summary: Create new newsletter list tags: [Newsletter] requestBody: content: application/json: schema: type: object properties: name: type: string description: type: string is_default: type: boolean responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/NewsletterList' /newsletter-list/{id}: get: summary: Get newsletter list tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterList' put: summary: Update newsletter list tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: name: type: string description: type: string is_default: type: boolean responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterList' delete: summary: Delete newsletter list tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '204': description: No Content /newsletter-subscriber: get: summary: Get all subscribers tags: [Newsletter] parameters: - name: list_id in: query schema: type: integer description: Only subscribers on this list. Returns 404 if the list does not belong to the webshop - name: status in: query schema: type: string enum: [pending, active, unsubscribed, bounced] default: active description: Filter by status. Send an empty value to get all statuses - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 100 - name: offset in: query schema: type: integer minimum: 0 default: 0 responses: '200': description: Success content: application/json: schema: type: object properties: total: type: integer description: Total number of subscribers matching the filters limit: type: integer offset: type: integer subscribers: type: array items: $ref: '#/components/schemas/NewsletterSubscriber' '404': description: List not found post: summary: Create new subscriber tags: [Newsletter] requestBody: content: application/json: schema: type: object required: [email] properties: email: type: string firstname: type: string lastname: type: string list_ids: type: array items: type: integer responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/NewsletterSubscriber' /newsletter-subscriber/{id}: get: summary: Get subscriber tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterSubscriber' put: summary: Update subscriber tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: email: type: string firstname: type: string lastname: type: string status: type: string enum: [active, unsubscribed, bounced] list_ids: type: array items: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterSubscriber' delete: summary: Delete subscriber tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '204': description: No Content /newsletter-campaign: get: summary: Get all campaigns tags: [Newsletter] responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/NewsletterCampaign' post: summary: Create new campaign tags: [Newsletter] requestBody: content: application/json: schema: type: object required: [name] properties: name: type: string subject: type: string list_ids: type: array items: type: integer responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaign' /newsletter-campaign/{id}: get: summary: Get campaign tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaign' put: summary: Update campaign tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: name: type: string subject: type: string list_ids: type: array items: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaign' delete: summary: Delete campaign tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '204': description: No Content /newsletter-campaign-block: get: summary: List a campaign's content blocks description: Returns the blocks of a campaign in display order (weight ascending). tags: [Newsletter] parameters: - name: campaign_id in: query required: true schema: type: integer responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/NewsletterCampaignBlock' post: summary: Add a content block to a draft campaign description: Only allowed on draft campaigns. tags: [Newsletter] requestBody: content: application/json: schema: type: object required: [campaign_id, block_type] properties: campaign_id: type: integer block_type: type: string enum: [header, text, products, image, image_row, button, divider, html] data: type: object additionalProperties: true custom_title: type: string weight: type: integer responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaignBlock' put: summary: Reorder a campaign's content blocks description: Pass the complete ordered array of block ids. Only allowed on draft campaigns. tags: [Newsletter] requestBody: content: application/json: schema: type: object required: [campaign_id, order] properties: campaign_id: type: integer order: type: array items: type: integer responses: '200': description: Success content: application/json: schema: type: array items: $ref: '#/components/schemas/NewsletterCampaignBlock' /newsletter-campaign-block/{id}: get: summary: Get a content block tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaignBlock' put: summary: Update a content block description: Only allowed on draft campaigns. Passing `data` replaces the block's entire data object. tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: data: type: object additionalProperties: true custom_title: type: string weight: type: integer responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/NewsletterCampaignBlock' delete: summary: Delete a content block description: Only allowed on draft campaigns. tags: [Newsletter] parameters: - name: id in: path required: true schema: type: integer responses: '204': description: No Content /theme-file: get: summary: List themes description: Returns a list of available theme directory names for the webshop. Requires `allow_theme_files` to be enabled on the token. tags: - Theme Files responses: '200': description: List of theme names content: application/json: schema: type: array items: type: string example: ["MyTheme", "California"] '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' /theme-file/{theme}: get: summary: List files in theme description: | Returns a list of all files in the specified theme directory with metadata. Maximum 5000 files are returned. Files are sorted alphabetically by path. Requires `allow_theme_files` to be enabled on the token. tags: - Theme Files parameters: - name: theme in: path required: true schema: type: string description: Theme directory name example: MyTheme responses: '200': description: List of files with metadata content: application/json: schema: type: array items: $ref: '#/components/schemas/ThemeFile' '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Theme not found content: application/json: schema: $ref: '#/components/schemas/Error' /theme-file/{theme}/{filepath}: get: summary: Get file content description: | Returns the raw file content with appropriate Content-Type header based on file extension. Metadata is included in response headers. Requires `allow_theme_files` to be enabled on the token. tags: - Theme Files parameters: - name: theme in: path required: true schema: type: string description: Theme directory name example: MyTheme - name: filepath in: path required: true schema: type: string description: Relative file path within the theme example: templates/front.html responses: '200': description: Raw file content headers: Content-Type: schema: type: string description: MIME type based on file extension (e.g. text/html, text/css, image/png, application/octet-stream) example: text/html X-Content-Type-Options: schema: type: string enum: [nosniff] description: Always set to `nosniff` to prevent MIME type sniffing X-File-Size: schema: type: integer description: File size in bytes example: 1227 X-File-Modified: schema: type: integer description: Last modification time as Unix timestamp example: 1709942400 X-File-Writable: schema: type: string enum: ["true", "false"] description: Whether the file is writable by the web server example: "true" content: '*/*': schema: type: string format: binary '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Access denied (path traversal attempt) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: File not found content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Create new file description: | Creates a new file in the theme. The request body is the raw file content (not JSON). Returns `409 Conflict` if the file already exists. Use PUT for upsert behavior. Maximum file size is 2MB. Requires `allow_theme_files` to be enabled on the token. Directories are created automatically as needed. tags: - Theme Files parameters: - name: theme in: path required: true schema: type: string description: Theme directory name example: MyTheme - name: filepath in: path required: true schema: type: string description: Relative file path within the theme example: templates/new-page.html requestBody: description: Raw file content (not JSON) required: true content: '*/*': schema: type: string format: binary responses: '201': description: File created successfully (empty body) '400': description: Invalid file path (contains invalid characters) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Cannot create file (directory not writable) content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: File already exists. Use PUT to update. content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: File exceeds 2MB size limit content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Create or update file (upsert) description: | Creates the file if it doesn't exist, or updates it if it does (upsert). The request body is the raw file content (not JSON). Returns `201 Created` for new files and `200 OK` for updates. Maximum file size is 2MB. Requires `allow_theme_files` to be enabled on the token. Directories are created automatically as needed. tags: - Theme Files parameters: - name: theme in: path required: true schema: type: string description: Theme directory name example: MyTheme - name: filepath in: path required: true schema: type: string description: Relative file path within the theme example: templates/front.html requestBody: description: Raw file content (not JSON) required: true content: '*/*': schema: type: string format: binary responses: '200': description: File updated successfully (empty body) '201': description: File created successfully (empty body) '400': description: Invalid file path (contains invalid characters) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: File is not writable. Set permissions via SFTP. content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: File exceeds 2MB size limit content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete file description: | Deletes a file from the theme. Requires `allow_theme_files` to be enabled on the token. Only tokens with `all` permission level can delete files. tags: - Theme Files parameters: - name: theme in: path required: true schema: type: string description: Theme directory name example: MyTheme - name: filepath in: path required: true schema: type: string description: Relative file path within the theme example: templates/old-page.html responses: '200': description: File deleted successfully (empty body) '401': description: Token does not have theme file access content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: File is not writable. Set permissions via SFTP. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: File not found content: application/json: schema: $ref: '#/components/schemas/Error' /webshop: get: summary: Get webshop settings description: | Returns the settings for the authenticated webshop. There is no `{id}` segment — the operation always targets the webshop the API token belongs to. Sensitive fields (api token, payment credentials, sms passwords, etc.) are intentionally NOT included. tags: - Webshop responses: '200': description: Webshop settings content: application/json: schema: $ref: '#/components/schemas/Webshop' put: summary: Update webshop settings description: | Updates whitelisted webshop settings. Only the fields you supply are changed; missing fields are left untouched. Fields that affect billing, payment credentials or other sensitive data are not writable here. tags: - Webshop requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebshopUpdate' responses: '200': description: Updated webshop settings content: application/json: schema: $ref: '#/components/schemas/Webshop' /webshop-settings: get: summary: All shop settings by section description: Settings that can be changed through the API, grouped in sections (general, catalog, checkout, stock, mails, seo), with type, current value and impact. tags: - Webshop responses: '200': description: Sections content: application/json: schema: type: object properties: sections: type: array items: $ref: '#/components/schemas/WebshopSettingsSection' /webshop-settings/{section}: get: summary: One settings section tags: - Webshop parameters: - name: section in: path required: true schema: { type: string, enum: [general, catalog, checkout, stock, mails, seo] } responses: '200': description: Section content: application/json: schema: $ref: '#/components/schemas/WebshopSettingsSection' '404': description: Unknown section put: summary: Change settings in a section description: | Each given field is validated and written on its own with its setter; other settings are never touched. If any field is invalid, unknown, in another section or blocked (currency, prices_incl_vat, order_notice_to, order_sent_bcc, domain, stylesheet, credentials, theme, status), nothing is saved and the answer explains why (blocked fields link to the admin page). `dry_run` shows before/after without saving. `warnings` flags risky values (robots.txt that blocks all robots, turning stock tracking off). tags: - Webshop parameters: - name: section in: path required: true schema: { type: string, enum: [general, catalog, checkout, stock, mails, seo] } requestBody: required: true content: application/json: schema: type: object required: [fields] properties: fields: type: object additionalProperties: true example: { page_limit: 24 } dry_run: { type: boolean } responses: '200': description: Saved (or previewed) content: application/json: schema: type: object properties: section: { type: string } dry_run: { type: boolean } changed: { type: integer } fields: type: object additionalProperties: type: object properties: label: { type: string } before: {} after: {} changed: { type: boolean } warnings: { type: array, items: { type: string } } notes: { type: array, items: { type: string } } '400': description: Validation failed, nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' /theme-settings: get: summary: List all theme settings + schema description: | Returns every saved theme setting for the active theme plus the full schema (`theme_settings.json`) describing which keys/types the theme supports. tags: - Theme Settings responses: '200': description: Theme settings + schema content: application/json: schema: type: object properties: theme: type: string description: Active theme name schema: type: object description: Schema from the theme's theme_settings.json settings: type: array items: $ref: '#/components/schemas/ThemeSetting' put: summary: Bulk update theme settings description: | Update several theme settings in one call (max 50). Only the listed settings are written. Every setting is validated against the active theme's schema first (unknown element/name, color not hex/rgb, list value not allowed, bool not 1/0, image_id not in the shop's image library); if any is invalid, nothing is saved and the answer is 400 with `errors`. Unsafe HTML (script, event handlers, javascript: links) is removed from texts. Image settings take `image_id` (library images are copied as theme images) or `remove_image`. tags: - Theme Settings parameters: - name: report in: query schema: { type: integer, enum: [0, 1] } description: 1 = answer 200 with before/after per setting instead of 204. requestBody: required: true content: application/json: schema: type: object required: [settings] properties: settings: type: array maxItems: 50 items: type: object required: [element, name] properties: element: { type: string } name: { type: string } value: { type: string } image_id: { type: integer } remove_image: { type: boolean } responses: '200': description: Settings updated (with ?report=1) content: application/json: schema: $ref: '#/components/schemas/ThemeSettingsReport' '204': description: Settings updated '400': description: Validation failed, nothing saved content: application/json: schema: type: object properties: error: { type: string } message: { type: string } errors: { type: array, items: { type: string } } /theme-settings/{element}/{name}: get: summary: Get a single theme setting tags: - Theme Settings parameters: - name: element in: path required: true schema: type: string description: Top-level group (e.g. "colors", "general") - name: name in: path required: true schema: type: string description: Setting name (e.g. "primary_color") responses: '200': description: Setting content: application/json: schema: $ref: '#/components/schemas/ThemeSetting' put: summary: Update a single theme setting tags: - Theme Settings parameters: - name: element in: path required: true schema: type: string - name: name in: path required: true schema: type: string - name: report in: query schema: { type: integer, enum: [0, 1] } description: 1 = answer 200 with before/after instead of 204. requestBody: required: true content: application/json: schema: type: object properties: value: type: string image_id: type: integer description: Image settings only. An image in the shop's image library (type library) or an existing theme image. remove_image: type: boolean description: Image settings only. Removes the image from the setting. responses: '200': description: Setting updated (with ?report=1) content: application/json: schema: $ref: '#/components/schemas/ThemeSettingsReport' '204': description: Setting updated '400': description: Validation failed (same rules as the bulk PUT), nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' /theme-stylesheet: get: summary: Get the custom stylesheet description: The shop's custom CSS (Tema > Stylesheet in admin) with its SHA1 hash, needed for PUT. tags: - Theme Settings responses: '200': description: Stylesheet content: application/json: schema: type: object properties: stylesheet: { type: string } hash: { type: string, description: SHA1 of the stylesheet } bytes: { type: integer } notes: { type: array, items: { type: string } } put: summary: Patch the custom stylesheet description: | Change part of the stylesheet instead of replacing it. `op=replace` swaps an exact snippet (`find` must occur once unless `replace_all`), `op=append` adds `css` at the end. `expected_hash` must be the hash from GET; if the stylesheet changed in the meantime the answer is 409 and nothing is saved. The new CSS may not contain </style, <script, <!--, javascript:, expression(), behavior, -moz-binding or @import of an external address (also when escaped). To replace the whole stylesheet use PUT /webshop with `stylesheet`. tags: - Theme Settings requestBody: required: true content: application/json: schema: type: object required: [op, expected_hash] properties: op: { type: string, enum: [replace, append] } expected_hash: { type: string } find: { type: string } replace: { type: string } replace_all: { type: boolean } css: { type: string } responses: '200': description: Saved content: application/json: schema: type: object properties: op: { type: string } replaced: { type: integer } hash_before: { type: string } hash: { type: string } bytes_before: { type: integer } bytes: { type: integer } before: { type: string, description: CSS around the change before } after: { type: string, description: CSS around the change after } notes: { type: array, items: { type: string } } '400': description: Invalid request or rejected CSS, nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: The stylesheet changed since it was read, nothing saved content: application/json: schema: $ref: '#/components/schemas/Error' /theme-text: get: summary: List theme texts description: | Returns the theme's translatable strings with their current translations, plus the list of valid `section` values. To find work: call with `translated=no` and `limit=1`, then read `paging.total` for the number of untranslated strings. tags: - Theme Texts parameters: - name: section in: query schema: type: string description: Only strings in this section (exact match). Valid values come back in `sections`. example: "Mail" - name: search in: query schema: type: string description: Free-text filter — matches a substring of either `original` or `translated`. example: "kurv" - name: translated in: query schema: type: string enum: [all, yes, no] default: all description: >- Filter on translation status. `no` returns only untranslated strings (both NULL and empty are treated as untranslated). - name: limit in: query schema: type: integer minimum: 1 maximum: 1000 default: 100 description: Maximum number of results - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ThemeTextList' '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' put: summary: Bulk update translations description: | Writes up to 500 translations in one call. Always answers 200, even when some rows failed: unknown ids are reported per row as `not_found` while the valid rows are still written. 400 is only used for a structurally invalid body (missing/empty `translations`, or more than 500 entries). Placeholders such as `{x}` must be preserved verbatim in the translation. HTML tags that do not occur in the original text are removed, and unsafe HTML (script, event handlers, javascript: links) is stripped. Such rows get `tags_removed` (tag names, or `unsafe_html`) in `results`. tags: - Theme Texts requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ThemeTextBulkUpdate' responses: '200': description: Per-row report content: application/json: schema: $ref: '#/components/schemas/ThemeTextBulkResult' '400': description: Missing/empty `translations`, or more than 500 entries content: application/json: schema: $ref: '#/components/schemas/Error' post: summary: Not allowed description: >- Theme texts cannot be created through the API — the storefront creates a row the first time it renders the string. tags: - Theme Texts responses: '405': description: Method Not Allowed /theme-text/{id}: parameters: - name: id in: path required: true schema: type: integer description: theme_text_id get: summary: Get a theme text tags: - Theme Texts responses: '200': description: Success content: application/json: schema: $ref: '#/components/schemas/ThemeText' '403': description: The row belongs to another webshop '404': description: No such theme text put: summary: Set the translation description: >- Only `translated` is writable. Including `original` or `checksum` in the body is rejected with 400 rather than silently ignored, so a caller cannot believe it renamed a string. Tags not in the original are removed (the answer then has `tags_removed`). tags: - Theme Texts requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ThemeTextUpdate' responses: '200': description: The updated theme text content: application/json: schema: $ref: '#/components/schemas/ThemeText' '400': description: Attempted to write `original` or `checksum` content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The row belongs to another webshop '404': description: No such theme text delete: summary: Not allowed description: >- Rows cannot be deleted through the API — a deleted row reappears empty on the next render. Clear the translation with PUT `{"translated": null}` instead. tags: - Theme Texts responses: '405': description: Method Not Allowed /page-builder-page: get: summary: List page-builder pages description: | Returns all page-builder pages for the authenticated webshop. A page-builder page represents one editable page in the shop frontend (frontpage, basket, product detail, category detail, etc.) and contains an ordered list of rows (modules). tags: - Page Builder responses: '200': description: List of page-builder pages content: application/json: schema: type: array items: $ref: '#/components/schemas/PageBuilderPage' post: summary: Create a new page-builder page tags: - Page Builder requestBody: required: true content: application/json: schema: type: object required: [type] properties: type: type: string description: | Page type. Fixed pages: search, basket, address, shipping, approve, payment, thanks, also, order, return, user-sign-in, user-orders, user-wishlists. Dynamic pages: product, landing_page, category, static_page, blog_post. Theme tags start with `#`. name: type: string ids: type: array description: Object IDs to attach (only for dynamic pages) items: type: integer is_online: type: boolean description: Whether the page is published. Default true. online_from: type: string format: date-time nullable: true description: | Schedule visibility start. Datetime "YYYY-MM-DD HH:MM:SS". Useful for seasonal layouts (Black Friday, summer). Null/omit = no restriction. online_to: type: string format: date-time nullable: true description: Schedule visibility end. Null/omit = no restriction. priority: type: integer description: Tie-breaker when multiple pages are eligible. Higher wins. Default 0. responses: '201': description: Page created content: application/json: schema: $ref: '#/components/schemas/PageBuilderPage' /page-builder-page/{id}: get: summary: Get a page-builder page (with rows) tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Page with rows content: application/json: schema: $ref: '#/components/schemas/PageBuilderPageWithRows' put: summary: Update a page-builder page tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: name: type: string type: type: string ids: type: array items: type: integer is_online: type: boolean description: Toggle published state. true = visible, false = hidden draft. online_from: type: string format: date-time nullable: true description: | Schedule visibility start. Datetime "YYYY-MM-DD HH:MM:SS". Empty string clears the schedule. online_to: type: string format: date-time nullable: true description: Schedule visibility end. Empty string clears the schedule. priority: type: integer description: Tie-breaker when multiple pages are online. Higher wins. Default 0. responses: '204': description: Updated delete: summary: Delete a page-builder page tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer responses: '204': description: Deleted /page-builder-row: get: summary: List rows on a page-builder page tags: - Page Builder parameters: - name: theme_page_id in: query required: true schema: type: integer responses: '200': description: Rows in weight order content: application/json: schema: type: array items: $ref: '#/components/schemas/PageBuilderRow' post: summary: Add a row (module) to a page-builder page description: | Adds a row of the given module type to the page. Use `GET /page-builder-component` first to discover which modules the active theme exposes and which fields they have. tags: - Page Builder requestBody: required: true content: application/json: schema: type: object required: [theme_page_id, name] properties: theme_page_id: type: integer name: type: string description: Module/component name (e.g. "hero", "image_text", "product_list") position: type: string enum: [top, bottom] custom_title: type: string data: type: object description: Field id → value map for the module's flat fields responses: '201': description: Row created content: application/json: schema: $ref: '#/components/schemas/PageBuilderRow' /page-builder-row/{id}: get: summary: Get a single page-builder row tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Row with data content: application/json: schema: $ref: '#/components/schemas/PageBuilderRow' put: summary: Update a page-builder row tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: weight: type: integer position: type: string custom_title: type: string data: type: object responses: '204': description: Updated delete: summary: Delete a page-builder row tags: - Page Builder parameters: - name: id in: path required: true schema: type: integer responses: '204': description: Deleted /page-builder-row/order: put: summary: Reorder rows on a page (bulk) description: Reorders all rows on a page in one call. Pass the row ids in the desired order. tags: - Page Builder requestBody: required: true content: application/json: schema: type: object required: [theme_page_id, row_ids] properties: theme_page_id: type: integer row_ids: type: array items: type: integer position: type: string enum: [top, bottom] responses: '204': description: Reordered /page-builder-component: get: summary: List available page-builder modules from the active theme description: | Returns the active theme's `components/sections.json` plus per-section field schemas. Use this to discover which module names are valid for `POST /page-builder-row` and which `data` keys each module accepts. tags: - Page Builder responses: '200': description: Available components grouped by page type content: application/json: schema: type: object additionalProperties: type: array items: type: object properties: name: type: string title: type: string description: type: string fields: type: array items: type: object /campaign: get: summary: List campaigns tags: - Campaigns parameters: - name: limit in: query schema: type: integer minimum: 1 default: 250 maximum: 1000 description: Maximum number of results (values above 1000 are clamped to 1000) - name: offset in: query schema: type: integer minimum: 0 default: 0 description: Offset in results responses: '200': description: List of campaigns headers: X-Total-Count: schema: type: integer description: >- Total number of campaigns on the shop. The body is a plain array without a paging object, so use this header to detect truncation. content: application/json: schema: type: array items: $ref: '#/components/schemas/Campaign' post: summary: Create a campaign tags: - Campaigns requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CampaignUpdate' responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Campaign' /campaign/{id}: get: summary: Get a campaign tags: - Campaigns parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Campaign content: application/json: schema: $ref: '#/components/schemas/Campaign' put: summary: Update a campaign tags: - Campaigns parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CampaignUpdate' responses: '204': description: Updated delete: summary: Delete a campaign tags: - Campaigns parameters: - name: id in: path required: true schema: type: integer responses: '204': description: Deleted /oauth/authorize: get: summary: OAuth 2.0 authorization endpoint description: | Standard OAuth 2.0 Authorization Code flow entry point. Redirects the user through Shoporama login + shop selection, then back to `redirect_uri` with a short-lived `code` (60s, single-use) and the original `state`. Used by remote MCP servers and other OAuth 2.0 clients. The legacy `/admin/oauth/login` implicit-token flow is unaffected by this endpoint. tags: - OAuth parameters: - name: response_type in: query required: true schema: type: string enum: [code] - name: client_id in: query required: true schema: type: string - name: redirect_uri in: query required: true schema: type: string format: uri description: HTTPS required except for localhost - name: scope in: query schema: type: string enum: [read, write, all] default: all - name: state in: query required: true schema: type: string description: CSRF token, returned unchanged in the redirect - name: code_challenge in: query schema: type: string description: Optional PKCE code challenge - name: code_challenge_method in: query schema: type: string enum: [S256] responses: '302': description: Redirect to redirect_uri with code + state /oauth/token: post: summary: OAuth 2.0 token endpoint description: Exchange an authorization code for an access token. tags: - OAuth requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object required: [grant_type, code, redirect_uri, client_id] properties: grant_type: type: string enum: [authorization_code] code: type: string redirect_uri: type: string client_id: type: string code_verifier: type: string description: Required if `code_challenge` was sent to /oauth/authorize responses: '200': description: Access token content: application/json: schema: type: object properties: access_token: type: string token_type: type: string example: Bearer expires_in: type: integer nullable: true scope: type: string '400': description: Invalid grant content: application/json: schema: type: object properties: error: type: string error_description: type: string /oauth/revoke: post: summary: OAuth 2.0 revocation endpoint tags: - OAuth requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object required: [token] properties: token: type: string responses: '200': description: Token revoked (always 200 per RFC 7009) /oauth/register: post: summary: OAuth 2.0 Dynamic Client Registration (RFC 7591) description: | Lets MCP clients (Claude Desktop, mobile, claude.ai connectors) register themselves automatically and obtain a `client_id`. We treat all clients as public (no client secret); the `client_id` is just an identifier used for logging and consent screens. Any HTTPS `redirect_uri` is accepted (plus `http://localhost*` for local development). tags: - OAuth requestBody: required: true content: application/json: schema: type: object properties: client_name: type: string redirect_uris: type: array items: type: string format: uri grant_types: type: array items: type: string token_endpoint_auth_method: type: string enum: [none] responses: '201': description: Client registered content: application/json: schema: type: object properties: client_id: type: string client_id_issued_at: type: integer redirect_uris: type: array items: type: string grant_types: type: array items: type: string response_types: type: array items: type: string token_endpoint_auth_method: type: string /.well-known/oauth-protected-resource: get: summary: OAuth 2.0 Protected Resource Metadata (RFC 9728) description: | Discovery endpoint for the Shoporama MCP server. MCP clients fetch this to learn which OAuth authorization server protects /mcp. tags: - OAuth responses: '200': description: Protected resource metadata content: application/json: schema: type: object properties: resource: type: string format: uri authorization_servers: type: array items: type: string format: uri bearer_methods_supported: type: array items: type: string scopes_supported: type: array items: type: string /.well-known/oauth-authorization-server: get: summary: OAuth 2.0 Authorization Server Metadata (RFC 8414) description: | Discovery endpoint for Shoporama as an OAuth 2.0 authorization server. MCP clients fetch this to learn the URLs of /oauth/authorize, /oauth/token, /oauth/register and /oauth/revoke. tags: - OAuth responses: '200': description: Authorization server metadata content: application/json: schema: type: object properties: issuer: type: string format: uri authorization_endpoint: type: string format: uri token_endpoint: type: string format: uri registration_endpoint: type: string format: uri revocation_endpoint: type: string format: uri response_types_supported: type: array items: type: string grant_types_supported: type: array items: type: string code_challenge_methods_supported: type: array items: type: string scopes_supported: type: array items: type: string /menu-item: get: summary: List items in a menu tags: - Menus parameters: - name: menu_id in: query required: true schema: type: integer responses: '200': description: Menu items in display order content: application/json: schema: type: array items: $ref: '#/components/schemas/MenuItem' post: summary: Add a new item (link) to a menu tags: - Menus requestBody: required: true content: application/json: schema: type: object required: [menu_id, type, val] properties: menu_id: { type: integer } type: type: string enum: [product, category, landing_page, page, url, menu, blog] val: type: string description: Object id (for product/category/...) or URL string (for type=url) title: { type: string } class: { type: string } weight: { type: integer } responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/MenuItem' /menu-item/{id}: get: summary: Get a single menu item tags: - Menus parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Menu item content: application/json: schema: $ref: '#/components/schemas/MenuItem' put: summary: Update a menu item description: >- Only the fields present in the body are changed. Pass `menu_id` to MOVE the item into another menu. tags: - Menus parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: type: object properties: menu_id: type: integer description: >- Move the item to this menu. It is appended last in the target menu, so follow up with PUT /menu-item/order (or `weight`) to position it. The target menu must belong to the same shop and not be deleted, otherwise 404. Moving an item of type=menu into its own submenu returns 400 (it would make the menu contain itself). type: type: string enum: [product, category, landing_page, page, url, menu, blog] val: { type: string } title: { type: string } class: { type: string } weight: { type: integer } responses: '204': description: Updated '400': description: >- Bad Request — `menu_id` would move a submenu item into its own submenu, or the move failed. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: >- Not Found — no such menu item, or `menu_id` does not point at a live menu in this shop. content: application/json: schema: $ref: '#/components/schemas/Error' delete: summary: Delete a menu item tags: - Menus parameters: - name: id in: path required: true schema: type: integer responses: '204': description: Deleted /menu-item/order: put: summary: Reorder all items in a menu tags: - Menus requestBody: required: true content: application/json: schema: type: object required: [menu_id, item_ids] properties: menu_id: { type: integer } item_ids: type: array items: type: integer responses: '204': description: Reordered /stats: get: summary: High-level shop statistics description: | Returns a summary of orders and revenue for today, yesterday, this/last month, this/last year, and the trailing-12-month average order price. Pass `?month=YYYY-MM` for a drill-down on a specific month. Cached server-side for 5 minutes per webshop (and per month-param if set). tags: - Stats parameters: - name: month in: query schema: type: string pattern: '^\d{4}-\d{2}$' description: Optional drill-down month in YYYY-MM format responses: '200': description: Stats summary content: application/json: schema: $ref: '#/components/schemas/StatsSummary' /stats/{group}/{view}: get: summary: Statistics report description: | The statistics reports from admin (Statistik) in one common envelope. Numbers are calculated with the same code as the admin pages, so they match for the same period. Amount fields end in `_incl_vat` or `_excl_vat`. `notes` explains what is included (order statuses, credit notes, snapshots). Customer reports never contain e-mails. Reports (group/view): - sales: summary, revenue, margin, timing, shipping, geography - products: sales, abc, brands, suppliers, bought_together, returns, price_history - inventory: summary, reorder, dead_stock, no_sales, out_of_stock, turnover - marketing: vouchers, attribution, conversion, visitors, baskets, recommendations - customers: summary, new_vs_returning, cohorts Snapshot reports (sales/summary, products/abc, inventory summary/reorder/dead_stock/ no_sales/out_of_stock, customers/cohorts) ignore `from`, `to` and `compare`. Periods longer than 1096 days are shortened from the start (see `notes`). Cached server-side: 5 minutes when the period includes today, otherwise 6 hours. tags: - Stats parameters: - name: group in: path required: true schema: { type: string, enum: [sales, products, inventory, marketing, customers] } - name: view in: path required: true schema: { type: string } description: Report within the group, see the list above. - name: from in: query schema: { type: string, format: date } description: Start date (inclusive). Each report has its own default period. - name: to in: query schema: { type: string, format: date } description: End date (inclusive). A day that does not exist in the month (2026-02-31) is clamped to the last day. - name: compare in: query schema: { type: string, enum: [none, previous, last_year], default: none } description: Adds compare_totals and change_pct for the previous period of the same length or the same dates last year. - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 50 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } - name: month in: query schema: { type: string, pattern: '^\d{4}-\d{2}$' } description: sales/summary only. Adds one month to the rows. - name: granularity in: query schema: { type: string, enum: [auto, day, week, month], default: auto } description: sales/revenue only. auto = day up to 31 days, week up to 92 days, else month. - name: category_id in: query schema: { type: integer } description: products/sales only. - name: include_subcategories in: query schema: { type: boolean, default: true } description: products/sales only. - name: supplier_id in: query schema: { type: integer } description: products/sales, products/abc, inventory/no_sales. - name: brand_id in: query schema: { type: integer } description: products/sales, products/abc, inventory/no_sales. - name: sort in: query schema: { type: string } description: | products/sales: product, sku, total_sold (default), orders, revenue, profit, total_return, margin_pct, stock_now, daily_avg, days_until_empty, sell_through. inventory/dead_stock: bound_capital (default), created, days_since_last_sale. inventory/no_sales: name, price, stock, last_sale, bound_capital (default), brand, supplier, sku. - name: sort_order in: query schema: { type: string, enum: [asc, desc], default: desc } - name: period_days in: query schema: { type: integer } description: products/abc (30, 60, 90, 180, 365) and inventory summary/reorder/dead_stock (30, 90, 180, 365). Default 90. - name: metric in: query schema: { type: string, enum: [revenue_net, quantity, profit], default: revenue_net } description: products/abc only. - name: min_count in: query schema: { type: integer, minimum: 1, default: 5 } description: products/bought_together only. - name: hide_related in: query schema: { type: boolean, default: true } description: products/bought_together only. - name: search in: query schema: { type: string } description: products/price_history only. - name: status in: query schema: { type: string, enum: [all, online, offline], default: all } description: inventory/dead_stock only. - name: months in: query schema: { type: integer } description: inventory/no_sales (1, 3, 6, 12, 18, 24; default 12) and customers/cohorts (6-24; default 12). - name: hide_zero_stock in: query schema: { type: boolean, default: false } description: inventory/no_sales only. - name: by in: query schema: { type: string, enum: [supplier, brand], default: supplier } description: inventory/turnover only. responses: '200': description: Report content: application/json: schema: $ref: '#/components/schemas/StatsEnvelope' '400': description: Invalid parameter content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Unknown report. The body lists `valid_reports`. content: application/json: schema: type: object properties: error: { type: string, example: unknown_report } message: { type: string } valid_reports: type: object additionalProperties: type: array items: { type: string } /top-products: get: summary: Most-sold products in the shop description: | Returns the most-sold products ranked by units sold on paid orders within the time window. Wraps `Webshop::getPopularProducts()` (1 hour cache). tags: - Stats parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 50, default: 10 } - name: days in: query schema: { type: integer, minimum: 1, maximum: 730, default: 90 } description: Look back this many days. Default 90. responses: '200': description: Top products list content: application/json: schema: type: object properties: webshop_id: { type: integer } currency: { type: string } days: { type: integer } limit: { type: integer } count: { type: integer } items: type: array items: type: object properties: product_id: { type: integer } name: { type: string } rewrite_url: { type: string } price: { type: number, format: float } sale_price: { type: number, format: float, nullable: true } sku: { type: string, nullable: true } is_online: { type: integer } /not-found-log: get: summary: List 404 errors logged by the storefront description: Grouped by target_url and sorted by frequency. Use this to find broken links to redirect. tags: - Stats parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 500 default: 100 responses: '200': description: 404 entries content: application/json: schema: type: object properties: total: type: integer items: type: array items: $ref: '#/components/schemas/NotFoundEntry' /not-found-log/{id}: delete: summary: Delete a single 404 entry tags: - Stats parameters: - name: id in: path required: true schema: type: integer responses: '204': description: Deleted /search-log: get: summary: List onsite search terms description: | Returns search terms users have entered in the storefront, with counts. Use `only_zero=1` to find searches that returned 0 results — those are the most valuable signals for products to add to the catalog. tags: - Stats parameters: - name: only_zero in: query schema: type: boolean - name: period in: query schema: type: integer description: Restrict to last N days (omit for all time) - name: order in: query schema: type: string enum: [cnt, term, results, time] - name: sort_order in: query schema: type: string enum: [asc, desc] - name: limit in: query schema: type: integer minimum: 1 maximum: 500 - name: offset in: query schema: type: integer responses: '200': description: Search log entries content: application/json: schema: type: object properties: paging: type: object properties: limit: { type: integer } offset: { type: integer } count: { type: integer } total: { type: integer } zero_result_count: type: integer items: type: array items: $ref: '#/components/schemas/SearchLogEntry' /mcp: post: summary: MCP (Model Context Protocol) JSON-RPC endpoint description: | JSON-RPC 2.0 endpoint that AI assistants like Claude Desktop, Claude mobile and Cowork speak. Each request is one JSON-RPC message (`initialize`, `tools/list`, `tools/call`, `ping`, ...). Authentication is via the same Bearer token mechanism as REST. Returns 401 + `WWW-Authenticate: Bearer ... resource_metadata=...` so OAuth-aware clients can auto-discover the OAuth flow on first contact. tags: - MCP requestBody: required: true content: application/json: schema: type: object description: A JSON-RPC 2.0 request (or batch) responses: '200': description: JSON-RPC response content: application/json: schema: type: object '401': description: Missing or invalid Bearer token (WWW-Authenticate header points clients to OAuth discovery) /legacy-page-builder-setup: get: summary: List setups in the old Theme Builder description: | Returns every setup in the legacy Theme Builder for the authenticated webshop. A setup is one page layout, bound to a page type or to a specific URL. Read-only. Works regardless of the shop's Smarty version — including shops already moved to Smarty 4, where the storefront no longer renders any of this. tags: - Legacy Page Builder parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 500, default: 100 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Setups content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderSetupList' '401': description: Missing or invalid token /legacy-page-builder-setup/{id}: get: summary: Get one setup with its full layout description: | Returns the setup with its rows, columns and modules resolved, including each module's settings, images and references. tags: - Legacy Page Builder parameters: - name: id in: path required: true schema: { type: integer } - name: version in: query description: Which layout to return. `both` returns published and draft side by side. schema: type: string enum: [published, draft, both] default: published - name: render in: query description: | Set to 1 to also return each module's rendered HTML. Off by default because it is considerably slower and rarely needed for a migration — the settings in `data` plus the module template describe the module completely. schema: { type: integer, enum: [0, 1] } responses: '200': description: Setup with layout content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderSetupWithLayout' '404': description: No such setup on this webshop '405': description: Read-only endpoint. Only GET is allowed. /legacy-page-builder-module: get: summary: List module instances description: | Every module instance belonging to the webshop, whether or not a layout uses it. Soft-deleted modules are excluded unless `include_removed=1`. tags: - Legacy Page Builder parameters: - name: theme_setup_id in: query description: Only modules referenced by this setup's published or draft layout. schema: { type: integer } - name: type in: query description: Only modules of this type, e.g. `hero`. schema: { type: string } - name: orphans_only in: query description: Only modules that no layout references. schema: { type: integer, enum: [0, 1] } - name: include_removed in: query description: Include soft-deleted modules. schema: { type: integer, enum: [0, 1] } - name: version in: query schema: { type: string, enum: [published, draft], default: published } - name: render in: query schema: { type: integer, enum: [0, 1] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 500, default: 100 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: '200': description: Modules content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderModuleList' '401': description: Missing or invalid token /legacy-page-builder-module/{id}: get: summary: Get one module instance tags: - Legacy Page Builder parameters: - name: id in: path required: true schema: { type: integer } - name: version in: query schema: { type: string, enum: [published, draft], default: published } - name: render in: query schema: { type: integer, enum: [0, 1] } responses: '200': description: Module content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderModule' '404': description: No such module on this webshop '405': description: Read-only endpoint. Only GET is allowed. /legacy-page-builder-module-type: get: summary: List all module types description: | The catalogue of module types the legacy Theme Builder offers, with the setting keys each type accepts and the template that renders it. The catalogue is the same for every shop; only `supported_by_theme` depends on the active theme. tags: - Legacy Page Builder responses: '200': description: Module types content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderModuleTypeList' '401': description: Missing or invalid token /legacy-page-builder-export: get: summary: Export the entire legacy setup in one call description: | Everything in one document: all setups with their layouts, all orphaned modules, and the module type catalogue. This is the endpoint to use when migrating a shop. Per-module `template` is omitted unless the template was edited on that specific module, since core and theme templates are listed once under `module_types`. tags: - Legacy Page Builder parameters: - name: version in: query schema: type: string enum: [published, draft, both] default: both - name: include_removed in: query description: | Include soft-deleted orphan modules. Soft-deleted modules that a layout still references are always included, because the storefront still renders them. schema: { type: integer, enum: [0, 1] } - name: render in: query schema: { type: integer, enum: [0, 1] } responses: '200': description: Full export content: application/json: schema: $ref: '#/components/schemas/LegacyPageBuilderExport' '401': description: Missing or invalid token '405': description: Read-only endpoint. Only GET is allowed. tags: - name: Theme Files description: | Programmatic access to theme template files. Provides read/write access to all files in a webshop's private theme directories. **Token Requirement**: The API token must have `allow_theme_files` enabled. This is a separate permission configured per token in the admin panel. **Permission Model**: - `read` tokens: GET only (list themes, list files, read file content) - `write` tokens: GET, POST, PUT (read and write files) - `all` tokens: GET, POST, PUT, DELETE (full access including deletion) **Important Differences from Other Endpoints**: - Request body for POST/PUT is **raw file content**, not JSON - `Content-Type: application/json` is NOT required for POST/PUT - GET responses for individual files return raw content with metadata in headers - Maximum file size for uploads is 2MB - File listings are capped at 5000 files **File Permissions**: Files created via SFTP may not be writable by the web server. Check the `is_writable` field in file listings or the `X-File-Writable` response header before attempting writes. - name: Newsletter description: Newsletter management - lists, subscribers, and campaigns - name: Brands description: Brand management operations - name: Products description: Product management operations - name: Categories description: Category management operations - name: Landing Pages description: Landing page management operations - name: Suppliers description: Supplier management operations - name: Orders description: Order management operations - name: Stock description: Stock management operations - name: Vouchers description: Voucher/discount code management operations - name: Order Labels description: Order label management operations - name: Countries description: Country management operations - name: Shipping description: Shipping method management operations - name: Redirects description: URL redirect management operations - name: Profiles description: Product profile management operations - name: Profile Attributes description: Profile attribute management operations - name: Profile Attribute Values description: Profile attribute value management operations - name: Product Labels description: Product label management operations - name: Order Returns description: Order return management operations - name: App Management description: App-level management operations - name: Easter Eggs description: Fun easter egg endpoints - name: Customers description: Customer management operations - name: Blog Posts description: Blog post management operations - name: Pages description: Page management operations - name: Menus description: Menu management operations - name: Landing Page Items description: Landing page item management operations - name: Notices description: Webshop notification management operations - name: Webshop description: | Read and update settings for the authenticated webshop. There is no `{id}` segment — the operation always targets the webshop the API token belongs to. Sensitive fields (api_token, payment credentials, sms passwords, partner_id, status) and identity fields (`domain`, `rewrite_url`) are intentionally NOT writable via this endpoint. - name: Theme Settings description: | Read and write the active theme's settings (colors, fonts, copy, etc.). The schema is loaded from the theme's `theme_settings.json` file at runtime. Only element/name pairs that appear in the returned `schema` do anything — writing an unknown pair is stored but no theme will ever read it, and PUT returns 204 either way. Call GET /theme-settings first to discover valid keys. The shop LOGO and FAVICON are not theme settings and are deliberately absent from this schema: they are shop-level and survive a theme change. Set and read them via `logo` / `favicon` on /webshop. - name: Theme Texts description: | The theme's translatable strings — everything the theme wraps in `<{t}>` (buttons, labels, validation messages, mail templates). Same data as Tema → Tekster in admin. Product, category, page and blog content is NOT here; use the respective endpoints for that. Rows are created by the storefront itself the first time a string is rendered, so a string that has never been shown does not exist yet. There is deliberately no POST or DELETE: `original` (and its md5 `checksum`) is the identity of the string and is owned by the theme, and a deleted row simply reappears empty on the next render. Clearing a translation with PUT `{"translated": null}` achieves the same thing without losing `hint`/`section`. Placeholders like `{x}` or `{product_name}` are substituted by the theme AFTER the translation is applied — they must be preserved verbatim in the translation, or the value disappears from the rendered page. - name: Page Builder description: | The Shoporama Sidedesigner. Manages pages (frontpage, basket, product detail, etc.) and the rows/modules within them. Use `/page-builder-component` to discover which modules the active theme exposes and which fields each module accepts before adding rows. - name: Legacy Page Builder description: | Read-only access to the old Theme Builder, the predecessor to the Sidedesigner. It exists so shops still on Smarty 2 can pull their old layout out as structured data and rebuild it in the Page Builder, instead of scraping the rendered page. The two systems are not equivalent and there is no automatic conversion. The old one is a free Bootstrap grid where each module can hold arbitrary Smarty and HTML; the new one is built from typed components with defined fields. Expect to make layout decisions by hand. These endpoints work on shops that have already moved to Smarty 4, where the storefront no longer renders any of this content. That is the main reason they exist. Start with `/legacy-page-builder-export`. - name: Campaigns description: Sales campaigns (cheapest free, fixed bundle price, percent off bundles). - name: Stats description: | Read-only observability endpoints: high-level sales summary, 404 error log, and onsite search log. Useful for finding broken links, products that need to be added (zero-result searches), and quickly checking how the shop is doing. - name: MCP description: | Model Context Protocol JSON-RPC endpoint for AI assistants. Speaks the Streamable HTTP transport, exposes ~80 tools that map to REST resources, and supports OAuth 2.0 discovery so mobile and web clients can onboard themselves without manual config. - name: OAuth description: | OAuth 2.0 Authorization Code flow endpoints. Used by remote MCP servers and other OAuth 2.0 clients. The legacy `/admin/oauth/login` implicit-token flow is unaffected. Includes RFC 8414 (authorization server metadata), RFC 9728 (protected resource metadata), and RFC 7591 (dynamic client registration) for full mobile/web auto-discovery. - name: Webhooks description: | Webhook management for receiving real-time notifications about events in your webshop. **Available Events**: - `order` - Triggered when orders are created, status changes, or payment is received - `stock` - Triggered when stock levels change - `product` - Triggered when products are created or updated - `category` - Triggered when categories are created, updated, or deleted **Security**: All webhook requests are signed using HMAC-SHA256. The signature is included in the `X-Webhook-Signature` header. **Payload Format**: ```json { "event": "order", "action": "create", "timestamp": "2025-01-01T12:00:00+00:00", "webshop_id": 123, "data": { ... } } ```