{
  "openapi": "3.0.3",
  "info": {
    "title": "Omega API Documentation",
    "version": "1.0.0",
    "description": "API Documentation for Omega Projects"
  },
  "servers": [
    {
      "url": "https://doc.trustisto.com"
    }
  ],
  "tags": [
    {
      "name": "OrganizationMedia"
    },
    {
      "name": "OrganizationTemplate"
    },
    {
      "name": "WebsiteClients"
    },
    {
      "name": "WebsiteEvents"
    },
    {
      "name": "WebsiteProducts"
    },
    {
      "name": "WebsiteSourceEvents"
    },
    {
      "name": "WebsiteStats"
    },
    {
      "name": "Websites"
    }
  ],
  "paths": {
    "/api/v2/organizations/{organization_id}/media": {
      "post": {
        "summary": "Create organization media",
        "description": "Create a media item, in one of three ways. From an address - send `source_url` and the file is fetched here. This is the cheap way for a client that is not holding the bytes itself: nothing is uploaded, only the address. Only `http(s)`, only JPEG and PNG, at most 2 MB, and an address resolving inside our own network is refused. From an upload - send a `media` file with a `name`. From base64 (the Topol editor mode, used when neither of the above is given) - send a `content` data URI and a `filename`; the answer is only the uploaded file URL. Costly for a model to use: base64 inflates the file by a third and tokenises poorly, so a 150 KB photo runs into tens of thousands of tokens. Prefer `source_url`, or reuse something the listing already shows. Returns 400 with the first validation error when invalid, 403 if the organization is not the current user's and 404 if it does not exist.",
        "tags": [
          "OrganizationMedia"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "description": "Media ID (standard mode)"
                    },
                    "name": {
                      "type": "string",
                      "description": "Media name (standard mode)"
                    },
                    "urls": {
                      "type": "object",
                      "description": "URLs per style (standard mode)"
                    },
                    "media_resolution": {
                      "type": "string",
                      "description": "Media resolution (standard mode)"
                    },
                    "updated_at": {
                      "type": "integer",
                      "description": "Last update time in Unix seconds (standard mode)"
                    },
                    "url": {
                      "type": "string",
                      "description": "Uploaded file URL (Topol mode)"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Media validation failed"
          }
        },
        "operationId": "CreateOrganizationMedia",
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "source_url": {
                    "type": "string",
                    "description": "Address of the image to fetch (address mode)"
                  },
                  "media": {
                    "type": "string",
                    "format": "binary",
                    "description": "Media file (standard mode)"
                  },
                  "name": {
                    "type": "string",
                    "description": "Media name (standard mode)"
                  },
                  "content": {
                    "type": "string",
                    "description": "Base64-encoded data URI of the file (Topol mode)"
                  },
                  "filename": {
                    "type": "string",
                    "description": "File name (Topol mode)"
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "summary": "List organization media",
        "description": "List the organization's media items, optionally filtered and sorted. Returns 403 if the organization is not the current user's and 404 if it does not exist.",
        "tags": [
          "OrganizationMedia"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Search phrase",
            "schema": {
              "type": "string",
              "description": "Search phrase"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum number of results",
            "schema": {
              "type": "integer",
              "description": "Maximum number of results"
            }
          },
          {
            "name": "sort_asc",
            "in": "query",
            "required": false,
            "description": "Field to sort ascending by. Anything else falls back to `media_updated_at`.",
            "schema": {
              "type": "string",
              "description": "Field to sort ascending by. Anything else falls back to `media_updated_at`.",
              "enum": [
                "name",
                "media_updated_at",
                "created_at",
                "updated_at"
              ]
            }
          },
          {
            "name": "sort_desc",
            "in": "query",
            "required": false,
            "description": "Field to sort descending by. Anything else falls back to `media_updated_at`.",
            "schema": {
              "type": "string",
              "description": "Field to sort descending by. Anything else falls back to `media_updated_at`.",
              "enum": [
                "name",
                "media_updated_at",
                "created_at",
                "updated_at"
              ]
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "description": "Filter by exact name",
            "schema": {
              "type": "string",
              "description": "Filter by exact name"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "media": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "description": "Media ID"
                          },
                          "name": {
                            "type": "string",
                            "description": "Media name"
                          },
                          "urls": {
                            "type": "object",
                            "description": "URLs per style (`thumb`, `preview`, `original`)"
                          },
                          "media_resolution": {
                            "type": "string",
                            "description": "Media resolution (e.g. `800x600`)"
                          },
                          "updated_at": {
                            "type": "integer",
                            "description": "Last update time (Unix seconds)"
                          }
                        },
                        "required": [
                          "id",
                          "name",
                          "urls",
                          "media_resolution",
                          "updated_at"
                        ]
                      },
                      "description": "List of media items"
                    }
                  },
                  "required": [
                    "media"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListOrganizationMedias"
      }
    },
    "/api/v2/organizations/{organization_id}/templates": {
      "post": {
        "summary": "Create organization template",
        "description": "Create a new organization template. Returns 400 with the first validation error when the template is invalid, 403 if the organization is not the current user's and 404 if it does not exist. A drag and drop template is published from `json`, `manifest` and `html` sent together - it stays unpublished, and gets no `url`, until all three are there. For a drag and drop template, the editor project sent as `json` is documented by [WebCampaignTemplate](#models/WebCampaignTemplate) (popup/topbar) and [MailCampaignTemplate](#models/MailCampaignTemplate) (mail), and the `manifest` next to it by [WebCampaignManifest](#models/WebCampaignManifest) and [MailCampaignManifest](#models/MailCampaignManifest).",
        "tags": [
          "OrganizationTemplate"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "description": "Template ID"
                    },
                    "name": {
                      "type": "string",
                      "description": "Template name"
                    },
                    "template_type": {
                      "type": "string",
                      "description": "Template type"
                    },
                    "status": {
                      "type": "integer",
                      "description": "Template status (numeric enum value)"
                    },
                    "updated_at": {
                      "type": "integer",
                      "description": "Last update time (Unix seconds)"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "template_type",
                    "status",
                    "updated_at"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Template validation failed"
          }
        },
        "operationId": "CreateOrganizationTemplate",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "template_type": {
                    "type": "string",
                    "description": "Template type. The `*_template` types are the campaign scoped copies the drag and drop editors save.",
                    "enum": [
                      "popup",
                      "topbar",
                      "mail",
                      "popup_template",
                      "topbar_template",
                      "mail_template"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "description": "Template name"
                  },
                  "html": {
                    "type": "string",
                    "description": "Template HTML (`index.html`) - the editor project exported as one standalone page. Not written by hand: how it is produced from `json` is described by [WebCampaignTemplate](#models/WebCampaignTemplate) and [MailCampaignTemplate](#models/MailCampaignTemplate)."
                  },
                  "campaign_id": {
                    "type": "integer",
                    "description": "Related campaign ID. Makes the template that campaign's own copy: a `mail` created with it is stored as `mail_template`, kept out of the organization's template list, and a package uploaded onto it afterwards belongs to the campaign alone."
                  },
                  "json": {
                    "type": "object",
                    "description": "Editor project (`input.mjml.json`) - see [WebCampaignTemplate](#models/WebCampaignTemplate) or [MailCampaignTemplate](#models/MailCampaignTemplate)."
                  },
                  "manifest": {
                    "type": "object",
                    "description": "Template manifest (`manifest.json`) - see [WebCampaignManifest](#models/WebCampaignManifest) or [MailCampaignManifest](#models/MailCampaignManifest)."
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "summary": "List organization templates",
        "description": "List the organization's templates (excluding `mail_template`), optionally filtered and sorted. Returns 403 if the organization is not the current user's and 404 if it does not exist. Each entry carries the `url` its package is stored under, which is how the template is read - see the field below.",
        "tags": [
          "OrganizationTemplate"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum number of results",
            "schema": {
              "type": "integer",
              "description": "Maximum number of results"
            }
          },
          {
            "name": "sort_asc",
            "in": "query",
            "required": false,
            "description": "Field to sort ascending by. Anything else falls back to `updated_at`.",
            "schema": {
              "type": "string",
              "description": "Field to sort ascending by. Anything else falls back to `updated_at`.",
              "enum": [
                "name",
                "template_type",
                "status",
                "created_at",
                "updated_at"
              ]
            }
          },
          {
            "name": "sort_desc",
            "in": "query",
            "required": false,
            "description": "Field to sort descending by. Anything else falls back to `updated_at`.",
            "schema": {
              "type": "string",
              "description": "Field to sort descending by. Anything else falls back to `updated_at`.",
              "enum": [
                "name",
                "template_type",
                "status",
                "created_at",
                "updated_at"
              ]
            }
          },
          {
            "name": "drag_drop",
            "in": "query",
            "required": false,
            "description": "Filter by drag-and-drop templates",
            "schema": {
              "type": "boolean",
              "description": "Filter by drag-and-drop templates"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Search filter (`name`, `template_type`, `status`)",
            "schema": {
              "type": "object",
              "description": "Search filter (`name`, `template_type`, `status`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "templates": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "description": "Template ID"
                          },
                          "name": {
                            "type": "string",
                            "description": "Template name"
                          },
                          "template_type": {
                            "type": "string",
                            "description": "Template type"
                          },
                          "status": {
                            "type": "integer",
                            "description": "Template status (numeric enum value)"
                          },
                          "updated_at": {
                            "type": "integer",
                            "description": "Last update time (Unix seconds)"
                          },
                          "url": {
                            "type": "string",
                            "description": "Public base URL of the stored package, present only once the template has an `index.html` (`status` ok). It addresses a directory, not a file: the package is read by appending a name to it - `index.html` is the markup, `input.mjml.json` the editor source, `manifest.json` the metadata, `thumbnail.png` the preview"
                          },
                          "editor": {
                            "type": "string",
                            "description": "Editor used to build the template"
                          }
                        },
                        "required": [
                          "id",
                          "name",
                          "template_type",
                          "status",
                          "updated_at"
                        ]
                      },
                      "description": "List of templates"
                    }
                  },
                  "required": [
                    "templates"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListOrganizationTemplates"
      }
    },
    "/api/v2/organizations/{organization_id}/templates/{id}": {
      "get": {
        "summary": "Get organization template",
        "description": "Get a single organization template. Returns 404 if the template does not exist for this organization, and 403 if the organization is not the current user's. To read what the template actually contains, fetch `index.html` under the returned `url`; the campaign's own public page is assembled in the browser, so fetching that instead returns the scaffolding rather than the template.",
        "tags": [
          "OrganizationTemplate"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Template ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "description": "Template ID"
                    },
                    "name": {
                      "type": "string",
                      "description": "Template name"
                    },
                    "template_type": {
                      "type": "string",
                      "description": "Template type"
                    },
                    "status": {
                      "type": "integer",
                      "description": "Template status (numeric enum value)"
                    },
                    "updated_at": {
                      "type": "integer",
                      "description": "Last update time (Unix seconds)"
                    },
                    "url": {
                      "type": "string",
                      "description": "Public base URL of the stored package, present only once the template has an `index.html` (`status` ok). It addresses a directory, not a file: the package is read by appending a name to it - `index.html` is the markup, `input.mjml.json` the editor source, `manifest.json` the metadata, `thumbnail.png` the preview"
                    },
                    "editor": {
                      "type": "string",
                      "description": "Editor used to build the template"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "template_type",
                    "status",
                    "updated_at"
                  ]
                }
              }
            }
          },
          "403": {
            "description": "Organization does not belong to the current user"
          },
          "404": {
            "description": "Template does not exist"
          }
        },
        "operationId": "GetOrganizationTemplate"
      },
      "put": {
        "summary": "Update organization template",
        "description": "Update an existing organization template. The files are updated one at a time. `html`, `json` and `manifest` map to `index.html`, `input.mjml.json` and `manifest.json`; whichever of them the request carries is written, and the rest are left exactly as they were. A request carrying none of them - a rename, say - writes nothing at all. The response lists what was written in `stored`. The field is absent when nothing was, so a caller can tell a save from a request that was accepted and had no file to store. A template stays unpublished, and gets no `url`, until its `index.html` has been written - that is the file the url points at. Sending the other two alone stores them and leaves the template unpublished. For a drag and drop template, the editor project sent as `json` is documented by [WebCampaignTemplate](#models/WebCampaignTemplate) (popup/topbar) and [MailCampaignTemplate](#models/MailCampaignTemplate) (mail), and the `manifest` next to it by [WebCampaignManifest](#models/WebCampaignManifest) and [MailCampaignManifest](#models/MailCampaignManifest). Three fields of the manifest are set here, whatever the request says: `name` and `type` follow the template, and `editable` is not a flag but the id a campaign scoped template is edited by. A manifest sent with those keys has them replaced. Returns 404 if the template does not exist for this organization, 400 with the first validation error when invalid, and 403 if the organization is not the current user's.",
        "tags": [
          "OrganizationTemplate"
        ],
        "parameters": [
          {
            "name": "organization_id",
            "in": "path",
            "required": true,
            "description": "Organization ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Template ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "description": "Template ID"
                    },
                    "name": {
                      "type": "string",
                      "description": "Template name"
                    },
                    "template_type": {
                      "type": "string",
                      "description": "Template type"
                    },
                    "status": {
                      "type": "integer",
                      "description": "Template status (numeric enum value)"
                    },
                    "updated_at": {
                      "type": "integer",
                      "description": "Last update time (Unix seconds)"
                    },
                    "stored": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Files written by this request; absent when none were"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "template_type",
                    "status",
                    "updated_at"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Template validation failed"
          },
          "404": {
            "description": "Template does not exist"
          }
        },
        "operationId": "UpdateOrganizationTemplate",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "template_type": {
                    "type": "string",
                    "description": "Template type. The `*_template` types are the campaign scoped copies the drag and drop editors save.",
                    "enum": [
                      "popup",
                      "topbar",
                      "mail",
                      "popup_template",
                      "topbar_template",
                      "mail_template"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "description": "Template name"
                  },
                  "html": {
                    "type": "string",
                    "description": "Template HTML (`index.html`) - the editor project exported as one standalone page. Not written by hand: how it is produced from `json` is described by [WebCampaignTemplate](#models/WebCampaignTemplate) and [MailCampaignTemplate](#models/MailCampaignTemplate)."
                  },
                  "campaign_id": {
                    "type": "integer",
                    "description": "Related campaign ID"
                  },
                  "json": {
                    "type": "object",
                    "description": "Editor project (`input.mjml.json`) - see [WebCampaignTemplate](#models/WebCampaignTemplate) or [MailCampaignTemplate](#models/MailCampaignTemplate)."
                  },
                  "manifest": {
                    "type": "object",
                    "description": "Template manifest (`manifest.json`) - see [WebCampaignManifest](#models/WebCampaignManifest) or [MailCampaignManifest](#models/MailCampaignManifest)."
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v2/websites/{website_id}/clients/{client_id}": {
      "get": {
        "summary": "Get client",
        "description": "Get a single client with data merged from DynamoDB and Elasticsearch, including conversions and per-source-event stats. Numeric/timestamp fields are normalized. Returns 404 if the client does not exist.",
        "tags": [
          "WebsiteClients"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "client_id",
            "in": "path",
            "required": true,
            "description": "Client ID",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "clientId": {
                      "type": "string",
                      "description": "Client ID"
                    },
                    "email": {
                      "type": "string",
                      "description": "Client e-mail"
                    },
                    "phone": {
                      "type": "string",
                      "description": "Client phone"
                    },
                    "createdAt": {
                      "type": "integer",
                      "description": "Creation time (Unix seconds)"
                    },
                    "lastSeen": {
                      "type": "integer",
                      "description": "Last activity time (Unix seconds)"
                    },
                    "score": {
                      "type": "integer",
                      "description": "Client score"
                    },
                    "tags": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Client tags"
                    },
                    "segments": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Client segments"
                    },
                    "conversions": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Conversion events (with normalized numeric fields)"
                    }
                  },
                  "required": [
                    "clientId"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Client does not exist"
          }
        },
        "operationId": "GetWebsiteClient"
      }
    },
    "/api/v2/websites/{website_id}/clients/{client_id}/history": {
      "get": {
        "summary": "Client history",
        "description": "Paginated activity history for a client. Pass `NextToken` from a previous response to fetch the next page. Returns 404 if the client has no history.",
        "tags": [
          "WebsiteClients"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "client_id",
            "in": "path",
            "required": true,
            "description": "Client ID",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "NextToken",
            "in": "query",
            "required": false,
            "description": "Pagination token from a previous response",
            "schema": {
              "type": "string",
              "description": "Pagination token from a previous response"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "history": {
                      "type": "object",
                      "description": "Paginated history (events plus a `NextToken` for the next page)"
                    }
                  },
                  "required": [
                    "history"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Client has no history"
          }
        },
        "operationId": "GetWebsiteClientHistory"
      }
    },
    "/api/v2/websites/{website_id}/clients/search": {
      "get": {
        "summary": "Search clients",
        "description": "Search the website's clients, paginated. `query` is a phrase match over a few fields; everything else narrows the result with filters, and the two combine. Filters. Any field below can be passed as its own parameter, and several can be combined - they are ANDed. Two conventions carry the rest: a `CO` suffix picks the comparison for a numeric or date field: `score=50&scoreCO=gte` reads \"score of at least 50\". Without it, passing the field twice is a range: `score=40&score=70` means 40 to 70. an `O` suffix picks the logic when a filter is given several values: `tags=vip&tags=loyal` requires both, `tags=vip&tags=loyal&tagsO=o` accepts either. Dates and times are Unix seconds, the same as in the response.",
        "tags": [
          "WebsiteClients"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "Phrase to look for in the client's e-mail, phone, tags, first name and last name - and nowhere else. A city or a segment name will not match here; use the matching filter instead. A full e-mail address matches exactly.",
            "schema": {
              "type": "string",
              "description": "Phrase to look for in the client's e-mail, phone, tags, first name and last name - and nowhere else. A city or a segment name will not match here; use the matching filter instead. A full e-mail address matches exactly."
            }
          },
          {
            "name": "tags",
            "in": "query",
            "required": false,
            "description": "Only clients carrying these tags. All of them by default, any of them with `tagsO=o`.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Only clients carrying these tags. All of them by default, any of them with `tagsO=o`."
            }
          },
          {
            "name": "tagsO",
            "in": "query",
            "required": false,
            "description": "Whether `tags` means all (`a`) or any (`o`).",
            "schema": {
              "type": "string",
              "description": "Whether `tags` means all (`a`) or any (`o`).",
              "enum": [
                "a",
                "o"
              ],
              "default": "a"
            }
          },
          {
            "name": "notHaveTags",
            "in": "query",
            "required": false,
            "description": "Only clients carrying none of these tags. Takes `notHaveTagsO` the same way.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Only clients carrying none of these tags. Takes `notHaveTagsO` the same way."
            }
          },
          {
            "name": "segments",
            "in": "query",
            "required": false,
            "description": "Only clients in these segments; `notHaveSegments` excludes instead. Both take an `O` suffix.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Only clients in these segments; `notHaveSegments` excludes instead. Both take an `O` suffix."
            }
          },
          {
            "name": "score",
            "in": "query",
            "required": false,
            "description": "Client score. With `scoreCO` a bound, without it a range.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "Client score. With `scoreCO` a bound, without it a range."
            }
          },
          {
            "name": "purchaseCount",
            "in": "query",
            "required": false,
            "description": "How many purchases the client made.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "How many purchases the client made."
            }
          },
          {
            "name": "purchaseSum",
            "in": "query",
            "required": false,
            "description": "How much the client spent.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "How much the client spent."
            }
          },
          {
            "name": "purchaseFirstTime",
            "in": "query",
            "required": false,
            "description": "When the client first bought.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "When the client first bought."
            }
          },
          {
            "name": "purchaseLastTime",
            "in": "query",
            "required": false,
            "description": "When the client last bought.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "When the client last bought."
            }
          },
          {
            "name": "lastSeen",
            "in": "query",
            "required": false,
            "description": "When the client was last active.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "When the client was last active."
            }
          },
          {
            "name": "createdAt",
            "in": "query",
            "required": false,
            "description": "When the client was created.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "When the client was created."
            }
          },
          {
            "name": "lastMailSend",
            "in": "query",
            "required": false,
            "description": "When a campaign mail was last sent to the client. `lastMailOpen`, `lastMailEngage` and `lastMailUnsubscribe` work the same way.",
            "schema": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "description": "When a campaign mail was last sent to the client. `lastMailOpen`, `lastMailEngage` and `lastMailUnsubscribe` work the same way."
            }
          },
          {
            "name": "scoreCO",
            "in": "query",
            "required": false,
            "description": "Comparison for `score`; every numeric and date field above takes its own `<field>CO`.",
            "schema": {
              "type": "string",
              "description": "Comparison for `score`; every numeric and date field above takes its own `<field>CO`.",
              "enum": [
                "gt",
                "gte",
                "lt",
                "lte"
              ]
            }
          },
          {
            "name": "details.city",
            "in": "query",
            "required": false,
            "description": "Client's city. `details.firstname` and `details.lastname` filter the same way, and unlike `query` they match the field alone.",
            "schema": {
              "type": "string",
              "description": "Client's city. `details.firstname` and `details.lastname` filter the same way, and unlike `query` they match the field alone."
            }
          },
          {
            "name": "custom_fields.NAME",
            "in": "query",
            "required": false,
            "description": "Value of a custom field the website defines, by its name.",
            "schema": {
              "type": "string",
              "description": "Value of a custom field the website defines, by its name."
            }
          },
          {
            "name": "consent.email",
            "in": "query",
            "required": false,
            "description": "Whether the client consented on that channel; `consent.phone`, `marketing.email` and `marketing.phone` behave the same.",
            "schema": {
              "type": "boolean",
              "description": "Whether the client consented on that channel; `consent.phone`, `marketing.email` and `marketing.phone` behave the same."
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Field to sort by.",
            "schema": {
              "type": "string",
              "description": "Field to sort by.",
              "default": "purchaseLastTime"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction.",
            "schema": {
              "type": "string",
              "description": "Sort direction.",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "Page number (zero-based)",
            "schema": {
              "type": "integer",
              "description": "Page number (zero-based)",
              "default": 0
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page",
            "schema": {
              "type": "integer",
              "description": "Results per page",
              "default": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer",
                      "description": "Total number of matching clients"
                    },
                    "clients": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Page of matching client objects"
                    }
                  },
                  "required": [
                    "count",
                    "clients"
                  ]
                }
              }
            }
          }
        },
        "operationId": "SearchWebsiteClients"
      }
    },
    "/api/v2/websites/{website_id}/standard_events": {
      "get": {
        "summary": "List standard events",
        "description": "List the predefined standard website events with their details.",
        "tags": [
          "WebsiteEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "events": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Predefined standard events with their details"
                    }
                  },
                  "required": [
                    "events"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListStandardWebsiteEvents"
      }
    },
    "/api/v2/websites/{website_id}/events": {
      "get": {
        "summary": "List website events",
        "description": "List custom website events for the website, paginated and searchable by title. When `standard_events` is set and the first page is requested, the predefined standard events are prepended.",
        "tags": [
          "WebsiteEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "Phrase to search event titles",
            "schema": {
              "type": "string",
              "description": "Phrase to search event titles"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "Page number",
            "schema": {
              "type": "integer",
              "description": "Page number",
              "default": 1
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page",
            "schema": {
              "type": "integer",
              "description": "Results per page"
            }
          },
          {
            "name": "standard_events",
            "in": "query",
            "required": false,
            "description": "Include predefined standard events on the first page",
            "schema": {
              "type": "boolean",
              "description": "Include predefined standard events on the first page"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "events": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Array of website events (`id`, `title`, `event`, `group_by`, `group_by_value`, `scoring`, `tags`, `created_at`, `triggers_attributes`)"
                    }
                  },
                  "required": [
                    "events"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListWebsiteEvents"
      }
    },
    "/api/v2/websites/{website_id}/products/{product_id}": {
      "get": {
        "summary": "Get product",
        "description": "Get a single product by ID from Elasticsearch. Returns 404 if the product does not exist.",
        "tags": [
          "WebsiteProducts"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "product_id",
            "in": "path",
            "required": true,
            "description": "Product ID",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "productId": {
                      "type": "string",
                      "description": "Product ID"
                    },
                    "productName": {
                      "type": "string",
                      "description": "Product name"
                    },
                    "productUrl": {
                      "type": "string",
                      "description": "Product URL"
                    },
                    "productPrice": {
                      "type": "number",
                      "description": "Product price"
                    },
                    "productCurrency": {
                      "type": "string",
                      "description": "Product currency"
                    },
                    "productImage": {
                      "type": "string",
                      "description": "Product image URL"
                    },
                    "productStock": {
                      "type": "number",
                      "description": "Product stock"
                    },
                    "productCategory": {
                      "type": "string",
                      "description": "Product category"
                    },
                    "productBrand": {
                      "type": "string",
                      "description": "Product brand"
                    },
                    "productPromoPrice": {
                      "type": "number",
                      "description": "Promotional price"
                    },
                    "productCustomFields": {
                      "type": "object",
                      "description": "Website-defined custom fields"
                    }
                  },
                  "required": [
                    "productId",
                    "productName",
                    "productUrl",
                    "productPrice",
                    "productCurrency",
                    "productImage"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Product does not exist"
          }
        },
        "operationId": "GetWebsiteProduct"
      }
    },
    "/api/v2/websites/{website_id}/products/{product_id}/price_history": {
      "get": {
        "summary": "Get product price history",
        "description": "Get the price history of a specific product from the worker service. Returns 404 if the product has no price history.",
        "tags": [
          "WebsiteProducts"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "product_id",
            "in": "path",
            "required": true,
            "description": "Product ID",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "history": {
                      "type": "object",
                      "description": "Price history for the product"
                    }
                  },
                  "required": [
                    "history"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Product has no price history"
          }
        },
        "operationId": "GetWebsiteProductPriceHistory"
      }
    },
    "/api/v2/websites/{website_id}/products/search": {
      "get": {
        "summary": "Search products",
        "description": "Full-text search over website products (Elasticsearch), paginated with aggregation buckets.",
        "tags": [
          "WebsiteProducts"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "Phrase to search for",
            "schema": {
              "type": "string",
              "description": "Phrase to search for"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "Page number",
            "schema": {
              "type": "integer",
              "description": "Page number"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page",
            "schema": {
              "type": "integer",
              "description": "Results per page"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer",
                      "description": "Total number of matching products"
                    },
                    "products": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Page of matching product objects"
                    }
                  },
                  "required": [
                    "count",
                    "products"
                  ]
                }
              }
            }
          }
        },
        "operationId": "SearchWebsiteProducts"
      }
    },
    "/api/v2/websites/{website_id}/{source_event}": {
      "post": {
        "summary": "Create source event",
        "description": "Create a campaign of the given type: `popups` and `topbars` show on the website, `mails` is an e-mail campaign. The campaign is created switched off - `enabled` defaults to `false`, so it reaches nobody until it is turned on, and a caller preparing a campaign for review should leave it that way. Access is the organization's packet, exactly as in the panel: a type whose module the packet does not carry is refused with 403, and so is a create past the packet's limit for that type. Returns 400 when validation fails - `name` and `template` are required, and a `mails` campaign is additionally rejected unless `template_params` carries `mailFooter`.",
        "tags": [
          "WebsiteSourceEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type",
            "schema": {
              "type": "string",
              "enum": [
                "popups",
                "topbars",
                "mails"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceEvent": {
                      "type": "object",
                      "description": "The created source event"
                    }
                  },
                  "required": [
                    "sourceEvent"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Validation failed"
          },
          "403": {
            "description": "Packet does not carry the module for this type, or its usage limit is exceeded"
          }
        },
        "operationId": "CreateWebsiteSourceEvent",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "website_source_event": {
                    "type": "object",
                    "description": "Source event attributes",
                    "properties": {
                      "name": {
                        "type": "string",
                        "description": "Name"
                      },
                      "locale": {
                        "type": "string",
                        "description": "Locale"
                      },
                      "enabled": {
                        "type": "boolean",
                        "description": "Whether the campaign is enabled. Left out, the campaign is created switched off.",
                        "default": false
                      },
                      "template": {
                        "type": "string",
                        "description": "Template URL - the `url` of an organization template (List/Get organization template) or a stock one. The template files (`manifest.json`, `index.html`) are read from there; any address is accepted, there is no allowlist, and a wrong one is not reported - the campaign just shows nothing."
                      },
                      "template_params": {
                        "type": "object",
                        "description": "Flat `key -> value` template parameters; which keys, nested data as a JSON string, `$sp.*` placeholders and the rest are under Get source event. Required for `mails`, which needs `mailFooter` here (the sender footer printed under the message); without that key the create fails with 400."
                      },
                      "schedule": {
                        "type": "object",
                        "description": "Schedule configuration"
                      },
                      "triggers_attributes": {
                        "type": "object",
                        "description": "Trigger definitions"
                      },
                      "options": {
                        "type": "object",
                        "description": "Campaign options.",
                        "properties": {
                          "metaData": {
                            "type": "object",
                            "description": "What the campaign needs from the rest of the platform. It is not descriptive: a recommendation engine only runs for campaigns whose `metaData.engine` names it, so a campaign that uses engine products and leaves this out renders their slots with nothing in them, and nothing reports an error. The drag and drop editors fill it in on save; a caller writing the template itself has to fill it in too.",
                            "properties": {
                              "engine": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Ids of the recommendation engines the template pulls products from, one per `engine` record in the project data."
                              },
                              "basket": {
                                "type": "array",
                                "items": {
                                  "type": "number"
                                },
                                "description": "Indexes of the abandoned basket products the template shows."
                              },
                              "purchase": {
                                "type": "array",
                                "items": {
                                  "type": "number"
                                },
                                "description": "Indexes of the purchased products the template shows."
                              },
                              "visit": {
                                "type": "array",
                                "items": {
                                  "type": "number"
                                },
                                "description": "Indexes of the visited products the template shows."
                              },
                              "clients": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Client fields the template prints, as `custom_fields.<field>` - one per `{{trustistoClient<Field>}}` text, and only for fields the website declares."
                              },
                              "coupon": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Coupon set ids used by the template, as strings - one per `{{coupon_<setId>}}` in the exported HTML."
                              }
                            }
                          }
                        }
                      }
                    },
                    "required": [
                      "name",
                      "template"
                    ]
                  }
                },
                "required": [
                  "website_source_event"
                ]
              }
            }
          }
        }
      },
      "get": {
        "summary": "List source events",
        "description": "List the campaigns of one type on a website: `popups` and `topbars` show on the site, `mails` is an e-mail campaign. Filterable by group, searchable by name and id, paginated. The `:source_event` path segment selects the type. Returns 403 for an unknown type or a website in another organization. For `mails` the list is not narrowed to `active` unless `group` is given, and `schedule_type` narrows it by schedule type.",
        "tags": [
          "WebsiteSourceEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type",
            "schema": {
              "type": "string",
              "enum": [
                "popups",
                "topbars",
                "mails"
              ]
            }
          },
          {
            "name": "group",
            "in": "query",
            "required": false,
            "description": "Group filter (e.g. active, archived)",
            "schema": {
              "type": "string",
              "description": "Group filter (e.g. active, archived)",
              "default": "active"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "Phrase to search over the name",
            "schema": {
              "type": "string",
              "description": "Phrase to search over the name"
            }
          },
          {
            "name": "id",
            "in": "query",
            "required": false,
            "description": "Filter by source event id",
            "schema": {
              "type": "integer",
              "description": "Filter by source event id"
            }
          },
          {
            "name": "schedule_type",
            "in": "query",
            "required": false,
            "description": "`mails` only - filter by schedule/target type",
            "schema": {
              "type": "string",
              "description": "`mails` only - filter by schedule/target type"
            }
          },
          {
            "name": "sort_by",
            "in": "query",
            "required": false,
            "description": "Sort specification (`column:direction`; `id`, `name`, `created_at`, `updated_at`, `enabled` or a `schedule.` path)",
            "schema": {
              "type": "string",
              "description": "Sort specification (`column:direction`; `id`, `name`, `created_at`, `updated_at`, `enabled` or a `schedule.` path)"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "Page number",
            "schema": {
              "type": "integer",
              "description": "Page number"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page",
            "schema": {
              "type": "integer",
              "description": "Results per page"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceEvents": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "description": "Campaign ID"
                          },
                          "name": {
                            "type": "string",
                            "description": "Name"
                          },
                          "enabled": {
                            "type": "boolean",
                            "description": "Whether the campaign is enabled"
                          },
                          "template": {
                            "type": "string",
                            "description": "Template URL - a directory, not a file. Append a name to read the package: `manifest.json` is the metadata, `index.html` the markup."
                          },
                          "template_params": {
                            "type": "object",
                            "description": "Template parameters currently set on the campaign"
                          },
                          "editor": {
                            "type": "string",
                            "description": "Where the campaign content lives; absent for a stock template, see Get source event",
                            "enum": [
                              "quick",
                              "topol",
                              "grapejs"
                            ]
                          },
                          "schedule": {
                            "type": "object",
                            "description": "Campaign schedule/status"
                          },
                          "options": {
                            "type": "object",
                            "description": "Campaign options"
                          },
                          "updated_at": {
                            "type": "integer",
                            "description": "Last update time (Unix seconds). Send it back when updating a mail campaign, see Update website mail campaign"
                          }
                        },
                        "required": [
                          "id",
                          "name",
                          "enabled",
                          "template",
                          "template_params",
                          "schedule",
                          "options",
                          "updated_at"
                        ]
                      },
                      "description": "Page of source event objects"
                    }
                  },
                  "required": [
                    "sourceEvents"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListWebsiteSourceEvents"
      }
    },
    "/api/v2/websites/{website_id}/{source_event}/{source_id}": {
      "get": {
        "summary": "Get source event",
        "description": "Get a single campaign by id. `source_id` `doi` returns the synthetic double opt-in campaign. 404 if it does not exist. Read this before editing. `editor` says where the content lives: `quick` - in `template_params`; edit those. `topol` or `grapejs` - in the editor project next to the template. `template_params` are ignored there (writing them changes nothing), and the project can be edited only in the panel. absent - a stock template (`template` under `/templates/`). Read `manifest.json` under `template`: `editable` with `version` above 3 is `grapejs`, `editable` on a mail with a lower version is `topol`, anything else `quick`. Editing a stock GrapesJS campaign in the panel makes it the organization's own copy, which then has `editor`. `template_params` is a flat `key -> value` object; the accepted keys, types and defaults are `params` in `manifest.json` ([WebCampaignManifest](#models/WebCampaignManifest) / [MailCampaignManifest](#models/MailCampaignManifest)). The API checks none of it: Values are strings, numbers or booleans. Popups and topbars get them as URL query parameters, so a nested object breaks rendering; send nested data as a JSON string (as the panel does for `product`, `productArray`, `messageArray`). Popups and topbars keep every key as sent, unknown ones included. A mail drops keys not in the manifest and keeps the values you set (`\"\"`, `0`, `false` too); the default applies only to a key left out. `$sp.*` in a value is filled per visitor or recipient: `$sp.client.<field>`, `$sp.coupon.<setId>[::fallback]`, `$sp.basket.*`, `$sp.visit.*`, `$sp.purchase.*`, `$sp.recommend.*`. A printed `{{variable}}` with no value renders as the literal `undefined`. A mail must keep `mailFooter`.",
        "tags": [
          "WebsiteSourceEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type",
            "schema": {
              "type": "string",
              "enum": [
                "popups",
                "topbars",
                "mails"
              ]
            }
          },
          {
            "name": "source_id",
            "in": "path",
            "required": true,
            "description": "Source event ID (or the literal `doi`)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceEvent": {
                      "type": "object",
                      "description": "The source event",
                      "properties": {
                        "id": {
                          "type": "integer",
                          "description": "Source event ID"
                        },
                        "name": {
                          "type": "string",
                          "description": "Name"
                        },
                        "enabled": {
                          "type": "boolean",
                          "description": "Whether the campaign is enabled"
                        },
                        "template": {
                          "type": "string",
                          "description": "Template URL. It addresses a directory, not a file: the template files are read from there - `manifest.json` (its `params` are the keys `template_params` accepts), `index.html`. Any address is accepted; there is no allowlist."
                        },
                        "template_params": {
                          "type": "object",
                          "description": "Template parameters currently set on the campaign, see the description"
                        },
                        "editor": {
                          "type": "string",
                          "description": "Where the campaign content lives, see the description. Absent for a stock template - read its manifest instead.",
                          "enum": [
                            "quick",
                            "topol",
                            "grapejs"
                          ]
                        }
                      },
                      "required": [
                        "id",
                        "name",
                        "enabled",
                        "template",
                        "template_params"
                      ]
                    }
                  },
                  "required": [
                    "sourceEvent"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Source event does not exist"
          }
        },
        "operationId": "GetWebsiteSourceEvent"
      },
      "put": {
        "summary": "Update source event",
        "description": "Update a popup or a topbar. Body accepts the same `website_source_event` object as Create; existing cascade options and schedule are preserved when not supplied. Returns 404 if the campaign does not exist and 400 when validation fails - `name` and `template` are required and must stay filled. A mail campaign is updated elsewhere, with a different body. Use Update website mail campaign: it takes its attributes under `website_mail`, not `website_source_event`, so sending this endpoint's body at a mail is answered with 400 and no hint as to why. It also requires `updated_at` and answers 409 when the campaign changed in the meantime, which is why the two cannot be one operation. This endpoint has no such check: it takes no `updated_at` and never answers 409, so the last write wins. What may be changed depends on the template: read `editor` from Get source event first - `quick` is edited through `template_params` (rules there), `topol` or `grapejs` ignores them, and an absent `editor` means a stock template whose manifest has to be read to tell. `template` may be changed to any address; nothing checks it.",
        "tags": [
          "WebsiteSourceEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type. `mails` is accepted by the path but never reaches this action, see above.",
            "schema": {
              "type": "string",
              "enum": [
                "popups",
                "topbars"
              ]
            }
          },
          {
            "name": "source_id",
            "in": "path",
            "required": true,
            "description": "Source event ID",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceEvent": {
                      "type": "object",
                      "description": "The updated source event"
                    }
                  },
                  "required": [
                    "sourceEvent"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Validation failed"
          },
          "404": {
            "description": "Source event does not exist"
          }
        },
        "operationId": "UpdateWebsiteSourceEvent",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "website_source_event": {
                    "type": "object",
                    "description": "Source event attributes (see Create)"
                  }
                },
                "required": [
                  "website_source_event"
                ]
              }
            }
          }
        }
      }
    },
    "/api/v2/websites/{website_id}/mails/{id}": {
      "put": {
        "summary": "Update website mail campaign",
        "description": "Update a mail campaign. Returns 404 if the campaign does not exist, 409 on an update conflict (stale `updated_at`) or when re-enabling an already sent manual campaign, and 400 when the plan mail limit would be exceeded or validation fails. This is the only way to edit a mail, and it is not interchangeable with the generic Update source event: attributes go under `website_mail` rather than `website_source_event`, and this endpoint requires `updated_at` and answers 409 without it, which the generic one neither takes nor returns. What may be changed depends on the template: read `editor` from Get source event first - `quick` is edited through `template_params` (rules there), `topol` or `grapejs` ignores them, and an absent `editor` means a stock template whose manifest has to be read to tell.",
        "tags": [
          "WebsiteSourceEvents"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Campaign ID",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "mail": {
                      "type": "object",
                      "description": "The updated campaign"
                    }
                  },
                  "required": [
                    "mail"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Mail limit exceeded or validation failed"
          },
          "403": {
            "description": "An OAuth token enabling an automated campaign that is already in a sending status"
          },
          "404": {
            "description": "Campaign does not exist"
          },
          "409": {
            "description": "Update conflict, or re-enabling an already sent manual campaign"
          }
        },
        "operationId": "UpdateWebsiteMail",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "website_mail": {
                    "type": "object",
                    "description": "Campaign attributes",
                    "properties": {
                      "updated_at": {
                        "type": "integer",
                        "description": "The value the campaign was read with, as Unix seconds, sent back so a concurrent edit is not silently overwritten: when it does not match what is stored the update is refused with 409. Omitting it reads as `0`, which never matches - so every update that touches anything else has to carry it. A body that changes only `enabled`, or only `schedule`, is exempt."
                      },
                      "name": {
                        "type": "string",
                        "description": "Campaign name"
                      },
                      "template": {
                        "type": "string",
                        "description": "Template URL. The template files are read from there; any address is accepted, nothing checks it."
                      },
                      "template_params": {
                        "type": "object",
                        "description": "Flat `key -> value` template parameters; the rules are under Get source event. Mail specifics: when the campaign is sent, keys not in the template's manifest `params` are dropped, and a value you set is kept as sent - an empty string, `0` and `false` included, so the manifest default applies only to a key you leave out; `$sp.*` is filled per recipient. Only a `quick` template renders from these. `mailFooter` has to stay present - the campaign is rejected without it."
                      },
                      "enabled": {
                        "type": "boolean",
                        "description": "Whether the campaign is enabled. For an OAuth (agent) token, enabling an automated campaign whose status already is `sending` is refused with 403 - that would put it live."
                      },
                      "locale": {
                        "type": "string",
                        "description": "Campaign locale"
                      },
                      "schedule": {
                        "type": "object",
                        "description": "Schedule settings. Merged into the stored schedule. `status` is taken only from the panel: for an OAuth (agent) token it is dropped and the stored status stays, because a `sending` status is what starts an automated campaign (cart or visit recovery, form, event) - every update pushes the campaigns in that status to the worker. Sending a campaign is done from the panel, not through this endpoint."
                      },
                      "options": {
                        "type": "object",
                        "description": "Campaign options (from, subject, effect, consents...)",
                        "properties": {
                          "metaData": {
                            "type": "object",
                            "description": "What the campaign needs from the rest of the platform, with the same meaning and the same keys as on a source event - see Create source event. Two of them decide whether anything happens at all: a recommendation engine only runs for campaigns whose `metaData.engine` names it, and a coupon set is only considered used by campaigns whose `metaData.coupon` lists it. A mail that prints engine products or coupons and leaves these out sends with empty slots, silently."
                          }
                        }
                      },
                      "tracking": {
                        "type": "object",
                        "description": "Tracking settings (trackers, utm)"
                      }
                    }
                  }
                },
                "required": [
                  "website_mail"
                ]
              }
            }
          }
        }
      }
    },
    "/api/v2/websites/{website_id}/stats/conversion/{conversion_type}": {
      "get": {
        "summary": "Get conversion stats",
        "description": "Get conversion/effect stats (count and sum) for a specific conversion type. Use `effect` instead of `conversion` in the path for the effect family. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "conversion_type",
            "in": "path",
            "required": true,
            "description": "Conversion type within the family",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day, all)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day, all)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Stats series with `<key>.count` and `<key>.sum` per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteConversion"
      }
    },
    "/api/v2/websites/{website_id}/stats/conversion/{conversion_type}/rate": {
      "get": {
        "summary": "Get conversion rate stats",
        "description": "Get the conversion rate (a base series vs a conversion series) for a specific conversion type. Use `effect` instead of `conversion` in the path for the effect family. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "conversion_type",
            "in": "path",
            "required": true,
            "description": "Conversion type within the family",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day, all)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day, all)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          },
          {
            "name": "x",
            "in": "query",
            "required": false,
            "description": "Base rate field name",
            "schema": {
              "type": "string",
              "description": "Base rate field name",
              "default": ".uu"
            }
          },
          {
            "name": "y",
            "in": "query",
            "required": false,
            "description": "Conversion rate field name",
            "schema": {
              "type": "string",
              "description": "Conversion rate field name",
              "default": "count"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "rate": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Rate series, one entry per period"
                    }
                  },
                  "required": [
                    "rate"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteConversionRate"
      }
    },
    "/api/v2/websites/{website_id}/stats/basket/recovery": {
      "get": {
        "summary": "Get awaiting basket recovery",
        "description": "Get the list of baskets awaiting recovery with their value and time to recovery, optionally scoped to a recovery campaign.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_id",
            "in": "query",
            "required": false,
            "description": "WebsiteMail recovery campaign ID",
            "schema": {
              "type": "integer",
              "description": "WebsiteMail recovery campaign ID"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "baskets": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Baskets awaiting recovery"
                    }
                  },
                  "required": [
                    "baskets"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsBasketRecovery"
      }
    },
    "/api/v2/websites/{website_id}/stats/events/{event}": {
      "get": {
        "summary": "Get event stats",
        "description": "Get stats for a specific event on the website. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "event",
            "in": "path",
            "required": true,
            "description": "Event name",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Event metric (e.g. emit)",
            "schema": {
              "type": "string",
              "description": "Event metric (e.g. emit)",
              "default": "emit"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Event stats series per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsEvent"
      }
    },
    "/api/v2/websites/{website_id}/stats/events/{event}/product/{product_id}": {
      "get": {
        "summary": "Get product event stats",
        "description": "Get stats for a specific event on a specific product. When the optional `/live` path is used it returns only the last 24h stats; otherwise resolution defaults to `day`.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "event",
            "in": "path",
            "required": true,
            "description": "Event name",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "product_id",
            "in": "path",
            "required": true,
            "description": "Product ID",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "day"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Product event stats series per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsProductEvents"
      }
    },
    "/api/v2/websites/{website_id}/stats/products/last/{event}/live": {
      "get": {
        "summary": "Get products with newest event",
        "description": "Get the list of products with the newest occurrence of `:event`. Only available on the `/live` path (last 24h stats).",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "event",
            "in": "path",
            "required": true,
            "description": "Event name",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "hour"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back"
            }
          },
          {
            "name": "max",
            "in": "query",
            "required": false,
            "description": "Maximum number of products to return (max 100)",
            "schema": {
              "type": "integer",
              "description": "Maximum number of products to return (max 100)",
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Products with their newest event timestamp"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsProductsLast"
      }
    },
    "/api/v2/websites/{website_id}/stats/products/popular/{event}": {
      "get": {
        "summary": "Get most popular products",
        "description": "Get the list of products with the highest number of `:event`s. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "event",
            "in": "path",
            "required": true,
            "description": "Event name",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          },
          {
            "name": "max",
            "in": "query",
            "required": false,
            "description": "Maximum number of products to return (max 100)",
            "schema": {
              "type": "integer",
              "description": "Maximum number of products to return (max 100)",
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Products ranked by event count"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsProductsPopular"
      }
    },
    "/api/v2/websites/{website_id}/stats/query": {
      "get": {
        "summary": "Query arbitrary stats",
        "description": "Query arbitrary stats keys for the website via the worker service. A key is written `<group>.<key>` - the group says which set of stats to read it from, and one call may mix groups. The groups are `stat` (events and views), `website` (account-wide counts such as `website.clients` and `website.segments`) and `products`. A key sent without a group is not recognised and is quietly read from `stat` instead. Defaults to `website.clients` and `website.segments` when no `query` is given.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "query",
            "in": "query",
            "required": false,
            "description": "Stats keys to fetch, each written `<group>.<key>` (defaults to `website.clients`, `website.segments`)",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Stats keys to fetch, each written `<group>.<key>` (defaults to `website.clients`, `website.segments`)"
            }
          },
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Event name",
            "schema": {
              "type": "string",
              "description": "Event name"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day, all). `hour` covers at most 31 days, `day` and `all` at most 10 years",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day, all). `hour` covers at most 31 days, `day` and `all` at most 10 years",
              "default": "hour"
            }
          },
          {
            "name": "max",
            "in": "query",
            "required": false,
            "description": "Maximum number of results",
            "schema": {
              "type": "integer",
              "description": "Maximum number of results"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "Stats keyed by the requested query keys"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Unsupported resolution or a date range over its limit; `error` says which"
          },
          "429": {
            "description": "Too many stats requests for the organization; wait `Retry-After` seconds. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`"
          }
        },
        "operationId": "GetWebsiteStatsQuery"
      }
    },
    "/api/v2/websites/{website_id}/stats/{source_event}/{source_id}/{event}": {
      "get": {
        "summary": "Get source event stats",
        "description": "Get the number of `:event`s for a specific source event (e.g. notification, popup) instance. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type (e.g. notify, popups)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "source_id",
            "in": "path",
            "required": true,
            "description": "Source event instance ID",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "event",
            "in": "path",
            "required": true,
            "description": "Event name (e.g. show, engage, purchase)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Event stats series per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsSourceEvent"
      }
    },
    "/api/v2/websites/{website_id}/stats/{source_event}/{source_id}/rate": {
      "get": {
        "summary": "Get source event rate",
        "description": "Get the base and conversion series for a source event instance plus the computed rate. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "source_event",
            "in": "path",
            "required": true,
            "description": "Source event type (e.g. notify, popups)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "source_id",
            "in": "path",
            "required": true,
            "description": "Source event instance ID",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x",
            "in": "query",
            "required": false,
            "description": "Base rate field name",
            "schema": {
              "type": "string",
              "description": "Base rate field name",
              "default": "show"
            }
          },
          {
            "name": "y",
            "in": "query",
            "required": false,
            "description": "Conversion rate field name",
            "schema": {
              "type": "string",
              "description": "Conversion rate field name",
              "default": "engage"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "show": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Base series data"
                    },
                    "engage": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Conversion series data"
                    },
                    "rate": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Computed rate series"
                    }
                  },
                  "required": [
                    "show",
                    "engage",
                    "rate"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsSourceEventRate"
      }
    },
    "/api/v2/websites/{website_id}/stats/uu": {
      "get": {
        "summary": "Get unique users stats",
        "description": "Get unique users stats for the website. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day, all)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day, all)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Unique users series per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsUu"
      }
    },
    "/api/v2/websites/{website_id}/stats/views": {
      "get": {
        "summary": "Get views stats",
        "description": "Get page view stats for the website. When the optional `/live` path is used it returns only the last 24h stats.",
        "tags": [
          "WebsiteStats"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "resolution",
            "in": "query",
            "required": false,
            "description": "Aggregation resolution (hour, day, all)",
            "schema": {
              "type": "string",
              "description": "Aggregation resolution (hour, day, all)",
              "default": "hour"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "From timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "From timestamp (Unix seconds)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "To timestamp (Unix seconds)",
            "schema": {
              "type": "integer",
              "description": "To timestamp (Unix seconds)"
            }
          },
          {
            "name": "last",
            "in": "query",
            "required": false,
            "description": "Number of seconds to look back (with `/live`)",
            "schema": {
              "type": "integer",
              "description": "Number of seconds to look back (with `/live`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      },
                      "description": "Views series per period"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          }
        },
        "operationId": "GetWebsiteStatsViews"
      }
    },
    "/api/v2/websites/{website_id}/feeds": {
      "get": {
        "summary": "List product sources",
        "description": "The product sources this website can actually put in a campaign - what the editors build their \"where does this product come from\" dropdown from, and what to read before writing a template by hand. Only sources the website really has are returned: the product feed once a feed has been uploaded and imported at least once, `baskets`/`visits`/`purchases` only with the basket module, and an engine only when the website has one that produces products. A source missing from this list cannot be resolved, so a template referencing it renders empty. `id` is what a campaign template stores in the component's `dynamic-product.type.id`; the `WebCampaignTemplate` / `MailCampaignTemplate` schemas map each one to the `type` and `engineId` of the matching `dataSources.products` record.",
        "tags": [
          "Websites"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page",
            "schema": {
              "type": "integer",
              "description": "Results per page"
            }
          },
          {
            "name": "current_page",
            "in": "query",
            "required": false,
            "description": "Current page number",
            "schema": {
              "type": "integer",
              "description": "Current page number"
            }
          },
          {
            "name": "campaign_type",
            "in": "query",
            "required": false,
            "description": "Type of the template being edited. `price_drops` is a mail only source: the quick popup and topbar templates have no way to show it.",
            "schema": {
              "type": "string",
              "description": "Type of the template being edited. `price_drops` is a mail only source: the quick popup and topbar templates have no way to show it.",
              "enum": [
                "mail",
                "popup",
                "topbar"
              ],
              "default": "mail"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "description": "Always true"
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Source identifier - the `dynamic-product.type.id` of the template component (`products`, `products_id`, `baskets`, `visits`, `purchases`, `price_drops`, `engine_<engineId>`)"
                          },
                          "name": {
                            "type": "string",
                            "description": "Human readable name, in the caller's locale"
                          }
                        },
                        "required": [
                          "id",
                          "name"
                        ]
                      },
                      "description": "Available product sources"
                    },
                    "total_records": {
                      "type": "integer",
                      "description": "Total number of sources"
                    },
                    "per_page": {
                      "type": "integer",
                      "description": "Page size"
                    },
                    "current_page": {
                      "type": "integer",
                      "description": "Current page number"
                    },
                    "last_page": {
                      "type": "integer",
                      "description": "Last page number"
                    }
                  },
                  "required": [
                    "success",
                    "data",
                    "total_records",
                    "per_page",
                    "current_page",
                    "last_page"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListWebsiteProductSources"
      }
    },
    "/api/v2/websites/{website_id}/products": {
      "get": {
        "summary": "List product source products",
        "description": "List the products of one source for the campaign editors (paginated). The source is chosen with `feed`, whose value comes from `GET /api/v2/websites/:website_id/feeds` - only the sources listed there return anything. `products` and `products_id` answer with real products from the website's catalogue. `baskets`, `visits`, `purchases` and `engine_<id>` are resolved per recipient when the campaign is built, so they answer with placeholders: as many mock products as `per_page` asks for (10 by default), each with the id a dynamic product in a template refers to - `basket_<n>`, `visit_<n>`, `purchase_<n>` or `engine_<engineId>_<n>`. With `product_format=grape` the fields are the ones a `dynamic-product` record carries in a campaign template (`productId`, `productName`, `productImage`, `productPrice`, `productPromoPrice`, ...); the default `topol` format names them `id`, `name`, `img_url`, `price_before`, `price_with_vat`, ... and fills a placeholder with the `{{basket_1_name}}`-style merge tag itself.",
        "tags": [
          "Websites"
        ],
        "parameters": [
          {
            "name": "website_id",
            "in": "path",
            "required": true,
            "description": "Website ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "feed",
            "in": "query",
            "required": true,
            "description": "Source identifier, as returned by `/feeds` (`products`, `products_id`, `baskets`, `visits`, `purchases` or `engine_<id>`): `products` and `products_id` are the uploaded product feed (searched by phrase and by id), `baskets`, `visits` and `purchases` are the recipient's own products, and `engine_<id>` is a recommendation engine. Without it the list is empty.",
            "schema": {
              "type": "string",
              "description": "Source identifier, as returned by `/feeds` (`products`, `products_id`, `baskets`, `visits`, `purchases` or `engine_<id>`): `products` and `products_id` are the uploaded product feed (searched by phrase and by id), `baskets`, `visits` and `purchases` are the recipient's own products, and `engine_<id>` is a recommendation engine. Without it the list is empty."
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "`products`: phrase searched over the product id, name, brand and category. `products_id`: the product id - sent as `search[]=<id>` it matches the id exactly (several may be given), sent as `search=<id>` it is a loose match. Placeholder sources ignore it.",
            "schema": {
              "type": "string",
              "description": "`products`: phrase searched over the product id, name, brand and category. `products_id`: the product id - sent as `search[]=<id>` it matches the id exactly (several may be given), sent as `search=<id>` it is a loose match. Placeholder sources ignore it."
            }
          },
          {
            "name": "product_format",
            "in": "query",
            "required": false,
            "description": "Shape of each product: `grape` is what the drag and drop editor and campaign templates use",
            "schema": {
              "type": "string",
              "description": "Shape of each product: `grape` is what the drag and drop editor and campaign templates use",
              "enum": [
                "topol",
                "grape"
              ],
              "default": "topol"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page (for placeholder sources: how many placeholders to generate)",
            "schema": {
              "type": "integer",
              "description": "Results per page (for placeholder sources: how many placeholders to generate)"
            }
          },
          {
            "name": "current_page",
            "in": "query",
            "required": false,
            "description": "Current page number",
            "schema": {
              "type": "integer",
              "description": "Current page number"
            }
          },
          {
            "name": "id",
            "in": "query",
            "required": false,
            "description": "Placeholder sources only - return just the placeholder with this id (e.g. `basket_1`)",
            "schema": {
              "type": "string",
              "description": "Placeholder sources only - return just the placeholder with this id (e.g. `basket_1`)"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "description": "Always true"
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "productId": {
                            "type": "string",
                            "description": "Product id (`grape` format) - the value a template's `dynamic-product` refers to"
                          },
                          "productName": {
                            "type": "string",
                            "description": "Product name (`grape` format)"
                          },
                          "productUrl": {
                            "type": "string",
                            "description": "Product page URL (`grape` format)"
                          },
                          "productImage": {
                            "type": "string",
                            "description": "Product image URL (`grape` format)"
                          },
                          "productPrice": {
                            "type": "string",
                            "description": "Regular price (`grape` format)"
                          },
                          "productPromoPrice": {
                            "type": "string",
                            "description": "Promotional price, `null` when the product is not on promotion (`grape` format)"
                          },
                          "productCurrency": {
                            "type": "string",
                            "description": "Currency code (`grape` format)"
                          },
                          "productLowestPrice30Days": {
                            "type": "string",
                            "description": "Lowest price in the last 30 days (`grape` format)"
                          },
                          "product_feed_id": {
                            "type": "string",
                            "description": "Source the product belongs to, as `dynamic-product.type` names it"
                          }
                        },
                        "required": [
                          "productId",
                          "productName",
                          "productUrl",
                          "productImage",
                          "productPrice",
                          "productPromoPrice",
                          "productCurrency",
                          "productLowestPrice30Days",
                          "product_feed_id"
                        ]
                      },
                      "description": "Products of the requested source, in the `product_format` shape"
                    },
                    "total_records": {
                      "type": "integer",
                      "description": "Total number of products"
                    },
                    "per_page": {
                      "type": "integer",
                      "description": "Page size"
                    },
                    "current_page": {
                      "type": "integer",
                      "description": "Current page number"
                    },
                    "last_page": {
                      "type": "integer",
                      "description": "Last page number"
                    }
                  },
                  "required": [
                    "success",
                    "data",
                    "total_records",
                    "per_page",
                    "current_page",
                    "last_page"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListWebsiteTopolProducts"
      }
    },
    "/api/v2/websites": {
      "get": {
        "summary": "List websites",
        "description": "List the websites of the current user's organization. One organization can own several websites, and every other endpoint is scoped to one of them, so start here to get the `website_id`.",
        "tags": [
          "Websites"
        ],
        "responses": {
          "200": {
            "description": "Success 200",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "websites": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer",
                            "description": "Website ID, used as `website_id` everywhere else"
                          },
                          "organization_id": {
                            "type": "integer",
                            "description": "Organization ID, used as `organization_id` by the media endpoints"
                          },
                          "name": {
                            "type": "string",
                            "description": "Website name"
                          },
                          "url": {
                            "type": "string",
                            "description": "Website URL"
                          },
                          "code": {
                            "type": "string",
                            "description": "Website code"
                          },
                          "status": {
                            "type": "integer",
                            "description": "Website status (numeric enum value)"
                          },
                          "status_label": {
                            "type": "string",
                            "description": "Website status as text"
                          },
                          "locale": {
                            "type": "string",
                            "description": "Website locale (ISO 639-1)"
                          },
                          "options": {
                            "type": "object",
                            "description": "Website settings the campaign editors read",
                            "properties": {
                              "custom_fields": {
                                "type": "object",
                                "properties": {
                                  "clients": {
                                    "type": "array",
                                    "items": {
                                      "type": "object",
                                      "properties": {
                                        "name": {
                                          "type": "string",
                                          "description": "Field name, and what a template binds an input to as `custom_fields.<name>`. It carries a `__<type>` suffix, so it is not the label"
                                        },
                                        "label": {
                                          "type": "string",
                                          "description": "The name as a person sees it, without the suffix"
                                        },
                                        "type": {
                                          "type": "string",
                                          "description": "Field type. An input bound to the field takes the matching HTML input type",
                                          "enum": [
                                            "text",
                                            "number",
                                            "date"
                                          ]
                                        }
                                      },
                                      "required": [
                                        "name",
                                        "label",
                                        "type"
                                      ]
                                    },
                                    "description": "The client fields this website defines on top of the fixed ones (`email`, `phone`, `details.firstname`, `details.lastname`, `details.city`, `details.country`). A form input can only be bound to a field listed here or one of those, so this is what to read before writing a form into a campaign template"
                                  }
                                },
                                "required": [
                                  "clients"
                                ]
                              }
                            }
                          }
                        },
                        "required": [
                          "id",
                          "organization_id",
                          "name",
                          "url",
                          "code",
                          "status",
                          "status_label",
                          "locale",
                          "options"
                        ]
                      },
                      "description": "List of websites (the fields below are the ones needed to pick one; the full object carries the website settings as well)"
                    }
                  },
                  "required": [
                    "websites"
                  ]
                }
              }
            }
          }
        },
        "operationId": "ListWebsites"
      }
    }
  },
  "components": {
    "securitySchemes": {
      "UserToken": {
        "type": "apiKey",
        "in": "header",
        "name": "X-User-Token"
      },
      "UserEmail": {
        "type": "apiKey",
        "in": "header",
        "name": "X-User-Email"
      }
    },
    "schemas": {
      "WebCampaignTemplate": {
        "type": "object",
        "description": "The editor project of a **web** campaign template - Trust Overlay (`popup`) and Trust Bar (`topbar`).\n\nThis is what the drag and drop editor loads and saves, stored as `input.mjml.json` and sent as the `json` field of `POST/PUT /api/v2/organizations/{organization_id}/templates`. It is the source of truth for the campaign: the `index.html` served to visitors is generated from it on every save, and is never authored by hand.\n\nThe `manifest` sent alongside it is described by [WebCampaignManifest](#models/WebCampaignManifest).\n\nPopups and topbars share this format completely; a topbar simply has no `width` (it always spans the page). Mail is a different shape - see [MailCampaignTemplate](#models/MailCampaignTemplate).\n\n## Creating one outside the editor\n\n`json` is only the source. A template is published from three parts sent together - `json` (this document), `manifest` and `html` - and nothing is stored until all three are there: a template created with `json` alone stays unpublished and gets no `url`. In the panel the editor's save routine derives the other two from the project on every save; calling the API directly means doing the same work.\n\n**`html` - the project as one standalone page**\n\n`<!DOCTYPE html><html><head>` with `<meta charset=\"UTF-8\">`, a `<title>`, one `<link>` per Google font used in the stylesheet and a single `<style>` holding the whole project stylesheet, then the `wrapper` rendered as the `<body>`, then the campaign runtime script tag. Copy that tag verbatim from a template you already have (`GET /api/v2/organizations/{organization_id}/templates/{id}` returns its `url`, and `index.html` sits under it) - its host depends on the environment, and without it the form, the countdown and the CTA buttons do nothing.\n\nOn top of that the web export does three things a hand written page would miss:\n\n- The stylesheet is prefixed with `html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }`, so the campaign fills exactly the size its manifest declares.\n- `pattern` attributes are injected into the inputs that need validation: `type=\"email\"`, `name=\"phone\"` and `name=\"phonePrefix\"`.\n- A campaign with dynamic products also gets the loading screen: the body content is wrapped in `<div class=\"sp-container\" style=\"display:none;\" ut:style=\"display:flex;\">`, followed by `<div class=\"sp-loading\" style=\"display:flex;\" ut:style=\"display:none;\">` holding a spinner, plus the CSS for both. The two `ut:style` attributes are swapped once the visitor's product data has been resolved - that is what keeps the raw `{{...}}` merge tags off the screen in the meantime.\n\n**`manifest`** is regenerated from the project as well - which fields, and from what, is described by [WebCampaignManifest](#models/WebCampaignManifest).\n\n**How to use this document.** It is a description of the format - a dictionary to read, not a validator to run as it stands. The component tree is a `oneOf` over every variant, which is expensive to evaluate directly: validating one real template against it takes seconds. Anything that needs to validate should first rewrite the union into a dispatch on `type`.",
        "x-defs": {
          "style": {
            "description": "CSS declarations, keyed by property name as written in a stylesheet (`background-color`, not `backgroundColor`).\n\nThe editor's Style Manager offers: dimension (`width`, `height`, `max-width`, `min-height`, `margin*`, `padding*`), typography (`font-family`, `font-size`, `font-weight`, `letter-spacing`, `color`, `line-height`, `text-align`, `text-decoration`, `font-style`, `vertical-align`, `text-shadow*`), decorations (`background-color`, `background*`, `border*`, `border-radius*`) and flexbox (`display`, `flex-direction`, `justify-content`, `align-items`, `flex`, `gap`). Any other valid declaration is accepted as well.\n\nThis is where a component's styling belongs. The editor writes it out as that component's own `#id` rule, and it replaces whatever default the component's type carries - whole, so repeat every property you want to keep. Styling a component from `styles` instead does not survive: see the note there.",
            "anyOf": [
              {
                "type": "object",
                "additionalProperties": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "number",
                      "description": "Unitless value, for the properties that take one (`opacity`, `line-height`, `z-index`, `font-weight`, `order`, `flex-grow`, …). Everything else - lengths, functions like `calc()`, colours, keywords - is written as a string."
                    }
                  ]
                },
                "example": {
                  "display": "flex",
                  "flex-direction": "column",
                  "gap": "10px",
                  "padding": "10px"
                }
              },
              {
                "type": "string",
                "maxLength": 0,
                "description": "The editor writes an empty string here when the component carries no styling of its own, so a template holds `\"\"` far more often than an object."
              }
            ]
          },
          "dataVariable": {
            "type": "object",
            "description": "A value taken from a data source instead of typed in. Shown resolved on the canvas, exported as a `{{param.field}}` merge tag filled with the visitor's own data when the campaign is shown.",
            "required": [
              "type",
              "path"
            ],
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "data-variable"
                ]
              },
              "path": {
                "type": "string",
                "description": "`<dataSourceId>.<recordId>.<field>`.",
                "example": "products.i8xk.product.productName"
              },
              "defaultValue": {
                "type": "string",
                "description": "Used when the path resolves to nothing."
              }
            }
          },
          "product": {
            "type": "object",
            "description": "Product data of one dynamic product. Real values while a fixed product from the feed is picked, mock values (`$sp.<type>.<index>` ids) while the product is dynamic - those become `{{product_<...>.field}}` merge tags on save.",
            "properties": {
              "productId": {
                "type": "string",
                "description": "Which form this id takes depends on where the product sits. In a `dynamic-product` attribute it is the id the products endpoint returned - the feed's own id for a static product, `basket_<index>` / `visit_<index>` / `purchase_<index>` / `engine_<engineId>_<index>` for a dynamic one. In a `dataSources` record it names where the block is bound instead: `$sp.basket.<index>`, `$sp.visit.<index>`, `$sp.purchase.<index>`, `$sp.recommend.<engineId>.<index>` - or `$sp.null.<component id>` while the block is bound to nothing.",
                "example": "$sp.basket.1"
              },
              "productName": {
                "type": "string"
              },
              "productUrl": {
                "type": "string"
              },
              "productImage": {
                "type": "string"
              },
              "productPrice": {
                "type": "string"
              },
              "productPromoPrice": {
                "type": "string"
              },
              "productCurrency": {
                "type": "string",
                "example": "PLN"
              },
              "productPriceCurrency": {
                "type": "string",
                "description": "`productPrice` + `productCurrency`, precomputed for display.",
                "example": "10.00 PLN"
              },
              "productPromoPriceCurrency": {
                "type": "string",
                "description": "`productPromoPrice` + `productCurrency`. Empty when there is no promo price."
              },
              "productCategory": {
                "type": "string"
              },
              "productBrand": {
                "type": "string"
              },
              "updatedAt": {
                "type": "string"
              }
            },
            "additionalProperties": true
          },
          "component": {
            "title": "Component",
            "oneOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentWrapper"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentGeneric"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentText"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentImage"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentLink"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentMergeTag"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentCtaButton"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentForm"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentFormConsent"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentFormInput"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentFormCheckbox"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentFormButton"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentFormMessage"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentCountdown"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentProductAdd"
              },
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentProductImage"
              }
            ]
          },
          "componentBase": {
            "type": "object",
            "title": "componentBase",
            "properties": {
              "tagName": {
                "type": "string",
                "description": "HTML tag rendered for the component.",
                "example": "div"
              },
              "name": {
                "type": "string",
                "description": "Label shown in the Layer Manager and on the component's badge."
              },
              "classes": {
                "type": "array",
                "description": "CSS classes. They are runtime hooks, never a styling tool - the editor styles each component through its own `#id` rule. Used classes: `sp-form`, `sp-form-input`, `sp-form-submit`, `sp-form-consent`, `form-success`, `form-error`, `countdown`, `tile`, `smalltext`, `days`, `hours`, `minutes`, `seconds`, `sp-container`, `sp-loading`.",
                "items": {
                  "type": "string"
                }
              },
              "style": {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/style"
              },
              "components": {
                "type": "array",
                "description": "Children.\n\nOne node of the campaign. `type` decides what the editor and the script showing the campaign make of it, `components` holds its children - the same structure again.\n\nEvery node carries the same shared properties, whatever its type is: how it is rendered (`tagName`, `classes`, `style`), its children, and what the editor lets the user do with it (`draggable`, `editable`, ...). Each variant below adds on top of those only what its own type uses - in its own properties and in its `attributes`.\n\n**Layout and content** - `wrapper` (the page root), `default` (a plain `div`: the Section and Column blocks, the divider), `text`, `textnode`, `image`, `link`, `comment`, `cta-button`, `spacer`.\n\n**Merge tag** - `merge-tag`, the `{{trustistoClient<Field>}}` chip inserted from the rich text toolbar.\n\n**Form**, submitted when the visitor sends it - `form`, `form-consent` (hidden single/double opt-in field), `form-input`, `form-checkbox` (the fixed `marketing` consent box), `form-checkbox-label`, `form-button`, `form-message` (the `.form-success` / `.form-error` texts).\n\n**Countdown**, ticked while the campaign is on screen - `countdown`, `countdown-tile`, `countdown-digit`, `countdown-label`.\n\n**Dynamic product** - `product-add` (owns one `dataSources` record and builds its own subtree), `product-image`.\n\n**Picking the variant.** The `type` field decides which variant below applies. A component **without** `type` is valid and common: that is how the editor stores a plain container, and it means the generic variant. Do not treat a missing `type` as an error.",
                "items": {
                  "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/component"
                }
              },
              "traits": {
                "type": "array",
                "description": "The property controls shown in the Settings panel for this component (the CTA, form, countdown and dynamic product panels).",
                "items": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": "string",
                      "example": "cta-properties"
                    },
                    "name": {
                      "type": "string"
                    },
                    "label": {
                      "type": "string"
                    },
                    "category": {
                      "type": "string"
                    }
                  },
                  "additionalProperties": true
                }
              },
              "void": {
                "type": "boolean",
                "description": "Self closing element (`input`)."
              },
              "draggable": {
                "description": "Whether - and into what - it can be dragged. Form parts use `form, form *` so they cannot leave their form.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "string"
                  }
                ]
              },
              "droppable": {
                "description": "Whether - and what - can be dropped inside.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "string"
                  }
                ]
              },
              "removable": {
                "type": "boolean"
              },
              "copyable": {
                "type": "boolean"
              },
              "editable": {
                "type": "boolean",
                "description": "Text can be edited inline. Off for content bound to a data variable."
              },
              "selectable": {
                "type": "boolean"
              },
              "hoverable": {
                "type": "boolean"
              },
              "highlightable": {
                "type": "boolean"
              },
              "layerable": {
                "type": "boolean",
                "description": "Shown in the Layer Manager."
              },
              "stylable": {
                "description": "Whether the Style Manager may style it, or which properties it may set.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                ]
              }
            },
            "additionalProperties": true
          },
          "componentAttributes": {
            "type": "object",
            "title": "componentAttributes",
            "description": "The HTML attributes every component may carry. Each type adds its own on top - those are what the campaign is driven by at runtime.",
            "properties": {
              "id": {
                "type": "string",
                "description": "Also the selector every style edit of this component is written to."
              },
              "title": {
                "type": "string"
              }
            },
            "additionalProperties": true
          },
          "componentWrapper": {
            "title": "wrapper",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "Root of the tree, the `<body>` of the exported page. Exactly one per template - the frame's own component.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "wrapper"
                    ]
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentGeneric": {
            "title": "default, comment, spacer, form-checkbox-label, countdown-tile, countdown-digit, countdown-label",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The types that add nothing to the shared properties: `default` (a plain `div` - the Section and Column blocks, the divider), `comment`, `spacer`, `form-checkbox-label` and the countdown parts `countdown-tile`, `countdown-digit`, `countdown-label`, which the countdown itself drives.",
                "properties": {
                  "type": {
                    "type": "string",
                    "description": "Left out for a plain `div` - a missing `type` is read as `default`.",
                    "enum": [
                      "default",
                      "comment",
                      "spacer",
                      "form-checkbox-label",
                      "countdown-tile",
                      "countdown-digit",
                      "countdown-label"
                    ],
                    "default": "default"
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentText": {
            "title": "text, textnode",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "A text box (`text`) and the raw text inside it (`textnode`).",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "text",
                      "textnode"
                    ]
                  },
                  "content": {
                    "description": "The text: typed in, or bound to a product field.",
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/dataVariable"
                      }
                    ]
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentImage": {
            "title": "image",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "An image picked through the panel media modal, so it is an Asset Manager entry as well.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "image"
                    ]
                  },
                  "src": {
                    "type": "string",
                    "description": "URL of the picked media."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "src": {
                            "type": "string"
                          },
                          "alt": {
                            "type": "string"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentLink": {
            "title": "link",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "An `<a>`. A link that only opens an URL; anything else a click can do is a `cta-button`.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "link"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "href": {
                            "type": "string"
                          },
                          "target": {
                            "type": "string",
                            "example": "_blank"
                          },
                          "data-cta-inline": {
                            "description": "Link inside a dynamic product. It is rewritten into the stats endpoint so the click is counted before the visitor is forwarded - a plain `href` would skip the stat.",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentMergeTag": {
            "title": "merge-tag",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The `{{trustistoClient<Field>}}` chip inserted from the rich text toolbar. Not editable inline: its content is written from `data-merge-tag`, and the canvas shows the field under the name the user knows it by (`{{Imię}}`) while the placeholder is what the exported HTML carries.\n\n## What fills it\n\nThe field becomes a `$sp.client.<field>` param on save (see [WebCampaignManifest](#models/WebCampaignManifest)), which the script showing the campaign resolves from the visitor's own client record - a visitor it knows nothing about gets an empty value. The field is named as the client record stores it - the fixed `firstname`, `lastname`, `city` and `country`, or one of the website's own client fields, whose name carries its type (`nip__text`).\n\nThe mail only placeholders (the footer, the unsubscribe and view in browser links) have no meaning here and the editor does not offer them.\n\n## The chip as the editor inserts it\n\n```json\n{\n  \"type\": \"merge-tag\",\n  \"tagName\": \"span\",\n  \"attributes\": { \"data-merge-tag\": \"trustistoClientFirstname\" },\n  \"components\": [\n    { \"type\": \"textnode\", \"content\": \"{{trustistoClientFirstname}}\" }\n  ]\n}\n```\n\nThe placeholder is rewritten from the attribute whenever the template is opened, so the two cannot drift apart - a chip sent with no children is filled in by the editor.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "merge-tag"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "required": [
                          "data-merge-tag"
                        ],
                        "properties": {
                          "data-merge-tag": {
                            "type": "string",
                            "description": "The field the placeholder is filled from, and what marks the element as a merge tag when a saved template is parsed back: a client field (`trustistoClientFirstname`, `trustistoClientnip__text`) or, in a mail, `mailFooter`."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentCtaButton": {
            "title": "cta-button",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "A button whose click is acted on while the campaign is on screen. The action is exported as `data-cta`.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "cta-button"
                    ]
                  },
                  "ctaType": {
                    "type": "string",
                    "description": "What a click does.",
                    "enum": [
                      "redirect",
                      "redirectNewTab",
                      "copy",
                      "openMailClient",
                      "openPopup",
                      "openTopbar",
                      "close"
                    ],
                    "default": "redirect"
                  },
                  "ctaValue": {
                    "type": "string",
                    "description": "What the action works on: an URL (`redirect`, `redirectNewTab`), the text to copy (`copy`), an e-mail address (`openMailClient`), the id of the campaign to open (`openPopup`, `openTopbar`), nothing (`close`). Cleared whenever `ctaType` changes."
                  },
                  "ctaValueName": {
                    "type": "string",
                    "description": "Name of the campaign picked for `openPopup`/`openTopbar`, so reopening the editor shows it instead of a bare id. Not exported."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-cta": {
                            "type": "string",
                            "description": "The click action, as `<ctaType>::<ctaValue>`.",
                            "example": "redirect::https://example.com"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentForm": {
            "title": "form",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The `<form>` sent when the visitor submits it. Its inputs are what `manifest.form.fields` is regenerated from on every save.\n\nThe form is submitted to the platform, not to your own endpoint, and it answers in place: the `form-message` text matching the result is revealed inside the form (see [form-message](#models/WebCampaignTemplate)), and the campaign closes 2.5 seconds later unless `data-close-on-submit` is `false`. The campaign renders at the fixed size of its manifest, so the layout has to keep room for that message.\n\nBelow is the whole Form block of the editor, exactly as dropping it produces: the `<form>` with the hidden consent field, one e-mail input, the marketing consent checkbox with its label, the submit button and the two message texts. The block styles itself through each component's own `style`, which is written out as an `#id` rule of that component - the classes are runtime hooks only.\n\n```json\n{\n  \"type\": \"form\",\n  \"tagName\": \"form\",\n  \"classes\": [\n    \"sp-form\"\n  ],\n  \"consent\": \"true\",\n  \"attributes\": {\n    \"data-close-on-submit\": \"true\"\n  },\n  \"style\": {\n    \"display\": \"flex\",\n    \"flex-direction\": \"column\",\n    \"align-items\": \"center\",\n    \"gap\": \"10px\",\n    \"width\": \"100%\"\n  },\n  \"components\": [\n    {\n      \"type\": \"form-consent\",\n      \"tagName\": \"input\",\n      \"void\": true,\n      \"attributes\": {\n        \"type\": \"hidden\",\n        \"name\": \"consent\",\n        \"value\": \"true\"\n      }\n    },\n    {\n      \"type\": \"form-input\",\n      \"tagName\": \"input\",\n      \"classes\": [\n        \"sp-form-input\"\n      ],\n      \"void\": true,\n      \"attributes\": {\n        \"type\": \"email\",\n        \"name\": \"email\",\n        \"placeholder\": \"E-mail\",\n        \"required\": true\n      },\n      \"style\": {\n        \"width\": \"100%\",\n        \"max-width\": \"300px\",\n        \"padding\": \"8px 15px\",\n        \"border\": \"1px solid #bdbdbd\",\n        \"border-radius\": \"4px\"\n      }\n    },\n    {\n      \"type\": \"form-checkbox-label\",\n      \"tagName\": \"label\",\n      \"classes\": [\n        \"sp-form-consent\"\n      ],\n      \"style\": {\n        \"display\": \"flex\",\n        \"align-items\": \"center\",\n        \"gap\": \"5px\",\n        \"font-size\": \"11px\",\n        \"color\": \"#757575\"\n      },\n      \"components\": [\n        {\n          \"type\": \"form-checkbox\",\n          \"tagName\": \"input\",\n          \"void\": true,\n          \"attributes\": {\n            \"type\": \"checkbox\",\n            \"name\": \"marketing\",\n            \"value\": \"true\",\n            \"required\": true\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"tagName\": \"span\",\n          \"components\": [\n            {\n              \"type\": \"textnode\",\n              \"content\": \"I agree to receive marketing messages\"\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"type\": \"form-button\",\n      \"tagName\": \"button\",\n      \"classes\": [\n        \"sp-form-submit\"\n      ],\n      \"attributes\": {\n        \"type\": \"submit\"\n      },\n      \"style\": {\n        \"padding\": \"8px 25px\",\n        \"border\": \"none\",\n        \"border-radius\": \"4px\",\n        \"background-color\": \"#212121\",\n        \"color\": \"#ffffff\",\n        \"cursor\": \"pointer\"\n      },\n      \"components\": [\n        {\n          \"type\": \"textnode\",\n          \"content\": \"Sign up\"\n        }\n      ]\n    },\n    {\n      \"type\": \"form-message\",\n      \"tagName\": \"p\",\n      \"classes\": [\n        \"form-error\"\n      ],\n      \"style\": {\n        \"display\": \"none\",\n        \"color\": \"#f44336\"\n      },\n      \"components\": [\n        {\n          \"type\": \"textnode\",\n          \"content\": \"Something went wrong. Please try again.\"\n        }\n      ]\n    },\n    {\n      \"type\": \"form-message\",\n      \"tagName\": \"p\",\n      \"classes\": [\n        \"form-success\"\n      ],\n      \"style\": {\n        \"display\": \"none\",\n        \"color\": \"#4caf50\"\n      },\n      \"components\": [\n        {\n          \"type\": \"textnode\",\n          \"content\": \"Thank you for signing up!\"\n        }\n      ]\n    }\n  ]\n}\n```",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form"
                    ]
                  },
                  "consent": {
                    "type": "string",
                    "description": "Opt-in mode, mirrored onto the hidden `form-consent` input read when the submission is processed: `true` subscribes right away (single opt-in), `false` asks for an e-mail confirmation (double opt-in). That input is the form's first child and is what persists the mode (in its `value` attribute); a form loaded without one gets it inserted.",
                    "enum": [
                      "true",
                      "false"
                    ],
                    "default": "true"
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-close-on-submit": {
                            "type": "string",
                            "description": "Close the campaign after a successful submit.",
                            "enum": [
                              "true",
                              "false"
                            ],
                            "default": "true"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentFormConsent": {
            "title": "form-consent",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The hidden input carrying the form's opt-in mode. Always the form's first child, never shown in the editor.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form-consent"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "hidden"
                            ]
                          },
                          "name": {
                            "type": "string",
                            "description": "Handled internally when the submission is processed, so it is left out of `manifest.form.fields`.",
                            "enum": [
                              "consent"
                            ]
                          },
                          "value": {
                            "type": "string",
                            "description": "The opt-in mode of the form: `true` single opt-in, `false` double opt-in.",
                            "enum": [
                              "true",
                              "false"
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentFormInput": {
            "title": "form-input",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "One field of the form. Its `name` is what maps the submitted value onto a client field.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form-input"
                    ]
                  },
                  "clientField": {
                    "type": "string",
                    "description": "Write only shortcut: picking a client field fills the input's `name` and `type`. Fixed fields: `email`, `phone`, `details.firstname`, `details.lastname`, `details.city`, `details.country`; the website's own: `custom_fields.<name>`.\n\nThe fixed ones exist everywhere, the rest do not: which custom fields a website defines is in `options.custom_fields.clients` of the website itself (`GET /api/v2/websites`). Take `<name>` from there verbatim - it carries a `__<type>` suffix (`birthday__date`), so the label is not the name, and an input bound to a field the website has not declared maps onto nothing.",
                    "example": "details.firstname"
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "description": "Input type.",
                            "enum": [
                              "text",
                              "email",
                              "tel",
                              "number",
                              "date"
                            ],
                            "example": "email"
                          },
                          "name": {
                            "type": "string",
                            "description": "Field name submitted with the form. Maps onto a client field and is listed in `manifest.form.fields`.",
                            "example": "email"
                          },
                          "placeholder": {
                            "type": "string"
                          },
                          "required": {
                            "description": "Present when the field is required.",
                            "oneOf": [
                              {
                                "type": "boolean"
                              },
                              {
                                "type": "string"
                              }
                            ]
                          },
                          "pattern": {
                            "type": "string",
                            "description": "Validation pattern, injected on export for e-mail and phone inputs."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentFormCheckbox": {
            "title": "form-checkbox",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The fixed `marketing` consent box.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form-checkbox"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "checkbox"
                            ]
                          },
                          "name": {
                            "type": "string",
                            "description": "Listed in `manifest.form.fields` as `bool`.",
                            "example": "marketing"
                          },
                          "value": {
                            "type": "string",
                            "enum": [
                              "true"
                            ]
                          },
                          "required": {
                            "description": "Present when the box has to be ticked.",
                            "oneOf": [
                              {
                                "type": "boolean"
                              },
                              {
                                "type": "string"
                              }
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentFormButton": {
            "title": "form-button",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The submit button of the form.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form-button"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "submit"
                            ]
                          },
                          "value": {
                            "type": "string",
                            "description": "Button label.",
                            "example": "Subscribe"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentFormMessage": {
            "title": "form-message",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The two texts a form shows once it has been submitted: the one classed `form-success` and the one classed `form-error`. Those classes are the only thing that tells them apart at runtime. Both are part of the form from the start and both are hidden (`style` carries `display: none`) - on submit the matching one is revealed, as `display: flex`, and the campaign then closes after 2.5 seconds unless the form's `data-close-on-submit` is `false`.\n\nKeep both of them inside the form: they are looked up by those classes when the form is submitted, so a form missing one has no message to show. The editor makes them neither removable nor copyable for that reason.\n\n**Leave room for them in the layout.** A message is revealed in the flow of the form, where it sits - it is not an overlay - while the campaign itself keeps rendering at the fixed size of its manifest (`height` / `mobile-height`, and `width` for a popup). A form sized exactly around its fields will therefore push its own content out of the campaign, or clip the message, the moment one appears. Size the campaign for the taller of the two states, or place the messages where the extra line has somewhere to go.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "form-message"
                    ]
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentCountdown": {
            "title": "countdown",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The countdown box. Its `data-*` attributes are read at runtime and tick the `countdown-tile` / `countdown-digit` / `countdown-label` parts below it.\n\nBelow is the whole Countdown block of the editor, exactly as dropping it produces: the box and one tile per unit, each holding the digit that is updated - the unit class on it (`days`, `hours`, `minutes`, `seconds`) is what says which one - and its label. The block styles itself through each component's own `style`, which is written out as an `#id` rule of that component; the classes are runtime hooks only.\n\n`countdownMode` / `countdownValue` and `data-deadline` say the same thing in two forms - the properties are what the editor edits, the attribute is what is read when the campaign runs, and it is rewritten from them on every change. A block dropped fresh is `absolute`, seven days out, at 23:59.\n\n```json\n{\n  \"type\": \"countdown\",\n  \"tagName\": \"div\",\n  \"classes\": [\n    \"countdown\"\n  ],\n  \"countdownMode\": \"absolute\",\n  \"countdownValue\": \"2026-08-11 23:59\",\n  \"countdownDisappear\": false,\n  \"attributes\": {\n    \"data-deadline\": \"2026-08-11 23:59\",\n    \"data-timepassedmessage\": \"\"\n  },\n  \"style\": {\n    \"display\": \"flex\",\n    \"flex-direction\": \"row\",\n    \"justify-content\": \"center\",\n    \"gap\": \"10px\"\n  },\n  \"components\": [\n    {\n      \"type\": \"countdown-tile\",\n      \"classes\": [\n        \"tile\"\n      ],\n      \"style\": {\n        \"display\": \"flex\",\n        \"flex-direction\": \"column\",\n        \"align-items\": \"center\"\n      },\n      \"components\": [\n        {\n          \"type\": \"countdown-digit\",\n          \"tagName\": \"span\",\n          \"classes\": [\n            \"days\"\n          ],\n          \"style\": {\n            \"display\": \"flex\",\n            \"align-items\": \"center\",\n            \"justify-content\": \"center\",\n            \"width\": \"48px\",\n            \"height\": \"48px\",\n            \"border-radius\": \"4px\",\n            \"background-color\": \"#212121\",\n            \"color\": \"#ffffff\",\n            \"font-size\": \"20px\",\n            \"font-weight\": \"700\"\n          },\n          \"components\": [\n            {\n              \"type\": \"textnode\",\n              \"content\": \"00\"\n            }\n          ]\n        },\n        {\n          \"type\": \"countdown-label\",\n          \"classes\": [\n            \"smalltext\"\n          ],\n          \"style\": {\n            \"margin-top\": \"5px\",\n            \"font-size\": \"11px\",\n            \"color\": \"#757575\"\n          },\n          \"content\": \"Days\"\n        }\n      ]\n    },\n    {\n      \"type\": \"countdown-tile\",\n      \"classes\": [\n        \"tile\"\n      ],\n      \"style\": {\n        \"display\": \"flex\",\n        \"flex-direction\": \"column\",\n        \"align-items\": \"center\"\n      },\n      \"components\": [\n        {\n          \"type\": \"countdown-digit\",\n          \"tagName\": \"span\",\n          \"classes\": [\n            \"hours\"\n          ],\n          \"style\": {\n            \"display\": \"flex\",\n            \"align-items\": \"center\",\n            \"justify-content\": \"center\",\n            \"width\": \"48px\",\n            \"height\": \"48px\",\n            \"border-radius\": \"4px\",\n            \"background-color\": \"#212121\",\n            \"color\": \"#ffffff\",\n            \"font-size\": \"20px\",\n            \"font-weight\": \"700\"\n          },\n          \"components\": [\n            {\n              \"type\": \"textnode\",\n              \"content\": \"00\"\n            }\n          ]\n        },\n        {\n          \"type\": \"countdown-label\",\n          \"classes\": [\n            \"smalltext\"\n          ],\n          \"style\": {\n            \"margin-top\": \"5px\",\n            \"font-size\": \"11px\",\n            \"color\": \"#757575\"\n          },\n          \"content\": \"Hours\"\n        }\n      ]\n    },\n    {\n      \"type\": \"countdown-tile\",\n      \"classes\": [\n        \"tile\"\n      ],\n      \"style\": {\n        \"display\": \"flex\",\n        \"flex-direction\": \"column\",\n        \"align-items\": \"center\"\n      },\n      \"components\": [\n        {\n          \"type\": \"countdown-digit\",\n          \"tagName\": \"span\",\n          \"classes\": [\n            \"minutes\"\n          ],\n          \"style\": {\n            \"display\": \"flex\",\n            \"align-items\": \"center\",\n            \"justify-content\": \"center\",\n            \"width\": \"48px\",\n            \"height\": \"48px\",\n            \"border-radius\": \"4px\",\n            \"background-color\": \"#212121\",\n            \"color\": \"#ffffff\",\n            \"font-size\": \"20px\",\n            \"font-weight\": \"700\"\n          },\n          \"components\": [\n            {\n              \"type\": \"textnode\",\n              \"content\": \"00\"\n            }\n          ]\n        },\n        {\n          \"type\": \"countdown-label\",\n          \"classes\": [\n            \"smalltext\"\n          ],\n          \"style\": {\n            \"margin-top\": \"5px\",\n            \"font-size\": \"11px\",\n            \"color\": \"#757575\"\n          },\n          \"content\": \"Minutes\"\n        }\n      ]\n    },\n    {\n      \"type\": \"countdown-tile\",\n      \"classes\": [\n        \"tile\"\n      ],\n      \"style\": {\n        \"display\": \"flex\",\n        \"flex-direction\": \"column\",\n        \"align-items\": \"center\"\n      },\n      \"components\": [\n        {\n          \"type\": \"countdown-digit\",\n          \"tagName\": \"span\",\n          \"classes\": [\n            \"seconds\"\n          ],\n          \"style\": {\n            \"display\": \"flex\",\n            \"align-items\": \"center\",\n            \"justify-content\": \"center\",\n            \"width\": \"48px\",\n            \"height\": \"48px\",\n            \"border-radius\": \"4px\",\n            \"background-color\": \"#212121\",\n            \"color\": \"#ffffff\",\n            \"font-size\": \"20px\",\n            \"font-weight\": \"700\"\n          },\n          \"components\": [\n            {\n              \"type\": \"textnode\",\n              \"content\": \"00\"\n            }\n          ]\n        },\n        {\n          \"type\": \"countdown-label\",\n          \"classes\": [\n            \"smalltext\"\n          ],\n          \"style\": {\n            \"margin-top\": \"5px\",\n            \"font-size\": \"11px\",\n            \"color\": \"#757575\"\n          },\n          \"content\": \"Seconds\"\n        }\n      ]\n    }\n  ]\n}\n```",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "countdown"
                    ]
                  },
                  "countdownMode": {
                    "type": "string",
                    "description": "How `countdownValue` is read: `absolute` a fixed date, `relative` a number of seconds from the first view, `daily` a time of day repeating every day.",
                    "enum": [
                      "absolute",
                      "relative",
                      "daily"
                    ],
                    "default": "absolute"
                  },
                  "countdownValue": {
                    "type": "string",
                    "description": "The deadline in the shape its mode expects: `YYYY-MM-DD HH:MM`, a number of seconds, or `HH:MM`."
                  },
                  "countdownDisappear": {
                    "type": "boolean",
                    "description": "Hide the campaign once the countdown is over.",
                    "default": false
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-deadline": {
                            "type": "string",
                            "description": "`YYYY-MM-DD HH:MM`, a number of seconds, or `daily HH:MM`.",
                            "example": "2026-08-07 23:59"
                          },
                          "data-timepassedmessage": {
                            "type": "string",
                            "description": "Text shown once the deadline has passed."
                          },
                          "data-countdown-disappear": {
                            "type": "string",
                            "description": "Present when the campaign hides itself after the countdown.",
                            "enum": [
                              "true"
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentProductAdd": {
            "title": "product-add",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "One **dynamic product** - a product chosen when the campaign is rendered rather than when the template is written, so every visitor sees their own.\n\nIt is two things that have to stay together: this component, and the record it owns in `dataSources.products`, keyed by the component's own id. Everything below the component - the image, the name, the prices, the link - is bound to that record through `data-variable` paths, so nothing under it is typed in.\n\n## Where the product comes from\n\nThe record's `type` is what decides that, and it is the field that turns one `product-add` into \"the first thing in this visitor's basket\" rather than a fixed item. It is set in the panel through the component's `dynamic-product` attribute, and the record follows.\n\n| `type` | `dynamic-product.type.id` | What the visitor sees | Extra fields | The record's `product.productId` |\n|---|---|---|---|---|\n| `product` | `products` / `products_id` | A **static product** - one fixed item, the same for everyone, picked from the feed in the panel | - | The id the feed gives it |\n| `basket` | `baskets` | The product at `index` in their **abandoned basket** | `index` | `$sp.basket.<index>` |\n| `visit` | `visits` | The product at `index` among the ones they **last looked at** | `index` | `$sp.visit.<index>` |\n| `purchase` | `purchases` | The product at `index` among their **last purchases** | `index` | `$sp.purchase.<index>` |\n| `engine` | `engine_<engineId>` | The recommendation at `index` from **recommendation engine** `engineId` (`/api/v2/websites/{website_id}/engines`) | `index`, `engineId` | `$sp.recommend.<engineId>.<index>` |\n\n`index` is a string: the number the feed's own id ends with, counting from `1`. `basket_1` is the first entry `/products` offers for a basket, so the record it produces is `index` `\"1\"`, `$sp.basket.1`, and the param a save writes for it is `product_basket_1`. `productId` says where the record was bound, and a block the panel has not bound to a feed yet carries `$sp.null.<component id>` - that is the one value that names the component rather than a product.\n\n**A static product is the only one that renders as itself.** Its data stays in the record and is written into the export as literal values, and it produces no `manifest.params` entry. For every other type the save replaces the record with merge tags and writes one `manifest.params` entry - `params` in [WebCampaignManifest](#models/WebCampaignManifest) has the naming and the tags. That entry carries a mock product as its default, which is what the preview and the gallery thumbnail render.\n\nNothing promises the visitor actually has a product at that position, so a resolved product also gets `ut:delete-if` on its `productName`: the whole block disappears when there is none.\n\n## ut:delete-if\n\n`ut:delete-if` drops the element when the value it names is empty. Two rules, both easy to miss:\n\n- **One `{{param.field}}` per condition.** The engine strips the braces and splits what is left on the first dot, so a composed value such as `{{p.productPromoPrice}} {{p.productCurrency}}` never resolves and the element is dropped every time.\n- **Name a field that survives the save.** The save rewrites a resolved record down to `productImage`, `productName`, `productPriceCurrency`, `productPromoPriceCurrency` and `productUrl`. A `data-variable` on anything else - `productPromoPrice`, say - exports as an empty condition and the line is always dropped. The engine reads the condition from `params`, where the whole product does exist, so write those as the merge tag string itself instead of as a `data-variable`.\n\n## The image\n\n`product-image` sets its own `src`. It reads `productImage` off the record it follows and rewrites `src` and `alt` on load and on every change to that record - which is also how the merge tag gets into the export, once the save has swapped the record. Never write `src` by hand; it is overwritten.\n\nFor a template written outside the editor that matters twice over: the `productImage` in the **record** is what the editor canvas renders, and the canvas runs on the panel, so it has to be an absolute URL. A path relative to the template folder only resolves in the `manifest.params` default, which is rendered inside the exported page.\n\n## The block as the editor drops it\n\nDropping **Add product** produces the component below, and beside it one `dataSources.products` record keyed by the component's id.\n\n| Field | Required | What it is |\n|---|---|---|\n| `type` | yes | Always `product-add`. |\n| `productTitle` | no | Sample name written at the drop, read by nothing. |\n| `attributes.id` | yes | The component id. The `dataSources.products` record is keyed by it, and every `data-variable` path in the subtree repeats it. |\n| `attributes.dynamic-product.type` | once a source is picked | The feed (`/api/v2/websites/{website_id}/feeds`), kept as that endpoint returned it. `id` has to name a real feed: the panel reads it back as the component loads and replaces `name` with what the endpoint answers. |\n| `attributes.dynamic-product.product` | once a source is picked | The `productId` of the entry picked from that feed, in the form the feed returns it (`basket_1`). The record's `index` is derived from it. Listing that feed's entries means `GET /api/v2/websites/{website_id}/products?product_format=grape&feed=<type.id>` - `feed=baskets`, `feed=engine_10`, `feed=products_id` - the call the panel's own select makes; **`feed` is required**, and without it the endpoint answers with an empty list. `productName` may sit beside the id as the label the panel shows until the feed answers; nothing else under `product` is read. |\n| `attributes.ut:delete-if` | every source but a static `product` | Drops the whole block when the visitor has nothing at that position. |\n| `components` | yes | The subtree, bound to the record through `data-variable` paths - nothing in it is typed in. |\n\n```json\n{\n  \"type\": \"product-add\",\n  \"productTitle\": \"Sample Product\",\n  \"attributes\": {\n    \"id\": \"i5tl\",\n    \"dynamic-product\": {\n      \"type\": {\n        \"id\": \"baskets\",\n        \"name\": \"Products from cart\"\n      },\n      \"product\": {\n        \"productId\": \"basket_1\",\n        \"productName\": \"Product from cart\"\n      }\n    },\n    \"ut:delete-if\": {\n      \"path\": \"products.i5tl.product.productName\",\n      \"type\": \"data-variable\"\n    }\n  },\n  \"components\": [\n    {\n      \"attributes\": {\n        \"id\": \"iley\"\n      },\n      \"components\": [\n        {\n          \"type\": \"link\",\n          \"attributes\": {\n            \"data-cta-inline\": {\n              \"defaultValue\": \"#\",\n              \"path\": \"products.i5tl.product.productUrl\",\n              \"type\": \"data-variable\"\n            },\n            \"target\": \"_blank\"\n          },\n          \"components\": [\n            {\n              \"type\": \"product-image\",\n              \"productId\": \"i5tl\",\n              \"attributes\": {\n                \"src\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n                \"alt\": \"Sample Product\",\n                \"align\": \"center\",\n                \"id\": \"i4sm\"\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"type\": \"link\",\n      \"editable\": false,\n      \"attributes\": {\n        \"data-cta-inline\": {\n          \"defaultValue\": \"#\",\n          \"path\": \"products.i5tl.product.productUrl\",\n          \"type\": \"data-variable\"\n        },\n        \"target\": \"_blank\",\n        \"id\": \"i1qs\"\n      },\n      \"content\": {\n        \"defaultValue\": \"Sample Product\",\n        \"path\": \"products.i5tl.product.productName\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"text\",\n      \"editable\": false,\n      \"attributes\": {\n        \"id\": \"i0rd\"\n      },\n      \"content\": {\n        \"path\": \"products.i5tl.product.productPriceCurrency\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"text\",\n      \"editable\": false,\n      \"attributes\": {\n        \"ut:delete-if\": {\n          \"path\": \"products.i5tl.product.productPromoPrice\",\n          \"type\": \"data-variable\"\n        },\n        \"id\": \"iyef\"\n      },\n      \"content\": {\n        \"path\": \"products.i5tl.product.productPromoPriceCurrency\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"link\",\n      \"attributes\": {\n        \"data-cta-inline\": {\n          \"defaultValue\": \"#\",\n          \"path\": \"products.i5tl.product.productUrl\",\n          \"type\": \"data-variable\"\n        },\n        \"target\": \"_blank\",\n        \"id\": \"ia9jk\"\n      },\n      \"content\": \"More\"\n    }\n  ]\n}\n```\n\nThe record beside it:\n\n```json\n{\n  \"id\": \"i5tl\",\n  \"type\": \"basket\",\n  \"index\": \"1\",\n  \"product\": {\n    \"productId\": \"$sp.basket.1\",\n    \"productUrl\": \"\",\n    \"productCurrency\": \"USD\",\n    \"updatedAt\": \"2020-01-01\",\n    \"productCategory\": \"Cart\",\n    \"productBrand\": \"Cart\",\n    \"productPrice\": \"10.00\",\n    \"productPromoPrice\": \"5.00\",\n    \"productImage\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n    \"productName\": \"Product from cart\",\n    \"productPriceCurrency\": \"10.00 USD\",\n    \"productPromoPriceCurrency\": \"5.00 USD\"\n  }\n}\n```\n\nBefore a source is picked the root carries neither `dynamic-product` nor `ut:delete-if`, and the record is still the mock the drop created - no `type`, no `index`, and a `$sp.null.<id>` `productId`:\n\n```json\n{\n  \"id\": \"i5tl\",\n  \"product\": {\n    \"productId\": \"$sp.null.i5tl\",\n    \"productUrl\": \"\",\n    \"productCurrency\": \"USD\",\n    \"updatedAt\": \"2020-01-01\",\n    \"productCategory\": \"\",\n    \"productBrand\": \"\",\n    \"productPrice\": \"10.00\",\n    \"productPromoPrice\": \"5.00\",\n    \"productImage\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n    \"productName\": \"Sample Product\",\n    \"productPriceCurrency\": \"10.00 USD\",\n    \"productPromoPriceCurrency\": \"5.00 USD\"\n  }\n}\n```\n",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "product-add"
                    ]
                  },
                  "productTitle": {
                    "type": "string",
                    "description": "Sample product name the editor writes when the block is dropped. Carried through the save and read by nothing - the names on screen come from the `data-variable` bindings below the component."
                  },
                  "showTitle": {
                    "type": "string",
                    "enum": [
                      "true",
                      "false"
                    ],
                    "description": "Editor default, always `\"true\"`. Written into the project and read by nothing."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "dynamic-product": {
                            "type": "object",
                            "description": "The picked source and product.",
                            "properties": {
                              "type": {
                                "type": "object",
                                "description": "Feed the product comes from (`/api/v2/websites/{id}/feeds`). `products`/`products_id` mean a fixed product; `baskets`, `visits`, `purchases` and `engine_<engineId>` are resolved per visitor.",
                                "properties": {
                                  "id": {
                                    "type": "string"
                                  },
                                  "name": {
                                    "type": "string"
                                  }
                                }
                              },
                              "product": {
                                "type": "object",
                                "description": "The entry picked from that feed, as `productId` - `index` and the record's `$sp.` id are derived from it. `productName` may sit beside it as the label the panel's second select shows until the feed answers. Every other product field is replaced by that answer before anything reads it, so writing one here has no effect - the full shape belongs in the `dataSources` record, not in this attribute.",
                                "required": [
                                  "productId"
                                ],
                                "properties": {
                                  "productId": {
                                    "type": "string",
                                    "description": "The id the products endpoint returned - the feed's own id for a static product, `basket_<index>` / `visit_<index>` / `purchase_<index>` / `engine_<engineId>_<index>` for a dynamic one. Never the record's `$sp.` form.",
                                    "example": "basket_1"
                                  },
                                  "productName": {
                                    "type": "string"
                                  }
                                },
                                "additionalProperties": true
                              }
                            }
                          },
                          "ut:delete-if": {
                            "description": "Drop the element when the bound value is empty (products the visitor has no data for).",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          },
                          "ut:style": {
                            "type": "string",
                            "description": "Style applied once the dynamic data has been resolved - how the loading spinner is swapped for the content."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentProductImage": {
            "title": "product-image",
            "allOf": [
              {
                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The image of a dynamic product, following the `dataSources` record of its `product-add`.\n\nIt has no default size of its own to fall back on - style it like any other component, through `style`.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "product-image"
                    ]
                  },
                  "productId": {
                    "type": "string",
                    "description": "Id of the `dataSources` record the image follows."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "src": {
                            "description": "The product image. **Written by the component itself** - it reads `productImage` off the record it follows and rewrites `src` and `alt` on load and on every change to that record, which is also how the merge tag reaches the export. Do not set it by hand; it is overwritten.",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          },
                          "ut:delete-if": {
                            "description": "Drop the element when the bound value is empty.",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          },
                          "ut:style": {
                            "type": "string",
                            "description": "Style applied once the dynamic data has been resolved."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          }
        },
        "properties": {
          "pages": {
            "type": "array",
            "description": "Always exactly one page with exactly one frame: a template is one popup or topbar.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "frames": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "component": {
                        "description": "The `wrapper` component - root of the tree, the `<body>` of the exported page.",
                        "allOf": [
                          {
                            "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/componentWrapper"
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "styles": {
            "type": "array",
            "description": "The project stylesheet. The editor styles the selected component itself, so nearly every rule targets one component's `#id`.\n\n**Style a component through its own `style`, not through a rule written here.** `cta-button`, `spacer`, `form-message` and `product-add` carry a default style of their own, which the editor stamps onto every component of that type as the project loads - restored components included - as an `#id` rule appended after this stylesheet. A `.class` rule loses to it on specificity; a rule you write here for that same `#id` loses to it on order, as a second `#id` rule. Either way the default wins: a CTA styled that way comes out in the default dark, not in the colours you set. A component's own `style` is that same `#id` rule, so it replaces the default instead of competing with it.\n\nWhat is left for this stylesheet is what cannot be said on a component: `state` rules (`hover`, ...) and anything targeting a selector no single component owns.",
            "items": {
              "type": "object",
              "properties": {
                "selectors": {
                  "type": "array",
                  "description": "Class/id selectors, as names or as full selector objects.",
                  "items": {
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "object",
                        "additionalProperties": true
                      }
                    ]
                  }
                },
                "selectorsAdd": {
                  "type": "string",
                  "description": "Raw selector appended to the generated one - the `#id` of the styled component.",
                  "example": "#i3kd"
                },
                "style": {
                  "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/style"
                },
                "state": {
                  "type": "string",
                  "description": "Pseudo state (`hover`, `active`, ...). Empty for the plain rule."
                },
                "mediaText": {
                  "type": "string",
                  "description": "Media query. Stays empty here: a web campaign renders at one fixed size, so its device declares no `widthMedia` - rules inside a media query would stop matching as soon as the campaign is made wider."
                },
                "atRuleType": {
                  "type": "string"
                },
                "important": {
                  "type": "boolean"
                }
              },
              "additionalProperties": true
            }
          },
          "assets": {
            "type": "array",
            "description": "Asset Manager entries - images picked through the panel media modal, so `src` points at the media bucket.",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "enum": [
                    "image"
                  ],
                  "default": "image"
                },
                "src": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "width": {
                  "type": "number"
                },
                "height": {
                  "type": "number"
                },
                "unitDim": {
                  "type": "string",
                  "example": "px"
                }
              },
              "additionalProperties": true
            }
          },
          "dataSources": {
            "type": "array",
            "description": "Only one is used, `products`: one record per `product-add` component, keyed by that component's id. `type` other than `product` means the product is resolved per visitor and gets a `manifest.params` entry on save.\n\n**Half of it lives on the campaign.** A record whose `type` is not `product` only produces products if the campaign using this template also lists it in `options.metaData` - a field of the campaign, not of this document. See the campaign endpoints for what it has to contain.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "enum": [
                    "products"
                  ]
                },
                "records": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "required": [
                      "id",
                      "product"
                    ],
                    "properties": {
                      "id": {
                        "type": "string",
                        "description": "Id of the `product-add` component owning the record."
                      },
                      "product": {
                        "$ref": "#/components/schemas/WebCampaignTemplate/x-defs/product"
                      },
                      "type": {
                        "type": "string",
                        "description": "Where the product comes from - the field that decides whether it is resolved per visitor at all.\n\n- `product` - a **static product**: one fixed item, the same for everyone, written into the export as literal values. No `index`, and no manifest param.\n- `basket` - the product at `index` in the visitor's abandoned basket.\n- `visit` - the product at `index` among the ones they last looked at.\n- `purchase` - the product at `index` among their last purchases.\n- `engine` - the recommendation at `index` from the engine named by `engineId`.\n\nWhich of these a website can actually use is what `GET /api/v2/websites/{website_id}/feeds` returns; a source it does not list resolves to nothing.\n\nAbsent on a block that has not been bound to a source yet. See [product-add](#models/WebCampaignTemplate) for the matching `dynamic-product.type.id` and the param each one writes.",
                        "enum": [
                          "product",
                          "basket",
                          "visit",
                          "purchase",
                          "engine"
                        ]
                      },
                      "index": {
                        "type": "string",
                        "description": "Position within the visitor's basket / last seen / last purchased products, or within the engine's recommendations. A string, counting from `0`. Absent for a static `product`."
                      },
                      "engineId": {
                        "type": "string",
                        "description": "Recommendation engine id (`/api/v2/websites/{website_id}/engines`) - only for `engine`, where it is also part of the param name (`product_engine_<engineId>_<index>`)."
                      }
                    }
                  }
                }
              }
            }
          },
          "symbols": {
            "type": "array",
            "description": "Reusable component instances (symbols). Unused here.",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          }
        },
        "additionalProperties": true
      },
      "MailCampaignTemplate": {
        "type": "object",
        "description": "The editor project of a **mail** campaign template - Trust Mail newsletters and Trust Recovery automated e-mails.\n\nThis is what the drag and drop editor loads and saves, stored as `input.mjml.json` and sent as the `json` field of `POST/PUT /api/v2/organizations/{organization_id}/templates`. It is the source of truth for the campaign: the `index.html` the message is built from is generated from it on every save, and is never authored by hand.\n\nThe `manifest` sent alongside it is described by [MailCampaignManifest](#models/MailCampaignManifest).\n\nMail is laid out with tables (e-mail clients cannot be trusted with flexbox) and keeps the desktop/tablet/mobile previews, so its stylesheet can carry media queries. The popup/topbar format is a different shape - see [WebCampaignTemplate](#models/WebCampaignTemplate).\n\n## Creating one outside the editor\n\n`json` is only the source. A template is published from three parts sent together - `json` (this document), `manifest` and `html` - and nothing is stored until all three are there: a template created with `json` alone stays unpublished and gets no `url`. In the panel the editor's save routine derives the other two from the project on every save; calling the API directly means doing the same work.\n\n**`html` - the project as one standalone page**\n\n`<!DOCTYPE html><html><head>` with `<meta charset=\"UTF-8\">`, a `<title>`, one `<link>` per Google font used in the stylesheet and a single `<style>` holding the whole project stylesheet, then the `wrapper` rendered as the `<body>` - opened with the hidden preheader element when the wrapper has a `preheader`. Mail is exported as it stands - no reset stylesheet, no injected input patterns and no loading screen, all of which only make sense for a campaign rendered on a website.\n\nA text holding `{{mailFooter}}` carries `data-html-friendly=\"true\"`, so the footer is inserted as markup instead of being escaped.\n\n**`manifest`** is regenerated from the project as well - which fields, and from what, is described by [MailCampaignManifest](#models/MailCampaignManifest).\n\n**How to use this document.** It is a description of the format - a dictionary to read, not a validator to run as it stands. The component tree is a `oneOf` over every variant, which is expensive to evaluate directly: validating one real template against it takes seconds. Anything that needs to validate should first rewrite the union into a dispatch on `type`.",
        "x-defs": {
          "style": {
            "description": "CSS declarations, keyed by property name as written in a stylesheet (`background-color`, not `backgroundColor`).\n\nThe editor's Style Manager offers: dimension (`width`, `height`, `max-width`, `min-height`, `margin*`, `padding*`), typography (`font-family`, `font-size`, `font-weight`, `letter-spacing`, `color`, `line-height`, `text-align`, `text-decoration`, `font-style`, `vertical-align`, `text-shadow*`) and decorations (`background-color`, `background*`, `border*`, `border-radius*`, `border-collapse`). Any other valid declaration is accepted as well.\n\nThis is where a component's styling belongs. The editor writes it out as that component's own `#id` rule, and it replaces whatever default the component's type carries - whole, so repeat every property you want to keep. Styling a component from `styles` instead does not survive: see the note there.",
            "anyOf": [
              {
                "type": "object",
                "additionalProperties": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "number",
                      "description": "Unitless value, for the properties that take one (`opacity`, `line-height`, `z-index`, `font-weight`, `order`, `flex-grow`, …). Everything else - lengths, functions like `calc()`, colours, keywords - is written as a string."
                    }
                  ]
                },
                "example": {
                  "padding": "10px 25px",
                  "font-size": "20px",
                  "text-align": "center"
                }
              },
              {
                "type": "string",
                "maxLength": 0,
                "description": "The editor writes an empty string here when the component carries no styling of its own, so a template holds `\"\"` far more often than an object."
              }
            ]
          },
          "dataVariable": {
            "type": "object",
            "description": "A value taken from a data source instead of typed in. Shown resolved on the canvas, exported as a `{{param.field}}` merge tag filled with the recipient's own data when the mail is built.",
            "required": [
              "type",
              "path"
            ],
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "data-variable"
                ]
              },
              "path": {
                "type": "string",
                "description": "`<dataSourceId>.<recordId>.<field>`.",
                "example": "products.i8xk.product.productName"
              },
              "defaultValue": {
                "type": "string",
                "description": "Used when the path resolves to nothing."
              }
            }
          },
          "product": {
            "type": "object",
            "description": "Product data of one dynamic product. Real values while a fixed product from the feed is picked, mock values (`$sp.<type>.<index>` ids) while the product is dynamic - those become `{{product_<...>.field}}` merge tags on save.",
            "properties": {
              "productId": {
                "type": "string",
                "description": "Which form this id takes depends on where the product sits. In a `dynamic-product` attribute it is the id the products endpoint returned - the feed's own id for a static product, `basket_<index>` / `visit_<index>` / `purchase_<index>` / `engine_<engineId>_<index>` for a dynamic one. In a `dataSources` record it names where the block is bound instead: `$sp.basket.<index>`, `$sp.visit.<index>`, `$sp.purchase.<index>`, `$sp.recommend.<engineId>.<index>` - or `$sp.null.<component id>` while the block is bound to nothing.",
                "example": "$sp.basket.1"
              },
              "productName": {
                "type": "string"
              },
              "productUrl": {
                "type": "string"
              },
              "productImage": {
                "type": "string"
              },
              "productPrice": {
                "type": "string"
              },
              "productPromoPrice": {
                "type": "string"
              },
              "productCurrency": {
                "type": "string",
                "example": "PLN"
              },
              "productPriceCurrency": {
                "type": "string",
                "description": "`productPrice` + `productCurrency`, precomputed for display.",
                "example": "10.00 PLN"
              },
              "productPromoPriceCurrency": {
                "type": "string",
                "description": "`productPromoPrice` + `productCurrency`. Empty when there is no promo price."
              },
              "productCategory": {
                "type": "string"
              },
              "productBrand": {
                "type": "string"
              },
              "updatedAt": {
                "type": "string"
              }
            },
            "additionalProperties": true
          },
          "component": {
            "title": "Component",
            "oneOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentWrapper"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentLayout"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentGeneric"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentText"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentImage"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentLink"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentMergeTag"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentCouponTag"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentCouponBlock"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentProductAdd"
              },
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentProductImage"
              }
            ]
          },
          "componentBase": {
            "type": "object",
            "title": "componentBase",
            "properties": {
              "tagName": {
                "type": "string",
                "description": "HTML tag rendered for the component.",
                "example": "table"
              },
              "name": {
                "type": "string",
                "description": "Label shown in the Layer Manager and on the component's badge."
              },
              "classes": {
                "type": "array",
                "description": "CSS classes. The preset blocks ship theirs with their markup: `button`, `divider`, `quote`, `heading`, `paragraph`, `card-title`, `card-text`, the `grid-item-*` set (grid block) and the `list-item-*` / `list-cell-*` set (list block).",
                "items": {
                  "type": "string"
                }
              },
              "style": {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/style"
              },
              "components": {
                "type": "array",
                "description": "Children.\n\nOne node of the message. `type` decides what the editor makes of it, `components` holds its children - the same structure again.\n\nEvery node carries the same shared properties, whatever its type is: how it is rendered (`tagName`, `classes`, `style`), its children, and what the editor lets the user do with it (`draggable`, `editable`, ...). Each variant below adds on top of those only what its own type uses - in its own properties and in its `attributes`.\n\n**Layout**, table based so e-mail clients render it - `section` (the `<table>` a row block drops), `cell` (a `<td>` column, the only part content can be dropped into), `table`, `tbody`, `thead`, `tfoot`, `row` (`<tr>`).\n\n**Content** - `wrapper` (the page root), `default` (anything else), `text` (also what the heading, paragraph, quote and card blocks produce), `textnode`, `image`, `link` (the button and link blocks, and the droppable link box), `comment`.\n\n**Dynamic product** - `product-add` (owns one `dataSources` record and builds its own subtree), `product-image`.\n\n**Merge tag** - `merge-tag`, the `{{trustistoClient<Field>}}` / `{{mailFooter}}` chip inserted from the rich text toolbar.\n\n**Coupon** - `coupon-tag`, the `{{coupon_<setId>}}` chip inserted from the rich text toolbar.\n\n**Picking the variant.** The `type` field decides which variant below applies. A component **without** `type` is valid and common: that is how the editor stores a plain container, and it means the generic variant. Do not treat a missing `type` as an error.",
                "items": {
                  "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/component"
                }
              },
              "traits": {
                "type": "array",
                "description": "The property controls shown in the Settings panel for this component (the dynamic product panel).",
                "items": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": "string",
                      "example": "dynamic-product-properties"
                    },
                    "name": {
                      "type": "string"
                    },
                    "label": {
                      "type": "string"
                    },
                    "category": {
                      "type": "string"
                    }
                  },
                  "additionalProperties": true
                }
              },
              "void": {
                "type": "boolean",
                "description": "Self closing element (`img`)."
              },
              "draggable": {
                "description": "Whether - and into what - it can be dragged. Cells are fixed inside their section.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "string"
                  }
                ]
              },
              "droppable": {
                "description": "Whether - and what - can be dropped inside. Only cells (and the link box) take content.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "string"
                  }
                ]
              },
              "removable": {
                "type": "boolean"
              },
              "copyable": {
                "type": "boolean"
              },
              "editable": {
                "type": "boolean",
                "description": "Text can be edited inline. Off for content bound to a data variable and for the coupon chip and the coupon block."
              },
              "selectable": {
                "type": "boolean"
              },
              "hoverable": {
                "type": "boolean"
              },
              "highlightable": {
                "type": "boolean"
              },
              "layerable": {
                "type": "boolean",
                "description": "Shown in the Layer Manager."
              },
              "stylable": {
                "description": "Whether the Style Manager may style it, or which properties it may set.",
                "oneOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                ]
              }
            },
            "additionalProperties": true
          },
          "componentAttributes": {
            "type": "object",
            "title": "componentAttributes",
            "description": "The HTML attributes every component may carry. Each type adds its own on top - those are what the mail build needs.",
            "properties": {
              "id": {
                "type": "string",
                "description": "Also the selector every style edit of this component is written to."
              },
              "title": {
                "type": "string"
              },
              "align": {
                "type": "string",
                "description": "Alignment - still the reliable way to align content in e-mail clients.",
                "example": "center"
              }
            },
            "additionalProperties": true
          },
          "componentWrapper": {
            "title": "wrapper",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "Root of the tree, the `<body>` of the exported message. Exactly one per template - the frame's own component.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "wrapper"
                    ]
                  },
                  "preheader": {
                    "type": "string",
                    "maxLength": 250,
                    "description": "The preheader - the preview line an inbox shows next to the subject before the message is opened. Plain text. Absent when the mail has none.\n\nIt is not content, so it is kept here rather than as a component: the export writes it as the first element of the `<body>`, a hidden `<div data-sp-preheader=\"true\">` holding the escaped text followed by invisible padding."
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentLayout": {
            "title": "section, cell, table, tbody, thead, tfoot, row",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The table based layout e-mail clients render: `section` (the `<table>` a row block drops), `cell` (a `<td>` column, the only part content can be dropped into), and the plain `table`, `tbody`, `thead`, `tfoot`, `row` (`<tr>`) nodes below them. Sizing and alignment are done with `width` / `align` attributes rather than with CSS alone.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "section",
                      "cell",
                      "table",
                      "tbody",
                      "thead",
                      "tfoot",
                      "row"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "width": {
                            "type": "string"
                          },
                          "height": {
                            "type": "string"
                          },
                          "valign": {
                            "type": "string",
                            "example": "top"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentGeneric": {
            "title": "default, comment",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The types that add nothing to the shared properties: `default` (anything the editor has no special component for) and `comment`.",
                "properties": {
                  "type": {
                    "type": "string",
                    "description": "Left out for a plain `div` - a missing `type` is read as `default`.",
                    "enum": [
                      "default",
                      "comment"
                    ],
                    "default": "default"
                  },
                  "attributes": {
                    "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                  }
                }
              }
            ]
          },
          "componentText": {
            "title": "text, textnode",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "A text box (`text` - also what the heading, paragraph, quote and card blocks produce) and the raw text inside it (`textnode`).",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "text",
                      "textnode"
                    ]
                  },
                  "content": {
                    "description": "The text: typed in, or bound to a product field.",
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                      }
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-html-friendly": {
                            "type": "string",
                            "description": "Marks the `{{mailFooter}}` text so its value is inserted as HTML rather than escaped. The placeholder itself sits in a `merge-tag` chip.",
                            "enum": [
                              "true"
                            ]
                          },
                          "ut:delete-if": {
                            "description": "Drop the element when the bound value is empty (the promo price of a dynamic product).",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentImage": {
            "title": "image",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "An image picked through the panel media modal, so it is an Asset Manager entry as well.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "image"
                    ]
                  },
                  "src": {
                    "type": "string",
                    "description": "URL of the picked media."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "src": {
                            "type": "string"
                          },
                          "alt": {
                            "type": "string"
                          },
                          "width": {
                            "type": "string"
                          },
                          "height": {
                            "type": "string"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentLink": {
            "title": "link",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "An `<a>` - the button and link blocks, and the droppable link box.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "link"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "href": {
                            "description": "Link target. Inside a dynamic product it is bound to the product URL instead of a fixed address.\n\nIt can also be one of the addresses the sender fills per recipient, which the editor offers as \"dynamic links\" on every `<a>`: `{{seeInBrowserUrl}}` (the message as a web page) and `{{unsubscribeUrl}}` (that recipient's unsubscribe link, which every mail has to carry).",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          },
                          "target": {
                            "type": "string",
                            "example": "_blank"
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentMergeTag": {
            "title": "merge-tag",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The `{{trustistoClient<Field>}}` or `{{mailFooter}}` chip inserted from the rich text toolbar. Not editable inline: its content is written from `data-merge-tag`, and the canvas shows the field under the name the user knows it by (`{{Imię}}`) while the placeholder is what the exported HTML carries.\n\n## What fills it\n\nA **client field** becomes a `$sp.client.<field>` param on save (see [MailCampaignManifest](#models/MailCampaignManifest)) and is filled with that recipient's own data when the mail is built. The field is named as the client record stores it - the fixed `firstname`, `lastname`, `city` and `country`, or one of the website's own client fields, whose name carries its type (`nip__text`).\n\n`{{mailFooter}}` is the sender's footer, a param every mail carries. The text it sits in is marked `data-html-friendly=\"true\"`, so the footer is inserted as markup rather than escaped.\n\n## The chip as the editor inserts it\n\n```json\n{\n  \"type\": \"merge-tag\",\n  \"tagName\": \"span\",\n  \"attributes\": { \"data-merge-tag\": \"trustistoClientFirstname\" },\n  \"components\": [\n    { \"type\": \"textnode\", \"content\": \"{{trustistoClientFirstname}}\" }\n  ]\n}\n```\n\nThe placeholder is rewritten from the attribute whenever the template is opened, so the two cannot drift apart - a chip sent with no children is filled in by the editor.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "merge-tag"
                    ]
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "required": [
                          "data-merge-tag"
                        ],
                        "properties": {
                          "data-merge-tag": {
                            "type": "string",
                            "description": "The field the placeholder is filled from, and what marks the element as a merge tag when a saved template is parsed back: a client field (`trustistoClientFirstname`, `trustistoClientnip__text`) or, in a mail, `mailFooter`."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentCouponTag": {
            "title": "coupon-tag",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The `{{coupon_<setId>}}` chip inserted from the rich text toolbar - a code inside a sentence someone is writing, where `coupon-block` is a coupon standing on its own.\n\nNot editable inline, and not editable by halves either: the code goes into the text as one element and comes out the same way, so a stray backspace cannot leave `{{coupon_4` behind in a sent campaign. Its content is the placeholder and is rewritten from `data-coupon-set` whenever the template is opened, so a chip sent with no children is filled in by the editor.\n\nOn the canvas it is shown by the set's name (`{{Kupon: Summer sale}}`) and set off from the text around it, like a merge tag - the exported HTML carries the placeholder. A chip written into a template by hand knows its set by id alone and is shown as `{{Kupon: #42}}`.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "coupon-tag"
                    ]
                  },
                  "content": {
                    "type": "string",
                    "description": "The placeholder, `{{coupon_<setId>}}`. Written from `data-coupon-set` by the editor, which shows the set's name in its place on the canvas."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-coupon-set": {
                            "type": "string",
                            "description": "Id of the coupon set the code is drawn from."
                          },
                          "data-coupon-name": {
                            "type": "string",
                            "description": "Name of that set, shown on the chip in the editor."
                          },
                          "data-coupon-fallback": {
                            "type": "string",
                            "description": "Code used when the set has run out of codes."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentCouponBlock": {
            "title": "coupon-block",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "A **coupon** standing on its own - the block manager drops it as one element whose entire content is the `{{coupon_<setId>}}` placeholder, where `coupon-tag` puts the same placeholder inside a sentence someone is writing.\n\nThe element *is* the coupon: nothing can be dropped into it and its text is not editable, because the editor rewrites that text from the chosen set. Until a set is picked it shows a caption instead - `{{coupon_}}` is never written without an id to fill it with.\n\n## Where the set is kept\n\nTwice, and both copies matter. The component properties `couponSetId`, `couponSetName` and `couponFallback` are what the panel edits; the editor mirrors them onto the `data-coupon-set`, `data-coupon-name` and `data-coupon-fallback` attributes, which is the only copy that reaches the exported HTML - and therefore the copy a block read back out of a saved template restores its panel values from. `data-coupon-block=\"true\"` is what marks the element as a coupon block when that template is parsed.\n\n**Write the three attributes in that order.** The save pairs a set with its fallback using a single expression over one tag, which expects `data-coupon-fallback` after `data-coupon-set` - a fallback written before the id is silently dropped from `manifest.params`, and the recipient of an exhausted set gets no code instead of the spare one.\n\n## Styling\n\nNo class of its own and no default style the editor re-applies: it is styled through `style` like any other component. The block arrives with `padding: 10px`, `font-size: 22px` and `text-align: center` - a code is read off the screen and typed into a shop, so it starts legible - and every one of those can be changed away.\n\n## What it shows\n\nOnce a set is picked, the canvas shows the set's name (`{{Kupon: Summer sale}}`) exactly as the inline chip does, while the exported HTML carries the `{{coupon_<setId>}}` placeholder. The placeholder is the block's whole content and is rewritten from the chosen set, so a block sent with no children is filled in by the editor.\n\n## The block as the editor drops it\n\n```json\n{\n  \"type\": \"coupon-block\",\n  \"attributes\": {\n    \"data-coupon-block\": \"true\",\n    \"data-coupon-set\": \"42\",\n    \"data-coupon-name\": \"Summer sale\",\n    \"data-coupon-fallback\": \"SUMMER10\",\n    \"id\": \"iky7\"\n  },\n  \"couponSetId\": \"42\",\n  \"couponSetName\": \"Summer sale\",\n  \"couponFallback\": \"SUMMER10\",\n  \"droppable\": false,\n  \"editable\": false,\n  \"style\": {\n    \"padding\": \"10px\",\n    \"font-size\": \"22px\",\n    \"text-align\": \"center\"\n  },\n  \"components\": [\n    {\n      \"type\": \"textnode\",\n      \"content\": \"{{coupon_42}}\"\n    }\n  ]\n}\n```\n",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "coupon-block"
                    ]
                  },
                  "couponSetId": {
                    "type": "string",
                    "description": "Id of the coupon set the code is drawn from (`/api/v2/websites/{website_id}/coupon_sets`). Empty while no set is picked, and the block then renders a caption rather than a placeholder."
                  },
                  "couponSetName": {
                    "type": "string",
                    "description": "Name of that set. Only shown in the editor - it is what the Layer Manager appends to the block's name."
                  },
                  "couponFallback": {
                    "type": "string",
                    "description": "Code sent when the set has run out of codes."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "data-coupon-block": {
                            "type": "string",
                            "enum": [
                              "true"
                            ],
                            "description": "Marks the element as a coupon block when a saved template is parsed back."
                          },
                          "data-coupon-set": {
                            "type": "string",
                            "description": "Id of the coupon set, mirrored from `couponSetId`. Absent until a set is picked."
                          },
                          "data-coupon-name": {
                            "type": "string",
                            "description": "Name of that set, mirrored from `couponSetName`."
                          },
                          "data-coupon-fallback": {
                            "type": "string",
                            "description": "Code used when the set has run out of codes, mirrored from `couponFallback`. Written after `data-coupon-set`."
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentProductAdd": {
            "title": "product-add",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "One **dynamic product** - a product chosen when the message is built rather than when the template is written, so every recipient sees their own.\n\nIt is two things that have to stay together: this component, and the record it owns in `dataSources.products`, keyed by the component's own id. Everything below the component - the image, the name, the prices, the link - is bound to that record through `data-variable` paths, so nothing under it is typed in.\n\n## Where the product comes from\n\nThe record's `type` is what decides that, and it is the field that turns one `product-add` into \"the first thing in this recipient's basket\" rather than a fixed item. It is set in the panel through the component's `dynamic-product` attribute, and the record follows.\n\n| `type` | `dynamic-product.type.id` | What the recipient sees | Extra fields | The record's `product.productId` |\n|---|---|---|---|---|\n| `product` | `products` / `products_id` | A **static product** - one fixed item, the same for everyone, picked from the feed in the panel | - | The id the feed gives it |\n| `basket` | `baskets` | The product at `index` in their **abandoned basket** | `index` | `$sp.basket.<index>` |\n| `visit` | `visits` | The product at `index` among the ones they **last looked at** | `index` | `$sp.visit.<index>` |\n| `purchase` | `purchases` | The product at `index` among their **last purchases** | `index` | `$sp.purchase.<index>` |\n| `engine` | `engine_<engineId>` | The recommendation at `index` from **recommendation engine** `engineId` (`/api/v2/websites/{website_id}/engines`) | `index`, `engineId` | `$sp.recommend.<engineId>.<index>` |\n\n`index` is a string: the number the feed's own id ends with, counting from `1`. `basket_1` is the first entry `/products` offers for a basket, so the record it produces is `index` `\"1\"`, `$sp.basket.1`, and the param a save writes for it is `product_basket_1`. `productId` says where the record was bound, and a block the panel has not bound to a feed yet carries `$sp.null.<component id>` - that is the one value that names the component rather than a product.\n\n**A static product is the only one that renders as itself.** Its data stays in the record and is written into the export as literal values, and it produces no `manifest.params` entry. For every other type the save replaces the record with merge tags and writes one `manifest.params` entry - `params` in [MailCampaignManifest](#models/MailCampaignManifest) has the naming and the tags. That entry carries a mock product as its default, which is what the preview and the gallery thumbnail render.\n\nNothing promises the recipient actually has a product at that position, so a resolved product also gets `ut:delete-if` on its `productName`: the whole block disappears when there is none.\n\n## ut:delete-if\n\n`ut:delete-if` drops the element when the value it names is empty. Two rules, both easy to miss:\n\n- **One `{{param.field}}` per condition.** The engine strips the braces and splits what is left on the first dot, so a composed value such as `{{p.productPromoPrice}} {{p.productCurrency}}` never resolves and the element is dropped every time.\n- **Name a field that survives the save.** The save rewrites a resolved record down to `productImage`, `productName`, `productPriceCurrency`, `productPromoPriceCurrency` and `productUrl`. A `data-variable` on anything else - `productPromoPrice`, say - exports as an empty condition and the line is always dropped. The engine reads the condition from `params`, where the whole product does exist, so write those as the merge tag string itself instead of as a `data-variable`.\n\n## The image\n\n`product-image` sets its own `src`. It reads `productImage` off the record it follows and rewrites `src` and `alt` on load and on every change to that record - which is also how the merge tag gets into the export, once the save has swapped the record. Never write `src` by hand; it is overwritten.\n\nFor a template written outside the editor that matters twice over: the `productImage` in the **record** is what the editor canvas renders, and the canvas runs on the panel, so it has to be an absolute URL. A path relative to the template folder only resolves in the `manifest.params` default, which is rendered inside the exported page.\n\n## The block as the editor drops it\n\nDropping **Add product** produces the component below, and beside it one `dataSources.products` record keyed by the component's id.\n\n| Field | Required | What it is |\n|---|---|---|\n| `type` | yes | Always `product-add`. |\n| `productTitle` | no | Sample name written at the drop, read by nothing. |\n| `attributes.id` | yes | The component id. The `dataSources.products` record is keyed by it, and every `data-variable` path in the subtree repeats it. |\n| `attributes.dynamic-product.type` | once a source is picked | The feed (`/api/v2/websites/{website_id}/feeds`), kept as that endpoint returned it. `id` has to name a real feed: the panel reads it back as the component loads and replaces `name` with what the endpoint answers. |\n| `attributes.dynamic-product.product` | once a source is picked | The `productId` of the entry picked from that feed, in the form the feed returns it (`basket_1`). The record's `index` is derived from it. Listing that feed's entries means `GET /api/v2/websites/{website_id}/products?product_format=grape&feed=<type.id>` - `feed=baskets`, `feed=engine_10`, `feed=products_id` - the call the panel's own select makes; **`feed` is required**, and without it the endpoint answers with an empty list. `productName` may sit beside the id as the label the panel shows until the feed answers; nothing else under `product` is read. |\n| `attributes.ut:delete-if` | every source but a static `product` | Drops the whole block when the recipient has nothing at that position. |\n| `components` | yes | The subtree, bound to the record through `data-variable` paths - nothing in it is typed in. |\n\n```json\n{\n  \"type\": \"product-add\",\n  \"productTitle\": \"Sample Product\",\n  \"attributes\": {\n    \"id\": \"iuq8\",\n    \"dynamic-product\": {\n      \"type\": {\n        \"id\": \"baskets\",\n        \"name\": \"Products from cart\"\n      },\n      \"product\": {\n        \"productId\": \"basket_1\",\n        \"productName\": \"Product from cart\"\n      }\n    },\n    \"ut:delete-if\": {\n      \"path\": \"products.iuq8.product.productName\",\n      \"type\": \"data-variable\"\n    }\n  },\n  \"components\": [\n    {\n      \"tagName\": \"section\",\n      \"attributes\": {\n        \"align\": \"center\"\n      },\n      \"components\": [\n        {\n          \"type\": \"product-image\",\n          \"productId\": \"iuq8\",\n          \"attributes\": {\n            \"src\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n            \"alt\": \"Sample Product\",\n            \"align\": \"center\",\n            \"id\": \"imv5\"\n          }\n        }\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"editable\": false,\n      \"attributes\": {\n        \"id\": \"ijdi\"\n      },\n      \"content\": {\n        \"defaultValue\": \"Sample Product\",\n        \"path\": \"products.iuq8.product.productName\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"text\",\n      \"editable\": false,\n      \"attributes\": {\n        \"id\": \"i7ig\"\n      },\n      \"content\": {\n        \"path\": \"products.iuq8.product.productPriceCurrency\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"text\",\n      \"editable\": false,\n      \"attributes\": {\n        \"ut:delete-if\": {\n          \"path\": \"products.iuq8.product.productPromoPrice\",\n          \"type\": \"data-variable\"\n        },\n        \"id\": \"if0g\"\n      },\n      \"content\": {\n        \"path\": \"products.iuq8.product.productPromoPriceCurrency\",\n        \"type\": \"data-variable\"\n      }\n    },\n    {\n      \"type\": \"link\",\n      \"attributes\": {\n        \"href\": {\n          \"defaultValue\": \"#\",\n          \"path\": \"products.iuq8.product.productUrl\",\n          \"type\": \"data-variable\"\n        },\n        \"target\": \"_blank\",\n        \"id\": \"ixxu\"\n      },\n      \"content\": \"More\"\n    }\n  ]\n}\n```\n\nThe record beside it:\n\n```json\n{\n  \"id\": \"iuq8\",\n  \"type\": \"basket\",\n  \"index\": \"1\",\n  \"product\": {\n    \"productId\": \"$sp.basket.1\",\n    \"productUrl\": \"\",\n    \"productCurrency\": \"USD\",\n    \"updatedAt\": \"2020-01-01\",\n    \"productCategory\": \"Cart\",\n    \"productBrand\": \"Cart\",\n    \"productPrice\": \"10.00\",\n    \"productPromoPrice\": \"5.00\",\n    \"productImage\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n    \"productName\": \"Product from cart\",\n    \"productPriceCurrency\": \"10.00 USD\",\n    \"productPromoPriceCurrency\": \"5.00 USD\"\n  }\n}\n```\n\nBefore a source is picked the root carries neither `dynamic-product` nor `ut:delete-if`, and the record is still the mock the drop created - no `type`, no `index`, and a `$sp.null.<id>` `productId`:\n\n```json\n{\n  \"id\": \"iuq8\",\n  \"product\": {\n    \"productId\": \"$sp.null.iuq8\",\n    \"productUrl\": \"\",\n    \"productCurrency\": \"USD\",\n    \"updatedAt\": \"2020-01-01\",\n    \"productCategory\": \"\",\n    \"productBrand\": \"\",\n    \"productPrice\": \"10.00\",\n    \"productPromoPrice\": \"5.00\",\n    \"productImage\": \"https://assets.trustisto.com/assets/basket_product_placeholder.png\",\n    \"productName\": \"Sample Product\",\n    \"productPriceCurrency\": \"10.00 USD\",\n    \"productPromoPriceCurrency\": \"5.00 USD\"\n  }\n}\n```\n",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "product-add"
                    ]
                  },
                  "productTitle": {
                    "type": "string",
                    "description": "Sample product name the editor writes when the block is dropped. Carried through the save and read by nothing - the names on screen come from the `data-variable` bindings below the component."
                  },
                  "showTitle": {
                    "type": "string",
                    "enum": [
                      "true",
                      "false"
                    ],
                    "description": "Editor default, always `\"true\"`. Written into the project and read by nothing."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "dynamic-product": {
                            "type": "object",
                            "description": "The picked source and product.",
                            "properties": {
                              "type": {
                                "type": "object",
                                "description": "Feed the product comes from (`/api/v2/websites/{id}/feeds`). `products`/`products_id` mean a fixed product; `baskets`, `visits`, `purchases` and `engine_<engineId>` are resolved per recipient.",
                                "properties": {
                                  "id": {
                                    "type": "string"
                                  },
                                  "name": {
                                    "type": "string"
                                  }
                                }
                              },
                              "product": {
                                "type": "object",
                                "description": "The entry picked from that feed, as `productId` - `index` and the record's `$sp.` id are derived from it. `productName` may sit beside it as the label the panel's second select shows until the feed answers. Every other product field is replaced by that answer before anything reads it, so writing one here has no effect - the full shape belongs in the `dataSources` record, not in this attribute.",
                                "required": [
                                  "productId"
                                ],
                                "properties": {
                                  "productId": {
                                    "type": "string",
                                    "description": "The id the products endpoint returned - the feed's own id for a static product, `basket_<index>` / `visit_<index>` / `purchase_<index>` / `engine_<engineId>_<index>` for a dynamic one. Never the record's `$sp.` form.",
                                    "example": "basket_1"
                                  },
                                  "productName": {
                                    "type": "string"
                                  }
                                },
                                "additionalProperties": true
                              }
                            }
                          },
                          "ut:delete-if": {
                            "description": "Drop the element when the bound value is empty (products the recipient has no data for).",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          },
          "componentProductImage": {
            "title": "product-image",
            "allOf": [
              {
                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentBase"
              },
              {
                "type": "object",
                "description": "The image of a dynamic product, following the `dataSources` record of its `product-add`.\n\nIt has no default size of its own to fall back on - style it like any other component, through `style`.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "product-image"
                    ]
                  },
                  "productId": {
                    "type": "string",
                    "description": "Id of the `dataSources` record the image follows."
                  },
                  "attributes": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentAttributes"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "src": {
                            "description": "The product image. **Written by the component itself** - it reads `productImage` off the record it follows and rewrites `src` and `alt` on load and on every change to that record, which is also how the merge tag reaches the export. Do not set it by hand; it is overwritten.",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          },
                          "ut:delete-if": {
                            "description": "Drop the element when the bound value is empty.",
                            "oneOf": [
                              {
                                "type": "string"
                              },
                              {
                                "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/dataVariable"
                              }
                            ]
                          }
                        },
                        "additionalProperties": true
                      }
                    ]
                  }
                }
              }
            ]
          }
        },
        "properties": {
          "pages": {
            "type": "array",
            "description": "Always exactly one page with exactly one frame: a template is one message.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "frames": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "component": {
                        "description": "The `wrapper` component - root of the tree, the `<body>` of the exported message.",
                        "allOf": [
                          {
                            "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/componentWrapper"
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "styles": {
            "type": "array",
            "description": "The project stylesheet. The editor styles the selected component itself, so nearly every rule targets one component's `#id`.\n\n**Style a component through its own `style`, not through a rule written here.** `product-add` carries a default style of its own, which the editor stamps onto every component of that type as the project loads - restored components included - as an `#id` rule appended after this stylesheet, and it beats both a `.class` rule (on specificity) and a rule written here for that same `#id` (on order). A component's own `style` is that same `#id` rule, so it replaces the default instead of competing with it.\n\nWhat is left for this stylesheet is what cannot be said on a component: `state` rules (`hover`, ...), media queries, and anything targeting a selector no single component owns.",
            "items": {
              "type": "object",
              "properties": {
                "selectors": {
                  "type": "array",
                  "description": "Class/id selectors, as names or as full selector objects.",
                  "items": {
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "object",
                        "additionalProperties": true
                      }
                    ]
                  }
                },
                "selectorsAdd": {
                  "type": "string",
                  "description": "Raw selector appended to the generated one - the `#id` of the styled component.",
                  "example": "#i3kd"
                },
                "style": {
                  "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/style"
                },
                "state": {
                  "type": "string",
                  "description": "Pseudo state (`hover`, `active`, ...). Empty for the plain rule."
                },
                "mediaText": {
                  "type": "string",
                  "description": "Media query - mail keeps the desktop/tablet/mobile previews, so rules can be device specific.",
                  "example": "(max-width: 480px)"
                },
                "atRuleType": {
                  "type": "string"
                },
                "important": {
                  "type": "boolean"
                }
              },
              "additionalProperties": true
            }
          },
          "assets": {
            "type": "array",
            "description": "Asset Manager entries - images picked through the panel media modal, so `src` points at the media bucket.",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "enum": [
                    "image"
                  ],
                  "default": "image"
                },
                "src": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "width": {
                  "type": "number"
                },
                "height": {
                  "type": "number"
                },
                "unitDim": {
                  "type": "string",
                  "example": "px"
                }
              },
              "additionalProperties": true
            }
          },
          "dataSources": {
            "type": "array",
            "description": "Only one is used, `products`: one record per `product-add` component, keyed by that component's id. `type` other than `product` means the product is resolved per recipient and gets a `manifest.params` entry on save.\n\n**Half of it lives on the campaign.** A record whose `type` is not `product` only produces products if the campaign using this template also lists it in `options.metaData` - a field of the campaign, not of this document. See the campaign endpoints for what it has to contain.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "enum": [
                    "products"
                  ]
                },
                "records": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "required": [
                      "id",
                      "product"
                    ],
                    "properties": {
                      "id": {
                        "type": "string",
                        "description": "Id of the `product-add` component owning the record."
                      },
                      "product": {
                        "$ref": "#/components/schemas/MailCampaignTemplate/x-defs/product"
                      },
                      "type": {
                        "type": "string",
                        "description": "Where the product comes from - the field that decides whether it is resolved per recipient at all.\n\n- `product` - a **static product**: one fixed item, the same for everyone, written into the export as literal values. No `index`, and no manifest param.\n- `basket` - the product at `index` in the recipient's abandoned basket.\n- `visit` - the product at `index` among the ones they last looked at.\n- `purchase` - the product at `index` among their last purchases.\n- `engine` - the recommendation at `index` from the engine named by `engineId`.\n\nWhich of these a website can actually use is what `GET /api/v2/websites/{website_id}/feeds` returns; a source it does not list resolves to nothing.\n\nAbsent on a block that has not been bound to a source yet. See [product-add](#models/MailCampaignTemplate) for the matching `dynamic-product.type.id` and the param each one writes.",
                        "enum": [
                          "product",
                          "basket",
                          "visit",
                          "purchase",
                          "engine"
                        ]
                      },
                      "index": {
                        "type": "string",
                        "description": "Position within the recipient's basket / last seen / last purchased products, or within the engine's recommendations. A string, counting from `0`. Absent for a static `product`."
                      },
                      "engineId": {
                        "type": "string",
                        "description": "Recommendation engine id (`/api/v2/websites/{website_id}/engines`) - only for `engine`, where it is also part of the param name (`product_engine_<engineId>_<index>`)."
                      }
                    }
                  }
                }
              }
            }
          },
          "symbols": {
            "type": "array",
            "description": "Reusable component instances (symbols). Unused here.",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          }
        },
        "additionalProperties": true
      },
      "WebCampaignManifest": {
        "type": "object",
        "description": "The manifest of a **web** campaign template (popup/topbar) - stored as `manifest.json` and sent as the `manifest` field of `POST/PUT /api/v2/organizations/{organization_id}/templates`, next to the project data described by [WebCampaignTemplate](#models/WebCampaignTemplate).\n\nIt tells the panel what the template is: which editor opens it, what size it is, and what it needs at runtime.\n\nOnly `name`, `type` and `editable` are set server side. Everything else is regenerated from the project on every save, so a template created through the API has to arrive with the same values the project implies: `width` / `height` from the size of the campaign, `form` from the inputs of its form, `countdown` from the presence of a countdown component, `params` from its dynamic products.",
        "properties": {
          "name": {
            "type": "string",
            "description": "Set server side from the template name on every save."
          },
          "type": {
            "type": "string",
            "description": "Set server side from `template_type`. The `*_template` values are the campaign scoped copies the drag and drop editors save; the plain ones are reusable organization templates.",
            "enum": [
              "popup",
              "popup_template",
              "topbar",
              "topbar_template"
            ]
          },
          "version": {
            "type": "number",
            "description": "Template format version - greater than 3 for templates of the current editor.",
            "example": 4
          },
          "editable": {
            "description": "Set server side: `true` for a reusable organization template, the template's own id for a campaign scoped one (so the editor updates it in place instead of copying it).",
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "integer"
              }
            ]
          },
          "width": {
            "type": "integer",
            "description": "Popup width in px. Absent for topbars - they span the full page width, which is why the editor offers them a height only.",
            "example": 600
          },
          "height": {
            "type": "integer",
            "description": "Height in px.",
            "example": 400
          },
          "mobile-breakpoint": {
            "type": "integer",
            "description": "Viewport width below which the mobile height is used."
          },
          "mobile-height": {
            "type": "integer",
            "description": "Height in px below that breakpoint."
          },
          "form": {
            "type": "object",
            "description": "Written whenever the template contains a form, regenerated from its inputs on every save.",
            "properties": {
              "fields": {
                "type": "object",
                "description": "Submitted field name (the input's `name`) mapped to its value type. The internally handled `consent` field is left out; checkboxes are always `bool`.",
                "additionalProperties": {
                  "type": "string",
                  "enum": [
                    "string",
                    "number",
                    "date",
                    "bool"
                  ]
                },
                "example": {
                  "email": "string",
                  "details.firstname": "string",
                  "marketing": "bool"
                }
              },
              "options": {
                "type": "object",
                "properties": {
                  "closeOnSubmit": {
                    "type": "boolean",
                    "description": "Close the campaign after a successful submit. If not present, defaults to true.",
                    "default": true
                  }
                }
              }
            }
          },
          "countdown": {
            "type": "boolean",
            "description": "Written when the template contains a countdown.",
            "enum": [
              true
            ]
          },
          "params": {
            "type": "object",
            "description": "Runtime parameters - the values resolved per visitor when the campaign is shown. One entry per dynamic product: a `dataSources` record whose `type` is not `product` becomes `product_<type>_<index>`, or `product_engine_<engineId>_<index>` for an engine, described as `{\"type\": \"product\", \"section\": \"\", \"label\": \"\", \"required\": true, \"default\": {\"en\": <mock product>}}` - the mock product is what previews are rendered from.\n\nThe product data in the project is swapped for the matching merge tags at the same time: `{{<name>.productImage}}`, `{{<name>.productName}}`, `{{<name>.productUrl}}`, `{{<name>.productPrice}} {{<name>.productCurrency}}` as `productPriceCurrency` and `{{<name>.productPromoPrice}} {{<name>.productCurrency}}` as `productPromoPriceCurrency`.\n\nEmpty when the campaign has no dynamic content.",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "params-sections": {
            "type": "object",
            "description": "Groups of `params` in the params form. Always empty for templates of the current editor.",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": true
            }
          }
        },
        "additionalProperties": true
      },
      "MailCampaignManifest": {
        "type": "object",
        "description": "The manifest of a **mail** campaign template - stored as `manifest.json` and sent as the `manifest` field of `POST/PUT /api/v2/organizations/{organization_id}/templates`, next to the project data described by [MailCampaignTemplate](#models/MailCampaignTemplate).\n\nIt tells the panel what the template is: which editor opens it, and what it needs when the mail is built.\n\nOnly `name`, `type` and `editable` are set server side. `params` is regenerated from the project on every save, so a template created through the API has to arrive with the entries its content implies.",
        "properties": {
          "name": {
            "type": "string",
            "description": "Set server side from the template name on every save."
          },
          "type": {
            "type": "string",
            "description": "Set server side from `template_type`. The `*_template` values are the campaign scoped copies the drag and drop editors save; the plain ones are reusable organization templates.",
            "enum": [
              "mail",
              "mail_template"
            ]
          },
          "version": {
            "type": "number",
            "description": "Template format version",
            "example": 4
          },
          "editable": {
            "description": "Set server side: `true` for a reusable organization template, the template's own id for a campaign scoped one (so the editor updates it in place instead of copying it).",
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "integer"
              }
            ]
          },
          "params": {
            "type": "object",
            "description": "Runtime parameters - the values resolved per recipient when the mail is built. A mail always carries `mailFooter` (`{\"type\": \"text\", \"section\": \"\", \"label\": \"\", \"required\": true, \"html-friendly\": true, \"default\": {\"en\": \"mail footer\"}}`), and on top of that:\n\n- **Dynamic products** - a `dataSources` record whose `type` is not `product` becomes `product_<type>_<index>`, or `product_engine_<engineId>_<index>` for an engine, described as `{\"type\": \"product\", \"section\": \"\", \"label\": \"\", \"required\": true, \"default\": {\"en\": <mock product>}}`. The product data in the project is swapped for the matching merge tags at the same time: `{{<name>.productImage}}`, `{{<name>.productName}}`, `{{<name>.productUrl}}`, `{{<name>.productPrice}} {{<name>.productCurrency}}` as `productPriceCurrency` and `{{<name>.productPromoPrice}} {{<name>.productCurrency}}` as `productPromoPriceCurrency`.\n- **Coupons** - one `coupon_<setId>` per `{{coupon_<setId>}}` in the exported HTML - from a `coupon-tag` chip or a `coupon-block` alike, and one entry however many times the set appears - `required` false, `default.en` being `$sp.coupon.<setId>` - or `$sp.coupon.<setId>::<fallback>` when the element carrying that set also carries a `data-coupon-fallback`, written after its `data-coupon-set`.\n- **Client fields** - one entry per `{{trustistoClient<Field>}}` text, `required` false, `default.en` being `$sp.client.<field>` with the field name lowercased.",
            "additionalProperties": {
              "type": "object",
              "description": "One runtime parameter, as the panel form reads it.",
              "properties": {
                "type": {
                  "type": "string",
                  "description": "What the value is - `text` for the footer, `product` for a dynamic product."
                },
                "section": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "required": {
                  "type": "boolean",
                  "description": "Whether the panel makes the user fill it in."
                },
                "html-friendly": {
                  "type": "boolean",
                  "description": "Insert the value as markup instead of escaping it."
                },
                "default": {
                  "type": "object",
                  "description": "Default value per locale, keyed by ISO 639-1 code."
                }
              },
              "additionalProperties": true
            },
            "required": [
              "mailFooter"
            ]
          },
          "params-sections": {
            "type": "object",
            "description": "Groups of `params` in the params form. Always empty for templates of the current editor.",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": true
            }
          }
        },
        "additionalProperties": true
      }
    }
  },
  "security": [
    {
      "UserToken": [],
      "UserEmail": []
    }
  ]
}