openapi: 3.1.0
info:
  title: Curious API
  version: 1.0.0
  summary: The Curious data plane — decide, declare, events, identify.
  # Maintainers: this document is the public wire contract (docs/sdk.md §4).
  # It is written from shipped behavior and held to the handlers by
  # tests/typescript/openapi_contract_test.ts — every request example passes
  # its operation's real validator and its schema, every x-rejected-examples
  # entry is rejected with the documented message, and every response example
  # validates against its schema. Keep repository paths and routing mechanics
  # out of the customer-visible text below.
  description: |
    The Curious data plane: `decide` to serve content for a user, `declare` to
    configure an element without serving anyone, `events` to report outcomes,
    and `identify` to link anonymous and known users.

    **Base URL.** `https://api.trycurious.ai/v1`. Every path below is relative
    to it.

    **Authentication.** Every call carries `Authorization: Bearer <key>`. Keys
    are `curious_sk_…` (secret) or `curious_pk_…` (publishable), each in a
    `live` or `test` environment (`curious_sk_test_…`). Secret keys may do
    everything. Publishable keys are serve-only: they may call `decide` without
    declaration fields and `events`, are refused by `declare` and `identify`,
    and must come from an origin on the key's allowlist.

    **Errors.** Every non-2xx response, and every error item inside a batch
    200, is JSON with `error` (a stable code: `invalid_request`,
    `invalid_json`, `unauthorized`, `forbidden`, `not_found`,
    `method_not_allowed`, `conflict`, `idempotency_error`, `lock_timeout`,
    `rate_limit_exceeded`, `internal_error`) and `message` (the human-readable
    reason, the text this document quotes). Every response carries an
    `X-Request-Id` header to quote to support; internal faults repeat it in
    the body as `request_id`. Unknown request fields are rejected
    with a 400 on `decide`, `declare` and `identify`; `events` ignores them.

    **Idempotency.** `events` dedupes on a caller-supplied `id`. `decide`
    replays a committed decision for a repeated `idempotency_key` with the same
    request, and answers 409 `idempotency_error` for the same key with a
    different request.
  contact:
    name: Curious
    url: https://trycurious.ai
servers:
  - url: https://api.trycurious.ai/v1
    description: Production. The hostname is the durable abstraction; `/v1` is the published contract.
security:
  - apiKey: []
tags:
  - name: decide
    description: Serve content for a user and record the decision.
  - name: declare
    description: Configure an element without serving a user.
  - name: events
    description: Ingest outcome events.
  - name: identify
    description: Link anonymous and known users.

