openapi: 3.0.3
info:
  title: Trackless Ingest API
  version: "1.0.0"
  description: |
    Server-side ingest API for Trackless - cookieless e-commerce analytics. Send a batch of visits,
    orders and events as JSON. PrestaShop, WordPress/WooCommerce, Joomla / VirtueMart, OpenCart, Magento 2, Shopware 6 and Sylius
    have ready-made modules and plugins; any other platform integrates directly against this endpoint. A
    read-only export API (GET /api/v1/...) is also included. Numeric values may be
    sent as JSON numbers or strings (the API normalizes them). All collections are optional - send
    what you have.

    ## Authentication: HMAC signature and indexed routing

    `POST /ingest` is authenticated by an **HMAC-SHA256 signature of the exact raw request body**,
    keyed by your secret account `api_key` (Trackless → My account). There is no Authorization
    header and no session:

    1. Build the JSON body and keep its **exact bytes** - do not re-encode, pretty-print or let a
       proxy modify it afterwards.
    2. Compute the non-secret routing id and send it as `X-Shaim-Account`:
       PHP `substr(hash('sha256', $apiKey), 0, 24)` ·
       Node `crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 24)`
    3. Compute the lowercase-hex signature:
       PHP `hash_hmac('sha256', $body, $apiKey)` ·
       Node `crypto.createHmac('sha256', apiKey).update(body).digest('hex')`
    4. Send the body with `Content-Type: application/json`, `X-Shaim-Account: <routing-id>` and
       `X-Shaim-Signature: <signature>`.

    The server recomputes the signature over the bytes it received, so any modification in transit
    invalidates it. The routing id is public and is never accepted as proof by itself. New clients
    have an 8 MB pre-authentication body limit. A 2 MB legacy route remains for clients without
    `X-Shaim-Account`, but it considers only exact indexed domain candidates: an existing shop or a
    pre-created account named after that domain. Responses: `400` = missing body/signature or invalid JSON, `403` = signature
    mismatch or unknown api_key, `409` = the api_key was reused on a second e-shop (permanent -
    fix the key, don't retry), `413` = body too large, `200` only **after a durable commit**.
    Storage is idempotent, so
    re-sending is always safe; but only `429`, `5xx` and network errors are worth retrying - `4xx`
    are permanent and need a fix, not a loop.

    Because the signature covers the exact body, the **"Try it out" feature is disabled here** -
    Swagger UI cannot compute it for you. Start from the ready-made
    [PHP example](/api/example) or the [full documentation](/api).

    `POST /collect` (the JS tracker beacon) is intentionally **unauthenticated**: the `tlc_` token
    in its body is public and only identifies the destination account. An accepted `order` is stored
    with `order_verification=browser_unverified`: it appears in normal order, revenue and conversion
    analytics, but is excluded from billing and the pricing band. Orders sent through HMAC-authenticated
    `POST /ingest` are assigned `order_verification=server_verified`. The server assigns this field;
    clients must not send it. Never put your secret `api_key` into a browser.
  contact:
    name: Trackless
    email: info@trackless.cz
servers:
  - url: https://trackless.cz
paths:
  /ingest:
    post:
      summary: Ingest one batch of analytics data
      description: >
        Stores one batch idempotently in a single transaction (insert-ignore for immutable rows,
        upsert for orders/clients). Return/treat 200 as success only after a confirmed store. Retrying
        is safe (idempotent), but retry only 429/5xx/network failures; 400/403/409/413 are permanent
        client-side errors - fix the request or the key instead of looping. X-Shaim-Account selects
        one indexed candidate; the HMAC signature remains the only authentication proof. Orders in
        the signed batch are assigned `order_verification=server_verified` by Trackless.
      operationId: ingestBatch
      security:
        - ShaimSignature: []
      parameters:
        - name: X-Shaim-Account
          in: header
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$', example: 1f4b3a7c89d0123456789abc }
          description: >
            Public, non-secret routing id: the first 24 lowercase hex characters of sha256(api_key).
            Required for new integrations. Legacy requests without it use only exact indexed domain
            candidates and a smaller 2 MB body limit; the server never scans accounts. This header
            never replaces X-Shaim-Signature.
        - name: X-Shaim-Module
          in: header
          required: false
          schema: { type: string, example: woocommerce }
          description: Identifier of your integration (audit only).
        - name: X-Shaim-Version
          in: header
          required: false
          schema: { type: string, example: "1.0.0" }
          description: Version of your integration (audit only).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Batch'
      responses:
        '200':
          description: >
            Batch stored durably. The JSON body carries the account's central IP-exclusion list
            (`excluded_ips`, exact IPs and CIDR ranges) so the integration can drop the merchant's
            own traffic locally - it sees the real client IP, whereas the server only ever receives a
            salted hash. It also carries `geoip`, a freshness pointer to the public IP→country database
            the integration reads locally (so it can resolve visitor country on hosting with no geo
            header). Integrations that ignore the body keep working unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  excluded_ips:
                    type: array
                    items: { type: string }
                    example: ["203.0.113.7", "10.0.0.0/8"]
                  geoip:
                    type: object
                    nullable: true
                    description: >
                      Pointer to the current IP→country database (DB-IP Lite, CC BY 4.0). Compare
                      `sha256` to your cached copy and re-download `url` only when it changed. null until
                      the first server-side build. Same data as GET /api/geoip/country.json.
                    properties:
                      version: { type: string, example: "2026-06" }
                      sha256: { type: string }
                      gz_sha256: { type: string }
                      url: { type: string, example: "https://trackless.cz/api/geoip/country.dat.gz" }
        '400':
          description: Missing body/signature or invalid JSON.
        '403':
          description: Signature mismatch or unknown tenant.
        '409':
          description: >
            The api_key was reused on a second e-shop (a different shop domain under the same shop id).
            Nothing was stored. Permanent config conflict, not a transient error - do not retry; use a
            separate api_key per website. The response body states exactly what to do.
        '413':
          description: Request body too large (8 MB limit) - split the batch into smaller requests.
        '429':
          description: Rate limit exceeded - retry after a short delay.
        '500':
          description: Storage failed - resend the batch.
  /collect:
    post:
      summary: Browser traffic and funnel beacon (JS tracker)
      description: >
        Endpoint of the t.js tracking snippet for hosted platforms and custom sites without a
        server-side module. Call it from the VISITOR'S BROWSER only - the visitor identity is
        derived server-side from the request IP + User-Agent (one-way, daily-rotating hash), so a
        server-side caller would count as a single visitor; server-side integrations belong on
        /ingest. The tlc_ token is public and only identifies the destination account. Origin/Referer
        may scope a browser hit to a declared site, but cannot authenticate the client-controlled event
        body, order id or amount. An accepted order event therefore creates a stored order with
        `order_verification=browser_unverified`. It appears in normal order, revenue and conversion
        analytics, but is excluded from billing and the pricing band. A later signed order with the same
        natural key upgrades the row to `server_verified`; browser retries cannot overwrite signed data.
        For an account configured as Shopify, Trackless accepts the known Shopify sandbox origins and
        an origin-less sandbox request because the custom pixel uses the sandbox on live stores too.
        This acceptance does not verify the payload: the order remains `browser_unverified`. Shopify has
        no server-side app/webhook authentication in this custom-pixel connection.
        The body is sent as text/plain to avoid a CORS preflight, but it is the JSON below. The
        production snippet does not need `data-endpoint`; t.js defaults to https://trackless.cz/collect.
        Use `data-endpoint` only as an override for testing, staging or a custom endpoint. The response
        is always 204, including for unknown tokens (no token oracle). Rate limits: 300/min per token+IP,
        plus a coarse daily per-token ceiling.
      operationId: collectHit
      security: []
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              $ref: '#/components/schemas/CollectHit'
      responses:
        '204':
          description: Accepted (or silently dropped - bot, opt-out, unknown token).
        '429':
          description: Rate limit exceeded for this token+IP.
  /api/geoip/country.json:
    get:
      summary: IP→country database metadata (freshness pointer)
      description: >
        Small JSON describing the current public IP→country database (DB-IP Lite, CC BY 4.0) that the
        tracking integrations download and read locally to resolve visitor country on hosting without a
        geo header (no Cloudflare / GeoIP module). The integrations poll this ~weekly, compare
        `sha256`/`version` to their cached copy, and fetch country.dat.gz only when it changed. Public
        and cacheable; 503 until the first server-side build.
      operationId: geoipMeta
      responses:
        '200':
          description: Current database metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "2026-06", description: Source month (YYYY-MM). }
                  sha256: { type: string, description: SHA-256 of the decompressed .dat (verify after gunzip). }
                  gz_sha256: { type: string, description: SHA-256 of the gzip download (verify before gunzip). }
                  size: { type: integer, example: 15317629 }
                  gz_size: { type: integer, example: 4372274 }
                  count4: { type: integer, example: 355800, description: Number of IPv4 ranges. }
                  count6: { type: integer, example: 345871, description: Number of IPv6 ranges. }
                  built_at: { type: string, format: date-time }
                  attribution: { type: string, example: "IP Geolocation by DB-IP (https://db-ip.com), CC BY 4.0" }
        '503':
          description: Not built yet - retry later.
  /api/geoip/country.dat.gz:
    get:
      summary: IP→country database (gzip)
      description: >
        The gzipped IP→country database. Decompressed layout: "DBIPC1" + 1B flags + uint32-BE count4 +
        uint32-BE count6, then count4×(4B start + 4B end + 2B ISO) ascending, then count6×(16B + 16B +
        2B) ascending. Verify the SHA-256 from country.json before use, then resolve an IP with a binary
        search over the fixed-width ranges (compare inet_pton bytes; the 2-byte ISO of the matching
        range is the country). Public CC-BY data (DB-IP Lite); attribute "IP Geolocation by DB-IP
        (https://db-ip.com)".
      operationId: geoipDat
      responses:
        '200':
          description: gzip stream (application/gzip).
        '503':
          description: Not built yet - retry later.
  /api/geoip/hosting.json:
    get:
      summary: IP→hosting database metadata (freshness pointer)
      description: >
        Sibling of country.json for the datacentre/VPN range database the integrations use to set
        `is_hosting` on the client row. The server never sees the visitor IP - only `ip_hash` - so the
        reputation of an IP has to be resolved by the connector locally. Poll this, compare
        `sha256`/`version` to the cached copy, and fetch hosting.dat.gz plus hosting-networks.json
        together only when they changed. Public and cacheable; 503 until the first server-side build.
      operationId: geoipHostingMeta
      responses:
        '200':
          description: Current database metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "2026-06", description: Source month (YYYY-MM). }
                  sha256: { type: string, description: SHA-256 of the decompressed .dat (verify after gunzip). }
                  size: { type: integer, example: 3184912 }
                  count4: { type: integer, example: 92140, description: Number of IPv4 ranges. }
                  count6: { type: integer, example: 41077, description: Number of IPv6 ranges. }
                  built_at: { type: string, format: date-time }
                  source: { type: string, description: Upstream dataset the build was made from. }
                  attribution: { type: string, example: "IP Geolocation by DB-IP (https://db-ip.com), CC BY 4.0" }
                  gz_size: { type: integer, example: 921004 }
                  gz_sha256: { type: string, description: SHA-256 of the gzip download (verify before gunzip). }
                  networks: { type: integer, example: 4820, description: Number of distinct networks in hosting-networks.json. }
                  networks_size: { type: integer, example: 214880 }
                  networks_sha256: { type: string, description: SHA-256 of hosting-networks.json for the same build. }
                description: >
                  Unlike country.json this document carries no `url` key. Hardcode the
                  /api/geoip/hosting.dat.gz fallback - two independent connector ports got this wrong.
        '503':
          description: Not built yet - retry later.
  /api/geoip/hosting.dat.gz:
    get:
      summary: IP→hosting database (gzip)
      description: >
        The gzipped datacentre/VPN range database, magic "DBIPH1", otherwise the same fixed-width
        layout and binary-search access as country.dat. Built from DB-IP ASN Lite: ranges whose ASN is
        hosting, cloud or VPN and which are not on the consumer-ISP protection list, with Apple iCloud
        Private Relay egress ranges cut back out - Private Relay exits through Cloudflare, Akamai and
        Fastly, so the site sees a partner IP rather than Apple's. Verify the SHA-256 from hosting.json
        before use. Fetch together with hosting-networks.json: the 2-byte network index is assigned per
        build, so pairing files from two builds names the wrong network.
      operationId: geoipHostingDat
      responses:
        '200':
          description: gzip stream (application/gzip).
        '503':
          description: Not built yet - retry later.
  /api/geoip/hosting-networks.json:
    get:
      summary: Network index for the hosting database
      description: >
        Maps the 2-byte index carried by every hosting.dat range back to its network, so a connector can
        report WHICH network flagged a visitor instead of only that one did - the difference between
        answering "why was this customer counted as a bot" and guessing. Belongs to exactly one build of
        hosting.dat; refresh both in the same step and verify `networks_sha256` from hosting.json.
      operationId: geoipHostingNetworks
      responses:
        '200':
          description: Index → network map for the current hosting.dat build.
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    asn: { type: integer, example: 24940 }
                    org: { type: string, example: "Hetzner Online GmbH" }
        '503':
          description: Not built yet - retry later.
  /api/geoip/cdn.json:
    get:
      summary: CDN edge ranges (metadata and list in one document)
      description: >
        Edge ranges a connector must match the peer IP against before it trusts a CDN client-IP header.
        Unlike its two siblings this is a single small JSON - meta and data together - because a separate
        .dat.gz would be noise. Verify `sha256` over implode("\n", ranges). Reject a list under 15 ranges
        or one carrying a mask wider than /12 (v4) or /24 (v6): a wide mask would open the gate to half
        the internet. A missing or corrupt list means an empty array, the header is ignored and the peer
        IP applies - the same behaviour as outside a CDN, never a failure. Read only `CF-Connecting-IP`;
        never read `X-Forwarded-For`, whose cheapest entry is the forged one.
      operationId: geoipCdn
      responses:
        '200':
          description: Current edge ranges and their metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, format: date, example: "2026-06-01" }
                  built_at: { type: string, format: date-time }
                  count: { type: integer, example: 22 }
                  count4: { type: integer, example: 15 }
                  count6: { type: integer, example: 7 }
                  sha256: { type: string, description: SHA-256 of the ranges joined by a newline. }
                  ranges:
                    type: array
                    items: { type: string, example: "104.16.0.0/13" }
                  sources:
                    type: array
                    items: { type: string, format: uri }
                  attribution: { type: string }
        '503':
          description: Not built yet - retry later.
  /api/v1:
    get:
      summary: Read API index (metadata)
      description: Self-describing index of the read-only export API - version, endpoints and params. No auth required.
      operationId: readApiIndex
      responses:
        '200':
          description: API metadata.
  /api/v1/export/all:
    get:
      summary: Export everything (KPIs + all reports)
      description: >
        One JSON document with the account's KPIs, traffic, behaviour, profit and cancelled-orders
        blocks plus every report. Alongside the totals, `kpis` contains the flat fields
        `server_verified_orders`, `server_verified_revenue`, `browser_unverified_orders` and
        `browser_unverified_revenue`. Read-only; authenticated by a Bearer read token (Account → My
        account), separate from the ingest api_key. Same date/range params as the dashboard.
      operationId: readExportAll
      security:
        - ReadToken: []
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - $ref: '#/components/parameters/Shop'
        - $ref: '#/components/parameters/Basis'
        - $ref: '#/components/parameters/Attr'
        - $ref: '#/components/parameters/Vat'
      responses:
        '200':
          description: KPIs + reports for the range.
        '401':
          description: Missing or invalid read token.
        '402':
          description: The account subscription does not currently allow read API access.
  /api/v1/export/{report}:
    get:
      summary: Export a single report
      description: >
        One report response with `account`, `report`, `range`, `rows` and an `order_verification`
        summary shaped as `{server_verified:{orders,revenue}, browser_unverified:{orders,revenue}}`.
        Same auth and range params as /api/v1/export/all.
      operationId: readExportReport
      security:
        - ReadToken: []
      parameters:
        - name: report
          in: path
          required: true
          schema:
            type: string
            enum: [pages, products, channels, categories, campaigns, search, events, devices, os, browsers, countries]
          description: Which report to export.
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - $ref: '#/components/parameters/Shop'
        - $ref: '#/components/parameters/Basis'
        - $ref: '#/components/parameters/Attr'
        - $ref: '#/components/parameters/Vat'
      responses:
        '200':
          description: One report plus the order-verification summary for the effective range.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleReportResponse'
        '401':
          description: Missing or invalid read token.
        '402':
          description: The account subscription does not currently allow read API access.
  /api/v1/bi/daily:
    get:
      summary: Flat daily metrics for BI tools
      description: >
        Returns one zero-filled row per calendar day, ordered ascending over the inclusive requested
        range. Stable English keys and JSON numbers make this endpoint suitable for Looker Studio,
        Power BI and custom reporting clients. Read-only; authenticated by the same per-account Bearer
        read token as the export endpoints. Gross-profit metrics always use tax-exclusive product-line
        economics; they do not change with the selected headline revenue basis. Account-level ad costs
        are not allocated to shops or countries, so ad_cost and roas are null when either filter is used.
      operationId: readBiDaily
      security:
        - ReadToken: []
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - $ref: '#/components/parameters/Shop'
        - $ref: '#/components/parameters/Basis'
        - $ref: '#/components/parameters/Attr'
        - $ref: '#/components/parameters/Vat'
      responses:
        '200':
          description: Account metadata, effective filters and one flat metrics row per day.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BiDailyResponse'
        '401':
          description: Missing or invalid read token.
        '402':
          description: The account subscription does not currently allow read API access.
