{
  "openapi": "3.1.0",
  "info": {
    "title": "Botsi AI Pricing API",
    "version": "1.0.0",
    "summary": "Public HTTP API for Botsi's AI paywall-variant personalization.",
    "description": "Botsi is a personalization engine for monetization. For each user it selects which of your pre-configured paywall variants to show, the one predicted to maximize their lifetime value.\n\n**Botsi never computes, generates, or alters a price.** Every price a user sees is one you configured in advance, in the Botsi dashboard and in your own app code or paywall tool. The Get Paywall response identifies a variant by `externalId` and carries no price field. `sourceProducts` lists store product identifiers, not prices; each product's price is the one you set in App Store Connect or Google Play Console. Hardcoded per-variant price constants in client code are the expected, correct integration pattern.\n\nTypical flow:\n\n1. `POST /profiles` on first app launch.\n2. `POST /profiles/custom-attributes` (or `/custom-attributes-all`) during onboarding, before requesting a paywall.\n3. `POST /paywalls` to fetch the predicted variant. Call this early and cache the result; it typically takes ~40ms but can take up to 2 seconds, so do not call it synchronously at paywall render time.\n4. `POST /events` with `eventType: \"paywall_shown\"`, echoing `paywallSessionId`, once the paywall is displayed. This is required for the model to learn, and must be sent even when the user does not purchase.\n5. `POST /purchases/apple-store/validate` or `POST /purchases/play-store/validate` after a successful transaction.\n\nBotsi does not accept demographic or personally identifiable data. Do not send names, emails, ages, or similar; send contextual and behavioral data only.\n\nThis document was generated from the hand-written integration reference linked in `x-source`. Where that reference does not specify something (for example, the exact response body of the custom-attribute, event, and purchase-validation endpoints), the schema here is deliberately left open and says so in its `description` rather than guessing.",
    "termsOfService": "https://botsi.com/terms",
    "contact": {
      "name": "Botsi",
      "url": "https://botsi.com/docs",
      "email": "hello@botsi.com"
    },
    "x-source": "https://botsi.com/botsi-ai-pricing-api-reference.md"
  },
  "externalDocs": {
    "description": "Botsi documentation",
    "url": "https://botsi.com/docs"
  },
  "servers": [
    {
      "url": "https://app.botsi.com/api/v1/web-api",
      "description": "Production. Always use HTTPS."
    }
  ],
  "security": [
    {
      "SecretKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Profiles",
      "description": "Create the user in Botsi and enrich them with custom attributes."
    },
    {
      "name": "Paywalls",
      "description": "Fetch the AI-predicted paywall variant for a user and placement."
    },
    {
      "name": "Events",
      "description": "Report what was shown to the user so the model can learn."
    },
    {
      "name": "Purchases",
      "description": "Validate App Store and Google Play transactions with Botsi."
    }
  ],
  "paths": {
    "/profiles": {
      "post": {
        "operationId": "createProfile",
        "tags": [
          "Profiles"
        ],
        "summary": "Create Profile",
        "description": "Call on first app launch to initialize the user in Botsi. Required before any other API call.\n\nStore the returned `profileId`: it is required for all subsequent calls. Sending `customerUserId` (your own internal user ID) is strongly recommended, because it is what makes cross-device tracking reliable.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateProfileRequest"
              },
              "example": {
                "country": "US",
                "device": "iPhone 15 Pro Max",
                "os": "iOS 17.0",
                "platform": "ios",
                "customerUserId": "user-123",
                "locale": "en_US",
                "currency": "USD"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. The profile for this request is returned.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProfileResponse"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "profileId": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
                    "customerUserId": "user-123",
                    "paid": false,
                    "country": "US",
                    "locale": "en_US",
                    "currency": "USD",
                    "device": "iPhone 15 Pro Max",
                    "os": "iOS 17.0",
                    "platform": "ios"
                  }
                }
              }
            }
          },
          "201": {
            "description": "Created. The reference lists 200 and 201 together in a single API-wide status table and does not bind either code to a specific endpoint, so treat any 2xx as success and read the body the same way.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProfileResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/profiles/custom-attributes": {
      "post": {
        "operationId": "addCustomAttribute",
        "tags": [
          "Profiles"
        ],
        "summary": "Add custom attribute",
        "description": "Add a single custom attribute to a profile.\n\nCustom attributes give Botsi context about the user (onboarding answers, behavioral signals) and improve the accuracy of the paywall-variant prediction. Send them BEFORE requesting a paywall. Best practice during onboarding is to send each answer as its own attribute as the user progresses, so that Botsi has full context by the time the user reaches the paywall.\n\nThe response to this call carries the `attrId` you need in order to update the attribute later via `PUT /profiles/custom-attributes`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCustomAttributeRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "custom": {
                  "key": "fitness_goal",
                  "value": "weight_loss"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomAttributeResponse"
                }
              }
            }
          },
          "201": {
            "description": "Created. The reference lists 200 and 201 together in a single API-wide status table and does not bind either code to a specific endpoint, so treat any 2xx as success and read the body the same way.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomAttributeResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "put": {
        "operationId": "updateCustomAttribute",
        "tags": [
          "Profiles"
        ],
        "summary": "Update existing custom attribute",
        "description": "Update a custom attribute that is already on the profile. Requires the `attrId` returned when the attribute was originally added.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateCustomAttributeRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "custom": {
                  "key": "fitness_goal",
                  "value": "muscle_gain",
                  "attrId": "abc123"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomAttributeResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/profiles/custom-attributes-all": {
      "post": {
        "operationId": "addCustomAttributes",
        "tags": [
          "Profiles"
        ],
        "summary": "Add multiple custom attributes",
        "description": "Add several custom attributes to a profile in one call. Same semantics as `POST /profiles/custom-attributes`, but `custom` is an array of key/value pairs instead of a single pair. Send these before requesting a paywall.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddCustomAttributesAllRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "custom": [
                  {
                    "key": "fitness_goal",
                    "value": "weight_loss"
                  },
                  {
                    "key": "experience_level",
                    "value": "beginner"
                  },
                  {
                    "key": "preferred_workout",
                    "value": "home"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomAttributeResponse"
                }
              }
            }
          },
          "201": {
            "description": "Created. The reference lists 200 and 201 together in a single API-wide status table and does not bind either code to a specific endpoint, so treat any 2xx as success and read the body the same way.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomAttributeResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/paywalls": {
      "post": {
        "operationId": "getPaywall",
        "tags": [
          "Paywalls"
        ],
        "summary": "Get Paywall",
        "description": "Fetch the AI-predicted optimal paywall variant for this user and placement.\n\nThe response identifies which of your pre-configured variants to show. **It contains no prices and your app is not expected to read prices from it.** The prices for that variant are the ones you configured in advance. `sourceProducts` lists store product identifiers configured for the selected variant; those are identifiers, not prices.\n\nPerformance: this call typically takes ~40ms but can take up to 2 seconds. Call it early in the user journey (for example during onboarding) and cache the result. Do NOT call it synchronously at paywall render time.\n\nTraffic Control: the AI Pricing Model behind a placement always has a Traffic Control distribution, and the target that serves a request is drawn **per request**, not per user. Consecutive responses for the same `profileId` are not a stable experiment assignment. A request that arrives without a `profileId` never reaches the AI Model target and is served the Baseline paywall instead. If an allocation points back at this model's own placement, the request fails with `Circular placement allocation detected` followed by the placement chain, instead of returning a paywall.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GetPaywallRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "placementId": "onboarding_paywall"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. The selected paywall variant for this user and placement.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaywallResponse"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "id": 42,
                    "externalId": "paywall_premium_v2",
                    "name": "Premium Paywall",
                    "isExperiment": true,
                    "aiPricingModelId": 32,
                    "paywallSessionId": "v1.eyJhIjo0MDIxLCJ3Ijo5MDUsInAiOiJvbmJvYXJkaW5nIiwiaSI6MTc4NTMxMjAwMH0.QmzR1w",
                    "sourceProducts": [
                      {
                        "productId": "premium_monthly",
                        "basePlanId": "monthly-base",
                        "offerId": "intro-offer-7d"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events": {
      "post": {
        "operationId": "sendProfileEvents",
        "tags": [
          "Events"
        ],
        "summary": "Send Profile Event (log impression)",
        "description": "Log that the paywall was shown to the user. This is REQUIRED for the AI to learn, and must be sent even when the user does not purchase, because the model learns from non-conversions as well as conversions.\n\nThe request body is an **array** of event objects.\n\nToken rules:\n\n- A `paywall_shown` event is accepted for 24 hours after the fetch that minted the token. Every other event type, and all transactions, accept a token of any age.\n- If a request carries both a token and explicit fields that disagree with it, Botsi persists the token's values.\n- A token that fails its signature check, or that was issued for a different app, returns 400 and nothing is stored. Unknown or stale `abTestId` values are still accepted.\n- Sending no token is still valid; the explicit fields are deprecated in the documentation only, and nothing is being removed.\n\nDocumented 400 messages: `paywallSessionId is not a valid Botsi paywall session token.` (malformed, truncated, or signature does not verify) · `paywallSessionId was issued for a different app.` · `paywallSessionId uses an unsupported token version.` · `paywallSessionId has expired for this event type. Fetch a new paywall before reporting the view.`",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendProfileEventsRequest"
              },
              "example": [
                {
                  "eventType": "paywall_shown",
                  "paywallSessionId": "v1.eyJhIjo0MDIxLCJ3Ijo5MDUsInAiOiJvbmJvYXJkaW5nIiwiaSI6MTc4NTMxMjAwMH0.QmzR1w",
                  "customerUserId": "user-123"
                }
              ]
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. The events were accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventsResponse"
                }
              }
            }
          },
          "201": {
            "description": "Created. The reference lists 200 and 201 together in a single API-wide status table and does not bind either code to a specific endpoint, so treat any 2xx as success and read the body the same way.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventsResponse"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request — missing required parameter, or an invalid `paywallSessionId`. When the token fails validation nothing is stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/purchases/apple-store/validate": {
      "post": {
        "operationId": "validateApplePurchase",
        "tags": [
          "Purchases"
        ],
        "summary": "Validate Apple purchase",
        "description": "Validate an App Store transaction with Botsi after a successful purchase, so the model learns from the conversion.\n\nPass `paywallId`, `isExperiment`, and `aiPricingModelId` exactly as received from Get Paywall. Modifying these values corrupts experiment attribution. This endpoint does not accept `paywallSessionId`, which is why `isExperiment` and `aiPricingModelId` are still carried explicitly here.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ValidateApplePurchaseRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "productId": "premium_monthly",
                "transactionId": "2000000123456789",
                "originalTransactionId": "2000000123456789",
                "paywallId": 42,
                "isExperiment": true,
                "aiPricingModelId": 32,
                "price": 9.99,
                "currency": "USD",
                "environment": "production"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. The transaction was validated and recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PurchaseValidationResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/purchases/play-store/validate": {
      "post": {
        "operationId": "validateGooglePurchase",
        "tags": [
          "Purchases"
        ],
        "summary": "Validate Google Play purchase",
        "description": "Validate a Google Play transaction with Botsi after a successful purchase, so the model learns from the conversion.\n\nPass `paywallId`, `isExperiment`, and `aiPricingModelId` exactly as received from Get Paywall. Modifying these values corrupts experiment attribution. This endpoint does not accept `paywallSessionId`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ValidateGooglePurchaseRequest"
              },
              "example": {
                "customerUserId": "user-123",
                "productId": "premium_monthly",
                "purchaseToken": "opaque-token-from-google-play",
                "paywallId": 42,
                "isExperiment": true,
                "aiPricingModelId": 32,
                "price": 9.99,
                "currency": "USD"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. The transaction was validated and recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PurchaseValidationResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/UnprocessableEntity"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "SecretKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "Your Botsi secret key, sent as the raw value of the `Authorization` header. This is **not** a Bearer token: the reference documents the header as `Authorization: {{secret_key}}`, with no scheme prefix. Get the key from the Botsi dashboard under App Settings → API Keys. Always use HTTPS."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Bad Request — missing required parameter.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Unauthorized — invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not Found — resource doesn't exist.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "UnprocessableEntity": {
        "description": "Unprocessable Entity — semantic error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate Limited — slow down. The reference does not publish limits, quotas, or retry headers for this API.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "ServerError": {
        "description": "Server Error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "title": "Error",
        "description": "The error envelope returned for every non-success status code, documented as `{ \"error\": \"message\", \"code\": \"MACHINE_CODE\" }`.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          },
          "code": {
            "type": "string",
            "description": "Machine-readable error code."
          }
        },
        "required": [
          "error",
          "code"
        ]
      },
      "Platform": {
        "type": "string",
        "description": "The platform the profile belongs to.",
        "enum": [
          "android",
          "ios",
          "ipados",
          "tvos",
          "macos",
          "watchos",
          "visionos",
          "stripe"
        ]
      },
      "Profile": {
        "type": "object",
        "title": "Profile",
        "description": "A Botsi profile, as returned by Create Profile. Modelled on the documented example response; the reference publishes no separate field-by-field table for the response, so fields not present in that example are not described here.",
        "properties": {
          "profileId": {
            "type": "string",
            "description": "Botsi's identifier for this profile. Store it — it is required for all subsequent API calls."
          },
          "customerUserId": {
            "type": "string",
            "description": "Your internal user ID, echoed back when it was supplied."
          },
          "paid": {
            "type": "boolean",
            "description": "Whether this profile is currently a paying user."
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 country code, e.g. \"US\"."
          },
          "locale": {
            "type": "string",
            "description": "Locale code, e.g. \"en_US\"."
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217 currency code, e.g. \"USD\"."
          },
          "device": {
            "type": "string",
            "description": "Device model, e.g. \"iPhone 15 Pro Max\"."
          },
          "os": {
            "type": "string",
            "description": "OS and version, e.g. \"iOS 17.0\"."
          },
          "platform": {
            "$ref": "#/components/schemas/Platform"
          }
        }
      },
      "ProfileResponse": {
        "type": "object",
        "title": "Profile response",
        "description": "Envelope returned by Create Profile.",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True when the request succeeded."
          },
          "data": {
            "$ref": "#/components/schemas/Profile"
          }
        },
        "required": [
          "ok",
          "data"
        ]
      },
      "CreateProfileRequest": {
        "type": "object",
        "title": "Create Profile request",
        "description": "Device and context data for the new profile. Botsi does NOT accept demographic or personally identifiable data — do not send names, emails, ages, or similar. On web flows, device-level data may not be available, so rely more heavily on custom attributes from onboarding.",
        "properties": {
          "country": {
            "type": "string",
            "description": "Required. ISO 3166-1 alpha-2 code, e.g. \"US\"."
          },
          "device": {
            "type": "string",
            "description": "Required. Device model, e.g. \"iPhone 15 Pro Max\"."
          },
          "os": {
            "type": "string",
            "description": "Required. OS and version, e.g. \"iOS 17.0\"."
          },
          "platform": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Platform"
              }
            ],
            "description": "Required. One of: android, ios, ipados, tvos, macos, watchos, visionos, stripe."
          },
          "customerUserId": {
            "type": "string",
            "description": "Recommended (not required by the reference). Your internal user ID. Strongly recommended for cross-device tracking, and should be used consistently across every endpoint."
          },
          "locale": {
            "type": "string",
            "description": "Optional. Locale code, e.g. \"en_US\"."
          },
          "currency": {
            "type": "string",
            "description": "Optional. ISO 4217 code, e.g. \"USD\"."
          },
          "storeCountry": {
            "type": "string",
            "description": "Optional. App Store country code."
          },
          "timezone": {
            "type": "string",
            "description": "Optional. IANA timezone, e.g. \"America/New_York\"."
          },
          "ip": {
            "type": "string",
            "description": "Optional. User IP address."
          },
          "appVersion": {
            "type": "string",
            "description": "Optional. App version, e.g. \"1.0.0\"."
          },
          "appBuild": {
            "type": "string",
            "description": "Optional. Build number. Typed as a string because the reference lists it as a string."
          },
          "osVersion": {
            "type": "string",
            "description": "Optional. OS version string."
          },
          "advertisingId": {
            "type": "string",
            "description": "Optional. Advertising identifier."
          },
          "sessionId": {
            "type": "string",
            "description": "Optional. Session identifier."
          }
        },
        "required": [
          "country",
          "device",
          "os",
          "platform"
        ]
      },
      "CustomAttribute": {
        "type": "object",
        "title": "Custom attribute",
        "description": "One key/value pair of context about the user, such as an onboarding answer.",
        "properties": {
          "key": {
            "type": "string",
            "description": "Required. Attribute name, e.g. \"fitness_goal\"."
          },
          "value": {
            "type": "string",
            "description": "Required. Attribute value, e.g. \"weight_loss\"."
          }
        },
        "required": [
          "key",
          "value"
        ]
      },
      "CustomAttributeUpdate": {
        "type": "object",
        "title": "Custom attribute update",
        "description": "A custom attribute being updated. Carries the `attrId` returned when the attribute was originally added.",
        "properties": {
          "key": {
            "type": "string",
            "description": "Required. Attribute name, e.g. \"fitness_goal\"."
          },
          "value": {
            "type": "string",
            "description": "Required. New attribute value, e.g. \"muscle_gain\"."
          },
          "attrId": {
            "type": "string",
            "description": "Required for an update. The identifier returned in the response to the original add call."
          }
        },
        "required": [
          "key",
          "value",
          "attrId"
        ]
      },
      "ProfileIdentifier": {
        "type": "object",
        "title": "Profile identifier fields",
        "description": "The two fields every profile-scoped endpoint uses to identify the user. The reference marks `profileId` as optional \"(or use customerUserId)\" and `customerUserId` as recommended, so neither is encoded as strictly required; in practice one of the two must identify the profile.",
        "properties": {
          "profileId": {
            "type": "string",
            "description": "Optional. Botsi profile ID from Create Profile (or use customerUserId)."
          },
          "customerUserId": {
            "type": "string",
            "description": "Recommended. Your internal user ID."
          }
        }
      },
      "AddCustomAttributeRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "type": "object",
            "properties": {
              "custom": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/CustomAttribute"
                  }
                ],
                "description": "Required. The single attribute to add."
              }
            },
            "required": [
              "custom"
            ]
          }
        ],
        "title": "Add custom attribute request"
      },
      "AddCustomAttributesAllRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "type": "object",
            "properties": {
              "custom": {
                "type": "array",
                "description": "Required. The attributes to add. The reference documents no maximum length for this array.",
                "items": {
                  "$ref": "#/components/schemas/CustomAttribute"
                }
              }
            },
            "required": [
              "custom"
            ]
          }
        ],
        "title": "Add multiple custom attributes request"
      },
      "UpdateCustomAttributeRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "type": "object",
            "properties": {
              "custom": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/CustomAttributeUpdate"
                  }
                ],
                "description": "Required. The attribute to update, including its `attrId`."
              }
            },
            "required": [
              "custom"
            ]
          }
        ],
        "title": "Update custom attribute request"
      },
      "CustomAttributeResponse": {
        "type": "object",
        "title": "Custom attribute response",
        "description": "The reference publishes no example response body for the custom-attribute endpoints. What it does state is that the response to an add call carries an `attrId`, which is required later by `PUT /profiles/custom-attributes`. Because the location of that field within the body is not documented, this schema is intentionally left open rather than guessed. Verify the exact shape against a live response before depending on it.",
        "additionalProperties": true
      },
      "PaywallSourceProduct": {
        "type": "object",
        "title": "Source product",
        "description": "A store product configured for the selected paywall variant. These are identifiers, NOT prices. Each product's price is the one you set in App Store Connect or Google Play Console.",
        "properties": {
          "productId": {
            "type": "string",
            "description": "Store product identifier, e.g. \"premium_monthly\"."
          },
          "basePlanId": {
            "type": "string",
            "description": "Base plan identifier, e.g. \"monthly-base\"."
          },
          "offerId": {
            "type": "string",
            "description": "Offer identifier, e.g. \"intro-offer-7d\"."
          }
        }
      },
      "Paywall": {
        "type": "object",
        "title": "Paywall",
        "description": "The paywall variant Botsi selected for this user and placement. Carries identifiers only — there is no price field, and your app is not expected to read prices from it.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric paywall ID. Used as `paywallId` on events and purchase validation when you are not sending `paywallSessionId`."
          },
          "externalId": {
            "type": "string",
            "description": "The paywall variant to display. Map it to your native paywall screens, or store it as the variant selector for RevenueCat, Superwall, or Adapty. On web, route to the matching pricing page."
          },
          "name": {
            "type": "string",
            "description": "Human-readable paywall name from the Botsi dashboard."
          },
          "isExperiment": {
            "type": "boolean",
            "description": "True only when the AI Model target served this request. Not set (so false downstream) for the Baseline and Placement targets. Pass it to the purchase validation calls exactly as received."
          },
          "aiPricingModelId": {
            "type": "integer",
            "description": "The AI Pricing Model behind this placement. Set before Traffic Control runs, so it is identical for all three target kinds. Pass it to the purchase validation calls exactly as received."
          },
          "paywallSessionId": {
            "type": "string",
            "description": "Attribution token. Echo it on the `paywall_shown` event and Botsi supplies `paywallId`, `placementId`, `abTestId`, `aiPricingModelId` and `isExperiment` itself. Present on every answer kind (A/B, AI Pricing, and plain), so there is nothing to branch on. Treat it as opaque: store and return it byte for byte, never parse, decode, truncate, or construct it. Around 200 characters today with no fixed maximum, so store it as TEXT or VARCHAR(512)."
          },
          "sourceProducts": {
            "type": "array",
            "description": "Store product identifiers configured for this variant. Identifiers, not prices.",
            "items": {
              "$ref": "#/components/schemas/PaywallSourceProduct"
            }
          }
        }
      },
      "PaywallResponse": {
        "type": "object",
        "title": "Get Paywall response",
        "description": "Envelope returned by Get Paywall.",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True when the request succeeded."
          },
          "data": {
            "$ref": "#/components/schemas/Paywall"
          }
        },
        "required": [
          "ok",
          "data"
        ]
      },
      "GetPaywallRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "type": "object",
            "properties": {
              "placementId": {
                "type": "string",
                "description": "Required. Placement ID from the Botsi dashboard, e.g. \"onboarding_paywall\"."
              },
              "locale": {
                "type": "string",
                "description": "Optional. Override locale."
              },
              "country": {
                "type": "string",
                "description": "Optional. Override country."
              },
              "ip": {
                "type": "string",
                "description": "Optional. Override IP."
              }
            },
            "required": [
              "placementId"
            ]
          }
        ],
        "title": "Get Paywall request"
      },
      "ProfileEvent": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "type": "object",
            "properties": {
              "eventType": {
                "type": "string",
                "description": "Required. Event type, e.g. \"paywall_shown\". The reference documents `paywall_shown` by name and refers to \"every other event type\" without enumerating them, so this is left as a free-form string rather than an enum."
              },
              "paywallSessionId": {
                "type": "string",
                "description": "Recommended. `data.paywallSessionId` from Get Paywall. Supplies `paywallId`, `placementId`, `abTestId`, `aiPricingModelId` and `isExperiment`, which is why those fields become optional when it is present. Echo it byte for byte."
              },
              "paywallId": {
                "type": "integer",
                "description": "Required unless `paywallSessionId` is sent. `data.id` from the Get Paywall response."
              },
              "placementId": {
                "type": "string",
                "description": "Required unless `paywallSessionId` is sent. The same placement ID used in Get Paywall."
              },
              "isExperiment": {
                "type": "boolean",
                "description": "Required unless `paywallSessionId` is sent. `data.isExperiment` from the Get Paywall response, passed through unmodified."
              },
              "aiPricingModelId": {
                "type": "integer",
                "description": "Required unless `paywallSessionId` is sent. `data.aiPricingModelId` from the Get Paywall response, passed through unmodified."
              }
            },
            "required": [
              "eventType"
            ]
          }
        ],
        "title": "Profile event",
        "description": "One event object. `eventType` is the only unconditionally required field: `paywallId`, `placementId`, `isExperiment`, and `aiPricingModelId` are documented as \"Required unless paywallSessionId\", a conditional that is described here rather than encoded as a JSON Schema constraint."
      },
      "SendProfileEventsRequest": {
        "type": "array",
        "title": "Send Profile Event request",
        "description": "An array of event objects. Even a single impression is sent as a one-element array.",
        "items": {
          "$ref": "#/components/schemas/ProfileEvent"
        }
      },
      "EventsResponse": {
        "type": "object",
        "title": "Send Profile Event response",
        "description": "The reference publishes no example response body for the events endpoint, only the status codes and the documented 400 messages. This schema is therefore left open rather than guessed.",
        "additionalProperties": true
      },
      "PurchaseAttribution": {
        "type": "object",
        "title": "Purchase attribution fields",
        "description": "Attribution fields shared by both purchase validation endpoints. Pass them exactly as received from Get Paywall — modifying these values corrupts experiment attribution. These endpoints do not accept `paywallSessionId`.",
        "properties": {
          "paywallId": {
            "type": "integer",
            "description": "Recommended. `data.id` from Get Paywall."
          },
          "isExperiment": {
            "type": "boolean",
            "description": "Recommended. `data.isExperiment` from Get Paywall."
          },
          "aiPricingModelId": {
            "type": "integer",
            "description": "Recommended. `data.aiPricingModelId` from Get Paywall."
          },
          "price": {
            "type": "number",
            "description": "Optional. Purchase price. Reported to Botsi for the record; Botsi does not compute or alter prices."
          },
          "currency": {
            "type": "string",
            "description": "Optional. Currency code."
          }
        }
      },
      "ValidateApplePurchaseRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "$ref": "#/components/schemas/PurchaseAttribution"
          },
          {
            "type": "object",
            "properties": {
              "productId": {
                "type": "string",
                "description": "Required. Apple product identifier."
              },
              "transactionId": {
                "type": "string",
                "description": "Required. StoreKit transaction ID."
              },
              "originalTransactionId": {
                "type": "string",
                "description": "Required. Original transaction ID."
              },
              "environment": {
                "type": "string",
                "description": "Optional. \"production\" or \"sandbox\".",
                "enum": [
                  "production",
                  "sandbox"
                ]
              }
            },
            "required": [
              "productId",
              "transactionId",
              "originalTransactionId"
            ]
          }
        ],
        "title": "Validate Apple purchase request"
      },
      "SubscriptionOfferDetails": {
        "type": "object",
        "title": "Google Play subscription offer details",
        "description": "Offer details for a Google Play subscription. The reference names the fields (basePlanId, offerId, offerToken, pricingPhases) but does not document their types or the shape of `pricingPhases`, so this object is left open and `pricingPhases` is left untyped.",
        "properties": {
          "basePlanId": {
            "type": "string",
            "description": "Base plan identifier."
          },
          "offerId": {
            "type": "string",
            "description": "Offer identifier."
          },
          "offerToken": {
            "type": "string",
            "description": "Offer token from Google Play."
          },
          "pricingPhases": {
            "description": "Pricing phases for the offer. The reference does not document the type or shape of this field, so it is left unconstrained."
          }
        },
        "additionalProperties": true
      },
      "ValidateGooglePurchaseRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ProfileIdentifier"
          },
          {
            "$ref": "#/components/schemas/PurchaseAttribution"
          },
          {
            "type": "object",
            "properties": {
              "productId": {
                "type": "string",
                "description": "Required. Google Play product ID."
              },
              "purchaseToken": {
                "type": "string",
                "description": "Required. Purchase token from Google Play."
              },
              "subscriptionOfferDetails": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/SubscriptionOfferDetails"
                  }
                ],
                "description": "Optional. For subscriptions."
              }
            },
            "required": [
              "productId",
              "purchaseToken"
            ]
          }
        ],
        "title": "Validate Google Play purchase request"
      },
      "PurchaseValidationResponse": {
        "type": "object",
        "title": "Purchase validation response",
        "description": "The reference publishes no example response body for either purchase validation endpoint, only the request fields and the API-wide status codes. This schema is therefore left open rather than guessed.",
        "additionalProperties": true
      }
    }
  }
}