paths:
  /decide:
    post:
      tags: [decide]
      operationId: decide
      summary: Decide what content to serve a user
      description: |
        Returns the arm content to display for `user_id` on `element`,
        and records the decision. Every call is a complete declaration of the
        element and experiment configuration: the first call for an element
        creates its experiment (so `control` is required then), a later call
        that changes a declaration-class field (`rewards`, `parameters`,
        `format`, `requirements`, `control` content, `options.holdout`)
        supersedes the experiment, and an identical declaration is a no-op.
        Mutable fields (`options.assignment`, `options.approval`,
        `options.minimum_effect_size`, `instructions`, `variables`) update in
        place.

        **Batch form.** Send `elements` (1–50 items) instead of a singular
        body. Each item is a singular request; the envelope's `user_id`,
        `identifiers`, `attributes`, `context` and `options` fill in what an
        item omits. Results come back per item in input order inside a 200; a
        failing item never fails its siblings. A repeated (user, element) pair
        in one envelope is an item-level error.

        **Publishable keys** may send only `element`, `user_id`,
        `identifiers`, `attributes`, `context` and `idempotency_key`; any
        declaration field is a 400, and an element with no live experiment is
        a 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/DecideRequest"
                - $ref: "#/components/schemas/DecideBatchRequest"
            examples:
              first_call:
                summary: First call for an element — declares the experiment and serves the control
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control:
                    content:
                      headline: Upgrade today
                      body: Unlock unlimited templates with Pro.
                    hypothesis: Direct and to the point
                  rewards:
                    - name: purchased
                      properties: { plan: pro }
                      weight: 1
                      window_seconds: 86400
                    - name: refunded
                      weight: -0.5
                      window_seconds: 2592000
                  constraints:
                    - name: support_ticket_created
                      threshold_type: relative
                      threshold: 0.1
                      bound: upper
                  format:
                    headline: { type: text, max_characters: 90 }
                    body: { type: text }
                  requirements:
                    forbidden_phrases: ["Limited Time"]
                  instructions: Optimize for enterprise SaaS buyers evaluating upgrade
                  identifiers: { email: alice@example.com }
                  attributes: { plan: pro, company_size: 150 }
                  context: { device: mobile, page: /pricing }
                  options: { assignment: static }
              serve_only:
                summary: A later call (or any publishable-key call) — serve from the live experiment
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  context: { device: mobile, page: /pricing }
              keyed_retry:
                summary: A retry-safe call — the same key and body replays the first decision
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  idempotency_key: 7d1f8e4c-2b6a-4e0d-9c3f-5a1b2c3d4e5f
                  context: { device: mobile }
              null_baseline_rate:
                summary: A reward with baseline_rate explicitly null — undeclared, exactly as omitted
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards:
                    - { name: purchased, baseline_rate: null }
                    - { name: added_seats, type: count, baseline_rate: null }
              declared_arms:
                summary: Additional starting points declared beside the control
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: { headline: Upgrade today, body: Unlock Pro. } }
                  arms:
                    - content: { headline: "Don't lose your templates", body: Upgrade keeps them. }
                      hypothesis: Loss aversion
                    - content: { headline: "Join 12,000 teams on Pro", body: Upgrade today. }
                  rewards: purchased
              batch:
                summary: Batch form — one envelope, per-item results
                value:
                  user_id: user_123
                  attributes: { plan: pro }
                  elements:
                    - element: upgrade_prompt
                      context: { page: /pricing }
                    - element: onboarding_banner
                      control: { content: Welcome to Curious }
                      rewards: [clicked]
                      idempotency_key: 0b3c1f0a-9d8e-4b7c-a6f5-e4d3c2b1a0f9
                    - element: upgrade_prompt
                      user_id: user_456
            x-rejected-examples:
              missing_user:
                message: "element and user_id are required"
                value:
                  element: upgrade_prompt
              null_in_idempotency_key:
                message: "idempotency_key must not contain U+0000 or unpaired surrogate code units"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  idempotency_key: "retry\u0000one"
              unknown_field:
                message: "Unknown field(s): segment"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  segment: enterprise
              bad_holdout:
                message: "options.holdout must be a number in [0.02, 0.2]"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  options: { holdout: 0.5 }
              bad_reward_type:
                message: "reward type must be one of: binary, count, semicontinuous, continuous"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards: [{ name: purchased, type: money }]
              empty_batch:
                message: "elements must be a non-empty array"
                value:
                  user_id: user_123
                  elements: []
              wrapper_typo:
                message: "Received unknown parameter: control.hypothesi"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today, hypothesi: Direct }
              array_without_items:
                message: 'items is required when type is "array"'
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: { bullets: [One, Two] } }
                  format:
                    bullets: { type: array }
              value_source_on_binary:
                message: "reward value sources are only valid with type 'semicontinuous'"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards: [{ name: purchased, type: binary, value: event.value }]
              zero_weight:
                message: "reward weight must be non-zero"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards: [{ name: purchased, weight: 0 }]
              baseline_rate_out_of_range:
                message: "reward baseline_rate must be a probability strictly between 0 and 1"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards: [{ name: purchased, baseline_rate: 1 }]
              arm_duplicates_control:
                message: "arms: an entry duplicates the declared control's content"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  arms: [{ content: Upgrade today, hypothesis: Same as control }]
              nested_arm_value_source:
                message: "must name a top-level content field"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: { price: { usd: 49 } } }
                  format: { price: { usd: { type: number, min: 1, max: 500 } } }
                  rewards: [{ name: purchased, type: semicontinuous, value: arm.price.usd }]
              nested_boolean_content:
                message: "is a boolean, which the format grammar does not"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: { settings: { enabled: true } } }
              reserved_content_field:
                message: "contains a field named 'type', which the format"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: { type: copy, headline: Upgrade today } }
              absolute_binary_threshold_over_one:
                message: "a binary constraint's absolute threshold is a rate in [0, 1]"
                value:
                  user_id: user_123
                  element: upgrade_prompt
                  control: { content: Upgrade today }
                  rewards: purchased
                  constraints:
                    - { name: failed, threshold_type: absolute, threshold: 2, bound: upper }
      responses:
        "200":
          description: |
            The decision (singular form), or the per-item results (batch
            form). A replayed decision is the original response verbatim plus
            `replayed: true`.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DecideResponse"
                  - $ref: "#/components/schemas/DecideBatchResponse"
              examples:
                decision:
                  summary: A fresh decision
                  value:
                    content:
                      headline: "Don't lose your 47 saved templates"
                      body: Upgrade to Pro and keep every one of them.
                    arm_id: 3f1c2b6e-0000-4000-8000-00000000a001
                    arm_type: variant
                    decision_id: 8a7b6c5d-0000-4000-8000-00000000d001
                    experiment_id: 1e2d3c4b-0000-4000-8000-00000000e001
                    experiment_status: exploring
                with_warnings:
                  summary: A decision with a warning about a skipped declared arm
                  value:
                    content: Upgrade today
                    arm_id: 3f1c2b6e-0000-4000-8000-00000000a000
                    arm_type: control
                    decision_id: 8a7b6c5d-0000-4000-8000-00000000d002
                    experiment_id: 1e2d3c4b-0000-4000-8000-00000000e001
                    experiment_status: initializing
                    warnings:
                      - code: arm_invalid
                        message: 'arms[1] ({"headline":"Join 12,000 teams on Pro","body":"Upgrade tod...) was skipped: it duplicates arms[0] in the same request'
                replayed:
                  summary: A replayed decision
                  value:
                    content: Upgrade today
                    arm_id: 3f1c2b6e-0000-4000-8000-00000000a000
                    arm_type: control
                    decision_id: 8a7b6c5d-0000-4000-8000-00000000d001
                    experiment_id: 1e2d3c4b-0000-4000-8000-00000000e001
                    experiment_status: exploring
                    replayed: true
                batch:
                  summary: Batch results — an ok item, a replayed item, and an item-level error
                  value:
                    results:
                      - index: 0
                        element: upgrade_prompt
                        status: ok
                        content: Upgrade today
                        arm_id: 3f1c2b6e-0000-4000-8000-00000000a000
                        arm_type: control
                        decision_id: 8a7b6c5d-0000-4000-8000-00000000d003
                        experiment_id: 1e2d3c4b-0000-4000-8000-00000000e001
                        experiment_status: exploring
                      - index: 1
                        element: onboarding_banner
                        status: ok
                        content: Welcome to Curious
                        arm_id: 3f1c2b6e-0000-4000-8000-00000000a010
                        arm_type: control
                        decision_id: 8a7b6c5d-0000-4000-8000-00000000d004
                        experiment_id: 1e2d3c4b-0000-4000-8000-00000000e002
                        experiment_status: initializing
                        replayed: true
                      - index: 2
                        element: upgrade_prompt
                        status: error
                        error: invalid_request
                        message: "Duplicate item: elements[2] resolves to the same user and element as elements[0]. A batch may carry each user and element pair once."
                    replayed: 1
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: |
            `idempotency_error`: the key was already used with a different
            request. `lock_timeout`: the user's identity changed while the
            decision was selecting an arm; the same request succeeds on a
            retry — the one retryable 409. Otherwise `conflict`: the element
            has no live experiment (publishable keys are serve-only), or the
            experiment is at its active-arm limit.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                idempotency:
                  value:
                    error: idempotency_error
                    message: idempotency_key was already used with a different request
                no_live_experiment:
                  value:
                    error: conflict
                    message: 'No live experiment for element "upgrade_prompt" — publishable keys are serve-only; declare the element first with a secret-key decide call or POST /declare'
                lock_timeout:
                  summary: Identity changed during selection — retry the same request
                  value:
                    error: lock_timeout
                    message: Identity changed during selection; retry the request
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/BadGateway"
        default:
          $ref: "#/components/responses/GatewayError"

  /declare:
    post:
      tags: [declare]
      operationId: declare
      summary: Configure an element without serving a user
      description: |
        The same declaration fields, validators, storage and lifecycle as a
        secret-key `decide` call — creation, supersession, in-place updates,
        declared arms — with no user upsert, no arm selection and no decision.
        Serving fields (`user_id`, `identifiers`, `attributes`, `context`,
        `idempotency_key`) are a 400 here. Secret keys only; a publishable key
        is a 403. There is no batch form.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeclareRequest"
            examples:
              declare:
                summary: Declare an element's experiment ahead of traffic
                value:
                  element: upgrade_prompt
                  element_type: paywall
                  control:
                    content: { headline: Upgrade today, body: Unlock unlimited templates with Pro. }
                  rewards:
                    - { name: purchased, properties: { plan: pro } }
                  format:
                    headline: { type: text, max_characters: 90 }
                    body: { type: text }
                  options: { approval: manual, holdout: 0.05 }
            x-rejected-examples:
              serving_field:
                message: "Declare configures an element without a user: user_id belong on decide, not here"
                value:
                  element: upgrade_prompt
                  user_id: user_123
                  control: { content: Upgrade today }
              missing_name:
                message: "element is required"
                value:
                  control: { content: Upgrade today }
      responses:
        "200":
          description: The element and its live experiment after the declaration was applied.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeclareResponse"
              examples:
                declared:
                  value:
                    element_id: 5c4d3e2f-0000-4000-8000-00000000f001
                    experiment_id: 1e2d3c4b-0000-4000-8000-00000000e001
                    experiment_status: initializing
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/BadGateway"
        default:
          $ref: "#/components/responses/GatewayError"

  /events:
    post:
      tags: [events]
      operationId: events
      parameters:
        - name: X-Curious-Client
          in: header
          required: false
          description: |
            The Curious SDK's self-identification, `browser-js/<semver>` for the
            browser SDK. Optional for customer events. Required — together with
            a publishable key — for the system events `$pageview`, `$click` and
            `$form_submit`: without it, or under a secret key, those names are
            rejected as reserved.
          schema:
            type: string
            maxLength: 64
            pattern: "^(?:browser-js|sdk-js)/[0-9]{1,4}\\.[0-9]{1,4}\\.[0-9]{1,4}(?:-[0-9A-Za-z.-]{1,32})?$"
      summary: Ingest an outcome event
      description: |
        Records that `user_id` did `name` (past tense: `purchased`, `clicked`,
        `signed_up`). `value` defaults to 1; `properties` describe the event,
        never the user; `timestamp` is the event's time and defaults to receipt.
        A caller-supplied `id` makes the event idempotent: a retry that finds
        its original reports `duplicate: true` and is still a success. Unknown
        fields are ignored. `created_at` is system-owned and rejected.

        **Batch form.** Send `events` (1–200 items); the envelope's `user_id`,
        `context` and `timestamp` apply to items that omit them. Results come back
        per item in input order inside a 200.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              # anyOf, not oneOf: the handler selects the batch form whenever
              # `events` is present and ignores unknown fields, so a batch
              # envelope may carry singular-looking fields. The singular schema
              # forbids `events`, which keeps the two branches disjoint for a
              # generator without claiming an exclusivity a linter must prove.
              anyOf:
                - $ref: "#/components/schemas/EventRequest"
                - $ref: "#/components/schemas/EventsBatchRequest"
            examples:
              purchase:
                summary: A purchase with its amount and properties
                value:
                  user_id: user_123
                  name: purchased
                  value: 49.99
                  properties: { plan: pro }
                  timestamp: "2026-04-30T22:33:42Z"
              keyed:
                summary: An idempotent event — retries dedupe on `id`
                value:
                  id: evt_8f1c2a
                  user_id: user_123
                  name: clicked
                  context: { page: /pricing }
              batch:
                summary: Batch form with envelope defaults
                value:
                  user_id: user_123
                  timestamp: "2026-04-30T22:33:42Z"
                  events:
                    - { name: viewed, properties: { page: /pricing } }
                    - { name: clicked, id: evt_9a2b3c }
                    - { user_id: user_456, name: signed_up }
              batch_with_ignored_fields:
                summary: Batch form — unknown envelope fields are ignored, and `events` alone selects it
                value:
                  user_id: user_123
                  name: ignored
                  source: sdk
                  events:
                    - { name: clicked }
            x-rejected-examples:
              reserved_name:
                message: "`$`-prefixed names are reserved for Curious SDK events"
                value:
                  user_id: user_123
                  name: $pageview
              retired_as_of:
                message: "as_of was renamed to timestamp (2026-10-15); send the event's time as timestamp"
                value:
                  user_id: user_123
                  name: purchased
                  as_of: "2026-04-30T22:33:42Z"
              system_owned:
                message: "created_at is system-owned (row write time); send the event's time as timestamp"
                value:
                  user_id: user_123
                  name: purchased
                  created_at: "2026-04-30T22:33:42Z"
              bad_timestamp:
                message: "timestamp must be an ISO-8601 datetime with an explicit timezone"
                value:
                  user_id: user_123
                  name: purchased
                  timestamp: "2026-04-30 22:33"
              timestamp_not_a_date:
                message: "timestamp must be an ISO-8601 datetime with an explicit timezone"
                value:
                  user_id: user_123
                  name: purchased
                  timestamp: tomorrow
      responses:
        "200":
          description: Success (singular form), or the per-item results (batch form).
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/EventResponse"
                  - $ref: "#/components/schemas/EventsBatchResponse"
              examples:
                success:
                  value: { status: success }
                duplicate:
                  summary: The retry found its original
                  value: { status: success, duplicate: true }
                batch:
                  value:
                    results:
                      - { index: 0, status: ok }
                      - { index: 1, id: evt_9a2b3c, status: ok, duplicate: true }
                      - index: 2
                        status: error
                        error: invalid_request
                        message: user_id and name are required
                    duplicates: 1
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/BadGateway"
        default:
          $ref: "#/components/responses/GatewayError"

  /identify:
    post:
      tags: [identify]
      operationId: identify
      summary: Link an anonymous user to a known user
      description: |
        Declares that `anonymous_id` and `user_id` are the same person and
        merges their state; prior decisions and events on either id attribute
        to the merged user. `identifiers` and `attributes` are the same two
        buckets as decide: the caller says which keys are identity and which
        are state, and nothing is routed by key name. Without `anonymous_id`
        the call only updates the user's state. Secret keys only. There is
        no batch form.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdentifyRequest"
            examples:
              link:
                value:
                  anonymous_id: anon_456
                  user_id: user_123
                  identifiers: { email: alice@example.com, name: Alice }
                  attributes: { plan: pro }
              profile_only:
                summary: Update a known user's state without a link
                value:
                  user_id: user_123
                  attributes: { plan: enterprise }
            x-rejected-examples:
              unknown_field:
                message: "Received unknown parameter: traits"
                value:
                  user_id: user_123
                  traits: { plan: pro }
              missing_user:
                message: "user_id is required"
                value:
                  anonymous_id: anon_456
      responses:
        "200":
          description: The link (or state update) was applied.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdentifyResponse"
              examples:
                success:
                  value: { status: success }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The declared identity conflicts with an existing user.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                conflict:
                  value: { error: conflict, message: Identity conflicts with an existing user }
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          $ref: "#/components/responses/BadGateway"
        default:
          $ref: "#/components/responses/GatewayError"

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        A Curious API key: `curious_sk_…` (secret) or `curious_pk_…`
        (publishable), `live` or `test` environment (`curious_sk_test_…`).
        Publishable keys are serve-only and origin-gated; `declare` and
        `identify` require a secret key. A revoked or unknown key is a 401.

  responses:
    BadRequest:
      description: |
        The request was rejected before anything was written. `error` is
        `invalid_request` (or `invalid_json` for an unparseable body) and
        `message` the human-readable reason, the same string the examples
        quote.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            unknown_field:
              value:
                error: invalid_request
                message: "Unknown field(s): segment. Allowed: element, user_id, control, arms, rewards, constraints, requirements, instructions, identifiers, attributes, context, idempotency_key, element_type, format, variables, parameters, options"
            invalid_json:
              value: { error: invalid_json, message: Invalid JSON body }
    Unauthorized:
      description: Missing, malformed, unknown or revoked API key.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            missing:
              value: { error: unauthorized, message: Missing or invalid API key }
            revoked:
              value: { error: unauthorized, message: API key has been revoked }
    Forbidden:
      description: |
        A publishable key from an origin not on its allowlist, or a
        publishable key on an endpoint that requires a secret key.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            origin:
              value: { error: forbidden, message: Origin not allowed for this key }
            secret_required:
              value: { error: forbidden, message: Declare requires a secret key }
    Conflict:
      description: The experiment is at its active-arm limit.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            arm_limit:
              value:
                error: conflict
                message: Experiment already has 50 active arms. Deactivate an active arm before activating another.
    TooManyRequests:
      description: |
        The organization exceeded its per-endpoint request rate for the
        current minute. `Retry-After` says how many seconds remain in it.
      headers:
        Retry-After:
          description: Seconds until the current minute ends.
          schema:
            type: integer
            minimum: 1
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/RateLimited"
          examples:
            limited:
              value:
                error: "rate_limit_exceeded"
                message: Request rate limit exceeded for this organization; retry after the current minute ends.
    InternalError:
      description: |
        A sanitized internal fault. Quote `request_id` to support; it
        correlates to the server-side log and error capture. A 500 after a
        keyed decide may mean the decision committed and the response was
        lost — retry with the same `idempotency_key` to find out.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorWithRequestId"
          examples:
            fault:
              value:
                error: internal_error
                message: Internal error
                request_id: 2f7e9c1a-5b3d-4e8f-9a0b-1c2d3e4f5a6b
    BadGateway:
      description: |
        The API edge could not complete the call to the origin (a network
        fault between the two, or a connection dropped before the response
        arrived). The outcome is ambiguous: the request may or may not have
        been applied. Retry only with the same `idempotency_key` (decide) or
        `id` (events), which make a retry safe either way; an unkeyed retry
        can duplicate the write. Quote `request_id` to support.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorWithRequestId"
          examples:
            unreachable:
              value:
                error: internal_error
                message: The API could not be reached, and the request may or may not have been applied; retry only with the same idempotency_key (decide) or id (events)
                request_id: 7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f
    GatewayError:
      description: |
        Any other status is a response no service function produced — the
        API edge or its gateway answered — rewritten into this same envelope
        at the gateway's status (`error` keyed by status: `not_found` for
        404, `rate_limit_exceeded` for 429, `internal_error` for 5xx). A 4xx
        means the gateway refused the request before the service (a 404 for
        a missing deployment, its own 429, which keeps `Retry-After`); nothing
        was written. A 5xx is ambiguous — a gateway 502 or 504 can follow a
        service call that committed and whose response was lost — so, as
        with the 502 above, retry only with the same `idempotency_key`
        (decide) or `id` (events). A redirect from the gateway is never
        forwarded; it arrives as the 502 above with the message `The API
        gateway answered with a redirect, which the data plane never issues;
        nothing was written`.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/RequestId"
        Retry-After:
          description: On a 429 only; seconds to wait.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            outage:
              value:
                error: internal_error
                message: The API could not be reached, and the request may or may not have been applied; retry only with the same idempotency_key (decide) or id (events)
                request_id: 3b2a1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d

  headers:
    RequestId:
      description: |
        The server-minted id of this request, on every response. Quote it to
        support; on an internal fault it equals the body's `request_id`.
        Browsers can read it (`Access-Control-Expose-Headers`).
      schema:
        type: string
        format: uuid
  schemas:
    # ---------------------------------------------------------------- errors
    ErrorCode:
      type: string
      description: Stable, machine-readable; an SDK maps these to exception classes.
      enum:
        - invalid_request
        - invalid_json
        - unauthorized
        - forbidden
        - not_found
        - method_not_allowed
        - conflict
        - idempotency_error
        - lock_timeout
        - rate_limit_exceeded
        - internal_error
    Error:
      type: object
      required: [error, message]
      properties:
        error:
          $ref: "#/components/schemas/ErrorCode"
        message:
          type: string
          description: Human-readable reason; the text this document quotes.
        request_id:
          type: string
          format: uuid
          description: Present on internal faults only.
      additionalProperties: false
    ErrorWithRequestId:
      type: object
      required: [error, message, request_id]
      properties:
        error:
          type: string
          const: internal_error
        message:
          type: string
        request_id:
          type: string
          format: uuid
          description: Correlation handle into the server-side logs; not stored.
      additionalProperties: false
    RateLimited:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          const: rate_limit_exceeded
        message:
          type: string
        request_id:
          type: string
          format: uuid
          description: Present when the 429 came from the API gateway rather than the service (the id is in the header either way).
      additionalProperties: false

    # ------------------------------------------------------------ primitives
    Uuid:
      type: string
      format: uuid
    JsonObject:
      type: object
      description: An open JSON object. Values may be any JSON.
    ContentValue:
      description: |
        A content value at any depth: a string, a finite number, an object of
        values, or an array of values. Never a boolean or null, and never a
        field named `type` or `__proto__` — the format grammar cannot represent
        them. Two rules stay in prose because JSON Schema cannot state them:
        arrays are homogeneous, and nesting stops at 4 container levels.
      oneOf:
        - type: string
        - type: number
        - type: object
          minProperties: 1
          propertyNames:
            not:
              enum: [type, __proto__]
          additionalProperties:
            $ref: "#/components/schemas/ContentValue"
        - type: array
          maxItems: 100
          items:
            $ref: "#/components/schemas/ContentValue"
    Content:
      description: |
        Arm content: whatever the caller's renderer displays — a string, a
        number, or a non-empty object of fields. Never an array or a boolean at
        the top level; nested values follow `ContentValue`.
      oneOf:
        - type: string
        - type: number
        - type: object
          minProperties: 1
          propertyNames:
            not:
              enum: [type, __proto__]
          additionalProperties:
            $ref: "#/components/schemas/ContentValue"
    ContentWrapper:
      type: object
      description: Content with an optional hypothesis about why it will perform.
      required: [content]
      properties:
        content:
          $ref: "#/components/schemas/Content"
        hypothesis:
          type: [string, "null"]
          description: What this content is testing. Blank is stored as null.
      additionalProperties: false
    BareContent:
      description: |
        Content passed without the wrapper. An object with a `content` key is
        always read as the wrapper, so bare object content never has one.
      allOf:
        - $ref: "#/components/schemas/Content"
        - not:
            type: object
            required: [content]
            properties:
              content: {}
    ContentOrWrapper:
      description: Bare content, or `{content, hypothesis}`. An object with a `content` key is the wrapper.
      oneOf:
        - $ref: "#/components/schemas/ContentWrapper"
        - $ref: "#/components/schemas/BareContent"

    # ---------------------------------------------------------- declaration
    Control:
      description: |
        The counterfactual baseline: the content you would serve without
        Curious. Singular, never an array; required whenever the call creates
        an experiment. A changed control supersedes the experiment.
      $ref: "#/components/schemas/ContentOrWrapper"
    Arms:
      description: |
        Additional starting points beside the control: one entry or a list,
        each bare content or `{content, hypothesis}`. Additive and idempotent:
        an entry the experiment already has is skipped. Entries that duplicate
        an earlier entry in the same request are skipped with an `arm_invalid`
        warning, and so are entries that violate the format or the
        requirements, or that would exceed the 50-active-arm cap. Two rules are
        request errors (400): an entry whose content equals the control's, and
        more than 10 distinct entries after duplicates are dropped — the limit
        counts distinct contents, so the list itself is not capped here.
      oneOf:
        - $ref: "#/components/schemas/ContentOrWrapper"
        - type: array
          items:
            $ref: "#/components/schemas/ContentOrWrapper"
    Reward:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          description: Past-tense event name (`purchased`, `clicked`).
        type:
          type: string
          enum: [binary, count, semicontinuous, continuous]
          default: binary
          description: The statistical shape of the reward; it selects the model.
        weight:
          type: number
          default: 1
          not:
            const: 0
          description: Finite and non-zero. Negative for cost-like attribution.
        window_seconds:
          type: integer
          minimum: 1
          default: 86400
          description: Attribution window after the decision.
        properties:
          $ref: "#/components/schemas/JsonObject"
          description: Only events whose properties match attribute to this reward.
        value:
          type: string
          pattern: "^(event\\.value|event\\.properties\\..+|arm\\.[^.\\[]+)$"
          description: |
            Value source, `semicontinuous` rewards only: `event.value`
            (default), `event.properties.<key>`, or `arm.<field>` — the served
            arm's top-level content field (no nested or array paths) supplies
            the amount, and the format must bound that field as a number with
            `min` and `max`.
        baseline_rate:
          type: [number, "null"]
          description: |
            Optional prior information, never identity: the rate the control
            currently achieves, in the reward's own units. Binary and
            semicontinuous rewards declare an occurrence probability strictly
            between 0 and 1; count rewards a strictly positive expected count;
            continuous rewards any finite value.
      additionalProperties: false
      allOf:
        # A value source is meaningful only for semicontinuous rewards; the
        # handler rejects it on any other type (the default type is binary).
        - if:
            required: [value]
            properties:
              value: {}
          then:
            required: [type]
            properties:
              type:
                const: semicontinuous
        # baseline_rate bounds follow the type (an omitted type is binary):
        # strictly between 0 and 1 unless the type is count or continuous, and
        # strictly positive when it is count.
        - dependentSchemas:
            baseline_rate:
              anyOf:
                - required: [type]
                  properties:
                    type:
                      enum: [count, continuous]
                - properties:
                    baseline_rate:
                      exclusiveMinimum: 0
                      exclusiveMaximum: 1
        - dependentSchemas:
            baseline_rate:
              anyOf:
                - not:
                    required: [type]
                    properties:
                      type: {}
                - properties:
                    type:
                      not:
                        const: count
                - properties:
                    baseline_rate:
                      exclusiveMinimum: 0
    Rewards:
      description: |
        The rewards the experiment optimizes. Shorthand: a name, an object, or
        a list of either; a bare name means a binary reward with weight 1 and
        a one-day window.
      oneOf:
        - type: string
          minLength: 1
        - $ref: "#/components/schemas/Reward"
        - type: array
          items:
            oneOf:
              - type: string
                minLength: 1
              - $ref: "#/components/schemas/Reward"
    Constraint:
      type: object
      required: [name, threshold_type, threshold, bound]
      properties:
        name:
          type: string
          minLength: 1
          description: Past-tense event name.
        type:
          type: string
          enum: [binary, continuous]
          default: binary
        threshold_type:
          type: string
          enum: [relative, absolute]
        threshold:
          type: number
          description: Finite. For a binary constraint with an absolute threshold, a rate in [0, 1].
        bound:
          type: string
          enum: [upper, lower]
        window_seconds:
          type: integer
          minimum: 1
          default: 86400
        properties:
          $ref: "#/components/schemas/JsonObject"
      additionalProperties: false
      # A binary constraint's absolute threshold is a rate. An omitted type is
      # binary; `properties` constrains `type` only when present.
      if:
        required: [threshold_type]
        properties:
          threshold_type:
            const: absolute
          type:
            const: binary
      then:
        properties:
          threshold:
            minimum: 0
            maximum: 1
    Constraints:
      type: array
      description: |
        Guardrails an arm may not breach. Each `(name, type, bound,
        properties)` identity may appear once; `threshold`, `threshold_type`
        and `window_seconds` are its adjustable fields. Changing constraints
        never supersedes the experiment.
      items:
        $ref: "#/components/schemas/Constraint"
    Requirements:
      type: object
      description: Phrase rules every arm must satisfy. Changing them supersedes the experiment.
      properties:
        forbidden_phrases:
          type: array
          items: { type: string }
        required_phrases:
          type: array
          items: { type: string }
      additionalProperties: false
    TextFieldSpec:
      type: object
      description: A text or HTML field, optionally bounded in characters (`min_characters` ≤ `max_characters`).
      required: [type]
      properties:
        type:
          type: string
          enum: [text, html]
        min_characters:
          type: integer
          minimum: 1
        max_characters:
          type: integer
          minimum: 1
      additionalProperties: false
    NumberFieldSpec:
      type: object
      description: A numeric field, optionally bounded (`min` ≤ `max`). Bounds are enforced on content.
      required: [type]
      properties:
        type:
          type: string
          const: number
        min:
          type: number
        max:
          type: number
      additionalProperties: false
    EnumFieldSpec:
      type: object
      description: A field whose value is one of `values`. Enum values are strings.
      required: [type, values]
      properties:
        type:
          type: string
          const: enum
        values:
          type: array
          minItems: 1
          items: { type: string }
      additionalProperties: false
    ArrayFieldSpec:
      type: object
      description: A homogeneous array of `items`. Defaults to 20 items; `max_items` may be declared up to 100.
      required: [type, items]
      properties:
        type:
          type: string
          const: array
        items:
          $ref: "#/components/schemas/FormatFieldSpec"
        min_items:
          type: integer
          minimum: 0
        max_items:
          type: integer
          minimum: 1
          maximum: 100
      additionalProperties: false
    ObjectOfFieldSpecs:
      type: object
      description: Named fields, each a spec. Never a field named `__proto__`, and never one named `type` (that key makes the object a typed spec).
      minProperties: 1
      propertyNames:
        not:
          enum: [type, __proto__]
      additionalProperties:
        $ref: "#/components/schemas/FormatFieldSpec"
    FormatFieldSpec:
      description: |
        A field spec is a typed spec (text, html, number, enum or array) or an
        object mapping field names to specs. At most 4 container levels.
      oneOf:
        - $ref: "#/components/schemas/TextFieldSpec"
        - $ref: "#/components/schemas/NumberFieldSpec"
        - $ref: "#/components/schemas/EnumFieldSpec"
        - $ref: "#/components/schemas/ArrayFieldSpec"
        - $ref: "#/components/schemas/ObjectOfFieldSpecs"
    Format:
      description: |
        The structural content contract: a single field spec, or an object of
        named field specs. Omitted on a first call, it is inferred from the
        control; declared, the control must match it. A changed format
        supersedes the experiment.
      $ref: "#/components/schemas/FormatFieldSpec"
    Options:
      type: object
      description: |
        Serving-policy knobs. `assignment`, `approval` and
        `minimum_effect_size` update in place; `holdout` is fixed for the
        experiment's life and a differing value supersedes it. Every default
        is resolved when the experiment is created and stored with it, so a
        later change to a default never alters a live experiment; an omitted
        key on a later call carries the stored value forward.
      properties:
        assignment:
          type: string
          enum: [static, dynamic]
          default: static
          description: "`static` (the default): the same user keeps the same arm while it stays active. `dynamic`: every call is chosen afresh, for per-send channels such as push or email."
        approval:
          type: string
          enum: [auto, manual]
          description: Whether generated arms serve immediately or wait for operator review. Defaults to the predecessor's value, then the organization's, then `manual`.
        holdout:
          type: number
          minimum: 0.02
          maximum: 0.2
          default: 0.05
          description: Share of users held out as the control cohort.
        minimum_effect_size:
          type: number
          minimum: 0.05
          maximum: 1
          default: 0.2
          description: |
            The smallest relative difference from the control that matters to
            you. Two arms whose contrast is confidently inside this share of the
            control's composite count as practically equivalent, and one of them
            is retired. Mutable in place; never widened by the platform.
      additionalProperties: false
    Parameters:
      type: object
      description: |
        Element-content facts fixed for the experiment and reproduced verbatim
        through `{{placeholders}}`. Keys match `^\w+$`; the namespace is
        disjoint from `variables`. Any change supersedes the experiment.
      propertyNames:
        pattern: "^\\w+$"
        not:
          const: __proto__
      additionalProperties:
        description: Any JSON value. Only non-null scalars are substituted into placeholders; structured values are visible to generation.
    Variables:
      type: array
      description: |
        Names that arm content may reference as `{{name|fallback}}`,
        substituted from the user's identifiers and attributes at decision
        time. Defaults, on creation, to the identifier and attribute keys sent.
        Updates in place.
      items:
        type: string
        pattern: "^\\w+$"
        not:
          const: __proto__
    ElementType:
      type: [string, "null"]
      enum: [push_notification, email_subject, email_preview, email_body, email, button_cta, hero, landing_page, blog_post, paywall, null]
      description: The element's kind. Case-insensitive on input. Null means unset.

    # --------------------------------------------------------------- decide
    DecideRequest:
      type: object
      required: [element, user_id]
      properties:
        element:
          type: string
          minLength: 1
          pattern: "\\S"
          description: Identifier of the surface being optimized.
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
          description: The caller's identifier for the user.
        identifiers:
          $ref: "#/components/schemas/JsonObject"
          description: Durable identity (email, name, phone); merged into the user.
        attributes:
          $ref: "#/components/schemas/JsonObject"
          description: Durable user state (plan, company_size); merged into the user and used for personalization.
        context:
          $ref: "#/components/schemas/JsonObject"
          description: Situational facts for this decision only (device, page); wins over stored attributes on a key collision.
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 255
          pattern: "^[^\\u0000\\uD800-\\uDFFF]*$"
          description: |
            An opaque key, 1–255 characters, generated before the first attempt
            and reused on every retry of the same logical request. Same key and
            same request replays the original decision; same key and a
            different request is a 409. A UUID is the recommended shape. The
            key may not contain U+0000 or an unpaired surrogate.
        control:
          $ref: "#/components/schemas/Control"
        arms:
          $ref: "#/components/schemas/Arms"
        rewards:
          $ref: "#/components/schemas/Rewards"
        constraints:
          $ref: "#/components/schemas/Constraints"
        requirements:
          $ref: "#/components/schemas/Requirements"
        instructions:
          type: string
          description: Guidance for future generation rounds. Updates in place.
        element_type:
          $ref: "#/components/schemas/ElementType"
        format:
          $ref: "#/components/schemas/Format"
        variables:
          $ref: "#/components/schemas/Variables"
        parameters:
          $ref: "#/components/schemas/Parameters"
        options:
          $ref: "#/components/schemas/Options"
      additionalProperties: false
    DecideBatchItem:
      type: object
      description: |
        A singular request. `element` is required per item; `user_id` may
        come from the envelope. This schema describes a valid
        item, the shape a client should send; the endpoint itself accepts any
        JSON per item and reports an item that fails validation inside the 200
        envelope rather than rejecting the request.
      required: [element]
      properties:
        element:
          type: string
          minLength: 1
          pattern: "\\S"
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
        identifiers:
          $ref: "#/components/schemas/JsonObject"
        attributes:
          $ref: "#/components/schemas/JsonObject"
        context:
          $ref: "#/components/schemas/JsonObject"
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 255
          pattern: "^[^\\u0000\\uD800-\\uDFFF]*$"
        control:
          $ref: "#/components/schemas/Control"
        arms:
          $ref: "#/components/schemas/Arms"
        rewards:
          $ref: "#/components/schemas/Rewards"
        constraints:
          $ref: "#/components/schemas/Constraints"
        requirements:
          $ref: "#/components/schemas/Requirements"
        instructions:
          type: string
        element_type:
          $ref: "#/components/schemas/ElementType"
        format:
          $ref: "#/components/schemas/Format"
        variables:
          $ref: "#/components/schemas/Variables"
        parameters:
          $ref: "#/components/schemas/Parameters"
        options:
          $ref: "#/components/schemas/Options"
      additionalProperties: false
    DecideBatchRequest:
      type: object
      required: [elements]
      properties:
        elements:
          type: array
          minItems: 1
          maxItems: 50
          items:
            $ref: "#/components/schemas/DecideBatchItem"
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
          description: Default for items that omit it.
        identifiers:
          $ref: "#/components/schemas/JsonObject"
        attributes:
          $ref: "#/components/schemas/JsonObject"
        context:
          $ref: "#/components/schemas/JsonObject"
        options:
          $ref: "#/components/schemas/Options"
      additionalProperties: false
    Warning:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [arm_invalid, serving_control_only]
        message:
          type: string
      additionalProperties: false
    ExperimentStatus:
      type: string
      enum: [initializing, exploring, concluded]
      description: "`initializing` until the first model is minted, then `exploring`; `concluded` is terminal."
    DecideResponse:
      type: object
      required: [content, arm_id, arm_type, decision_id, experiment_id, experiment_status]
      properties:
        content:
          $ref: "#/components/schemas/Content"
          description: The selected arm's content after `{{variable}}` substitution.
        arm_id:
          $ref: "#/components/schemas/Uuid"
        arm_type:
          type: [string, "null"]
          enum: [control, variant, null]
        decision_id:
          $ref: "#/components/schemas/Uuid"
          description: The id of the decision row this call committed. Repeated serves of the same arm carry different ids.
        experiment_id:
          $ref: "#/components/schemas/Uuid"
        experiment_status:
          $ref: "#/components/schemas/ExperimentStatus"
        warnings:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/Warning"
          description: Present only when non-empty.
        replayed:
          type: boolean
          const: true
          description: Present only when this is the stored response to an earlier request with the same `idempotency_key`.
      additionalProperties: false
    DecideBatchItemOk:
      type: object
      required: [index, element, status, content, arm_id, arm_type, decision_id, experiment_id, experiment_status]
      properties:
        index:
          type: integer
          minimum: 0
        element:
          type: string
        status:
          type: string
          const: ok
        content:
          $ref: "#/components/schemas/Content"
        arm_id:
          $ref: "#/components/schemas/Uuid"
        arm_type:
          type: [string, "null"]
          enum: [control, variant, null]
        decision_id:
          $ref: "#/components/schemas/Uuid"
        experiment_id:
          $ref: "#/components/schemas/Uuid"
        experiment_status:
          $ref: "#/components/schemas/ExperimentStatus"
        warnings:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/Warning"
        replayed:
          type: boolean
          const: true
      additionalProperties: false
    DecideBatchItemError:
      type: object
      required: [index, status, error, message]
      properties:
        index:
          type: integer
          minimum: 0
        element:
          type: [string, "null"]
          description: The item's element name as sent; absent when the item was not an object.
        status:
          type: string
          const: error
        error:
          $ref: "#/components/schemas/ErrorCode"
        message:
          type: string
          description: The human-readable reason.
        request_id:
          type: string
          format: uuid
          description: Present on internal faults only.
      additionalProperties: false
    DecideBatchResponse:
      type: object
      required: [results, replayed]
      properties:
        results:
          type: array
          description: One result per item, in input order.
          items:
            oneOf:
              - $ref: "#/components/schemas/DecideBatchItemOk"
              - $ref: "#/components/schemas/DecideBatchItemError"
        replayed:
          type: integer
          minimum: 0
          description: How many items were replays.
      additionalProperties: false

    # -------------------------------------------------------------- declare
    DeclareRequest:
      type: object
      required: [element]
      properties:
        element:
          type: string
          minLength: 1
          pattern: "\\S"
        control:
          $ref: "#/components/schemas/Control"
        arms:
          $ref: "#/components/schemas/Arms"
        rewards:
          $ref: "#/components/schemas/Rewards"
        constraints:
          $ref: "#/components/schemas/Constraints"
        requirements:
          $ref: "#/components/schemas/Requirements"
        instructions:
          type: string
        element_type:
          $ref: "#/components/schemas/ElementType"
        format:
          $ref: "#/components/schemas/Format"
        variables:
          $ref: "#/components/schemas/Variables"
        parameters:
          $ref: "#/components/schemas/Parameters"
        options:
          $ref: "#/components/schemas/Options"
      additionalProperties: false
    DeclareResponse:
      type: object
      required: [element_id, experiment_id, experiment_status]
      properties:
        element_id:
          $ref: "#/components/schemas/Uuid"
        experiment_id:
          $ref: "#/components/schemas/Uuid"
        experiment_status:
          $ref: "#/components/schemas/ExperimentStatus"
        warnings:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/Warning"
      additionalProperties: false

    # --------------------------------------------------------------- events
    EventId:
      type: string
      minLength: 1
      description: The caller's id for this event; makes it idempotent per organization and environment.
    EventName:
      type: string
      minLength: 1
      pattern: "^(?:\\s*[^\\s$]|\\$(?:pageview|click|form_submit)$)"
      description: |
        Past tense (`purchased`, `clicked`). Names starting with `$` are
        reserved for Curious SDK system events: a customer integration sending
        one gets a 400 (rename at the source, e.g. `$pageview` → `pageview`).
        The only `$` names the schema admits are the browser SDK's own —
        `$pageview`, `$click` and `$form_submit` — and the endpoint accepts
        them only under a publishable key with the SDK's client header
        (`SystemEventConstraints` bounds their properties).
    SystemEventConstraints:
      description: |
        What the endpoint enforces on a system event (docs/events.md, Browser
        provenance): allowlisted string properties only — `$pageview` takes
        `path`, a normalized path of at most 2048 characters; `$click` and
        `$form_submit` take `element_name`, at most 256 characters — and no
        `context` (the browser SDK sends none). Applied to the singular request
        and to every batch item; a customer event is unconstrained here.
      allOf:
        - if:
            required: [name]
            properties:
              name:
                const: $pageview
          then:
            properties:
              properties:
                type: object
                additionalProperties: false
                properties:
                  path:
                    type: string
                    maxLength: 2048
                    pattern: "^/(?!/)[^\\s?#\\x00-\\x1f\\x7f\\u0080-\\u009f\\uD800-\\uDFFF]*$"
              context:
                not: {}
        - if:
            required: [name]
            properties:
              name:
                enum: [$click, $form_submit]
          then:
            properties:
              properties:
                type: object
                additionalProperties: false
                properties:
                  element_name:
                    type: string
                    maxLength: 256
                    pattern: "^[^\\u0000\\uD800-\\uDFFF]*$"
              context:
                not: {}
    EventValue:
      type: [number, "null"]
      default: 1
      description: Finite. Null and absent both mean 1.
    EventTimestamp:
      type: string
      pattern: "^\\d{4}-\\d{2}-\\d{2}(?:[T ]\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|z|[+-]\\d{2}(?::?\\d{2})?))?$"
      description: |
        When the event happened: an ISO-8601 datetime with an explicit
        timezone (`2026-04-30T22:33:42Z`, `2026-04-30 22:33:42+00:00`,
        `+00` and `+0000` offsets included) or a date, read as UTC midnight. A
        naive datetime is rejected. Must be a real calendar date and at most
        5 minutes in the future. Defaults to receipt.
    SystemOwnedCreatedAt:
      description: "`created_at` is the row's write time and system-owned; sending it is a 400. Send the event's time as `timestamp`."
      not: {}
    EventRequest:
      type: object
      description: Unknown fields are ignored, except `created_at` (system-owned) and the retired `as_of`, which are a 400. A body with `events` is the batch form, whatever else it carries.
      required: [user_id, name]
      additionalProperties: true
      allOf:
        - $ref: "#/components/schemas/SystemEventConstraints"
      properties:
        events:
          description: Not on a singular event; its presence selects the batch form.
          not: {}
        as_of:
          description: The wire name until 2026-10-15; rejected rather than ignored, because ignoring it would mean receipt time.
          not: {}
        id:
          $ref: "#/components/schemas/EventId"
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
        name:
          $ref: "#/components/schemas/EventName"
        value:
          $ref: "#/components/schemas/EventValue"
        properties:
          $ref: "#/components/schemas/JsonObject"
          description: Facts about the event, never about the user.
        context:
          $ref: "#/components/schemas/JsonObject"
          description: Situational facts at event time.
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        created_at:
          $ref: "#/components/schemas/SystemOwnedCreatedAt"
    EventsBatchItem:
      type: object
      description: |
        A singular event; `user_id`, `context` and `timestamp` may come from the
        envelope. Unknown fields are ignored. This schema describes a valid
        item, the shape a client should send; the endpoint itself accepts any
        JSON per item and reports an item that fails validation (a missing
        `name`, a bad `timestamp`, a non-numeric `value`) inside the 200 envelope,
        never as a 400 for the request.
      additionalProperties: true
      allOf:
        - $ref: "#/components/schemas/SystemEventConstraints"
      properties:
        id:
          $ref: "#/components/schemas/EventId"
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
        name:
          $ref: "#/components/schemas/EventName"
        value:
          $ref: "#/components/schemas/EventValue"
        properties:
          $ref: "#/components/schemas/JsonObject"
        context:
          $ref: "#/components/schemas/JsonObject"
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        as_of:
          description: Retired wire name; an item-level error, never silently receipt time.
          not: {}
        created_at:
          $ref: "#/components/schemas/SystemOwnedCreatedAt"
    EventsBatchRequest:
      type: object
      description: Unknown envelope fields are ignored.
      required: [events]
      additionalProperties: true
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 200
          items:
            $ref: "#/components/schemas/EventsBatchItem"
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
          description: Default for items that omit it.
        context:
          $ref: "#/components/schemas/JsonObject"
          description: Default for items that omit it.
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
          description: Default for items that omit it.
        as_of:
          description: The retired name of the envelope default; a 400, never silently receipt time.
          not: {}
    EventResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: success
        duplicate:
          type: boolean
          const: true
          description: Present when an event with this `id` already existed.
      additionalProperties: false
    EventsBatchItemOk:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
          minimum: 0
        id:
          type: string
          description: Echoed when the item supplied one.
        status:
          type: string
          const: ok
        duplicate:
          type: boolean
          const: true
          description: Present when an event with this `id` already existed, or an earlier item in the batch carried the same `id`.
      additionalProperties: false
    EventsBatchItemError:
      type: object
      required: [index, status, error, message]
      properties:
        index:
          type: integer
          minimum: 0
        id:
          type: string
          description: Echoed when the item supplied a non-empty one.
        status:
          type: string
          const: error
        error:
          $ref: "#/components/schemas/ErrorCode"
        message:
          type: string
        request_id:
          type: string
          format: uuid
          description: Present on internal faults only.
      additionalProperties: false
    EventsBatchResponse:
      type: object
      required: [results, duplicates]
      properties:
        results:
          type: array
          description: One result per item, in input order.
          items:
            oneOf:
              - $ref: "#/components/schemas/EventsBatchItemOk"
              - $ref: "#/components/schemas/EventsBatchItemError"
        duplicates:
          type: integer
          minimum: 0
      additionalProperties: false

    # ------------------------------------------------------------- identify
    IdentifyRequest:
      type: object
      required: [user_id]
      properties:
        user_id:
          type: string
          minLength: 1
          pattern: "\\S"
          description: The known user.
        anonymous_id:
          type: string
          minLength: 1
          pattern: "\\S"
          description: The anonymous user to link into `user_id`.
        identifiers:
          $ref: "#/components/schemas/JsonObject"
          description: Durable identity (email, name, phone); merged into the user. Never a model feature.
        attributes:
          $ref: "#/components/schemas/JsonObject"
          description: Durable user state (plan, company_size); merged into the user and used for personalization.
      additionalProperties: false
    IdentifyResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: success
      additionalProperties: false
