openapi: 3.1.0
info:
  title: Casa Signals API
  version: v1
  summary: Send events, products, and customers to Casa Signals from any platform.
  description: |
    Casa Signals ingests behavioural events (page views, cart activity,
    checkouts, orders, subscriptions, form submissions), a product catalogue,
    and a customer directory. Once data is flowing, the dashboard shows live
    analytics and identity timelines, and the flow engine can trigger SMS
    and email based on customer behaviour.

    This API is what the first-party WordPress plugin and Shopify app use
    internally. It is stable and public — integrate directly if you're on
    a platform without an official plugin, or if you want tighter control
    than a plugin provides.

    ## Getting started

    1. Sign in at [casasignals.com](https://casasignals.com), add a domain
       for your site, and create an API key from the connection page. Keys
       are prefixed `wpp_` and are only shown once at creation — store them
       in your app's secret store.
    2. Every request needs an `Authorization: Bearer <api_key>` header and
       either a `domain` field in the body or a `X-Site-Domain` header
       identifying which of your Casa Signals domains the request is for.
    3. Verify the connection with `POST /api/v1/verify`, then start
       sending events.

    ## Rate limits and idempotency

    Hard cap: 500 events per `POST /api/v1/events` request (same for
    `products` and `customers`). Exceed it and you'll get `400 Bad
    Request` — chunk your input. There is no per-minute quota today;
    when we introduce one it will surface as `429 Too Many Requests`
    with a `Retry-After` header, and the Casa Signals SDKs already
    retry that status.

    `POST /api/v1/events` accepts an `Idempotency-Key` header (Stripe
    semantics). Retries with the same key within 24 hours replay the
    original response verbatim; retries with the same key but a different
    request body get `409 Conflict`. The Casa Signals SDKs set this header
    automatically per call. Combined with per-event `event_id` dedup, this
    makes retry loops safe by construction.

    ## Money format

    All monetary amounts are **decimal major units** (e.g. `58.50` for
    £58.50, not `5850` for pence). Casa Signals never multiplies by 100
    server-side — what you send is what appears on the dashboard. Use
    ISO 4217 currency codes on the `order.currency` field (`GBP`, `USD`,
    `EUR`, etc.). Mixed-currency stores work — analytics groups revenue
    per currency, so a GBP payment and a USD payment are surfaced as two
    lines rather than added together.

    ## Product attribution

    For a product to show up in the drilldown (rather than "Unknown
    product"), the event payload must carry product identity in one of
    two exact shapes, keyed by event type:

    - **`product_view`** — `properties.product.{id, sku, name}` on a
      single object. `id` wins as the join key; falls back to `sku`,
      then `name`. Sending `product_id` at the root of `properties` is
      NOT read — the SQL analytics trigger only looks at
      `properties -> 'product' -> 'id'`.
    - **cart + order events** (`add_to_cart`, `viewed_cart`,
      `checkout_started`, `checkout_contact_captured`,
      `cart_quantity_changed`, `cart_emptied`, `order_placed`,
      `payment_successful`, `order_refunded`, `order_cancelled`) —
      **`properties.order.items[]`** array (items live under the
      `order` object, not at the root of `properties`), each item with
      `{product_id, variation_id, sku, name, quantity, unit_price}`.
      Same join priority: `product_id` → `variation_id` → `sku` → `name`.
      Variations roll into their parent product for catalogue-level
      metrics.

    Common footgun: putting `items[]` at the root of `properties`
    instead of inside `properties.order`. The SQL trigger reads
    `payload#>'{order,items}'` — an `items[]` at the wrong level is
    silently ignored and every event resolves to "Unknown product".
    The `dry_run` flag will flag this immediately with a
    `product_identity_missing` warning.

    Products backfilled through `POST /api/v1/products` show as tracked
    with 0 activity until the first event references them.

    ## Order vs payment semantics

    `order_placed` and `payment_successful` are separate events —
    Casa Signals doesn't enforce a relationship between them, so send
    what maps to your actual funnel:

    - Payment collected at checkout (retail): send **both** at the same
      moment. The analytics engine dedupes revenue by
      `(domain, order.id)` so you don't get double-counted.
    - Booking with pending payment (services, invoicing): send
      **`order_placed`** when the booking is made. Send
      **`payment_successful`** later when payment clears. The order
      appears on the timeline immediately; revenue counts on payment.
    - Cancelled or abandoned before payment: send **neither** — or
      `order_cancelled` if you want the timeline entry. Never fire
      `payment_successful` for a failed charge.

    Both events read revenue from the top-level `order.total`. If you
    only send `payment_successful` (webhook-only architectures like
    Stripe), that's fine — the same drilldown works.

    ## Historical import (backfill)

    Set `backfill: true` on `POST /api/v1/events`. Casa Signals persists
    events and refreshes rollups but skips flow triggering so old data
    doesn't fire live SMS/email campaigns to customers.

    Guidance:

    - **Chunk size**: 500 events per request (hard cap). Real-world
      sweet spot is 100-250 per batch — big enough to amortise HTTP
      round-trips, small enough that a mid-batch failure retries cheap.
    - **Retry safety**: the SDK auto-generates `event_id` per event.
      Send from your own DB with a stable `event_id` (e.g.
      `order_<id>_placed`) if you want to re-run the import safely.
      Duplicates on `(domain, event_id)` are dropped silently.
    - **Ordering**: not required. The analytics trigger orders by
      `occurred_at` when computing rollups, so a batch out of
      chronological order still lands correctly.
    - **Rate**: no per-minute limit today. Casa Signals' ingest layer
      handles ~50 batches/sec per domain comfortably.
servers:
  - url: https://casasignals.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Verify
    description: Confirm your API key + domain wiring before shipping data.
  - name: Events
    description: Behavioural events (the primary ingest path).
  - name: Products
    description: Product catalogue backfill.
  - name: Customers
    description: Customer directory backfill.
  - name: Accounts
    description: |
      Admin-only endpoint for programmatic account provisioning.
      Requires a platform admin token, not a merchant API key.
paths:
  /api/v1/verify:
    get:
      tags: [Verify]
      summary: Ping the API
      description: "Unauthenticated liveness check. Returns `ok: true` and the API version."
      security: []
      responses:
        "200":
          description: API is live
          content:
            application/json:
              schema:
                type: object
                properties:
                  api: { type: string, example: "casa-signals" }
                  ok: { type: boolean, example: true }
                  version: { type: string, example: "v1" }
    post:
      tags: [Verify]
      summary: Verify an API key against a domain
      description: |
        Returns 200 with the resolved domain/connection IDs if the bearer
        token is a live API key attached to the supplied domain, otherwise
        401/403 with a machine-readable error string.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DomainScopedRequest"
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                required: [ok, domain, domainId, connectionId]
                properties:
                  ok: { type: boolean, example: true }
                  domain: { type: string, example: "shop.example.com" }
                  domainId:
                    type: string
                    format: uuid
                    example: "09e86c67-d93b-43cd-8203-e980c476a28b"
                  connectionId:
                    type: string
                    format: uuid
        "401":
          $ref: "#/components/responses/AuthError"
        "403":
          $ref: "#/components/responses/AuthError"
  /api/v1/events:
    post:
      tags: [Events]
      summary: Ingest a batch of events
      description: |
        Accepts up to 500 events in one call. Events with `event_id`
        collisions against previously-ingested rows for the same domain
        are dropped silently, so retries with the same body are safe.

        Every event needs `event_type` and `occurred_at`. Everything else
        is optional but recommended — the richer the payload, the better
        the identity resolution and product-metric derivation downstream.

        Optional `Idempotency-Key` header (Stripe semantics). See the
        top-level "Rate limits and idempotency" note.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 255 }
          description: |
            Opaque client-generated identifier for this call. Retries
            within 24h with the same key replay the cached response;
            same key + different body returns 409 Conflict. Casa Signals
            SDKs set this automatically per call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain, events]
              properties:
                domain:
                  type: string
                  description: The Casa Signals domain these events belong to.
                  example: "shop.example.com"
                backfill:
                  type: boolean
                  description: |
                    Set to `true` for historical import runs. Casa Signals
                    persists events and refreshes rollups but skips flow
                    triggering so old data doesn't fire live campaigns.
                  default: false
                dry_run:
                  type: boolean
                  description: |
                    Set to `true` to validate + normalize + preview the
                    events WITHOUT inserting them. Zero side-effects —
                    no tracking events created, no flow triggered, no
                    rollup refreshed. Response body includes a
                    `preview[]` array showing how Casa Signals will
                    resolve customer, product, order, currency, and
                    revenue for each event, plus any warnings.

                    Use this during integration development to iterate
                    on payload shape without polluting your tracking
                    table.
                  default: false
                events:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/Event"
            examples:
              product_view:
                summary: product_view — single product identity
                value:
                  domain: "shop.example.com"
                  events:
                    - event_type: "product_view"
                      occurred_at: "2026-07-09T14:22:11Z"
                      user:
                        id: "user_42"
                        email: "jane@example.com"
                      page:
                        url: "https://shop.example.com/products/blue-jacket"
                        path: "/products/blue-jacket"
                      properties:
                        product:
                          id: "SKU-BLUE-42"
                          sku: "BAJ-42"
                          name: "Blue Alpine Jacket"
                          price: 129.99
              viewed_cart:
                summary: viewed_cart — line items nested under properties.order
                value:
                  domain: "shop.example.com"
                  events:
                    - event_type: "viewed_cart"
                      occurred_at: "2026-07-09T14:23:44Z"
                      user:
                        id: "user_42"
                      properties:
                        order:
                          items:
                            - product_id: "SKU-BLUE-42"
                              sku: "BAJ-42"
                              name: "Blue Alpine Jacket"
                              quantity: 1
                              unit_price: 129.99
                            - product_id: "SKU-RED-19"
                              sku: "RAJ-19"
                              name: "Red Alpine Jacket"
                              quantity: 2
                              unit_price: 129.99
              checkout_contact_captured:
                summary: checkout_contact_captured — identity locked in
                value:
                  domain: "shop.example.com"
                  events:
                    - event_type: "checkout_contact_captured"
                      occurred_at: "2026-07-09T14:25:01Z"
                      user:
                        email: "jane@example.com"
                        first_name: "Jane"
                        last_name: "Doe"
                        phone: "+441234567890"
                      properties:
                        order:
                          items:
                            - product_id: "SKU-BLUE-42"
                              name: "Blue Alpine Jacket"
                              quantity: 1
                              unit_price: 129.99
              payment_successful:
                summary: payment_successful — revenue-bearing, decimal major units
                value:
                  domain: "shop.example.com"
                  events:
                    - event_type: "payment_successful"
                      occurred_at: "2026-07-09T14:26:33Z"
                      user:
                        id: "user_42"
                        email: "jane@example.com"
                      order:
                        id: "order_9124"
                        total: 129.99
                        currency: "GBP"
                      properties:
                        order:
                          currency: "GBP"
                          items:
                            - product_id: "SKU-BLUE-42"
                              name: "Blue Alpine Jacket"
                              quantity: 1
                              unit_price: 129.99
              order_placed:
                summary: order_placed — booking/invoice model, payment pending
                value:
                  domain: "bookings.example.com"
                  events:
                    - event_type: "order_placed"
                      occurred_at: "2026-07-09T09:00:00Z"
                      user:
                        id: "user_42"
                        email: "jane@example.com"
                      order:
                        id: "booking_501"
                        total: 250.00
                        currency: "GBP"
                      properties:
                        payment_status: "pending"
                        order:
                          currency: "GBP"
                          items:
                            - product_id: "SVC-CONSULT-60"
                              name: "60-minute consult"
                              quantity: 1
                              unit_price: 250.00
              dry_run:
                summary: dry_run — preview resolution without inserting
                value:
                  domain: "shop.example.com"
                  dry_run: true
                  events:
                    - event_type: "product_view"
                      occurred_at: "2026-07-09T14:22:11Z"
                      user: { id: "user_42" }
                      properties:
                        product:
                          id: "SKU-BLUE-42"
                          name: "Blue Alpine Jacket"
                    - event_type: "payment_successful"
                      occurred_at: "2026-07-09T14:26:33Z"
                      user: { email: "jane@example.com" }
                      order: { id: "order_9124", total: 129.99, currency: "GBP" }
                      properties:
                        order:
                          currency: "GBP"
                          items:
                            - product_id: "SKU-BLUE-42"
                              name: "Blue Alpine Jacket"
                              quantity: 1
                              unit_price: 129.99
              backfill_batch:
                summary: Historical import — set backfill:true so flows don't fire
                value:
                  domain: "shop.example.com"
                  backfill: true
                  events:
                    - event_id: "order_9124_placed"
                      event_type: "order_placed"
                      occurred_at: "2026-06-15T10:00:00Z"
                      user: { id: "user_42", email: "jane@example.com" }
                      order: { id: "order_9124", total: 129.99, currency: "GBP" }
                      properties:
                        order:
                          currency: "GBP"
                          items:
                            - { product_id: "SKU-BLUE-42", name: "Blue Alpine Jacket", quantity: 1, unit_price: 129.99 }
                    - event_id: "order_9124_paid"
                      event_type: "payment_successful"
                      occurred_at: "2026-06-15T10:00:12Z"
                      user: { id: "user_42", email: "jane@example.com" }
                      order: { id: "order_9124", total: 129.99, currency: "GBP" }
                      properties:
                        order:
                          currency: "GBP"
                          items:
                            - { product_id: "SKU-BLUE-42", name: "Blue Alpine Jacket", quantity: 1, unit_price: 129.99 }
      responses:
        "200":
          description: |
            Events accepted (or previewed when `dry_run: true`). When
            previewing, `stored` is always 0 and `preview[]` describes
            how each event would resolve.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  received: { type: integer, example: 42 }
                  stored:
                    type: integer
                    description: |
                      Events actually persisted. 0 when `dry_run: true`.
                      May be less than `received` when the dedup filter
                      suppresses a `cart_emptied` that correlates to an
                      immediately-following `checkout_started`.
                    example: 41
                  dry_run:
                    type: boolean
                    description: "Present only when the request set `dry_run: true`."
                    example: true
                  preview:
                    type: array
                    description: |
                      Per-event resolution preview. Present only when
                      `dry_run: true`. Each entry mirrors what the
                      analytics trigger would extract at real insert time.
                    items:
                      $ref: "#/components/schemas/EventPreview"
                  warnings:
                    type: array
                    description: |
                      Per-event advisories emitted during normalization.
                      Empty when every event was fully-resolvable. Common
                      codes: `product_identity_missing`,
                      `customer_identifier_missing`, `currency_missing`,
                      `order_total_missing`. Presence of a warning does
                      NOT mean the event was rejected — `stored` counts
                      what actually landed.
                    items:
                      $ref: "#/components/schemas/IngestWarning"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/AuthError"
        "409":
          description: Idempotency-Key already used with a different request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Events]
      summary: List valid event types
      description: |
        Returns the current catalogue of `event_type` values accepted by
        `POST /api/v1/events`. Useful for building type-safe clients.
      security: []
      responses:
        "200":
          description: Event catalogue
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  eventTypes:
                    type: array
                    items:
                      $ref: "#/components/schemas/EventType"
  /api/v1/products:
    post:
      tags: [Products]
      summary: Bulk-import a product catalogue
      description: |
        Idempotent upsert into the domain's product catalogue. Send once
        on plugin install, or re-run periodically — existing rows are
        left alone (first_seen_at, event-count-derived fields are never
        clobbered).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain, products]
              properties:
                domain: { type: string, example: "shop.example.com" }
                products:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/Product"
      responses:
        "200":
          description: Products ingested
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  received: { type: integer }
                  upserted: { type: integer }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/AuthError"
  /api/v1/customers:
    post:
      tags: [Customers]
      summary: Bulk-import a customer directory
      description: |
        Idempotent upsert into the domain's customer directory. Identity
        is keyed by `user_id` (your platform's stable id) when present,
        else by SHA-256(email). Existing customers are left alone —
        this is a catalogue import, not a mutation stream.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain, customers]
              properties:
                domain: { type: string, example: "shop.example.com" }
                customers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/Customer"
      responses:
        "200":
          description: Customers ingested
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  received: { type: integer }
                  upserted: { type: integer }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/AuthError"
  /api/v1/accounts:
    post:
      tags: [Accounts]
      summary: Provision a new Casa Signals account + domain + API key
      description: |
        **Admin-only endpoint** — requires the platform's
        `CASA_SIGNALS_ADMIN_API_TOKEN` in the Authorization header,
        NOT a merchant `wpp_*` key. Meant for partner integrations
        that need to onboard merchants programmatically (custom
        checkout backends, agencies white-labelling Casa Signals,
        etc.).

        One request creates:
          1. the auth user (with a random temporary password),
          2. the customer domain,
          3. a `connection_type='api'` domain connection,
          4. a `wpp_*` API key.

        A welcome email with the temporary password + API key is sent
        to the merchant. Both are ALSO returned in the response so the
        admin caller has a fallback if the email bounces — treat the
        response body with the same care as the email itself.

        Idempotency: the temporary password + api key are only shown
        once, in the response. If the email address already has a Casa
        Signals account, returns `409 Conflict` — no partial state is
        left behind (the transaction rolls back). Rerun with a fresh
        email or handle the conflict in your integration.
      security:
        - adminAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, domain]
              properties:
                email:
                  type: string
                  format: email
                  description: |
                    Merchant email. This becomes the login for the
                    Casa Signals dashboard. Casa Signals does NOT
                    verify the address itself — the admin caller
                    is trusted to have verified it upstream.
                  example: "owner@shop.example.com"
                domain:
                  type: string
                  description: |
                    Storefront domain (or myshopify.com handle for
                    Shopify shops). Normalised server-side.
                  example: "shop.example.com"
                store_name:
                  type: string
                  description: |
                    Optional display name for the site card in the
                    dashboard. Defaults to the domain if omitted.
                  example: "Blue Alpine Trading Co."
                locale:
                  type: string
                  description: |
                    Two-letter locale tag for the merchant's dashboard
                    and welcome email. Defaults to `en`. Unknown
                    locales fall back to `en`.
                  example: "en"
      responses:
        "201":
          description: Account provisioned. Response echoes the credentials once.
          content:
            application/json:
              schema:
                type: object
                required: [ok, user_id, domain_id, connection_id, api_key, api_key_prefix, domain, temporary_password, email_sent]
                properties:
                  ok: { type: boolean, example: true }
                  user_id:
                    type: string
                    format: uuid
                  domain_id:
                    type: string
                    format: uuid
                  connection_id:
                    type: string
                    format: uuid
                  domain:
                    type: string
                    description: Normalised domain (host only, no scheme).
                    example: "shop.example.com"
                  api_key:
                    type: string
                    description: |
                      Full `wpp_*` API key. Shown only in this response
                      and in the welcome email. Store in your secret
                      manager immediately.
                    example: "wpp_abcdef1234567890abcdef1234567890abcdef1234"
                  api_key_prefix:
                    type: string
                    example: "wpp_abcdef12"
                  temporary_password:
                    type: string
                    description: |
                      Random human-typeable password (24 chars from an
                      unambiguous alphabet). Also emailed to the
                      merchant. Prompts them to change it on first login.
                    example: "Kv6iF5pYCix5G3nA4b8jH2Ru"
                  email_sent:
                    type: boolean
                    description: |
                      `true` if the welcome email was accepted by the
                      Casa Mail gateway. `false` means the account is
                      created but the merchant hasn't been notified —
                      admin caller should forward the credentials
                      manually.
                  email_error:
                    type: string
                    description: "Present only when `email_sent: false`."
        "400":
          description: Invalid email, invalid domain, or missing required field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid `CASA_SIGNALS_ADMIN_API_TOKEN`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: An account with that email already exists. No partial state written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "wpp_*"
      description: |
        API key created from the Casa Signals dashboard. Prefixed `wpp_`.
        Only shown once — treat it as a secret and rotate if leaked.
    adminAuth:
      type: http
      scheme: bearer
      description: |
        Platform admin token (`CASA_SIGNALS_ADMIN_API_TOKEN` env). Used
        by partner integrations to provision merchant accounts via
        `POST /api/v1/accounts`. Distinct from the merchant `wpp_*`
        keys — an admin token has NO scope for /events, /products,
        /customers, or /verify, and a merchant key cannot call
        /accounts.
  responses:
    BadRequest:
      description: Body failed validation
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AuthError:
      description: Missing, invalid, or domain-mismatched API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, example: false }
        error:
          type: string
          example: "Missing bearer token."
    DomainScopedRequest:
      type: object
      required: [domain]
      properties:
        domain:
          type: string
          description: Domain of the site the request targets (must match the API key's scope).
          example: "shop.example.com"
    EventType:
      type: string
      description: |
        Canonical event names. `inactivity` is a synthetic trigger used
        by the flow engine, not something you send.
      enum:
        - user_login
        - user_logout
        - user_register
        - page_view
        - viewed_cart
        - checkout_started
        - payment_failed
        - payment_successful
        - order_placed
        - user_reset_password
        - updated_account_data
        - add_to_cart
        - remove_from_cart
        - product_view
        - product_search
        - coupon_applied
        - coupon_removed
        - checkout_field_updated
        - shipping_method_selected
        - payment_method_selected
        - order_refunded
        - order_cancelled
        - subscription_started
        - subscription_cancelled
        - subscription_renewed
        - lead_form_submitted
        - newsletter_signup
        - contact_form_submitted
        - attribution_captured
        - cart_quantity_changed
        - cart_emptied
        - checkout_contact_captured
        - checkout_address_updated
        - checkout_validation_failed
        - site_health_reported
    Event:
      type: object
      required: [event_type, occurred_at]
      properties:
        event_id:
          type: string
          description: |
            Your platform's stable id for this event. If two events with
            the same `event_id` arrive for the same domain, only the first
            is persisted — retries are safe.
          example: "evt_9f3c1a"
        event_type:
          $ref: "#/components/schemas/EventType"
        occurred_at:
          type: string
          format: date-time
          example: "2026-06-30T14:22:11Z"
        page:
          type: object
          properties:
            url: { type: string, format: uri }
            path: { type: string }
            referrer: { type: string }
        visitor:
          type: object
          properties:
            visitor_id: { type: string, description: "Anonymous browser id (cookie or localStorage)." }
            session_id: { type: string }
        user:
          $ref: "#/components/schemas/EventUser"
        order:
          type: object
          description: |
            Present on `order_placed`, `payment_successful`,
            `order_refunded`, `order_cancelled`, and `checkout_started`.
            `total` is a decimal in major units (e.g. 58.50) — see
            "Money format" above. `currency` is ISO 4217.
          properties:
            id: { type: string, example: "42" }
            total: { type: number, example: 58.50 }
            currency: { type: string, example: "GBP" }
        properties:
          $ref: "#/components/schemas/EventProperties"
        context:
          type: object
          additionalProperties: true
          description: |
            Environmental context (device, locale, campaign, referrer
            attribution). Free-form JSON — merged into the tracking row's
            context column, surfaced back on the event detail view.
    EventPreview:
      type: object
      description: |
        How Casa Signals would resolve one event at real ingestion time
        — customer, product(s), order, currency, revenue — plus any
        per-event warnings. Only returned when the request sets
        `dry_run: true`.
      required: [event_index, event_type, products, warnings]
      properties:
        event_index:
          type: integer
          description: "0-based index into the request's events[] array."
          example: 0
        event_id:
          type: string
          description: "Echo of the event's event_id if one was provided."
          example: "evt_9f3c1a"
        event_type:
          $ref: "#/components/schemas/EventType"
        identity_key:
          type: string
          nullable: true
          description: |
            How the event will be stitched to a customer. Format:
            `wp:<id>` when a user id is present, else
            `email:<sha256_hex(lower_email)>`. Null when no identifier
            was resolvable.
          example: "wp:user_42"
        order_key:
          type: string
          nullable: true
          description: |
            Order-scoping key for revenue rollups. `order.id` if
            present, else the caller-supplied `event_id`. Null when
            neither was set on an order-bearing event.
          example: "order_9124"
        order_total:
          type: number
          nullable: true
          description: Parsed decimal revenue amount, major units.
          example: 129.99
        currency:
          type: string
          nullable: true
          description: ISO 4217 currency code from `order.currency`.
          example: "GBP"
        products:
          type: array
          description: |
            One entry per resolvable product on the event. For
            `product_view`, at most one entry from
            `properties.product`; for cart / order events, one entry
            per item in `properties.items[]` that carries a joinable
            key.
          items:
            $ref: "#/components/schemas/ResolvedProduct"
        warnings:
          type: array
          description: Per-event warnings raised while resolving this event.
          items:
            $ref: "#/components/schemas/IngestWarning"
    ResolvedProduct:
      type: object
      required: [product_key, product_name]
      properties:
        product_key:
          type: string
          nullable: true
          description: |
            Join key the analytics trigger will use. Priority:
            `product_id` → `variation_id` → `sku` → `name`. Null when
            no identifier was present.
          example: "SKU-BLUE-42"
        product_id:
          type: string
          nullable: true
        variation_id:
          type: string
          nullable: true
        sku:
          type: string
          nullable: true
        product_name:
          type: string
          description: Falls back to "Unknown product" when no name was resolvable.
          example: "Blue Alpine Jacket"
    IngestWarning:
      type: object
      required: [event_index, code, message]
      properties:
        event_index:
          type: integer
          description: "0-based index into the request's events[] array."
          example: 3
        event_id:
          type: string
          description: "Echo of the event's event_id if one was provided."
        code:
          type: string
          enum:
            - product_identity_missing
            - customer_identifier_missing
            - currency_missing
            - order_total_missing
          description: |
            Machine-readable warning type.
              - `product_identity_missing`: `product_view` event had no
                `properties.product.{id,sku,name}`, OR a cart/order
                event had no `properties.order.items[]` entries with a
                resolvable key. The event is stored but won't roll up
                into product analytics.
              - `customer_identifier_missing`: no `user.id`, `user.email`,
                or `user.email_hash`. Identity resolution can't stitch
                this event to a customer.
              - `currency_missing`: an `order.total` was sent without
                `order.currency`. Revenue rolls up under an empty
                currency bucket (invisible on the multi-currency panel).
              - `order_total_missing`: an order/payment event with no
                `order.total`. Timeline entry created but revenue rollup
                skipped.
        message:
          type: string
          description: Human-readable explanation, safe to log verbatim.
          example: "product_view event has no properties.product.id/sku/name — event stored but not linked to a product."
    EventProperties:
      type: object
      description: |
        Event-specific properties. Two shapes are read by the analytics
        trigger:
          - `product` — single-product identity for `product_view`
            events
          - `order.items[]` — line items array for cart + order events
            (note the `.order` nesting — items at the root of
            `properties` are silently ignored by the analytics trigger)
        Everything else is stored as-is and available on event
        drilldowns, but doesn't power any rollup.
      properties:
        product:
          $ref: "#/components/schemas/EventProduct"
        order:
          $ref: "#/components/schemas/EventPropertiesOrder"
      additionalProperties: true
    EventPropertiesOrder:
      type: object
      description: |
        The `order` sub-object of `properties` carries line items for
        cart + order events, plus (optionally) an order id / total /
        currency that the analytics trigger reads for revenue rollups.
        You can send this same information as the top-level Event
        `order` field too — either place is read.
      properties:
        id:
          type: string
          example: "42"
        total:
          type: number
          description: Decimal major units. See "Money format" above.
          example: 129.99
        currency:
          type: string
          description: ISO 4217.
          example: "GBP"
        items:
          type: array
          description: |
            Line items for cart + order events. Each item's
            `product_id`, `variation_id`, `sku`, or `name` becomes
            a join key for the products drilldown. See "Product
            attribution" above.
          items:
            $ref: "#/components/schemas/EventLineItem"
      additionalProperties: true
    EventProduct:
      type: object
      description: |
        Single-product identity for `product_view` events. `id` is the
        preferred join key; `sku` and `name` fall back if `id` isn't
        available.
      properties:
        id:
          type: string
          description: Your platform's stable product id.
          example: "SKU-BLUE-42"
        sku:
          type: string
          example: "BAJ-42"
        name:
          type: string
          example: "Blue Alpine Jacket"
        price:
          type: number
          description: Unit price in major units (decimal).
          example: 129.99
        quantity:
          type: number
          description: "Optional: for events that carry a per-view quantity."
          example: 1
      additionalProperties: true
    EventLineItem:
      type: object
      description: |
        One line in `properties.items[]` for cart and order events.
        Join priority for product attribution: `product_id` →
        `variation_id` → `sku` → `name`. Variations roll into their
        parent product for catalogue-level metrics.
      properties:
        product_id:
          type: string
          example: "SKU-BLUE-42"
        variation_id:
          type: string
          description: "Optional: variant id if the platform separates them."
          example: "SKU-BLUE-42-L"
        sku:
          type: string
          example: "BAJ-42-L"
        name:
          type: string
          example: "Blue Alpine Jacket (Large)"
        quantity:
          type: number
          example: 2
        unit_price:
          type: number
          description: Per-unit price in major units.
          example: 129.99
      additionalProperties: true
    EventUser:
      type: object
      description: |
        Identity signals on the event. Send whatever you have; Casa Signals
        stitches identities across events using `id`, `email`, and phone.
      properties:
        id:
          oneOf:
            - { type: string }
            - { type: integer }
          description: Your platform's stable id for the customer, if known.
        email: { type: string, format: email }
        email_hash:
          type: string
          description: "SHA-256 of the lowercased email, if you can't send the raw address."
        phone: { type: string }
        display_name: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        billing_email: { type: string }
        billing_first_name: { type: string }
        billing_last_name: { type: string }
        billing_phone: { type: string }
        billing_address_1: { type: string }
        billing_city: { type: string }
        billing_state: { type: string }
        billing_country: { type: string }
        billing_postcode: { type: string }
        shipping_first_name: { type: string }
        shipping_last_name: { type: string }
        shipping_address_1: { type: string }
        shipping_city: { type: string }
        shipping_state: { type: string }
        shipping_country: { type: string }
        shipping_postcode: { type: string }
    Product:
      type: object
      required: [product_id]
      properties:
        product_id:
          type: string
          description: Your platform's stable id for the product.
          example: "SKU-BLUE-42"
        variation_id:
          type: string
          description: "Optional variant id — rolls up under the parent product for metrics."
        sku:
          type: string
        product_name:
          type: string
          example: "Blue Alpine Jacket"
    Customer:
      type: object
      description: |
        At least one of `user_id`, `external_id`, or `email` must be
        provided so identity resolution has something to key on.
      properties:
        user_id:
          type: string
          description: Preferred stable identifier from your platform.
        external_id:
          type: string
          description: "Alias for `user_id` (whichever is more natural in your code)."
        email:
          type: string
          format: email
        email_hash:
          type: string
          description: "SHA-256 of lowercased email — accepted if you can't send raw."
        phone: { type: string }
        display_name: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        billing:
          type: object
          additionalProperties: true
        shipping:
          type: object
          additionalProperties: true
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