components:
  securitySchemes:
    ShaimSignature:
      type: apiKey
      in: header
      name: X-Shaim-Signature
      description: >
        Not a static key: HMAC-SHA256 of the exact raw request body, keyed by your secret account
        api_key, lowercase hex - recomputed for every request. Example (PHP):
        hash_hmac('sha256', $rawBody, $apiKey). Do not modify the body after signing. Full
        walkthrough: see the "Authentication" section at the top of this document.
    ReadToken:
      type: http
      scheme: bearer
      description: >
        Per-account read-only token (Account → My account), separate from the ingest api_key and
        revocable. Send as `Authorization: Bearer <token>`. X-Api-Key and ?token= are also accepted,
        but Bearer is recommended - ?token= can leak into proxy/server logs and browser history.
  parameters:
    From:
      name: from
      in: query
      required: false
      schema: { type: string, format: date, example: "2026-05-01" }
      description: "Range start (YYYY-MM-DD). Default: 29 days ago."
    To:
      name: to
      in: query
      required: false
      schema: { type: string, format: date, example: "2026-05-31" }
      description: "Range end (YYYY-MM-DD). Default: today."
    Shop:
      name: shop
      in: query
      required: false
      schema: { type: integer }
      description: Shop id (optional; multishop).
    Basis:
      name: basis
      in: query
      required: false
      schema: { type: string, enum: [products, total] }
      description: Revenue basis - products = goods after discounts, excluding shipping/wrapping/non-product fees; total = full order amount, net of refunds.
    Attr:
      name: attr
      in: query
      required: false
      schema: { type: string, enum: [lt, ft] }
      description: Attribution - last-touch (lt) or first-touch (ft).
    Vat:
      name: vat
      in: query
      required: false
      schema: { type: integer, enum: [0, 1] }
      description: Revenue incl. VAT (1) or excl. (0).
  schemas:
    OrderVerificationSummary:
      type: object
      required: [server_verified, browser_unverified]
      properties:
        server_verified:
          type: object
          description: HMAC-authenticated or otherwise server-side verified orders.
          required: [orders, revenue]
          properties:
            orders: { type: integer, example: 10 }
            revenue: { type: number, format: double, example: 16400.5 }
        browser_unverified:
          type: object
          description: Browser-declared orders shown in normal analytics but excluded from billing and the pricing band.
          required: [orders, revenue]
          properties:
            orders: { type: integer, example: 2 }
            revenue: { type: number, format: double, example: 2240 }
    SingleReportResponse:
      type: object
      required: [account, report, range, order_verification, rows]
      properties:
        account:
          type: object
          additionalProperties: true
        report: { type: string, example: channels }
        range:
          type: object
          required: [from, to, countries]
          properties:
            from: { type: string, format: date, example: "2026-05-01" }
            to: { type: string, format: date, example: "2026-05-31" }
            countries:
              type: array
              items: { type: string, minLength: 2, maxLength: 2, example: CZ }
        order_verification:
          $ref: '#/components/schemas/OrderVerificationSummary'
        rows:
          type: array
          items:
            type: object
            additionalProperties: true
    BiDailyResponse:
      type: object
      required: [account, range, filters, rows]
      properties:
        account:
          type: object
          required: [id, name]
          properties:
            id: { type: string, example: "acc_7f6d2b9a" }
            name: { type: string, example: "Můj e-shop" }
        range:
          type: object
          required: [from, to]
          properties:
            from: { type: string, format: date, example: "2026-05-01" }
            to: { type: string, format: date, example: "2026-05-31" }
        filters:
          type: object
          required: [shop, attribution, basis, with_vat, countries]
          properties:
            shop:
              type: integer
              nullable: true
              description: Selected shop id, or null for all shops in the account.
            attribution: { type: string, enum: [lt, ft], example: lt }
            basis: { type: string, enum: [products, total], example: products }
            with_vat: { type: boolean, example: false }
            countries:
              type: array
              items: { type: string, minLength: 2, maxLength: 2, example: CZ }
              description: Account-wide visitor-country allow-list; empty means all countries.
        rows:
          type: array
          description: One row for every day in the inclusive range, including days with zero activity.
          items:
            $ref: '#/components/schemas/BiDailyRow'
    BiDailyRow:
      type: object
      required: [date, sessions, visitors, pageviews, orders, revenue, server_verified_orders, server_verified_revenue, browser_unverified_orders, browser_unverified_revenue, refunds, average_order_value, conversion_rate, gross_profit, gross_profit_revenue, gross_margin_pct, cost_lines_with_cost, cost_lines_total, cost_coverage_pct, ad_cost, roas]
      properties:
        date: { type: string, format: date, example: "2026-05-01" }
        sessions: { type: integer, example: 326 }
        visitors: { type: integer, example: 281 }
        pageviews: { type: integer, example: 864 }
        orders: { type: integer, example: 12 }
        revenue: { type: number, format: double, example: 18640.5 }
        server_verified_orders: { type: integer, example: 10, description: HMAC-authenticated or otherwise server-side verified orders. }
        server_verified_revenue: { type: number, format: double, example: 16400.5, description: Revenue from server_verified orders. }
        browser_unverified_orders: { type: integer, example: 2, description: Browser-declared orders included in analytics but excluded from billing and the pricing band. }
        browser_unverified_revenue: { type: number, format: double, example: 2240, description: Revenue from browser_unverified orders. }
        refunds: { type: number, format: double, example: 1290 }
        average_order_value: { type: number, format: double, example: 1553.38 }
        conversion_rate: { type: number, format: double, example: 3.68, description: Conversion rate as a 0-100 percentage. }
        gross_profit: { type: number, format: double, example: 6214.25, description: Tax-exclusive product-line gross profit. }
        gross_profit_revenue: { type: number, format: double, example: 17148.5, description: Tax-exclusive covered product-line revenue used to aggregate gross margin correctly. }
        gross_margin_pct: { type: number, format: double, example: 36.24, description: Gross margin as a 0-100 percentage over covered product-line revenue. }
        cost_lines_with_cost: { type: integer, example: 44, description: "Order lines with a real purchase cost, used for weighted aggregation." }
        cost_lines_total: { type: integer, example: 48, description: "All eligible order lines, used for weighted aggregation." }
        cost_coverage_pct: { type: number, format: double, example: 91.67, description: "Share of order lines with a real purchase cost, as a 0-100 percentage." }
        ad_cost:
          type: number
          format: double
          nullable: true
          example: 2400
          description: Account-level ad cost; null when a shop or country filter is used.
        roas:
          type: number
          format: double
          nullable: true
          example: 7.77
          description: Revenue divided by ad cost; null when ad_cost is zero or a shop/country filter is used.
    Decimal:
      description: 'Monetary/decimal value. Send as a JSON string ("1290.00", preferred - no float rounding) or a number; the API accepts both.'
      oneOf:
        - type: string
          example: "1290.00"
        - type: number
    CollectHit:
      type: object
      description: >
        Unauthenticated browser measurement. An accepted order-shaped event creates a stored
        `browser_unverified` order for normal order, revenue and conversion analytics. It is excluded
        from billing and the pricing band. `order_verification` is server-assigned and is not accepted
        from this payload.
      required: [t, u]
      properties:
        v: { type: integer, example: 1, description: Payload version. }
        t: { type: string, example: tlc_0123456789abcdef0123456789abcdef, description: Public collect token (Account → Tracking snippet). }
        u:
          type: string
          example: "https://www.muj-eshop.cz/akce?utm_source=newsletter"
          description: Full page URL incl. query (utm_*, gclid, fbclid are parsed out of it).
        r: { type: string, example: "https://www.google.com/", description: document.referrer. }
        l: { type: string, example: cs-CZ, description: Browser language. }
        e:
          type: object
          nullable: true
          description: Optional event override; omitted/null = automatic pageview.
          properties:
            event_type: { type: string, maxLength: 40, pattern: '^[a-z][a-z0-9_]{0,39}$', example: lead_submitted, description: "A standard event or any custom lowercase snake_case name. Standard names with built-in semantics: pageview, view_item, view_category, search, view_cart, add_to_cart, begin_checkout, order, engagement. Invalid names fall back to pageview. An accepted public order is stored as browser_unverified." }
            page_type: { type: string, example: checkout, description: "One of: home, product, category, search, cart, checkout, order, cms, 404, other." }
            id_object:
              description: Product id / client-declared numeric order number; numeric strings are accepted. For order events it becomes the browser_unverified order id, but is not authenticated.
              oneOf:
                - type: integer
                - type: string
            value: { type: number, example: 1234.5, description: "Client-declared value; for order events it becomes browser_unverified revenue." }
            value_tax_excl: { type: number, example: 1020.25, description: "Client-declared order value excl. VAT for browser_unverified analytics." }
            value_tax_incl: { type: number, example: 1234.5, description: "Client-declared order value incl. VAT for browser_unverified analytics." }
            shipping_tax_excl: { type: number, example: 66.12, description: "Client-declared shipping amount excl. VAT for browser_unverified analytics." }
            shipping_tax_incl: { type: number, example: 80, description: "Client-declared shipping amount incl. VAT for browser_unverified analytics." }
            products_tax_excl: { type: number, example: 900, description: "Client-declared product amount excl. VAT for browser_unverified analytics." }
            products_tax_incl: { type: number, example: 1089, description: "Client-declared product amount incl. VAT for browser_unverified analytics." }
            non_product_tax_excl: { type: number, example: 0, description: "Client-declared non-product fees excl. VAT for browser_unverified analytics." }
            non_product_tax_incl: { type: number, example: 0, description: "Client-declared non-product fees incl. VAT for browser_unverified analytics." }
            currency: { type: string, example: CZK, description: "Client-declared event currency; not authenticated by /collect." }
            search_query: { type: string }
            quantity: { type: integer }
    Batch:
      type: object
      description: >
        One batch. De-duplication is by natural key: orders by (shop, id_order) - upserted, so
        resend a changed order to update it; order_items by (shop, id_order_item); touches by id_touch
        and events by id_event (insert-ignore, immutable - omit the id and a stable key is derived from
        the row content). Always send a real id_order / id_order_item: a missing id collapses to 0 and
        rows overwrite each other.
      properties:
        shop:
          $ref: '#/components/schemas/Shop'
        touches:
          type: array
          items: { $ref: '#/components/schemas/Touch' }
        orders:
          type: array
          items: { $ref: '#/components/schemas/Order' }
        order_items:
          type: array
          items: { $ref: '#/components/schemas/OrderItem' }
        events:
          type: array
          items: { $ref: '#/components/schemas/Event' }
        client:
          type: array
          items: { $ref: '#/components/schemas/Client' }
        dimensions:
          $ref: '#/components/schemas/Dimensions'
    Shop:
      type: object
      properties:
        id: { type: integer, example: 1 }
        domain: { type: string, example: muj-eshop.cz }
    Touch:
      type: object
      description: A visit / marketing touch.
      required: [visitor_uuid]
      properties:
        id_touch: { type: integer }
        id_shop: { type: integer }
        visitor_uuid: { type: string, description: Rotates daily; the cookieless visitor key. }
        country: { type: string, pattern: '^[A-Za-z]{2}$', example: CZ, description: "Visitor country, ISO-3166 alpha-2. Optional; client context may enrich it." }
        id_customer: { type: integer }
        date_add: { type: string, example: "2026-06-02 10:15:00" }
        source: { type: string }
        medium: { type: string }
        campaign: { type: string }
        content: { type: string }
        term: { type: string }
        gclid: { type: string }
        fbclid: { type: string }
        landing_url: { type: string }
        referer: { type: string }
        ip_hash: { type: string, description: Hash of the IP (for cross-day attribution). Never the raw IP. }
        session_id: { type: string, description: 30-minute tumbling session id. }
        channel: { type: string, example: paid_search }
        user_agent: { type: string, description: "Raw visitor User-Agent. Used server-side to flag bots (is_bot); NOT stored. Optional - omit and the row counts as human." }
    Order:
      type: object
      description: >
        Order input for the HMAC-authenticated /ingest batch. Trackless assigns
        `order_verification=server_verified`; clients must not send or override this server-owned field.
      required: [id_order]
      properties:
        id_order: { type: integer }
        id_shop: { type: integer }
        id_customer: { type: integer }
        visitor_uuid: { type: string }
        country: { type: string, pattern: '^[A-Za-z]{2}$', example: CZ, description: "Visitor country, ISO-3166 alpha-2. Optional; client context may enrich it." }
        ip_hash: { type: string }
        ft_source: { type: string, description: First-touch source (module attribution). }
        ft_medium: { type: string }
        ft_campaign: { type: string }
        lt_source: { type: string, description: Last-touch source (module attribution). }
        lt_medium: { type: string }
        lt_campaign: { type: string }
        gclid: { type: string }
        fbclid: { type: string }
        n_touches: { type: integer }
        days_to_convert: { type: integer }
        total_paid:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Full paid order amount incl. VAT, after discounts, including shipping and non-product fees.
        total_paid_tax_excl:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Full paid order amount excl. VAT, after discounts, including shipping and non-product fees.
        total_products:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Product revenue excl. VAT after discounts, excluding shipping, wrapping, payment and other non-product fees.
        total_products_wt:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Product revenue incl. VAT after discounts, excluding shipping, wrapping, payment and other non-product fees.
        total_shipping_tax_excl: { $ref: '#/components/schemas/Decimal' }
        total_shipping_tax_incl: { $ref: '#/components/schemas/Decimal' }
        total_discounts_tax_excl: { $ref: '#/components/schemas/Decimal' }
        total_discounts_tax_incl: { $ref: '#/components/schemas/Decimal' }
        total_refunded_tax_excl: { $ref: '#/components/schemas/Decimal' }
        total_refunded_tax_incl:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Refunds; net revenue subtracts this.
        conversion_rate: { $ref: '#/components/schemas/Decimal' }
        id_currency: { type: integer }
        currency: { type: string, example: CZK }
        voucher_code: { type: string }
        payment: { type: string }
        id_carrier: { type: integer }
        carrier_name: { type: string }
        id_customer_group: { type: integer }
        is_first_order: { type: integer, enum: [0, 1] }
        valid: { type: integer, enum: [0, 1] }
        current_state: { type: integer, description: Order state id; excluded states are configured per account. }
        date_order: { type: string, example: "2026-06-02 10:40:00" }
    OrderItem:
      type: object
      required: [id_order_item, id_order]
      properties:
        id_order_item: { type: integer }
        id_shop: { type: integer }
        id_order: { type: integer }
        id_product: { type: integer }
        id_product_attribute: { type: integer }
        product_name: { type: string }
        product_reference: { type: string }
        id_category_default: { type: integer }
        quantity: { type: integer }
        unit_price_tax_excl: { $ref: '#/components/schemas/Decimal' }
        unit_price_tax_incl: { $ref: '#/components/schemas/Decimal' }
        total_price_tax_excl: { $ref: '#/components/schemas/Decimal' }
        total_price_tax_incl: { $ref: '#/components/schemas/Decimal' }
        wholesale_price:
          allOf: [{ $ref: '#/components/schemas/Decimal' }]
          description: Purchase/wholesale price; used for margin.
    Event:
      type: object
      required: [visitor_uuid, event_type]
      properties:
        id_event: { type: integer }
        id_shop: { type: integer }
        visitor_uuid: { type: string }
        country: { type: string, pattern: '^[A-Za-z]{2}$', example: CZ, description: "Visitor country, ISO-3166 alpha-2. Optional; client context may enrich it." }
        id_customer: { type: integer }
        date_add: { type: string, example: "2026-06-02 10:16:00" }
        event_type: { type: string, maxLength: 40, pattern: '^[a-z][a-z0-9_]{0,39}$', example: lead_submitted, description: "A standard event or any custom lowercase snake_case name. Invalid names fall back to pageview. Custom events appear in the event report but have no built-in funnel or revenue semantics. Send purchases through the orders collection." }
        page_type: { type: string, example: product }
        id_object: { type: integer }
        quantity: { type: integer }
        value: { $ref: '#/components/schemas/Decimal' }
        currency: { type: string }
        search_query: { type: string }
        url: { type: string }
        session_id: { type: string }
        referer: { type: string }
        user_agent: { type: string, description: "Raw visitor User-Agent (server-side bot detection; NOT stored). Optional." }
    Client:
      type: object
      description: Server-side visitor context (no client-side fingerprinting).
      required: [visitor_uuid]
      properties:
        id_shop: { type: integer }
        visitor_uuid: { type: string, description: Required - merge key for the client row. }
        id_client: { type: integer, nullable: true }
        date_add: { type: string }
        device_type: { type: string, example: desktop, description: "Derived server-side from user_agent; send only as a legacy fallback for builds that do not forward the UA." }
        browser: { type: string, example: Chrome, description: "Derived server-side from user_agent (legacy fallback)." }
        os: { type: string, example: Windows, description: "Derived server-side from user_agent (legacy fallback)." }
        country: { type: string, pattern: '^[A-Za-z]{2}$', example: CZ, description: "Visitor country, ISO-3166 alpha-2." }
        language: { type: string, example: cs }
        user_agent: { type: string, description: "Raw visitor User-Agent. Server derives device_type/browser/os AND is_bot from it; NOT stored. Recommended - forward this instead of pre-parsing device/browser/os on the client." }
        is_hosting: { type: integer, enum: [0, 1], nullable: true, description: "Did the visitor IP resolve to a datacenter/cloud/VPN range? The connector holds the raw IP and answers this itself; the server never sees the address. Omit it entirely if you cannot resolve it - 0 means 'checked, residential' and is NOT the same as saying nothing." }
        hosting_asn: { type: integer, nullable: true, description: "Which network the is_hosting verdict came from. Only meaningful together with is_hosting = 1." }
        fetch_meta: { type: string, example: "1|navigate|document|1", description: "Sec-Fetch evidence for this request, as 'secure|mode|dest|any-sec-fetch-header-present'. A browser obliged to send Sec-Fetch-* that sends none is a forged User-Agent. The rule stays silent until the shop has demonstrably delivered the header at least once, so a CDN that strips it can never turn an audience into bots." }
    Dimensions:
      type: object
      description: Lookup tables. Object keys are the platform ids.
      properties:
        shop_meta:
          type: object
          properties:
            name: { type: string }
            timezone: { type: string }
            default_currency: { type: string }
            default_lang: { type: string }
            domain: { type: string }
        order_states:
          type: object
          additionalProperties:
            type: object
            properties:
              name: { type: string }
              logable: { type: boolean }
              paid: { type: boolean }
              shipped: { type: boolean }
              delivered: { type: boolean }
        carriers:
          type: object
          additionalProperties: { type: string }
        categories:
          type: object
          additionalProperties: { type: string }
        currencies:
          type: object
          additionalProperties:
            type: object
            properties:
              iso: { type: string }
              conversion_rate: { type: string }
              sign: { type: string }
        languages:
          type: object
          additionalProperties: { type: string }
        payment_modules:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              display_name: { type: string }
